From 2056c1e35236a7eb8fe8ed2afba753d51a5ef0b8 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 22:57:27 -0700 Subject: [PATCH] Support captured CRDT changes in compound transaction contracts --- src/capability-language/generic-types.ts | 12 ++--- src/capability-model/validation.ts | 10 ++++ src/execution-world/plan.ts | 8 +-- test/compound-crdt-fixture.ts | 23 ++++++++ test/compound-crdt.test.ts | 62 ++++++++++++++++++++++ test/crdt-edit-contracts.test.ts | 12 ++--- test/emit-portable-reader.ts | 6 +++ test/fixtures/compound-crdt/EditRuntime.qx | 14 +++++ test/fixtures/compound-crdt/NoteData.qx | 18 +++++++ test/fixtures/compound-crdt/workspace.qx | 22 ++++++++ 10 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 test/compound-crdt-fixture.ts create mode 100644 test/compound-crdt.test.ts create mode 100644 test/fixtures/compound-crdt/EditRuntime.qx create mode 100644 test/fixtures/compound-crdt/NoteData.qx create mode 100644 test/fixtures/compound-crdt/workspace.qx diff --git a/src/capability-language/generic-types.ts b/src/capability-language/generic-types.ts index 98fa817..94a8420 100644 --- a/src/capability-language/generic-types.ts +++ b/src/capability-language/generic-types.ts @@ -319,12 +319,12 @@ export class GenericSourceTypes { return { kind: "record", fields: Object.fromEntries(fields) }; } const name = context.identifier()!.getText(); - if (name === "change" && context.typeArguments()) - throw new GenericTypeError( - "unsupported-prepared-change", - name, - "Authored change transport values require LF-19; native edit contracts are available separately", - ); + if (name === "change" && context.typeArguments()) { + const args = this.arguments(context.typeArguments()); + if (args.length !== 1 || args[0]!.kind !== "value") + throw new GenericTypeError("invalid-change-type", name, "change requires one materialized value type"); + return { kind: "change", value: args[0]!.type }; + } if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) }; if (context.INTERFACE_REF()) return { diff --git a/src/capability-model/validation.ts b/src/capability-model/validation.ts index 88a3e05..3e0daf5 100644 --- a/src/capability-model/validation.ts +++ b/src/capability-model/validation.ts @@ -1,3 +1,4 @@ +import { isStorableType } from "./generics.js"; import { validateInitialization } from "./initialization.js"; import { validateExecutionContracts } from "./execution.js"; import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js"; @@ -244,6 +245,15 @@ const validateValueType = ( requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity"); return; case "change": + if (!isStorableType(type.value)) + issue( + issues, + "invalid-value-type", + path, + "change requires a storable materialized type, not references or nested changes", + ); + validateValueType(issues, type.value, path + ".value", indexes); + return; case "optional": case "list": validateValueType(issues, type.value, `${path}.value`, indexes); diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index 9d62e22..cc0a96b 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -144,8 +144,8 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { for (const pkg of workspace.packageImports) for (const entry of pkg.exports) if (entry.execution && entry.execution.kind !== "remote") { - checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`); - checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`); + checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`, true); + checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`, true); } for (const attachment of attachments) if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document") @@ -208,8 +208,8 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { operationMap.set(key, planned); const portable = planned.execution.kind !== "remote"; if (portable) { - checkValue(planned.input, `${key}/input`, binding.kind === "state" && binding.primitive === "edit"); - checkValue(planned.output, `${key}/output`); + checkValue(planned.input, `${key}/input`, true); + checkValue(planned.output, `${key}/output`, true); } if (binding.kind === "state") planned.dependencies.push({ kind: "state", slot: binding.slotId }); else if (binding.kind === "edge") diff --git a/test/compound-crdt-fixture.ts b/test/compound-crdt-fixture.ts new file mode 100644 index 0000000..2f22a29 --- /dev/null +++ b/test/compound-crdt-fixture.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js"; +import { compileExecutionPlan } from "../src/execution-world/plan.js"; +import type { InterfaceRevision } from "../src/capability-model/types.js"; +export async function compoundCrdtPlan() { + const read = (name: string) => readFile(`test/fixtures/compound-crdt/${name}.qx`, "utf8"); + const source = { repository: "https://example.test/compound.git", commit: "a".repeat(40) }; + const interfaces = new Map(); + for (const name of ["NoteData"]) { + const result = compileCapabilityResourceSource(await read(name), { source, environment: { interfaces } }); + assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics)); + interfaces.set(name, result.resource.revision); + } + const pkg = compileCapabilityResourceSource(await read("EditRuntime"), { source, environment: { interfaces } }); + assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const result = compileCapabilitySource(await read("workspace"), "workspace.qx", { + interfaces, + packages: new Map([["EditRuntime", pkg.resource.revision]]), + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + return compileExecutionPlan(result.workspace); +} diff --git a/test/compound-crdt.test.ts b/test/compound-crdt.test.ts new file mode 100644 index 0000000..30855cb --- /dev/null +++ b/test/compound-crdt.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { compoundCrdtPlan } from "./compound-crdt-fixture.js"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js"; +import { compileKernelProgram } from "../src/execution-world/index.js"; +import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js"; +const source = { repository: "https://example.test/change.git", commit: "a".repeat(40) }; +test("compound captured changes survive planning, transport lowering and typed edit ports", async () => { + const plan = await compoundCrdtPlan(); + const kernel = compileKernelProgram(plan) as { + operations: { operation: string; input: { fields: { change: { kind: string } } } }[]; + }; + const operation = kernel.operations.find((op) => op.operation === "compound:update:call")!; + assert.equal(operation.input.fields.change.kind, "record"); + const code = generatePortableTypeScriptBindings(plan.definition.bindingSchema, "compound:runtime@1", { + target: "browser", + }); + assert.ok(code.includes("PreparedChange<")); + assert.ok(code.includes('"edit"')); + assert.ok(!code.includes('"compound:runtime:check"')); +}); +test("prepared changes require one storable materialized type", () => { + for (const shape of ["change", "change", "change>", "change>"]) { + const result = compileCapabilityResourceSource( + `external atom N id "n"; package P id "p" revision "p@1" { + function f id "f" : ${shape} -> unit execution transaction targets [browser, server]; + }`, + { source }, + ); + assert.equal(result.ok, false, shape); + } +}); +test("captured changes cannot be persisted in a register or CRDT", async () => { + const iface = compileCapabilityResourceSource(await readFile("test/fixtures/compound-crdt/NoteData.qx", "utf8"), { + source, + }); + assert.ok(iface.ok && iface.resource.kind === "interface", JSON.stringify(iface.diagnostics)); + const pkg = compileCapabilityResourceSource(await readFile("test/fixtures/compound-crdt/EditRuntime.qx", "utf8"), { + source, + }); + assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const workspace = await readFile("test/fixtures/compound-crdt/workspace.qx", "utf8"); + for (const policy of ["register", "crdt(change)"]) { + const result = compileCapabilitySource( + workspace.replace( + "private state Allowed", + `private state Captured id "captured" on Note : record { captured: change; } policy ${policy};\n private state Allowed`, + ), + "workspace.qx", + { + interfaces: new Map([["NoteData", iface.resource.revision]]), + packages: new Map([["EditRuntime", pkg.resource.revision]]), + }, + ); + assert.equal(result.ok, false); + assert.ok( + result.diagnostics.some((d) => d.code === "invalid-attachment"), + JSON.stringify(result.diagnostics), + ); + } +}); diff --git a/test/crdt-edit-contracts.test.ts b/test/crdt-edit-contracts.test.ts index c97329b..ac12b99 100644 --- a/test/crdt-edit-contracts.test.ts +++ b/test/crdt-edit-contracts.test.ts @@ -53,16 +53,16 @@ test("edit cannot target a register, mismatched profile, or ordinary replacement ); } }); -test("authored prepared change transport values fail explicitly until their runtime is implemented", () => { +test("authored prepared changes retain their materialized type as transport values", () => { const result = compileCapabilityResourceSource( 'package P id "p" revision "p@1" { function applyChange id "apply" : change -> unit; }', { source }, ); - assert.equal(result.ok, false); - assert.ok( - result.diagnostics.some((d) => d.code === "unsupported-prepared-change"), - JSON.stringify(result.diagnostics), - ); + assert.ok(result.ok && result.resource.kind === "package", JSON.stringify(result.diagnostics)); + assert.deepEqual(result.resource.revision.exports[0]!.inputType, { + kind: "change", + value: { kind: "scalar", name: "string" }, + }); }); test("generic edit retains its materialized type through specialization", () => { diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts index dba500f..67af83d 100644 --- a/test/emit-portable-reader.ts +++ b/test/emit-portable-reader.ts @@ -125,3 +125,9 @@ const collections = await collectionsPlan(); await mkdir("dist/portable-reader/collections", { recursive: true }); await writeFile("dist/portable-reader/collections/plan.json", JSON.stringify(collections)); await writeFile("dist/portable-reader/collections/kernel.json", JSON.stringify(compileKernelProgram(collections))); + +const { compoundCrdtPlan } = await import("./compound-crdt-fixture.js"); +const compoundCrdt = await compoundCrdtPlan(); +await mkdir("dist/portable-reader/compound-crdt", { recursive: true }); +await writeFile("dist/portable-reader/compound-crdt/plan.json", JSON.stringify(compoundCrdt)); +await writeFile("dist/portable-reader/compound-crdt/kernel.json", JSON.stringify(compileKernelProgram(compoundCrdt))); diff --git a/test/fixtures/compound-crdt/EditRuntime.qx b/test/fixtures/compound-crdt/EditRuntime.qx new file mode 100644 index 0000000..7921851 --- /dev/null +++ b/test/fixtures/compound-crdt/EditRuntime.qx @@ -0,0 +1,14 @@ +external atom Note id "compound:note"; +type Content = record { text: string; tags: list; }; +type Edit = record { change: change; resolved: bool; }; +package EditRuntime id "compound:runtime" revision "compound:runtime@1" { + operation update id "compound:runtime:update" : Edit -> unit + mode call execution transaction targets [browser, server] receiver atom Note requires { + state content id "compound:edit:content" : Content [edit]; + state resolved id "compound:edit:resolved" : bool [write]; + } accept using check; + operation check id "compound:runtime:check" : Edit -> unit + mode call execution snapshot targets [server] receiver atom Note requires { + state allowed id "compound:check:allowed" : bool [read]; + }; +} diff --git a/test/fixtures/compound-crdt/NoteData.qx b/test/fixtures/compound-crdt/NoteData.qx new file mode 100644 index 0000000..7834319 --- /dev/null +++ b/test/fixtures/compound-crdt/NoteData.qx @@ -0,0 +1,18 @@ +type Content = record { text: string; tags: list; }; +type Edit = record { change: change; resolved: bool; }; +interface NoteData id "compound:data" revision "compound:data@1" { + operation update id "compound:update" : Edit -> unit { + call id "compound:update:call" execution transaction targets [browser, server]; + } + queryable value content id "compound:content" : Content { + get id "compound:content:get" execution snapshot targets [browser, server]; + edit id "compound:content:edit" execution transaction targets [browser, server]; + } + queryable value resolved id "compound:resolved" : bool { + get id "compound:resolved:get" execution snapshot targets [browser, server]; + } + queryable value title id "compound:title" : string { + get id "compound:title:get" execution snapshot targets [browser, server]; + set id "compound:title:set" execution transaction targets [browser, server]; + } +} diff --git a/test/fixtures/compound-crdt/workspace.qx b/test/fixtures/compound-crdt/workspace.qx new file mode 100644 index 0000000..d3eb453 --- /dev/null +++ b/test/fixtures/compound-crdt/workspace.qx @@ -0,0 +1,22 @@ +workspace Compound id "workspace:compound" revision "workspace:compound@1" commit "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + import interface NoteData; + import package EditRuntime; + type Content = record { text: string; tags: list; }; + atom Note id "compound:note"; + conform Note as NoteData id "compound:note:data" { + private state Content id "compound:content-slot" on Note : Content policy crdt(Content); + private state Resolved id "compound:resolved-slot" on Note : bool policy register default false; + private state Title id "compound:title-slot" on Note : string policy register default "note"; + private state Allowed id "compound:allowed-slot" on Note : bool policy register default true; + initialize { content to state Content; }; + bind content.get to state Content.read; + bind content.edit to state Content.edit; + bind resolved.get to state Resolved.read; + bind title.get to state Title.read; + bind title.set to state Title.write; + bind update.call to package EditRuntime.update with { + content to state Content; + resolved to state Resolved; + } accept with { allowed to state Allowed; }; + } +}