/** * IndexedDB Persistence for Yjs * Provides offline-first storage with automatic sync */ import * as Y from 'yjs'; export interface IDBPOptions { dbName?: string; storeName?: string; autoSave?: boolean; saveIntervalMs?: number; } /** * IndexedDB persistence provider for Yjs documents * Enables offline editing with automatic sync when online */ export class IDBPersistence { private db: IDBDatabase | null = null; private doc: Y.Doc; private options: Required; private saveTimer: ReturnType | null = null; private isOnline: boolean = true; private pendingUpdates: Uint8Array[] = []; constructor(doc: Y.Doc, options: IDBPOptions = {}) { this.doc = doc; this.options = { dbName: options.dbName || 'frenocorp-yjs', storeName: options.storeName || 'documents', autoSave: options.autoSave ?? true, saveIntervalMs: options.saveIntervalMs || 5000, }; this.init(); } /** * Initialize IndexedDB connection */ private async init(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(this.options.dbName, 1); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(); }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Create object store for documents if (!db.objectStoreNames.contains(this.options.storeName)) { const store = db.createObjectStore(this.options.storeName, { keyPath: 'docName', }); store.createIndex('updatedAt', 'updatedAt', { unique: false }); } }; }); } /** * Save document state to IndexedDB */ async save(docName: string): Promise { if (!this.db) { throw new Error('IndexedDB not initialized'); } const state = Y.encodeStateAsUpdate(this.doc); const timestamp = Date.now(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction([this.options.storeName], 'readwrite'); const store = transaction.objectStore(this.options.storeName); const request = store.put({ docName, state: Array.from(state), updatedAt: timestamp, }); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * Load document state from IndexedDB */ async load(docName: string): Promise { if (!this.db) { throw new Error('IndexedDB not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db!.transaction([this.options.storeName], 'readonly'); const store = transaction.objectStore(this.options.storeName); const request = store.get(docName); request.onsuccess = () => { const result = request.result; if (result && result.state) { resolve(new Uint8Array(result.state)); } else { resolve(null); } }; request.onerror = () => reject(request.error); }); } /** * Apply saved state to document */ async loadAndApply(docName: string): Promise { const state = await this.load(docName); if (state) { Y.applyUpdate(this.doc, state); return true; } return false; } /** * Start auto-save timer */ startAutoSave(docName: string): void { if (!this.options.autoSave) { return; } this.stopAutoSave(); this.saveTimer = setInterval(() => { this.save(docName).catch((err) => { console.error('[IDBPersistence] Auto-save failed:', err); }); }, this.options.saveIntervalMs); } /** * Stop auto-save timer */ stopAutoSave(): void { if (this.saveTimer) { clearInterval(this.saveTimer); this.saveTimer = null; } } /** * Queue update for later sync (offline mode) */ queueUpdate(update: Uint8Array): void { this.pendingUpdates.push(update); console.log(`[IDBPersistence] Update queued for sync. Queue size: ${this.pendingUpdates.length}`); } /** * Flush queued updates */ async flushUpdates(docName: string): Promise { if (this.pendingUpdates.length === 0) { return; } console.log(`[IDBPersistence] Flushing ${this.pendingUpdates.length} queued updates`); // Apply all pending updates to document this.pendingUpdates.forEach((update) => { Y.applyUpdate(this.doc, update); }); // Save merged state await this.save(docName); this.pendingUpdates = []; } /** * Set online/offline status */ setOnline(online: boolean): void { const wasOffline = !this.isOnline; this.isOnline = online; if (online && wasOffline) { console.log('[IDBPersistence] Back online, flushing queued updates'); // Will flush updates when docName is provided } else if (!online) { console.log('[IDBPersistence] Went offline, queuing updates'); } } /** * Check if currently online */ isOnlineStatus(): boolean { return this.isOnline; } /** * Get pending update count */ getPendingUpdateCount(): number { return this.pendingUpdates.length; } /** * Clear all stored data */ async clear(docName: string): Promise { if (!this.db) { throw new Error('IndexedDB not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db!.transaction([this.options.storeName], 'readwrite'); const store = transaction.objectStore(this.options.storeName); const request = store.delete(docName); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * Get last saved timestamp */ async getLastSavedTime(docName: string): Promise { if (!this.db) { throw new Error('IndexedDB not initialized'); } return new Promise((resolve, reject) => { const transaction = this.db!.transaction([this.options.storeName], 'readonly'); const store = transaction.objectStore(this.options.storeName); const request = store.get(docName); request.onsuccess = () => { const result = request.result; resolve(result?.updatedAt || null); }; request.onerror = () => reject(request.error); }); } /** * Destroy persistence provider */ destroy(): void { this.stopAutoSave(); if (this.db) { this.db.close(); this.db = null; } } } /** * Create IndexedDB persistence provider */ export function createIDBPersistence( doc: Y.Doc, options: IDBPOptions = {} ): IDBPersistence { return new IDBPersistence(doc, options); }