import { createHash } from "node:crypto"; import type { ValueType } from "../capability-model/types.js"; import { canonicalJson } from "../capability-model/evolution.js"; import { QueryCompileError, type CheckedQuery } from "./types.js"; /** Bindings refer to checked values, never executable callbacks. A path cannot * silently cross a nullable value or expand an unbounded collection. */ export type QueryBinding = | { flatMap: { source: QueryBinding; path: string[] } } | { literal: string | boolean } | { equal: [QueryBinding, QueryBinding] } | { from: "input"; path: string[] } | { from: "item"; path: string[] } | { from: "result"; name: string; path: string[] } | { record: Record }; export interface QueryCompositionStep { name: string; query: string; root: QueryBinding | null; arguments: QueryBinding; /** Null roots skip this step and produce null, rather than querying an * arbitrary object or treating unavailable query inputs as absence. */ optional?: boolean; when?: QueryBinding; /** An optional object is unwrapped as item; absence skips the query. */ with?: QueryBinding; each?: { source: QueryBinding; limit: number }; } export interface QueryComposition { id: string; input: ValueType; steps: QueryCompositionStep[]; maxQueries: number; } export interface CheckedQueryComposition extends QueryComposition { output: ValueType; definitionDigest: string; dependencies: { query: string; digest: string }[]; } /** Checks a finite dependency graph. Query identities are resolved by the * caller's package/workspace scope; no presentation concepts participate. */ export function compileQueryComposition( source: QueryComposition, queries: ReadonlyMap, ): CheckedQueryComposition { const fail = (message: string): never => { throw new QueryCompileError("QUERY_COMPOSITION", `${source.id}: ${message}`); }; if (!Number.isSafeInteger(source.maxQueries) || source.maxQueries < 1) fail("maxQueries must be a positive integer"); if (source.input.kind !== "record") fail("input must be a record"); const outputs = new Map(); const dependencies = new Map(); let count = 0; const binding = (expression: QueryBinding, item?: ValueType) => queryBindingType(expression, source.input, outputs, item); const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b); for (const step of source.steps) { if (!step.name || outputs.has(step.name)) fail(`duplicate or empty step name ${step.name}`); const query = queries.get(step.query); if (!query) return fail(`unknown query ${step.query}`); let item: ValueType | undefined; if (step.with) { if (step.each) fail(`${step.name} cannot combine with and each`); const value = binding(step.with); item = value.kind === "optional" ? value.value : value; } if (step.each) { const collection = binding(step.each.source); if (collection.kind !== "list") return fail(`${step.name}.each requires a list`); if (!Number.isSafeInteger(step.each.limit) || step.each.limit < 1) fail(`${step.name}.each.limit must be a positive integer`); item = collection.value; } count += step.each?.limit ?? 1; if (!Number.isSafeInteger(count) || count > source.maxQueries) fail("declared steps exceed maxQueries"); if (step.when && !equal(binding(step.when, item), { kind: "scalar", name: "bool" })) fail(`${step.name}.when requires a boolean`); if (query.declaration.root.kind === "workspace-interface") { if (step.root !== null) fail(`${step.name} workspace query takes no object root`); } else { if (step.root === null) return fail(`${step.name} requires an object root`); let root = binding(step.root, item); if (root.kind === "optional" && step.optional) root = root.value; const expected: ValueType = { kind: "object-ref", expectation: { kind: "interface", interfaceRevisionId: query.declaration.root.interface, }, }; if (!equal(root, expected)) fail(`${step.name} root must be a reference to ${query.declaration.root.interface}`); } if (!equal(binding(step.arguments, item), query.variables)) fail(`${step.name} arguments do not match ${step.query}`); let output = query.output; if (step.optional || step.with || step.when) output = { kind: "optional", value: output }; if (step.each) output = { kind: "list", value: output }; outputs.set(step.name, output); dependencies.set(step.query, query.definitionDigest); } const result = { ...structuredClone(source), output: { kind: "record" as const, fields: Object.fromEntries(outputs) }, dependencies: [...dependencies].map(([query, digest]) => ({ query, digest })), }; return { ...result, definitionDigest: createHash("sha256").update(canonicalJson(result)).digest("hex") }; } export function queryBindingType( expression: QueryBinding, input: ValueType, outputs: ReadonlyMap, item?: ValueType, ): ValueType { const fail = (message: string): never => { throw new QueryCompileError("QUERY_COMPOSITION", message); }; const binding = (expression: QueryBinding, item?: ValueType) => queryBindingType(expression, input, outputs, item); const equal = (a: ValueType, b: ValueType) => canonicalJson(a) === canonicalJson(b); if ("flatMap" in expression) { const rows = binding(expression.flatMap.source, item); if (rows.kind !== "list") return fail("flatMap requires a list"); const children = binding({ from: "item", path: expression.flatMap.path }, rows.value); if (children.kind !== "list") return fail("flatMap path requires a list"); return children; } if ("literal" in expression) return { kind: "scalar", name: typeof expression.literal === "string" ? "string" : "bool" }; if ("equal" in expression) { const left = binding(expression.equal[0], item); const right = binding(expression.equal[1], item); if (left.kind !== "scalar" || !equal(left, right)) fail("equal requires matching scalar operands"); return { kind: "scalar", name: "bool" }; } if ("record" in expression) return { kind: "record", fields: Object.fromEntries(Object.entries(expression.record).map(([key, value]) => [key, binding(value, item)])), }; let type = expression.from === "input" ? input : expression.from === "item" ? item : outputs.get(expression.name); if (!type) return fail( expression.from === "result" ? `result ${expression.name} must name an earlier step (forward references and cycles are unsupported)` : "item is only available within a bounded each step", ); for (const key of expression.path) { if (type.kind !== "record" || !Object.hasOwn(type.fields, key)) return fail(`cannot select ${key} from ${type.kind}; nulls and collections need an explicit boundary`); type = type.fields[key]!; } return type; }