361 lines
17 KiB
TypeScript
361 lines
17 KiB
TypeScript
import type {
|
|
InterfaceRevision,
|
|
PackageRevision,
|
|
ValueType,
|
|
DependencyPort,
|
|
WorkspaceRevision,
|
|
} from "../capability-model/types.js";
|
|
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
|
|
import { genericImplementationType } from "./generics.js";
|
|
import { capabilityId } from "../capability-model/types.js";
|
|
|
|
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
|
export type BindingSchema = {
|
|
format: "quixos-bindings";
|
|
version: 1;
|
|
interfaces: InterfaceRevision[];
|
|
interfaceTemplates?: InterfaceRevision[];
|
|
packages: PackageRevision[];
|
|
};
|
|
export const bindingSchema = (compiled: Pick<CompiledCapabilityResourceRepository, "resources">): BindingSchema => ({
|
|
format: "quixos-bindings",
|
|
version: 1,
|
|
interfaceTemplates: compiled.resources.flatMap((node) =>
|
|
node.resource.kind === "interface" && node.resource.revision.template ? [node.resource.revision] : [],
|
|
),
|
|
interfaces: [
|
|
...new Map(
|
|
compiled.resources
|
|
.flatMap((node) => [
|
|
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
|
|
...(node.resource.specializations ?? []),
|
|
])
|
|
.filter((entry) => !entry.template)
|
|
.map((entry) => [entry.revisionId, entry]),
|
|
).values(),
|
|
],
|
|
packages: compiled.resources.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
|
|
});
|
|
|
|
export const specializeBindingSchema = (
|
|
base: BindingSchema,
|
|
workspace: WorkspaceRevision,
|
|
revisionId: string,
|
|
): BindingSchema => {
|
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
|
if (!pkg) throw new Error(`Missing candidate package ${revisionId}`);
|
|
const needed = new Set<string>();
|
|
const visit = (value: unknown): void => {
|
|
if (!value || typeof value !== "object") return;
|
|
if ("interfaceRevisionId" in value && typeof value.interfaceRevisionId === "string")
|
|
needed.add(value.interfaceRevisionId);
|
|
Object.values(value).forEach(visit);
|
|
};
|
|
visit(pkg.exports);
|
|
const interfaces = new Map<string, InterfaceRevision>(base.interfaces.map((entry) => [entry.revisionId, entry]));
|
|
for (const id of needed) {
|
|
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === id);
|
|
if (iface) {
|
|
interfaces.set(id, iface);
|
|
visit(iface.members);
|
|
}
|
|
}
|
|
return {
|
|
...base,
|
|
interfaces: [...interfaces.values()],
|
|
packages: base.packages.map((entry) => {
|
|
const candidate = workspace.packageImports.find((value) => value.revisionId === entry.revisionId);
|
|
if (!candidate) throw new Error(`Missing dependency package ${entry.revisionId}`);
|
|
return candidate;
|
|
}),
|
|
};
|
|
};
|
|
export type TypeScriptBindingOptions = {
|
|
runtimeModule?: string;
|
|
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
|
|
messages?: Record<string, { module: string; export: string }>;
|
|
};
|
|
export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string {
|
|
const pkg = schema.packages.find((entry) => entry.revisionId === revisionId);
|
|
if (!pkg) throw new Error(`Unknown package revision ${revisionId}`);
|
|
return (
|
|
`# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
|
|
pkg.exports
|
|
.map(
|
|
(entry) =>
|
|
`exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`,
|
|
)
|
|
.join("")
|
|
);
|
|
}
|
|
const q = JSON.stringify;
|
|
const object = (entries: [string, string][]) =>
|
|
`{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
|
|
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
|
|
|
|
export const generateTypeScriptBindings = (
|
|
schema: BindingSchema,
|
|
packageRevisionId: string,
|
|
options: TypeScriptBindingOptions = {},
|
|
) => {
|
|
if (schema.format !== "quixos-bindings" || schema.version !== 1)
|
|
throw new Error("Unsupported binding schema version");
|
|
if (schema.interfaces.some((entry) => entry.template))
|
|
throw new Error("Package bindings require closed interface applications, not generic definitions");
|
|
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
|
|
if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`);
|
|
const messages = new Map<string, string>();
|
|
const type = (value: ValueType): string => {
|
|
switch (value.kind) {
|
|
case "builtin":
|
|
return value.name === "unit" ? "null" : "QxWatchHandle";
|
|
case "scalar":
|
|
return {
|
|
bool: "boolean",
|
|
bytes: "Uint8Array",
|
|
string: "string",
|
|
int64: "bigint",
|
|
uint64: "bigint",
|
|
double: "number",
|
|
int32: "number",
|
|
uint32: "number",
|
|
}[value.name];
|
|
case "object-ref":
|
|
return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`;
|
|
case "optional":
|
|
return `(${type(value.value)} | null)`;
|
|
case "list":
|
|
return `Array<${type(value.value)}>`;
|
|
case "record":
|
|
return `{ ${Object.entries(value.fields)
|
|
.map(([name, field]) => `${q(name)}: ${type(field)}`)
|
|
.join("; ")} }`;
|
|
case "message": {
|
|
if (!options.messages?.[value.descriptorId])
|
|
throw new Error(`Missing TypeScript message binding for ${value.descriptorId}`);
|
|
if (!messages.has(value.descriptorId)) messages.set(value.descriptorId, `message${messages.size}`);
|
|
return `BindingValue<typeof ${messages.get(value.descriptorId)}>`;
|
|
}
|
|
}
|
|
};
|
|
const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) =>
|
|
`QxObjectRef<${q(target.kind === "atom" ? `atom:${target.atomId}` : `interface:${target.interfaceRevisionId}`)}>`;
|
|
const params = (input: ValueType) => (unit(input) ? "" : `input: ${type(input)}`);
|
|
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
|
const requirement = entry.requirement;
|
|
switch (requirement.kind) {
|
|
case "state": {
|
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
|
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
|
if (primitive === "write") return ["set", `(value: ${type(requirement.valueType)}) => Promise<void>`];
|
|
throw new Error(`State primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
|
|
});
|
|
if (requirement.primitives.includes("read")) methods.push(["live", "() => Promise<QxLiveValue>"]);
|
|
return { type: object(methods), spec: { ...requirement, id: entry.id } };
|
|
}
|
|
case "edge": {
|
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
|
if (primitive === "resolve") return [primitive, `() => Promise<Array<${ref(requirement.target)}>>`];
|
|
if (primitive === "connect" || primitive === "disconnect")
|
|
return [primitive, `(target: ${ref(requirement.target)}) => Promise<void>`];
|
|
throw new Error(`Edge primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
|
|
});
|
|
if (requirement.primitives.includes("resolve"))
|
|
methods.push(["collection", `() => Promise<RelationshipCollection<${ref(requirement.target)}>>`]);
|
|
if (
|
|
["resolve", "connect", "disconnect"].every((primitive) =>
|
|
requirement.primitives.includes(primitive as "resolve"),
|
|
)
|
|
)
|
|
methods.push([
|
|
"replace",
|
|
`(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise<RelationshipCollection<${ref(requirement.target)}>>`,
|
|
]);
|
|
return { type: object(methods), spec: { kind: "edge", id: entry.id, primitives: requirement.primitives } };
|
|
}
|
|
case "interface": {
|
|
const contract = schema.interfaces.find(
|
|
(candidate) => candidate.revisionId === requirement.interfaceRevisionId,
|
|
);
|
|
if (!contract) throw new Error(`Missing imported interface contract ${requirement.interfaceRevisionId}`);
|
|
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
|
const operations = contract.members.flatMap((member) =>
|
|
member.operations
|
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
|
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
|
|
);
|
|
const methods: [string, string][] = [
|
|
["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })],
|
|
[
|
|
"live",
|
|
object(
|
|
operations.map((operation) => [
|
|
operation.name,
|
|
`(${params(operation.inputType)}) => Promise<QxLiveValue>`,
|
|
]),
|
|
),
|
|
],
|
|
...operations.map((operation): [string, string] => [
|
|
operation.name,
|
|
`(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`,
|
|
]),
|
|
];
|
|
return {
|
|
type: object([...methods, ["contract", `QxInterfaceContract<${object(methods)}>`]]),
|
|
spec: {
|
|
kind: "interface",
|
|
id: entry.id,
|
|
interfaceRevisionId: requirement.interfaceRevisionId,
|
|
operations: Object.fromEntries(
|
|
operations.map(({ name, id, inputType, outputType }) => [name, { id, inputType, outputType }]),
|
|
),
|
|
},
|
|
};
|
|
}
|
|
case "constructor": {
|
|
const input = requirement.inputType;
|
|
if (!input)
|
|
throw new Error(
|
|
`Constructor port ${entry.id} needs an explicit input contract: add 'input TYPE' after ${requirement.atomId} in QX`,
|
|
);
|
|
return {
|
|
type: object([
|
|
["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`],
|
|
]),
|
|
spec: { kind: "constructor", id: entry.id, inputType: input },
|
|
};
|
|
}
|
|
}
|
|
};
|
|
const exports = [...pkg.exports].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
const names = new Set<string>();
|
|
const specs: Record<string, unknown> = {};
|
|
const contexts: [string, string][] = [];
|
|
const handlers: [string, string][] = [];
|
|
const results: [string, string][] = [];
|
|
for (const entry of exports) {
|
|
if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`);
|
|
names.add(entry.displayName);
|
|
const ports = entry.dependencyPorts.map((dependency) => ({ name: dependency.displayName, ...port(dependency) }));
|
|
if (new Set(ports.map((p) => p.name)).size !== ports.length)
|
|
throw new Error(`Duplicate dependency name in ${entry.displayName}`);
|
|
const receiver =
|
|
entry.kind === "constructor"
|
|
? ref({ kind: "atom", atomId: entry.constructsAtom })
|
|
: entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom"
|
|
? ref({ kind: "atom", atomId: entry.receiverRequirement.atomId })
|
|
: entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces"
|
|
? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
|
|
: "QxObjectRef<string>";
|
|
const contextShape = object([
|
|
["conform", "QxConformer"],
|
|
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
|
|
["input", type(entry.inputType)],
|
|
["ports", object(ports.map((port) => [port.name, port.type]))],
|
|
]);
|
|
contexts.push([
|
|
entry.displayName,
|
|
entry.kind === "function"
|
|
? `${contextShape} & {signal?: AbortSignal}`
|
|
: `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`,
|
|
]);
|
|
const event = entry.kind === "operation" ? entry.eventType : undefined;
|
|
const contextType = `Contexts[${q(entry.displayName)}]`;
|
|
const outputType = type(event ?? entry.outputType);
|
|
results.push([entry.displayName, outputType]);
|
|
// Watch-start handlers produce events through the runtime's derived stream protocol.
|
|
if (!entry.application)
|
|
handlers.push([
|
|
entry.displayName,
|
|
event
|
|
? `QxDerived<${contextType}, ${outputType}>`
|
|
: `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
|
|
]);
|
|
specs[entry.displayName] = {
|
|
...(entry.kind === "function" ? { receiver: "none" } : {}),
|
|
inputType: entry.inputType,
|
|
outputType: entry.outputType,
|
|
...(event ? { eventType: event } : {}),
|
|
ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])),
|
|
};
|
|
}
|
|
for (const definition of pkg.genericExports ?? []) {
|
|
handlers.push([
|
|
definition.displayName,
|
|
genericImplementationType(definition, [...schema.interfaces, ...(schema.interfaceTemplates ?? [])], type),
|
|
]);
|
|
}
|
|
// Unused imported interfaces must not introduce new message-codec obligations.
|
|
// A used port still fails above if its own required codec is missing.
|
|
const hasCodec = (value: ValueType): boolean => {
|
|
if (value.kind === "message") return !!options.messages?.[value.descriptorId];
|
|
if (value.kind === "record") return Object.values(value.fields).every(hasCodec);
|
|
if (value.kind === "list" || value.kind === "optional") return hasCodec(value.value);
|
|
return true;
|
|
};
|
|
const contracts = schema.interfaces
|
|
.filter((iface) =>
|
|
iface.members.every((member) =>
|
|
member.operations
|
|
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
|
|
.every((operation) => hasCodec(operation.inputType) && hasCodec(operation.outputType)),
|
|
),
|
|
)
|
|
.map((iface) => {
|
|
const generated = port({
|
|
id: capabilityId.dependencyPort("contract"),
|
|
displayName: iface.displayName,
|
|
requirement: { kind: "interface", interfaceRevisionId: iface.revisionId },
|
|
});
|
|
const spec = generated.spec as { operations: unknown };
|
|
return {
|
|
iface,
|
|
type: generated.type,
|
|
code: `defineQxInterfaceContract<${generated.type}>(${q(iface.revisionId)}, ${JSON.stringify(spec.operations)})`,
|
|
};
|
|
});
|
|
const imports = [...messages].map(([id, alias]) => {
|
|
const binding = options.messages![id]!;
|
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export))
|
|
throw new Error(`Invalid message binding export ${binding.export}`);
|
|
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
|
});
|
|
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)} ${contracts.map((entry) => entry.type).join(" ")}`;
|
|
const typeImports = [
|
|
"BindingValue",
|
|
"QxObjectRef",
|
|
"QxWatchHandle",
|
|
"QxHandler",
|
|
"QxDerived",
|
|
"QxContextLifecycle",
|
|
"QxConformer",
|
|
"QxInterfaceContract",
|
|
"QxLiveValue",
|
|
"RelationshipCollection",
|
|
"RelationshipEntry",
|
|
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
|
return (
|
|
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
|
`import { ${contracts.length ? "defineQxInterfaceContract, " : ""}${exports.length ? "bindQxHandler, " : ""}${[...typeImports, "QxHandlerSpec", "QxMessages"].map((name) => `type ${name}`).join(", ")} } from ${q(options.runtimeModule ?? "@quixos/camino-package-runtime")};\n` +
|
|
imports.join("\n") +
|
|
`\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
|
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
|
|
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
|
|
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
|
|
`export const interfaces = {${contracts
|
|
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
|
|
.map((entry) => `${q(entry.iface.displayName)}: contracts[${q(entry.iface.revisionId)}]`)
|
|
.join(",\n")}};\n` +
|
|
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
|
|
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
|
|
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` +
|
|
exports
|
|
.map(
|
|
(entry) =>
|
|
` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.application ? pkg.genericExports!.find((definition) => definition.id === entry.application!.exportId)!.displayName : entry.displayName)}], messages),`,
|
|
)
|
|
.join("\n") +
|
|
`\n },\n});\n`
|
|
);
|
|
};
|