421 lines
19 KiB
TypeScript
421 lines
19 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
parse,
|
|
print,
|
|
visit,
|
|
Kind,
|
|
type SelectionSetNode,
|
|
type FragmentDefinitionNode,
|
|
type SelectionNode,
|
|
type DocumentNode,
|
|
} from "graphql";
|
|
import type { PackageRevision, WorkspaceRevision } from "../capability-model/types.js";
|
|
import { compileQuery } from "../query/compile.js";
|
|
import { compileQueryComposition, type QueryBinding } from "../query/composition.js";
|
|
import type { CheckedQuery, QueryDeclaration } from "../query/types.js";
|
|
import { preparationArtifact, type ReactModulePreparation, type ReactPreparationArtifact } from "./declarations.js";
|
|
|
|
interface Branch {
|
|
packageRevision: string;
|
|
id: string;
|
|
}
|
|
type Child = ReactModulePreparation["children"][number] & { branches: Branch[] };
|
|
interface Module {
|
|
pkg: PackageRevision;
|
|
preparation: ReactModulePreparation;
|
|
}
|
|
const identity = (pkg: string, id: string) => JSON.stringify([pkg, id]);
|
|
const suffix = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 20);
|
|
|
|
/** Lower bounded, statically bound subject projections into ordinary fragments.
|
|
* The generated query still passes the normal GraphQL/QX checker. Stateful
|
|
* lookups remain independent ordinary root-scoped queries owned by the adapter. */
|
|
export async function expandPresentationQueries(workspace: WorkspaceRevision): Promise<void> {
|
|
const modules = new Map<string, Module>();
|
|
for (const pkg of workspace.packageImports) {
|
|
const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined;
|
|
for (const preparation of artifact?.modules ?? [])
|
|
modules.set(identity(pkg.revisionId, preparation.module), { pkg, preparation });
|
|
}
|
|
const query = (pkg: string, id: string) => {
|
|
const result = workspace.packageImports
|
|
.find((entry) => entry.revisionId === pkg)
|
|
?.checkedQueries?.find((entry) => entry.declaration.id === id);
|
|
if (!result) throw Error(`Missing checked preparation query ${pkg}/${id}`);
|
|
return result;
|
|
};
|
|
const resolver = async (pkg: string, id: string) => query(pkg, id);
|
|
const install = async (pkg: PackageRevision, declaration: QueryDeclaration, document: string) => {
|
|
const views = new Map(declaration.views.map((view) => [view.atomId, view]));
|
|
const allowances = [...declaration.allowances];
|
|
for (const imported of declaration.importedFragments ?? []) {
|
|
const dependency = query(imported.packageRevisionId, imported.fragmentId).declaration;
|
|
for (const view of dependency.views) {
|
|
const previous = views.get(view.atomId);
|
|
if (previous && previous.interfaceRevisionId !== view.interfaceRevisionId)
|
|
throw Error(`${declaration.displayName}: conflicting interface views for ${view.atomId}`);
|
|
views.set(view.atomId, view);
|
|
}
|
|
for (const allowance of dependency.allowances)
|
|
if (!allowances.some((entry) => JSON.stringify(entry) === JSON.stringify(allowance)))
|
|
allowances.push(allowance);
|
|
}
|
|
declaration = { ...declaration, views: [...views.values()], allowances };
|
|
const checked = await compileQuery(declaration, workspace.interfaceImports, async () => document, resolver);
|
|
pkg.queries = [...(pkg.queries ?? []).filter((entry) => entry.id !== declaration.id), declaration];
|
|
pkg.checkedQueries = [
|
|
...(pkg.checkedQueries ?? []).filter((entry) => entry.declaration.id !== declaration.id),
|
|
checked,
|
|
];
|
|
return checked;
|
|
};
|
|
function expanded(source: CheckedQuery): SelectionSetNode {
|
|
const document = parse(source.document);
|
|
const fragments = new Map(
|
|
document.definitions
|
|
.filter((entry): entry is FragmentDefinitionNode => entry.kind === Kind.FRAGMENT_DEFINITION)
|
|
.map((entry) => [entry.name.value, entry]),
|
|
);
|
|
const operation = document.definitions.find((entry) => entry.kind === Kind.OPERATION_DEFINITION);
|
|
if (!operation || operation.kind !== Kind.OPERATION_DEFINITION)
|
|
throw Error(`Missing query operation ${source.declaration.id}`);
|
|
const expand = (selection: SelectionSetNode, parents: string[] = []): SelectionSetNode => ({
|
|
...selection,
|
|
selections: selection.selections.flatMap((entry): SelectionNode[] => {
|
|
if (entry.kind === Kind.FRAGMENT_SPREAD) {
|
|
if (parents.includes(entry.name.value)) throw Error("Cyclic query fragment");
|
|
const fragment = fragments.get(entry.name.value);
|
|
if (!fragment) throw Error(`Missing query fragment ${entry.name.value}`);
|
|
return [
|
|
{
|
|
kind: Kind.INLINE_FRAGMENT,
|
|
typeCondition: fragment.typeCondition,
|
|
directives: entry.directives ?? [],
|
|
selectionSet: expand(fragment.selectionSet, [...parents, entry.name.value]),
|
|
},
|
|
];
|
|
}
|
|
return [{ ...entry, ...(entry.selectionSet ? { selectionSet: expand(entry.selectionSet, parents) } : {}) }];
|
|
}),
|
|
});
|
|
return expand(operation.selectionSet);
|
|
}
|
|
function insert(selection: SelectionSetNode, path: readonly string[], spread: string): SelectionSetNode {
|
|
if (!path.length)
|
|
return {
|
|
...selection,
|
|
selections: [...selection.selections, { kind: Kind.FRAGMENT_SPREAD, name: { kind: Kind.NAME, value: spread } }],
|
|
};
|
|
let found = false;
|
|
const selections = selection.selections.map((entry): SelectionNode => {
|
|
if (entry.kind === Kind.INLINE_FRAGMENT) {
|
|
try {
|
|
const nested = insert(entry.selectionSet, path, spread);
|
|
found = true;
|
|
return { ...entry, selectionSet: nested };
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || error.message !== "REACT_SELECTION_PATH") throw error;
|
|
}
|
|
}
|
|
if (entry.kind === Kind.FIELD && (entry.alias?.value ?? entry.name.value) === path[0] && entry.selectionSet) {
|
|
found = true;
|
|
return { ...entry, selectionSet: insert(entry.selectionSet, path.slice(1), spread) };
|
|
}
|
|
return entry;
|
|
});
|
|
if (!found) throw Error("REACT_SELECTION_PATH");
|
|
return { ...selection, selections };
|
|
}
|
|
function location(binding: QueryBinding): { name: string; path: string[] } | undefined {
|
|
if ("from" in binding && binding.from === "result") return { name: binding.name, path: [...binding.path] };
|
|
if ("flatMap" in binding) {
|
|
const root = location(binding.flatMap.source);
|
|
return root ? { name: root.name, path: [...root.path, ...binding.flatMap.path] } : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
const done = new Set<string>();
|
|
const eager = new Map<string, { step: string; query: CheckedQuery }[]>();
|
|
const admissionFragments = new Map<string, CheckedQuery>();
|
|
const stateInputs = new Map<string, { pkg: PackageRevision; query: CheckedQuery }[]>();
|
|
async function expandModule(key: string): Promise<void> {
|
|
if (done.has(key)) return;
|
|
const module = modules.get(key);
|
|
if (!module) throw Error(`Missing preparation module ${key}`);
|
|
const { pkg, preparation } = module;
|
|
const children = preparation.children as Child[];
|
|
for (const child of children)
|
|
for (const branch of child.branches) await expandModule(identity(branch.packageRevision, branch.id));
|
|
const composition = pkg.queryCompositions!.find((entry) => entry.id === preparation.composition)!;
|
|
const discovery = { ...composition, id: composition.id + ":discovery" };
|
|
preparation.discoveryComposition = discovery.id;
|
|
pkg.queryCompositions!.push(discovery);
|
|
const steps = structuredClone(composition.steps);
|
|
const exported: { step: string; query: CheckedQuery }[] = [];
|
|
for (const step of steps) {
|
|
const original = query(pkg.revisionId, step.query);
|
|
// Variable roots/arguments remain checked dependent steps. Constants can
|
|
// be substituted without requesting new runtime parameters from a parent.
|
|
if (!("record" in step.arguments) || Object.values(step.arguments.record).some((value) => !("literal" in value)))
|
|
continue;
|
|
const constants = step.arguments.record;
|
|
let selection = expanded(original);
|
|
if (original.declaration.placement?.kind !== "local")
|
|
throw Error(key + "." + step.name + ": expected checked local input");
|
|
const source = query(pkg.revisionId, original.declaration.placement.sourceQueryId);
|
|
let admissionSelection = expanded(source);
|
|
const admissionImports: NonNullable<QueryDeclaration["importedFragments"]> = [];
|
|
const imports: NonNullable<QueryDeclaration["importedFragments"]> = [];
|
|
for (const child of children) {
|
|
const selected = child.with
|
|
? location(child.with)
|
|
: child.each
|
|
? location(child.each.source)
|
|
: location(child.root);
|
|
if (!selected || selected.name !== step.name) continue;
|
|
const rootPath = "from" in child.root && child.root.from === "item" ? child.root.path : [];
|
|
const path = [...selected.path, ...rootPath];
|
|
if (path.slice(-2).join(".") !== "_camino.ref") {
|
|
if (!rootPath.length && (child.with || child.each))
|
|
throw Error(`${key}.${child.name}: child root must select the item's reference`);
|
|
} else path.splice(-2);
|
|
for (const branch of child.branches) {
|
|
for (const input of eager.get(identity(branch.packageRevision, branch.id)) ?? []) {
|
|
const declaration = input.query.declaration;
|
|
if (
|
|
!imports.some(
|
|
(entry) => entry.packageRevisionId === branch.packageRevision && entry.fragmentId === declaration.id,
|
|
)
|
|
) {
|
|
imports.push({ packageRevisionId: branch.packageRevision, fragmentId: declaration.id, variables: {} });
|
|
}
|
|
selection = insert(selection, path, declaration.fragment!.name);
|
|
const admission = admissionFragments.get(identity(branch.packageRevision, declaration.id))!;
|
|
if (
|
|
!admissionImports.some(
|
|
(entry) =>
|
|
entry.packageRevisionId === branch.packageRevision && entry.fragmentId === admission.declaration.id,
|
|
)
|
|
)
|
|
admissionImports.push({
|
|
packageRevisionId: branch.packageRevision,
|
|
fragmentId: admission.declaration.id,
|
|
variables: {},
|
|
});
|
|
admissionSelection = insert(admissionSelection, path, admission.declaration.fragment!.name);
|
|
}
|
|
}
|
|
}
|
|
const token = suffix(identity(pkg.revisionId, `${preparation.module}/${step.name}`));
|
|
const name = `ReactInput${token}`,
|
|
id = `react:input:${preparation.module}:${step.name}`;
|
|
const base: QueryDeclaration = {
|
|
...original.declaration,
|
|
id,
|
|
displayName: name,
|
|
document: `generated/react/${name}.graphql`,
|
|
operation: name,
|
|
fragments: [],
|
|
importedFragments: imports,
|
|
watch: true,
|
|
budgets: {
|
|
...original.declaration.budgets,
|
|
depth: Math.max(original.declaration.budgets.depth, 32),
|
|
rows: Math.max(original.declaration.budgets.rows, 5000),
|
|
},
|
|
};
|
|
delete base.fragment;
|
|
function documentFor(selection: SelectionSetNode, fragmentName: string): DocumentNode {
|
|
const substituted = visit(selection, {
|
|
Variable(node) {
|
|
const constant = constants[node.name.value];
|
|
if (!constant || !("literal" in constant))
|
|
throw Error(key + "." + step.name + ": unbound query variable " + node.name.value);
|
|
return typeof constant.literal === "boolean"
|
|
? { kind: Kind.BOOLEAN, value: constant.literal }
|
|
: { kind: Kind.STRING, value: constant.literal };
|
|
},
|
|
});
|
|
if (original.declaration.root.kind === "object") {
|
|
const root = substituted.selections.find((entry) => entry.kind === Kind.FIELD && entry.name.value === "root");
|
|
if (!root || root.kind !== Kind.FIELD || !root.selectionSet)
|
|
throw Error(key + ": expected object-rooted query");
|
|
const contract = workspace.interfaceImports.find(
|
|
(entry) => entry.revisionId === original.declaration.root.interface,
|
|
)!;
|
|
return {
|
|
kind: Kind.DOCUMENT,
|
|
definitions: [
|
|
{
|
|
kind: Kind.FRAGMENT_DEFINITION,
|
|
name: { kind: Kind.NAME, value: fragmentName },
|
|
typeCondition: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: contract.displayName } },
|
|
selectionSet: root.selectionSet,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
kind: Kind.DOCUMENT,
|
|
definitions: [
|
|
{
|
|
kind: Kind.OPERATION_DEFINITION,
|
|
operation: "query" as import("graphql").OperationTypeNode,
|
|
name: { kind: Kind.NAME, value: name },
|
|
selectionSet: substituted,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (original.declaration.root.kind === "object") base.fragment = { name };
|
|
const sourceId = id + ":fragment-inputs";
|
|
const admissionName = name + "Admission";
|
|
const admissionBase: QueryDeclaration = {
|
|
...base,
|
|
views: source.declaration.views,
|
|
allowances: source.declaration.allowances,
|
|
importedFragments: admissionImports,
|
|
...(base.fragment ? { fragment: { name: admissionName } } : {}),
|
|
};
|
|
const admissionDocument = documentFor(admissionSelection, admissionName);
|
|
await install(
|
|
pkg,
|
|
{
|
|
...admissionBase,
|
|
id: sourceId,
|
|
displayName: name + "$inputs",
|
|
placement: { kind: "replica" },
|
|
watch: false,
|
|
...(base.fragment ? { fragment: { name: admissionName, admission: true } } : {}),
|
|
},
|
|
print(admissionDocument),
|
|
);
|
|
const admissionFragment = await install(
|
|
pkg,
|
|
{
|
|
...admissionBase,
|
|
id: id + ":admission-projection",
|
|
displayName: name + "$admission",
|
|
placement: { kind: "local", sourceQueryId: sourceId },
|
|
},
|
|
print(admissionDocument),
|
|
);
|
|
const local = await install(
|
|
pkg,
|
|
{ ...base, placement: { kind: "local", sourceQueryId: sourceId } },
|
|
print(documentFor(selection, name)),
|
|
);
|
|
admissionFragments.set(identity(pkg.revisionId, local.declaration.id), admissionFragment);
|
|
step.query = local.declaration.id;
|
|
step.arguments = { record: {} };
|
|
if (
|
|
local.declaration.fragment &&
|
|
step.root &&
|
|
"from" in step.root &&
|
|
step.root.from === "input" &&
|
|
step.root.path.join(".") === "subject"
|
|
)
|
|
exported.push({ step: step.name, query: local });
|
|
if (
|
|
local.declaration.fragment &&
|
|
step.root &&
|
|
"from" in step.root &&
|
|
step.root.from === "input" &&
|
|
step.root.path.join(".") === "navigation"
|
|
) {
|
|
const iface = local.declaration.root.interface;
|
|
stateInputs.set(iface, [...(stateInputs.get(iface) ?? []), { pkg, query: local }]);
|
|
}
|
|
}
|
|
const available = new Map(
|
|
workspace.packageImports.flatMap((pkg) =>
|
|
(pkg.checkedQueries ?? []).map((query) => [identity(pkg.revisionId, query.declaration.id), query] as const),
|
|
),
|
|
);
|
|
for (const query of pkg.checkedQueries ?? []) available.set(query.declaration.id, query);
|
|
const checked = compileQueryComposition(
|
|
{ id: composition.id, input: composition.input, steps, maxQueries: composition.maxQueries },
|
|
available,
|
|
);
|
|
const checkedDiscovery = compileQueryComposition(
|
|
{ id: discovery.id, input: discovery.input, steps: discovery.steps, maxQueries: discovery.maxQueries },
|
|
available,
|
|
);
|
|
pkg.queryCompositions = pkg.queryCompositions!.map((entry) =>
|
|
entry.id === composition.id ? checked : entry.id === discovery.id ? checkedDiscovery : entry,
|
|
);
|
|
eager.set(key, exported);
|
|
done.add(key);
|
|
}
|
|
for (const key of modules.keys()) await expandModule(key);
|
|
// Retention queries are ordinary checked queries too. They carry the declared
|
|
// state projections, including any subject fragments reached through state.
|
|
// This is specific to this frontend; no navigation operator enters Camino.
|
|
for (const pkg of workspace.packageImports) {
|
|
for (const original of [...(pkg.checkedQueries ?? [])]) {
|
|
if (!original.declaration.id.startsWith("presentation:navigation:")) continue;
|
|
const inputs = (stateInputs.get(original.declaration.root.interface) ?? []).map((input) => ({
|
|
...input,
|
|
query:
|
|
original.declaration.placement?.kind === "replica"
|
|
? admissionFragments.get(identity(input.pkg.revisionId, input.query.declaration.id))!
|
|
: input.query,
|
|
}));
|
|
if (!inputs.length) continue;
|
|
const document = parse(original.document);
|
|
const collection = original.declaration.placement?.kind === "local" ? "availableObjects" : "objects";
|
|
const definitions = document.definitions.map((definition) => {
|
|
if (definition.kind !== Kind.OPERATION_DEFINITION) return definition;
|
|
return visit(definition, {
|
|
Field(node) {
|
|
if (node.name.value !== "root" || !node.selectionSet) return;
|
|
const source = node.selectionSet.selections.find(
|
|
(entry) => entry.kind === Kind.FIELD && entry.name.value === collection,
|
|
);
|
|
if (!source || source.kind !== Kind.FIELD) throw Error("Missing navigation collection");
|
|
return {
|
|
...node,
|
|
selectionSet: {
|
|
...node.selectionSet,
|
|
selections: [
|
|
...node.selectionSet.selections,
|
|
...inputs.map((input) => ({
|
|
...source,
|
|
alias: {
|
|
kind: Kind.NAME as const,
|
|
value: `prepared${suffix(identity(input.pkg.revisionId, input.query.declaration.id))}`,
|
|
},
|
|
selectionSet: parse(
|
|
`{ entries { node { ...${input.query.declaration.fragment!.name} } } }`,
|
|
).definitions.flatMap((entry) =>
|
|
entry.kind === Kind.OPERATION_DEFINITION ? [entry.selectionSet] : [],
|
|
)[0]!,
|
|
})),
|
|
],
|
|
},
|
|
};
|
|
},
|
|
});
|
|
});
|
|
await install(
|
|
pkg,
|
|
{
|
|
...original.declaration,
|
|
...(original.declaration.placement?.kind === "local"
|
|
? {
|
|
id: original.declaration.id.replace(/:view$/, ":prepared-view"),
|
|
displayName: original.declaration.displayName + "Prepared",
|
|
}
|
|
: {}),
|
|
importedFragments: inputs.map((input) => ({
|
|
packageRevisionId: input.pkg.revisionId,
|
|
fragmentId: input.query.declaration.id,
|
|
variables: {},
|
|
})),
|
|
},
|
|
print({ ...document, definitions }),
|
|
);
|
|
}
|
|
}
|
|
}
|