diff --git a/src/bindings/cli.ts b/src/bindings/cli.ts index 2febad9..322c9de 100644 --- a/src/bindings/cli.ts +++ b/src/bindings/cli.ts @@ -1,6 +1,10 @@ #!/usr/bin/env node 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 { reactPlatformTypes } from "./react-platform.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]"); const config = options ? JSON.parse(await readFile(options, "utf8")) : {}; const contracts = JSON.parse(await readFile(schema, "utf8")); - if (config.portable && config.react) - throw new Error("Portable handlers and React component bindings are separate artifacts"); + if ([config.portable, config.remote, config.react].filter(Boolean).length > 1) + 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") 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); + const generated = config.remote + ? generateRemoteTypeScriptBindings( + contracts, + revision, + config.runtimeModule ? { runtimeModule: config.runtimeModule } : {}, + ) + : 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 56d0ae1..8290516 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -424,6 +424,7 @@ export const generateTypeScriptBindings = ( export { generatePortableTypeScriptBindings, + generateRemoteTypeScriptBindings, portableValueSchema, PortableBindingError, type PortableBindingOptions, diff --git a/src/bindings/portable.ts b/src/bindings/portable.ts index 1bdd4b6..f6ea42d 100644 --- a/src/bindings/portable.ts +++ b/src/bindings/portable.ts @@ -104,7 +104,13 @@ interface Method { output: ValueType; 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 path = `${entry.displayName}.ports.${port.displayName}`; const method = ( @@ -115,6 +121,8 @@ function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPo keyed = false, ): Method => ({ name, portId: port.id, portName: port.displayName, action, input, output, keyed }); const writable = () => { + if (remote) + throw new PortableBindingError(path, "Remote writes and creation require a named transaction capability"); if (entry.execution?.kind !== "transaction") 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 }) => { const execution: ExecutionContract = op.execution ?? { kind: "remote" }; if ( - execution.kind === "remote" || - !execution.targets.includes(target) || + (execution.kind === "remote" ? !remote : !execution.targets.includes(target)) || op.mode !== "call" || op.scope === "class" ) @@ -175,7 +182,7 @@ function methods(schema: BindingSchema, entry: PackageExport, port: DependencyPo `${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(); + if (execution.kind === "transaction" && !remote) writable(); return method( `${member.displayName}.${op.displayName}`, { kind: "interface", operationId: op.id }, @@ -199,10 +206,28 @@ export function generatePortableTypeScriptBindings( revision: string, options: PortableBindingOptions, ): 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); 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 entries = pkg.exports.filter((e) => + remote + ? !e.execution || e.execution.kind === "remote" + : e.execution && e.execution.kind !== "remote" && e.execution.targets.includes(options.target), ); const names = new Set(); const contexts: string[] = [], @@ -210,6 +235,7 @@ export function generatePortableTypeScriptBindings( adapters: string[] = [], descriptors: unknown[] = []; for (const entry of entries) { + const effect = entry.execution?.kind ?? "remote"; // The checked binding schema already contains concrete generic applications. // Each has its own identity, value codecs and specialized dependency ports. if (entry.kind === "constructor") @@ -223,12 +249,12 @@ export function generatePortableTypeScriptBindings( 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); + const selected = methods(schema, entry, port, options.target, remote); 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("; ")} }`, + `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 = @@ -244,7 +270,7 @@ export function generatePortableTypeScriptBindings( ? "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("; ")} } }`, + `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); implementations.push( @@ -256,12 +282,12 @@ export function generatePortableTypeScriptBindings( descriptors.push({ id: entry.id, name: entry.displayName, - effect: entry.execution!.kind, + effect, 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 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`; } diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index 6e234bc..1bd02b3 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -5,7 +5,7 @@ export * from "./files.js"; export * from "./receipts.js"; -export { generatePortableTypeScriptBindings } from "../bindings/portable.js"; +export { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } from "../bindings/portable.js"; export * from "./kernel.js"; export * from "./projections.js"; diff --git a/test/async-fixture.ts b/test/async-fixture.ts new file mode 100644 index 0000000..54e594e --- /dev/null +++ b/test/async-fixture.ts @@ -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(); + const packages = new Map(); + 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); +} diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts index 2e92d9c..9af1a7a 100644 --- a/test/emit-portable-reader.ts +++ b/test/emit-portable-reader.ts @@ -87,3 +87,18 @@ const negative: PortableImplementation = { 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), + ); diff --git a/test/fixtures/async-objects/ControllerRuntime.qx b/test/fixtures/async-objects/ControllerRuntime.qx new file mode 100644 index 0000000..5b06255 --- /dev/null +++ b/test/fixtures/async-objects/ControllerRuntime.qx @@ -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]; + }; +} diff --git a/test/fixtures/async-objects/DocumentData.qx b/test/fixtures/async-objects/DocumentData.qx new file mode 100644 index 0000000..214adbe --- /dev/null +++ b/test/fixtures/async-objects/DocumentData.qx @@ -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]; + } +} diff --git a/test/fixtures/async-objects/ProviderRuntime.qx b/test/fixtures/async-objects/ProviderRuntime.qx new file mode 100644 index 0000000..8dfe9f9 --- /dev/null +++ b/test/fixtures/async-objects/ProviderRuntime.qx @@ -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; +} diff --git a/test/fixtures/async-objects/Summarizer.qx b/test/fixtures/async-objects/Summarizer.qx new file mode 100644 index 0000000..84f14f4 --- /dev/null +++ b/test/fixtures/async-objects/Summarizer.qx @@ -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; + } +} diff --git a/test/fixtures/async-objects/SummaryController.qx b/test/fixtures/async-objects/SummaryController.qx new file mode 100644 index 0000000..82567e5 --- /dev/null +++ b/test/fixtures/async-objects/SummaryController.qx @@ -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; + } +} diff --git a/test/fixtures/async-objects/workspace.qx b/test/fixtures/async-objects/workspace.qx new file mode 100644 index 0000000..fb0d48a --- /dev/null +++ b/test/fixtures/async-objects/workspace.qx @@ -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; + } +} diff --git a/test/remote-bindings.test.ts b/test/remote-bindings.test.ts new file mode 100644 index 0000000..39a559c --- /dev/null +++ b/test/remote-bindings.test.ts @@ -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/); + 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/, + ); +});