Implement progressive query admission, session boundaries and event-driven Studio observations
This commit is contained in:
+61
-4
@@ -41,6 +41,7 @@ import { objectRow } from "./relational-schema.js";
|
||||
import { workspaceQueryRootId } from "./root.js";
|
||||
import { relationalCompiler } from "./relational-compile.js";
|
||||
import { fragmentSources, type FragmentResolver } from "./fragments.js";
|
||||
import { lowerQueryTree, stripQueryBoundaries } from "./tree.js";
|
||||
import {
|
||||
QueryCompileError,
|
||||
type QueryDeclaration,
|
||||
@@ -158,7 +159,7 @@ export async function compileQuery(
|
||||
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
|
||||
},
|
||||
Directive(node) {
|
||||
if (!["include", "skip"].includes(node.name.value))
|
||||
if (!["include", "skip", "boundary"].includes(node.name.value))
|
||||
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
|
||||
},
|
||||
});
|
||||
@@ -421,6 +422,7 @@ export async function compileQuery(
|
||||
for (const selection of set.selections) {
|
||||
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
|
||||
if (selection.kind === "FragmentSpread") {
|
||||
if (selection.directives?.some((directive) => directive.name.value === "boundary")) continue;
|
||||
if (fragmentContracts.get(selection.name.value)?.mode === "reference") continue;
|
||||
add(
|
||||
fragments.get(selection.name.value)!.selectionSet,
|
||||
@@ -519,7 +521,7 @@ export async function compileQuery(
|
||||
};
|
||||
const conditions = (directives: readonly DirectiveNode[] = []) =>
|
||||
directives
|
||||
.filter((directive) => directive.name.value !== "live")
|
||||
.filter((directive) => ["include", "skip"].includes(directive.name.value))
|
||||
.map((directive) => ({
|
||||
include: directive.name.value === "include",
|
||||
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
|
||||
@@ -540,6 +542,52 @@ export async function compileQuery(
|
||||
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);
|
||||
const directive = node.directives?.find((directive) => directive.name.value === "boundary");
|
||||
if (directive) {
|
||||
const name = directive.arguments?.find((argument) => argument.name.value === "name")?.value;
|
||||
if (name?.kind !== "StringValue" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name.value))
|
||||
return fail(
|
||||
"QUERY_BOUNDARY_NAME",
|
||||
"A boundary needs a literal identifier of at most 64 characters",
|
||||
directive,
|
||||
);
|
||||
const root = generated.byName.get(parent.name)?.revisionId;
|
||||
if (!root || root === workspaceQueryRootId)
|
||||
return fail("QUERY_BOUNDARY_ROOT", "A boundary must select a nominal Graph Object", node);
|
||||
if (contract?.mode === "reference")
|
||||
fail("QUERY_BOUNDARY_REFERENCE", "Use an ordinary checked Fragment spread for a boundary", node);
|
||||
const fragment = fragments.get(node.name.value)!;
|
||||
const token = node.loc?.startToken;
|
||||
return [
|
||||
{
|
||||
name: "$boundary",
|
||||
key: "",
|
||||
arguments: {},
|
||||
conditions: inheritedConditions,
|
||||
boundary: {
|
||||
name: name.value,
|
||||
root,
|
||||
output: required(parent, fragment.selectionSet),
|
||||
...(token ? { location: { file: node.loc!.source.name, line: token.line, column: token.column } } : {}),
|
||||
},
|
||||
selection: [
|
||||
...(contract
|
||||
? [
|
||||
{
|
||||
name: "$fragment",
|
||||
key: "",
|
||||
selection: [],
|
||||
arguments: {},
|
||||
conditions: [],
|
||||
fragmentSpread: contract,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...selection(fragment.selectionSet, parent),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
...(contract
|
||||
? [
|
||||
@@ -598,6 +646,13 @@ export async function compileQuery(
|
||||
];
|
||||
});
|
||||
const contract = generated.byName.get(parent.name);
|
||||
const names = new Set<string>();
|
||||
for (const field of selections)
|
||||
if (field.boundary) {
|
||||
if (names.has(field.boundary.name))
|
||||
fail("QUERY_BOUNDARY_DUPLICATE", `Duplicate boundary ${field.boundary.name}`);
|
||||
names.add(field.boundary.name);
|
||||
}
|
||||
if (!contract || contract.revisionId === workspaceQueryRootId) return selections;
|
||||
// Fragment expansion may repeat metadata. Combine its children so a later
|
||||
// implicit reference cannot overwrite an explicitly selected aggregate.
|
||||
@@ -632,10 +687,11 @@ export async function compileQuery(
|
||||
const normalized = print(document),
|
||||
schema = printSchema(generated.schema);
|
||||
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
|
||||
const tree = lowerQueryTree(declaration, selections, result);
|
||||
const definitionDigest = createHash("sha256")
|
||||
.update(
|
||||
canonicalJson({
|
||||
semantics: 6,
|
||||
semantics: tree ? 7 : 6,
|
||||
declaration,
|
||||
normalized,
|
||||
fragmentContracts: Object.fromEntries(fragmentContracts),
|
||||
@@ -667,7 +723,8 @@ export async function compileQuery(
|
||||
variables: { kind: "record", fields: variables },
|
||||
output: result,
|
||||
effects: [...effects.values()],
|
||||
selection: selections,
|
||||
selection: tree ? stripQueryBoundaries(selections) : selections,
|
||||
...(tree ? { tree } : {}),
|
||||
variableDefaults: Object.fromEntries(
|
||||
(operations[0]!.variableDefinitions ?? [])
|
||||
.filter((entry) => entry.defaultValue)
|
||||
|
||||
+10
-1
@@ -11,6 +11,8 @@ import {
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLSchema,
|
||||
GraphQLDirective,
|
||||
DirectiveLocation,
|
||||
specifiedDirectives,
|
||||
visit,
|
||||
type GraphQLOutputType,
|
||||
@@ -294,7 +296,14 @@ export function querySchema(
|
||||
return result;
|
||||
};
|
||||
const schema = new GraphQLSchema({
|
||||
directives: specifiedDirectives,
|
||||
directives: [
|
||||
...specifiedDirectives,
|
||||
new GraphQLDirective({
|
||||
name: "boundary",
|
||||
locations: [DirectiveLocation.FRAGMENT_SPREAD],
|
||||
args: { name: { type: new GraphQLNonNull(GraphQLString) } },
|
||||
}),
|
||||
],
|
||||
// Nominal projections can mention an interface not reached by a relation.
|
||||
types: (() => {
|
||||
const requested = new Set<string>();
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { valueType, type InterfaceRevisionId, type ValueType } from "../capability-model/types.js";
|
||||
import {
|
||||
QueryCompileError,
|
||||
type QueryDeclaration,
|
||||
type QuerySelection,
|
||||
type QueryTreePlan,
|
||||
type QueryTreeNode,
|
||||
} from "./types.js";
|
||||
import type { CheckedQuery } from "./types.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
|
||||
export const stripQueryBoundaries = (fields: readonly QuerySelection[]): QuerySelection[] =>
|
||||
fields
|
||||
.filter((field) => !field.boundary)
|
||||
.map((field) => ({ ...field, selection: stripQueryBoundaries(field.selection) }));
|
||||
|
||||
const containsBoundary = (fields: readonly QuerySelection[]): boolean =>
|
||||
fields.some((field) => !!field.boundary || containsBoundary(field.selection));
|
||||
|
||||
/** GraphQL repeated paths form one projection. Equivalent checked selections
|
||||
* reuse their binding; differing windows/guards never silently share it. */
|
||||
function mergeTreeFields(fields: readonly QuerySelection[]): QuerySelection[] {
|
||||
const result: QuerySelection[] = [];
|
||||
for (const input of fields) {
|
||||
const field = { ...input, selection: mergeTreeFields(input.selection) };
|
||||
const previous = result.find(
|
||||
(candidate) =>
|
||||
!candidate.boundary &&
|
||||
!field.boundary &&
|
||||
!candidate.fragmentSpread &&
|
||||
!field.fragmentSpread &&
|
||||
candidate.key === field.key,
|
||||
);
|
||||
if (!previous) {
|
||||
result.push(field);
|
||||
continue;
|
||||
}
|
||||
const { selection: _left, ...left } = previous;
|
||||
const { selection: _right, ...right } = field;
|
||||
if (canonicalJson(left) !== canonicalJson(right)) {
|
||||
if (containsBoundary(previous.selection) || containsBoundary(field.selection))
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TREE_BINDING_CONFLICT",
|
||||
`Alias differently guarded or parameterized paths selected as ${field.key}`,
|
||||
);
|
||||
result.push(field);
|
||||
continue;
|
||||
}
|
||||
previous.selection = mergeTreeFields([...previous.selection, ...field.selection]);
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const field of result)
|
||||
if (field.boundary) {
|
||||
if (names.has(field.boundary.name))
|
||||
throw new QueryCompileError("QUERY_BOUNDARY_DUPLICATE", `Duplicate boundary ${field.boundary.name}`);
|
||||
names.add(field.boundary.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const required = (type: ValueType): ValueType =>
|
||||
type.kind === "optional" || type.kind === "list" ? required(type.value) : type;
|
||||
const fieldType = (type: ValueType, key: string): ValueType => {
|
||||
const shape = required(type);
|
||||
if (shape.kind !== "record" || !shape.fields[key])
|
||||
throw new QueryCompileError("QUERY_TREE_SHAPE", `Missing checked field ${key}`);
|
||||
return shape.fields[key];
|
||||
};
|
||||
const objectInterface = (fields: readonly QuerySelection[]): InterfaceRevisionId | undefined =>
|
||||
fields
|
||||
.find((field) => field.name === "_camino" && field.key === "_camino")
|
||||
?.selection.find((field) => field.name === "ref")?.targetInterfaceRevisionId;
|
||||
const identity = (fields: readonly QuerySelection[]): QuerySelection[] =>
|
||||
fields
|
||||
.filter((field) => field.name === "_camino" && field.key === "_camino")
|
||||
.map((field) => ({ ...field, conditions: [], selection: field.selection.filter((child) => child.name === "ref") }));
|
||||
function projectionType(type: ValueType, fields: readonly QuerySelection[]): ValueType {
|
||||
if (type.kind === "optional") return valueType.optional(projectionType(type.value, fields));
|
||||
if (type.kind === "list") return valueType.list(projectionType(type.value, fields));
|
||||
if (type.kind !== "record") return type;
|
||||
return {
|
||||
kind: "record",
|
||||
fields: Object.fromEntries(
|
||||
fields
|
||||
.filter((field) => !field.fragmentSpread)
|
||||
.map((field) => {
|
||||
const source = type.fields[field.key] ?? (field.key === "key" ? valueType.string : undefined);
|
||||
if (!source) throw new QueryCompileError("QUERY_TREE_SHAPE", `Missing checked projection ${field.key}`);
|
||||
return [field.key, projectionType(source, field.selection)];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
const keyField = (): QuerySelection => ({ name: "key", key: "key", conditions: [], arguments: {}, selection: [] });
|
||||
|
||||
/** Lower into complete projections and minimal binding projections. No runtime
|
||||
* component inspection, executable client source, or graph handle value is involved.
|
||||
* Each projection is rooted at its own nominal subject; lists retain entry keys.
|
||||
*/
|
||||
export function lowerQueryTree(
|
||||
declaration: QueryDeclaration,
|
||||
selection: QuerySelection[],
|
||||
output: ValueType,
|
||||
): QueryTreePlan | undefined {
|
||||
if (!containsBoundary(selection)) return;
|
||||
selection = mergeTreeFields(selection);
|
||||
if (declaration.placement?.kind !== "replica" || declaration.fragment)
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TREE_PLACEMENT",
|
||||
"Declare a Query Tree as a replica query; its checked projections supply local views",
|
||||
);
|
||||
const root = selection.find((field) => field.name === "root" && field.key === "root");
|
||||
if (!root || selection.some((field) => field !== root))
|
||||
throw new QueryCompileError("QUERY_TREE_ROOT", "A Query Tree requires one unaliased root selection");
|
||||
const plan: QueryTreePlan = { root: "root", nodes: [], projections: [], maxOccurrences: 0 };
|
||||
const project = (id: string, subject: InterfaceRevisionId, fields: QuerySelection[], shape: ValueType) => {
|
||||
if (plan.projections.length >= 256)
|
||||
throw new QueryCompileError("QUERY_TREE_LIMIT", "Query Tree exceeds 256 projections");
|
||||
plan.projections.push({
|
||||
id,
|
||||
root: id === "root/value" ? declaration.root : { kind: "object", interface: subject },
|
||||
selection: [{ name: "root", key: "root", arguments: {}, conditions: [], selection: fields }],
|
||||
output: { kind: "record", fields: { root: shape } },
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const node = (
|
||||
id: string,
|
||||
subject: InterfaceRevisionId,
|
||||
fields: QuerySelection[],
|
||||
shape: ValueType,
|
||||
occurrences: number,
|
||||
): string => {
|
||||
plan.maxOccurrences += occurrences;
|
||||
if (!Number.isSafeInteger(plan.maxOccurrences) || plan.maxOccurrences > 256)
|
||||
throw new QueryCompileError("QUERY_TREE_LIMIT", "Query Tree exceeds 256 bounded node occurrences");
|
||||
const current: QueryTreeNode = {
|
||||
id,
|
||||
projection: project(id + "/value", subject, stripQueryBoundaries(fields), shape),
|
||||
boundaries: [],
|
||||
bindings: [],
|
||||
};
|
||||
plan.nodes.push(current);
|
||||
for (const field of fields)
|
||||
if (field.boundary) {
|
||||
const child = field.boundary;
|
||||
current.boundaries.push({
|
||||
name: child.name,
|
||||
conditions: field.conditions,
|
||||
node: node(id + "/boundary/" + child.name, child.root, field.selection, child.output, occurrences),
|
||||
});
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (field.boundary || !containsBoundary(field.selection)) continue;
|
||||
const targets: { path: string[]; node: string }[] = [];
|
||||
const prune = (fields: QuerySelection[], shape: ValueType, path: string[], bound: number): QuerySelection[] => {
|
||||
if (shape.kind === "optional") return prune(fields, shape.value, path, bound);
|
||||
if (shape.kind === "list") return prune(fields, shape.value, [...path, "[]"], bound * declaration.budgets.rows);
|
||||
const target = objectInterface(fields);
|
||||
if (target) {
|
||||
targets.push({
|
||||
path,
|
||||
node: node(id + "/binding/" + path.map(encodeURIComponent).join("/"), target, fields, shape, bound),
|
||||
});
|
||||
return identity(fields);
|
||||
}
|
||||
const minimal = fields
|
||||
.filter((field) => containsBoundary(field.selection))
|
||||
.map((field) => ({
|
||||
...field,
|
||||
selection: prune(field.selection, fieldType(shape, field.key), [...path, field.key], bound),
|
||||
}));
|
||||
// Relationship occurrence identity is its entry key, including duplicate
|
||||
// target Objects. Never substitute list position or Object identity.
|
||||
if (minimal.some((field) => field.name === "node") && !minimal.some((field) => field.name === "key"))
|
||||
minimal.unshift(fields.find((field) => field.name === "key" && field.key === "key") ?? keyField());
|
||||
return minimal;
|
||||
};
|
||||
const type = fieldType(shape, field.key);
|
||||
const minimal = { ...field, selection: prune(field.selection, type, [field.key], occurrences) };
|
||||
const selected = [...identity(fields), minimal];
|
||||
current.bindings.push({
|
||||
name: field.key,
|
||||
targets,
|
||||
projection: project(
|
||||
id + "/bindings/" + encodeURIComponent(field.key),
|
||||
subject,
|
||||
selected,
|
||||
projectionType(shape, selected),
|
||||
),
|
||||
});
|
||||
}
|
||||
return id;
|
||||
};
|
||||
node("root", declaration.root.interface, root.selection, fieldType(output, "root"), 1);
|
||||
return plan;
|
||||
}
|
||||
|
||||
export const treeProjectionQuery = (tree: string, projection: string, role: "replica" | "local") =>
|
||||
`${tree}/@tree/${projection}/${role}`;
|
||||
|
||||
/** Checked synthetic queries remain ordinary nominal queries for linking,
|
||||
* authority, native input acquisition and execution. Public trees own the plan;
|
||||
* applications do not copy these projections into their source declarations. */
|
||||
export function queryTreeProjections(query: CheckedQuery): CheckedQuery[] {
|
||||
if (!query.tree) return [];
|
||||
const { tree: _tree, ...base } = query;
|
||||
return query.tree.projections.flatMap((projection) => {
|
||||
const selected = new Set<string>();
|
||||
const walk = (fields: readonly QuerySelection[]) => {
|
||||
for (const field of fields) {
|
||||
if (field.interfaceRevisionId && field.memberId)
|
||||
selected.add(JSON.stringify([field.interfaceRevisionId, field.memberId]));
|
||||
walk(field.selection);
|
||||
}
|
||||
};
|
||||
walk(projection.selection);
|
||||
const effects = query.effects.filter(
|
||||
(effect) =>
|
||||
selected.has(JSON.stringify([effect.interfaceRevisionId, effect.memberId])) ||
|
||||
effect.uses.some((use) => use !== "select"),
|
||||
);
|
||||
if (effects.some((effect) => effect.execution === "rpc-permitted"))
|
||||
throw new QueryCompileError(
|
||||
"QUERY_TREE_LOCAL_DEPENDENCY",
|
||||
"A replica Query Tree requires locally executable snapshot projections",
|
||||
);
|
||||
return (["replica", "local"] as const).map((role): CheckedQuery => {
|
||||
const id = treeProjectionQuery(query.declaration.id, projection.id, role);
|
||||
const declaration: QueryDeclaration = {
|
||||
...query.declaration,
|
||||
id,
|
||||
displayName: id,
|
||||
root: projection.root,
|
||||
placement:
|
||||
role === "replica"
|
||||
? { kind: "replica" }
|
||||
: { kind: "local", sourceQueryId: treeProjectionQuery(query.declaration.id, projection.id, "replica") },
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
declaration,
|
||||
effects,
|
||||
selection: projection.selection,
|
||||
output: projection.output,
|
||||
treeProjection: { tree: query.declaration.id, projection: projection.id, role },
|
||||
definitionDigest: createHash("sha256")
|
||||
.update(
|
||||
canonicalJson({
|
||||
parent: query.definitionDigest,
|
||||
declaration,
|
||||
selection: projection.selection,
|
||||
output: projection.output,
|
||||
}),
|
||||
)
|
||||
.digest("hex"),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -94,6 +94,9 @@ export class QueryCompileError extends Error {
|
||||
}
|
||||
}
|
||||
export interface CheckedQuery {
|
||||
/** Checked decomposition; handles belong to a Client Session, never graph values. */
|
||||
tree?: QueryTreePlan;
|
||||
treeProjection?: { tree: string; projection: string; role: "replica" | "local" };
|
||||
fragmentContracts?: Record<string, NonNullable<QuerySelection["fragmentSpread"]>>;
|
||||
declaration: QueryDeclaration;
|
||||
definitionDigest: string;
|
||||
@@ -115,6 +118,12 @@ export type QueryArgument =
|
||||
| { kind: "list"; values: QueryArgument[] }
|
||||
| { kind: "object"; fields: Record<string, QueryArgument> };
|
||||
export interface QuerySelection {
|
||||
boundary?: {
|
||||
name: string;
|
||||
root: InterfaceRevisionId;
|
||||
output: ValueType;
|
||||
location?: QuerySourceLocation;
|
||||
};
|
||||
/** A checked occurrence marker, not an output field or storage capability. */
|
||||
fragmentSpread?: {
|
||||
mode?: "reference";
|
||||
@@ -135,3 +144,27 @@ export interface QuerySelection {
|
||||
relational?: import("./relational.js").QueryRelationalPlan;
|
||||
predicate?: import("./relational.js").QueryPredicate;
|
||||
}
|
||||
|
||||
export interface QueryTreeProjection {
|
||||
id: string;
|
||||
root: QueryRoot;
|
||||
selection: QuerySelection[];
|
||||
output: ValueType;
|
||||
}
|
||||
export interface QueryTreeNode {
|
||||
id: string;
|
||||
projection: string;
|
||||
boundaries: { name: string; conditions: QuerySelection["conditions"]; node: string }[];
|
||||
bindings: {
|
||||
name: string;
|
||||
projection: string;
|
||||
/** [] denotes an occurrence in a bounded list; its entry key owns identity. */
|
||||
targets: { path: string[]; node: string }[];
|
||||
}[];
|
||||
}
|
||||
export interface QueryTreePlan {
|
||||
root: string;
|
||||
nodes: QueryTreeNode[];
|
||||
projections: QueryTreeProjection[];
|
||||
maxOccurrences: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user