diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index f4f307b..e30aeb4 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "286ada5419c4fc2184b387cea4715fae1a74bde5", + "sourceCommit": "a61bdda658be4878403b8d60aecb71fe5a614ac0", "sourcePath": "quixos-protocol", "exportName": "quixos-protocol", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index 6236e1d..e064823 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -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; diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index 1159d8c..37c35c6 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -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); diff --git a/src/execution-world/candidate.ts b/src/execution-world/candidate.ts index fea5cd4..c7c8b05 100644 --- a/src/execution-world/candidate.ts +++ b/src/execution-world/candidate.ts @@ -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)), diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index a258e4e..59f2e7b 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -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; } 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 & ExtraModuleProps<${JSON.stringify(key)}>>;`; }) .join("\n") + diff --git a/src/execution-world/metadata.ts b/src/execution-world/metadata.ts index 4736498..01cf497 100644 --- a/src/execution-world/metadata.ts +++ b/src/execution-world/metadata.ts @@ -10,10 +10,10 @@ export class CandidateMetadataBudget { private scopes = 0; private readonly owners = new Map(); // 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"); diff --git a/src/execution-world/queries.ts b/src/execution-world/queries.ts index 26cb3c3..1513014 100644 --- a/src/execution-world/queries.ts +++ b/src/execution-world/queries.ts @@ -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, }; } diff --git a/src/execution-world/query-trees.ts b/src/execution-world/query-trees.ts new file mode 100644 index 0000000..573ff72 --- /dev/null +++ b/src/execution-world/query-trees.ts @@ -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); +} diff --git a/src/query/compile.ts b/src/query/compile.ts index 6711e72..3835402 100644 --- a/src/query/compile.ts +++ b/src/query/compile.ts @@ -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(); + 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) diff --git a/src/query/schema.ts b/src/query/schema.ts index a4a91e3..a28b96b 100644 --- a/src/query/schema.ts +++ b/src/query/schema.ts @@ -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(); diff --git a/src/query/tree.ts b/src/query/tree.ts new file mode 100644 index 0000000..e2c6e81 --- /dev/null +++ b/src/query/tree.ts @@ -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(); + 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(); + 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"), + }; + }); + }); +} diff --git a/src/query/types.ts b/src/query/types.ts index cfa096c..e127f7a 100644 --- a/src/query/types.ts +++ b/src/query/types.ts @@ -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>; declaration: QueryDeclaration; definitionDigest: string; @@ -115,6 +118,12 @@ export type QueryArgument = | { kind: "list"; values: QueryArgument[] } | { kind: "object"; fields: Record }; 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; +} diff --git a/src/react-build/declarations.ts b/src/react-build/declarations.ts index 429d5f4..6549b1a 100644 --- a/src/react-build/declarations.ts +++ b/src/react-build/declarations.ts @@ -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[]; } 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(); + const preparedTrees = trees.map((value) => { + const tree = object(value, `${name} tree`) as unknown as NonNullable[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); diff --git a/src/react-build/workspace.ts b/src/react-build/workspace.ts index a413d57..5a10463 100644 --- a/src/react-build/workspace.ts +++ b/src/react-build/workspace.ts @@ -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 [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) => { diff --git a/test/emit-portable-reader.ts b/test/emit-portable-reader.ts index d3d548f..a0aeafc 100644 --- a/test/emit-portable-reader.ts +++ b/test/emit-portable-reader.ts @@ -179,3 +179,11 @@ await writeFile( "dist/portable-reader/variants/entries.ts", "export type Entries = " + tsType(entries.valueType) + ";\n", ); + +const { queryTreeFixture } = await import("./query-tree-fixture.js"); +const { generatePackageQueryClientBindings } = await import("../src/execution-world/client-contracts.js"); +const treeFixture = await queryTreeFixture(); +await mkdir("dist/portable-reader/query-tree", { recursive: true }); +await writeFile("dist/portable-reader/query-tree/queries.ts", generatePackageQueryClientBindings(treeFixture.pkg)); +await writeFile("dist/portable-reader/query-tree/queries.json", JSON.stringify(compileQueryProgram(treeFixture.plan))); +await writeFile("dist/portable-reader/query-tree/kernel.json", JSON.stringify(compileKernelProgram(treeFixture.plan))); diff --git a/test/fixtures/query-workspace.ts b/test/fixtures/query-workspace.ts index 9999d65..5f9f320 100644 --- a/test/fixtures/query-workspace.ts +++ b/test/fixtures/query-workspace.ts @@ -8,10 +8,18 @@ export const queryFixtureSource = { repository: "https://query-fixture.example.test/source.git", commit: "1".repeat(40), }; -export async function queryWorkspaceFixture(options: { live?: boolean } = {}) { +export async function queryWorkspaceFixture(options: { live?: boolean; snapshot?: boolean } = {}) { const interfaces = new Map(); const sources: Record = {}; function iface(name: string, members: string, parameters = "") { + if (options.snapshot) members = members.replaceAll(" ordered", ""); + if (options.snapshot) + for (const member of ["name", "title", "done", "rank", "assignee", "items"]) + for (const operation of ["get", "resolve"]) + members = members.replace( + `${operation} id "${member}:${operation}";`, + `${operation} id "${member}:${operation}" execution snapshot;`, + ); const text = `${[...interfaces.keys()].map((name) => `import interface ${name};`).join("\n")} interface ${name}${parameters} id "${name}" revision "${name}@1" {${members}}`; sources[name] = text; @@ -117,7 +125,7 @@ export async function queryWorkspaceFixture(options: { live?: boolean } = {}) { ${implementation("Task")} ${implementation("Reminder")} conform Tasks as Collection id "tasks-collection" { private edge Items id "items" { - atom Tasks projection items id "items:forward" many ordered; + atom Tasks projection items id "items:forward" many${options.snapshot ? "" : " ordered"}; interface TaskFacts projection collections id "items:inverse" many; } bind items.resolve to edge Items.items.resolve; diff --git a/test/query-tree-fixture.ts b/test/query-tree-fixture.ts new file mode 100644 index 0000000..5adf6b0 --- /dev/null +++ b/test/query-tree-fixture.ts @@ -0,0 +1,37 @@ +import { queryWorkspaceFixture } from "./fixtures/query-workspace.js"; +import { compileQuery } from "../src/query/compile.js"; +import { queryTreeProjections } from "../src/query/tree.js"; +import { linkQueries } from "../src/query/link.js"; +import { compileExecutionPlan } from "../src/execution-world/plan.js"; + +export async function queryTreeFixture() { + const fixture = await queryWorkspaceFixture({ live: true, snapshot: true }); + const declaration = { + ...fixture.pkg.queries!.find((query) => query.id === "upcoming")!, + id: "tree", + displayName: "Tree", + operation: "Tree", + placement: { kind: "replica" as const }, + fragments: [], + }; + const checked = await compileQuery( + declaration, + fixture.interfaces, + async () => `query Tree($first:Int!, $details:Boolean!) { + root {items(first:$first) {entries {node { + ...Heading @boundary(name:"basic") ...Details @boundary(name:"details") @include(if:$details) + assignee {...Person @boundary(name:"person")} + }}}} + } fragment Heading on TaskFacts {title} fragment Details on TaskFacts {rank done} + fragment Person on PersonFacts {name}`, + ); + const projections = queryTreeProjections(checked); + fixture.pkg.queries = [declaration, ...projections.map((query) => query.declaration)]; + fixture.pkg.checkedQueries = [checked, ...projections]; + // Workspace imports retain the checked Package by value in the assembler. + const imported = fixture.workspace.packageImports.find((pkg) => pkg.revisionId === fixture.pkg.revisionId)!; + imported.queries = fixture.pkg.queries; + imported.checkedQueries = fixture.pkg.checkedQueries; + fixture.workspace.linkedQueries = linkQueries(fixture.workspace); + return { ...fixture, plan: compileExecutionPlan(fixture.workspace) }; +} diff --git a/test/query-tree.test.ts b/test/query-tree.test.ts new file mode 100644 index 0000000..6e915f5 --- /dev/null +++ b/test/query-tree.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compileCapabilityResourceSource } from "../src/capability-language/index.js"; +import { compileQuery } from "../src/query/compile.js"; +import type { InterfaceRevision } from "../src/capability-model/types.js"; + +const source = { repository: "https://example.test/tree", commit: "a".repeat(40) }; +function iface(text: string, dependencies: InterfaceRevision[] = []) { + const result = compileCapabilityResourceSource(text, { + source, + environment: { + interfaces: new Map(dependencies.map((value) => [value.displayName, value])), + interfaceClosure: dependencies, + }, + }); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + if (result.resource.kind !== "interface") throw Error("Expected Interface"); + return result.resource.revision; +} +const item = iface(`interface Item id "item" revision "item@1" { + queryable value title id "title" : string {get id "title:get" execution snapshot;} + queryable value body id "body" : string {get id "body:get" execution snapshot;} +}`); +const navigation = iface( + `import interface Item; interface Navigation id "nav" revision "nav@1" { + queryable value chrome id "chrome" : string {get id "chrome:get" execution snapshot;} + queryable relation document id "document" : optional-one interface Item {resolve id "document:resolve" execution snapshot;} + queryable relation items id "items" : many interface Item ordered {resolve id "items:resolve" execution snapshot;} +}`, + [item], +); +function declaration(root: InterfaceRevision = item) { + const result = compileCapabilityResourceSource( + `import interface ${root.displayName}; package Views id "views" revision "views@1" { + query Open id "open" root ${root.displayName} document "open.graphql" operation "Open" {replica; max rows 10;} + }`, + { source, environment: { interfaces: new Map([[root.displayName, root]]), interfaceClosure: [root, item] } }, + ); + assert.ok(result.ok, JSON.stringify(result.diagnostics)); + if (result.resource.kind !== "package") throw Error("Expected Package"); + return result.resource.revision.queries![0]!; +} +const fragments = `fragment Heading on Item {title} fragment Body on Item {body}`; +test("boundaries remain complete typed projections; ordinary spreads stay inline", async () => { + const checked = await compileQuery( + declaration(), + [item], + async () => `query Open {root {...Heading ...Body @boundary(name:"body")}} ${fragments}`, + ); + assert(checked.tree); + assert(!JSON.stringify(checked.output).includes('"body"')); + assert(!JSON.stringify(checked.selection).includes('"body"')); + const body = checked.tree.nodes.find((node) => node.id === "root/boundary/body")!; + assert(body); + assert( + JSON.stringify(checked.tree.projections.find((projection) => projection.id === body.projection)!.output).includes( + '"body"', + ), + ); + assert.equal(checked.tree.nodes[0]!.boundaries[0]!.name, "body"); + const ordinary = await compileQuery( + declaration(), + [item], + async () => `query Open {root {...Heading ...Body}} ${fragments}`, + ); + assert.equal(ordinary.tree, undefined); + assert(JSON.stringify(ordinary.output).includes('"body"')); +}); +test("minimal nullable-root bindings exclude slow siblings and carry child handles", async () => { + const checked = await compileQuery( + declaration(navigation), + [item, navigation], + async () => `query Open {root { + ...Chrome @boundary(name:"chrome") document {...Heading @boundary(name:"basic") ...Body @boundary(name:"body")} + }} fragment Chrome on Navigation {chrome} ${fragments}`, + ); + const tree = checked.tree!; + const binding = tree.nodes[0]!.bindings[0]!; + assert.equal(binding.name, "document"); + const projection = tree.projections.find((projection) => projection.id === binding.projection)!; + assert(!JSON.stringify(projection.selection).includes('"chrome"')); + assert(!JSON.stringify(projection.selection).includes('"title"')); + assert(!JSON.stringify(projection.selection).includes('"body"')); + assert.deepEqual(binding.targets[0]!.path, ["document"]); + assert.deepEqual( + tree.nodes.find((node) => node.id === binding.targets[0]!.node)!.boundaries.map((boundary) => boundary.name), + ["basic", "body"], + ); +}); +test("bounded list bindings preserve entry identity without requesting details", async () => { + const checked = await compileQuery( + declaration(navigation), + [item, navigation], + async () => `query Open {root { + items(first: 3) {entries {node {...Body @boundary(name:"body")}}} + }} fragment Body on Item {body}`, + ); + const tree = checked.tree!, + binding = tree.nodes[0]!.bindings[0]!; + assert.deepEqual(binding.targets[0]!.path, ["items", "entries", "[]", "node"]); + const projection = tree.projections.find((projection) => projection.id === binding.projection)!; + assert(JSON.stringify(projection.selection).includes('"name":"key"')); + assert(!JSON.stringify(projection.selection).includes('"name":"body"')); + assert(tree.maxOccurrences <= 256); +}); +test("guards control boundary participation and invalid syntax fails checking", async () => { + const checked = await compileQuery( + declaration(), + [item], + async () => `query Open($show:Boolean!) {root { + ...Body @boundary(name:"body") @include(if:$show) + }} fragment Body on Item {body}`, + ); + assert.deepEqual(checked.tree!.nodes[0]!.boundaries[0]!.conditions, [ + { include: true, value: { kind: "variable", name: "show" } }, + ]); + for (const [text, error] of [ + [`query Open {root {...Body @boundary(name:"")}} fragment Body on Item {body}`, /QUERY_BOUNDARY_NAME/], + [ + `query Open($name:String!) {root {...Body @boundary(name:$name)}} fragment Body on Item {body}`, + /QUERY_BOUNDARY_NAME/, + ], + [ + `query Open {root {...Body @boundary(name:"x") ...Heading @boundary(name:"x")}} ${fragments}`, + /QUERY_BOUNDARY_DUPLICATE/, + ], + [`query Open {root {body @boundary(name:"x")}}`, /QUERY_VALIDATION/], + [`query Open {root {...Cycle @boundary(name:"x")}} fragment Cycle on Item {...Cycle}`, /QUERY_VALIDATION/], + ] as const) + await assert.rejects( + compileQuery(declaration(), [item], async () => text), + error, + ); +}); +test("equivalent repeated paths share bindings; aliases preserve different windows", async () => { + const checked = await compileQuery( + declaration(navigation), + [item, navigation], + async () => `query Open {root { + document {...Heading @boundary(name:"basic")} document {...Body @boundary(name:"body")} + recent:items(first:2) {entries {node {...Body @boundary(name:"body")}}} + earlier:items(first:3) {entries {node {...Body @boundary(name:"body")}}} + }} ${fragments}`, + ); + assert.deepEqual( + checked.tree!.nodes[0]!.bindings.map((binding) => binding.name), + ["document", "recent", "earlier"], + ); + assert.deepEqual( + checked + .tree!.nodes.find((node) => node.id === "root/binding/document")! + .boundaries.map((boundary) => boundary.name), + ["basic", "body"], + ); + await assert.rejects( + compileQuery( + declaration(navigation), + [item, navigation], + async () => `query Open($show:Boolean!) {root { + document @include(if:$show) {...Heading @boundary(name:"basic")} document {...Body @boundary(name:"body")} + }} ${fragments}`, + ), + /QUERY_TREE_BINDING_CONFLICT/, + ); +});