import { parse, Source, validate, specifiedRules, print, printSchema, visit, TypeInfo, visitWithTypeInfo, getNamedType, isNonNullType, isListType, isObjectType, isInputObjectType, isScalarType, isEnumType, typeFromAST, type GraphQLType, type SelectionSetNode, type FragmentDefinitionNode, type ValueNode, type ASTNode, type DocumentNode, type DirectiveNode, } from "graphql"; import { createHash } from "node:crypto"; import { canonicalJson } from "../capability-model/evolution.js"; import { readRepositorySource } from "../capability-language/source-loader.js"; import { valueType, type InterfaceRevision, type ValueType, type InterfaceRevisionId, } from "../capability-model/types.js"; import { querySchema } from "./schema.js"; import { objectRow } from "./relational-schema.js"; import { relationalCompiler } from "./relational-compile.js"; import { QueryCompileError, type QueryDeclaration, type CheckedQuery, type QueryFieldEffect, type QueryUse, type QueryArgument, type QuerySelection, } from "./types.js"; const fail = (code: string, message: string, node?: ASTNode): never => { const token = node?.loc?.startToken; throw new QueryCompileError( code, message, token ? { file: node!.loc!.source.name, line: token.line, column: token.column, } : undefined, ); }; /** Only immutable repository sources call this. Runtime requests never compile documents. */ export async function compileQuery( declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[], read: (name: string) => Promise, ): Promise { const paths = [declaration.document, ...declaration.fragments]; if (paths.length > 128 || new Set(paths).size !== paths.length) fail("QUERY_SOURCE_LIMIT", "Query files must be distinct and bounded to 128"); const definitions: DocumentNode["definitions"][number][] = []; let totalBytes = 0; for (const path of paths) { if ( !path.endsWith(".graphql") || path.startsWith("/") || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..") ) fail("QUERY_SOURCE_PATH", `Invalid query source path ${path}`); const text = await read(path); totalBytes += Buffer.byteLength(text); if (totalBytes > 262144) fail("QUERY_SOURCE_LIMIT", "Query sources exceed 256 KiB"); const document = parse(new Source(text, path), { maxTokens: 10000 }); definitions.push(...document.definitions); } const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions }; const generated = querySchema(declaration, interfaces, document); const operations = definitions.filter((entry) => entry.kind === "OperationDefinition"); if ( operations.length !== 1 || operations[0]!.operation !== "query" || operations[0]!.name?.value !== declaration.operation ) fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required"); visit(document, { Field(node) { if (node.name.value === "_unavailable") fail("QUERY_AGGREGATE_TYPE", "No supported field exists at this operator path", node); if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node); }, Directive(node) { if (!["include", "skip", "live"].includes(node.name.value)) fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node); }, InlineFragment(node) { fail("QUERY_UNSUPPORTED_FEATURE", "Use named fragments on the exact selected type", node); }, }); const errors = validate(generated.schema, document, specifiedRules, { maxErrors: 20 }); if (errors.length) { const error = errors[0]!; fail("QUERY_VALIDATION", error.message, error.nodes?.[0]); } const effects = new Map(); const fragments = new Map( definitions .filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition") .map((entry) => [entry.name.value, entry]), ); const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => { const contract = generated.contracts.get(id)!; const member = contract.members.find((entry) => entry.displayName === name); if (!member || member.kind === "operation" || !member.queryRead) return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node); const key = `${id}\0${member.id}`; const effect = effects.get(key) ?? { interfaceRevisionId: id, memberId: member.id, uses: [], execution: member.queryRead.execution, }; if (!effect.uses.includes(use)) effect.uses.push(use); effects.set(key, effect); if ( effect.execution === "rpc-permitted" && !declaration.allowances.some( (allow) => allow.interfaceRevisionId === id && allow.memberId === member.id && allow.uses.includes(use) && allow.reason.trim(), ) ) fail("QUERY_RPC_CONSUMER_REASON", `${contract.displayName}.${name} needs an allowance for ${use}`, node); if ( declaration.watch && effect.execution === "rpc-permitted" && !declaration.polling && !member.operations.some((op) => op.displayName === "watch-start") ) fail( "QUERY_WATCH_UNSUPPORTED", `${contract.displayName}.${name} has no watch; explicitly acknowledge polling or use a one-shot query`, node, ); }; const relational = relationalCompiler(generated, declaration, mark, fragments); const compiledPredicates = new WeakMap(); const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => { if (use === "predicate") { relational.predicate(objectRow(id), value); return; } if (value.kind === "Variable") { // A dynamic filter may name any field in its declared input type. for (const member of generated.contracts.get(id)!.members) if (member.kind === "value" && member.queryRead) mark(id, member.displayName, use, value); } else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use)); else if (value.kind === "ObjectValue") for (const field of value.fields) { mark(id, field.name.value, use, field); } }; const info = new TypeInfo(generated.schema); visit( document, visitWithTypeInfo(info, { Field(node) { const parent = info.getParentType(); const contract = parent && generated.byName.get(parent.name); if (node.directives?.some((directive) => directive.name.value === "live")) { const member = contract?.members.find((entry) => entry.displayName === node.name.value); if (!member || member.kind !== "value" || member.queryRead?.execution !== "native") fail( "QUERY_LIVE_UNSUPPORTED", "@live requires a direct native-queryable value field, not RPC enrichment or a synthetic result", node, ); } if (!contract || node.name.value === "_qx") return; mark(contract.revisionId, node.name.value, "select", node); const member = contract.members.find((entry) => entry.displayName === node.name.value)!; if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique")) return; const target = generated.target(member); const bounds = (node.arguments ?? []).filter( (argument) => argument.name.value === "first" || argument.name.value === "all", ); if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one of first or all", node); if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after")) fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node); for (const argument of node.arguments ?? []) { if (argument.name.value === "where") compiledPredicates.set(node, relational.predicate(objectRow(target), argument.value)); if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order"); if ( ["first", "all"].includes(argument.name.value) && argument.value.kind !== "Variable" && (argument.value.kind !== "IntValue" || Number(argument.value.value) < 1 || Number(argument.value.value) > declaration.budgets.rows) ) fail( "QUERY_ROW_LIMIT", `${argument.name.value} must be between 1 and ${declaration.budgets.rows}`, argument, ); } if (node.arguments?.some((a) => a.name.value === "after")) { const rpcOrder = (value: ValueNode): boolean => value.kind === "Variable" ? generated.contracts .get(target)! .members.some((m) => m.kind === "value" && m.queryRead?.execution === "rpc-permitted") : value.kind === "ListValue" ? value.values.some(rpcOrder) : value.kind === "ObjectValue" && value.fields.some((f) => generated.contracts .get(target)! .members.some( (m) => m.displayName === f.name.value && m.kind === "value" && m.queryRead?.execution === "rpc-permitted", ), ); const order = node.arguments.find((a) => a.name.value === "orderBy"); if (relational.needsRpc(compiledPredicates.get(node)) || (order && rpcOrder(order.value))) fail( "QUERY_UNSUPPORTED_FEATURE", "RPC predicates/order require a bounded first/all window without continuation", node, ); } }, FragmentSpread(node) { const fragment = definitions.find( (entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value, ) as FragmentDefinitionNode; if (fragment.typeCondition.name.value !== info.getParentType()?.name) fail("QUERY_UNSUPPORTED_FEATURE", "Fragments must select the exact current type", node); }, }), ); let expandedFields = 0; // GraphQL merges repeated response keys. In particular, fragments may each // contribute different children of one relationship; last-write-wins would // silently remove fields from both the generated type and the wire codec. const mergeOutput = (left: ValueType, right: ValueType): ValueType => { const a = left.kind === "optional" ? left.value : left; const b = right.kind === "optional" ? right.value : right; let result = a; if (a.kind === "record" && b.kind === "record") { const fields = { ...a.fields }; for (const [key, type] of Object.entries(b.fields)) fields[key] = fields[key] ? mergeOutput(fields[key], type) : type; result = { kind: "record", fields }; } else if (a.kind === "list" && b.kind === "list") result = valueType.list(mergeOutput(a.value, b.value)); return left.kind === "optional" && right.kind === "optional" ? valueType.optional(result) : result; }; const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => { if (depth > declaration.budgets.depth * 4 + 4) fail("QUERY_DEPTH_LIMIT", "Expanded query exceeds its depth budget"); if (isNonNullType(type)) return required(type.ofType, selections, depth, conditional); return valueType.optional(required(type, selections, depth, conditional)); }; const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => { if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional)); if (isObjectType(type)) { const fields: Record = {}; const add = (set: SelectionSetNode, conditional = false) => { for (const selection of set.selections) { if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection); if (selection.kind === "FragmentSpread") { add(fragments.get(selection.name.value)!.selectionSet, conditional || !!selection.directives?.length); continue; } if (selection.kind !== "Field") continue; const key = selection.alias?.value ?? selection.name.value; const field = type.getFields()[selection.name.value]!; const previous = fields[key]; fields[key] = output( field.type, selection.selectionSet, depth + 1, conditional || !!selection.directives?.some((entry) => entry.name.value !== "live"), ); if ( (conditional || selection.directives?.some((entry) => entry.name.value !== "live")) && fields[key]!.kind !== "optional" ) fields[key] = valueType.optional(fields[key]!); if (previous) fields[key] = mergeOutput(previous, fields[key]!); } }; if (selections) add(selections, conditional); return { kind: "record", fields }; } return ( generated.scalarTypes.get(getNamedType(type)!.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`) ); }; const variables: Record = {}; const inputShape = (type: GraphQLType, seen = new Set()): ValueType => { if (isNonNullType(type)) return inputRequired(type.ofType, seen); return valueType.optional(inputRequired(type, seen)); }; const inputRequired = (type: GraphQLType, seen: Set): ValueType => { if (isListType(type)) return valueType.list(inputShape(type.ofType, seen)); if (isInputObjectType(type)) { // Recursive boolean filter inputs need generated recursive TS shapes; never degrade to any. if (seen.has(type.name)) fail( "QUERY_UNSUPPORTED_FEATURE", "Pass scalar variables inside fixed filter expressions instead of a whole recursive filter", ); return { kind: "record", fields: Object.fromEntries( Object.entries(type.getFields()).map(([key, field]) => [ key, inputShape(field.type, new Set([...seen, type.name])), ]), ), }; } if (isEnumType(type)) return valueType.string; if (isScalarType(type)) return generated.scalarTypes.get(type.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${type.name}`); return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type"); }; for (const variable of operations[0]!.variableDefinitions ?? []) variables[variable.variable.name.value] = inputShape(typeFromAST(generated.schema, variable.type)!); const result = required(generated.schema.getQueryType()!, operations[0]!.selectionSet); const argument = (node: ValueNode): QueryArgument => { switch (node.kind) { case "Variable": return { kind: "variable", name: node.name.value }; case "ListValue": return { kind: "list", values: node.values.map(argument) }; case "ObjectValue": return { kind: "object", fields: Object.fromEntries(node.fields.map((field) => [field.name.value, argument(field.value)])), }; case "NullValue": return { kind: "literal", value: null }; case "BooleanValue": return { kind: "literal", value: node.value }; // Int literals stay decimal until their checked field codec interprets them. default: return { kind: "literal", value: node.value }; } }; const conditions = (directives: readonly DirectiveNode[] = []) => directives .filter((directive) => directive.name.value !== "live") .map((directive) => ({ include: directive.name.value === "include", value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value), })); const selection = ( set: SelectionSetNode, parent: import("graphql").GraphQLObjectType, inherited: QuerySelection["conditions"] = [], ): QuerySelection[] => set.selections.flatMap((node): QuerySelection[] => { if (node.kind === "FragmentSpread") return selection(fragments.get(node.name.value)!.selectionSet, parent, [ ...inherited, ...conditions(node.directives), ]); if (node.kind !== "Field") return []; const contract = generated.byName.get(parent.name); const member = contract?.members.find((entry) => entry.displayName === node.name.value); const type = getNamedType(parent.getFields()[node.name.value]!.type); const role = generated.relational.roles.get(parent.name); const relationship = role?.kind === "relations" ? generated.relational.relations(role.row).find((m) => m.displayName === node.name.value) : undefined; const plan = relationship && role?.row.kind === "object" && node.selectionSet ? relational.plan(role.row.interfaceRevisionId, relationship, node.selectionSet, (set, name) => { const t = generated.schema.getType(name); if (!t || !isObjectType(t)) return fail("QUERY_TYPE", `Missing generated output type ${name}`, node); return selection(set, t); }) : undefined; return [ { name: node.name.value, key: node.alias?.value ?? node.name.value, ...(node.directives?.some((directive) => directive.name.value === "live") ? { liveSelectionId: createHash("sha256") .update(JSON.stringify([node.loc?.source.name, node.loc?.start, contract?.revisionId, member?.id])) .digest("hex"), } : {}), ...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}), ...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}), conditions: [...inherited, ...conditions(node.directives)], arguments: Object.fromEntries( (node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]), ), selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [], ...(plan ? { relational: plan } : {}), ...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}), }, ]; }); const normalized = print(document), schema = printSchema(generated.schema); const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!); const checkPresentation = (entries: QuerySelection[]) => { for (const key of new Set(entries.map((entry) => entry.key))) { const siblings = entries.filter((entry) => entry.key === key); if (siblings.some((entry) => entry.liveSelectionId) && siblings.some((entry) => !entry.liveSelectionId)) throw new QueryCompileError( "QUERY_LIVE_CONFLICT", `${key} mixes live and plain selections; use separate aliases`, ); checkPresentation(siblings.flatMap((entry) => entry.selection)); } }; checkPresentation(selections); const definitionDigest = createHash("sha256") .update( canonicalJson({ semantics: 2, declaration, normalized, interfaces: [...generated.byName.values()].sort((a, b) => a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0, ), }), ) .digest("hex"); return { declaration, definitionDigest, document: normalized, schema, variables: { kind: "record", fields: variables }, output: result, effects: [...effects.values()], selection: selections, variableDefaults: Object.fromEntries( (operations[0]!.variableDefinitions ?? []) .filter((entry) => entry.defaultValue) .map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]), ), sourceFiles: paths, }; } export const compileRepositoryQuery = async ( root: string, declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[], ) => { const started = performance.now(); try { return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name)); } finally { // Timing belongs in check logs, never in immutable artifact identities. console.error( `[query-check] ${JSON.stringify(declaration.displayName)}: ${(performance.now() - started).toFixed(1)}ms`, ); } };