diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index 22a0ef1..e72aa42 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -381,6 +381,27 @@ export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan { query.id, "Local and replica queries must use the same root contract", ); + if (localRoot.kind === "workspace-interface") { + const sourceWindows = source.query.checked.selection + .flatMap((root) => root.selection) + .filter((s) => s.name === "objects"); + for (const selection of query.query.checked.selection + .flatMap((root) => root.selection) + .filter((s) => s.name === "objects")) { + if ( + !sourceWindows.some( + (window) => + canonicalJson({ arguments: window.arguments, conditions: window.conditions }) === + canonicalJson({ arguments: selection.arguments, conditions: selection.conditions }), + ) + ) + throw new ExecutionPlanError( + "REPLICA_WINDOW_MISMATCH", + query.id, + "Local objects must observe the identical confirmed source window; use availableObjects for a local candidate view", + ); + } + } const traversals = (linked: LinkedQuery) => { const paths = new Map }>(); const entry = (key: string) => { diff --git a/src/query/root.ts b/src/query/root.ts index 7d3c244..5a1b604 100644 --- a/src/query/root.ts +++ b/src/query/root.ts @@ -27,7 +27,7 @@ export function queryRootSchema(root: QueryRoot, interfaces: readonly InterfaceR { kind: "relationship", id: workspaceObjectsMemberId, - displayName: local ? "availableObjects" : "objects", + displayName: "objects", target: { kind: "interface", interfaceRevisionId: root.interface }, cardinality: "many-unique", ordered: false, @@ -36,5 +36,8 @@ export function queryRootSchema(root: QueryRoot, interfaces: readonly InterfaceR }, ], }; + // Local objects observes the confirmed server window. availableObjects is the + // separate opt-in view that also includes pending local candidates. + if (local) synthetic.members.push({ ...synthetic.members[0]!, displayName: "availableObjects" }); return { interface: synthetic.revisionId, interfaces: [...interfaces, synthetic] }; } diff --git a/test/execution-contracts.test.ts b/test/execution-contracts.test.ts index 35b3f06..a80e263 100644 --- a/test/execution-contracts.test.ts +++ b/test/execution-contracts.test.ts @@ -89,7 +89,7 @@ package Reader id "package:reader" revision "package:reader@1" { assert.ok(transaction.ok, JSON.stringify(transaction.diagnostics)); }); -test("query placement round-trips and local extents never claim complete membership", async () => { +test("query placement distinguishes confirmed windows from local candidate extents", async () => { const { compileQuery } = await import("../src/query/compile.js"); const { linkQueries } = await import("../src/query/link.js"); const { toBinary, fromBinary, fromJson } = await import("@bufbuild/protobuf"); @@ -125,10 +125,8 @@ package Reader id "package:reader" revision "package:reader@1" { await compileQuery(admission, [iface], async () => document("Visible", "objects")), await compileQuery(local, [iface], async () => document("Presentation", "availableObjects")), ]; - await assert.rejects( - compileQuery(local, [iface], async () => document("Presentation", "objects")), - /Cannot query field "objects"/, - ); + const confirmed = await compileQuery(local, [iface], async () => document("Presentation", "objects")); + assert.equal(confirmed.selection[0]!.selection[0]!.name, "objects"); const remote = structuredClone(iface); delete remote.members[0]!.operations[0]!.execution; await assert.rejects( diff --git a/test/query-cursor-validation.test.ts b/test/query-cursor-validation.test.ts index 0fc6df2..2e23986 100644 --- a/test/query-cursor-validation.test.ts +++ b/test/query-cursor-validation.test.ts @@ -18,3 +18,17 @@ test("local relationship pages may reuse exactly the confirmed source window", a }); assert.doesNotThrow(() => compileExecutionPlan(workspace)); }); + +test("local workspace objects reads the exact server window without adding local candidates", async () => { + const load = (first: number) => + loadReaderWorkspace((name, text) => { + if (name === "ReaderRuntime.qx") return text.replace(/^.*query HighlightList id.*$/m, ""); + if (name === "queries/highlight-list.graphql") + return text.replace("availableObjects(all: 200", `objects(first: ${first}`); + return text; + }); + const workspace = await load(2); + assert.doesNotThrow(() => compileExecutionPlan(workspace)); + const wrong = await load(3); + assert.throws(() => compileExecutionPlan(wrong), /REPLICA_WINDOW_MISMATCH/); +});