web based infinite canvas

feat: implement persistence manager and cursor update handling

+490 -29
+11 -11
TODO.txt
··· 146 146 N1. Define the StatusBar view model (core, pure TS) 147 147 ------------------------------------------------------------------------------ 148 148 149 - /packages/core/src/ui/statusbar/types.ts 150 - [ ] Define StatusBarVM (single object the UI renders): 149 + /packages/core/src/ui/statusbar.ts 150 + [x] Define StatusBarVM (single object the UI renders): 151 151 - cursorWorld: { x, y } " world coords (always) 152 152 - cursorScreen: { x, y }? " optional dev-only 153 153 - zoomPct: number " e.g. 100, 67, 250 ··· 179 179 N2. Provide selectors / derivations for StatusBarVM 180 180 ------------------------------------------------------------------------------ 181 181 182 - /packages/core/src/ui/statusbar/selectors.ts 183 - [ ] Implement pure functions: 182 + /packages/core/src/ui/statusbar.ts 183 + [x] Implement pure functions: 184 184 - getZoomPct(state) -> number 185 185 - getToolId(state) -> ToolId 186 186 - getSelectionSummary(state) -> { count, kind?, bounds? } 187 187 - getSnapSummary(state) -> snap summary (default safe values) 188 188 189 189 Cursor position source: 190 - [ ] Define a minimal CursorState in core (NOT persisted): 190 + [x] Define a minimal CursorState in core (NOT persisted): 191 191 - cursorWorld: Vec2 192 192 - cursorScreen?: Vec2 193 193 - lastMoveAt: number 194 194 195 - [ ] Add updateCursor(world, screen?) action + reducer handler (or direct setter) 195 + [x] Add updateCursor(world, screen?) action + reducer handler (or direct setter) 196 196 that ONLY touches CursorState (no history command, no persistence). 197 197 198 198 (DoD): ··· 202 202 N3. Wire cursor updates from pointer movement (apps/web) 203 203 ------------------------------------------------------------------------------ 204 204 205 - /apps/web/src/lib/pointer.ts 206 - [ ] On pointermove (or mousemove when not captured): 205 + /apps/web/src/lib/input.ts 206 + [x] On pointermove (or mousemove when not captured): 207 207 - compute world coords using camera.screenToWorld 208 208 - dispatch updateCursor(world, screen) 209 209 210 210 Performance: 211 - [ ] Throttle cursor updates: 211 + [x] Throttle cursor updates: 212 212 - v0: requestAnimationFrame coalescing (only publish latest per frame) 213 213 - avoid flooding render/history/persistence 214 214 ··· 224 224 done; persistence is already hooked to history in Milestone M). 225 225 226 226 /apps/web/src/lib/status.ts 227 - [ ] Extend your persistence sink (from Milestone M) to expose a small status: 227 + [x] Extend your persistence sink (from Milestone M) to expose a small status: 228 228 - pendingWrites counter (increment on enqueue, decrement on commit) 229 229 - lastSavedAt timestamp (set on successful commit) 230 230 - lastError (set on failed commit) 231 - [ ] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB 231 + [x] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB 232 232 and reflect it in the UI (helps confirm persisted state across tabs). 233 233 234 234 (DoD):
+18 -4
apps/web/src/lib/canvas/Canvas.svelte
··· 2 2 import HistoryViewer from '$lib/components/HistoryViewer.svelte'; 3 3 import Toolbar from '$lib/components/Toolbar.svelte'; 4 4 import { createInputAdapter, type InputAdapter } from '$lib/input'; 5 + import { createPersistenceManager } from '$lib/status'; 5 6 import { 6 7 ArrowTool, 8 + CursorStore, 7 9 EditorState, 8 10 EllipseTool, 9 11 InkfiniteDB, ··· 13 15 SnapshotCommand, 14 16 Store, 15 17 TextTool, 16 - createPersistenceSink, 17 18 createToolMap, 18 19 createWebDocRepo, 19 20 diffDoc, ··· 22 23 type Action, 23 24 type CommandKind, 24 25 type LoadedDoc, 26 + type PersistenceSink, 25 27 type ToolId, 26 28 type Viewport 27 29 } from 'inkfinite-core'; ··· 29 31 import { onDestroy, onMount } from 'svelte'; 30 32 31 33 let repo: ReturnType<typeof createWebDocRepo> | null = null; 32 - let sink: ReturnType<typeof createPersistenceSink> | null = null; 34 + let sink: PersistenceSink | null = null; 35 + let persistenceManager: ReturnType<typeof createPersistenceManager> | null = null; 33 36 let activeBoardId: string | null = null; 34 37 35 38 const store = new Store(undefined, { ··· 41 44 sink.enqueueDocPatch(activeBoardId, patch); 42 45 } 43 46 }); 47 + const cursorStore = new CursorStore(); 44 48 45 49 function applyLoadedDoc(doc: LoadedDoc) { 46 50 const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; ··· 151 155 onMount(() => { 152 156 const db = new InkfiniteDB(); 153 157 repo = createWebDocRepo(db); 154 - sink = createPersistenceSink(repo, { debounceMs: 200 }); 158 + persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); 159 + sink = persistenceManager.sink; 155 160 let disposed = false; 156 161 157 162 const hydrate = async () => { ··· 168 173 activeBoardId = id; 169 174 const loaded = await repoInstance.loadDoc(id); 170 175 if (!disposed) { 176 + persistenceManager?.setActiveBoard(id); 171 177 applyLoadedDoc(loaded); 172 178 } 173 179 } catch (error) { ··· 187 193 } 188 194 189 195 renderer = createRenderer(canvas, store); 190 - inputAdapter = createInputAdapter({ canvas, getCamera, getViewport, onAction: handleAction }); 196 + inputAdapter = createInputAdapter({ 197 + canvas, 198 + getCamera, 199 + getViewport, 200 + onAction: handleAction, 201 + onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) 202 + }); 191 203 192 204 function handleBeforeUnload() { 193 205 if (sink) { ··· 212 224 repo = null; 213 225 sink = null; 214 226 activeBoardId = null; 227 + persistenceManager?.dispose(); 228 + persistenceManager = null; 215 229 }); 216 230 </script> 217 231
+70 -11
apps/web/src/lib/input.ts
··· 1 - import { Action, type Action as ActionType, Camera, Modifiers, PointerButtons, type Viewport } from "inkfinite-core"; 1 + import { 2 + Action, 3 + type Action as ActionType, 4 + Camera, 5 + Modifiers, 6 + PointerButtons, 7 + type Vec2, 8 + type Viewport, 9 + } from "inkfinite-core"; 2 10 3 11 /** 4 12 * Pointer state tracked by the input adapter ··· 30 38 getViewport: () => Viewport; 31 39 /** Callback for dispatching actions */ 32 40 onAction: (action: ActionType) => void; 41 + /** Optional callback for raw cursor updates */ 42 + onCursorUpdate?: (world: Vec2, screen: Vec2) => void; 33 43 /** Whether to prevent default browser behavior (default: true) */ 34 44 preventDefault?: boolean; 35 45 /** Whether to capture keyboard events on window (default: true) */ ··· 49 59 * - Dispatches normalized actions 50 60 */ 51 61 export class InputAdapter { 52 - private config: Required<InputAdapterConfig>; 62 + private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; 53 63 private pointerState: PointerState; 54 64 private boundHandlers: { 55 65 pointerDown: (e: PointerEvent) => void; ··· 60 70 keyUp: (e: KeyboardEvent) => void; 61 71 contextMenu: (e: Event) => void; 62 72 }; 73 + private cursorUpdateFrame: number | null; 74 + private pendingCursorWorld: Vec2 | null; 75 + private pendingCursorScreen: Vec2 | null; 63 76 64 77 constructor(config: InputAdapterConfig) { 65 78 this.config = { ··· 86 99 keyUp: this.handleKeyUp.bind(this), 87 100 contextMenu: this.handleContextMenu.bind(this), 88 101 }; 102 + this.cursorUpdateFrame = null; 103 + this.pendingCursorWorld = null; 104 + this.pendingCursorScreen = null; 89 105 90 106 this.attach(); 91 107 } ··· 131 147 window.removeEventListener("keydown", this.boundHandlers.keyDown); 132 148 window.removeEventListener("keyup", this.boundHandlers.keyUp); 133 149 } 150 + 151 + if ( 152 + this.cursorUpdateFrame !== null 153 + && typeof window !== "undefined" 154 + && typeof window.cancelAnimationFrame === "function" 155 + ) { 156 + window.cancelAnimationFrame(this.cursorUpdateFrame); 157 + this.cursorUpdateFrame = null; 158 + } 134 159 } 135 160 136 161 /** ··· 191 216 this.pointerState.buttons = buttons; 192 217 193 218 this.config.onAction(Action.pointerMove(screen, world, buttons, modifiers)); 219 + this.queueCursorUpdate(world, screen); 194 220 } 195 221 196 222 /** ··· 235 261 this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers)); 236 262 } 237 263 238 - /** 239 - * Handle key down event 240 - */ 241 264 private handleKeyDown(e: KeyboardEvent): void { 242 265 const target = e.target as HTMLElement; 243 266 if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { ··· 253 276 } 254 277 } 255 278 256 - /** 257 - * Handle key up event 258 - */ 259 279 private handleKeyUp(e: KeyboardEvent): void { 260 280 const target = e.target as HTMLElement; 261 281 if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { ··· 266 286 this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); 267 287 } 268 288 269 - /** 270 - * Handle context menu event (prevent default) 271 - */ 272 289 private handleContextMenu(e: Event): void { 273 290 if (this.config.preventDefault) { 274 291 e.preventDefault(); 275 292 } 293 + } 294 + 295 + /** 296 + * Throttle cursor updates using requestAnimationFrame. 297 + */ 298 + private queueCursorUpdate(world: Vec2, screen: Vec2): void { 299 + if (!this.config.onCursorUpdate) { 300 + return; 301 + } 302 + 303 + this.pendingCursorWorld = { x: world.x, y: world.y }; 304 + this.pendingCursorScreen = { x: screen.x, y: screen.y }; 305 + 306 + if (this.cursorUpdateFrame !== null) { 307 + return; 308 + } 309 + 310 + const schedule = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" 311 + ? window.requestAnimationFrame.bind(window) 312 + : null; 313 + 314 + if (!schedule) { 315 + this.flushCursorUpdate(); 316 + return; 317 + } 318 + 319 + this.cursorUpdateFrame = schedule(() => { 320 + this.cursorUpdateFrame = null; 321 + this.flushCursorUpdate(); 322 + }); 323 + } 324 + 325 + private flushCursorUpdate(): void { 326 + if (!this.config.onCursorUpdate || !this.pendingCursorWorld || !this.pendingCursorScreen) { 327 + this.pendingCursorWorld = null; 328 + this.pendingCursorScreen = null; 329 + return; 330 + } 331 + 332 + this.config.onCursorUpdate(this.pendingCursorWorld, this.pendingCursorScreen); 333 + this.pendingCursorWorld = null; 334 + this.pendingCursorScreen = null; 276 335 } 277 336 278 337 /**
+168
apps/web/src/lib/status.ts
··· 1 + import { liveQuery } from "dexie"; 2 + import { 3 + createPersistenceSink, 4 + type DocPatch, 5 + type DocRepo, 6 + type PersistenceSink, 7 + type PersistenceSinkOptions, 8 + } from "inkfinite-core"; 9 + import type { InkfiniteDB, PersistenceStatus } from "inkfinite-core"; 10 + 11 + type StatusListener = (status: PersistenceStatus) => void; 12 + 13 + export type StatusStore = { 14 + get(): PersistenceStatus; 15 + subscribe(listener: StatusListener): () => void; 16 + update(updater: (status: PersistenceStatus) => PersistenceStatus): void; 17 + }; 18 + 19 + type LiveQueryFactory = typeof liveQuery; 20 + 21 + export type PersistenceManagerOptions = { sink?: PersistenceSinkOptions; liveQueryFn?: LiveQueryFactory }; 22 + 23 + export type PersistenceManager = { 24 + sink: PersistenceSink; 25 + status: StatusStore; 26 + setActiveBoard(boardId: string | null): void; 27 + dispose(): void; 28 + }; 29 + 30 + export function createPersistenceManager( 31 + db: InkfiniteDB, 32 + repo: DocRepo, 33 + options?: PersistenceManagerOptions, 34 + ): PersistenceManager { 35 + const sink = createPersistenceSink(repo, options?.sink); 36 + const status = createStatusStore({ backend: "indexeddb", state: "saved", pendingWrites: 0 }); 37 + 38 + let activeBoardId: string | null = null; 39 + let subscription: { unsubscribe(): void } | null = null; 40 + const liveQueryFactory = options?.liveQueryFn ?? liveQuery; 41 + 42 + function incrementPending() { 43 + status.update((current) => ({ 44 + ...current, 45 + pendingWrites: (current.pendingWrites ?? 0) + 1, 46 + state: "saving", 47 + lastError: undefined, 48 + })); 49 + } 50 + 51 + function markSaved(timestamp?: number) { 52 + status.update((current) => ({ 53 + ...current, 54 + pendingWrites: 0, 55 + state: "saved", 56 + lastSavedAt: timestamp ?? current.lastSavedAt, 57 + errorMsg: undefined, 58 + })); 59 + } 60 + 61 + function markError(error: unknown) { 62 + status.update((current) => ({ 63 + ...current, 64 + state: "error", 65 + errorMsg: error instanceof Error ? error.message : String(error), 66 + })); 67 + } 68 + 69 + function setActiveBoard(boardId: string | null) { 70 + if (activeBoardId === boardId) { 71 + return; 72 + } 73 + 74 + subscription?.unsubscribe(); 75 + subscription = null; 76 + activeBoardId = boardId; 77 + 78 + if (!boardId) { 79 + return; 80 + } 81 + 82 + const observable = liveQueryFactory(() => db.boards.get(boardId)); 83 + subscription = observable.subscribe({ 84 + next(board) { 85 + if (board?.updatedAt !== undefined) { 86 + markSaved(board.updatedAt); 87 + } 88 + }, 89 + error(err) { 90 + markError(err); 91 + }, 92 + }); 93 + } 94 + 95 + const trackedSink: PersistenceSink = { 96 + enqueueDocPatch(boardId, patch) { 97 + if (hasPatchChanges(patch)) { 98 + incrementPending(); 99 + } 100 + sink.enqueueDocPatch(boardId, patch); 101 + }, 102 + async flush() { 103 + try { 104 + await sink.flush(); 105 + } catch (error) { 106 + markError(error); 107 + throw error; 108 + } 109 + }, 110 + }; 111 + 112 + return { 113 + sink: trackedSink, 114 + status, 115 + setActiveBoard, 116 + dispose() { 117 + subscription?.unsubscribe(); 118 + subscription = null; 119 + }, 120 + }; 121 + } 122 + 123 + function createStatusStore(initial: PersistenceStatus): StatusStore { 124 + let value = initial; 125 + const listeners = new Set<StatusListener>(); 126 + 127 + return { 128 + get() { 129 + return value; 130 + }, 131 + subscribe(listener: StatusListener) { 132 + listeners.add(listener); 133 + listener(value); 134 + return () => { 135 + listeners.delete(listener); 136 + }; 137 + }, 138 + update(updater) { 139 + value = updater(value); 140 + for (const listener of listeners) { 141 + listener(value); 142 + } 143 + }, 144 + }; 145 + } 146 + 147 + function hasPatchChanges(patch: DocPatch): boolean { 148 + const upserts = patch.upserts; 149 + if (upserts?.pages?.length || upserts?.shapes?.length || upserts?.bindings?.length) { 150 + return true; 151 + } 152 + 153 + const deletes = patch.deletes; 154 + if (deletes?.pageIds?.length || deletes?.shapeIds?.length || deletes?.bindingIds?.length) { 155 + return true; 156 + } 157 + 158 + if (patch.order) { 159 + if (patch.order.pageIds?.length) { 160 + return true; 161 + } 162 + if (patch.order.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0) { 163 + return true; 164 + } 165 + } 166 + 167 + return false; 168 + }
+42 -2
apps/web/src/lib/tests/Canvas.history.test.ts
··· 3 3 4 4 const actionHandlers: Array<(action: any) => void> = []; 5 5 const coreMocks = vi.hoisted(() => ({ sinkEnqueueSpy: vi.fn(), storeInstances: [] as any[] })); 6 + const persistenceMocks = vi.hoisted(() => { 7 + const state = { 8 + instance: null as null | { 9 + sink: { enqueueDocPatch: ReturnType<typeof vi.fn>; flush: ReturnType<typeof vi.fn> }; 10 + status: { 11 + get: () => { backend: string; state: string; pendingWrites: number }; 12 + subscribe: () => () => void; 13 + update: () => void; 14 + }; 15 + setActiveBoard: ReturnType<typeof vi.fn>; 16 + dispose: ReturnType<typeof vi.fn>; 17 + }, 18 + }; 19 + return { 20 + state, 21 + createPersistenceManager: vi.fn(() => { 22 + state.instance = { 23 + sink: { enqueueDocPatch: vi.fn(), flush: vi.fn() }, 24 + status: { 25 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 26 + subscribe: () => () => {}, 27 + update: () => {}, 28 + }, 29 + setActiveBoard: vi.fn(), 30 + dispose: vi.fn(), 31 + }; 32 + return state.instance; 33 + }), 34 + }; 35 + }); 6 36 7 37 vi.mock("../input", () => { 8 38 return { ··· 12 42 }), 13 43 }; 14 44 }); 45 + 46 + vi.mock("$lib/status", () => ({ createPersistenceManager: persistenceMocks.createPersistenceManager })); 15 47 16 48 vi.mock("inkfinite-renderer", () => { 17 49 return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; ··· 216 248 createToolMap: (toolList: any[]) => new Map(toolList.map((tool) => [tool.id, tool])), 217 249 routeAction, 218 250 switchTool: (state: any, toolId: string) => ({ ...state, ui: { ...state.ui, toolId } }), 251 + CursorStore: class { 252 + updateCursor() {} 253 + subscribe() { 254 + return () => {}; 255 + } 256 + getState() { 257 + return { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 258 + } 259 + }, 219 260 createWebDocRepo, 220 261 createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })), 221 262 diffDoc: vi.fn(() => ({})), ··· 256 297 const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 257 298 expect(stores.at(-1)?.commands).toHaveLength(1); 258 299 expect(stores.at(-1)?.commands[0].kind).toBe("doc"); 259 - const sinkSpy = (InkfiniteCore as any).__sinkEnqueueSpy as ReturnType<typeof vi.fn>; 260 - expect(sinkSpy).toHaveBeenCalledTimes(1); 300 + expect(persistenceMocks.state.instance?.sink.enqueueDocPatch).toHaveBeenCalledTimes(1); 261 301 }); 262 302 });
+19 -1
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { cleanup, render } from "vitest-browser-svelte"; 3 3 import Canvas from "../canvas/Canvas.svelte"; 4 + 5 + vi.mock("$lib/status", () => { 6 + return { 7 + createPersistenceManager: () => ({ 8 + sink: { 9 + enqueueDocPatch: () => {}, 10 + flush: () => Promise.resolve(), 11 + }, 12 + status: { 13 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 14 + subscribe: () => () => {}, 15 + update: () => {}, 16 + }, 17 + setActiveBoard: () => {}, 18 + dispose: () => {}, 19 + }), 20 + }; 21 + }); 4 22 5 23 describe("Canvas component", () => { 6 24 beforeEach(() => {
+35
apps/web/src/lib/tests/input.test.ts
··· 247 247 }); 248 248 }); 249 249 250 + describe("cursor updates", () => { 251 + it("throttles onCursorUpdate via requestAnimationFrame", () => { 252 + adapter.dispose(); 253 + const rafCallbacks: FrameRequestCallback[] = []; 254 + const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { 255 + rafCallbacks.push(cb); 256 + return rafCallbacks.length; 257 + }); 258 + const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); 259 + const onCursorUpdate = vi.fn(); 260 + 261 + adapter = new InputAdapter({ 262 + canvas, 263 + getCamera: () => camera, 264 + getViewport: () => ({ width: 800, height: 600 }), 265 + onAction: (action) => actions.push(action), 266 + onCursorUpdate, 267 + }); 268 + 269 + canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 10, clientY: 20 })); 270 + canvas.dispatchEvent(createPointerEvent("pointermove", { clientX: 50, clientY: 60 })); 271 + 272 + expect(onCursorUpdate).not.toHaveBeenCalled(); 273 + expect(rafCallbacks).toHaveLength(1); 274 + 275 + rafCallbacks[0](16); 276 + 277 + expect(onCursorUpdate).toHaveBeenCalledTimes(1); 278 + expect(onCursorUpdate).toHaveBeenCalledWith({ x: -350, y: -240 }, { x: 50, y: 60 }); 279 + 280 + rafSpy.mockRestore(); 281 + cancelSpy.mockRestore(); 282 + }); 283 + }); 284 + 250 285 describe("wheel events", () => { 251 286 it("should dispatch wheel action", () => { 252 287 const event = createWheelEvent({ clientX: 400, clientY: 300, deltaY: -100 });
+127
apps/web/src/lib/tests/status.test.ts
··· 1 + import type { Observable, Observer, Subscription } from "dexie"; 2 + import type { DocPatch, DocRepo, InkfiniteDB, PageRecord } from "inkfinite-core"; 3 + import { describe, expect, it, vi } from "vitest"; 4 + import { createPersistenceManager, type PersistenceManagerOptions } from "../status"; 5 + 6 + function createMockRepo(): DocRepo { 7 + return { 8 + listBoards: vi.fn(async () => []), 9 + createBoard: vi.fn(async () => "board:mock"), 10 + renameBoard: vi.fn(async () => {}), 11 + deleteBoard: vi.fn(async () => {}), 12 + loadDoc: vi.fn(async () => ({ pages: {}, shapes: {}, bindings: {}, order: { pageIds: [], shapeOrder: {} } })), 13 + applyDocPatch: vi.fn(async () => {}), 14 + exportBoard: vi.fn(async () => ({ 15 + board: { id: "board:mock", name: "", createdAt: 0, updatedAt: 0 }, 16 + doc: { pages: {}, shapes: {}, bindings: {} }, 17 + order: { pageIds: [], shapeOrder: {} }, 18 + })), 19 + importBoard: vi.fn(async () => "board:mock"), 20 + }; 21 + } 22 + 23 + type ObserverLike = { next: (value: any) => void; error?: (err: unknown) => void }; 24 + 25 + function createMockLiveQuery() { 26 + const observers = new Set<ObserverLike>(); 27 + const factory: PersistenceManagerOptions["liveQueryFn"] = () => { 28 + const observable: Observable<any> = { 29 + subscribe(observer?: Observer<any> | ((value: any) => void) | null) { 30 + const normalized: ObserverLike = typeof observer === "function" 31 + ? { next: observer } 32 + : observer 33 + ? { next: observer.next ?? (() => {}), error: observer.error } 34 + : { next: () => {} }; 35 + observers.add(normalized); 36 + const subscription = { 37 + closed: false, 38 + unsubscribe() { 39 + if (subscription.closed) { 40 + return; 41 + } 42 + subscription.closed = true; 43 + observers.delete(normalized); 44 + }, 45 + }; 46 + return subscription as Subscription; 47 + }, 48 + [Symbol.observable]() { 49 + return this; 50 + }, 51 + }; 52 + return observable; 53 + }; 54 + 55 + return { 56 + factory, 57 + emit(value: any) { 58 + for (const observer of observers) { 59 + observer.next(value); 60 + } 61 + }, 62 + error(err: unknown) { 63 + for (const observer of observers) { 64 + observer.error?.(err); 65 + } 66 + }, 67 + observerCount() { 68 + return observers.size; 69 + }, 70 + }; 71 + } 72 + 73 + function createStatusTracker( 74 + overrides?: { repo?: DocRepo; options?: PersistenceManagerOptions; db?: Partial<InkfiniteDB> }, 75 + ) { 76 + const repo = overrides?.repo ?? createMockRepo(); 77 + const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery(); 78 + const options: PersistenceManagerOptions = overrides?.options ?? { liveQueryFn: live?.factory }; 79 + const db = (overrides?.db ?? { boards: { get: vi.fn(async () => undefined) } }) as InkfiniteDB; 80 + const manager = createPersistenceManager(db, repo, options); 81 + const mock = { repo, live, manager }; 82 + return mock; 83 + } 84 + 85 + function buildPatch(): DocPatch { 86 + return { upserts: { pages: [{ id: "page:1", name: "Page 1", shapeIds: [] } as PageRecord] } }; 87 + } 88 + 89 + describe("createPersistenceManager", () => { 90 + it("tracks pending writes and resets when liveQuery emits", () => { 91 + const { live, manager } = createStatusTracker(); 92 + expect(manager.status.get().pendingWrites).toBe(0); 93 + manager.setActiveBoard("board:1"); 94 + 95 + manager.sink.enqueueDocPatch("board:1", buildPatch()); 96 + let status = manager.status.get(); 97 + expect(status.state).toBe("saving"); 98 + expect(status.pendingWrites).toBe(1); 99 + 100 + live?.emit({ updatedAt: 123 }); 101 + status = manager.status.get(); 102 + expect(status.pendingWrites).toBe(0); 103 + expect(status.state).toBe("saved"); 104 + expect(status.lastSavedAt).toBe(123); 105 + }); 106 + 107 + it("records errors from flush", async () => { 108 + const repo = createMockRepo(); 109 + (repo.applyDocPatch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("failed")); 110 + const { manager } = createStatusTracker({ repo }); 111 + manager.setActiveBoard("board:1"); 112 + manager.sink.enqueueDocPatch("board:1", buildPatch()); 113 + 114 + await expect(manager.sink.flush()).rejects.toThrow("failed"); 115 + expect(manager.status.get().state).toBe("error"); 116 + expect(manager.status.get().errorMsg).toBe("failed"); 117 + }); 118 + 119 + it("stops liveQuery when disposed", () => { 120 + const live = createMockLiveQuery(); 121 + const { manager } = createStatusTracker({ options: { liveQueryFn: live.factory } }); 122 + manager.setActiveBoard("board:1"); 123 + expect(live.observerCount()).toBe(1); 124 + manager.dispose(); 125 + expect(live.observerCount()).toBe(0); 126 + }); 127 + });