1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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;
|