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 });