Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions packages/runner/src/builder/json-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export function toJSONWithLegacyAliases(
ignoreSelfAliases: boolean = false,
path: PropertyKey[] = [],
): JSONValue | undefined {
// Convert regular cells to opaque refs
// Turn strongly typed builder values into legacy JSON structures while
// preserving alias metadata for consumers that still rely on it.

// Convert regular cells and results from Cell.get() to opaque refs
if (canBeOpaqueRef(value)) value = makeOpaqueRef(value);

// Verify that opaque refs are not in a parent frame
Expand All @@ -45,12 +48,13 @@ export function toJSONWithLegacyAliases(
if (external) return external as JSONValue;
}

// Otherwise it's an internal reference. Extract the schema and output a link.
if (isOpaqueRef(value) || isShadowRef(value)) {
const pathToCell = paths.get(value);
if (pathToCell) {
if (ignoreSelfAliases && deepEqual(path, pathToCell)) return undefined;

// Get schema from exported value if available
// Add schema from exported value if available
const exported = isOpaqueRef(value) ? value.export() : undefined;
return {
$alias: {
Expand All @@ -67,8 +71,13 @@ export function toJSONWithLegacyAliases(
} else throw new Error(`Cell not found in paths`);
}

// If we encounter a link, it's from a nested recipe.
if (isLegacyAlias(value)) {
const alias = (value as LegacyAlias).$alias;
// If this was a shadow ref, i.e. a closed over reference, see whether
// we're now at the level that it should be resolved to the actual cell.
// (i.e. we're generating the recipe from which the closed over reference
// was captured)
if (isShadowRef(alias.cell)) {
const cell = alias.cell.shadowOf;
if (cell.export().frame !== getTopFrame()) {
Expand All @@ -81,18 +90,30 @@ export function toJSONWithLegacyAliases(
`Shadow ref alias with parent cell not found in current frame`,
);
}
// If we're not at the top level, just emit it again. This will be
// converted once the higher level recipe is being processed.
return value as JSONValue;
}
if (!paths.has(cell)) throw new Error(`Cell not found in paths`);
// If in top frame, it's an alias to another cell on the process cell. So
// we emit the alias without the cell reference (it will be filled in
// later with the process cell) and concatenate the path.
const { cell: _, ...aliasWithoutCell } = alias;
return {
$alias: {
// Keep any extra metadata (schema, rootSchema, etc.) that might have
// been attached to the legacy alias originally.
...aliasWithoutCell,
path: [...paths.get(cell)!, ...alias.path] as (string | number)[],
},
} satisfies LegacyAlias;
} else if (!("cell" in alias) || typeof alias.cell === "number") {
// If we encounter an existing alias and it isn't an absolute reference
// with a cell id, then increase the nesting level.
return {
$alias: {
cell: ((alias.cell as number) ?? 0) + 1,
...alias, // Preserve existing metadata.
cell: ((alias.cell as number) ?? 0) + 1, // Increase nesting level.
path: alias.path as (string | number)[],
},
} satisfies LegacyAlias;
Expand All @@ -101,20 +122,23 @@ export function toJSONWithLegacyAliases(
}
}

// If this is an array, process each element recursively.
if (Array.isArray(value)) {
return (value as Opaque<any>).map((v: Opaque<any>, i: number) =>
toJSONWithLegacyAliases(v, paths, ignoreSelfAliases, [...path, i])
);
}

// If this is an object or a recipe, process each key recursively.
if (isRecord(value) || isRecipe(value)) {
// If this is a recipe, call its toJSON method to get the properly
// serialized version.
const valueToProcess = (isRecipe(value) &&
typeof (value as unknown as toJSON).toJSON === "function")
? (value as unknown as toJSON).toJSON() as Record<string, any>
: (value as Record<string, any>);

const result: any = {};
let hasValue = false;
for (const key in valueToProcess as any) {
const jsonValue = toJSONWithLegacyAliases(
valueToProcess[key],
Expand All @@ -124,13 +148,13 @@ export function toJSONWithLegacyAliases(
);
if (jsonValue !== undefined) {
result[key] = jsonValue;
hasValue = true;
}
}

// Retain the original recipe reference for downstream processing.
if (isRecipe(value)) result[unsafe_originalRecipe] = value;

return hasValue || Object.keys(result).length === 0 ? result : undefined;
return result;
}

return value;
Expand Down
24 changes: 21 additions & 3 deletions packages/runner/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,14 +585,32 @@ export class Runner implements IRunner {
syncAllMentionedCells(inputs);
await Promise.all(promises);

const sourceCell = resultCell.getSourceCell({
// TODO(@ubik2): Move this to a more general method in schema.ts or cfc.ts
const processCellSchema: any = {
type: "object",
properties: {
[TYPE]: { type: "string" },
argument: recipe.argumentSchema ?? {},
argument: recipe.argumentSchema ?? true,
},
required: [TYPE],
});
};

if (
isRecord(processCellSchema) && "properties" in processCellSchema &&
isObject(recipe.argumentSchema)
) {
// extract $defs and definitions and remove them from argumentSchema
const { $defs, definitions, ...rest } = recipe.argumentSchema;
(processCellSchema as any).properties.argument = rest ?? true;
if (isRecord($defs)) {
(processCellSchema as any).$defs = $defs;
}
if (isRecord(definitions)) {
(processCellSchema as any).definitions = definitions;
}
}

const sourceCell = resultCell.getSourceCell(processCellSchema);
if (!sourceCell) return false;

await sourceCell.sync();
Expand Down
75 changes: 72 additions & 3 deletions packages/runner/test/json-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { createJsonSchema } from "../src/builder/json-utils.ts";
import { Runtime } from "../src/runtime.ts";
import type { JSONSchema } from "../src/builder/types.ts";

import { Identity } from "@commontools/identity";
import { StorageManager } from "@commontools/runner/storage/cache.deno";

import {
createJsonSchema,
toJSONWithLegacyAliases,
} from "../src/builder/json-utils.ts";
import {
isOpaqueRefMarker,
type JSONSchema,
type Opaque,
type OpaqueRef,
type ShadowRef,
} from "../src/builder/types.ts";
import type { LegacyAlias } from "../src/sigil-types.ts";
import { Runtime } from "../src/runtime.ts";

const signer = await Identity.fromPassphrase("test operator");
const space = signer.did();

Expand Down Expand Up @@ -317,3 +329,60 @@ describe("createJsonSchema", () => {
});
});
});

describe("toJSONWithLegacyAliases", () => {
it("preserves metadata when expanding shadow ref aliases", () => {
const cell = {
export: () => ({ frame: undefined }),
[isOpaqueRefMarker]: true,
} as unknown as OpaqueRef<unknown>;
const paths = new Map<OpaqueRef<unknown>, PropertyKey[]>([
[cell, ["root"]],
]);
const schema = { type: "string" as const };
const alias: LegacyAlias = {
$alias: {
cell: { shadowOf: cell } as ShadowRef,
path: ["child"],
schema,
},
};

const result = toJSONWithLegacyAliases(
alias as unknown as Opaque<LegacyAlias>,
paths,
);

expect(result).toEqual({
$alias: {
path: ["root", "child"],
schema,
},
});
});

it("increments numeric alias cells without dropping schemas", () => {
const alias: LegacyAlias = {
$alias: {
cell: 2,
path: ["child"],
schema: { type: "boolean" as const },
rootSchema: { type: "boolean" as const },
},
};

const result = toJSONWithLegacyAliases(
alias as unknown as Opaque<LegacyAlias>,
new Map(),
);

expect(result).toEqual({
$alias: {
cell: 3,
path: ["child"],
schema: { type: "boolean" as const },
rootSchema: { type: "boolean" as const },
},
});
});
});
Loading