feat(search): opt-in persistent index for encrypted-room search (P4-8)

Raw-IndexedDB cache (lotus-search-cache: messages keyed [roomId,eventId] +
per-room coverage) merged into local search with in-memory-wins dedupe. OPT-IN
(default off) via a standalone atom — stores decrypted text at rest, so it ships
with a privacy note, a Clear button, and an unconditional wipe on logout
(initMatrix). All IDB errors degrade to cache-miss. +8 tests (1 IDB skip in node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:19:02 -04:00
parent ed51c39fe7
commit 7da960ac8c
6 changed files with 700 additions and 78 deletions
@@ -11,6 +11,8 @@ import {
Line, Line,
toRem, toRem,
Button, Button,
Switch,
Chip,
} from 'folds'; } from 'folds';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
@@ -41,7 +43,9 @@ import {
ResultGroup, ResultGroup,
useMessageSearch, useMessageSearch,
} from './useMessageSearch'; } from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch'; import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches'; import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup'; import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
@@ -240,6 +244,10 @@ export function MessageSearch({
// Bump this whenever more messages are loaded so localResult re-computes // Bump this whenever more messages are loaded so localResult re-computes
const [cacheVersion, setCacheVersion] = useState(0); const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []); const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
}, []);
// The rooms actually in scope for this search (mirrors server-side logic) // The rooms actually in scope for this search (mirrors server-side logic)
const localSearchRooms = useMemo( const localSearchRooms = useMemo(
@@ -253,24 +261,43 @@ export function MessageSearch({
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length; const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length; const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
// Run synchronous client-side search immediately. // Run the client-side search whenever inputs change.
// In text-search mode: covers encrypted rooms only (server handles plaintext). // In text-search mode: covers encrypted rooms only (server handles plaintext).
// In sender-only mode: covers all rooms (server has no sender-only search). // In sender-only mode: covers all rooms (server has no sender-only search).
// cacheVersion in deps so it re-runs after "Load more" paginates new events. // The scan is async because — when the persistent cache is enabled — it also
const localResult = useMemo(() => { // reads cached rows from IndexedDB and merges them with the in-memory hits.
if (!hasActiveSearch) return null; // cacheVersion in deps so it re-runs after "Load more" paginates new events;
return searchLocalMessages({ // searchCacheEnabled so toggling the cache re-runs the merge.
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
useEffect(() => {
if (!hasActiveSearch) {
setLocalResult(null);
return undefined;
}
let cancelled = false;
searchLocalMessages({
term: msgSearchParams.term ?? '', term: msgSearchParams.term ?? '',
roomIds: localSearchRooms, roomIds: localSearchRooms,
senders: msgSearchParams.senders, senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
}).then((result) => {
if (!cancelled) setLocalResult(result);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps return () => {
cancelled = true;
};
}, [ }, [
searchLocalMessages, searchLocalMessages,
localSearchRooms, localSearchRooms,
msgSearchParams.term, msgSearchParams.term,
msgSearchParams.senders, msgSearchParams.senders,
msgSearchParams.fromTs,
msgSearchParams.toTs,
hasActiveSearch,
cacheVersion, cacheVersion,
searchCacheEnabled,
]); ]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
@@ -668,6 +695,37 @@ export function MessageSearch({
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.` ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`} : `No matches in your local cache. Load messages below to search further back.`}
</Text> </Text>
<Box
alignItems="Center"
gap="200"
style={{
padding: config.space.S200,
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
}}
>
<Switch
variant="Primary"
value={searchCacheEnabled}
onChange={setSearchCacheEnabled}
/>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300">Persist search index on this device</Text>
<Text size="T200" priority="300">
Stores decrypted text on this device
</Text>
</Box>
{searchCacheEnabled && (
<Chip
variant="Secondary"
radii="Pill"
onClick={handleClearSearchCache}
before={<Icon size="100" src={Icons.Delete} />}
>
<Text size="T200">Clear cached index</Text>
</Chip>
)}
</Box>
<Line size="300" variant="Surface" /> <Line size="300" variant="Surface" />
</Box> </Box>
{localGroups.length > 0 && ( {localGroups.length > 0 && (
@@ -1,12 +1,23 @@
import { EventType } from 'matrix-js-sdk'; import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch'; import { ResultGroup, ResultItem } from './useMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import {
mergeSearchResults,
queryRoom,
saveRoomIndex,
SearchCacheRow,
} from '../../utils/searchCache';
export type LocalSearchParams = { export type LocalSearchParams = {
term: string; term: string;
roomIds: string[]; roomIds: string[];
senders?: string[]; senders?: string[];
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
}; };
export type LocalSearchResult = { export type LocalSearchResult = {
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
searchedRoomsCount: number; searchedRoomsCount: number;
}; };
/** Extracted, searchable plaintext for a single message event. */
type ExtractedText = {
body: string;
formattedBody: string;
pollText: string;
};
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null => {
const evType = event.getType();
const content = event.getContent();
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
if (!poll) return null;
const qBody =
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
const pollText = `${qBody} ${answerBodies}`.trim();
return pollText ? { body: '', formattedBody: '', pollText } : null;
}
if (evType !== EventType.RoomMessage) return null;
const body = (content.body as string | undefined) ?? '';
const formattedBody = (content.formatted_body as string | undefined) ?? '';
if (!body && !formattedBody) return null;
return { body, formattedBody, pollText: '' };
};
/** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) ||
text.formattedBody.toLowerCase().includes(termLower) ||
text.pollText.toLowerCase().includes(termLower);
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
row.body.toLowerCase().includes(termLower) ||
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower);
/** Build the synthetic result item a cached row renders as (text message). */
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) {
content.format = 'org.matrix.custom.html';
content.formatted_body = row.formattedBody;
}
const syntheticEvent = {
room_id: row.roomId,
event_id: row.eventId,
type: EventType.RoomMessage,
sender: row.sender,
origin_server_ts: row.ts,
content,
unsigned: {},
};
return {
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
};
};
/** /**
* Client-side full-text search over locally cached events in encrypted rooms. * Client-side full-text search over locally cached events in encrypted rooms.
* The homeserver cannot search E2EE message content, so we scan whatever the * The homeserver cannot search E2EE message content, so we scan whatever the
* client has already received and decrypted in memory. * client has already received and decrypted in memory.
* *
* Limitation: only messages present in the live timeline window are covered. * When the persistent search cache is enabled (opt-in), the in-memory scan is
* Rooms that haven't been opened yet will return no results. * also persisted to IndexedDB (fire-and-forget) and merged with prior cached
* coverage so results survive reloads. When disabled, zero cache reads/writes
* occur.
*/ */
export const useLocalMessageSearch = () => { export const useLocalMessageSearch = () => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
const search = useCallback( const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { async ({
term,
roomIds,
senders,
fromTs,
toTs,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim(); const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null; const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
} }
const termLower = trimmedTerm.toLowerCase(); const termLower = trimmedTerm.toLowerCase();
const inRange = (ts: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0; let encryptedRoomsCount = 0;
let searchedRoomsCount = 0; let searchedRoomsCount = 0;
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
.getUnfilteredTimelineSet() .getUnfilteredTimelineSet()
.getTimelines() .getTimelines()
.flatMap((tl) => tl.getEvents()); .flatMap((tl) => tl.getEvents());
if (events.length === 0) continue;
// eslint-disable-next-line no-await-in-loop
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
if (events.length === 0 && cachedRows.length === 0) continue;
searchedRoomsCount += 1; searchedRoomsCount += 1;
const items: ResultItem[] = []; const memoryItems: ResultItem[] = [];
const rowsToPersist: SearchCacheRow[] = [];
for (let i = 0; i < events.length; i += 1) { for (let i = 0; i < events.length; i += 1) {
const event = events[i]; const event = events[i];
// In sender-only mode: include all message types; skip non-message events
if (event.getType() !== EventType.RoomMessage) {
if (senderOnlyMode) continue;
const evType = event.getType();
const isSticker = evType === 'm.sticker';
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
if (!isSticker && !isPoll) continue;
}
if (event.isDecryptionFailure()) continue; if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue; if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption const evType = event.getType();
const content = event.getContent(); const isSticker = evType === 'm.sticker';
const isMessageLike =
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
// Sender-only mode: no text filter needed // Sender-only mode indexes/returns all message types; text mode needs text.
if (!senderOnlyMode) { if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
const evType = event.getType();
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
let body = ''; const sender = event.getSender() ?? '';
let formattedBody = ''; const ts = event.getTs();
if (!isPoll) { const text = extractText(event);
body = (content.body as string | undefined) ?? '';
formattedBody = (content.formatted_body as string | undefined) ?? '';
} else {
// Poll — index question text and all answer options
const poll = (content['m.poll'] ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content['org.matrix.msc3381.poll.start']) as any;
if (poll) {
const qBody =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
(poll.question?.body as string | undefined) ??
'';
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
.map(
(a) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
.join(' ');
body = `${qBody} ${answerBodies}`.trim();
}
}
if ( // Persist every indexable (text-bearing) event we scanned, regardless
!body.toLowerCase().includes(termLower) && // of whether it matches the current term — future searches benefit.
!formattedBody.toLowerCase().includes(termLower) if (cacheEnabled && text && event.getId()) {
) rowsToPersist.push({
continue; roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
} }
// Build a synthetic IEventWithRoomId using decrypted content so the if (senderSet && !senderSet.has(sender)) continue;
// existing SearchResultGroup renderer works without modification. if (!inRange(ts)) continue;
if (!senderOnlyMode) {
if (!text || !matchesTerm(text, termLower)) continue;
}
const content = event.getContent();
const syntheticEvent = { const syntheticEvent = {
room_id: roomId, room_id: roomId,
event_id: event.getId() ?? '', event_id: event.getId() ?? '',
type: event.getType(), type: evType,
sender: event.getSender() ?? '', sender,
origin_server_ts: event.getTs(), origin_server_ts: ts,
content, content,
unsigned: event.getUnsigned(), unsigned: event.getUnsigned(),
}; };
memoryItems.push({
items.push({
rank: 0, rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any, event: syntheticEvent as any,
context: { context: { events_before: [], events_after: [], profile_info: {} },
events_before: [],
events_after: [],
profile_info: {},
},
}); });
} }
// Match cached rows (skip ids already present in memory happens in merge).
const cachedItems: ResultItem[] = [];
cachedRows.forEach((row) => {
if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
cachedItems.push(rowToResultItem(row));
});
const items = mergeSearchResults(memoryItems, cachedItems);
if (items.length > 0) { if (items.length > 0) {
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
groups.push({ roomId, items }); groups.push({ roomId, items });
} }
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
} }
return { groups, encryptedRoomsCount, searchedRoomsCount }; return { groups, encryptedRoomsCount, searchedRoomsCount };
}, },
[mx], [mx, cacheEnabled],
); );
return search; return search;
+24
View File
@@ -0,0 +1,24 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
const SEARCH_CACHE_ENABLED = 'searchCacheEnabled';
/**
* P4-8 — persistent encrypted-search cache opt-in flag (default `false`).
*
* Standalone, `localStorage`-backed boolean atom kept separate from
* `state/settings.ts` on purpose. When `true`, encrypted-room search persists a
* decrypted plaintext index to IndexedDB (`lotus-search-cache`) so coverage
* survives reloads. Because this writes decrypted plaintext at rest it must be
* explicitly opted into; the cache is clearable from the search UI and wiped on
* logout. Toggling this atom off stops all reads/writes but does NOT wipe
* existing data — that is the explicit "Clear cached index" button / logout.
*/
export const searchCacheEnabledAtom = atomWithLocalStorage<boolean>(
SEARCH_CACHE_ENABLED,
(key) => getLocalStorageItem<boolean>(key, false),
(key, value) => setLocalStorageItem(key, value),
);
+130
View File
@@ -0,0 +1,130 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
computeCoverage,
mergeSearchResults,
putRows,
queryRoom,
getCoverage,
saveRoomIndex,
clearRoom,
clearAll,
deleteSearchCacheDatabase,
SearchCacheRow,
} from './searchCache';
// --- Pure helpers: mergeSearchResults ---------------------------------------
type Item = { event: { event_id: string; origin_server_ts?: number } };
const item = (eventId: string, ts?: number): Item => ({
event: { event_id: eventId, origin_server_ts: ts },
});
test('mergeSearchResults: sorts by origin_server_ts descending', () => {
const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$b', '$c', '$a'],
);
});
test('mergeSearchResults: dedupes by event_id with in-memory winning', () => {
const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }];
const cached = [
{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' },
{ event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' },
];
const out = mergeSearchResults(memory, cached);
assert.equal(out.length, 2);
const dup = out.find((i) => i.event.event_id === '$dup');
assert.equal(dup?.tag, 'memory');
});
test('mergeSearchResults: cached-only hits are included', () => {
const out = mergeSearchResults<Item>([], [item('$c1', 1), item('$c2', 2)]);
assert.equal(out.length, 2);
});
test('mergeSearchResults: missing ts sorts as 0 (last)', () => {
const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []);
assert.deepEqual(
out.map((i) => i.event.event_id),
['$withTs', '$noTs'],
);
});
// --- Pure helpers: computeCoverage ------------------------------------------
const row = (ts: number): Pick<SearchCacheRow, 'ts'> => ({ ts });
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 });
});
test('computeCoverage: widens the window against previous coverage', () => {
const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 };
const cov = computeCoverage('!r', [row(15), row(40)], 4, prev);
assert.equal(cov.oldestTs, 5); // previous oldest kept
assert.equal(cov.newestTs, 40); // batch newest wins
assert.equal(cov.count, 4); // authoritative count from caller
});
test('computeCoverage: empty rows with no previous yields zeroed window', () => {
const cov = computeCoverage('!r', [], 0);
assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 });
});
// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) ---
const hasIdb = typeof indexedDB !== 'undefined';
test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
await clearAll();
const rows: SearchCacheRow[] = [
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' },
{
roomId: '!r1',
eventId: '$2',
ts: 200,
sender: '@b',
body: 'goodbye',
formattedBody: '<b>x</b>',
},
{ roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' },
];
await putRows(rows);
const r1 = await queryRoom('!r1');
assert.equal(r1.length, 2);
assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']);
await saveRoomIndex(
'!r1',
rows.filter((x) => x.roomId === '!r1'),
);
const cov = await getCoverage('!r1');
assert.equal(cov?.count, 2);
assert.equal(cov?.oldestTs, 100);
assert.equal(cov?.newestTs, 200);
await clearRoom('!r1');
assert.equal((await queryRoom('!r1')).length, 0);
assert.equal((await queryRoom('!r2')).length, 1);
await deleteSearchCacheDatabase();
});
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
// In this environment IndexedDB is absent; every call must degrade to a
// cache-miss rather than throwing.
await assert.doesNotReject(
putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]),
);
assert.deepEqual(await queryRoom('!r'), []);
assert.equal(await getCoverage('!r'), null);
await assert.doesNotReject(saveRoomIndex('!r', []));
await assert.doesNotReject(clearRoom('!r'));
await assert.doesNotReject(clearAll());
await assert.doesNotReject(deleteSearchCacheDatabase());
});
+308
View File
@@ -0,0 +1,308 @@
/**
* 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<IDBDatabase | null> | null = null;
const openDb = (): Promise<IDBDatabase | null> => {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase | null>((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<void> =>
new Promise<void>((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<void> => {
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<SearchCacheRow[]> => {
const db = await openDb();
if (!db) return [];
try {
return await new Promise<SearchCacheRow[]>((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<SearchCacheRow[]> => {
const db = await openDb();
if (!db) return [];
try {
return await new Promise<SearchCacheRow[]>((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<number> => {
const db = await openDb();
if (!db) return 0;
try {
return await new Promise<number>((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<SearchCacheCoverage | null> => {
const db = await openDb();
if (!db) return null;
try {
return await new Promise<SearchCacheCoverage | null>((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<void> => {
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<Pick<SearchCacheRow, 'ts'>>,
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<void> => {
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<T>,
cached: ReadonlyArray<T>,
): T[] => {
const byId = new Map<string, T>();
// 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<void> => {
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<void> => {
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<void> => {
try {
const existing = dbPromise ? await dbPromise : null;
if (existing) existing.close();
} catch {
// ignore
}
dbPromise = null;
return new Promise<void>((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();
}
});
};
+4
View File
@@ -6,6 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state
import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout'; import { revokeOidcTokens } from './oidcLogout';
import { pushSessionToSW } from '../sw-session'; import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects. // Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -87,6 +88,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// ignore if failed to logout // ignore if failed to logout
} }
await mx.clearStores(); await mx.clearStores();
// The opt-in local search index stores decrypted plaintext — always wipe it
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
await deleteSearchCacheDatabase();
// Remove only the session credential keys, preserving user preferences and // Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below. // unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession(); removeFallbackSession();