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