From d9bd5b280020a0fdcfa101ae1b5968a090aa5bc7 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 01:49:56 -0700 Subject: [PATCH] Acquire installed portable operations with explicit authority and input coverage --- flake.nix | 4 +- package.json | 1 + src/bindings/index.ts | 1 + src/bindings/portable-client-cli.ts | 13 +++ src/bindings/portable-client.ts | 68 +++++++++++ src/bindings/portable.ts | 2 +- src/execution-world/index.ts | 2 + src/execution-world/projections.ts | 44 +++++++ test/composite-fixture.ts | 57 +++++++++ test/emit-portable-reader.ts | 16 +++ .../local-first-composite/CommentFactory.qx | 7 ++ .../local-first-composite/CommentRuntime.qx | 11 ++ .../local-first-composite/DocumentFactory.qx | 25 ++++ .../local-first-composite/DocumentMetadata.qx | 6 + .../local-first-composite/ReaderRuntime.qx | 84 ++++++++++++++ .../local-first-composite/workspace.qx | 108 ++++++++++++++++++ test/projection-compatibility.test.ts | 29 +++++ yarn.lock | 1 + 18 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 src/bindings/portable-client-cli.ts create mode 100644 src/bindings/portable-client.ts create mode 100644 src/execution-world/projections.ts create mode 100644 test/composite-fixture.ts create mode 100644 test/fixtures/local-first-composite/CommentFactory.qx create mode 100644 test/fixtures/local-first-composite/CommentRuntime.qx create mode 100644 test/fixtures/local-first-composite/DocumentFactory.qx create mode 100644 test/fixtures/local-first-composite/DocumentMetadata.qx create mode 100644 test/fixtures/local-first-composite/ReaderRuntime.qx create mode 100644 test/fixtures/local-first-composite/workspace.qx create mode 100644 test/projection-compatibility.test.ts diff --git a/flake.nix b/flake.nix index 3256fe8..f2fcab4 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,7 @@ --bundle --platform=node --target=node24 --format=esm \ --outfile=quixos-lock-check.mjs esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs + esbuild dist/src/bindings/portable-client-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-client.mjs esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs runHook postBuild @@ -85,7 +86,7 @@ mkdir -p "$out/share/quixos-protocol/qualification" cp -r dist/portable-reader "$out/share/quixos-protocol/qualification/" mkdir -p "$out/bin" - for tool in quixos-codegen-ts quixos-qx; do + for tool in quixos-codegen-ts quixos-codegen-client quixos-qx; do printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool" chmod +x "$out/bin/$tool" done @@ -121,6 +122,7 @@ install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs" install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs" install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/" + install -m644 quixos-codegen-client.mjs "$out/libexec/quixos-protocol/" install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs" diff --git a/package.json b/package.json index 742ebb6..4aa8a44 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "bin": { "quixos-capability-compile": "dist/src/capability-language/cli.js", + "quixos-codegen-client": "dist/src/bindings/portable-client-cli.js", "quixos-codegen-ts": "dist/src/bindings/cli.js", "quixos-descriptor-check": "dist/src/descriptor-check.js", "quixos-lock-check": "dist/src/resource-lock/cli.js", diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 826c8b5..56d0ae1 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -428,3 +428,4 @@ export { PortableBindingError, type PortableBindingOptions, } from "./portable.js"; +export * from "./portable-client.js"; diff --git a/src/bindings/portable-client-cli.ts b/src/bindings/portable-client-cli.ts new file mode 100644 index 0000000..904a7d7 --- /dev/null +++ b/src/bindings/portable-client-cli.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import { generatePortableClientBindings } from "./portable-client.js"; +async function main() { + const [plan, output, target = "browser", ...rest] = process.argv.slice(2); + if (!plan || !output || rest.length) + throw Error("usage: quixos-codegen-client EXECUTION_PLAN.json OUTPUT.ts [TARGET]"); + await writeFile(output, generatePortableClientBindings(JSON.parse(await readFile(plan, "utf8")), { target })); +} +void main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/bindings/portable-client.ts b/src/bindings/portable-client.ts new file mode 100644 index 0000000..b9be48e --- /dev/null +++ b/src/bindings/portable-client.ts @@ -0,0 +1,68 @@ +import { hexDigest, type ExecutionPlan } from "../execution-world/plan.js"; +import { resultInterfaces } from "../execution-world/projections.js"; +import { portableValueSchema, tsType, PortableBindingError } from "./portable.js"; +/** Closed-world app descriptors. Handler bindings and application acquisition share the same public identities. */ +export function generatePortableClientBindings( + plan: ExecutionPlan, + options: { target: string; runtimeModule?: string }, +): string { + if (hexDigest(plan.definition) !== plan.digest) throw new PortableBindingError("client", "Invalid plan digest"); + const workspace = plan.definition.workspace, + atoms = new Map(), + unavailable: { key: string; reason: string }[] = []; + const seen = new Set(); + for (const operation of plan.definition.operations) { + if (operation.execution.kind === "remote" || !operation.execution.targets.includes(options.target)) { + unavailable.push({ key: operation.key, reason: "OPERATION_NOT_PORTABLE" }); + continue; + } + let input: string, output: string; + try { + input = tsType(operation.input); + output = tsType(operation.output); + } catch (error) { + if (!(error instanceof PortableBindingError)) throw error; + unavailable.push({ key: operation.key, reason: "UNSUPPORTED_OPERATION_CODEC" }); + continue; + } + const atom = workspace.atoms.find((a) => a.id === operation.atomId)!; + const iface = workspace.interfaceImports.find((i) => i.revisionId === operation.interfaceRevisionId)!; + const member = iface.members.find((m) => m.operations.some((o) => o.id === operation.operationId))!; + const facet = member.operations.find((o) => o.id === operation.operationId)!; + const name = `${iface.displayName}.${member.displayName}.${facet.displayName}`; + const alias = JSON.stringify([atom.displayName, name]); + if (seen.has(alias)) throw new PortableBindingError(alias, "Ambiguous application descriptor alias"); + seen.add(alias); + const roots = resultInterfaces(plan, operation.key); + const descriptor = { + key: operation.key, + plan: plan.digest, + effect: operation.execution.kind, + input: portableValueSchema(operation.input), + output: portableValueSchema(operation.output), + resultInterfaces: roots, + }; + const list = atoms.get(atom.displayName) ?? []; + list.push( + `${JSON.stringify(name)}: defineOperationDescriptor<${input}, ${output}, ${roots.map((r) => JSON.stringify(r)).join(" | ") || "never"}, ${JSON.stringify(operation.execution.kind)}>(${JSON.stringify(descriptor)})`, + ); + atoms.set(atom.displayName, list); + } + const queries = new Map(); + for (const query of plan.definition.queries) { + const declaration = query.query.checked.declaration; + if (declaration.placement?.kind !== "replica") continue; + const pkg = workspace.packageImports.find((p) => p.revisionId === query.query.packageRevisionId)!; + const alias = JSON.stringify([pkg.displayName, declaration.displayName]); + if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias"); + seen.add(`query:${alias}`); + const list = queries.get(pkg.displayName) ?? []; + list.push( + `${JSON.stringify(declaration.displayName)}: { id: ${JSON.stringify(query.id)}, plan: ${JSON.stringify(plan.digest)}, root: ${JSON.stringify(declaration.root.interface)}, rootKind: ${JSON.stringify(declaration.root.kind)} } as const satisfies ReplicaProjection<${JSON.stringify(declaration.root.interface)}, ${JSON.stringify(declaration.root.kind)}>`, + ); + queries.set(pkg.displayName, list); + } + const groups = (entries: Map) => + [...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n"); + return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, type ManagedReference, type ReplicaProjection } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`; +} diff --git a/src/bindings/portable.ts b/src/bindings/portable.ts index 012f935..be5a90d 100644 --- a/src/bindings/portable.ts +++ b/src/bindings/portable.ts @@ -52,7 +52,7 @@ export function portableValueSchema(type: ValueType): unknown { "Unsupported portable value; use a supported record/reference contract (native edits and generic adapters have separate tasks)", ); } -function tsType(type: ValueType): string { +export function tsType(type: ValueType): string { portableValueSchema(type); switch (type.kind) { case "scalar": diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index c924f5b..726a4ac 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -8,3 +8,5 @@ export * from "./receipts.js"; export { generatePortableTypeScriptBindings } from "../bindings/portable.js"; export * from "./kernel.js"; +export * from "./projections.js"; +export { generatePortableClientBindings } from "../bindings/portable-client.js"; diff --git a/src/execution-world/projections.ts b/src/execution-world/projections.ts new file mode 100644 index 0000000..2eb24af --- /dev/null +++ b/src/execution-world/projections.ts @@ -0,0 +1,44 @@ +import { ExecutionPlanError, hexDigest, type ExecutionPlan, type PlannedQuery } from "./plan.js"; +/** Contracts promised by a direct result. Interface dependencies are explicit; accidental + * overlap among today's implementations does not silently widen an exported interface contract. */ +export function resultInterfaces(plan: ExecutionPlan, operationKey: string): string[] { + const operation = plan.definition.operations.find((operation) => operation.key === operationKey); + if (!operation) throw new ExecutionPlanError("UNKNOWN_OPERATION", operationKey, "No installed public operation"); + if (operation.output.kind !== "object-ref") return []; + const expected = operation.output.expectation; + if (expected.kind === "atom") + return plan.definition.workspace.conformances + .filter((c) => c.atomId === expected.atomId) + .map((c) => c.interfaceRevisionId) + .sort(); + const interfaces = new Map(plan.definition.workspace.interfaceImports.map((i) => [String(i.revisionId), i])); + const result = new Set(); + const visit = (id: string) => { + if (result.has(id)) return; + result.add(id); + for (const dependency of interfaces.get(id)?.requiredInterfaces ?? []) visit(dependency); + }; + visit(expected.interfaceRevisionId); + return [...result].sort(); +} +/** Compile-time observeResult compatibility. Observation does not grant read or mutation authority. */ +export function checkResultProjection(plan: ExecutionPlan, operationKey: string, queryId: string): PlannedQuery { + if (hexDigest(plan.definition) !== plan.digest) + throw new ExecutionPlanError("INVALID_PLAN", "projection", "Plan digest mismatch"); + const query = plan.definition.queries.find((q) => q.id === queryId); + if (!query) throw new ExecutionPlanError("UNKNOWN_PROJECTION", queryId, "No installed query"); + const declaration = query.query.checked.declaration; + if (declaration.placement?.kind !== "replica" || declaration.root.kind !== "object") + throw new ExecutionPlanError( + "INVALID_RESULT_PROJECTION", + queryId, + "Result observation requires a direct-object replica declaration", + ); + if (!resultInterfaces(plan, operationKey).includes(declaration.root.interface)) + throw new ExecutionPlanError( + "RESULT_PROJECTION_MISMATCH", + operationKey, + `Result does not promise ${declaration.root.interface}`, + ); + return query; +} diff --git a/test/composite-fixture.ts b/test/composite-fixture.ts new file mode 100644 index 0000000..156d8aa --- /dev/null +++ b/test/composite-fixture.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + compileCapabilityResourceSource, + compileCapabilitySource, + formatQx, +} from "../src/capability-language/index.js"; +import type { InterfaceRevision, PackageRevision } from "../src/capability-model/types.js"; +import { compileQuery } from "../src/query/compile.js"; +import { readReaderFixture } from "./reader-fixture.js"; +const directory = resolve("test/fixtures/local-first-composite"); +export async function loadCompositeWorkspace() { + const changed = new Set(["DocumentFactory", "DocumentMetadata", "CommentFactory"]); + const read = (name: string) => readFile(resolve(directory, `${name}.qx`), "utf8"); + const source = { repository: "https://example.test/composite.git", commit: "b".repeat(40) }; + const interfaces = new Map(); + for (const name of [ + "DocumentData", + "HighlightData", + "CommentData", + "DocumentActions", + "DocumentFactory", + "DocumentMetadata", + "CommentFactory", + ]) { + const result = compileCapabilityResourceSource( + formatQx(await (changed.has(name) ? read(name) : readReaderFixture(`${name}.qx`))), + { source }, + ); + assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics)); + interfaces.set(name, result.resource.revision); + } + const environment = { interfaces, interfaceClosure: [...interfaces.values()] }, + packages = new Map(); + for (const name of ["ReaderRuntime", "CommentRuntime"]) { + const selected = + name === "CommentRuntime" ? new Map([["CommentData", interfaces.get("CommentData")!]]) : interfaces; + const result = compileCapabilityResourceSource(formatQx(await read(name)), { + source, + environment: { interfaces: selected, interfaceClosure: [...selected.values()] }, + }); + assert.ok(result.ok && result.resource.kind === "package", JSON.stringify(result.diagnostics)); + const revision = result.resource.revision; + revision.checkedQueries = await Promise.all( + (revision.queries ?? []).map((query) => compileQuery(query, [...interfaces.values()], readReaderFixture)), + ); + packages.set(name, revision); + } + const result = compileCapabilitySource(formatQx(await read("workspace")), "workspace.qx", { + ...environment, + packages, + packageClosure: [...packages.values()], + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + return result.workspace; +} diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts index 33ffbab..4867172 100644 --- a/test/emit-portable-reader.ts +++ b/test/emit-portable-reader.ts @@ -4,6 +4,8 @@ import { loadReaderWorkspace } from "./reader-fixture.js"; import { compileKernelProgram } from "../src/execution-world/kernel.js"; import { compileExecutionPlan } from "../src/execution-world/plan.js"; import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js"; +import { generatePortableClientBindings } from "../src/bindings/portable-client.js"; +import { loadCompositeWorkspace } from "./composite-fixture.js"; const plan = compileExecutionPlan(await loadReaderWorkspace()); await mkdir("dist/portable-reader", { recursive: true }); for (const target of ["browser", "server"]) @@ -13,6 +15,7 @@ for (const target of ["browser", "server"]) ); await writeFile("dist/portable-reader/plan.json", JSON.stringify(plan)); await writeFile("dist/portable-reader/kernel.json", JSON.stringify(compileKernelProgram(plan))); +await writeFile("dist/portable-reader/client.ts", generatePortableClientBindings(plan, { target: "browser" })); const serviceWorkspace = structuredClone(plan.definition.workspace); serviceWorkspace.atoms = []; serviceWorkspace.conformances = []; @@ -33,3 +36,16 @@ serviceWorkspace.packageImports[0]!.exports = [ }, ]; await writeFile("dist/portable-reader/service-plan.json", JSON.stringify(compileExecutionPlan(serviceWorkspace))); +const composite = compileExecutionPlan(await loadCompositeWorkspace()); +await mkdir("dist/portable-reader/composite", { recursive: true }); +await writeFile("dist/portable-reader/composite/plan.json", JSON.stringify(composite)); +await writeFile("dist/portable-reader/composite/kernel.json", JSON.stringify(compileKernelProgram(composite))); +for (const target of ["browser", "server"]) + for (const [name, revision] of [ + ["reader", "package:reader-runtime@1"], + ["comment", "package:comment-runtime@1"], + ] as const) + await writeFile( + `dist/portable-reader/composite/${name}-${target}.ts`, + generatePortableTypeScriptBindings(composite.definition.bindingSchema, revision, { target }), + ); diff --git a/test/fixtures/local-first-composite/CommentFactory.qx b/test/fixtures/local-first-composite/CommentFactory.qx new file mode 100644 index 0000000..12edd77 --- /dev/null +++ b/test/fixtures/local-first-composite/CommentFactory.qx @@ -0,0 +1,7 @@ +external atom Comment id "atom:reader:comment"; +interface CommentFactory id "interface:reader:comment-factory" +revision "interface:reader:comment-factory@1" { + operation prepareComment id "document:prepare-comment" : bool -> atom-ref { + call id "document:prepare-comment:call" execution transaction targets [browser, server]; + } +} diff --git a/test/fixtures/local-first-composite/CommentRuntime.qx b/test/fixtures/local-first-composite/CommentRuntime.qx new file mode 100644 index 0000000..3f12bf7 --- /dev/null +++ b/test/fixtures/local-first-composite/CommentRuntime.qx @@ -0,0 +1,11 @@ +external atom Document id "atom:reader:document"; +external atom Comment id "atom:reader:comment"; +import interface CommentData; +type CommentSeed = record { content: record { text: string; tags: list; }; }; +package CommentRuntime id "package:comment-runtime" revision "package:comment-runtime@1" { + operation prepareComment id "comment:prepare" + : bool -> atom-ref mode call execution transaction targets [browser, server] + receiver atom Document requires { + creation comment id "comment:prepare:comment" : Comment input CommentSeed; + }; +} diff --git a/test/fixtures/local-first-composite/DocumentFactory.qx b/test/fixtures/local-first-composite/DocumentFactory.qx new file mode 100644 index 0000000..f5e700c --- /dev/null +++ b/test/fixtures/local-first-composite/DocumentFactory.qx @@ -0,0 +1,25 @@ +// Shared record definitions, repeated explicitly in independently locked resources. +external atom Document id "atom:reader:document"; +external atom Highlight id "atom:reader:highlight"; +external atom Comment id "atom:reader:comment"; + +type SourceRange = record { + sourceRevision: string; + start: int32; + end: int32; +}; + +type CommentContent = record { + text: string; + tags: list; +}; + +type DocumentSeed = record { document: record { sourceRevision: string; content: string; }; metadata: record { label: string; }; }; + +interface DocumentFactory id "interface:reader:document-factory" +revision "interface:reader:document-factory@1" { + static operation create id "document:create" : DocumentSeed -> atom-ref { + call id "document:create:call" + execution transaction targets [browser, server]; + } +} diff --git a/test/fixtures/local-first-composite/DocumentMetadata.qx b/test/fixtures/local-first-composite/DocumentMetadata.qx new file mode 100644 index 0000000..fe1f648 --- /dev/null +++ b/test/fixtures/local-first-composite/DocumentMetadata.qx @@ -0,0 +1,6 @@ +interface DocumentMetadata id "interface:reader:document-metadata" +revision "interface:reader:document-metadata@1" { + queryable value label id "document:label" : string { + get id "document:label:get" execution snapshot targets [browser, server]; + } +} diff --git a/test/fixtures/local-first-composite/ReaderRuntime.qx b/test/fixtures/local-first-composite/ReaderRuntime.qx new file mode 100644 index 0000000..5b6fce9 --- /dev/null +++ b/test/fixtures/local-first-composite/ReaderRuntime.qx @@ -0,0 +1,84 @@ +// Shared record definitions, repeated explicitly in independently locked resources. +external atom Document id "atom:reader:document"; +external atom Highlight id "atom:reader:highlight"; +external atom Comment id "atom:reader:comment"; + +type SourceRange = record { + sourceRevision: string; + start: int32; + end: int32; +}; + +type CommentContent = record { + text: string; + tags: list; +}; + +type DocumentSeed = record { document: record { sourceRevision: string; content: string; }; metadata: record { label: string; }; }; + +import interface DocumentData; +import interface CommentFactory; +import interface HighlightData; +import interface CommentData; +type CommentSeed = record { content: CommentContent; }; +type HighlightSeed = record { + range: SourceRange; + comments: list; }>; +}; + +package ReaderRuntime id "package:reader-runtime" +revision "package:reader-runtime@1" { + operation addHighlight id "reader:add-highlight" + : SourceRange -> atom-ref + mode call execution transaction targets [browser, server] + receiver atom Document requires { + interface document id "reader:add:document" : DocumentData + using [sourceRevision.get, content.get, highlights.connect]; + creation highlight id "reader:add:highlight" : Highlight input HighlightSeed; + interface comments id "reader:add:comments" : CommentFactory using [prepareComment.call]; + }; + + operation excerpt id "reader:excerpt" : unit -> string + mode call execution snapshot targets [browser, server] + receiver atom Highlight requires { + state range id "reader:excerpt:range" : SourceRange [read]; + interface document id "reader:excerpt:document" : DocumentData + using [sourceRevision.get, content.get]; + }; + + // A receiverless function needs no synthetic service object. + function validateRange id "reader:validate-range" + : record { range: SourceRange; content: string; } -> bool + execution snapshot targets [browser, server]; + + function createDocument id "reader:create-document" + : DocumentSeed -> atom-ref + execution transaction targets [browser, server] requires { + creation document id "reader:create:document" : Document input DocumentSeed; + }; + query ReplicaInputs id "reader:replica-inputs" root DocumentData + document "queries/replica-inputs.graphql" operation "ReplicaInputs" { + replica; + view Highlight as HighlightData; + view Comment as CommentData; + max rows 5000; + max depth 8; + max result-bytes 8388608; + } + + query ReaderScreen id "reader:screen" root DocumentData + document "queries/reader-screen.graphql" operation "ReaderScreen" { + local from ReplicaInputs; + view Highlight as HighlightData; + view Comment as CommentData; + max rows 5000; + max depth 8; + max result-bytes 8388608; + watch; + } + + query HighlightScope id "reader:highlight-scope" root workspace HighlightData document "queries/highlight-scope.graphql" operation "HighlightScope" { view Comment as CommentData; replica; max rows 200; } + query HighlightList id "reader:highlight-list" root workspace HighlightData document "queries/highlight-list.graphql" operation "HighlightList" { view Comment as CommentData; local from HighlightScope; max rows 200; watch; } + query HighlightEditorInputs id "reader:editor-inputs" root HighlightData document "queries/editor-inputs.graphql" operation "HighlightEditorInputs" { view Comment as CommentData; replica; max rows 1; } + query HighlightEditorView id "reader:editor-view" root HighlightData document "queries/editor-view.graphql" operation "HighlightEditorView" { view Comment as CommentData; local from HighlightEditorInputs; max rows 1; watch; } +} diff --git a/test/fixtures/local-first-composite/workspace.qx b/test/fixtures/local-first-composite/workspace.qx new file mode 100644 index 0000000..af3eea3 --- /dev/null +++ b/test/fixtures/local-first-composite/workspace.qx @@ -0,0 +1,108 @@ +workspace Reader id "workspace:reader" revision "workspace:reader@1" commit "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + import interface DocumentData; + import interface HighlightData; + import interface CommentData; + import interface DocumentActions; + import interface DocumentFactory; + import package ReaderRuntime; + import package CommentRuntime; + import interface DocumentMetadata; + import interface CommentFactory; + // Shared record definitions, repeated explicitly in independently locked resources. + + type SourceRange = record { + sourceRevision: string; + start: int32; + end: int32; + }; + + type CommentContent = record { + text: string; + tags: list; + }; + + type DocumentSeed = record { sourceRevision: string; content: string; }; + + atom Document id "atom:reader:document"; + atom Highlight id "atom:reader:highlight"; + atom Comment id "atom:reader:comment"; + + conform Document as DocumentData id "conformance:reader:document-data" { + private state Revision id "slot:document:revision" + on Document : string policy register; + private state Content id "slot:document:content" + on Document : string policy register; + private edge DocumentHighlights id "edge:document-highlights" { + atom Document projection highlights id "projection:highlights" many-unique; + atom Highlight projection document id "projection:document" + exactly-one public-traversal; + } + initialize { + sourceRevision to state Revision; + content to state Content; + }; + bind sourceRevision.get to state Revision.read; + bind content.get to state Content.read; + bind highlights.resolve to edge DocumentHighlights.highlights.resolve; + bind highlights.connect to edge DocumentHighlights.highlights.connect; + } + + conform Document as DocumentActions id "conformance:reader:document-actions" { + + bind annotate.call to package ReaderRuntime.addHighlight with { + document to interface DocumentData; + highlight to creation Highlight with { HighlightData from input; }; + comments to interface CommentFactory; + }; + } + + conform Document as CommentFactory id "conformance:reader:comment-factory" { + bind prepareComment.call to package CommentRuntime.prepareComment with { + comment to creation Comment with { CommentData from input; }; + }; + } + + conform Document as DocumentMetadata id "conformance:reader:document-metadata" { + private state Label id "slot:document:label" on Document : string policy register; + initialize { label to state Label; }; + bind label.get to state Label.read; + } + + conform Document as DocumentFactory id "conformance:reader:document-factory" { + bind create.call to package ReaderRuntime.createDocument with { + document to creation Document with { DocumentData from input.document; DocumentMetadata from input.metadata; }; + }; + } + + conform Highlight as HighlightData id "conformance:reader:highlight-data" { + private state Range id "slot:highlight:range" + on Highlight : SourceRange policy register; + private state Color id "slot:highlight:color" + on Highlight : string policy register default "yellow"; + private edge HighlightComments id "edge:highlight-comments" { + atom Highlight projection comments id "projection:comments" many-unique; + atom Comment projection highlight id "projection:highlight" + exactly-one public-traversal; + } + initialize { + range to state Range; + comments to edge HighlightComments.comments; + }; + bind range.get to state Range.read; + bind color.get to state Color.read; + bind color.set to state Color.write; + bind comments.resolve to edge HighlightComments.comments.resolve; + bind excerpt.get to package ReaderRuntime.excerpt with { + range to state Range; + document to interface DocumentData via edge DocumentHighlights.document; + }; + } + + conform Comment as CommentData id "conformance:reader:comment-data" { + private state CommentBody id "slot:comment:content" + on Comment : CommentContent policy crdt(CommentContent); + initialize { content to state CommentBody; }; + bind content.get to state CommentBody.read; + bind content.edit to state CommentBody.edit; + } +} diff --git a/test/projection-compatibility.test.ts b/test/projection-compatibility.test.ts new file mode 100644 index 0000000..9a73e34 --- /dev/null +++ b/test/projection-compatibility.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { compileExecutionPlan, checkResultProjection, publicOperationKey } from "../src/execution-world/index.js"; +test("factory result observation checks direct replica roots against the result contract", async () => { + const plan = compileExecutionPlan(await loadReaderWorkspace()); + const query = (id: string) => JSON.stringify(["package:reader-runtime@1", id]); + const create = publicOperationKey( + "atom:reader:document", + "interface:reader:document-factory@1", + "document:create:call", + ); + const annotate = publicOperationKey( + "atom:reader:document", + "interface:reader:document-actions@1", + "document:annotate:call", + ); + assert.equal(checkResultProjection(plan, create, query("reader:replica-inputs")).id, query("reader:replica-inputs")); + assert.equal(checkResultProjection(plan, annotate, query("reader:editor-inputs")).id, query("reader:editor-inputs")); + assert.throws(() => checkResultProjection(plan, create, query("reader:editor-inputs")), { + code: "RESULT_PROJECTION_MISMATCH", + }); + assert.throws(() => checkResultProjection(plan, annotate, query("reader:highlight-scope")), { + code: "INVALID_RESULT_PROJECTION", + }); + assert.throws(() => checkResultProjection(plan, annotate, query("reader:editor-view")), { + code: "INVALID_RESULT_PROJECTION", + }); +}); diff --git a/yarn.lock b/yarn.lock index fb471a2..5ebe829 100644 --- a/yarn.lock +++ b/yarn.lock @@ -89,6 +89,7 @@ __metadata: typescript: "npm:^7.0.2" bin: quixos-capability-compile: dist/src/capability-language/cli.js + quixos-codegen-client: dist/src/bindings/portable-client-cli.js quixos-codegen-ts: dist/src/bindings/cli.js quixos-descriptor-check: dist/src/descriptor-check.js quixos-lock-check: dist/src/resource-lock/cli.js