Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions webui/src/features/board/harness/graph/subtree.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})

Expand Down Expand Up @@ -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)
})
})
15 changes: 13 additions & 2 deletions webui/src/features/board/harness/graph/subtree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading