1/** 2 * @factory/collaboration — offline persistence wiring (TASK-0092 R5 / 3 * TASK-0095). 4 * 5 * `y-indexeddb` (`IndexeddbPersistence`) is the offline store: it keeps the 6 * `Y.Doc` in IndexedDB under a deterministic name ({@link offlineDocName} — 7 * `factory:{workspaceId}:{pageId}`, stable across tabs so two tabs of the 8 * same page share one offline doc and tab-to-tab offline edits converge) 9 * and loads stored updates into the doc on construction. 10 * 11 * v9 storage semantics (the library's built-in autosave — the task's 1 s 12 * cadence, the architecture doc's win over the brief's 2 s): every update 13 * (local or network) is APPENDED to the `updates` store immediately; when 14 * the store reaches 500 updates the provider merges the doc state into it 15 * after its built-in 1 s debounce (`_storeTimeout` — the private 1 s 16 * autosave the library ships). {@link OfflinePersistence.flush} forces an 17 * immediate merge via the exported `storeState(provider, true)` — the 18 * provider calls it before a hard navigation and on dispose, so no 19 * pending offline edit is ever lost. 20 * 21 * The "offline → online" convergence is the CRDT's, not the store's: on 22 * reconnect the provider sends `sync.request {stateVector}` and the server 23 * answers with only the missing updates (the merge discards duplicates — 24 * realtime-architecture §4.5). 25 * 26 * `createMemoryOfflineFactory` is the Node/test stand-in (no IndexedDB in 27 * Node — recorded; the browser path is the real one). The y-indexeddb 28 * import is side-effect-free to load in Node; only the factory CALL 29 * touches IndexedDB (the browser). 30 */ 31 32import { IndexeddbPersistence, storeState } from "y-indexeddb"; 33 34import type { OfflinePersistence, OfflinePersistenceFactory } from "./types.js"; 35 36/** 37 * The offline doc name: deterministic per (workspace, page) so every tab 38 * of the page shares one IndexedDB doc and offline edits converge 39 * tab-to-tab. (Phase-7 brief: `factory:{workspaceId}:{pageId}`.) 40 */ 41export function offlineDocName(workspaceId: string, pageId: string): string { 42 return `factory:${workspaceId}:${pageId}`; 43} 44 45/** 46 * The autosave interval passed to the factory contract. The y-indexeddb 47 * v9 merge debounce is a fixed 1 s inside the library (`_storeTimeout`); 48 * this constant documents that contract and is what the task sheet's 49 * "debounced 1 s (architecture doc over the brief's 2 s)" resolves to. 50 */ 51export const OFFLINE_AUTOSAVE_INTERVAL_MS = 1000; 52 53/** 54 * The browser default: y-indexeddb. Construction opens the DB, loads the 55 * stored updates into the doc, and fires `synced` (even when empty) — 56 * `create` awaits `whenSynced` so the provider can compute its 57 * `sync.request` (full vs state-vector) only AFTER local offline state 58 * exists. A 2.5 s guard resolves anyway: a corrupt/locked store must not 59 * block the connect — the full sync covers the gap (recorded). 60 */ 61export function createIndexeddbOfflineFactory(): OfflinePersistenceFactory { 62 return { 63 async create(docName, doc, _opts) { 64 const provider = new IndexeddbPersistence(docName, doc); 65 await raceTimeout(provider.whenSynced, 2500); 66 return { 67 docName, 68 async flush() { 69 // Force the immediate merge (no 500-update threshold, no debounce). 70 await storeState(provider, true); 71 }, 72 async dispose() { 73 // Flush then close (destroy unbinds the doc's update listener). 74 await storeState(provider, true); 75 await provider.destroy(); 76 }, 77 }; 78 }, 79 }; 80} 81 82/** 83 * A no-op offline persistence for environments without IndexedDB (Node 84 * tests — the provider tests inject this or nothing). The doc name is 85 * honored for logging/consistency; nothing is stored. 86 */ 87export function createMemoryOfflineFactory(): OfflinePersistenceFactory { 88 return { 89 async create(docName, _doc, _opts) { 90 return { 91 docName, 92 async flush() {}, 93 async dispose() {}, 94 }; 95 }, 96 }; 97} 98 99function raceTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> { 100 return new Promise<T | undefined>((resolve) => { 101 let settled = false; 102 const timer = setTimeout(() => { 103 if (!settled) { 104 settled = true; 105 resolve(undefined); 106 } 107 }, ms); 108 promise.then( 109 (value) => { 110 if (!settled) { 111 settled = true; 112 clearTimeout(timer); 113 resolve(value); 114 } 115 }, 116 () => { 117 if (!settled) { 118 settled = true; 119 clearTimeout(timer); 120 resolve(undefined); 121 } 122 }, 123 ); 124 }); 125} 126 127/** Re-export for the canvas (the offline store IS y-indexeddb). */ 128export { IndexeddbPersistence }; 129