From 60b1bdb39f2979e1d1f30e682ab2abe8b982dd2a Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 00:14:08 -0700 Subject: [PATCH] Generate portable capability contexts and checked handler registries --- flake.nix | 3 + src/bindings/cli.ts | 13 +- src/bindings/index.ts | 7 + src/bindings/portable.ts | 252 +++++++++++++++++++++++++++++++++ src/execution-world/README.md | 14 ++ test/emit-portable-reader.ts | 13 ++ test/portable-bindings.test.ts | 38 +++++ 7 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/bindings/portable.ts create mode 100644 test/emit-portable-reader.ts create mode 100644 test/portable-bindings.test.ts diff --git a/flake.nix b/flake.nix index 69fa91f..35c21a0 100644 --- a/flake.nix +++ b/flake.nix @@ -68,6 +68,7 @@ checkPhase = '' runHook preCheck yarn test + node dist/test/emit-portable-reader.js runHook postCheck ''; installPhase = '' @@ -80,6 +81,8 @@ cp --reflink=auto --recursive proto "$out/proto" cp --reflink=auto --recursive dist "$out/dist" cp package.json "$out/package.json" + 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 printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool" diff --git a/src/bindings/cli.ts b/src/bindings/cli.ts index d62f844..2febad9 100644 --- a/src/bindings/cli.ts +++ b/src/bindings/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { readFile, writeFile } from "node:fs/promises"; -import { generateTypeScriptBindings } from "./index.js"; +import { generateTypeScriptBindings, generatePortableTypeScriptBindings } from "./index.js"; import path from "node:path"; import { reactPlatformTypes } from "./react-platform.js"; import { generateReactBindings } from "./react.js"; @@ -11,7 +11,16 @@ const main = async () => { throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); const config = options ? JSON.parse(await readFile(options, "utf8")) : {}; const contracts = JSON.parse(await readFile(schema, "utf8")); - const generated = generateTypeScriptBindings(contracts, revision, config); + if (config.portable && config.react) + throw new Error("Portable handlers and React component bindings are separate artifacts"); + if (config.portable && typeof config.portable.target !== "string") + throw new Error("Portable bindings require an explicit target"); + const generated = config.portable + ? generatePortableTypeScriptBindings(contracts, revision, { + ...config.portable, + ...(config.runtimeModule ? { runtimeModule: config.runtimeModule } : {}), + }) + : generateTypeScriptBindings(contracts, revision, config); await writeFile(output, generated); if (config.react) { await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes); diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 1e17989..826c8b5 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -421,3 +421,10 @@ export const generateTypeScriptBindings = ( `\n },\n});\n` ); }; + +export { + generatePortableTypeScriptBindings, + portableValueSchema, + PortableBindingError, + type PortableBindingOptions, +} from "./portable.js"; diff --git a/src/bindings/portable.ts b/src/bindings/portable.ts new file mode 100644 index 0000000..012f935 --- /dev/null +++ b/src/bindings/portable.ts @@ -0,0 +1,252 @@ +import type { BindingSchema } from "./index.js"; +import type { + ValueType, + ObjectExpectation, + PackageExport, + DependencyPort, + ExecutionContract, +} from "../capability-model/types.js"; +import { hexDigest } from "../execution-world/plan.js"; + +export class PortableBindingError extends Error { + constructor( + readonly path: string, + message: string, + ) { + super(`${path}: ${message}`); + this.name = "PortableBindingError"; + } +} +const q = JSON.stringify; +const unit: ValueType = { kind: "builtin", name: "unit" }; +const string: ValueType = { kind: "scalar", name: "string" }; +const bool: ValueType = { kind: "scalar", name: "bool" }; +const isUnit = (t: ValueType) => t.kind === "builtin" && t.name === "unit"; +const expectation = (e: ObjectExpectation) => ({ + kind: e.kind, + id: e.kind === "atom" ? e.atomId : e.interfaceRevisionId, +}); +/** The protocol IR and replica wire schema intentionally share structure, not reference identity. */ +export function portableValueSchema(type: ValueType): unknown { + switch (type.kind) { + case "scalar": + return type; + case "builtin": + if (type.name === "unit") return type; + break; + case "object-ref": + return { kind: type.kind, expectation: expectation(type.expectation) }; + case "optional": + case "list": + return { kind: type.kind, value: portableValueSchema(type.value) }; + case "record": + return { + kind: type.kind, + fields: Object.fromEntries( + Object.entries(type.fields).map(([key, value]) => [key, portableValueSchema(value)]), + ), + }; + } + throw new PortableBindingError( + type.kind, + "Unsupported portable value; use a supported record/reference contract (native edits and generic adapters have separate tasks)", + ); +} +function tsType(type: ValueType): string { + portableValueSchema(type); + switch (type.kind) { + case "scalar": + return type.name === "bool" + ? "boolean" + : type.name === "string" + ? "string" + : type.name === "bytes" + ? "Uint8Array" + : ["int64", "uint64"].includes(type.name) + ? "bigint" + : "number"; + case "builtin": + return "null"; + case "object-ref": { + const e = expectation(type.expectation); + return `ManagedReference<${q(e.kind)}, ${q(e.id)}>`; + } + case "optional": + return `(${tsType(type.value)} | null)`; + case "list": + return `ReadonlyArray<${tsType(type.value)}>`; + case "record": + return `{ ${Object.entries(type.fields) + .map(([key, value]) => `readonly ${q(key)}: ${tsType(value)}`) + .join("; ")} }`; + default: + throw new PortableBindingError(type.kind, "Unsupported type"); + } +} +interface Method { + name: string; + portId: string; + portName: string; + action: { kind: string; primitive?: string; operationId?: string }; + input: ValueType; + output: ValueType; + keyed: boolean; +} +function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPort, target: string): Method[] { + const req = port.requirement; + const path = `${entry.displayName}.ports.${port.displayName}`; + const method = ( + name: string, + action: Method["action"], + input: ValueType, + output: ValueType, + keyed = false, + ): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed }); + const writable = () => { + if (entry.execution?.kind !== "transaction") + throw new PortableBindingError(path, "Snapshot context cannot expose mutation ports"); + }; + switch (req.kind) { + case "creation": + writable(); + return [ + method( + "create", + { kind: "creation" }, + req.inputType, + { kind: "object-ref", expectation: { kind: "atom", atomId: req.atomId } }, + true, + ), + ]; + case "state": + return req.primitives.map((primitive) => { + if (primitive === "read") return method("get", { kind: "state", primitive }, unit, req.valueType); + if (primitive === "write") { + writable(); + return method("set", { kind: "state", primitive }, req.valueType, unit); + } + throw new PortableBindingError(path, `State ${primitive} is not supported by this portable adapter yet`); + }); + case "edge": { + const ref: ValueType = { kind: "object-ref", expectation: req.target }; + const item: ValueType = { kind: "record", fields: { entry: string, target: ref } }; + return req.primitives.map((primitive) => { + if (primitive === "resolve") + return method("resolve", { kind: "edge", primitive }, unit, { kind: "list", value: item }); + writable(); + if (primitive === "connect") return method("connect", { kind: "edge", primitive }, ref, string, true); + if (primitive === "disconnect") return method("disconnect", { kind: "edge", primitive }, string, bool); + throw new PortableBindingError(path, `Edge ${primitive} is not supported by this portable adapter yet`); + }); + } + case "interface": { + const iface = schema.interfaces.find((i) => i.revisionId === req.interfaceRevisionId); + if (!iface) throw new PortableBindingError(path, `Missing interface ${req.interfaceRevisionId}`); + const operations = iface.members.flatMap((member) => member.operations.map((op) => ({ member, op }))); + if (req.operations?.some((id) => !operations.some(({ op }) => op.id === id))) + throw new PortableBindingError(path, "Unknown selected interface operation"); + return operations + .filter(({ op }) => !req.operations || req.operations.includes(op.id)) + .map(({ member, op }) => { + const execution: ExecutionContract = op.execution ?? { kind: "remote" }; + if ( + execution.kind === "remote" || + !execution.targets.includes(target) || + op.mode !== "call" || + op.scope === "class" + ) + throw new PortableBindingError( + `${path}.${member.displayName}.${op.displayName}`, + `Selected operation is not an object call available in portable target ${target}; select a portable subset or move this handler to remote execution`, + ); + if (execution.kind === "transaction") writable(); + return method( + `${member.displayName}.${op.displayName}`, + { kind: "interface", operationId: op.id }, + op.inputType, + op.outputType, + execution.kind === "transaction", + ); + }); + } + default: + throw new PortableBindingError(path, `Unsupported ${req.kind} port in portable bindings`); + } +} +export interface PortableBindingOptions { + target: string; + runtimeModule?: string; +} +/** Generate one target's handlers. The same source signatures are used in browser/server builds. */ +export function generatePortableTypeScriptBindings( + schema: BindingSchema, + revision: string, + options: PortableBindingOptions, +): string { + const pkg = schema.packages.find((p) => p.revisionId === revision); + if (!pkg) throw new PortableBindingError(revision, "Missing package"); + const entries = pkg.exports.filter( + (e) => e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target), + ); + const names = new Set(); + const contexts: string[] = [], + implementations: string[] = [], + adapters: string[] = [], + descriptors: unknown[] = []; + for (const entry of entries) { + if (entry.application || entry.kind === "constructor") + throw new PortableBindingError( + entry.id, + "Portable generic adapters/legacy constructors are not implemented; declare an ordinary factory", + ); + if (names.has(entry.displayName)) throw new PortableBindingError(entry.id, "Duplicate handler name"); + names.add(entry.displayName); + const portNames = new Set(); + const all: Method[] = [], + facets: string[] = []; + for (const port of entry.dependencyPorts) { + if (portNames.has(port.displayName)) + throw new PortableBindingError(entry.id, `Duplicate port name ${port.displayName}`); + portNames.add(port.displayName); + const selected = methods(schema, entry, port, options.target); + if (new Set(selected.map((m) => m.name)).size !== selected.length) + throw new PortableBindingError(port.id, "Duplicate method name"); + all.push(...selected); + facets.push( + `readonly ${q(port.displayName)}: { ${selected.map((m) => `readonly ${q(m.name)}: (${[...(isUnit(m.input) ? [] : [`input: ${tsType(m.input)}`]), ...(m.keyed ? ["options: MutationKey"] : [])].join(", ")}) => Promise<${tsType(m.output)}>`).join("; ")} }`, + ); + } + const receiver = + entry.kind === "function" + ? null + : entry.receiverRequirement.kind === "exact-atom" + ? { kind: "atom", id: entry.receiverRequirement.atomId } + : "object"; + const receiverType = + receiver === null + ? "null" + : receiver === "object" + ? "ExactReference" + : `ManagedReference<"atom", ${q(receiver.id)}>`; + contexts.push( + `readonly ${q(entry.displayName)}: { readonly effect: ${q(entry.execution!.kind)}; readonly input: ${tsType(entry.inputType)}; readonly receiver: ${receiverType}; readonly ports: { ${facets.join("; ")} } }`, + ); + const output = tsType(entry.outputType); + implementations.push( + `readonly ${q(entry.displayName)}: (context: Contexts[${q(entry.displayName)}]) => ${output} | Promise<${output}>`, + ); + adapters.push( + `${q(entry.id)}: context => installed[${q(entry.displayName)}](context as Contexts[${q(entry.displayName)}])`, + ); + descriptors.push({ + id: entry.id, + name: entry.displayName, + effect: entry.execution!.kind, + receiver, + input: portableValueSchema(entry.inputType), + output: portableValueSchema(entry.outputType), + methods: all.map((m) => ({ ...m, input: portableValueSchema(m.input), output: portableValueSchema(m.output) })), + }); + } + return `// Generated portable bindings. Regenerate from the checked capability schema; do not edit.\nimport { definePortableRegistry, type PortableExport, type ManagedReference, type ExactReference, type MutationKey } from ${q(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const bindingDigest = ${q(hexDigest(schema))};\nexport const packageRevision = ${q(revision)};\nexport const target = ${q(options.target)};\nexport type Contexts = { ${contexts.join(";\n")} };\nexport type PortableImplementation = { ${implementations.join(";\n")} };\nexport const declarations = ${JSON.stringify(descriptors, null, 2)} as const satisfies readonly PortableExport[];\nexport function createRegistry(handlers: PortableImplementation) { const installed = { ...handlers }; return definePortableRegistry(declarations, { ${adapters.join(",\n")} }); }\n`; +} diff --git a/src/execution-world/README.md b/src/execution-world/README.md index d65ec12..023933c 100644 --- a/src/execution-world/README.md +++ b/src/execution-world/README.md @@ -89,3 +89,17 @@ The service control vocabulary includes readiness, cancellation, and shutdown; transport framing and generated adapters are LF-05/LF-08. Portable modules and services implement the same declared operation contracts. ABI compatibility is not a statement about package trust, co-location permission, or multi-user isolation. + +Portable package bindings are generated with `quixos-codegen-ts` options +`{"portable":{"target":"browser"}}` (or `server`). They expose `Contexts`, +`PortableImplementation`, checked codec declarations, and `createRegistry`. +Handlers import the shared replica core, never Replicache. Stable mutation keys +are mandatory for creation and selected nested transaction calls. Snapshot +contexts expose only their declared read methods; runtime frame checks remain +mandatory even when the TypeScript source passed compilation. + +The maintained reader source in `tools/local-first/reader/portable.ts` typechecks +against both generated targets without casts. The root +`camino-portable-bindings` qualification compares Node, Window, Worker and +serialized-port behavior. Its simulated channel tests adapters, not installed +operation acquisition or process isolation; those require the coordinator. diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts new file mode 100644 index 0000000..1b2dd7c --- /dev/null +++ b/test/emit-portable-reader.ts @@ -0,0 +1,13 @@ +/** Qualification artifact emitted after tests, never a production fixture dependency. */ +import { mkdir, writeFile } from "node:fs/promises"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { compileExecutionPlan } from "../src/execution-world/plan.js"; +import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js"; +const plan = compileExecutionPlan(await loadReaderWorkspace()); +await mkdir("dist/portable-reader", { recursive: true }); +for (const target of ["browser", "server"]) + await writeFile( + `dist/portable-reader/${target}.ts`, + generatePortableTypeScriptBindings(plan.definition.bindingSchema, "package:reader-runtime@1", { target }), + ); +await writeFile("dist/portable-reader/plan.json", JSON.stringify(plan)); diff --git a/test/portable-bindings.test.ts b/test/portable-bindings.test.ts new file mode 100644 index 0000000..5265972 --- /dev/null +++ b/test/portable-bindings.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js"; +import { compileExecutionPlan } from "../src/execution-world/plan.js"; +import { loadReaderWorkspace } from "./reader-fixture.js"; + +test("reader portable bindings have selected facets, creation inputs and stable keyed calls", async () => { + const schema = compileExecutionPlan(await loadReaderWorkspace()).definition.bindingSchema; + const source = generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" }); + assert.match(source, /readonly "sourceRevision.get": \(\) => Promise/); + assert.match( + source, + /readonly "highlights.connect": \(input: ManagedReference<"atom", "atom:reader:highlight">, options: MutationKey\)/, + ); + assert.match(source, /readonly "createDocument"/); + assert.match(source, /readonly "create":/); + assert.match(source, /readonly "comments": ReadonlyArray/); + assert.doesNotMatch(source, /"highlights.resolve"|context\.conform|objectId|"live"/); + assert.equal(source, generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" })); + const server = generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "server" }); + assert.equal(source.replace('target = "browser"', 'target = "server"'), server); +}); + +test("generation rejects remote dependencies, wrong effects and unsupported shapes explicitly", async () => { + const schema = compileExecutionPlan(await loadReaderWorkspace()).definition.bindingSchema; + const generate = () => generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" }); + const entry = schema.packages[0]!.exports.find((e) => e.displayName === "addHighlight")!; + entry.execution = { kind: "snapshot", targets: ["browser"] }; + assert.throws(generate, /Snapshot context cannot expose mutation ports/); + entry.execution = { kind: "transaction", targets: ["browser"] }; + const iface = schema.interfaces.find((i) => i.revisionId === "interface:reader:document-data@1")!; + const getter = iface.members[0]!.operations[0]!; + getter.execution = { kind: "remote" }; + assert.throws(generate, /not an object call available in portable target browser/); + getter.execution = { kind: "snapshot", targets: ["browser", "server"] }; + entry.dependencyPorts[0]!.requirement = { kind: "reflection", selectors: ["active"] }; + assert.throws(generate, /Unsupported reflection port/); +});