Implement capability generics, checked package specializations and CRUD scaffolding
Add kinded parameters, capability bounds, Self, aliases and closed application identities. Check generic implementations universally and build candidate-specific codecs and descriptors from immutable schemas. Preserve lexical aliases and exact dispatch identities in package and host bindings. Add an imperative CRUD+index domain scaffold with explicit soft-deletion semantics, source/codegen regression coverage, installed CLI tests and an authoring guide. Existing Web Studio opaque props and class-level create-menu migration are separate from the implemented language core.
This commit is contained in:
+40
-2
@@ -2,7 +2,13 @@ import type { InterfaceRevision, ValueType } from "../capability-model/types.js"
|
||||
|
||||
/** Host clients have no package receiver, but must use the same checked
|
||||
* interface signatures and argument framing as generated package ports. */
|
||||
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => {
|
||||
export const generateClientContracts = (
|
||||
interfaces: InterfaceRevision[],
|
||||
messages: Record<string, string>,
|
||||
qualified = false,
|
||||
) => {
|
||||
if (interfaces.some((entry) => entry.template))
|
||||
throw new Error("Host contracts require closed interface applications, not generic definitions");
|
||||
const type = (value: ValueType): string => {
|
||||
switch (value.kind) {
|
||||
case "builtin":
|
||||
@@ -19,7 +25,9 @@ export const generateClientContracts = (interfaces: InterfaceRevision[], message
|
||||
uint64: "bigint",
|
||||
}[value.name];
|
||||
case "object-ref":
|
||||
return `{readonly $quixosRef: string}`;
|
||||
return qualified
|
||||
? `CapabilityReference<${JSON.stringify(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`
|
||||
: `{readonly $quixosRef: string}`;
|
||||
case "optional":
|
||||
return `(${type(value.value)} | null)`;
|
||||
case "list":
|
||||
@@ -42,6 +50,30 @@ export const generateClientContracts = (interfaces: InterfaceRevision[], message
|
||||
.map((operation) => ({ ...operation, interfaceRevisionId: iface.revisionId })),
|
||||
),
|
||||
);
|
||||
if (qualified) {
|
||||
const contracts = interfaces.map((iface) => {
|
||||
const members = iface.members.flatMap((member) =>
|
||||
member.operations
|
||||
.filter((op) => op.mode === "call")
|
||||
.map((op) => ` ${JSON.stringify(op.id)}: {input: ${type(op.inputType)}; output: ${type(op.outputType)}};`),
|
||||
);
|
||||
return `${JSON.stringify(iface.revisionId)}: {\n${members.join("\n")}\n}`;
|
||||
});
|
||||
if (new Set(interfaces.map((iface) => iface.revisionId)).size !== interfaces.length)
|
||||
throw new Error("Duplicate closed interface identity");
|
||||
return (
|
||||
`// Generated closed capability contracts. Dispatch by interface AND operation.\n` +
|
||||
`declare const referenceType: unique symbol;\nexport type CapabilityReference<T extends string> = {readonly $quixosRef: string; readonly [referenceType]: T};\n` +
|
||||
`export type CapabilityContracts = {${contracts.join(";\n")}};\n` +
|
||||
`export type CapabilityInput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {input: infer T} ? T : never;\n` +
|
||||
`export type CapabilityOutput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {output: infer T} ? T : never;\n` +
|
||||
`export const capabilityApplications = ${JSON.stringify(Object.fromEntries(interfaces.map((iface) => [iface.revisionId, { definitionId: iface.application?.definitionId ?? iface.revisionId, arguments: iface.application?.arguments ?? [], ...(iface.application?.self ? { self: iface.application.self } : {}) }])))} as const;\n`
|
||||
);
|
||||
}
|
||||
if (new Set(operations.map((entry) => entry.id)).size !== operations.length)
|
||||
throw new Error(
|
||||
"Host operation IDs are ambiguous across interfaces; select a closed interface application explicitly",
|
||||
);
|
||||
return (
|
||||
`// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
|
||||
`export type PlatformInputs = {\n${operations.map((operation) => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
|
||||
@@ -65,3 +97,9 @@ export const generateClientContracts = (interfaces: InterfaceRevision[], message
|
||||
)} as const;\n`
|
||||
);
|
||||
};
|
||||
|
||||
/** New consumers use exact closed interface identities; operation IDs alone are not unique. */
|
||||
export const generateAppliedClientContracts = (
|
||||
interfaces: InterfaceRevision[],
|
||||
messages: Record<string, string> = {},
|
||||
) => generateClientContracts(interfaces, messages, true);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { GenericPackageExport, GenericDependencyPort } from "../capability-model/generic-packages.js";
|
||||
import type { InterfaceRevision, ValueType } from "../capability-model/types.js";
|
||||
import type {
|
||||
ValueTypeExpression,
|
||||
ObjectTypeExpression,
|
||||
TypeArgumentExpression,
|
||||
TypeParameter,
|
||||
ValueAliasDefinition,
|
||||
} from "../capability-model/generics.js";
|
||||
|
||||
/** Universal source contracts. Only closed exports get executable codecs. */
|
||||
export const genericImplementationType = (
|
||||
definition: GenericPackageExport,
|
||||
interfaces: InterfaceRevision[],
|
||||
concrete: (type: ValueType) => string,
|
||||
): string => {
|
||||
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
|
||||
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
|
||||
const rootScope = (): Scope => ({ arguments: new Map(), aliases: definition.aliases });
|
||||
const object = (entries: [string, string][]) =>
|
||||
`{${entries.map(([key, value]) => `${JSON.stringify(key)}:${value}`).join(";")}}`;
|
||||
const target = (type: ObjectTypeExpression, scope: Scope): string => {
|
||||
if (type.kind === "parameter") {
|
||||
const bound = scope.arguments.get(type.parameterId);
|
||||
if (bound) return bound;
|
||||
const name = names.get(type.parameterId);
|
||||
if (!name) throw new Error(`Unbound object parameter ${type.parameterId}`);
|
||||
return name;
|
||||
}
|
||||
if (type.kind === "atom") return JSON.stringify(`atom:${type.atomId}`);
|
||||
if (type.kind === "interface") return JSON.stringify(`interface:${type.interfaceRevisionId}`);
|
||||
if (type.kind === "application")
|
||||
return `QxApplied<${JSON.stringify(type.application.definitionId)}, [${type.application.arguments.map((arg) => argument(arg, scope)).join(",")}]>`;
|
||||
throw new Error("Generic package Self must be expressed as an explicit object parameter");
|
||||
};
|
||||
const argument = (arg: TypeArgumentExpression, scope: Scope): string =>
|
||||
arg.kind === "value" ? value(arg.type, scope) : target(arg.target, scope);
|
||||
const bind = (parameters: TypeParameter[], args: TypeArgumentExpression[], scope: Scope): Scope => {
|
||||
if (parameters.length !== args.length) throw new Error("Wrong generic arity during code generation");
|
||||
const result = new Map(scope.arguments);
|
||||
// Render arguments in their original lexical scope before introducing binders.
|
||||
const rendered = args.map((arg) => argument(arg, scope));
|
||||
parameters.forEach((parameter, index) => result.set(parameter.id, rendered[index]));
|
||||
return { ...scope, arguments: result };
|
||||
};
|
||||
const value = (type: ValueTypeExpression, scope: Scope, depth = 0): string => {
|
||||
if (depth > 128) throw new Error("Generic type expansion exceeds depth limit");
|
||||
switch (type.kind) {
|
||||
case "parameter": {
|
||||
const bound = scope.arguments.get(type.parameterId);
|
||||
if (bound) return bound;
|
||||
const name = names.get(type.parameterId);
|
||||
if (!name) throw new Error(`Unbound value parameter ${type.parameterId}`);
|
||||
return name;
|
||||
}
|
||||
case "object-ref":
|
||||
return `QxObjectRef<${target(type.expectation, scope)}>`;
|
||||
case "record":
|
||||
return object(Object.entries(type.fields).map(([name, field]) => [name, value(field, scope, depth + 1)]));
|
||||
case "optional":
|
||||
return `(${value(type.value, scope, depth + 1)} | null)`;
|
||||
case "list":
|
||||
return `Array<${value(type.value, scope, depth + 1)}>`;
|
||||
case "alias": {
|
||||
const alias = scope.aliases.find((entry) => entry.id === type.definitionId);
|
||||
if (!alias) throw new Error(`Missing alias ${type.definitionId}`);
|
||||
return value(alias.body, bind(alias.parameters, type.arguments, scope), depth + 1);
|
||||
}
|
||||
default:
|
||||
return concrete(type);
|
||||
}
|
||||
};
|
||||
const params = (type: ValueTypeExpression, scope: Scope) =>
|
||||
type.kind === "builtin" && type.name === "unit" ? "" : `input:${value(type, scope)}`;
|
||||
const port = (entry: GenericDependencyPort): string => {
|
||||
const requirement = entry.requirement,
|
||||
scope = rootScope();
|
||||
switch (requirement.kind) {
|
||||
case "state":
|
||||
return object([
|
||||
...requirement.primitives.map((primitive): [string, string] => {
|
||||
if (primitive === "read") return ["get", `()=>Promise<${value(requirement.valueType, scope)}>`];
|
||||
if (primitive === "write") return ["set", `(value:${value(requirement.valueType, scope)})=>Promise<void>`];
|
||||
throw new Error(`Unsupported generic state primitive ${primitive}`);
|
||||
}),
|
||||
...(requirement.primitives.includes("read")
|
||||
? [["live", "()=>Promise<QxLiveValue>"] as [string, string]]
|
||||
: []),
|
||||
]);
|
||||
case "edge": {
|
||||
const ref = `QxObjectRef<${target(requirement.target, scope)}>`;
|
||||
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||
if (primitive === "resolve") return ["resolve", `()=>Promise<Array<${ref}>>`];
|
||||
if (primitive === "connect" || primitive === "disconnect")
|
||||
return [primitive, `(target:${ref})=>Promise<void>`];
|
||||
throw new Error(`Unsupported generic edge primitive ${primitive}`);
|
||||
});
|
||||
if (requirement.primitives.includes("resolve"))
|
||||
methods.push(["collection", `()=>Promise<RelationshipCollection<${ref}>>`]);
|
||||
if (
|
||||
["resolve", "connect", "disconnect"].every((primitive) =>
|
||||
requirement.primitives.includes(primitive as "resolve"),
|
||||
)
|
||||
)
|
||||
methods.push([
|
||||
"replace",
|
||||
`(entries:RelationshipEntry<${ref}>[],expectedRevision:bigint)=>Promise<RelationshipCollection<${ref}>>`,
|
||||
]);
|
||||
return object(methods);
|
||||
}
|
||||
case "constructor":
|
||||
return object([
|
||||
[
|
||||
"construct",
|
||||
`(${params(requirement.inputType, scope)})=>Promise<QxObjectRef<${target(requirement.target, scope)}>>`,
|
||||
],
|
||||
]);
|
||||
case "interface": {
|
||||
const contract = interfaces.find((entry) => entry.revisionId === requirement.application.definitionId);
|
||||
if (!contract) throw new Error(`Missing generic port contract ${requirement.application.definitionId}`);
|
||||
const local = {
|
||||
...bind(contract.template?.parameters ?? [], requirement.application.arguments, scope),
|
||||
aliases: contract.template?.aliases ?? [],
|
||||
};
|
||||
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
|
||||
member.operations
|
||||
.filter((op) => op.mode === "call")
|
||||
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
|
||||
);
|
||||
return object([
|
||||
["objectId", `QxObjectRef<${target({ kind: "application", application: requirement.application }, scope)}>`],
|
||||
["live", object(operations.map((op) => [op.name, `(${params(op.inputType, local)})=>Promise<QxLiveValue>`]))],
|
||||
...operations.map(
|
||||
(op) =>
|
||||
[op.name, `(${params(op.inputType, local)})=>Promise<${value(op.outputType, local)}>`] as [
|
||||
string,
|
||||
string,
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
const declarations = definition.parameters
|
||||
.map((parameter) => `${names.get(parameter.id)}${parameter.kind === "object" ? " extends string" : ""}`)
|
||||
.join(",");
|
||||
const receiver = definition.receiverRequirement;
|
||||
const context = object([
|
||||
["objectId", `QxObjectRef<${receiver.kind === "target" ? target(receiver.target, rootScope()) : "string"}>`],
|
||||
["input", value(definition.inputType, rootScope())],
|
||||
["ports", object(definition.dependencyPorts.map((entry) => [entry.displayName, port(entry)]))],
|
||||
]);
|
||||
const contextWithLifecycle = `${context} & QxContextLifecycle<${context} & {signal?: AbortSignal}>`;
|
||||
const result = value(definition.eventType ?? definition.outputType, rootScope());
|
||||
const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`;
|
||||
const derived = `{kind:"derived";get:${handler}}`;
|
||||
return definition.eventType
|
||||
? derived
|
||||
: `(${handler})${definition.kind === "operation" && definition.mode === "call" ? ` | ${derived}` : ""}`;
|
||||
};
|
||||
+78
-13
@@ -1,21 +1,74 @@
|
||||
import type { InterfaceRevision, PackageRevision, ValueType, DependencyPort } from "../capability-model/types.js";
|
||||
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";
|
||||
|
||||
/** 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: CompiledCapabilityResourceRepository): BindingSchema => ({
|
||||
export const bindingSchema = (compiled: Pick<CompiledCapabilityResourceRepository, "resources">): BindingSchema => ({
|
||||
format: "quixos-bindings",
|
||||
version: 1,
|
||||
interfaces: compiled.resources.flatMap((node) =>
|
||||
node.resource.kind === "interface" ? [node.resource.revision] : [],
|
||||
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. */
|
||||
@@ -46,6 +99,8 @@ export const generateTypeScriptBindings = (
|
||||
) => {
|
||||
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>();
|
||||
@@ -174,6 +229,7 @@ export const generateTypeScriptBindings = (
|
||||
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);
|
||||
@@ -200,13 +256,15 @@ export const generateTypeScriptBindings = (
|
||||
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.
|
||||
handlers.push([
|
||||
entry.displayName,
|
||||
event
|
||||
? `QxDerived<${contextType}, ${outputType}>`
|
||||
: `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
|
||||
]);
|
||||
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] = {
|
||||
inputType: entry.inputType,
|
||||
outputType: entry.outputType,
|
||||
@@ -214,13 +272,19 @@ export const generateTypeScriptBindings = (
|
||||
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),
|
||||
]);
|
||||
}
|
||||
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)}`;
|
||||
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)}`;
|
||||
const typeImports = [
|
||||
"BindingValue",
|
||||
"QxObjectRef",
|
||||
@@ -237,14 +301,15 @@ export const generateTypeScriptBindings = (
|
||||
`import { ${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` +
|
||||
`export type Contexts = ${object(contexts)};\nexport type Implementation = ${object(handlers)};\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` +
|
||||
`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.displayName)}], messages),`,
|
||||
` ${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`
|
||||
|
||||
Reference in New Issue
Block a user