diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 07:24:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 07:24:33 -0700 |
| commit | 78cf670939133fc4f656f0f42b21a59431a53842 (patch) | |
| tree | 745ec53290fe37c91fb607e6013a322b887f9388 /tools/cache-store-test.js | |
| parent | 5bb7da55232c7a1debed2a505a85b5e7da5ba6db (diff) | |
| download | coinosv3-78cf670939133fc4f656f0f42b21a59431a53842.tar.gz coinosv3-78cf670939133fc4f656f0f42b21a59431a53842.zip | |
Rebuildable caches move from localStorage to IndexedDB (cache-store.js)
localStorage is ~5M characters per site whatever the device, and a phone
had filled it. Profiles, faceless list, reports, follow packs, profile
pages, relay lists, the moderation baseline, community directory, link
previews, video sizes/probes and search profiles (~3M on that phone) now
live in IndexedDB, which shares the site's disk allowance (GBs).
cacheStorage keeps localStorage's string API: reads from memory,
coalesced write-behind, flushed on hide. Copies migrate on open and are
removed once written. Boot waits for it (max 3s) beside the Ark store;
caches read at construction re-merge on ready; an early write can't
replace the stored copy. A copy an older build writes to localStorage
later moves in on first ask. Wallet wipes reach it; private mode stays on
localStorage. Read markers, drafts, follows, note threads and sync
outboxes stay in localStorage on purpose (cross-tab consistency, early
server-rendered seeding). Also asks navigator.storage.persist().
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'tools/cache-store-test.js')
| -rw-r--r-- | tools/cache-store-test.js | 137 |
1 files changed, 137 insertions, 0 deletions
diff --git a/tools/cache-store-test.js b/tools/cache-store-test.js new file mode 100644 index 0000000..20ad9e1 --- /dev/null +++ b/tools/cache-store-test.js @@ -0,0 +1,137 @@ +// 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); |
