Support captured CRDT changes in compound transaction contracts
This commit is contained in:
@@ -319,12 +319,12 @@ export class GenericSourceTypes {
|
|||||||
return { kind: "record", fields: Object.fromEntries(fields) };
|
return { kind: "record", fields: Object.fromEntries(fields) };
|
||||||
}
|
}
|
||||||
const name = context.identifier()!.getText();
|
const name = context.identifier()!.getText();
|
||||||
if (name === "change" && context.typeArguments())
|
if (name === "change" && context.typeArguments()) {
|
||||||
throw new GenericTypeError(
|
const args = this.arguments(context.typeArguments());
|
||||||
"unsupported-prepared-change",
|
if (args.length !== 1 || args[0]!.kind !== "value")
|
||||||
name,
|
throw new GenericTypeError("invalid-change-type", name, "change<T> requires one materialized value type");
|
||||||
"Authored change<T> transport values require LF-19; native edit contracts are available separately",
|
return { kind: "change", value: args[0]!.type };
|
||||||
);
|
}
|
||||||
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
|
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
|
||||||
if (context.INTERFACE_REF())
|
if (context.INTERFACE_REF())
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isStorableType } from "./generics.js";
|
||||||
import { validateInitialization } from "./initialization.js";
|
import { validateInitialization } from "./initialization.js";
|
||||||
import { validateExecutionContracts } from "./execution.js";
|
import { validateExecutionContracts } from "./execution.js";
|
||||||
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
import { resolveForwardedBinding, type ConcreteBinding } from "./forwarding.js";
|
||||||
@@ -244,6 +245,15 @@ const validateValueType = (
|
|||||||
requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity");
|
requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity");
|
||||||
return;
|
return;
|
||||||
case "change":
|
case "change":
|
||||||
|
if (!isStorableType(type.value))
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-value-type",
|
||||||
|
path,
|
||||||
|
"change<T> requires a storable materialized type, not references or nested changes",
|
||||||
|
);
|
||||||
|
validateValueType(issues, type.value, path + ".value", indexes);
|
||||||
|
return;
|
||||||
case "optional":
|
case "optional":
|
||||||
case "list":
|
case "list":
|
||||||
validateValueType(issues, type.value, `${path}.value`, indexes);
|
validateValueType(issues, type.value, `${path}.value`, indexes);
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
|||||||
for (const pkg of workspace.packageImports)
|
for (const pkg of workspace.packageImports)
|
||||||
for (const entry of pkg.exports)
|
for (const entry of pkg.exports)
|
||||||
if (entry.execution && entry.execution.kind !== "remote") {
|
if (entry.execution && entry.execution.kind !== "remote") {
|
||||||
checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`);
|
checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`, true);
|
||||||
checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`);
|
checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`, true);
|
||||||
}
|
}
|
||||||
for (const attachment of attachments)
|
for (const attachment of attachments)
|
||||||
if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document")
|
if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document")
|
||||||
@@ -208,8 +208,8 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
|||||||
operationMap.set(key, planned);
|
operationMap.set(key, planned);
|
||||||
const portable = planned.execution.kind !== "remote";
|
const portable = planned.execution.kind !== "remote";
|
||||||
if (portable) {
|
if (portable) {
|
||||||
checkValue(planned.input, `${key}/input`, binding.kind === "state" && binding.primitive === "edit");
|
checkValue(planned.input, `${key}/input`, true);
|
||||||
checkValue(planned.output, `${key}/output`);
|
checkValue(planned.output, `${key}/output`, true);
|
||||||
}
|
}
|
||||||
if (binding.kind === "state") planned.dependencies.push({ kind: "state", slot: binding.slotId });
|
if (binding.kind === "state") planned.dependencies.push({ kind: "state", slot: binding.slotId });
|
||||||
else if (binding.kind === "edge")
|
else if (binding.kind === "edge")
|
||||||
|
|||||||
@@ -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<string, InterfaceRevision>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, bool>", "change<atom N>", "change<atom-ref<N>>", "change<change<string>>"]) {
|
||||||
|
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<Content>)"]) {
|
||||||
|
const result = compileCapabilitySource(
|
||||||
|
workspace.replace(
|
||||||
|
"private state Allowed",
|
||||||
|
`private state Captured id "captured" on Note : record { captured: change<Content>; } 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(
|
const result = compileCapabilityResourceSource(
|
||||||
'package P id "p" revision "p@1" { function applyChange id "apply" : change<string> -> unit; }',
|
'package P id "p" revision "p@1" { function applyChange id "apply" : change<string> -> unit; }',
|
||||||
{ source },
|
{ source },
|
||||||
);
|
);
|
||||||
assert.equal(result.ok, false);
|
assert.ok(result.ok && result.resource.kind === "package", JSON.stringify(result.diagnostics));
|
||||||
assert.ok(
|
assert.deepEqual(result.resource.revision.exports[0]!.inputType, {
|
||||||
result.diagnostics.some((d) => d.code === "unsupported-prepared-change"),
|
kind: "change",
|
||||||
JSON.stringify(result.diagnostics),
|
value: { kind: "scalar", name: "string" },
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generic edit retains its materialized type through specialization", () => {
|
test("generic edit retains its materialized type through specialization", () => {
|
||||||
|
|||||||
@@ -125,3 +125,9 @@ const collections = await collectionsPlan();
|
|||||||
await mkdir("dist/portable-reader/collections", { recursive: true });
|
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/plan.json", JSON.stringify(collections));
|
||||||
await writeFile("dist/portable-reader/collections/kernel.json", JSON.stringify(compileKernelProgram(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)));
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
external atom Note id "compound:note";
|
||||||
|
type Content = record { text: string; tags: list<string>; };
|
||||||
|
type Edit = record { change: change<Content>; 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];
|
||||||
|
};
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
type Content = record { text: string; tags: list<string>; };
|
||||||
|
type Edit = record { change: change<Content>; 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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<string>; };
|
||||||
|
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; };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user