/** * P4-8 — persistent encrypted-search cache (raw IndexedDB, no new deps). * * The homeserver cannot search E2EE message content, so encrypted-room search * only ever covers what the client has paginated + decrypted this session. This * module persists a local plaintext index so coverage survives reloads. * * PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF), * clearable, and wiped on logout via `deleteSearchCacheDatabase()`. * * Resilience contract: every entry point swallows IndexedDB errors and behaves * as a cache-miss. Nothing here ever throws to the UI. */ const DB_NAME = 'lotus-search-cache'; const DB_VERSION = 1; const MESSAGES_STORE = 'messages'; const COVERAGE_STORE = 'coverage'; const ROOM_TS_INDEX = 'roomTs'; /** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */ export type SearchCacheRow = { roomId: string; eventId: string; ts: number; sender: string; body: string; formattedBody?: string; pollText?: string; }; /** Per-room coverage stats for the "X / Y cached" UI counters. */ export type SearchCacheCoverage = { roomId: string; oldestTs: number; newestTs: number; count: number; }; // A key range that matches every `[roomId, *]` entry in a composite-key store // or `[roomId, ts]` index: an empty array sorts after all other key types, so // `[roomId]` .. `[roomId, []]` brackets the whole room partition. const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]); let dbPromise: Promise | null = null; const openDb = (): Promise => { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { try { if (typeof indexedDB === 'undefined') { resolve(null); return; } const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(MESSAGES_STORE)) { const store = db.createObjectStore(MESSAGES_STORE, { keyPath: ['roomId', 'eventId'], }); store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']); } if (!db.objectStoreNames.contains(COVERAGE_STORE)) { db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => { dbPromise = null; // allow a later retry resolve(null); }; req.onblocked = () => { dbPromise = null; resolve(null); }; } catch { dbPromise = null; resolve(null); } }); return dbPromise; }; /** Resolve once a write transaction commits (or reject/abort → caller swallows). */ const awaitTx = (tx: IDBTransaction): Promise => new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); /** Upsert message rows. No-op on empty input or when IDB is unavailable. */ export const putRows = async (rows: SearchCacheRow[]): Promise => { if (rows.length === 0) return; const db = await openDb(); if (!db) return; try { const tx = db.transaction(MESSAGES_STORE, 'readwrite'); const store = tx.objectStore(MESSAGES_STORE); rows.forEach((row) => store.put(row)); await awaitTx(tx); } catch { // Cache write failures must never surface to the UI. } }; /** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */ export const queryRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return []; try { return await new Promise((resolve, reject) => { const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.getAll(roomRange(roomId)); req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []); req.onerror = () => reject(req.error); }); } catch { return []; } }; /** Cursor variant: stream a room's rows through a matcher, collecting hits. */ export const searchRoom = async ( roomId: string, matcher: (row: SearchCacheRow) => boolean, ): Promise => { const db = await openDb(); if (!db) return []; try { return await new Promise((resolve, reject) => { const hits: SearchCacheRow[] = []; const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.openCursor(roomRange(roomId)); req.onsuccess = () => { const cursor = req.result; if (!cursor) { resolve(hits); return; } const row = cursor.value as SearchCacheRow; if (matcher(row)) hits.push(row); cursor.continue(); }; req.onerror = () => reject(req.error); }); } catch { return []; } }; /** Number of cached rows for a room. */ export const countRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return 0; try { return await new Promise((resolve, reject) => { const tx = db.transaction(MESSAGES_STORE, 'readonly'); const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); const req = index.count(roomRange(roomId)); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } catch { return 0; } }; export const getCoverage = async (roomId: string): Promise => { const db = await openDb(); if (!db) return null; try { return await new Promise((resolve, reject) => { const tx = db.transaction(COVERAGE_STORE, 'readonly'); const req = tx.objectStore(COVERAGE_STORE).get(roomId); req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null); req.onerror = () => reject(req.error); }); } catch { return null; } }; export const putCoverage = async (coverage: SearchCacheCoverage): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction(COVERAGE_STORE, 'readwrite'); tx.objectStore(COVERAGE_STORE).put(coverage); await awaitTx(tx); } catch { // ignore } }; /** * Pure helper: fold a batch of rows into a coverage record, widening the * `oldestTs`/`newestTs` window against any previous coverage. `count` is * supplied by the caller (authoritative store count) so dedup across sessions * is handled correctly. Exported for testing without IDB. */ export const computeCoverage = ( roomId: string, rows: ReadonlyArray>, count: number, previous?: SearchCacheCoverage | null, ): SearchCacheCoverage => { let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY; let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY; rows.forEach((row) => { if (row.ts < oldestTs) oldestTs = row.ts; if (row.ts > newestTs) newestTs = row.ts; }); if (!Number.isFinite(oldestTs)) oldestTs = 0; if (!Number.isFinite(newestTs)) newestTs = 0; return { roomId, oldestTs, newestTs, count }; }; /** * Convenience persist path used by the search hook: upsert a batch of rows for * a room, then recompute + store the room's coverage from the authoritative * store count. Fire-and-forget; never throws. */ export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise => { if (rows.length === 0) return; await putRows(rows); const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]); await putCoverage(computeCoverage(roomId, rows, count, previous)); }; /** * Pure helper: merge in-memory result items with cache-derived result items, * deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts` * descending. Generic over the minimal shape it reads so it is fully testable * without matrix-js-sdk types. Exported for testing. */ export const mergeSearchResults = < T extends { event: { event_id: string; origin_server_ts?: number } }, >( memory: ReadonlyArray, cached: ReadonlyArray, ): T[] => { const byId = new Map(); // Seed with cached, then let in-memory overwrite so in-memory always wins. cached.forEach((item) => byId.set(item.event.event_id, item)); memory.forEach((item) => byId.set(item.event.event_id, item)); return Array.from(byId.values()).sort( (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0), ); }; export const clearRoom = async (roomId: string): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId)); tx.objectStore(COVERAGE_STORE).delete(roomId); await awaitTx(tx); } catch { // ignore } }; export const clearAll = async (): Promise => { const db = await openDb(); if (!db) return; try { const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); tx.objectStore(MESSAGES_STORE).clear(); tx.objectStore(COVERAGE_STORE).clear(); await awaitTx(tx); } catch { // ignore } }; /** * Drop the entire on-disk database. Wired into the logout path by the * coordinator (initMatrix) so no decrypted plaintext lingers after sign-out. * Closes any open handle first so the delete is not blocked. Never throws. */ export const deleteSearchCacheDatabase = async (): Promise => { try { const existing = dbPromise ? await dbPromise : null; if (existing) existing.close(); } catch { // ignore } dbPromise = null; return new Promise((resolve) => { try { if (typeof indexedDB === 'undefined') { resolve(); return; } const req = indexedDB.deleteDatabase(DB_NAME); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); } catch { resolve(); } }); };