Files
quixos-protocol/src/query/compile.ts
T

515 lines
22 KiB
TypeScript
Raw Normal View History

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";
2026-09-18 14:55:12 -07:00
import { workspaceQueryRootId } from "./root.js";
import { relationalCompiler } from "./relational-compile.js";
import { fragmentSources, type FragmentResolver } from "./fragments.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<string>,
resolveFragment?: FragmentResolver,
): Promise<CheckedQuery> {
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 fragmentContracts = await fragmentSources(declaration, definitions, resolveFragment);
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
if (definitions.length > 256 || Buffer.byteLength(print(document)) > 262144)
fail("QUERY_SOURCE_LIMIT", "Expanded fragment sources exceed the query source budget");
visit(document, {
Directive(node) {
if (node.name.value === "live")
fail(
"QUERY_RETIRED_DIRECTIVE",
"@live is retired. Observations watch ordinary query values; use checked operations for mutations.",
node,
);
},
});
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"].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<string, QueryFieldEffect>();
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) => {
2026-09-18 14:55:12 -07:00
// The virtual extent has no provider/getter effect. Its membership is
// linked separately, including when the query only selects references.
if (id === workspaceQueryRootId) return;
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || member.kind === "module" || !member.queryRead)
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
if (declaration.placement?.kind === "local") {
const getter = member.operations.find(
(operation) => operation.displayName === (member.kind === "value" ? "get" : "resolve"),
);
if (getter?.execution?.kind !== "snapshot" || !getter.execution.targets.includes("browser"))
fail(
"QUERY_LOCAL_DEPENDENCY",
`Local query requires a browser snapshot getter for ${contract.displayName}.${name}; split the remote field or move the query to the server`,
node,
);
if (member.queryRead.execution === "rpc-permitted")
fail(
"QUERY_LOCAL_DEPENDENCY",
`Local query cannot include remote-compute field ${contract.displayName}.${name}`,
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<ASTNode, import("./relational.js").QueryPredicate>();
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 (!contract || node.name.value === "_camino") 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<string, ValueType> = {};
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<string, ValueType> = {};
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
if (isNonNullType(type)) return inputRequired(type.ofType, seen);
return valueType.optional(inputRequired(type, seen));
};
const inputRequired = (type: GraphQLType, seen: Set<string>): 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") {
const inheritedConditions = [...inherited, ...conditions(node.directives)];
const contract = fragmentContracts.get(node.name.value);
if (contract && generated.byName.get(parent.name)?.revisionId !== contract.root)
fail("FRAGMENT_ROOT_MISMATCH", "Imported fragment requires its exact nominal root interface", node);
return [
...(contract
? [
{
name: "$fragment",
key: "",
selection: [],
arguments: {},
conditions: inheritedConditions,
fragmentSpread: contract,
},
]
: []),
...selection(fragments.get(node.name.value)!.selectionSet, parent, inheritedConditions),
];
}
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,
...(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 definitionDigest = createHash("sha256")
.update(
canonicalJson({
semantics: 3,
declaration,
normalized,
// Source provenance belongs to the checked installation. Identical public
// contracts keep their query identity when retained in another repository.
// Closed generic identity remains explicit in revisionId/application.
interfaces: [...generated.byName.values()]
.map(({ source: _source, ...contract }) => contract)
.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,
...(fragmentContracts.size ? { fragmentContracts: Object.fromEntries(fragmentContracts) } : {}),
};
}
export const compileRepositoryQuery = async (
root: string,
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
resolveFragment?: FragmentResolver,
) => {
const started = performance.now();
try {
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name), resolveFragment);
} 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`,
);
}
};