347 lines
19 KiB
TypeScript
347 lines
19 KiB
TypeScript
import type {
|
|
InterfaceRevision,
|
|
InterfaceOperation,
|
|
ExecutionContract,
|
|
PackageRevision,
|
|
} from "../capability-model/types.js";
|
|
import { canonicalJson } from "../capability-model/evolution.js";
|
|
import { portableValueSchema, tsType } from "../bindings/portable.js";
|
|
import { type ExecutionPlan, resolvePlannedOperation, hexDigest, ExecutionPlanError } from "./plan.js";
|
|
import type { BindingSchema } from "../bindings/index.js";
|
|
import type { PackageBuildContract } from "./build-contract.js";
|
|
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
|
import { moduleTypeContext } from "./module-contracts.js";
|
|
import { queryTreeBlueprint, queryTreeType } from "./query-trees.js";
|
|
|
|
/** Public shape only: no slot inventory, implementation binding, or workspace identity. */
|
|
export interface PublicOperationContract {
|
|
interface: string;
|
|
operation: string;
|
|
scope: "object" | "class";
|
|
execution: ExecutionContract;
|
|
input: unknown;
|
|
output: unknown;
|
|
fieldPolicy?: "register" | "crdt";
|
|
}
|
|
export function publicOperationContract(
|
|
iface: InterfaceRevision,
|
|
operation: InterfaceOperation,
|
|
): PublicOperationContract {
|
|
if (iface.template || operation.mode !== "call" || !iface.members.some((m) => m.operations.includes(operation)))
|
|
throw new ExecutionPlanError(
|
|
"UNSUPPORTED_CLIENT_CONTRACT",
|
|
iface.revisionId,
|
|
"Expected a closed interface call operation",
|
|
);
|
|
const execution = operation.execution ?? { kind: "remote" };
|
|
const member = iface.members.find((m) => m.operations.includes(operation))!;
|
|
const fieldPolicy =
|
|
member.kind === "value" && operation.scope !== "class" && execution.kind === "transaction"
|
|
? operation.displayName === "edit"
|
|
? "crdt"
|
|
: member.nativeField && operation.displayName === "set"
|
|
? "register"
|
|
: undefined
|
|
: undefined;
|
|
return {
|
|
...(fieldPolicy ? { fieldPolicy } : {}),
|
|
interface: iface.revisionId,
|
|
operation: operation.id,
|
|
scope: operation.scope ?? "object",
|
|
execution: execution.kind === "remote" ? execution : { ...execution, targets: [...execution.targets].sort() },
|
|
input: portableValueSchema(operation.inputType),
|
|
output: portableValueSchema(operation.outputType),
|
|
};
|
|
}
|
|
/** Resolve a public contract to internal dispatch. This does not authorize the receiver. */
|
|
export function linkInterfaceOperation(plan: ExecutionPlan, atom: string, contract: PublicOperationContract) {
|
|
if (hexDigest(plan.definition) !== plan.digest)
|
|
throw new ExecutionPlanError("INVALID_PLAN", atom, "Invalid execution plan digest");
|
|
const iface = plan.definition.workspace.interfaceImports.find((i) => i.revisionId === contract.interface);
|
|
const operation = iface?.members.flatMap((m) => m.operations).find((o) => o.id === contract.operation);
|
|
if (!iface || !operation || canonicalJson(publicOperationContract(iface, operation)) !== canonicalJson(contract))
|
|
throw new ExecutionPlanError(
|
|
"CLIENT_CONTRACT_MISMATCH",
|
|
`${contract.interface}/${contract.operation}`,
|
|
"Installed interface differs from the imported operation contract",
|
|
);
|
|
return resolvePlannedOperation(plan, atom, contract.interface, contract.operation);
|
|
}
|
|
|
|
export interface PublicQueryContract {
|
|
package: string;
|
|
query: string;
|
|
definitionDigest: string;
|
|
root: { kind: "object" | "workspace-interface"; interface: string };
|
|
placement: "server" | "replica" | "local";
|
|
sourceQuery?: string;
|
|
input: unknown;
|
|
output: unknown;
|
|
tree?: ReturnType<typeof queryTreeBlueprint>;
|
|
}
|
|
export function publicQueryContract(pkg: PackageRevision, queryId: string): PublicQueryContract {
|
|
const query = pkg.checkedQueries?.find((q) => q.declaration.id === queryId);
|
|
if (!query)
|
|
throw new ExecutionPlanError("UNCHECKED_QUERY", `${pkg.revisionId}/${queryId}`, "Expected a checked package query");
|
|
const placement = query.declaration.placement;
|
|
return {
|
|
package: pkg.revisionId,
|
|
query: queryId,
|
|
definitionDigest: query.definitionDigest,
|
|
root: query.declaration.root,
|
|
placement: placement?.kind ?? "server",
|
|
...(placement?.kind === "local" ? { sourceQuery: query.treeProjection?.tree ?? placement.sourceQueryId } : {}),
|
|
input: portableValueSchema(query.variables),
|
|
output: portableValueSchema(query.output),
|
|
...(query.tree ? { tree: queryTreeBlueprint(pkg.revisionId, query) } : {}),
|
|
};
|
|
}
|
|
export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) {
|
|
if (hexDigest(plan.definition) !== plan.digest)
|
|
throw new ExecutionPlanError("INVALID_PLAN", contract.query, "Invalid execution plan digest");
|
|
const pkg = plan.definition.workspace.packageImports.find((p) => p.revisionId === contract.package);
|
|
if (!pkg || canonicalJson(publicQueryContract(pkg, contract.query)) !== canonicalJson(contract))
|
|
throw new ExecutionPlanError(
|
|
"CLIENT_QUERY_MISMATCH",
|
|
`${contract.package}/${contract.query}`,
|
|
"Installed query differs from the imported query contract",
|
|
);
|
|
const query = plan.definition.queries.find(
|
|
(q) => q.query.packageRevisionId === contract.package && q.query.checked.declaration.id === contract.query,
|
|
);
|
|
if (!query) throw new ExecutionPlanError("UNINSTALLED_QUERY", contract.query, "No installed query binding");
|
|
return query;
|
|
}
|
|
|
|
/** Generate only the explicitly imported interfaces. No concrete atom inventory is needed. */
|
|
export function generateInterfaceClientBindings(
|
|
imports: Readonly<Record<string, InterfaceRevision>>,
|
|
runtimeModule = "@quixos/camino-replica-core",
|
|
moduleProps?: (interfaceId: string, memberId: string) => string,
|
|
): string {
|
|
const entries = Object.entries(imports).map(([alias, iface]) => {
|
|
if (iface.template)
|
|
throw new ExecutionPlanError(
|
|
"OPEN_CLIENT_CONTRACT",
|
|
alias,
|
|
"Specialize the interface before generating a client",
|
|
);
|
|
const members = iface.members.map((member) => {
|
|
if (member.kind === "module" && member.subject?.kind === "interface") {
|
|
const subject = member.subject.interfaceRevisionId;
|
|
return `${JSON.stringify(member.displayName)}: defineModuleMember<${JSON.stringify(subject)}, ${moduleProps?.(iface.revisionId, member.id) ?? "{}"}>(${JSON.stringify({ interface: iface.revisionId, member: member.id, subject, contract: member.contract, ...moduleTypeContext(iface) })})`;
|
|
}
|
|
const operations = member.operations
|
|
.filter((op) => op.mode === "call")
|
|
.map((op) => {
|
|
const contract = publicOperationContract(iface, op);
|
|
return contract.fieldPolicy && member.kind === "value"
|
|
? `${JSON.stringify(op.displayName)}: defineFieldOperation<${tsType(member.valueType)}, ${JSON.stringify(contract.fieldPolicy)}, ${JSON.stringify(contract.execution)}>(${JSON.stringify(contract)})`
|
|
: `${JSON.stringify(op.displayName)}: defineInterfaceOperation<${tsType(op.inputType)}, ${tsType(op.outputType)}, ${JSON.stringify(contract.execution)}>(${JSON.stringify(contract)})`;
|
|
});
|
|
return `${JSON.stringify(member.displayName)}: {${operations.join(",\n")}}`;
|
|
});
|
|
return `${JSON.stringify(alias)}: {${members.join(",\n")}}`;
|
|
});
|
|
return `// Generated from imported interface contracts. Regenerate; do not edit.\nimport { defineModuleMember, defineInterfaceOperation, defineFieldOperation, type PreparedChange, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const interfaces = {${entries.join(",\n")}} as const;\n`;
|
|
}
|
|
|
|
export function generatePackageQueryClientBindings(
|
|
pkg: PackageRevision,
|
|
runtimeModule = "@quixos/camino-replica-core",
|
|
packages: readonly PackageRevision[] = [pkg],
|
|
): string {
|
|
const internal =
|
|
(pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined)?.internalQueries ?? [];
|
|
pkg = {
|
|
...pkg,
|
|
queries: pkg.queries?.filter((query) => !internal.includes(query.id)),
|
|
checkedQueries: pkg.checkedQueries?.filter((query) => !internal.includes(query.declaration.id)),
|
|
};
|
|
const queries = (pkg.checkedQueries ?? [])
|
|
.filter((query) => !query.declaration.fragment && !query.treeProjection)
|
|
.map(
|
|
(query) =>
|
|
`${JSON.stringify(query.declaration.displayName)}: ${query.tree ? `definePackageQueryTree<${tsType(query.variables)}, ${queryTreeType(query)}, ${JSON.stringify(query.declaration.root.interface)}>` : `definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>`}(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
|
);
|
|
const emitFragment = (provider: PackageRevision, id: string) => {
|
|
const query = provider.checkedQueries?.find((query) => query.declaration.id === id);
|
|
if (
|
|
!query?.declaration.fragment ||
|
|
query.declaration.fragment.admission ||
|
|
query.declaration.placement?.kind !== "local" ||
|
|
query.output.kind !== "record" ||
|
|
!query.output.fields.root
|
|
)
|
|
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", id, "Expected a checked local fragment export");
|
|
const output = query.output.fields.root;
|
|
const descriptor = {
|
|
package: provider.revisionId,
|
|
fragment: id,
|
|
digest: query.definitionDigest,
|
|
subject: query.declaration.root.interface,
|
|
input: portableValueSchema(query.variables),
|
|
output: portableValueSchema(output),
|
|
admission: publicQueryContract(provider, query.declaration.placement.sourceQueryId),
|
|
view: publicQueryContract(provider, id),
|
|
};
|
|
return `definePackageFragment<${tsType(query.variables)}, ${tsType(output)}, ${JSON.stringify(query.declaration.root.interface)}>(${JSON.stringify(descriptor)})`;
|
|
};
|
|
const fragments = (pkg.checkedQueries ?? [])
|
|
.filter((query) => query.declaration.fragment && !query.declaration.fragment.admission)
|
|
.map((query) => `${JSON.stringify(query.declaration.displayName)}: ${emitFragment(pkg, query.declaration.id)}`);
|
|
const imports = new Map<string, Map<string, string>>();
|
|
for (const query of pkg.queries ?? [])
|
|
for (const imported of query.importedFragments ?? []) {
|
|
if (imported.packageRevisionId === pkg.revisionId) continue;
|
|
const provider = packages.find((provider) => provider.revisionId === imported.packageRevisionId);
|
|
const name = provider?.queries?.find((query) => query.id === imported.fragmentId)?.displayName;
|
|
if (!provider || !name)
|
|
throw new ExecutionPlanError("UNCHECKED_FRAGMENT", imported.fragmentId, "Missing imported fragment provider");
|
|
const alias = imported.packageAlias ?? provider.displayName;
|
|
if (pkg.queries?.some((query) => query.fragment && query.displayName === alias))
|
|
throw new ExecutionPlanError(
|
|
"FRAGMENT_ALIAS_COLLISION",
|
|
alias,
|
|
"Imported fragment namespace conflicts with a local fragment export",
|
|
);
|
|
const entries = imports.get(alias) ?? new Map<string, string>();
|
|
entries.set(name, emitFragment(provider, imported.fragmentId));
|
|
imports.set(alias, entries);
|
|
}
|
|
for (const [alias, entries] of imports)
|
|
fragments.push(
|
|
`${JSON.stringify(alias)}: {${[...entries].map(([name, value]) => `${JSON.stringify(name)}: ${value}`).join(",")}}`,
|
|
);
|
|
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, definePackageFragment, ${queries.some((query) => query.includes("definePackageQueryTree<")) ? "definePackageQueryTree, type QueryTreeShape, type QueryTreeOccurrence, " : ""}type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\nexport const fragments = {${fragments.join(",\n")}} as const;\n`;
|
|
}
|
|
|
|
/** Receipt-covered bindings expose the package's imports, component subjects and
|
|
* declared queries, never the workspace's complete atom/interface inventory. */
|
|
export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string {
|
|
const shape = plan.definition.packages.find((pkg) => pkg.revision === packageRevision);
|
|
if (!shape) throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Unknown package");
|
|
return generatePackageComponentBindings(plan.definition.bindingSchema, shape);
|
|
}
|
|
export function generatePackageComponentBindings(
|
|
schema: BindingSchema,
|
|
shape: PackageBuildContract["definition"]["package"],
|
|
): string {
|
|
const packageRevision = shape.revision;
|
|
const pkg = schema.packages.find((pkg) => pkg.revisionId === packageRevision);
|
|
const components = shape.components;
|
|
const modules = shape.modules ?? [];
|
|
if (!pkg || !components?.length)
|
|
throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries");
|
|
const byId = new Map(schema.interfaces.map((iface) => [iface.revisionId as string, iface]));
|
|
const find = (id: string) => {
|
|
const contract = byId.get(id);
|
|
if (!contract || contract.template)
|
|
throw new ExecutionPlanError("OPEN_CLIENT_CONTRACT", id, "Component contracts must be closed");
|
|
return contract;
|
|
};
|
|
const imports = Object.fromEntries(
|
|
Object.entries(pkg.clientInterfaces ?? {}).map(([alias, id]) => [alias, find(id)]),
|
|
);
|
|
const subjects = Object.fromEntries(components.map((component) => [component.displayName, find(component.subject)]));
|
|
const props = (iface: string, member: string) =>
|
|
`ExtraModuleProps<${JSON.stringify(JSON.stringify([iface, member]))}>`;
|
|
// Preserve closed type arguments for the artifact consumer. Interpretation of
|
|
// a particular module contract belongs to that consumer, not this compiler.
|
|
const moduleArguments = (iface: InterfaceRevision) =>
|
|
`[${(iface.application?.arguments ?? [])
|
|
.map((argument) => {
|
|
if (argument.kind === "value") return tsType(argument.type);
|
|
const target = argument.target;
|
|
const reference =
|
|
target.kind === "atom"
|
|
? `ManagedReference<"atom", ${JSON.stringify(target.atomId)}>`
|
|
: `ManagedReference<"interface", ${JSON.stringify(target.interfaceRevisionId)}>`;
|
|
const fields =
|
|
target.kind === "interface"
|
|
? find(target.interfaceRevisionId).members.flatMap((member) => {
|
|
if ((member.kind !== "value" && member.kind !== "relationship") || !member.queryRead) return [];
|
|
const getter = member.operations.find(
|
|
(operation) => operation.displayName === (member.kind === "value" ? "get" : "resolve"),
|
|
);
|
|
return getter ? [`readonly ${JSON.stringify(member.displayName)}: ${tsType(getter.outputType)}`] : [];
|
|
})
|
|
: [];
|
|
return `{readonly reference: ${reference}; readonly values: {${fields.join(";")}}}`;
|
|
})
|
|
.join(",")}]`;
|
|
const interfaces = generateInterfaceClientBindings(imports, "@quixos/camino-react", props);
|
|
const subjectBindings = generateInterfaceClientBindings(subjects, "@quixos/camino-react", props)
|
|
.split("\n")
|
|
.filter((line) => !line.startsWith("import "))
|
|
.join("\n")
|
|
.replace("export const interfaces =", "export const subjects =");
|
|
const queries = generatePackageQueryClientBindings(pkg, "@quixos/camino-react", schema.packages)
|
|
.split("\n")
|
|
.filter((line) => !line.startsWith("import "))
|
|
.join("\n");
|
|
return (
|
|
interfaces +
|
|
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,definePackageQueryTree,type QueryTreeShape,type QueryTreeOccurrence,type QueryTreeHandle,type ComponentType,type ComponentProps,type PresentationHandle} from "@quixos/camino-react";\n' +
|
|
subjectBindings +
|
|
"\n" +
|
|
queries +
|
|
`\nexport const providers = {${Object.entries(pkg.clientPackages ?? {})
|
|
.map(([alias, id]) => {
|
|
const imported = schema.packages.find((entry) => entry.revisionId === id);
|
|
if (!imported) throw new ExecutionPlanError("UNINSTALLED_PACKAGE", id, "Missing imported module provider");
|
|
return `${JSON.stringify(alias)}:{${(imported.modules ?? []).map((module) => `${JSON.stringify(module.displayName)}:${JSON.stringify({ packageRevision: id, id: module.id })}`).join(",")}}`;
|
|
})
|
|
.join(",")}} as const;\n` +
|
|
`\nexport const renderers = {${Object.entries(imports)
|
|
.map(([name, iface]) => `${JSON.stringify(name)}:createObjectRenderer(${JSON.stringify(iface.revisionId)})`)
|
|
.join(",\n")}} as const;\n` +
|
|
"\nexport interface ModuleProps {}\n" +
|
|
"export type ExtraModuleProps<K extends string> = K extends keyof ModuleProps ? ModuleProps[K] : {};\n" +
|
|
"export interface Modules {\n" +
|
|
components
|
|
.map((component) => {
|
|
const module = modules.find((module) => module.id === component.id)!;
|
|
const key = JSON.stringify([module.interfaceRevisionId, module.memberId]);
|
|
const artifact = pkg.buildArtifacts?.["org.quixos.react.preparation/1"] as
|
|
| import("../react-build/declarations.js").ReactPreparationArtifact
|
|
| undefined;
|
|
const preparation = artifact?.modules.find((entry) => entry.module === module.id);
|
|
const composition = pkg.queryCompositions?.find((entry) => entry.id === preparation?.composition);
|
|
if (!preparation || !composition)
|
|
throw new ExecutionPlanError("MISSING_PRESENTATION_INPUTS", module.id, "Declare inputs in quixos.react.json");
|
|
const children = preparation.children.map((child) => {
|
|
let extra = "{}";
|
|
if (child.role) {
|
|
const iface = find(child.role.interface);
|
|
const member = iface.members.find(
|
|
(entry) => entry.id === child.role!.member || entry.displayName === child.role!.member,
|
|
)!;
|
|
extra = props(iface.revisionId, member.id);
|
|
}
|
|
const handle = `PresentationHandle<${JSON.stringify(child.subject)}, ${extra}>`;
|
|
return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`;
|
|
});
|
|
const trees = (preparation.trees ?? []).map((tree) => {
|
|
const query = pkg.checkedQueries?.find((query) => query.declaration.id === tree.query);
|
|
if (!query?.tree)
|
|
throw new ExecutionPlanError("MISSING_QUERY_TREE", tree.query, "Expected a checked Query Tree");
|
|
return `readonly ${JSON.stringify(tree.name)}: QueryTreeHandle<${queryTreeType(query)}>`;
|
|
});
|
|
const input = `${tsType(preparation.input ?? composition.output)} & {readonly children: {${children.join(";")}}${trees.length ? `;readonly trees:{${trees.join(";")}}` : ""}}`;
|
|
return `${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}, ${JSON.stringify(module.contract)}, ${moduleArguments(find(module.interfaceRevisionId))}, ${input}> & ExtraModuleProps<${JSON.stringify(key)}>>;`;
|
|
})
|
|
.join("\n") +
|
|
"\n}\n"
|
|
);
|
|
}
|
|
export function componentAssetDeclarations(): string {
|
|
return (
|
|
"// Generated component asset modules. Regenerate; do not edit.\n" +
|
|
["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"]
|
|
.map((extension) => `declare module "*.${extension}" { const value: string; export default value; }`)
|
|
.join("\n") +
|
|
'\ndeclare module "*.css";\n'
|
|
);
|
|
}
|