// The local message store (#457): an IndexedDB cache that makes reloads and // thread switches paint instantly, and (later) feeds message search. // // What it holds — and deliberately nothing more: the RAW server responses. // Message rows are ciphertext blobs; the boot snapshot is the byte-equivalent // of /api/threads, /api/roster, /api/nicknames, /api/tiers and // /api/instance-key (wrapped keys, encrypted name/preview blobs). Nothing is // stored decrypted, so this cache's at-rest posture equals the server's own — // strictly tighter than the per-thread keys the service worker already keeps // in the sibling "lchat" DB (push.js). Reading this DB gains an attacker // nothing they couldn't already decrypt with those. // // The page hydrates by running the cached responses through the SAME apply + // fold pipeline the network path uses (unwrap keys → titles → fold rows), so // there is no parallel render path; the real fetches then reconcile — folds // dedupe by message id, edits refold via edited_at, deletes replay via // tombstone rows (#581/#583). // // The service worker will also write pushed rows here (phase 2); it can't // import this module (classic worker), so keep DB_NAME/DB_VERSION/store names // in sync with any inline writer added to sw.js. // // Every exported IDB call is best-effort: a quota error, a private-mode // browser, or a torn write must degrade to "no cache" — never break chat. const DB_NAME = "lchat-store"; // NOT "lchat": three openers pin that at v1 for the SW's kv const DB_VERSION = 1; const SNAP_KEY = "boot"; export const SNAP_VERSION = 1; // bump to shed incompatible old snapshots // ---- pure helpers (unit-tested in Node, no IndexedDB) --------------------- // A snapshot is usable only when it carries the current format version and the // two responses hydration cannot start without. Everything else is optional — // a partial snapshot (e.g. tiers never fetched) still paints. export function snapshotUsable(snap) { return !!( snap && snap.v === SNAP_VERSION && snap.threads && Array.isArray(snap.threads.threads) && snap.roster && Array.isArray(snap.roster.roster) ); } // Split a fetched page into rows to keep and the ids its tombstones unsend. // The tombstone row itself is stored too: it keeps the delete durable locally // (a later hydrate re-folds it as a no-op) while the target's ciphertext is // actually dropped — mirroring the server's own posture. export function partitionRows(rows) { const puts = []; const unsend = []; for (const m of rows || []) { if (!m || !m.id) continue; puts.push(m); if (m.tombstone_of) unsend.push(m.tombstone_of); } return { puts, unsend }; } // Which cached thread ids are no longer in the live list (left, evicted, or // deleted while this device was away) — their rows must drop. Only trust a // well-formed live list: an error body must never wipe the cache. export function staleThreadIds(cachedIds, liveList) { if (!liveList || !Array.isArray(liveList.threads)) return []; const live = new Set(liveList.threads.map((t) => t.id)); return (cachedIds || []).filter((id) => !live.has(id)); } // ---- IndexedDB plumbing --------------------------------------------------- let dbPromise = null; function openStore() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { let req; try { req = indexedDB.open(DB_NAME, DB_VERSION); } catch (e) { reject(e); // e.g. storage disabled entirely return; } req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv"); if (!db.objectStoreNames.contains("msgs")) { // One row per server message row, newest read by id order within a // thread. keyPath mirrors the server's identity: (thread, message id). db.createObjectStore("msgs", { keyPath: ["t", "id"] }); } }; req.onsuccess = () => { // If another tab upgrades the schema later, close so it isn't blocked. req.result.onversionchange = () => { req.result.close(); dbPromise = null; }; resolve(req.result); }; req.onerror = () => reject(req.error); req.onblocked = () => reject(new Error("blocked")); }); dbPromise.catch(() => (dbPromise = null)); // let a later call retry return dbPromise; } // Run one transaction; resolves when it completes. `fn(t, done)` does the // requests — a read calls `done(value)` from a request's onsuccess to set what // the transaction resolves to; a write just issues its requests. Any failure // resolves `fallback` (best-effort contract — see the header). async function tx(storeNames, mode, fn, fallback = undefined) { try { const db = await openStore(); return await new Promise((resolve) => { const t = db.transaction(storeNames, mode); let out = fallback; const done = (v) => (out = v); try { fn(t, done); } catch { try { t.abort(); } catch {} resolve(fallback); return; } t.oncomplete = () => resolve(out); t.onabort = () => resolve(fallback); t.onerror = () => resolve(fallback); }); } catch { return fallback; } } // ---- boot snapshot -------------------------------------------------------- export function getSnapshot() { return tx(["kv"], "readonly", (t, done) => { const req = t.objectStore("kv").get(SNAP_KEY); req.onsuccess = () => done(req.result || null); }, null); } export function putSnapshot(snap) { return tx(["kv"], "readwrite", (t) => { t.objectStore("kv").put(snap, SNAP_KEY); }); } // ---- message rows --------------------------------------------------------- // The newest `limit` raw rows for a thread, ascending by id — the same shape // and order /api/messages returns, so folds can't tell cache from network. export function getRecentMessages(threadId, limit = 100) { return tx(["msgs"], "readonly", (t, done) => { const rows = []; const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); const cur = t.objectStore("msgs").openCursor(range, "prev"); cur.onsuccess = () => { const c = cur.result; if (c && rows.length < limit) { rows.push(c.value); c.continue(); } else { done(rows.reverse()); } }; }, []); } // Store fetched rows; apply any tombstones in the batch (drop their targets' // ciphertext). Rows are stored verbatim plus the keyPath's thread tag. export function putMessages(threadId, rows) { const { puts, unsend } = partitionRows(rows); if (!puts.length) return Promise.resolve(); return tx(["msgs"], "readwrite", (t) => { const s = t.objectStore("msgs"); for (const m of puts) s.put({ ...m, t: threadId }); for (const id of unsend) s.delete([threadId, id]); }); } // A live edit frame rewrote the ciphertext in place — mirror it. export function applyEditRow(threadId, id, blob, editedAt) { return tx(["msgs"], "readwrite", (t) => { const s = t.objectStore("msgs"); const g = s.get([threadId, id]); g.onsuccess = () => { if (g.result) s.put({ ...g.result, blob, edited_at: editedAt || Math.floor(Date.now() / 1000) }); }; }); } // A live delete frame — drop the ciphertext now; the tombstone row arrives // with the next fetched page and is stored then. export function deleteMessageRow(threadId, id) { return tx(["msgs"], "readwrite", (t) => { t.objectStore("msgs").delete([threadId, id]); }); } export function clearThread(threadId) { return tx(["msgs", "kv"], "readwrite", (t) => { t.objectStore("msgs").delete(IDBKeyRange.bound([threadId, 0], [threadId, Infinity])); // Any per-thread bookkeeping (e.g. a future backfill watermark) goes too. t.objectStore("kv").delete("thread:" + threadId); }); } // Thread ids present in the msgs store — the prune sweep's input. export function cachedThreadIds() { return tx(["msgs"], "readonly", (t, done) => { const ids = []; // Jump cursor: visit one key per thread, then skip past its rows. const cur = t.objectStore("msgs").openKeyCursor(); cur.onsuccess = () => { const c = cur.result; if (c) { ids.push(c.key[0]); c.continue([c.key[0], Infinity]); } else { done(ids); } }; }, []); } // ---- full-history backfill (#457) ----------------------------------------- // Page a thread's history backwards into the store with the server's `before` // cursor, persisting an oldest-fetched watermark per thread so an interrupted // run RESUMES rather than restarts. A thread that reaches its beginning (an // empty page) is marked complete and never re-walked — from then on the // ordinary forward sync keeps coverage total. Rows are stored, never folded: // this is the search substrate (#586), not a display path. // // Known limit, accepted for now: the watermark only ever descends from the // oldest row present when the walk started. A gap ABOVE that point (a thread // that outran the cached window between sessions) isn't detected — those // messages exist server-side but not locally until a fresh walk. Fine for // search-completeness at this product's scale; revisit with interval tracking // if it ever bites. // Where a thread's backfill has reached. null = never started. export function getBackfillMark(threadId) { return tx(["kv"], "readonly", (t, done) => { const req = t.objectStore("kv").get("thread:" + threadId); req.onsuccess = () => done(req.result || null); }, null); } function setBackfillMark(threadId, mark) { return tx(["kv"], "readwrite", (t) => { t.objectStore("kv").put(mark, "thread:" + threadId); }); } // The lowest stored id for a thread — where a first walk starts from. export function oldestStoredId(threadId) { return tx(["msgs"], "readonly", (t, done) => { const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); const cur = t.objectStore("msgs").openKeyCursor(range, "next"); cur.onsuccess = () => done(cur.result ? cur.result.key[1] : 0); }, 0); } // Pure: the `before` cursor a run should continue from. -1 = already complete; // 0 = nothing local yet (seed a recent page first). export function backfillCursor(mark, oldestStored) { if (mark && mark.complete) return -1; if (mark && mark.oldest) return mark.oldest; return oldestStored || 0; } // Drive a thread's backfill to completion (or cancellation). Transport is // injected — fetchPage(before) resolves the raw rows of one page (before=0 ⇒ // the most recent page) — so this stays free of tokens and testable end to // end by importing the module. onPage({oldest, oldestAt, count, rows}) is // AWAITED per page — it narrates progress ("searching back to 2024…") and // receives the page's rows for incremental indexing; an AbortSignal cancels // between pages. export async function runBackfill(threadId, fetchPage, { onPage, signal } = {}) { let cursor = backfillCursor(await getBackfillMark(threadId), await oldestStoredId(threadId)); if (cursor === -1) return { complete: true }; if (!cursor) { const rows = await fetchPage(0); if (!rows.length) { // A thread with no history at all is trivially complete. await setBackfillMark(threadId, { oldest: 0, complete: true }); return { complete: true }; } await putMessages(threadId, rows); cursor = rows[0].id; await setBackfillMark(threadId, { oldest: cursor, complete: false }); } while (!(signal && signal.aborted)) { const rows = await fetchPage(cursor); if (!rows.length) { await setBackfillMark(threadId, { oldest: cursor, complete: true }); return { complete: true }; } const next = rows[0].id; // pages come ascending; the first row is the oldest if (!(next < cursor)) { // The walk only terminates because each page's oldest id is strictly // older than the cursor. A page that fails to advance (duplicate or // non-descending ids — a server bug) would replay forever; stop // honestly instead. The mark is untouched, so a later run retries. return { complete: false }; } await putMessages(threadId, rows); cursor = next; await setBackfillMark(threadId, { oldest: cursor, complete: false }); // Hand the page's rows over for incremental indexing, and AWAIT: a slow // consumer backpressures the walk rather than racing it. if (onPage) await onPage({ oldest: cursor, oldestAt: rows[0].created_at || 0, count: rows.length, rows }); } return { complete: false }; } // Every stored row for a thread, ascending — the search scan's input. Bounded // by cap as a runaway guard; search streams per thread, so a giant thread // still yields in slices. export function allMessages(threadId, cap = 50000) { return tx(["msgs"], "readonly", (t, done) => { const rows = []; const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); const cur = t.objectStore("msgs").openCursor(range, "next"); cur.onsuccess = () => { const c = cur.result; if (c && rows.length < cap) { rows.push(c.value); c.continue(); } else { done(rows); } }; }, []); } // Sign-out: everything goes, snapshot included — the next visitor to this // browser profile must find nothing. export function wipeStore() { return tx(["msgs", "kv"], "readwrite", (t) => { t.objectStore("msgs").clear(); t.objectStore("kv").clear(); }); }