Support checked closed generic portable bindings
This commit is contained in:
@@ -210,11 +210,10 @@ export function generatePortableTypeScriptBindings(
|
|||||||
adapters: string[] = [],
|
adapters: string[] = [],
|
||||||
descriptors: unknown[] = [];
|
descriptors: unknown[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.application || entry.kind === "constructor")
|
// The checked binding schema already contains concrete generic applications.
|
||||||
throw new PortableBindingError(
|
// Each has its own identity, value codecs and specialized dependency ports.
|
||||||
entry.id,
|
if (entry.kind === "constructor")
|
||||||
"Portable generic adapters/legacy constructors are not implemented; declare an ordinary factory",
|
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");
|
if (names.has(entry.displayName)) throw new PortableBindingError(entry.id, "Duplicate handler name");
|
||||||
names.add(entry.displayName);
|
names.add(entry.displayName);
|
||||||
const portNames = new Set<string>();
|
const portNames = new Set<string>();
|
||||||
|
|||||||
@@ -103,3 +103,13 @@ against both generated targets without casts. The root
|
|||||||
`camino-portable-bindings` qualification compares Node, Window, Worker and
|
`camino-portable-bindings` qualification compares Node, Window, Worker and
|
||||||
serialized-port behavior. Its simulated channel tests adapters, not installed
|
serialized-port behavior. Its simulated channel tests adapters, not installed
|
||||||
operation acquisition or process isolation; those require the coordinator.
|
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$<specialization digest>`); 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.
|
||||||
|
|||||||
@@ -51,3 +51,39 @@ for (const target of ["browser", "server"])
|
|||||||
`dist/portable-reader/composite/${name}-${target}.ts`,
|
`dist/portable-reader/composite/${name}-${target}.ts`,
|
||||||
generatePortableTypeScriptBindings(composite.definition.bindingSchema, revision, { target }),
|
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;
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -36,3 +36,46 @@ test("generation rejects remote dependencies, wrong effects and unsupported shap
|
|||||||
entry.dependencyPorts[0]!.requirement = { kind: "reflection", selectors: ["active"] };
|
entry.dependencyPorts[0]!.requirement = { kind: "reflection", selectors: ["active"] };
|
||||||
assert.throws(generate, /Unsupported reflection port/);
|
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<bigint>/);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<value T> 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<value T> 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<string> id "string-echo" { bind echo.call to package Generic.echo<string>; }
|
||||||
|
conform Thing as Echo<list<int64>> id "list-echo" { bind echo.call to package Generic.echo<list<int64>>; }
|
||||||
|
}`,
|
||||||
|
"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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user