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 /src/cache-store.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 'src/cache-store.js')
| -rw-r--r-- | src/cache-store.js | 241 |
1 files changed, 241 insertions, 0 deletions
diff --git a/src/cache-store.js b/src/cache-store.js new file mode 100644 index 0000000..dcfbad7 --- /dev/null +++ b/src/cache-store.js @@ -0,0 +1,241 @@ +// Disposable caches (profiles, reports, previews, …) in IndexedDB instead of +// localStorage, behind the same getItem/setItem/removeItem shape. +// +// Why: localStorage is about five million characters per site, whatever the +// device, and a phone that follows a few hundred people filled it — every +// cache then had to be squeezed (a 150-row profile cache churned through in +// twenty minutes, and a reload painted punks and npubs). IndexedDB shares the +// site's disk allowance instead: gigabytes. +// +// The values stay the strings localStorage held, so callers keep their +// JSON.parse/stringify and nothing about a cache's shape changes. Reads are +// synchronous from memory; writes are coalesced and land in IndexedDB in the +// background, flushed at once when the page hides. +// +// Before the store has opened (a few ms into boot) reads fall back to +// localStorage and writes go there only if the key still lives there — a +// write built from an early, empty read must never replace the full copy in +// IndexedDB. Boot waits for `cacheReady()` before restoring the wallet, so +// wallet-scoped caches never see that window; caches read at construction +// re-merge from `onCacheReady`. localStorage copies migrate on open (they're +// newer: written by this device before the move, or by an older build still +// open in another tab) and are removed once the IndexedDB write completed. +// If IndexedDB is unavailable everything stays in localStorage, as before. + +const DB = 'coinos-cache'; +const STORE = 'kv'; +const DEBOUNCE_MS = 1000; +const OPEN_WAIT_MS = 3000; + +// What lives here: rebuildable caches only. State a user would miss (read +// markers, drafts, the follow list, sync outboxes) stays in localStorage, +// where every tab sees every other tab's writes at once. +const WALLET_CACHES = ['profiles', 'profilesFaceless', 'reports', 'followPacks', 'profPages', 'relayLists']; +const WALLET_RE = new RegExp('^btc-wallet-cache:[0-9a-f]+:(' + WALLET_CACHES.join('|') + ')$'); +const GLOBAL_KEYS = new Set([ + // (btc-wallet-note-threads stays: a server-rendered note page seeds it + // before boot has waited for anything) + 'coinos-mod-baseline', 'coinos-community-directory-v1', + 'coinos-link-previews', 'coinos-video-info', 'coinos-video-dims', 'btc-wallet-search-profiles', +]); +export const isCacheKey = (k) => typeof k === 'string' && (GLOBAL_KEYS.has(k) || WALLET_RE.test(k)); + +function idbBackend() { + if (typeof indexedDB === 'undefined') return null; + let dbp = null; + const open = () => dbp || (dbp = new Promise((resolve, reject) => { + const req = indexedDB.open(DB, 1); + req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE); }; + req.onsuccess = () => { + const db = req.result; + db.onversionchange = () => { try { db.close(); } catch {} dbp = null; }; + db.onclose = () => { dbp = null; }; + resolve(db); + }; + req.onerror = () => { dbp = null; reject(req.error); }; + req.onblocked = () => { dbp = null; reject(new Error('IndexedDB blocked')); }; + })); + return { + async list() { + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, 'readonly'); + const s = t.objectStore(STORE); + const keys = s.getAllKeys(), vals = s.getAll(); + t.oncomplete = () => resolve(keys.result.map((k, i) => [k, vals.result[i]])); + t.onerror = () => reject(t.error); + }); + }, + async write(puts, dels) { + if (!puts.length && !dels.length) return; + const db = await open(); + return new Promise((resolve, reject) => { + const t = db.transaction(STORE, 'readwrite'); + const s = t.objectStore(STORE); + for (const [k, v] of puts) s.put(v, k); + for (const k of dels) s.delete(k); + t.oncomplete = () => resolve(); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error || new Error('aborted')); + }); + }, + }; +} + +export class CacheStore { + constructor({ backend = idbBackend(), ls = () => globalThis.localStorage, listen = true } = {}) { + this.backend = backend; + this.ls = ls; + this.mem = new Map(); + this.dirty = new Set(); + this.ready = !backend; + this.timer = 0; + this.opening = null; + this._flushing = null; + this._onReady = []; + if (listen && typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.flush(); }); + addEventListener('pagehide', () => this.flush()); + } + } + get lsMode() { return !this.backend; } + _ls() { try { return this.ls(); } catch { return null; } } + + open() { + if (this.ready) return Promise.resolve(); + if (this.opening) return this.opening; + this.opening = (async () => { + let rows; + try { rows = await this.backend.list(); } catch { + this.backend = null; // IndexedDB refused (private mode, quota): stay on localStorage + this._becomeReady(); + return; + } + for (const [k, v] of rows) if (typeof v === 'string') this.mem.set(k, v); + const migrated = []; + const ls = this._ls(); + if (ls) { + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (!isCacheKey(k)) continue; + const v = ls.getItem(k); + if (v != null) { this.mem.set(k, v); this.dirty.add(k); } + migrated.push(k); + } + } + this._becomeReady(); + if (migrated.length) { + this.flush().then(() => { + for (const k of migrated) { try { ls.removeItem(k); } catch {} } + }).catch(() => {}); + } + })(); + return this.opening; + } + _becomeReady() { + this.ready = true; + for (const fn of this._onReady.splice(0)) { try { fn(); } catch (e) { console.warn('cache ready hook:', e); } } + } + // Runs once the store has opened (at once if it already has). + onReady(fn) { if (this.ready) { try { fn(); } catch {} } else this._onReady.push(fn); } + + getItem(key) { + if (this.ready && !this.lsMode && isCacheKey(key)) { + if (this.mem.has(key)) return this.mem.get(key); + // a copy that reached localStorage after we opened (an older build in + // another tab) moves in on first ask + let v = null; + try { v = this._ls().getItem(key); } catch {} + if (v != null) this._adopt(key, v); + return v; + } + try { return this._ls().getItem(key); } catch { return null; } + } + _adopt(key, v) { + this.mem.set(key, v); + this.dirty.add(key); + this._schedule(); + const ls = this._ls(); + Promise.resolve(this._flushing || this.flush()).then(() => this.flush()) + .then(() => { try { if (ls.getItem(key) === v) ls.removeItem(key); } catch {} }).catch(() => {}); + } + setItem(key, value) { + value = String(value); + if (!isCacheKey(key) || this.lsMode) { this._ls()?.setItem(key, value); return; } + if (!this.ready) { + // early: only a key that hasn't migrated yet may be written (see above) + const ls = this._ls(); + try { if (ls && ls.getItem(key) != null) ls.setItem(key, value); } catch {} + return; + } + if (this.mem.get(key) === value) return; + this.mem.set(key, value); + this.dirty.add(key); + this._schedule(); + } + removeItem(key) { + try { this._ls()?.removeItem(key); } catch {} + if (!isCacheKey(key) || this.lsMode || !this.ready) return; + if (!this.mem.has(key)) return; + this.mem.delete(key); + this.dirty.add(key); + this._schedule(); + } + // Every cache key held, wherever it lives right now. + keys() { + const out = new Set(this.ready && !this.lsMode ? this.mem.keys() : []); + const ls = this._ls(); + try { for (let i = 0; i < ls.length; i++) { const k = ls.key(i); if (isCacheKey(k)) out.add(k); } } catch {} + return [...out]; + } + // Wallet wipe: every cache under the wallet's key prefix. + removePrefix(prefix) { + for (const k of [...this.mem.keys()]) if (k.startsWith(prefix)) this.removeItem(k); + } + + _schedule() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { this.flush(); return; } + if (this.timer) return; + this.timer = setTimeout(() => { this.timer = 0; this.flush(); }, DEBOUNCE_MS); + } + flush() { + if (this.timer) { clearTimeout(this.timer); this.timer = 0; } + if (!this.ready || this.lsMode || !this.dirty.size) return this._flushing || Promise.resolve(); + if (this._flushing) return this._flushing.then(() => this.flush()); + const keys = [...this.dirty]; this.dirty.clear(); + const puts = [], dels = []; + for (const k of keys) { if (this.mem.has(k)) puts.push([k, this.mem.get(k)]); else dels.push(k); } + this._flushing = this.backend.write(puts, dels) + .catch(() => { for (const k of keys) if (!this.dirty.has(k)) this.dirty.add(k); }) + .finally(() => { this._flushing = null; }); + return this._flushing; + } +} + +let shared = null; +export function cacheStore() { + if (!shared) { + shared = new CacheStore(); + shared.open().catch(() => {}); + } + return shared; +} +// A Storage-shaped handle for code that used localStorage directly. +export const cacheStorage = { + getItem: (k) => cacheStore().getItem(k), + setItem: (k, v) => cacheStore().setItem(k, v), + removeItem: (k) => cacheStore().removeItem(k), + keys: () => cacheStore().keys(), + flush: () => cacheStore().flush(), +}; +export const onCacheReady = (fn) => cacheStore().onReady(fn); +// for tests and the console: what a cache holds, wherever it lives +try { globalThis.coinosCache = cacheStorage; } catch {} +// Boot waits for this, but never long: a wedged IndexedDB must not hold the +// wallet closed — the caches just start out empty. +export function cacheReady() { + const s = cacheStore(); + if (s.ready) return Promise.resolve(); + return Promise.race([s.open(), new Promise((r) => setTimeout(r, OPEN_WAIT_MS))]); +} +export const cacheIsReady = () => cacheStore().ready; |
