2026-09-27 16:34:53 -07:00
|
|
|
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;
|
2026-09-30 22:03:17 -07:00
|
|
|
/** Public input type before workspace query lowering adds private projections. */
|
|
|
|
|
input?: ValueType;
|
2026-09-27 16:34:53 -07:00
|
|
|
children: ReactChildDeclaration[];
|
|
|
|
|
}
|
|
|
|
|
export interface ReactPreparationArtifact {
|
2026-09-30 22:03:17 -07:00
|
|
|
/** Frontend queries belong to installed preparation, not the authored client API. */
|
|
|
|
|
internalQueries?: string[];
|
2026-09-27 16:34:53 -07:00
|
|
|
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"].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);
|
2026-09-28 21:05:55 -07:00
|
|
|
// 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) };
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-27 16:34:53 -07:00
|
|
|
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),
|
2026-09-28 21:05:55 -07:00
|
|
|
...(role ? { role } : {}),
|
2026-09-27 16:34:53 -07:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
artifact.modules.push({ module: module.id, composition, children: parsed });
|
|
|
|
|
}
|
|
|
|
|
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 };
|
|
|
|
|
}
|