Implement progressive query admission, session boundaries and event-driven Studio observations
This commit is contained in:
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { profile } from "./profile.js";
|
||||
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||
import { compileQuery, compileRepositoryQuery } from "../query/compile.js";
|
||||
import { queryTreeProjections } from "../query/tree.js";
|
||||
import type { QueryDeclaration } from "../query/types.js";
|
||||
import { compileQueryComposition, type QueryComposition } from "../query/composition.js";
|
||||
import { checkQueryTemplate } from "../query/templates.js";
|
||||
@@ -390,6 +391,13 @@ const createResourceGraphResolver = (
|
||||
};
|
||||
pkg.checkedQueries = [];
|
||||
for (const query of pkg.queries!) pkg.checkedQueries.push(await compile(query.id));
|
||||
const projections = pkg.checkedQueries.flatMap(queryTreeProjections);
|
||||
for (const projection of projections) {
|
||||
if (pkg.queries!.some((query) => query.id === projection.declaration.id))
|
||||
throw new Error(`Generated Query Tree projection conflicts with ${projection.declaration.id}`);
|
||||
pkg.queries!.push(projection.declaration);
|
||||
pkg.checkedQueries.push(projection);
|
||||
}
|
||||
}
|
||||
if (generated?.compositions?.length) {
|
||||
const pkg = compiled.resource.revision;
|
||||
|
||||
@@ -118,7 +118,9 @@ const main = async () => {
|
||||
if (options.executionPlanOut || options.buildContextsOut) {
|
||||
const executionStarted = performance.now();
|
||||
const plan = compileExecutionPlan(assembled.workspace);
|
||||
if (options.executionPlanOut) await writeFile(options.executionPlanOut, `${JSON.stringify(plan, null, 2)}\n`);
|
||||
// Machine-consumed immutable metadata should not spend its bounded input
|
||||
// reservation on indentation. Its parsed allocation is charged separately.
|
||||
if (options.executionPlanOut) await writeFile(options.executionPlanOut, `${JSON.stringify(plan)}\n`);
|
||||
if (options.buildContextsOut)
|
||||
await writeFile(options.buildContextsOut, JSON.stringify(packageBuildContexts(plan.definition.bindingSchema)));
|
||||
profile("execution-plan-and-contexts", executionStarted);
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
} from "./metadata.js";
|
||||
const rootPattern = /^\/nix\/store\/[a-z0-9]{32}-[^/\s]+$/;
|
||||
async function readJson(root: string, file: string, scope: CandidateMetadataScope) {
|
||||
const input = await readCandidateMetadata(join(root, file), scope, 32 * 1024 * 1024);
|
||||
// Query Trees add checked projections to an already large application plan.
|
||||
// The Reader now exceeds 32 MiB even without indentation. This file bound
|
||||
// does not replace the stricter aggregate/owner parse-allocation budgets.
|
||||
const input = await readCandidateMetadata(join(root, file), scope, 64 * 1024 * 1024);
|
||||
try {
|
||||
return {
|
||||
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input.bytes)),
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { BindingSchema } from "../bindings/index.js";
|
||||
import type { PackageBuildContract } from "./build-contract.js";
|
||||
import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js";
|
||||
import { moduleTypeContext } from "./module-contracts.js";
|
||||
import { queryTreeBlueprint, queryTreeType } from "./query-trees.js";
|
||||
|
||||
/** Public shape only: no slot inventory, implementation binding, or workspace identity. */
|
||||
export interface PublicOperationContract {
|
||||
@@ -76,6 +77,7 @@ export interface PublicQueryContract {
|
||||
sourceQuery?: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
tree?: ReturnType<typeof queryTreeBlueprint>;
|
||||
}
|
||||
export function publicQueryContract(pkg: PackageRevision, queryId: string): PublicQueryContract {
|
||||
const query = pkg.checkedQueries?.find((q) => q.declaration.id === queryId);
|
||||
@@ -88,9 +90,10 @@ export function publicQueryContract(pkg: PackageRevision, queryId: string): Publ
|
||||
definitionDigest: query.definitionDigest,
|
||||
root: query.declaration.root,
|
||||
placement: placement?.kind ?? "server",
|
||||
...(placement?.kind === "local" ? { sourceQuery: placement.sourceQueryId } : {}),
|
||||
...(placement?.kind === "local" ? { sourceQuery: query.treeProjection?.tree ?? placement.sourceQueryId } : {}),
|
||||
input: portableValueSchema(query.variables),
|
||||
output: portableValueSchema(query.output),
|
||||
...(query.tree ? { tree: queryTreeBlueprint(pkg.revisionId, query) } : {}),
|
||||
};
|
||||
}
|
||||
export function linkPackageQuery(plan: ExecutionPlan, contract: PublicQueryContract) {
|
||||
@@ -156,10 +159,10 @@ export function generatePackageQueryClientBindings(
|
||||
checkedQueries: pkg.checkedQueries?.filter((query) => !internal.includes(query.declaration.id)),
|
||||
};
|
||||
const queries = (pkg.checkedQueries ?? [])
|
||||
.filter((query) => !query.declaration.fragment)
|
||||
.filter((query) => !query.declaration.fragment && !query.treeProjection)
|
||||
.map(
|
||||
(query) =>
|
||||
`${JSON.stringify(query.declaration.displayName)}: definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
||||
`${JSON.stringify(query.declaration.displayName)}: ${query.tree ? `definePackageQueryTree<${tsType(query.variables)}, ${queryTreeType(query)}, ${JSON.stringify(query.declaration.root.interface)}>` : `definePackageQuery<${tsType(query.variables)}, ${tsType(query.output)}, ${JSON.stringify(publicQueryContract(pkg, query.declaration.id).placement)}>`}(${JSON.stringify(publicQueryContract(pkg, query.declaration.id))})`,
|
||||
);
|
||||
const emitFragment = (provider: PackageRevision, id: string) => {
|
||||
const query = provider.checkedQueries?.find((query) => query.declaration.id === id);
|
||||
@@ -210,7 +213,7 @@ export function generatePackageQueryClientBindings(
|
||||
fragments.push(
|
||||
`${JSON.stringify(alias)}: {${[...entries].map(([name, value]) => `${JSON.stringify(name)}: ${value}`).join(",")}}`,
|
||||
);
|
||||
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, definePackageFragment, type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\nexport const fragments = {${fragments.join(",\n")}} as const;\n`;
|
||||
return `// Generated from checked package query contracts. Regenerate; do not edit.\nimport { definePackageQuery, definePackageFragment, ${queries.some((query) => query.includes("definePackageQueryTree<")) ? "definePackageQueryTree, type QueryTreeShape, type QueryTreeOccurrence, " : ""}type ManagedReference } from ${JSON.stringify(runtimeModule)};\nexport const queries = {${queries.join(",\n")}} as const;\nexport const fragments = {${fragments.join(",\n")}} as const;\n`;
|
||||
}
|
||||
|
||||
/** Receipt-covered bindings expose the package's imports, component subjects and
|
||||
@@ -279,7 +282,7 @@ export function generatePackageComponentBindings(
|
||||
.join("\n");
|
||||
return (
|
||||
interfaces +
|
||||
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,type ComponentType,type ComponentProps,type PresentationHandle} from "@quixos/camino-react";\n' +
|
||||
'\nimport {createObjectRenderer,definePackageQuery,definePackageFragment,definePackageQueryTree,type QueryTreeShape,type QueryTreeOccurrence,type QueryTreeHandle,type ComponentType,type ComponentProps,type PresentationHandle} from "@quixos/camino-react";\n' +
|
||||
subjectBindings +
|
||||
"\n" +
|
||||
queries +
|
||||
@@ -319,7 +322,13 @@ export function generatePackageComponentBindings(
|
||||
const handle = `PresentationHandle<${JSON.stringify(child.subject)}, ${extra}>`;
|
||||
return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`;
|
||||
});
|
||||
const input = `${tsType(preparation.input ?? composition.output)} & {readonly children: {${children.join(";")}}}`;
|
||||
const trees = (preparation.trees ?? []).map((tree) => {
|
||||
const query = pkg.checkedQueries?.find((query) => query.declaration.id === tree.query);
|
||||
if (!query?.tree)
|
||||
throw new ExecutionPlanError("MISSING_QUERY_TREE", tree.query, "Expected a checked Query Tree");
|
||||
return `readonly ${JSON.stringify(tree.name)}: QueryTreeHandle<${queryTreeType(query)}>`;
|
||||
});
|
||||
const input = `${tsType(preparation.input ?? composition.output)} & {readonly children: {${children.join(";")}}${trees.length ? `;readonly trees:{${trees.join(";")}}` : ""}}`;
|
||||
return `${JSON.stringify(component.displayName)}: ComponentType<ComponentProps<${JSON.stringify(component.subject)}, ${JSON.stringify(module.contract)}, ${moduleArguments(find(module.interfaceRevisionId))}, ${input}> & ExtraModuleProps<${JSON.stringify(key)}>>;`;
|
||||
})
|
||||
.join("\n") +
|
||||
|
||||
@@ -10,10 +10,10 @@ export class CandidateMetadataBudget {
|
||||
private scopes = 0;
|
||||
private readonly owners = new Map<string, number>();
|
||||
// A cutover holds both the serving and proposed installation plus transient
|
||||
// parsing space. The 57-repository reader retains ~133 MiB per installation;
|
||||
// a 256 MiB owner ceiling cannot admit its ordinary atomic replacement.
|
||||
// parsing space. The Reader with checked Query Trees retains ~176 MiB per
|
||||
// installation; its replacement also needs transient parse/index space.
|
||||
// Keep the aggregate ceiling independent and leave room for parsing overlap.
|
||||
constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 384 * 1024 * 1024, candidates: 128 }) {
|
||||
constructor(limits = { bytes: 768 * 1024 * 1024, ownerBytes: 512 * 1024 * 1024, candidates: 128 }) {
|
||||
for (const value of Object.values(limits))
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||
if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { portableValueSchema } from "../bindings/portable.js";
|
||||
import { ExecutionPlanError, hexDigest, type ExecutionPlan } from "./plan.js";
|
||||
import type { ValueType } from "../capability-model/types.js";
|
||||
import { compileQueryComposition } from "../query/composition.js";
|
||||
import { queryTreeBlueprint } from "./query-trees.js";
|
||||
|
||||
const lowerTypes = (value: unknown): unknown =>
|
||||
JSON.parse(
|
||||
@@ -59,6 +60,12 @@ export function compileQueryProgram(plan: ExecutionPlan): unknown {
|
||||
codec: "camino-query-program-v1",
|
||||
plan: (compileKernelProgram(plan) as { plan: string }).plan,
|
||||
...(compositions.length ? { compositions } : {}),
|
||||
trees: plan.definition.queries
|
||||
.filter((entry) => entry.query.checked.tree)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
...queryTreeBlueprint(entry.query.packageRevisionId, entry.query.checked),
|
||||
})),
|
||||
queries: [
|
||||
...compileInspectionTemplates(plan).queries,
|
||||
...plan.definition.queries.map((entry) => queryProgramEntry(plan, entry)),
|
||||
@@ -85,7 +92,10 @@ export function queryProgramEntry(plan: ExecutionPlan, entry: import("./plan.js"
|
||||
placement: checked.declaration.placement?.kind ?? "remote",
|
||||
source:
|
||||
checked.declaration.placement?.kind === "local"
|
||||
? JSON.stringify([query.packageRevisionId, checked.declaration.placement.sourceQueryId])
|
||||
? JSON.stringify([
|
||||
query.packageRevisionId,
|
||||
checked.treeProjection?.tree ?? checked.declaration.placement.sourceQueryId,
|
||||
])
|
||||
: null,
|
||||
root: checked.declaration.root,
|
||||
atoms: query.rootAtoms,
|
||||
@@ -105,6 +115,19 @@ export function queryProgramEntry(plan: ExecutionPlan, entry: import("./plan.js"
|
||||
operation: JSON.stringify([field.atomId, field.interfaceRevisionId, field.getter]),
|
||||
};
|
||||
}),
|
||||
admittedOperations,
|
||||
admittedOperations: checked.tree
|
||||
? [
|
||||
...new Set([
|
||||
...admittedOperations,
|
||||
...plan.definition.queries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.query.packageRevisionId === query.packageRevisionId &&
|
||||
entry.query.checked.treeProjection?.tree === checked.declaration.id,
|
||||
)
|
||||
.flatMap((entry) => entry.operations),
|
||||
]),
|
||||
].sort()
|
||||
: admittedOperations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CheckedQuery, QueryTreeNode } from "../query/types.js";
|
||||
import type { ValueType } from "../capability-model/types.js";
|
||||
import { treeProjectionQuery } from "../query/tree.js";
|
||||
import { tsType } from "../bindings/portable.js";
|
||||
|
||||
export function queryTreeBlueprint(packageId: string, query: CheckedQuery) {
|
||||
const tree = query.tree!;
|
||||
const projection = (id: string) =>
|
||||
JSON.stringify([packageId, treeProjectionQuery(query.declaration.id, id, "local")]);
|
||||
const admission = (id: string) =>
|
||||
JSON.stringify([packageId, treeProjectionQuery(query.declaration.id, id, "replica")]);
|
||||
return {
|
||||
root: tree.root,
|
||||
maxOccurrences: tree.maxOccurrences,
|
||||
nodes: tree.nodes.map((node) => ({
|
||||
...node,
|
||||
projection: projection(node.projection),
|
||||
admission: admission(node.projection),
|
||||
bindings: node.bindings.map((binding) => ({
|
||||
...binding,
|
||||
projection: projection(binding.projection),
|
||||
admission: admission(binding.projection),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Emit the shape of owned handles separately from graph result codecs. */
|
||||
export function queryTreeType(query: CheckedQuery): string {
|
||||
const tree = query.tree!;
|
||||
const nodes = new Map(tree.nodes.map((node) => [node.id, node]));
|
||||
const projections = new Map(tree.projections.map((projection) => [projection.id, projection]));
|
||||
const rootOutput = (id: string) => {
|
||||
const shape = projections.get(id)!.output;
|
||||
if (shape.kind !== "record" || !shape.fields.root) throw Error("Unchecked Query Tree projection");
|
||||
return shape.fields.root;
|
||||
};
|
||||
const shape = (id: string): string => {
|
||||
const node = nodes.get(id)!;
|
||||
return `QueryTreeShape<${tsType(rootOutput(node.projection))}, {${node.boundaries
|
||||
.map(
|
||||
(boundary) =>
|
||||
`readonly ${JSON.stringify(boundary.name)}${boundary.conditions.length ? "?" : ""}: ${shape(boundary.node)}`,
|
||||
)
|
||||
.join(";")}}, {readonly root: {${node.bindings
|
||||
.map(
|
||||
(binding) =>
|
||||
`readonly ${JSON.stringify(binding.name)}: ${bindingType(rootOutput(binding.projection), binding.targets, [binding.name])}`,
|
||||
)
|
||||
.join(";")}}}>`;
|
||||
};
|
||||
const bindingType = (
|
||||
type: ValueType,
|
||||
targets: QueryTreeNode["bindings"][number]["targets"],
|
||||
prefix: string[],
|
||||
): string => {
|
||||
const at = (type: ValueType, targets: QueryTreeNode["bindings"][number]["targets"]): string => {
|
||||
if (type.kind === "optional") return `(${at(type.value, targets)} | null)`;
|
||||
if (type.kind === "list")
|
||||
return `ReadonlyArray<${at(
|
||||
type.value,
|
||||
targets.map((target) => ({ ...target, path: target.path.slice(1) })),
|
||||
)}>`;
|
||||
const target = targets.find((target) => !target.path.length);
|
||||
if (target) {
|
||||
if (type.kind !== "record" || type.fields._camino?.kind !== "record" || !type.fields._camino.fields.ref)
|
||||
throw Error("Unchecked binding identity");
|
||||
return `QueryTreeOccurrence<${tsType(type.fields._camino.fields.ref)}, ${shape(target.node)}>`;
|
||||
}
|
||||
if (type.kind !== "record") return tsType(type);
|
||||
return `{${Object.entries(type.fields)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`readonly ${JSON.stringify(key)}: ${at(
|
||||
value,
|
||||
targets
|
||||
.filter((target) => target.path[0] === key)
|
||||
.map((target) => ({ ...target, path: target.path.slice(1) })),
|
||||
)}`,
|
||||
)
|
||||
.join(";")}}`;
|
||||
};
|
||||
let selected = type;
|
||||
for (const key of prefix) {
|
||||
if (selected.kind !== "record" || !selected.fields[key]) throw Error("Unchecked binding field");
|
||||
selected = selected.fields[key];
|
||||
}
|
||||
return at(
|
||||
selected,
|
||||
targets.map((target) => ({ ...target, path: target.path.slice(prefix.length) })),
|
||||
);
|
||||
};
|
||||
return shape(tree.root);
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface ReactModulePreparation {
|
||||
/** Public input type before workspace query lowering adds private projections. */
|
||||
input?: ValueType;
|
||||
children: ReactChildDeclaration[];
|
||||
/** Binds roots/arguments only. The ordinary checked Query Tree owns every
|
||||
* acquisition path; the React declaration does not repeat its descendants. */
|
||||
trees?: Pick<QueryCompositionStep, "name" | "query" | "root" | "arguments">[];
|
||||
}
|
||||
export interface ReactPreparationArtifact {
|
||||
/** Frontend queries belong to installed preparation, not the authored client API. */
|
||||
@@ -51,7 +54,7 @@ export function presentationDeclarations(
|
||||
};
|
||||
for (const [name, value] of Object.entries(modules)) {
|
||||
const declaration = object(value, `module ${name}`);
|
||||
if (Object.keys(declaration).some((key) => !["inputs", "children"].includes(key)))
|
||||
if (Object.keys(declaration).some((key) => !["inputs", "children", "trees"].includes(key)))
|
||||
throw Error(`${name}: unknown preparation field`);
|
||||
const module = pkg.modules?.find((entry) => entry.displayName === name);
|
||||
if (!module) throw Error(`React declaration: unknown module ${name}`);
|
||||
@@ -129,7 +132,29 @@ export function presentationDeclarations(
|
||||
...(role ? { role } : {}),
|
||||
};
|
||||
});
|
||||
artifact.modules.push({ module: module.id, composition, children: parsed });
|
||||
const trees = declaration.trees ?? [];
|
||||
if (!Array.isArray(trees) || trees.length > 16) throw Error(`${name}: trees must be a bounded array`);
|
||||
const treeNames = new Set<string>();
|
||||
const preparedTrees = trees.map((value) => {
|
||||
const tree = object(value, `${name} tree`) as unknown as NonNullable<ReactModulePreparation["trees"]>[number];
|
||||
if (
|
||||
Object.keys(tree).some((key) => !["name", "query", "root", "arguments"].includes(key)) ||
|
||||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tree.name) ||
|
||||
treeNames.has(tree.name)
|
||||
)
|
||||
throw Error(`${name}: tree requires a unique name and checked root/arguments`);
|
||||
treeNames.add(tree.name);
|
||||
const query = pkg.queries?.find((entry) => entry.displayName === tree.query);
|
||||
if (!query || query.placement?.kind !== "replica")
|
||||
throw Error(`${name}.${tree.name}: requires a replica Query Tree`);
|
||||
return { ...tree, query: query.id };
|
||||
});
|
||||
artifact.modules.push({
|
||||
module: module.id,
|
||||
composition,
|
||||
children: parsed,
|
||||
...(preparedTrees.length ? { trees: preparedTrees } : {}),
|
||||
});
|
||||
}
|
||||
for (const module of pkg.modules ?? []) {
|
||||
const contract = interfaces.find((entry) => entry.revisionId === module.interfaceRevisionId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WorkspaceRevision, ValueType } from "../capability-model/types.js";
|
||||
import { moduleContract } from "../capability-model/modules.js";
|
||||
import { canonicalJson } from "../capability-model/evolution.js";
|
||||
import { queryBindingType } from "../query/composition.js";
|
||||
import { queryBindingType, compileQueryComposition } from "../query/composition.js";
|
||||
import { moduleTypeContext } from "../execution-world/module-contracts.js";
|
||||
import { preparationArtifact, type ReactPreparationArtifact } from "./declarations.js";
|
||||
import { expandPresentationQueries } from "./expansion.js";
|
||||
@@ -25,6 +25,17 @@ export async function completeWorkspace(workspace: WorkspaceRevision): Promise<v
|
||||
if (!composition || composition.output.kind !== "record")
|
||||
throw Error(`Missing checked inputs for ${parent.module.displayName}`);
|
||||
parent.preparation.input = structuredClone(composition.output);
|
||||
const treeQueries = new Map((parent.pkg.checkedQueries ?? []).map((query) => [query.declaration.id, query]));
|
||||
for (const tree of parent.preparation.trees ?? []) {
|
||||
if (!treeQueries.get(tree.query)?.tree)
|
||||
throw Error(`${parent.module.displayName}.${tree.name}: expected a checked Query Tree`);
|
||||
// Reuse Camino's ordinary nominal/argument binder. Only input parameters
|
||||
// are available; a tree cannot wait on a whole presentation result.
|
||||
compileQueryComposition(
|
||||
{ id: `react:tree:${parent.module.id}:${tree.name}`, input: composition.input, steps: [tree], maxQueries: 1 },
|
||||
treeQueries,
|
||||
);
|
||||
}
|
||||
const outputs = new Map(Object.entries(composition.output.fields));
|
||||
const dependencies: string[] = [];
|
||||
const children = parent.preparation.children.map((child) => {
|
||||
|
||||
Reference in New Issue
Block a user