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
5 changes: 5 additions & 0 deletions webui/src/features/board/harness/canvas/use-board-sync-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { dedupeRepeatUpdates } from "../sync/outbound-dedupe"
import { ReconnectSupervisor } from "../sync/reconnect-supervisor"
import { attachBoardSync } from "../sync/board-sync"
import type { BoardSyncHandle } from "../sync/board-sync"
import { setBoardSyncRef } from "../sync/board-sync-ref"
import { createWebSocketRelay } from "../sync/ws-relay"
import { applyGraphToStore } from "../persist/snapshot-load"
import { applyContentToStore } from "@/features/board/persist/local/apply-content"
Expand Down Expand Up @@ -152,6 +153,9 @@ export const useBoardSyncV2 = (
enrichOutbound: (batch) => enrichEdgeMidpoints(dedupeRepeatUpdates(batch), store),
coalesceMs: 75, // merge a burst (e.g. rotate's per-tick ops) into one send
})
// Publish the sync-correct intake so headless/cross-layer writers enter
// the same rebase + send path instead of writing the oplog directly.
setBoardSyncRef(handle)
})
})
.catch((err) => {
Expand All @@ -167,6 +171,7 @@ export const useBoardSyncV2 = (
cancelled = true
document.removeEventListener("visibilitychange", onVisibility)
supervisor?.stop()
setBoardSyncRef(null)
handle?.detach()
detachPersist?.()
setBoardPersistenceRef(null)
Expand Down
24 changes: 24 additions & 0 deletions webui/src/features/board/harness/sync/board-sync-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { BoardSyncHandle } from "./board-sync"


/**
* Module ref to the active synced board's sync coordinator. Mirrors
* `board-persistence-ref` so code outside the harness (e.g. a headless,
* cross-layer writer) can enter the sync-correct local-batch intake
* (`submitLocalBatch(batch, { scene: false })`) instead of writing the oplog
* directly — a direct write skips the send trigger, so the batch ships only
* opportunistically on the next unrelated pump and desyncs a synced board.
*
* Set by the v2 sync mount, cleared on unmount / scope change. `null` on a
* purely local board (no relay) — callers fall back to the persistence ref,
* which is sync-correct there because a local board has no outbox to desync.
*/
let _sync: BoardSyncHandle | null = null


export const setBoardSyncRef = (s: BoardSyncHandle | null): void => {
_sync = s
}


export const getBoardSyncRef = (): BoardSyncHandle | null => _sync
45 changes: 44 additions & 1 deletion webui/src/features/board/harness/sync/board-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"
import { asBatchId, asClientId, asNodeId, type OpBatch } from "@canvas-harness/core"
import { asBatchId, asClientId, asNodeId, type Node, type OpBatch } from "@canvas-harness/core"
import type { CanvasStore } from "@canvas-harness/core"
import { makeBatch } from "@/features/board/harness/make-batch"
import { addEdge, addNode, freshStore } from "@/test/canvas"
import type { DimNodeData } from "@/features/board/model"
import { labelText } from "@/features/board/model"
Expand Down Expand Up @@ -107,6 +108,48 @@ const setLabel = (store: CanvasStore, id: string, label: string): void => {


describe("board sync coordinator", () => {
it("submitLocalBatch(scene:false) ships a headless batch without injecting it into the local scene", async () => {
const relay = new MemoryRelay()
const a = makeClient(relay, "A")
const b = makeClient(relay, "B")

// Seed an in-scene edit on A so there IS a rebasable local entry, to prove
// the headless batch is treated differently from a store commit.
addNode(a.store, "local1", "in-scene")

// A headless producer: a batch NOT applied to A's live store (e.g. a
// cross-layer write to an unloaded folder). It records to the oplog itself,
// then enters the intake as an off-scene batch — no direct store write.
const node = {
id: asNodeId("h1"),
type: "rect",
x: 0,
y: 0,
w: 100,
h: 50,
angle: 0,
groups: [],
data: { label: { markdown: "headless" }, meta: { v: 1, createdAt: 0, updatedAt: 0 } },
} as unknown as Node
const batch = makeBatch(a.store, "local", [{ type: "node.add", node }])
a.persistence.record(batch)
a.sync.submitLocalBatch(batch, { scene: false })
await a.sync.settle()
await b.sync.settle()

// The peer converges from the headless write (it shipped through the outbox),
// but it never lands in the submitter's own loaded scene.
expect(ids(b.store)).toEqual(["h1", "local1"])
expect(ids(a.store)).toEqual(["local1"]) // h1 was never applied to A's store

// A concurrent remote op triggers a rebase. Because the headless batch is NOT
// in the rebase set, its off-scene ops must NOT be injected into A's scene.
addNode(b.store, "b1", "peer")
await b.sync.settle()
await a.sync.settle()
expect(ids(a.store)).toEqual(["b1", "local1"]) // still no h1
})

it("propagates a local edit to the other client (no self-echo, no dupes)", async () => {
const relay = new MemoryRelay()
const a = makeClient(relay, "A")
Expand Down
42 changes: 36 additions & 6 deletions webui/src/features/board/harness/sync/board-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export type BoardSyncOptions = {


export type BoardSyncHandle = {
/**
* The single sync-correct intake for a locally-produced batch: (maybe) track
* it for rebase and trigger a pump, so it ships through the outbox rather than
* a direct oplog write. The store producer (via `attachSync.sendBatch`) is one
* caller; a headless / off-scene producer is another.
*
* `scene: false` marks a batch whose ops are NOT in the loaded store (a
* cross-layer / headless write): it is pumped but kept OUT of the rebase set,
* since replaying its ops would inject off-layer nodes into the current scene.
* A `scene: false` caller must record the batch to the oplog itself (store
* commits are already recorded by `persistence`).
*/
submitLocalBatch: (batch: OpBatch, opts?: { scene?: boolean }) => void
/** Detach sync + persistence wiring and close the connection. */
detach: () => void
/** Simulate going offline (close connection; keep editing locally). */
Expand Down Expand Up @@ -305,14 +318,30 @@ export const attachBoardSync = (opts: BoardSyncOptions): BoardSyncHandle => {
enqueue(pump)
}

// The single intake for a locally-produced batch's SYNC side: (maybe) track it
// for rebase and trigger a pump. The send source is the outbox, so the batch
// object is only used for the in-scene rebase. Both the store producer (via
// `attachSync.sendBatch`) and headless producers route through here.
//
// `scene` splits the two kinds of local batch — they need different handling:
// - `true` (default, store commits): the ops WERE applied to the live store
// optimistically, so track the batch as an unacked rebase entry — it's
// undone + replayed on top of every remote op so the local edit stays
// "latest". This is today's `sendBatch` behavior, unchanged.
// - `false` (headless / off-scene, e.g. a cross-layer write): the ops are NOT
// in the loaded store, so it must NOT enter the rebase set — `applyRemote`
// would otherwise inject the off-layer ops into the current scene on the
// next remote op. It only needs to be recorded (by the caller) + pumped;
// `serverSeq` is stamped on ack via the outbox path like any sent record.
const submitLocalBatch = (batch: OpBatch, { scene = true }: { scene?: boolean } = {}): void => {
if (scene) pending.set(batch.id, batch)
schedulePump()
}

const adapter: SyncAdapter = {
capabilities: { causalOrdering: true },
// A local commit: track it as unacked (rebase set) and trigger a pump. The
// send source is the outbox, so the batch itself is only used for rebase.
sendBatch: (batch: OpBatch) => {
pending.set(batch.id, batch)
schedulePump()
},
// A local store commit: its ops are in the live scene → rebase-tracked.
sendBatch: (batch: OpBatch) => submitLocalBatch(batch),
sendPresence: (patch: PresencePatch) => {
const state = { ...patch, clientId: opts.clientId } as PresenceState
connection?.send({ kind: "presence", clientId: opts.clientId, state })
Expand All @@ -337,6 +366,7 @@ export const attachBoardSync = (opts: BoardSyncOptions): BoardSyncHandle => {
}

return {
submitLocalBatch,
detach: () => {
clearTimer()
detachSync()
Expand Down
Loading