// The IndexedDB cache store (src/cache-store.js): localStorage copies move in // and leave, an early write can't replace the full copy, re-reads fire once // it opens, writes land behind, wipes reach it, and a refused IndexedDB // leaves everything in localStorage as before. // Run: bun tools/cache-store-test.js import { CacheStore, isCacheKey } from '../src/cache-store.js'; let fails = 0; const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; }; const fakeLs = (init = {}) => { const m = new Map(Object.entries(init)); return { get length() { return m.size; }, key: (i) => [...m.keys()][i] ?? null, getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => { m.set(k, String(v)); }, removeItem: (k) => { m.delete(k); }, dump: () => m, }; }; const memBackend = (init = []) => { const m = new Map(init); return { writes: 0, async list() { return [...m]; }, async write(puts, dels) { this.writes++; for (const [k, v] of puts) m.set(k, v); for (const k of dels) m.delete(k); }, dump: () => m }; }; const W = 'btc-wallet-cache:abc123'; console.log('[which keys]'); check('a wallet\'s profile cache is one', isCacheKey(W + ':profiles')); check('link previews are one', isCacheKey('coinos-link-previews')); check('the messages state is not (read markers, drafts)', !isCacheKey(W + ':messages')); check('the follow list is not', !isCacheKey(W + ':follows')); check('note threads stay (server-rendered pages seed them early)', !isCacheKey('btc-wallet-note-threads')); console.log('\n[first open: localStorage moves in]'); { const ls = fakeLs({ [W + ':profiles']: '{"a":1}', 'coinos-link-previews': '[["u",1]]', [W + ':messages']: '{"read":{}}' }); const be = memBackend(); const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); check('before opening, reads come from localStorage', s.getItem(W + ':profiles') === '{"a":1}'); let hooked = 0; s.onReady(() => hooked++); await s.open(); check('the ready hook ran once', hooked === 1); check('reads now come from memory', s.getItem(W + ':profiles') === '{"a":1}'); await s.flush(); await new Promise((r) => setTimeout(r, 0)); check('the copies are in IndexedDB', be.dump().get(W + ':profiles') === '{"a":1}' && be.dump().get('coinos-link-previews') === '[["u",1]]'); check('...and gone from localStorage', ls.getItem(W + ':profiles') === null && ls.getItem('coinos-link-previews') === null); check('state that isn\'t a cache stays put', ls.getItem(W + ':messages') === '{"read":{}}'); check('a non-cache key still reads from localStorage', s.getItem(W + ':messages') === '{"read":{}}'); } console.log('\n[a later boot: an early write must not replace the full copy]'); { const ls = fakeLs(); const be = memBackend([[W + ':profiles', '{"full":true}']]); const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); check('an early read finds nothing (migrated)', s.getItem(W + ':profiles') === null); s.setItem(W + ':profiles', '{"early":true}'); check('the early write didn\'t land in localStorage', ls.getItem(W + ':profiles') === null); await s.open(); check('the full copy survived', s.getItem(W + ':profiles') === '{"full":true}'); } console.log('\n[an older build in another tab wrote localStorage again]'); { const ls = fakeLs({ [W + ':reports']: '{"newer":1}' }); const be = memBackend([[W + ':reports', '{"older":1}']]); const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); s.setItem(W + ':reports', '{"early":1}'); check('an early write to a key still in localStorage goes there', ls.getItem(W + ':reports') === '{"early":1}'); await s.open(); check('localStorage\'s copy wins on open', s.getItem(W + ':reports') === '{"early":1}'); } console.log('\n[a copy written to localStorage after opening]'); { const ls = fakeLs(); const be = memBackend(); const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); await s.open(); ls.setItem(W + ':profPages', '{"late":1}'); check('is found on first ask', s.getItem(W + ':profPages') === '{"late":1}'); await s.flush(); await new Promise((r) => setTimeout(r, 0)); await s.flush(); await new Promise((r) => setTimeout(r, 0)); check('...and moves in', be.dump().get(W + ':profPages') === '{"late":1}' && ls.getItem(W + ':profPages') === null); } console.log('\n[writes land behind, deletes and wipes reach it]'); { const ls = fakeLs(); const be = memBackend([[W + ':profiles', 'x'], [W + ':reports', 'y'], ['btc-wallet-cache:other:profiles', 'z']]); const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); await s.open(); s.setItem(W + ':profiles', 'x2'); s.setItem(W + ':profiles', 'x3'); check('nothing written yet (coalesced)', be.dump().get(W + ':profiles') === 'x'); await s.flush(); check('one write with the latest value', be.dump().get(W + ':profiles') === 'x3' && be.writes === 1); s.removePrefix(W); await s.flush(); check('a wallet wipe removes its caches', !be.dump().has(W + ':profiles') && !be.dump().has(W + ':reports')); check('...and only its own', be.dump().get('btc-wallet-cache:other:profiles') === 'z'); check('a non-cache key written through it goes to localStorage', (s.setItem(W + ':messages', 'm'), ls.getItem(W + ':messages') === 'm')); } console.log('\n[a failed write is retried]'); { const ls = fakeLs(); let fail = true; const m = new Map(); const be = { async list() { return []; }, async write(puts) { if (fail) throw new Error('quota'); for (const [k, v] of puts) m.set(k, v); } }; const s = new CacheStore({ backend: be, ls: () => ls, listen: false }); await s.open(); s.setItem(W + ':profiles', 'p'); await s.flush(); check('kept in memory after the failure', s.getItem(W + ':profiles') === 'p'); fail = false; await s.flush(); check('written on the next flush', m.get(W + ':profiles') === 'p'); } console.log('\n[IndexedDB refused]'); { const ls = fakeLs({ [W + ':profiles']: 'kept' }); const s = new CacheStore({ backend: { async list() { throw new Error('private mode'); } }, ls: () => ls, listen: false }); let hooked = 0; s.onReady(() => hooked++); await s.open(); check('still opens (hooks run)', hooked === 1); check('reads localStorage', s.getItem(W + ':profiles') === 'kept'); s.setItem(W + ':profiles', 'new'); check('writes localStorage', ls.getItem(W + ':profiles') === 'new'); } console.log(fails ? `\n❌ ${fails} failed` : '\n✅ cache store holds'); process.exit(fails ? 1 : 0);