diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index 4fe1390..22a0ef1 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -382,12 +382,20 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { "Local and replica queries must use the same root contract", ); const traversals = (linked: LinkedQuery) => { - const paths = new Map(); + const paths = new Map }>(); + const entry = (key: string) => { + let value = paths.get(key); + if (!value) { + value = { complete: false, requiresComplete: false, windows: new Set() }; + paths.set(key, value); + } + return value; + }; const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => { for (const selection of selections) { for (const stage of selection.relational?.stages ?? []) { if (stage.operation.kind === "source") - paths.set(JSON.stringify([...parents, stage.operation.memberId]), false); + entry(JSON.stringify([...parents, stage.operation.memberId])).requiresComplete = true; } const member = selection.interfaceRevisionId ? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId) @@ -397,7 +405,11 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { const many = member.cardinality === "many" || member.cardinality === "many-unique"; const complete = !selection.predicate && (!many || !!selection.arguments.all); const key = JSON.stringify(path); - paths.set(key, complete || paths.get(key) === true); + const value = entry(key); + value.complete ||= complete; + if (selection.arguments.first) + value.windows.add(canonicalJson({ arguments: selection.arguments, conditions: selection.conditions })); + else value.requiresComplete = true; } visit(selection.selection, path); } @@ -406,18 +418,23 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { return paths; }; const admittedTraversals = traversals(source.query); - for (const traversal of traversals(query.query).keys()) { + for (const [traversal, needed] of traversals(query.query)) { if (!admittedTraversals.has(traversal)) throw new ExecutionPlanError( "REPLICA_TRAVERSAL_MISSING", query.id, `Replica source does not admit relationship path ${traversal}`, ); - if (!admittedTraversals.get(traversal)) + if ( + !admittedTraversals.get(traversal)!.complete && + (needed.requiresComplete || + !needed.windows.size || + [...needed.windows].some((window) => !admittedTraversals.get(traversal)!.windows.has(window))) + ) throw new ExecutionPlanError( "REPLICA_RELATION_INCOMPLETE", query.id, - `Local relationship ${traversal} needs an unfiltered bounded all selection in its replica source; a filtered/windowed source cannot supply unseen matches`, + `Local relationship ${traversal} needs either complete source candidates or the identical confirmed first window; a window cannot supply unseen matches`, ); } // The admission executor follows these canonical dependencies on the admitted roots. diff --git a/src/execution-world/query-operators.ts b/src/execution-world/query-operators.ts index 16e0abf..3555478 100644 --- a/src/execution-world/query-operators.ts +++ b/src/execution-world/query-operators.ts @@ -22,8 +22,6 @@ export function unsupportedQueryOperator(query: CheckedQuery): string | undefine selections.some((selection) => selection.conditions.length > 0 || conditional(selection.selection)); const walk = (selections: readonly QuerySelection[]): string | undefined => { for (const selection of selections) { - const after = selection.arguments.after; - if (after && !(after.kind === "literal" && after.value === null)) return "cursor continuation"; if (selection.predicate) { const reason = predicate(selection.predicate); if (reason) return reason; diff --git a/test/query-aggregation.test.ts b/test/query-aggregation.test.ts index 5a563d8..1ecd2b8 100644 --- a/test/query-aggregation.test.ts +++ b/test/query-aggregation.test.ts @@ -112,6 +112,14 @@ test("typed reference operands and scalar membership tests reject unbounded lite test("new runtime operator matrix accepts direct count and rejects unsupported relational plans", async () => { const { unsupportedQueryOperator } = await import("../src/execution-world/query-operators.js"); assert.equal(unsupportedQueryOperator(await compile("aggregate { count }")), undefined); + assert.equal( + unsupportedQueryOperator( + await compileAggregationDocument( + `query Report($after: Cursor) {root {tasks(first: 30, after: $after, orderBy: [{cost: ASC}]) {entries {cursor node {cost}} pageInfo {hasNextPage endCursor}}}}`, + ), + ), + undefined, + ); for (const expression of [ "aggregate { sum { estimatedHours } }", "filter(where: {estimatedHours: {gt: 0}}) { aggregate { count } }", diff --git a/test/query-cursor-validation.test.ts b/test/query-cursor-validation.test.ts new file mode 100644 index 0000000..0fc6df2 --- /dev/null +++ b/test/query-cursor-validation.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { loadReaderWorkspace } from "./reader-fixture.js"; +import { compileExecutionPlan } from "../src/execution-world/index.js"; +test("local relationship pages may reuse exactly the confirmed source window", async () => { + const workspace = await loadReaderWorkspace((name, text) => { + if (name === "queries/replica-inputs.graphql") + return text.replace("highlights(all: 1000)", "highlights(first: 10)"); + if (name === "queries/reader-screen.graphql") + return text + .replace("ReaderScreen($color: String!)", "ReaderScreen") + .replace( + "highlights(all: 1000, where: {color: {eq: $color}}, orderBy: [{range: {start: ASC}}])", + "highlights(first: 10)", + ) + .replace("_camino { relations { highlights { aggregate { count } } } }", ""); + return text; + }); + assert.doesNotThrow(() => compileExecutionPlan(workspace)); +});