/** * Layer 3: Storage — Encrypted multi-document IndexedDB store. * * Extends the single-document OfflineStore pattern into a multi-document, * per-document encrypted store. Each document is AES-256-GCM encrypted * at rest using keys derived from the user's passkey (DocCrypto). * * IndexedDB database: "rspace-docs" * Object store "docs": { docId, data (EncryptedBlob packed), updatedAt } * Object store "meta": { docId, module, collection, version, createdAt, updatedAt } * Object store "sync": { key: "{docId}:{peerId}", state: Uint8Array } * Index on "meta" by [module] for listByModule queries */ import type { DocumentId } from './document'; import { DocCrypto, type EncryptedBlob } from './crypto'; // ============================================================================ // TYPES // ============================================================================ interface StoredDoc { docId: string; /** Packed encrypted blob (nonce + ciphertext) — or raw bytes if encryption disabled */ data: Uint8Array; updatedAt: number; encrypted: boolean; } interface StoredMeta { docId: string; module: string; collection: string; version: number; createdAt: number; updatedAt: number; } interface StoredSyncState { key: string; // "{docId}\0{peerId}" state: Uint8Array; } // ============================================================================ // EncryptedDocStore // ============================================================================ export class EncryptedDocStore { #db: IDBDatabase | null = null; #dbName = 'rspace-docs'; #version = 1; #crypto: DocCrypto | null = null; #spaceId: string; // Debounce infrastructure (same pattern as OfflineStore) #saveTimers = new Map>(); #pendingSaves = new Map(); #saveDebounceMs = 2000; constructor(spaceId: string, docCrypto?: DocCrypto) { this.#spaceId = spaceId; this.#crypto = docCrypto ?? null; } /** * Open the IndexedDB database. Must be called before any other method. */ 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('docs')) { db.createObjectStore('docs', { keyPath: 'docId' }); } if (!db.objectStoreNames.contains('meta')) { const metaStore = db.createObjectStore('meta', { keyPath: 'docId' }); metaStore.createIndex('by_module', 'module', { unique: false }); metaStore.createIndex('by_module_collection', ['module', 'collection'], { unique: false }); } if (!db.objectStoreNames.contains('sync')) { db.createObjectStore('sync', { keyPath: 'key' }); } }; request.onsuccess = () => { this.#db = request.result; resolve(); }; request.onerror = () => { console.error('[EncryptedDocStore] Failed to open IndexedDB:', request.error); reject(request.error); }; }); } /** * Save a document (debounced). Encrypts if DocCrypto is configured. */ save(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): void { this.#pendingSaves.set(docId, { docId, data: plaintext }); const existing = this.#saveTimers.get(docId); if (existing) clearTimeout(existing); this.#saveTimers.set( docId, setTimeout(() => { this.#saveTimers.delete(docId); this.#pendingSaves.delete(docId); this.#writeDoc(docId, plaintext, meta).catch((e) => { console.error('[EncryptedDocStore] Failed to save doc:', e); }); }, this.#saveDebounceMs) ); } /** * Save a document immediately (bypasses debounce). Use before page unload. */ async saveImmediate(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): Promise { const existing = this.#saveTimers.get(docId); if (existing) { clearTimeout(existing); this.#saveTimers.delete(docId); } this.#pendingSaves.delete(docId); await this.#writeDoc(docId, plaintext, meta); } /** * Load a document. Decrypts if encrypted. */ async load(docId: DocumentId): Promise { if (!this.#db) return null; const stored = await this.#getDoc(docId); if (!stored) return null; if (stored.encrypted && this.#crypto) { const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId); const blob = DocCrypto.unpack(stored.data); return this.#crypto.decrypt(docKey, blob); } return stored.data; } /** * Delete a document and its metadata. */ async delete(docId: DocumentId): Promise { if (!this.#db) return; const tx = this.#db.transaction(['docs', 'meta'], 'readwrite'); tx.objectStore('docs').delete(docId); tx.objectStore('meta').delete(docId); await this.#txComplete(tx); } /** * List all document IDs for a space and module. */ async listByModule(module: string, collection?: string): Promise { if (!this.#db) return []; return new Promise((resolve, reject) => { const tx = this.#db!.transaction('meta', 'readonly'); const store = tx.objectStore('meta'); let request: IDBRequest; if (collection) { const index = store.index('by_module_collection'); request = index.getAllKeys(IDBKeyRange.only([module, collection])); } else { const index = store.index('by_module'); request = index.getAllKeys(IDBKeyRange.only(module)); } request.onsuccess = () => { // Filter to only docs in this space const all = request.result as string[]; const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`)); resolve(filtered as DocumentId[]); }; request.onerror = () => reject(request.error); }); } /** * List all stored document IDs. */ async listAll(): Promise { if (!this.#db) return []; return new Promise((resolve, reject) => { const tx = this.#db!.transaction('meta', 'readonly'); const request = tx.objectStore('meta').getAllKeys(); request.onsuccess = () => { const all = request.result as string[]; const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`)); resolve(filtered as DocumentId[]); }; request.onerror = () => reject(request.error); }); } /** * Save sync state for a (docId, peerId) pair. */ async saveSyncState(docId: DocumentId, peerId: string, state: Uint8Array): Promise { if (!this.#db) return; const entry: StoredSyncState = { key: `${docId}\0${peerId}`, state, }; const tx = this.#db.transaction('sync', 'readwrite'); tx.objectStore('sync').put(entry); await this.#txComplete(tx); } /** * Load sync state for a (docId, peerId) pair. */ async loadSyncState(docId: DocumentId, peerId: string): Promise { if (!this.#db) return null; return new Promise((resolve, reject) => { const tx = this.#db!.transaction('sync', 'readonly'); const request = tx.objectStore('sync').get(`${docId}\0${peerId}`); request.onsuccess = () => { const entry = request.result as StoredSyncState | undefined; resolve(entry?.state ?? null); }; request.onerror = () => reject(request.error); }); } /** * Clear all sync state for a document. */ async clearSyncState(docId: DocumentId): Promise { if (!this.#db) return; // Have to iterate since there's no compound index const tx = this.#db.transaction('sync', 'readwrite'); const store = tx.objectStore('sync'); const request = store.openCursor(); return new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(); return; } const key = cursor.key as string; if (key.startsWith(`${docId}\0`)) { cursor.delete(); } cursor.continue(); }; request.onerror = () => reject(request.error); }); } /** * Get metadata for a document. */ async getMeta(docId: DocumentId): Promise { if (!this.#db) return null; return new Promise((resolve, reject) => { const tx = this.#db!.transaction('meta', 'readonly'); const request = tx.objectStore('meta').get(docId); request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); } /** * Flush all pending debounced saves immediately. */ async flush(): Promise { const promises: Promise[] = []; for (const [docId, { data }] of this.#pendingSaves) { const timer = this.#saveTimers.get(docId); if (timer) clearTimeout(timer); this.#saveTimers.delete(docId); promises.push(this.#writeDoc(docId as DocumentId, data)); } this.#pendingSaves.clear(); await Promise.all(promises); } // ---------- Private helpers ---------- async #writeDoc( docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number } ): Promise { if (!this.#db) return; const now = Date.now(); let data: Uint8Array; let encrypted = false; if (this.#crypto?.isInitialized) { const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId); const blob = await this.#crypto.encrypt(docKey, plaintext); data = DocCrypto.pack(blob); encrypted = true; } else { data = plaintext; } const tx = this.#db.transaction(['docs', 'meta'], 'readwrite'); const storedDoc: StoredDoc = { docId, data, updatedAt: now, encrypted }; tx.objectStore('docs').put(storedDoc); if (meta) { const existingMeta = await this.getMeta(docId); const storedMeta: StoredMeta = { docId, module: meta.module, collection: meta.collection, version: meta.version, createdAt: existingMeta?.createdAt ?? now, updatedAt: now, }; tx.objectStore('meta').put(storedMeta); } await this.#txComplete(tx); } #getDoc(docId: DocumentId): Promise { if (!this.#db) return Promise.resolve(null); return new Promise((resolve, reject) => { const tx = this.#db!.transaction('docs', 'readonly'); const request = tx.objectStore('docs').get(docId); request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); } #txComplete(tx: IDBTransaction): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } }