diff --git a/webui/src/features/board/harness/graph/subtree.test.ts b/webui/src/features/board/harness/graph/subtree.test.ts index 9567559a..bf38da04 100644 --- a/webui/src/features/board/harness/graph/subtree.test.ts +++ b/webui/src/features/board/harness/graph/subtree.test.ts @@ -1,18 +1,21 @@ import { afterEach, describe, expect, it } from "vitest" -import { asNodeId } from "@canvas-harness/core" +import { asNodeId, type OpBatch } from "@canvas-harness/core" import { addEdge, freshStore, resetIdb } from "@/test/canvas" import type { DimNode } from "@/features/board/model" import { getLocalStores } from "@/features/local-stores" import { BoardPersistence } from "@/features/board/persist/local/board-persistence" import { InMemoryEngine } from "@/features/board/persist/local/in-memory-engine" import { setBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref" +import { setBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" +import type { BoardSyncHandle } from "@/features/board/harness/sync/board-sync" import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" import { collectSubtreeIds, removeNodeSubtree, removeNodesSubtreeAsync } from "./subtree" afterEach(() => { - // The persistence ref is a module singleton — isolate tests. + // The refs are module singletons — isolate tests. setBoardPersistenceRef(null) + setBoardSyncRef(null) useBoardAppStore.setState({ boardId: null }) }) @@ -191,4 +194,39 @@ describe("removeNodeSubtree", () => { // Whole board: the deeper descendants were swept from the oplog too. expect((await persistence.load()).nodes.map((n) => n.id).sort()).toEqual(["s"]) }) + + + it("routes the deep-layer sweep through the sync intake (scene:false) on a synced board", async () => { + const engine = new InMemoryEngine() + const persistence = new BoardPersistence("b", { engine }) + + // Seed the whole board: F at root, a deeper child c1 (an unloaded layer). + const full = freshStore("seed") + const unsub = persistence.attach(full) + addChild(full, "F") + addChild(full, "c1", "F") + await persistence.flush() + unsub() + + const live = freshStore("live") + addChild(live, "F") // only the folder is loaded at the parent layer + persistence.attach(live) + setBoardPersistenceRef(persistence) + + // Spy sync ref: capture how the deep-layer batch enters the coordinator. + const submitted: { batch: { ops: { type: string }[] }; scene?: boolean }[] = [] + setBoardSyncRef({ + submitLocalBatch: (batch: OpBatch, opts?: { scene?: boolean }) => + submitted.push({ batch: batch as unknown as { ops: { type: string }[] }, scene: opts?.scene }), + } as unknown as BoardSyncHandle) + + await removeNodesSubtreeAsync(live, [asNodeId("F")]) + + // The deep sweep entered submitLocalBatch exactly once, off-scene (not + // rebase-tracked), carrying the deeper node's removal — so a synced board + // pumps it to the relay instead of leaving it to ship opportunistically. + expect(submitted).toHaveLength(1) + expect(submitted[0].scene).toBe(false) + expect(submitted[0].batch.ops.some((op) => op.type === "node.remove")).toBe(true) + }) }) diff --git a/webui/src/features/board/harness/graph/subtree.ts b/webui/src/features/board/harness/graph/subtree.ts index 3e6575ce..5727ed35 100644 --- a/webui/src/features/board/harness/graph/subtree.ts +++ b/webui/src/features/board/harness/graph/subtree.ts @@ -7,7 +7,9 @@ * store, in one batch (so a single undo restores them, edges included). * 2. Deeper layers — a folder's children live in a deeper layer that isn't in * the store. Sweep them from the WHOLE board via persistence (the oplog - * covers every layer), recording their removal so they don't orphan. + * covers every layer), recording their removal so they don't orphan, then + * enter the sync intake (`submitLocalBatch`, off-scene) so a synced board + * pumps them to the relay promptly instead of only on the next edit. * * On backend boards there's no local persistence ref, so only step 1 runs and the * server performs its own cascade (unchanged behaviour). @@ -16,6 +18,7 @@ import type { CanvasStore, NodeId, Op } from "@canvas-harness/core" import type { OpBatch } from "@canvas-harness/core" import type { DimNode } from "@/features/board/model" import { getBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref" +import { getBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" import { makeBatch } from "@/features/board/harness/make-batch" import { isDurableDelete } from "@/features/board/harness/node-types/durable-delete" import { cascadeRemovedDocs } from "@/features/board/harness/agent/use-doc-node-cascade" @@ -96,8 +99,16 @@ const sweepDeepDescendants = async ( ...removeEdges.map((edge) => ({ type: "edge.remove", edge }) as Op), ...removeNodes.map((node) => ({ type: "node.remove", node }) as Op), ] - persistence.record(makeBatch(store, origin, ops)) + const batch = makeBatch(store, origin, ops) + persistence.record(batch) await persistence.flush() + // On a synced board, enter the sync-correct intake so the batch pumps to the + // relay promptly (and is serverSeq-stamped on ack) instead of shipping only + // opportunistically on the next unrelated edit. `scene: false` — these ops + // target unloaded layers, so they must NOT join the in-scene rebase set. On a + // local board there's no sync ref (null) and the oplog record alone is + // sync-correct (no outbox to desync). + getBoardSyncRef()?.submitLocalBatch(batch, { scene: false }) return removeNodes.filter((n) => n.type === "document").map((n) => String(n.id)) } catch (err) { console.warn("[harness] deep subtree cascade failed", err)