// 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;