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