| index 1802e8d..627080d 100644 |
| --- a/web/static/store.js |
| +++ b/web/static/store.js |
| @@ -227,6 +227,105 @@ export function cachedThreadIds() { |
| }, []); |
| } |
| |
| +// ---- 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}) narrates |
| +// progress ("searching back to 2024…"); 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 }; |
| + } |
| + await putMessages(threadId, rows); |
| + cursor = rows[0].id; // pages come ascending; the first row is the oldest |
| + await setBackfillMark(threadId, { oldest: cursor, complete: false }); |
| + if (onPage) onPage({ oldest: cursor, oldestAt: rows[0].created_at || 0, count: rows.length }); |
| + } |
| + 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() { |