84 lines
4.2 KiB
TypeScript
84 lines
4.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|||
|
|
import type { InterfaceRevision, PackageRevision, ValueType } from "../capability-model/types.js";
|
||
|
|
import { defaultQueryBudgets, type QueryDeclaration } from "../query/types.js";
|
||
|
|
|
||
|
|
/** React contract tooling generates ordinary queries before normal checking.
|
||
|
|
* Neither the query language nor its execution planner knows presentation IDs. */
|
||
|
|
export function presentationQueries(pkg: PackageRevision, interfaces: readonly InterfaceRevision[]) {
|
||
|
|
const documents = new Map<string, string>();
|
||
|
|
const generated: QueryDeclaration[] = [];
|
||
|
|
const contracts = new Map(interfaces.map((contract) => [contract.revisionId, contract]));
|
||
|
|
const navigation = new Set<string>();
|
||
|
|
for (const module of pkg.modules ?? []) {
|
||
|
|
const contract = contracts.get(module.interfaceRevisionId);
|
||
|
|
const member = contract?.members.find((member) => member.id === module.memberId);
|
||
|
|
if (member?.kind !== "module" || member.contract !== "org.quixos.react.component/2") continue;
|
||
|
|
const argument = contract?.application?.arguments[1];
|
||
|
|
if (argument?.kind !== "object" || argument.target.kind !== "interface")
|
||
|
|
throw Error("Stateful React presentations require a closed navigation interface");
|
||
|
|
navigation.add(argument.target.interfaceRevisionId);
|
||
|
|
}
|
||
|
|
const valueSelection = (type: ValueType): string => {
|
||
|
|
if (type.kind === "optional" || type.kind === "list") return valueSelection(type.value);
|
||
|
|
return type.kind === "record"
|
||
|
|
? `{${Object.entries(type.fields)
|
||
|
|
.map(([name, type]) => `${name} ${valueSelection(type)}`)
|
||
|
|
.join(" ")}}`
|
||
|
|
: "";
|
||
|
|
};
|
||
|
|
for (const id of [...navigation].sort()) {
|
||
|
|
const contract = contracts.get(id as InterfaceRevision["revisionId"]);
|
||
|
|
if (!contract || contract.template) throw Error(`Missing closed presentation navigation ${id}`);
|
||
|
|
const fields = contract.members.flatMap((member) => {
|
||
|
|
if ((member.kind !== "value" && member.kind !== "relationship") || !member.queryRead) return [];
|
||
|
|
if (member.kind === "value") return [`${member.displayName} ${valueSelection(member.valueType)}`];
|
||
|
|
if (member.target.kind !== "interface")
|
||
|
|
throw Error(`Navigation relationship ${member.displayName} must declare an interface view`);
|
||
|
|
return [
|
||
|
|
member.cardinality === "many" || member.cardinality === "many-unique"
|
||
|
|
? `${member.displayName}(all:512) {entries {node {_camino {ref}}}}`
|
||
|
|
: `${member.displayName} {_camino {ref}}`,
|
||
|
|
];
|
||
|
|
});
|
||
|
|
const stem = `PresentationNavigation${createHash("sha256").update(id).digest("hex").slice(0, 12)}`;
|
||
|
|
const inputs = `presentation:navigation:${id}:inputs`;
|
||
|
|
for (const local of [false, true]) {
|
||
|
|
const name = `${stem}${local ? "View" : "Inputs"}`;
|
||
|
|
const document = `generated/${name}.graphql`;
|
||
|
|
documents.set(
|
||
|
|
document,
|
||
|
|
`query ${name}($presentationMount: QxRef_PresentationMount!) {
|
||
|
|
root { ${local ? "availableObjects" : "objects"}(all:512, where:{_camino:{as:{PresentationState:{presentationMount:{eq:$presentationMount}}}}}) {
|
||
|
|
entries { node { ${fields.join("\n")}
|
||
|
|
... on PresentationState {
|
||
|
|
_presentationOccurrence: occurrence
|
||
|
|
_presentationSource: source {_camino {ref}}
|
||
|
|
_presentationParent: parent {_camino {ref}}
|
||
|
|
_presentationMount: presentationMount {_camino {ref}}
|
||
|
|
}
|
||
|
|
} }
|
||
|
|
} }
|
||
|
|
}`,
|
||
|
|
);
|
||
|
|
generated.push({
|
||
|
|
id: local ? `presentation:navigation:${id}:view` : inputs,
|
||
|
|
displayName: name,
|
||
|
|
root: { kind: "workspace-interface", interface: contract.revisionId },
|
||
|
|
document,
|
||
|
|
operation: name,
|
||
|
|
fragments: [],
|
||
|
|
views: [],
|
||
|
|
allowances: [],
|
||
|
|
budgets: { ...defaultQueryBudgets, rows: 5000, candidates: 5000, resultBytes: 8388608 },
|
||
|
|
watch: local,
|
||
|
|
placement: local ? { kind: "local", sourceQueryId: inputs } : { kind: "replica" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const query of generated)
|
||
|
|
if (pkg.queries?.some((existing) => existing.id === query.id || existing.displayName === query.displayName))
|
||
|
|
throw Error(`Presentation query conflicts with an authored query: ${query.id}`);
|
||
|
|
pkg.queries = [...(pkg.queries ?? []), ...generated];
|
||
|
|
return documents;
|
||
|
|
}
|