Files
quixos-protocol/src/react-build/declarations.ts
T

171 lines
8.2 KiB
TypeScript

import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js";
import type { QueryBinding, QueryComposition, QueryCompositionStep } from "../query/composition.js";
/** This is an artifact of the explicitly selected React frontend, not QX IR. */
export const preparationArtifact = "org.quixos.react.preparation/1";
export interface ReactChildDeclaration {
name: string;
subject: string;
root: QueryBinding;
slot: string;
with?: QueryBinding;
each?: { source: QueryBinding; limit: number };
when?: QueryBinding;
role?: { interface: string; member: string };
provider?: { package: string; module: string };
}
export interface ReactModulePreparation {
module: string;
composition: string;
/** Public input type before workspace query lowering adds private projections. */
input?: ValueType;
children: ReactChildDeclaration[];
/** Binds roots/arguments only. The ordinary checked Query Tree owns every
* acquisition path; the React declaration does not repeat its descendants. */
trees?: Pick<QueryCompositionStep, "name" | "query" | "root" | "arguments">[];
}
export interface ReactPreparationArtifact {
/** Frontend queries belong to installed preparation, not the authored client API. */
internalQueries?: string[];
modules: ReactModulePreparation[];
}
/** Names are package imports/module declarations. All query bindings are passed
* to Camino's ordinary composition checker after generated queries are checked. */
export function presentationDeclarations(
text: string,
pkg: PackageRevision,
interfaces: readonly InterfaceRevision[],
): { compositions: QueryComposition[]; artifact: ReactPreparationArtifact } {
const source: unknown = JSON.parse(text);
const object = (value: unknown, label: string): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label}: expected an object`);
return value as Record<string, unknown>;
};
const top = object(source, "quixos.react.json");
if (Object.keys(top).some((key) => key !== "modules")) throw Error("quixos.react.json: unknown field");
const modules = object(top.modules, "modules");
const compositions: QueryComposition[] = [];
const artifact: ReactPreparationArtifact = { modules: [] };
const imported = (name: string) => {
const id = pkg.clientInterfaces?.[name];
if (!id) throw Error(`React declaration: unknown imported interface ${name}`);
return id;
};
for (const [name, value] of Object.entries(modules)) {
const declaration = object(value, `module ${name}`);
if (Object.keys(declaration).some((key) => !["inputs", "children", "trees"].includes(key)))
throw Error(`${name}: unknown preparation field`);
const module = pkg.modules?.find((entry) => entry.displayName === name);
if (!module) throw Error(`React declaration: unknown module ${name}`);
const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
const member = contract?.members.find((entry) => entry.id === module.memberId);
if (
member?.kind !== "module" ||
member.subject?.kind !== "interface" ||
!["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
member.contract,
)
)
throw Error(`${name}: preparation requires a React module contract`);
const fields: Record<string, ValueType> = {
subject: {
kind: "object-ref",
expectation: { kind: "interface", interfaceRevisionId: member.subject.interfaceRevisionId },
},
};
if (member.contract === "org.quixos.react.component/2") {
const state = contract?.application?.arguments[1];
if (state?.kind !== "object" || state.target.kind !== "interface")
throw Error(`${name}: expected a closed state interface`);
fields.navigation = {
kind: "object-ref",
expectation: { kind: "interface", interfaceRevisionId: state.target.interfaceRevisionId },
};
}
if (!Array.isArray(declaration.inputs)) throw Error(`${name}: inputs must be an array of query bindings`);
const steps = declaration.inputs.map((value) => {
const step = object(value, `${name} input`) as unknown as QueryCompositionStep;
if (step.name === "children") throw Error(`${name}: children is reserved for prepared child inputs`);
const query = pkg.queries?.find((entry) => entry.displayName === step.query);
if (!query) throw Error(`${name}.${step.name}: unknown query ${step.query}`);
if (query.placement?.kind !== "local" && !query.fragment)
throw Error(`${name}.${step.name}: requires a local projection or fragment`);
return { ...step, query: query.id };
});
const composition = `react:inputs:${module.id}`;
compositions.push({
id: composition,
input: { kind: "record", fields },
steps,
maxQueries: steps.reduce((count, step) => count + (step.each?.limit ?? 1), 0) || 1,
});
const children = declaration.children ?? [];
if (!Array.isArray(children)) throw Error(`${name}: children must be an array`);
const names = new Set<string>();
const parsed = children.map((value) => {
const child = object(value, `${name} child`) as unknown as ReactChildDeclaration;
if (!child.name || names.has(child.name) || !child.slot || !child.root)
throw Error(`${name}: child requires a unique name, slot and root`);
names.add(child.name);
// A local module identifies a closed role, including its generic arguments.
// This avoids inventing an import alias for a generic application solely
// to select that role in the React preparation manifest.
let role: ReactChildDeclaration["role"];
if (child.role) {
const authored = object(child.role, `${name}.${child.name} role`);
if (typeof authored.module === "string") {
const target = pkg.modules?.find((entry) => entry.displayName === authored.module);
if (!target || Object.keys(authored).length !== 1)
throw Error(`${name}.${child.name}: expected a local module role`);
role = { interface: target.interfaceRevisionId, member: target.memberId };
} else {
role = { ...child.role, interface: imported(child.role.interface) };
}
}
if (child.each && child.with) throw Error(`${name}.${child.name}: cannot combine each and with`);
if (child.each && (!Number.isSafeInteger(child.each.limit) || child.each.limit < 1))
throw Error(`${name}.${child.name}: finite positive collection limit required`);
return {
...child,
subject: imported(child.subject),
...(role ? { role } : {}),
};
});
const trees = declaration.trees ?? [];
if (!Array.isArray(trees) || trees.length > 16) throw Error(`${name}: trees must be a bounded array`);
const treeNames = new Set<string>();
const preparedTrees = trees.map((value) => {
const tree = object(value, `${name} tree`) as unknown as NonNullable<ReactModulePreparation["trees"]>[number];
if (
Object.keys(tree).some((key) => !["name", "query", "root", "arguments"].includes(key)) ||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tree.name) ||
treeNames.has(tree.name)
)
throw Error(`${name}: tree requires a unique name and checked root/arguments`);
treeNames.add(tree.name);
const query = pkg.queries?.find((entry) => entry.displayName === tree.query);
if (!query || query.placement?.kind !== "replica")
throw Error(`${name}.${tree.name}: requires a replica Query Tree`);
return { ...tree, query: query.id };
});
artifact.modules.push({
module: module.id,
composition,
children: parsed,
...(preparedTrees.length ? { trees: preparedTrees } : {}),
});
}
for (const module of pkg.modules ?? []) {
const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
const member = contract?.members.find((entry) => entry.id === module.memberId);
if (
member?.kind === "module" &&
member.contract.startsWith("org.quixos.react.") &&
!artifact.modules.some((entry) => entry.module === module.id)
)
throw Error(`React module ${module.displayName} has no preparation declaration`);
}
return { compositions, artifact };
}