idb.mjs raw
1 // TinyJS Runtime — IndexedDB Bridge
2 // Provides Go-callable IndexedDB operations for event and DM storage.
3
4 import { setKey as _setEncKey, sealStr, openStr, hasKey as _encHasKey } from './aead.mjs';
5
6 const DB_NAME = 'musiquay';
7 const DB_VERSION = 5;
8 let _db = null;
9 let _appVersion = '';
10
11 export function SetEncKey(hexKey) {
12 const hex = '' + hexKey;
13 const key = new Uint8Array(32);
14 for (let i = 0; i < 32; i++) key[i] = parseInt(hex.substr(i * 2, 2), 16);
15 _setEncKey(key);
16 }
17
18 function _openDB(onReady) {
19 // Watchdog: if neither onsuccess/onerror/onblocked fires within 10s
20 // (e.g. another tab holds the DB open, OS-level IDB throttling on mobile
21 // Firefox, or storage quota deadlock), invoke onReady with _db=null so the
22 // store worker can still send SR_READY and the boot doesn't hang.
23 let _called = false;
24 const _onReadyOnce = (reason) => {
25 if (_called) return;
26 _called = true;
27 if (reason) console.warn('idb:', reason);
28 onReady();
29 };
30 const _watchdog = setTimeout(() => {
31 _onReadyOnce('open timed out after 10s, proceeding with _db=null');
32 }, 10000);
33 const _done = (reason) => {
34 clearTimeout(_watchdog);
35 _onReadyOnce(reason);
36 };
37 let req;
38 try {
39 req = indexedDB.open(DB_NAME, DB_VERSION);
40 } catch (e) {
41 console.error('idb: open threw:', e);
42 _done('open threw synchronously');
43 return;
44 }
45 req.onupgradeneeded = (e) => {
46 const db = e.target.result;
47 if (!db.objectStoreNames.contains('events')) {
48 const ev = db.createObjectStore('events', { keyPath: 'id' });
49 ev.createIndex('kind', 'kind', { unique: false });
50 ev.createIndex('pubkey', 'pubkey', { unique: false });
51 ev.createIndex('created_at', 'created_at', { unique: false });
52 ev.createIndex('kind_created', ['kind', 'created_at'], { unique: false });
53 }
54 if (!db.objectStoreNames.contains('dms')) {
55 const dm = db.createObjectStore('dms', { keyPath: 'id' });
56 dm.createIndex('peer', 'peer', { unique: false });
57 dm.createIndex('created_at', 'created_at', { unique: false });
58 dm.createIndex('peer_created', ['peer', 'created_at'], { unique: false });
59 }
60 if (!db.objectStoreNames.contains('meta')) {
61 db.createObjectStore('meta', { keyPath: 'key' });
62 }
63 if (!db.objectStoreNames.contains('mls_groups')) {
64 db.createObjectStore('mls_groups', { keyPath: 'id' });
65 }
66 if (!db.objectStoreNames.contains('mls_kpp')) {
67 db.createObjectStore('mls_kpp', { keyPath: 'key' });
68 }
69 if (!db.objectStoreNames.contains('kv_profiles')) {
70 db.createObjectStore('kv_profiles');
71 }
72 if (!db.objectStoreNames.contains('kv_settings')) {
73 db.createObjectStore('kv_settings');
74 }
75 if (!db.objectStoreNames.contains('kv_cache')) {
76 db.createObjectStore('kv_cache');
77 }
78 };
79 req.onsuccess = (e) => {
80 _db = e.target.result;
81 _done(null);
82 };
83 req.onerror = (e) => {
84 const err = e.target.error;
85 if (err && err.name === 'VersionError') {
86 console.warn('idb: version downgrade, deleting stale DB');
87 clearTimeout(_watchdog);
88 const del = indexedDB.deleteDatabase(DB_NAME);
89 del.onsuccess = () => _openDB(onReady);
90 del.onerror = () => _onReadyOnce('delete after VersionError failed');
91 del.onblocked = () => _onReadyOnce('delete blocked by other tab');
92 return;
93 }
94 console.error('idb: open error:', err);
95 _done('open errored');
96 };
97 // Mobile Firefox sometimes fires onblocked when another tab/version holds
98 // the database open, instead of returning an error. Resolve the watchdog
99 // and proceed so the boot doesn't hang.
100 req.onblocked = (e) => {
101 console.warn('idb: open blocked', e);
102 _done('open blocked');
103 };
104 }
105
106 function _checkVersion(fn) {
107 if (!_db || !_appVersion) { fn(); return; }
108 const tx = _db.transaction('meta', 'readonly');
109 const store = tx.objectStore('meta');
110 const req = store.get('version');
111 req.onsuccess = () => {
112 const stored = req.result ? req.result.value : '';
113 if (stored && stored !== _appVersion) {
114 // Version mismatch — flush all data stores.
115 const clearTx = _db.transaction(['events', 'dms', 'meta', 'mls_groups', 'mls_kpp'], 'readwrite');
116 clearTx.objectStore('events').clear();
117 clearTx.objectStore('dms').clear();
118 clearTx.objectStore('mls_groups').clear();
119 clearTx.objectStore('mls_kpp').clear();
120 clearTx.objectStore('meta').put({ key: 'version', value: _appVersion });
121 clearTx.oncomplete = fn;
122 clearTx.onerror = fn;
123 } else if (!stored) {
124 const writeTx = _db.transaction('meta', 'readwrite');
125 writeTx.objectStore('meta').put({ key: 'version', value: _appVersion });
126 writeTx.oncomplete = fn;
127 writeTx.onerror = fn;
128 } else {
129 fn();
130 }
131 };
132 req.onerror = fn;
133 }
134
135 export function SetVersion(v) {
136 _appVersion = v;
137 }
138
139 export function Open(fn) {
140 _openDB(() => {
141 _checkVersion(() => { if (fn) fn(); });
142 });
143 }
144
145 export function SaveEvent(eventJSON, fn) {
146 if (!_db) { if (fn) fn(false); return; }
147 let ev;
148 try { ev = JSON.parse(eventJSON); } catch(e) { if (fn) fn(false); return; }
149 const tx = _db.transaction('events', 'readwrite');
150 const store = tx.objectStore('events');
151 const check = store.get(ev.id);
152 check.onsuccess = () => {
153 if (check.result) {
154 if (fn) fn(false); // duplicate
155 } else {
156 store.put(ev);
157 tx.oncomplete = () => { if (fn) fn(true); };
158 tx.onerror = () => { if (fn) fn(false); };
159 }
160 };
161 check.onerror = () => { if (fn) fn(false); };
162 }
163
164 export function QueryEvents(filterJSON, fn) {
165 if (!_db) { if (fn) fn('[]'); return; }
166 let filter;
167 try { filter = JSON.parse(filterJSON); } catch(e) { if (fn) fn('[]'); return; }
168 const tx = _db.transaction('events', 'readonly');
169 const store = tx.objectStore('events');
170 const results = [];
171 const limit = filter.limit || 500;
172
173 // If filtering by specific IDs, fetch directly.
174 if (filter.ids && filter.ids.length > 0) {
175 let pending = filter.ids.length;
176 for (const id of filter.ids) {
177 const req = store.get(id);
178 req.onsuccess = () => {
179 if (req.result) results.push(req.result);
180 if (--pending === 0) {
181 if (fn) fn(JSON.stringify(results.slice(0, limit)));
182 }
183 };
184 req.onerror = () => {
185 if (--pending === 0) {
186 if (fn) fn(JSON.stringify(results.slice(0, limit)));
187 }
188 };
189 }
190 return;
191 }
192
193 // Use kind+created_at index when filtering by kinds.
194 let source;
195 if (filter.kinds && filter.kinds.length === 1) {
196 const kind = filter.kinds[0];
197 const idx = store.index('kind_created');
198 const lower = [kind, Number(filter.since) || 0];
199 const upper = [kind, Number(filter.until) || Date.now() / 1000 + 86400];
200 source = idx.openCursor(IDBKeyRange.bound(lower, upper), 'prev');
201 } else {
202 const idx = store.index('created_at');
203 source = idx.openCursor(null, 'prev');
204 }
205
206 source.onsuccess = (e) => {
207 const cursor = e.target.result;
208 if (!cursor || results.length >= limit) {
209 if (fn) fn(JSON.stringify(results));
210 return;
211 }
212 const ev = cursor.value;
213 if (_matchesFilter(ev, filter)) {
214 results.push(ev);
215 }
216 cursor.continue();
217 };
218 source.onerror = () => { if (fn) fn('[]'); };
219 }
220
221 function _matchesFilter(ev, f) {
222 if (f.kinds && f.kinds.length > 0 && !f.kinds.includes(ev.kind)) return false;
223 if (f.authors && f.authors.length > 0 && !f.authors.includes(ev.pubkey)) return false;
224 if (f.since && ev.created_at < f.since) return false;
225 if (f.until && ev.created_at > f.until) return false;
226 // Tag filters (#e, #p, etc.)
227 for (const key of Object.keys(f)) {
228 if (key.startsWith('#') && key.length === 2) {
229 const tag = key[1];
230 const vals = f[key];
231 if (vals && vals.length > 0) {
232 const evTags = (ev.tags || []).filter(t => t[0] === tag).map(t => t[1]);
233 if (!vals.some(v => evTags.includes(v))) return false;
234 }
235 }
236 }
237 return true;
238 }
239
240 export function SaveDM(dmJSON, fn) {
241 if (!_db) { if (fn) fn('error'); return; }
242 let dm;
243 try { dm = JSON.parse(dmJSON); } catch(e) { if (fn) fn('error'); return; }
244 if (!dm.id) { if (fn) fn('error'); return; }
245 // Encrypt content at rest
246 if (_encHasKey() && dm.content) dm.content = sealStr(dm.content);
247 const tx = _db.transaction('dms', 'readwrite');
248 const store = tx.objectStore('dms');
249 const check = store.get(dm.id);
250 check.onsuccess = () => {
251 if (check.result) {
252 if (dm.content && (!check.result.content || check.result.content !== dm.content)) {
253 store.put(dm);
254 tx.oncomplete = () => { if (fn) fn('upgraded'); };
255 tx.onerror = () => { if (fn) fn('error'); };
256 } else {
257 if (fn) fn('duplicate');
258 }
259 } else {
260 store.put(dm);
261 tx.oncomplete = () => { if (fn) fn('saved'); };
262 tx.onerror = () => { if (fn) fn('error'); };
263 }
264 };
265 check.onerror = () => { if (fn) fn('error'); };
266 }
267
268 function _decryptDM(dm) {
269 if (_encHasKey() && dm.content) dm.content = openStr(dm.content);
270 return dm;
271 }
272
273 export function QueryDMs(peer, limit, until, fn) {
274 if (!_db) { if (fn) fn('[]'); return; }
275 const tx = _db.transaction('dms', 'readonly');
276 const store = tx.objectStore('dms');
277 const idx = store.index('peer_created');
278 const results = [];
279 const max = limit || 50;
280 const upper = Number(until) > 0 ? Number(until) : Date.now() / 1000 + 86400;
281 const range = IDBKeyRange.bound([peer, 0], [peer, upper]);
282 const req = idx.openCursor(range, 'prev');
283 req.onsuccess = (e) => {
284 const cursor = e.target.result;
285 if (!cursor || results.length >= max) {
286 if (fn) fn(JSON.stringify(results));
287 return;
288 }
289 results.push(_decryptDM(cursor.value));
290 cursor.continue();
291 };
292 req.onerror = () => { if (fn) fn('[]'); };
293 }
294
295 export function GetConversationList(fn) {
296 if (!_db) { if (fn) fn('[]'); return; }
297 const tx = _db.transaction('dms', 'readonly');
298 const store = tx.objectStore('dms');
299 const convos = new Map(); // peer -> latest DM
300 const req = store.openCursor();
301 req.onsuccess = (e) => {
302 const cursor = e.target.result;
303 if (!cursor) {
304 const list = Array.from(convos.values());
305 list.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
306 if (fn) fn(JSON.stringify(list));
307 return;
308 }
309 const dm = _decryptDM(cursor.value);
310 const peer = dm.peer || '';
311 if (peer) {
312 const existing = convos.get(peer);
313 if (!existing || (dm.created_at || 0) > (existing.created_at || 0)) {
314 convos.set(peer, dm);
315 }
316 }
317 cursor.continue();
318 };
319 req.onerror = () => { if (fn) fn('[]'); };
320 }
321
322 export function ClearDMsByPeer(peer, fn) {
323 if (!_db) { if (fn) fn(); return; }
324 const tx = _db.transaction('dms', 'readwrite');
325 const store = tx.objectStore('dms');
326 const idx = store.index('peer');
327 const req = idx.openCursor(IDBKeyRange.only(peer));
328 req.onsuccess = (e) => {
329 const cursor = e.target.result;
330 if (!cursor) return;
331 cursor.delete();
332 cursor.continue();
333 };
334 tx.oncomplete = () => { if (fn) fn(); };
335 tx.onerror = () => { if (fn) fn(); };
336 }
337
338 // --- MLS storage ---
339
340 export function MlsSaveGroup(groupIDHex, stateBase64, fn) {
341 if (!_db) { if (fn) fn(); return; }
342 const tx = _db.transaction('mls_groups', 'readwrite');
343 tx.objectStore('mls_groups').put({ id: groupIDHex, state: stateBase64 });
344 tx.oncomplete = () => { if (fn) fn(); };
345 tx.onerror = () => { if (fn) fn(); };
346 }
347
348 export function MlsLoadGroup(groupIDHex, fn) {
349 if (!_db) { if (fn) fn(''); return; }
350 const tx = _db.transaction('mls_groups', 'readonly');
351 const req = tx.objectStore('mls_groups').get(groupIDHex);
352 req.onsuccess = () => { if (fn) fn(req.result ? req.result.state : ''); };
353 req.onerror = () => { if (fn) fn(''); };
354 }
355
356 export function MlsListGroups(fn) {
357 if (!_db) { if (fn) fn('[]'); return; }
358 const tx = _db.transaction('mls_groups', 'readonly');
359 const req = tx.objectStore('mls_groups').getAllKeys();
360 req.onsuccess = () => { if (fn) fn(JSON.stringify(req.result || [])); };
361 req.onerror = () => { if (fn) fn('[]'); };
362 }
363
364 export function MlsSaveKPP(kppBase64, fn) {
365 if (!_db) { if (fn) fn(); return; }
366 const tx = _db.transaction('mls_kpp', 'readwrite');
367 tx.objectStore('mls_kpp').put({ key: 'kpp', data: kppBase64 });
368 tx.oncomplete = () => { if (fn) fn(); };
369 tx.onerror = () => { if (fn) fn(); };
370 }
371
372 export function MlsLoadKPP(fn) {
373 if (!_db) { if (fn) fn(''); return; }
374 const tx = _db.transaction('mls_kpp', 'readonly');
375 const req = tx.objectStore('mls_kpp').get('kpp');
376 req.onsuccess = () => { if (fn) fn(req.result ? req.result.data : ''); };
377 req.onerror = () => { if (fn) fn(''); };
378 }
379
380 // --- KV storage (profiles, settings, cache) ---
381
382 const _kvStores = { profiles: 'kv_profiles', settings: 'kv_settings', cache: 'kv_cache' };
383
384 function _kvStore(name) { return _kvStores[name] || name; }
385
386 export function KVGet(storeName, key, fn) {
387 if (!_db) { if (fn) fn(''); return; }
388 const s = _kvStore(storeName);
389 const tx = _db.transaction(s, 'readonly');
390 const req = tx.objectStore(s).get(key);
391 req.onsuccess = () => {
392 const raw = req.result ?? '';
393 fn(_encHasKey() ? openStr(raw) : raw);
394 };
395 req.onerror = () => { if (fn) fn(''); };
396 }
397
398 export function KVPut(storeName, key, value) {
399 if (!_db) return;
400 const s = _kvStore(storeName);
401 const tx = _db.transaction(s, 'readwrite');
402 const v = _encHasKey() ? sealStr('' + value) : value;
403 tx.objectStore(s).put(v, key);
404 }
405
406 export function KVGetAll(storeName, eachFn, doneFn) {
407 if (!_db) { if (doneFn) doneFn(); return; }
408 const s = _kvStore(storeName);
409 const tx = _db.transaction(s, 'readonly');
410 const req = tx.objectStore(s).openCursor();
411 req.onsuccess = (e) => {
412 const cursor = e.target.result;
413 if (cursor) {
414 const val = _encHasKey() ? openStr(String(cursor.value)) : String(cursor.value);
415 eachFn(String(cursor.key), val);
416 cursor.continue();
417 } else {
418 if (doneFn) doneFn();
419 }
420 };
421 req.onerror = () => { if (doneFn) doneFn(); };
422 }
423