diff --git a/src/app/utils/searchCache.test.ts b/src/app/utils/searchCache.test.ts index ee82afaab..a4f4619cd 100644 --- a/src/app/utils/searchCache.test.ts +++ b/src/app/utils/searchCache.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { computeCoverage, + evictCount, mergeSearchResults, putRows, queryRoom, @@ -57,6 +58,18 @@ test('mergeSearchResults: missing ts sorts as 0 (last)', () => { const row = (ts: number): Pick => ({ ts }); +test('evictCount: 0 when under or at the cap, else the excess', () => { + assert.equal(evictCount(0, 100), 0); + assert.equal(evictCount(100, 100), 0); // exactly at cap → nothing evicted + assert.equal(evictCount(101, 100), 1); + assert.equal(evictCount(250, 100), 150); +}); + +test('evictCount: uses the default per-room cap (5000)', () => { + assert.equal(evictCount(5000), 0); + assert.equal(evictCount(5001), 1); +}); + test('computeCoverage: derives oldest/newest from rows', () => { const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3); assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 }); diff --git a/src/app/utils/searchCache.ts b/src/app/utils/searchCache.ts index 1149b49d0..c023f4dcf 100644 --- a/src/app/utils/searchCache.ts +++ b/src/app/utils/searchCache.ts @@ -16,6 +16,17 @@ const DB_NAME = 'lotus-search-cache'; const DB_VERSION = 1; const MESSAGES_STORE = 'messages'; const COVERAGE_STORE = 'coverage'; + +// Cap cached rows per room so the on-disk index can't grow unbounded over a +// long-lived session. When a room exceeds this, the oldest rows (by ts) are +// evicted on write. ~5k small rows/room is generous search history; the coverage +// window is intentionally left claiming the evicted tail so we don't re-fetch + +// re-evict it forever (Clear cached index / logout still wipe everything). +const MAX_ROWS_PER_ROOM = 5000; + +/** How many of a room's rows to evict to bring it back to the cap (0 if under). */ +export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number => + Math.max(0, currentCount - max); const ROOM_TS_INDEX = 'roomTs'; /** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */ @@ -90,6 +101,29 @@ const awaitTx = (tx: IDBTransaction): Promise => tx.onabort = () => reject(tx.error); }); +/** + * Within an open readwrite tx, delete the oldest rows of `roomId` (ascending + * `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests + * so the transaction stays alive — never awaits a non-IDB promise mid-tx (which + * would let the transaction auto-commit early). + */ +const pruneRoom = (store: IDBObjectStore, roomId: string): void => { + const index = store.index(ROOM_TS_INDEX); + const countReq = index.count(roomRange(roomId)); + countReq.onsuccess = () => { + let remaining = evictCount(countReq.result); + if (remaining <= 0) return; + const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first + cursorReq.onsuccess = () => { + const cursor = cursorReq.result; + if (!cursor || remaining <= 0) return; + cursor.delete(); + remaining -= 1; + cursor.continue(); + }; + }; +}; + /** 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; @@ -99,6 +133,8 @@ export const putRows = async (rows: SearchCacheRow[]): Promise => { const tx = db.transaction(MESSAGES_STORE, 'readwrite'); const store = tx.objectStore(MESSAGES_STORE); rows.forEach((row) => store.put(row)); + // Bound growth: prune each room this batch touched back to the cap. + new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId)); await awaitTx(tx); } catch { // Cache write failures must never surface to the UI.