Generate portable capability contexts and checked handler registries

This commit is contained in:
Timothy J. Aveni
2026-09-20 00:25:53 -07:00
parent 0aaa80a4ba
commit 60b1bdb39f
7 changed files with 338 additions and 2 deletions
+3
View File
@@ -68,6 +68,7 @@
checkPhase = '' checkPhase = ''
runHook preCheck runHook preCheck
yarn test yarn test
node dist/test/emit-portable-reader.js
runHook postCheck runHook postCheck
''; '';
installPhase = '' installPhase = ''
@@ -80,6 +81,8 @@
cp --reflink=auto --recursive proto "$out/proto" cp --reflink=auto --recursive proto "$out/proto"
cp --reflink=auto --recursive dist "$out/dist" cp --reflink=auto --recursive dist "$out/dist"
cp package.json "$out/package.json" 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" mkdir -p "$out/bin"
for tool in quixos-codegen-ts quixos-qx; do 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" printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
+11 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { generateTypeScriptBindings } from "./index.js"; import { generateTypeScriptBindings, generatePortableTypeScriptBindings } from "./index.js";
import path from "node:path"; import path from "node:path";
import { reactPlatformTypes } from "./react-platform.js"; import { reactPlatformTypes } from "./react-platform.js";
import { generateReactBindings } from "./react.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]"); 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 config = options ? JSON.parse(await readFile(options, "utf8")) : {};
const contracts = JSON.parse(await readFile(schema, "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); await writeFile(output, generated);
if (config.react) { if (config.react) {
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes); await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
+7
View File
@@ -421,3 +421,10 @@ export const generateTypeScriptBindings = (
`\n },\n});\n` `\n },\n});\n`
); );
}; };
export {
generatePortableTypeScriptBindings,
portableValueSchema,
PortableBindingError,
type PortableBindingOptions,
} from "./portable.js";
+252
View File
@@ -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<string>();
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<string>();
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`;
}
+14
View File
@@ -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 transport framing and generated adapters are LF-05/LF-08. Portable modules and
services implement the same declared operation contracts. ABI compatibility is services implement the same declared operation contracts. ABI compatibility is
not a statement about package trust, co-location permission, or multi-user isolation. 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.
+13
View File
@@ -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));
+38
View File
@@ -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<string>/);
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/);
});