Generate typed asynchronous service capability bindings

This commit is contained in:
Timothy J. Aveni
2026-09-20 20:35:16 -07:00
parent 21502ed023
commit c4ae58831d
13 changed files with 192 additions and 21 deletions
+20 -9
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { generateTypeScriptBindings, generatePortableTypeScriptBindings } from "./index.js"; import {
generateTypeScriptBindings,
generatePortableTypeScriptBindings,
generateRemoteTypeScriptBindings,
} 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,16 +15,23 @@ 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"));
if (config.portable && config.react) if ([config.portable, config.remote, config.react].filter(Boolean).length > 1)
throw new Error("Portable handlers and React component bindings are separate artifacts"); throw new Error("Portable, remote and React bindings are separate artifacts");
if (config.remote !== undefined && config.remote !== true) throw new Error("Remote binding mode must be true");
if (config.portable && typeof config.portable.target !== "string") if (config.portable && typeof config.portable.target !== "string")
throw new Error("Portable bindings require an explicit target"); throw new Error("Portable bindings require an explicit target");
const generated = config.portable const generated = config.remote
? generatePortableTypeScriptBindings(contracts, revision, { ? generateRemoteTypeScriptBindings(
...config.portable, contracts,
...(config.runtimeModule ? { runtimeModule: config.runtimeModule } : {}), revision,
}) config.runtimeModule ? { runtimeModule: config.runtimeModule } : {},
: generateTypeScriptBindings(contracts, revision, config); )
: 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);
+1
View File
@@ -424,6 +424,7 @@ export const generateTypeScriptBindings = (
export { export {
generatePortableTypeScriptBindings, generatePortableTypeScriptBindings,
generateRemoteTypeScriptBindings,
portableValueSchema, portableValueSchema,
PortableBindingError, PortableBindingError,
type PortableBindingOptions, type PortableBindingOptions,
+37 -11
View File
@@ -104,7 +104,13 @@ interface Method {
output: ValueType; output: ValueType;
keyed: boolean; keyed: boolean;
} }
function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPort, target: string): Method[] { function methods(
schema: BindingSchema,
entry: PackageExport,
port: DependencyPort,
target: string,
remote = false,
): Method[] {
const req = port.requirement; const req = port.requirement;
const path = `${entry.displayName}.ports.${port.displayName}`; const path = `${entry.displayName}.ports.${port.displayName}`;
const method = ( const method = (
@@ -115,6 +121,8 @@ function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPo
keyed = false, keyed = false,
): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed }); ): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed });
const writable = () => { const writable = () => {
if (remote)
throw new PortableBindingError(path, "Remote writes and creation require a named transaction capability");
if (entry.execution?.kind !== "transaction") if (entry.execution?.kind !== "transaction")
throw new PortableBindingError(path, "Snapshot context cannot expose mutation ports"); throw new PortableBindingError(path, "Snapshot context cannot expose mutation ports");
}; };
@@ -166,8 +174,7 @@ function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPo
.map(({ member, op }) => { .map(({ member, op }) => {
const execution: ExecutionContract = op.execution ?? { kind: "remote" }; const execution: ExecutionContract = op.execution ?? { kind: "remote" };
if ( if (
execution.kind === "remote" || (execution.kind === "remote" ? !remote : !execution.targets.includes(target)) ||
!execution.targets.includes(target) ||
op.mode !== "call" || op.mode !== "call" ||
op.scope === "class" op.scope === "class"
) )
@@ -175,7 +182,7 @@ function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPo
`${path}.${member.displayName}.${op.displayName}`, `${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`, `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(); if (execution.kind === "transaction" && !remote) writable();
return method( return method(
`${member.displayName}.${op.displayName}`, `${member.displayName}.${op.displayName}`,
{ kind: "interface", operationId: op.id }, { kind: "interface", operationId: op.id },
@@ -199,10 +206,28 @@ export function generatePortableTypeScriptBindings(
revision: string, revision: string,
options: PortableBindingOptions, options: PortableBindingOptions,
): string { ): string {
return generateBindings(schema, revision, options, false);
}
export function generateRemoteTypeScriptBindings(
schema: BindingSchema,
revision: string,
options: { runtimeModule?: string } = {},
): string {
return generateBindings(schema, revision, { ...options, target: "server" }, true);
}
function generateBindings(
schema: BindingSchema,
revision: string,
options: PortableBindingOptions,
remote: boolean,
): string {
const mode = remote ? "Remote" : "Portable";
const pkg = schema.packages.find((p) => p.revisionId === revision); const pkg = schema.packages.find((p) => p.revisionId === revision);
if (!pkg) throw new PortableBindingError(revision, "Missing package"); if (!pkg) throw new PortableBindingError(revision, "Missing package");
const entries = pkg.exports.filter( const entries = pkg.exports.filter((e) =>
(e) => e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target), remote
? !e.execution || e.execution.kind === "remote"
: e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target),
); );
const names = new Set<string>(); const names = new Set<string>();
const contexts: string[] = [], const contexts: string[] = [],
@@ -210,6 +235,7 @@ export function generatePortableTypeScriptBindings(
adapters: string[] = [], adapters: string[] = [],
descriptors: unknown[] = []; descriptors: unknown[] = [];
for (const entry of entries) { for (const entry of entries) {
const effect = entry.execution?.kind ?? "remote";
// The checked binding schema already contains concrete generic applications. // The checked binding schema already contains concrete generic applications.
// Each has its own identity, value codecs and specialized dependency ports. // Each has its own identity, value codecs and specialized dependency ports.
if (entry.kind === "constructor") if (entry.kind === "constructor")
@@ -223,12 +249,12 @@ export function generatePortableTypeScriptBindings(
if (portNames.has(port.displayName)) if (portNames.has(port.displayName))
throw new PortableBindingError(entry.id, `Duplicate port name ${port.displayName}`); throw new PortableBindingError(entry.id, `Duplicate port name ${port.displayName}`);
portNames.add(port.displayName); portNames.add(port.displayName);
const selected = methods(schema, entry, port, options.target); const selected = methods(schema, entry, port, options.target, remote);
if (new Set(selected.map((m) => m.name)).size !== selected.length) if (new Set(selected.map((m) => m.name)).size !== selected.length)
throw new PortableBindingError(port.id, "Duplicate method name"); throw new PortableBindingError(port.id, "Duplicate method name");
all.push(...selected); all.push(...selected);
facets.push( 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("; ")} }`, `readonly ${q(port.displayName)}: { ${selected.map((m) => `readonly ${q(m.name)}: (${[...(isUnit(m.input) ? [] : [`input: ${tsType(m.input)}`]), ...(m.keyed ? [remote ? "options: ServerMutationOptions" : "options: MutationKey"] : [])].join(", ")}) => Promise<${tsType(m.output)}>`).join("; ")} }`,
); );
} }
const receiver = const receiver =
@@ -244,7 +270,7 @@ export function generatePortableTypeScriptBindings(
? "ExactReference" ? "ExactReference"
: `ManagedReference<"atom", ${q(receiver.id)}>`; : `ManagedReference<"atom", ${q(receiver.id)}>`;
contexts.push( contexts.push(
`readonly ${q(entry.displayName)}: { readonly effect: ${q(entry.execution!.kind)}; readonly input: ${tsType(entry.inputType)}; readonly receiver: ${receiverType}; readonly ports: { ${facets.join("; ")} } }`, `readonly ${q(entry.displayName)}: { readonly effect: ${q(effect)}; ${remote ? "readonly signal: AbortSignal;" : ""} readonly input: ${tsType(entry.inputType)}; readonly receiver: ${receiverType}; readonly ports: { ${facets.join("; ")} } }`,
); );
const output = tsType(entry.outputType); const output = tsType(entry.outputType);
implementations.push( implementations.push(
@@ -256,12 +282,12 @@ export function generatePortableTypeScriptBindings(
descriptors.push({ descriptors.push({
id: entry.id, id: entry.id,
name: entry.displayName, name: entry.displayName,
effect: entry.execution!.kind, effect,
receiver, receiver,
input: portableValueSchema(entry.inputType), input: portableValueSchema(entry.inputType),
output: portableValueSchema(entry.outputType), output: portableValueSchema(entry.outputType),
methods: all.map((m) => ({ ...m, input: portableValueSchema(m.input), output: portableValueSchema(m.output) })), 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 PreparedChange, 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`; return `// Generated ${remote ? "remote" : "portable"} bindings. Regenerate from the checked capability schema; do not edit.\nimport { define${mode}Registry, type ${mode}Export, type PreparedChange, type ManagedReference, type ExactReference, type ${remote ? "ServerMutationOptions" : "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 ${mode}Implementation = { ${implementations.join(";\n")} };\nexport const declarations = ${JSON.stringify(descriptors, null, 2)} as const satisfies readonly ${mode}Export[];\nexport function createRegistry(handlers: ${mode}Implementation) { const installed = { ...handlers }; return define${mode}Registry(declarations, { ${adapters.join(",\n")} }); }\n`;
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export * from "./files.js";
export * from "./receipts.js"; export * from "./receipts.js";
export { generatePortableTypeScriptBindings } from "../bindings/portable.js"; export { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } from "../bindings/portable.js";
export * from "./kernel.js"; export * from "./kernel.js";
export * from "./projections.js"; export * from "./projections.js";
+25
View File
@@ -0,0 +1,25 @@
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, PackageRevision } from "../src/capability-model/types.js";
export async function asyncObjectPlan() {
const read = (name: string) => readFile(`test/fixtures/async-objects/${name}.qx`, "utf8");
const source = { repository: "https://example.test/async.git", commit: "a".repeat(40) };
const interfaces = new Map<string, InterfaceRevision>();
const packages = new Map<string, PackageRevision>();
for (const name of ["DocumentData", "SummaryController", "Summarizer"]) {
const result = compileCapabilityResourceSource(await read(name), { 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()] };
for (const name of ["ControllerRuntime", "ProviderRuntime"]) {
const result = compileCapabilityResourceSource(await read(name), { source, environment });
assert.ok(result.ok && result.resource.kind === "package", JSON.stringify(result.diagnostics));
packages.set(name, result.resource.revision);
}
const result = compileCapabilitySource(await read("workspace"), "workspace.qx", { ...environment, packages });
assert.ok(result.ok, JSON.stringify(result.diagnostics));
return compileExecutionPlan(result.workspace);
}
+15
View File
@@ -87,3 +87,18 @@ const negative: PortableImplementation = {
void negative; void negative;
`, `,
); );
const { asyncObjectPlan } = await import("./async-fixture.js");
const { generateRemoteTypeScriptBindings } = await import("../src/bindings/portable.js");
const asyncPlan = await asyncObjectPlan();
await mkdir("dist/portable-reader/async", { recursive: true });
await writeFile("dist/portable-reader/async/plan.json", JSON.stringify(asyncPlan));
await writeFile("dist/portable-reader/async/kernel.json", JSON.stringify(compileKernelProgram(asyncPlan)));
for (const [name, revision] of [
["controller", "async:controller-runtime@1"],
["provider", "async:provider-runtime@2"],
] as const)
await writeFile(
`dist/portable-reader/async/${name}.ts`,
generateRemoteTypeScriptBindings(asyncPlan.definition.bindingSchema, revision),
);
+10
View File
@@ -0,0 +1,10 @@
external atom Document id "async:document";
import interface DocumentData;
import interface Summarizer;
package ControllerRuntime id "async:controller-runtime" revision "async:controller-runtime@1" {
operation refresh id "async:controller:refresh" : record { operationId: string; } -> string
mode call execution remote receiver atom Document requires {
interface document id "async:controller:document" : DocumentData using [content.get, summary.set];
interface provider id "async:controller:provider" : Summarizer using [summarize.call];
};
}
+10
View File
@@ -0,0 +1,10 @@
type Summary = record { text: string; basedOn: string; };
interface DocumentData id "async:document-data" revision "async:document-data@1" {
queryable value content id "async:content" : string {
get id "async:content:get" execution snapshot targets [browser, server];
}
queryable value summary id "async:summary" : Summary {
get id "async:summary:get" execution snapshot targets [browser, server];
set id "async:summary:set" execution transaction targets [browser, server];
}
}
+5
View File
@@ -0,0 +1,5 @@
external atom Provider id "async:provider";
package ProviderRuntime id "async:provider-runtime" revision "async:provider-runtime@2" {
operation summarize id "async:provider:summarize" : string -> string
mode call execution remote receiver atom Provider;
}
+5
View File
@@ -0,0 +1,5 @@
interface Summarizer id "async:summarizer" revision "async:summarizer@1" {
operation summarize id "async:summarize" : string -> string {
call id "async:summarize:call" execution remote;
}
}
+5
View File
@@ -0,0 +1,5 @@
interface SummaryController id "async:controller" revision "async:controller@1" {
operation refresh id "async:refresh" : record { operationId: string; } -> string {
call id "async:refresh:call" execution remote;
}
}
+31
View File
@@ -0,0 +1,31 @@
workspace AsyncObjects id "workspace:async" revision "workspace:async@1" commit "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
import interface DocumentData;
import interface SummaryController;
import interface Summarizer;
import package ControllerRuntime;
import package ProviderRuntime;
atom Document id "async:document";
atom Provider id "async:provider";
conform Document as DocumentData id "async:document:data" {
private state Content id "async:state:content" on Document : string policy register;
private state Summary id "async:state:summary" on Document : record { text: string; basedOn: string; }
policy register default { "text": "", "basedOn": "" };
private edge ProviderLink id "async:provider-link" {
atom Document projection provider id "async:provider-projection" exactly-one public-traversal;
atom Provider projection documents id "async:documents-projection" many-unique;
}
initialize { content to state Content; provider to edge ProviderLink.provider; };
bind content.get to state Content.read;
bind summary.get to state Summary.read;
bind summary.set to state Summary.write;
}
conform Document as SummaryController id "async:document:controller" {
bind refresh.call to package ControllerRuntime.refresh with {
document to interface DocumentData;
provider to interface Summarizer via edge ProviderLink.provider;
};
}
conform Provider as Summarizer id "async:provider:summarizer" {
bind summarize.call to package ProviderRuntime.summarize;
}
}
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { asyncObjectPlan } from "./async-fixture.js";
import { generateRemoteTypeScriptBindings, generatePortableTypeScriptBindings } from "../src/bindings/portable.js";
test("remote adapters expose selected typed calls, lifetimes and explicit transaction identities", async () => {
const plan = await asyncObjectPlan();
const schema = plan.definition.bindingSchema;
const source = generateRemoteTypeScriptBindings(schema, "async:controller-runtime@1");
assert.match(source, /readonly signal: AbortSignal/);
assert.match(source, /readonly "summary.set":.*options: ServerMutationOptions/);
assert.match(source, /readonly "summarize.call": \(input: string\) => Promise<string>/);
assert.doesNotMatch(source, /readonly "summary.get"|context\.conform|readonly snapshot:/);
assert.doesNotMatch(
generatePortableTypeScriptBindings(schema, "async:controller-runtime@1", { target: "browser" }),
/readonly "refresh"/,
);
const entry = schema.packages.find((p) => p.revisionId === "async:controller-runtime@1")!.exports[0]!;
entry.dependencyPorts[0]!.requirement = {
kind: "state",
valueType: { kind: "scalar", name: "string" },
primitives: ["write"],
};
assert.throws(
() => generateRemoteTypeScriptBindings(schema, "async:controller-runtime@1"),
/named transaction capability/,
);
});