2026-09-27 15:53:34 -07:00
|
|
|
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 =
|
2026-09-27 16:34:53 -07:00
|
|
|
| { flatMap: { source: QueryBinding; path: string[] } }
|
2026-09-27 15:53:34 -07:00
|
|
|
| { literal: string | boolean }
|
|
|
|
|
| { equal: [QueryBinding, QueryBinding] }
|
|
|
|
|
| { from: "input"; path: string[] }
|
|
|
|
|
| { from: "item"; path: string[] }
|
|
|
|
|
| { from: "result"; name: string; path: string[] }
|
|
|
|
|
| { record: Record<string, QueryBinding> };
|
|
|
|
|
|
|
|
|
|
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<string, CheckedQuery>,
|
|
|
|
|
): 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<string, ValueType>();
|
|
|
|
|
const dependencies = new Map<string, string>();
|
|
|
|
|
let count = 0;
|
2026-09-27 16:34:53 -07:00
|
|
|
const binding = (expression: QueryBinding, item?: ValueType) =>
|
|
|
|
|
queryBindingType(expression, source.input, outputs, item);
|
2026-09-27 15:53:34 -07:00
|
|
|
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") };
|
|
|
|
|
}
|
2026-09-27 16:34:53 -07:00
|
|
|
|
|
|
|
|
export function queryBindingType(
|
|
|
|
|
expression: QueryBinding,
|
|
|
|
|
input: ValueType,
|
|
|
|
|
outputs: ReadonlyMap<string, ValueType>,
|
|
|
|
|
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;
|
|
|
|
|
}
|