Implement checked aggregation plans, native SQL, and bounded RPC capture

This commit is contained in:
Timothy J. Aveni
2026-09-17 19:43:46 -07:00
parent cd13120937
commit e61b0a36ac
17 changed files with 2968 additions and 100 deletions
+71 -52
View File
@@ -34,6 +34,8 @@ import {
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,
@@ -58,26 +60,6 @@ const fail = (code: string, message: string, node?: ASTNode): never => {
: undefined,
);
};
const shapeScalar = (name: string): ValueType => {
switch (name) {
case "Boolean":
return valueType.bool;
case "Int":
return valueType.int32;
case "Int64":
return valueType.int64;
case "UInt32":
return valueType.uint32;
case "UInt64":
return valueType.uint64;
case "Float":
return valueType.double;
case "Bytes":
return valueType.bytes;
default:
return valueType.string;
}
};
/** Only immutable repository sources call this. Runtime requests never compile documents. */
export async function compileQuery(
@@ -104,7 +86,7 @@ export async function compileQuery(
definitions.push(...document.definitions);
}
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
const generated = querySchema(declaration, interfaces);
const generated = querySchema(declaration, interfaces, document);
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
if (
operations.length !== 1 ||
@@ -114,6 +96,8 @@ export async function compileQuery(
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) {
@@ -130,6 +114,11 @@ export async function compileQuery(
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) => {
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
@@ -167,7 +156,13 @@ export async function compileQuery(
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)
@@ -175,12 +170,10 @@ export async function compileQuery(
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
else if (value.kind === "ObjectValue")
for (const field of value.fields) {
if (use === "predicate" && ["and", "or", "not"].includes(field.name.value)) inputEffects(id, field.value, use);
else mark(id, field.name.value, use, field);
mark(id, field.name.value, use, field);
}
};
const info = new TypeInfo(generated.schema);
let hasContinuation = false;
visit(
document,
visitWithTypeInfo(info, {
@@ -200,9 +193,9 @@ export async function compileQuery(
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") inputEffects(target, argument.value, "predicate");
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 (argument.name.value === "after") hasContinuation = true;
if (
["first", "all"].includes(argument.name.value) &&
argument.value.kind !== "Variable" &&
@@ -216,6 +209,33 @@ export async function compileQuery(
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(
@@ -226,18 +246,6 @@ export async function compileQuery(
},
}),
);
if (
hasContinuation &&
[...effects.values()].some(
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
)
)
fail("QUERY_UNSUPPORTED_FEATURE", "RPC predicates/order support bounded first windows, not continuation");
const fragments = new Map(
definitions
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
.map((entry) => [entry.name.value, entry]),
);
let expandedFields = 0;
// GraphQL merges repeated response keys. In particular, fragments may each
// contribute different children of one relationship; last-write-wins would
@@ -280,16 +288,6 @@ export async function compileQuery(
depth + 1,
conditional || !!selection.directives?.length,
);
if (selection.name.value === "_qx") {
const contract = generated.byName.get(type.name)!;
const metadataFields: Record<string, ValueType> = {};
for (const metadata of selection.selectionSet?.selections ?? []) {
if (metadata.kind !== "Field" || metadata.name.value !== "ref")
fail("QUERY_UNSUPPORTED_FEATURE", "Select _qx.ref directly", metadata);
else metadataFields[metadata.alias?.value ?? "ref"] = valueType.interfaceRef(contract.revisionId);
}
fields[key] = { kind: "record", fields: metadataFields };
}
if ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
fields[key] = valueType.optional(fields[key]!);
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
@@ -298,7 +296,10 @@ export async function compileQuery(
if (selections) add(selections, conditional);
return { kind: "record", fields };
}
return shapeScalar(getNamedType(type)!.name);
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 => {
@@ -324,7 +325,9 @@ export async function compileQuery(
),
};
}
if (isScalarType(type) || isEnumType(type)) return shapeScalar(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 ?? [])
@@ -370,6 +373,19 @@ export async function compileQuery(
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,
@@ -381,15 +397,18 @@ export async function compileQuery(
(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: 1,
semantics: 2,
declaration,
normalized,
interfaces: [...generated.byName.values()].sort((a, b) =>
@@ -406,7 +425,7 @@ export async function compileQuery(
variables: { kind: "record", fields: variables },
output: result,
effects: [...effects.values()],
selection: selection(operations[0]!.selectionSet, generated.schema.getQueryType()!),
selection: selections,
variableDefaults: Object.fromEntries(
(operations[0]!.variableDefinitions ?? [])
.filter((entry) => entry.defaultValue)