fix(search): cap the encrypted-search IDB cache per room (bound disk growth)

The on-device search index grew unbounded over a long session. putRows now
prunes each touched room to MAX_ROWS_PER_ROOM (5000) — deleting the oldest rows
by [roomId, ts] via a self-chaining IDB cursor within the same write tx (never
awaits a non-IDB promise mid-tx, so the transaction can't auto-commit and
truncate the prune). Exposed a pure, unit-tested evictCount() for the decision;
the cursor path itself is browser-only (node --test has no IndexedDB).

Deliberate tradeoff (documented in code): the coverage window keeps claiming the
evicted tail so the search doesn't re-fetch → re-evict it forever. Net effect —
in a room past 5000 cached rows, an evicted old message is silently unsearchable
rather than churning. Clear cached index / logout still wipe everything.

Two review agents verified the IndexedDB-spec correctness (cursor delete+continue
semantics, put-then-count ordering, roomRange bracketing with no prefix bleed,
tx liveness, abort→cache-miss) since CI can't. Gate-green (tsc, eslint, prettier,
925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 19:41:16 -04:00
co-authored by Claude Opus 4.8
parent f54c386f36
commit fff811cb2d
2 changed files with 49 additions and 0 deletions
+13
View File
@@ -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<SearchCacheRow, 'ts'> => ({ 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 });
+36
View File
@@ -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<void> =>
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<void> => {
if (rows.length === 0) return;
@@ -99,6 +133,8 @@ export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
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.