From d701464312221e8aefdb2aa56b936b13e8db5e78 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 19:42:24 -0700 Subject: [PATCH] Support checked closed generic portable bindings --- src/bindings/portable.ts | 9 +++---- src/execution-world/README.md | 10 ++++++++ test/emit-portable-reader.ts | 36 ++++++++++++++++++++++++++ test/portable-bindings.test.ts | 43 ++++++++++++++++++++++++++++++++ test/portable-generic-fixture.ts | 42 +++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 test/portable-generic-fixture.ts diff --git a/src/bindings/portable.ts b/src/bindings/portable.ts index bb62c06..1bdd4b6 100644 --- a/src/bindings/portable.ts +++ b/src/bindings/portable.ts @@ -210,11 +210,10 @@ export function generatePortableTypeScriptBindings( 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", - ); + // The checked binding schema already contains concrete generic applications. + // Each has its own identity, value codecs and specialized dependency ports. + if (entry.kind === "constructor") + throw new PortableBindingError(entry.id, "Legacy constructors are not supported; 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(); diff --git a/src/execution-world/README.md b/src/execution-world/README.md index 023933c..15f5b28 100644 --- a/src/execution-world/README.md +++ b/src/execution-world/README.md @@ -103,3 +103,13 @@ 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. + +Closed generic package applications use the same portable adapters. The checked +workspace specializes their input/output types and dependency ports before code +generation. Each application has its own export ID and generated handler name +(`name$`); handlers are concrete, so a string application +and a list-of-int64 application cannot accidentally share an untyped codec. +Common implementation helpers can still be ordinary generic TypeScript functions. +This does not implement universal runtime type application or legacy constructors. +The `camino-portable-bindings` check compiles positive/negative typed consumers and +executes both specializations in Node, Chromium Window, and Worker hosts. diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts index f60e04b..2e92d9c 100644 --- a/test/emit-portable-reader.ts +++ b/test/emit-portable-reader.ts @@ -51,3 +51,39 @@ for (const target of ["browser", "server"]) `dist/portable-reader/composite/${name}-${target}.ts`, generatePortableTypeScriptBindings(composite.definition.bindingSchema, revision, { target }), ); + +const { portableGenericPlan } = await import("./portable-generic-fixture.js"); +const generic = portableGenericPlan(); +const genericPackage = generic.definition.bindingSchema.packages[0]!; +await mkdir("dist/portable-reader/generic", { recursive: true }); +for (const target of ["browser", "server"]) { + await writeFile( + `dist/portable-reader/generic/${target}.ts`, + generatePortableTypeScriptBindings(generic.definition.bindingSchema, genericPackage.revisionId, { target }), + ); +} +const stringEntry = genericPackage.exports.find((e) => e.inputType.kind === "scalar")!; +const listEntry = genericPackage.exports.find((e) => e.inputType.kind === "list")!; +await writeFile( + "dist/portable-reader/generic/implementation.ts", + ` +import { createRegistry, type PortableImplementation } from "./gen/qx.js"; +export const registry = createRegistry({ + ${JSON.stringify(stringEntry.displayName)}: context => context.input.toUpperCase(), + ${JSON.stringify(listEntry.displayName)}: context => context.input.map(value => value + 1n), +}); +// The specialization must retain its actual contract, not become unknown or any. +const negative: PortableImplementation = { + // @ts-expect-error a string specialization cannot return a number + ${JSON.stringify(stringEntry.displayName)}: context => context.input.length, + ${JSON.stringify(listEntry.displayName)}: context => { + // @ts-expect-error input collections are read-only + context.input.push(0n); + // @ts-expect-error integer values are exact bigint, not number + const lostPrecision: number = context.input[0]!; + return context.input; + }, +}; +void negative; +`, +); diff --git a/test/portable-bindings.test.ts b/test/portable-bindings.test.ts index 5265972..4373227 100644 --- a/test/portable-bindings.test.ts +++ b/test/portable-bindings.test.ts @@ -36,3 +36,46 @@ test("generation rejects remote dependencies, wrong effects and unsupported shap entry.dependencyPorts[0]!.requirement = { kind: "reflection", selectors: ["active"] }; assert.throws(generate, /Unsupported reflection port/); }); + +test("closed generic portable handlers have distinct concrete codecs and no universal casts", async () => { + const { portableGenericPlan } = await import("./portable-generic-fixture.js"); + const plan = portableGenericPlan(); + const schema = plan.definition.bindingSchema; + const pkg = schema.packages[0]!; + assert.equal(pkg.exports.length, 2); + const generated = generatePortableTypeScriptBindings(schema, pkg.revisionId, { target: "browser" }); + assert.match(generated, /readonly input: string/); + assert.match(generated, /readonly input: ReadonlyArray/); + for (const entry of pkg.exports) { + assert.ok(entry.application); + assert.ok(generated.includes(JSON.stringify(entry.id))); + assert.ok(generated.includes(JSON.stringify(entry.displayName))); + } + assert.notEqual(pkg.exports[0]!.displayName, pkg.exports[1]!.displayName); + assert.equal( + generated.replace('target = "browser"', 'target = "server"'), + generatePortableTypeScriptBindings(schema, pkg.revisionId, { target: "server" }), + ); +}); + +test("a portable subset of a mixed interface excludes remote dependencies", async () => { + const schema = compileExecutionPlan(await loadReaderWorkspace()).definition.bindingSchema; + const entry = schema.packages.flatMap((p) => p.exports).find((e) => e.displayName === "addHighlight")!; + const iface = schema.interfaces.find((i) => i.revisionId === "interface:reader:document-data@1")!; + const selected = entry.dependencyPorts.find((p) => p.requirement.kind === "interface")!.requirement; + assert.equal(selected.kind, "interface"); + if (selected.kind !== "interface") throw Error("interface required"); + const operation = structuredClone(iface.members[0]!.operations[0]!); + operation.id = "document:remote:get" as typeof operation.id; + operation.execution = { kind: "remote" }; + iface.members.push({ + ...structuredClone(iface.members[0]!), + id: "document:remote" as (typeof iface.members)[0]["id"], + displayName: "remote", + operations: [operation], + }); + const generate = () => generatePortableTypeScriptBindings(schema, "package:reader-runtime@1", { target: "browser" }); + assert.doesNotMatch(generate(), /readonly "remote.get"/); + selected.operations!.push(operation.id); + assert.throws(generate, /select a portable subset/); +}); diff --git a/test/portable-generic-fixture.ts b/test/portable-generic-fixture.ts new file mode 100644 index 0000000..e358515 --- /dev/null +++ b/test/portable-generic-fixture.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js"; +import { compileExecutionPlan } from "../src/execution-world/plan.js"; + +/** Independently versioned generic interface and implementation, closed by a workspace. */ +export function portableGenericPlan() { + const source = { repository: "https://example.test/portable-generics.git", commit: "a".repeat(40) }; + const iface = compileCapabilityResourceSource( + ` + interface Echo id "echo-interface" revision "echo-interface@2" { + operation echo id "echo-member" : T -> T { + call id "echo-call" execution snapshot targets [browser, server]; + } + }`, + { source }, + ); + assert.ok(iface.ok && iface.resource.kind === "interface", JSON.stringify(iface.diagnostics)); + const pkg = compileCapabilityResourceSource( + ` + package Generic id "generic" revision "generic@3" { + operation echo id "echo" : T -> T mode call + execution snapshot targets [browser, server] receiver any; + }`, + { source: { ...source, commit: "b".repeat(40) } }, + ); + assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + const workspace = compileCapabilitySource( + ` + workspace W id "w" revision "w@1" commit "${source.commit}" { + import interface Echo; import package Generic; atom Thing id "thing"; + conform Thing as Echo id "string-echo" { bind echo.call to package Generic.echo; } + conform Thing as Echo> id "list-echo" { bind echo.call to package Generic.echo>; } + }`, + "workspace.qx", + { + interfaces: new Map([["Echo", iface.resource.revision]]), + packages: new Map([["Generic", pkg.resource.revision]]), + }, + ); + assert.ok(workspace.ok, JSON.stringify(workspace.diagnostics)); + return compileExecutionPlan(workspace.workspace); +}