/** * OfflineStore — IndexedDB persistence for Automerge documents and sync state. * * Stores per-community: * - docBinary: Automerge.save(doc) output (Uint8Array) * - syncStateBinary: Automerge.encodeSyncState() output (Uint8Array) * - lastSynced / lastModified timestamps */ export interface OfflineCacheEntry { slug: string; docBinary: Uint8Array; syncStateBinary: Uint8Array | null; lastSynced: number; lastModified: number; } export class OfflineStore { #db: IDBDatabase | null = null; #dbName = "rspace-offline"; #storeName = "communities"; #version = 1; #saveTimers = new Map>(); #pendingSaves = new Map(); #saveDebounceMs = 2000; /** * Open the IndexedDB database. Must be called before any other method. * Safe to call multiple times (idempotent). */ async open(): Promise { if (this.#db) return; return new Promise((resolve, reject) => { const request = indexedDB.open(this.#dbName, this.#version); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(this.#storeName)) { db.createObjectStore(this.#storeName, { keyPath: "slug" }); } }; request.onsuccess = () => { this.#db = request.result; resolve(); }; request.onerror = () => { console.error("[OfflineStore] Failed to open IndexedDB:", request.error); reject(request.error); }; }); } /** * Save Automerge document binary, debounced to avoid thrashing. */ saveDoc(slug: string, docBinary: Uint8Array): void { this.#pendingSaves.set(slug, docBinary); const existing = this.#saveTimers.get(slug); if (existing) clearTimeout(existing); this.#saveTimers.set( slug, setTimeout(() => { this.#saveTimers.delete(slug); this.#pendingSaves.delete(slug); this.#writeDoc(slug, docBinary).catch((e) => { console.error("[OfflineStore] Failed to save doc:", e); }); }, this.#saveDebounceMs) ); } /** * Immediately save document binary (bypasses debounce). * Used before page unload. */ async saveDocImmediate(slug: string, docBinary: Uint8Array): Promise { // Cancel any pending debounced save for this slug const existing = this.#saveTimers.get(slug); if (existing) { clearTimeout(existing); this.#saveTimers.delete(slug); } this.#pendingSaves.delete(slug); await this.#writeDoc(slug, docBinary); } /** * Load cached Automerge document binary. */ async loadDoc(slug: string): Promise { const entry = await this.#getEntry(slug); return entry?.docBinary ?? null; } /** * Save Automerge SyncState binary for incremental reconnection. */ async saveSyncState(slug: string, syncStateBinary: Uint8Array): Promise { if (!this.#db) return; try { const entry = await this.#getEntry(slug); if (!entry) return; // No doc saved yet, skip sync state entry.syncStateBinary = syncStateBinary; await this.#putEntry(entry); } catch (e) { console.error("[OfflineStore] Failed to save sync state:", e); } } /** * Load cached SyncState binary. */ async loadSyncState(slug: string): Promise { const entry = await this.#getEntry(slug); return entry?.syncStateBinary ?? null; } /** * Update the lastSynced timestamp (called when server confirms sync). */ async markSynced(slug: string): Promise { if (!this.#db) return; try { const entry = await this.#getEntry(slug); if (!entry) return; entry.lastSynced = Date.now(); await this.#putEntry(entry); } catch (e) { console.error("[OfflineStore] Failed to mark synced:", e); } } /** * Clear all cached data for a community. */ async clear(slug: string): Promise { if (!this.#db) return; return new Promise((resolve, reject) => { const tx = this.#db!.transaction(this.#storeName, "readwrite"); const store = tx.objectStore(this.#storeName); const request = store.delete(slug); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * List all cached community slugs. */ async listCommunities(): Promise { if (!this.#db) return []; return new Promise((resolve, reject) => { const tx = this.#db!.transaction(this.#storeName, "readonly"); const store = tx.objectStore(this.#storeName); const request = store.getAllKeys(); request.onsuccess = () => resolve(request.result as string[]); request.onerror = () => reject(request.error); }); } /** * Flush all pending debounced saves immediately. * Call from beforeunload handler. */ async flush(): Promise { const promises: Promise[] = []; for (const [slug, binary] of this.#pendingSaves) { const timer = this.#saveTimers.get(slug); if (timer) clearTimeout(timer); this.#saveTimers.delete(slug); promises.push(this.#writeDoc(slug, binary)); } this.#pendingSaves.clear(); await Promise.all(promises); } // --- Private helpers --- async #writeDoc(slug: string, docBinary: Uint8Array): Promise { if (!this.#db) return; const existing = await this.#getEntry(slug); const entry: OfflineCacheEntry = { slug, docBinary, syncStateBinary: existing?.syncStateBinary ?? null, lastSynced: existing?.lastSynced ?? 0, lastModified: Date.now(), }; await this.#putEntry(entry); } #getEntry(slug: string): Promise { if (!this.#db) return Promise.resolve(null); return new Promise((resolve, reject) => { const tx = this.#db!.transaction(this.#storeName, "readonly"); const store = tx.objectStore(this.#storeName); const request = store.get(slug); request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); } #putEntry(entry: OfflineCacheEntry): Promise { if (!this.#db) return Promise.resolve(); return new Promise((resolve, reject) => { const tx = this.#db!.transaction(this.#storeName, "readwrite"); const store = tx.objectStore(this.#storeName); const request = store.put(entry); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } }