import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { type Frame, isModule, isRecipe, type JSONSchema, type Module, type Recipe, } from "../src/builder/types.ts"; import { lift } from "../src/builder/module.ts"; import { popFrame, pushFrame, recipe } from "../src/builder/recipe.ts"; import { opaqueRef } from "../src/builder/opaque-ref.ts"; import { Runtime } from "../src/runtime.ts"; import { StorageManager } from "../src/storage/cache.deno.ts"; import { Identity } from "@commontools/identity"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); describe("recipe", () => { let runtime: Runtime; let storageManager: ReturnType; let frame: Frame; beforeEach(() => { storageManager = StorageManager.emulate({ as: signer }); runtime = new Runtime({ apiUrl: new URL(import.meta.url), storageManager, }); frame = pushFrame({ space, generatedIdCounter: 0, opaqueRefs: new Set(), runtime, }); }); afterEach(async () => { popFrame(frame); await runtime?.dispose(); }); it("creates a recipe", () => { const doubleRecipe = recipe<{ x: number }>("Double a number", ({ x }) => { const double = lift(({ x }) => x * 2); return { double: double({ x }) }; }); expect(isRecipe(doubleRecipe)).toBe(true); }); it("creates a recipe, with simple function", () => { const doubleRecipe = recipe<{ x: number }>("Double a number", ({ x }) => { const double = lift((x) => x * 2); return { double: double(x) }; }); expect(isRecipe(doubleRecipe)).toBe(true); }); it("creates a recipe, with an inner opaque ref", () => { const double = lift(({ x }) => x * 2); const doubleRecipe = recipe<{ x: number }>("Double a number", () => { const x = opaqueRef(1); x.for("x"); return { double: double({ x }) }; }); expect(isRecipe(doubleRecipe)).toBe(true); expect(doubleRecipe.nodes.length).toBe(1); expect(doubleRecipe.nodes[0].inputs).toMatchObject({ x: { $alias: { path: ["internal", "x"] } }, }); }); it("complex recipe has correct schema and nodes", () => { const doubleRecipe = recipe<{ x: number }>("Double a number", ({ x }) => { const double = lift((x) => x * 2); return { double: double(double(x)) }; }); const { argumentSchema, result, nodes } = doubleRecipe; expect(isRecipe(doubleRecipe)).toBe(true); expect(argumentSchema).toMatchObject({ description: "Double a number", }); expect(result).toEqual({ double: { $alias: { path: ["internal", "double"] } }, }); expect(nodes.length).toBe(2); expect(isModule(nodes[0].module) && nodes[0].module.type).toBe( "javascript", ); expect(nodes[0].inputs).toEqual({ $alias: { path: ["argument", "x"] } }); expect(nodes[0].outputs).toEqual({ $alias: { path: ["internal", "__#0"] }, }); expect(nodes[1].inputs).toEqual({ $alias: { path: ["internal", "__#0"] }, }); expect(nodes[1].outputs).toEqual({ $alias: { path: ["internal", "double"] }, }); }); it("supports JSON Schema with descriptions", () => { const schema = { type: "object", properties: { x: { type: "number" }, }, description: "A number", } as const satisfies JSONSchema; const testRecipe = recipe<{ x: number }>(schema, ({ x }) => ({ x })); expect(isRecipe(testRecipe)).toBe(true); expect(testRecipe.argumentSchema).toMatchObject({ description: "A number", type: "object", properties: { x: { type: "number" }, }, }); }); it("works with JSON Schema in lifted functions", () => { const inputSchema = { type: "number", description: "A number", } as const satisfies JSONSchema; const outputSchema = { type: "number", description: "Doubled", } as const satisfies JSONSchema; const double = lift( inputSchema, outputSchema, (x: number) => x * 2, ); const recipeInputSchema = { type: "object", properties: { x: { type: "number" }, }, } as const satisfies JSONSchema; const testRecipe = recipe<{ x: number }>(recipeInputSchema, ({ x }) => ({ doubled: double(x), })); const module = testRecipe.nodes[0].module as Module; expect(module.argumentSchema).toMatchObject({ description: "A number", type: "number", }); expect(module.resultSchema).toMatchObject({ description: "Doubled", type: "number", }); }); it("complex recipe with path aliases has correct schema, nodes, and serialization", () => { const doubleRecipe = recipe<{ x: number }>("Double a number", ({ x }) => { const double = lift<{ x: number }>(({ x }) => ({ doubled: x * 2 })); const result = double({ x }); const result2 = double({ x: result.doubled }); return { double: result2.doubled }; }); const { argumentSchema, result, nodes } = doubleRecipe; expect(isRecipe(doubleRecipe)).toBe(true); expect(argumentSchema).toMatchObject({ description: "Double a number", }); expect(result).toEqual({ double: { $alias: { path: ["internal", "__#1", "doubled"] } }, }); expect(nodes.length).toBe(2); expect(isModule(nodes[0].module) && nodes[0].module.type).toBe( "javascript", ); expect(nodes[0].inputs).toEqual({ x: { $alias: { path: ["argument", "x"] } }, }); expect(nodes[0].outputs).toEqual({ $alias: { path: ["internal", "__#0"] }, }); expect(nodes[1].inputs).toEqual({ x: { $alias: { path: ["internal", "__#0", "doubled"] } }, }); expect(nodes[1].outputs).toEqual({ $alias: { path: ["internal", "__#1"] }, }); const json = JSON.stringify(doubleRecipe); const parsed = JSON.parse(json); expect(json.length).toBeGreaterThan(200); expect(parsed.nodes[0].module.implementation).toContain("=>"); }); it("recipe with map node serializes correctly", () => { const doubleArray = recipe<{ values: { x: number }[] }>( "Double numbers", ({ values }) => { const doubled = values.map(({ x }) => { const double = lift((x) => x * 2); return { doubled: double(x) }; }); return { doubled }; }, ); expect(doubleArray.nodes.length).toBe(1); const module = doubleArray.nodes[0].module as Module; expect(module.type).toBe("ref"); expect(module.implementation).toBe("map"); const node = doubleArray.nodes[0]; expect(node.inputs).toMatchObject({ list: { $alias: { path: ["argument", "values"] } }, }); const inputs = doubleArray.nodes[0].inputs as unknown as { op: Recipe }; expect(isRecipe(inputs.op)).toBe(true); const innerModule = inputs.op.nodes[0].module as Module; expect(innerModule.type).toBe("javascript"); expect(typeof innerModule.implementation).toBe("function"); }); it("recipe with ifc property has correct classification tracking", () => { const ArgumentSchema = { description: "Double a number", type: "object", properties: { x: { type: "integer", default: 1, ifc: { classification: ["confidential"] }, }, }, required: ["x"], } as const satisfies JSONSchema; const ResultSchema = { description: "Doubled number", type: "object", properties: { double: { type: "integer", default: 1, }, }, required: ["double"], } as const satisfies JSONSchema; const double = lift( ArgumentSchema, ResultSchema, ({ x }) => ({ double: x * 2, }), ); const doubleRecipe = recipe<{ x: number }, { double: number }>( ArgumentSchema, ResultSchema, ({ x }) => { const result = double({ x }); const result2 = double({ x: result.double }); return { double: result2.double }; }, ); const { result, nodes, argumentSchema } = doubleRecipe; expect(isRecipe(doubleRecipe)).toBe(true); expect(argumentSchema).toMatchObject(ArgumentSchema); // It would be nice if we also had the {"type": "integer"} for the schema // The lifted function knows this in the result schema expect(result).toMatchObject({ double: { $alias: { path: ["internal", "__#1", "double"], schema: { ifc: ArgumentSchema.properties.x.ifc, }, }, }, }); expect(nodes.length).toBe(2); expect(isModule(nodes[0].module) && nodes[0].module.type).toBe( "javascript", ); expect(nodes[0].inputs).toMatchObject({ x: { $alias: { path: ["argument", "x"], rootSchema: ArgumentSchema, schema: ArgumentSchema.properties?.x, }, }, }); // I don't like that we don't know the other properties of our output here expect(nodes[0].outputs).toMatchObject({ $alias: { path: ["internal", "__#0"], schema: { ifc: ArgumentSchema.properties.x.ifc }, }, }); expect(nodes[1].inputs).toMatchObject({ x: { $alias: { path: ["internal", "__#0", "double"], schema: { ifc: ArgumentSchema.properties.x.ifc, }, }, }, }); expect(nodes[1].outputs).toMatchObject({ $alias: { path: ["internal", "__#1"], schema: { ifc: ArgumentSchema.properties.x.ifc, }, }, }); }); it("recipe with mixed ifc properties has correct classification in the schema of the ssn result", () => { const ArgumentSchema = { description: "Capitalize a word", type: "object", properties: { word: { type: "string", default: "hello", }, }, required: ["word"], } as const satisfies JSONSchema; const ResultSchema = { description: "Capitalized word", type: "object", properties: { capitalized: { type: "string", default: "Hello", }, }, required: ["capitalized"], } as const satisfies JSONSchema; const capitalize = lift( ArgumentSchema, ResultSchema, ({ word }) => ({ capitalized: word.charAt(0).toUpperCase() + word.slice(1), }), ); const UserSchema = { description: "Capitalize a word", type: "object", properties: { name: { type: "string", default: "hello", }, ssn: { type: "string", default: "123-45-6789", ifc: { classification: ["confidential"] }, }, }, required: ["word"], } as const satisfies JSONSchema; const capitalizeSsnRecipe = recipe< { ssn: string }, { capitalized: string } >( UserSchema, ResultSchema, ({ ssn }) => { const result = capitalize({ ssn }); return { capitalized: result.capitalized }; }, ); const { result, nodes, argumentSchema, resultSchema } = capitalizeSsnRecipe; expect(isRecipe(capitalizeSsnRecipe)).toBe(true); expect(argumentSchema).toMatchObject(UserSchema); expect(nodes).toHaveLength(1); expect(nodes[0].outputs).toHaveProperty("$alias"); const nodeOutputAlias = (nodes[0].outputs as any)["$alias"]; expect(nodeOutputAlias).toMatchObject({ path: ["internal", "__#0"], schema: { ifc: { classification: ["confidential"] } }, }); expect(result).toMatchObject({ capitalized: { $alias: { path: ["internal", "__#0", "capitalized"], schema: { ifc: { classification: ["confidential"] } }, }, }, }); expect(resultSchema).toMatchObject({ ...ResultSchema, ...{ ifc: { classification: ["confidential"] } }, }); // Perhaps I should handle a similar recipe that only accesses the name // in such a way that it does not end up classified. For now, I've decided // not to do this, since I'm not confident enough that code can't get out. }); it("creates a recipe with function-only syntax (no schema)", () => { const doubleRecipe = recipe((input: { x: any }) => { const double = lift((x) => x * 2); return { double: double(input.x) }; }); expect(isRecipe(doubleRecipe)).toBe(true); }); it("creates nodes correctly with function-only syntax", () => { const doubleRecipe = recipe((input: { x: any }) => { const double = lift((x) => x * 2); return { double: double(double(input.x)) }; }); const { nodes } = doubleRecipe; expect(nodes.length).toBe(2); expect(isModule(nodes[0].module) && nodes[0].module.type).toBe( "javascript", ); }); it("supports never schemas", () => { const neverRecipe = recipe(false, false, () => { }); expect(isRecipe(neverRecipe)).toBe(true); }); });