Generate portable capability contexts and checked handler registries
This commit is contained in:
+11
-2
@@ -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);
|
||||
|
||||
@@ -421,3 +421,10 @@ export const generateTypeScriptBindings = (
|
||||
`\n },\n});\n`
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
generatePortableTypeScriptBindings,
|
||||
portableValueSchema,
|
||||
PortableBindingError,
|
||||
type PortableBindingOptions,
|
||||
} from "./portable.js";
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user