idb.mjs raw

   1  // TinyJS Runtime — IndexedDB Bridge
   2  // Provides Go-callable IndexedDB operations for event and DM storage.
   3  
   4  const DB_NAME = 'smesh';
   5  const DB_VERSION = 2;
   6  let _db = null;
   7  let _appVersion = '';
   8  
   9  function _openDB(onReady) {
  10    const req = indexedDB.open(DB_NAME, DB_VERSION);
  11    req.onupgradeneeded = (e) => {
  12      const db = e.target.result;
  13      if (!db.objectStoreNames.contains('events')) {
  14        const ev = db.createObjectStore('events', { keyPath: 'id' });
  15        ev.createIndex('kind', 'kind', { unique: false });
  16        ev.createIndex('pubkey', 'pubkey', { unique: false });
  17        ev.createIndex('created_at', 'created_at', { unique: false });
  18        ev.createIndex('kind_created', ['kind', 'created_at'], { unique: false });
  19      }
  20      if (!db.objectStoreNames.contains('dms')) {
  21        const dm = db.createObjectStore('dms', { keyPath: 'id' });
  22        dm.createIndex('peer', 'peer', { unique: false });
  23        dm.createIndex('created_at', 'created_at', { unique: false });
  24        dm.createIndex('peer_created', ['peer', 'created_at'], { unique: false });
  25      }
  26      if (!db.objectStoreNames.contains('meta')) {
  27        db.createObjectStore('meta', { keyPath: 'key' });
  28      }
  29    };
  30    req.onsuccess = (e) => {
  31      _db = e.target.result;
  32      onReady();
  33    };
  34    req.onerror = (e) => {
  35      console.error('idb: open error:', e.target.error);
  36      onReady();
  37    };
  38  }
  39  
  40  function _checkVersion(fn) {
  41    if (!_db || !_appVersion) { fn(); return; }
  42    const tx = _db.transaction('meta', 'readonly');
  43    const store = tx.objectStore('meta');
  44    const req = store.get('version');
  45    req.onsuccess = () => {
  46      const stored = req.result ? req.result.value : '';
  47      if (stored && stored !== _appVersion) {
  48        // Version mismatch — flush all data stores.
  49        const clearTx = _db.transaction(['events', 'dms', 'meta'], 'readwrite');
  50        clearTx.objectStore('events').clear();
  51        clearTx.objectStore('dms').clear();
  52        clearTx.objectStore('meta').put({ key: 'version', value: _appVersion });
  53        clearTx.oncomplete = fn;
  54        clearTx.onerror = fn;
  55      } else if (!stored) {
  56        const writeTx = _db.transaction('meta', 'readwrite');
  57        writeTx.objectStore('meta').put({ key: 'version', value: _appVersion });
  58        writeTx.oncomplete = fn;
  59        writeTx.onerror = fn;
  60      } else {
  61        fn();
  62      }
  63    };
  64    req.onerror = fn;
  65  }
  66  
  67  export function SetVersion(v) {
  68    _appVersion = v;
  69  }
  70  
  71  export function Open(fn) {
  72    _openDB(() => {
  73      _checkVersion(() => { if (fn) fn(); });
  74    });
  75  }
  76  
  77  export function SaveEvent(eventJSON, fn) {
  78    if (!_db) { if (fn) fn(false); return; }
  79    let ev;
  80    try { ev = JSON.parse(eventJSON); } catch(e) { if (fn) fn(false); return; }
  81    const tx = _db.transaction('events', 'readwrite');
  82    const store = tx.objectStore('events');
  83    const check = store.get(ev.id);
  84    check.onsuccess = () => {
  85      if (check.result) {
  86        if (fn) fn(false); // duplicate
  87      } else {
  88        store.put(ev);
  89        tx.oncomplete = () => { if (fn) fn(true); };
  90        tx.onerror = () => { if (fn) fn(false); };
  91      }
  92    };
  93    check.onerror = () => { if (fn) fn(false); };
  94  }
  95  
  96  export function QueryEvents(filterJSON, fn) {
  97    if (!_db) { if (fn) fn('[]'); return; }
  98    let filter;
  99    try { filter = JSON.parse(filterJSON); } catch(e) { if (fn) fn('[]'); return; }
 100    const tx = _db.transaction('events', 'readonly');
 101    const store = tx.objectStore('events');
 102    const results = [];
 103    const limit = filter.limit || 500;
 104  
 105    // If filtering by specific IDs, fetch directly.
 106    if (filter.ids && filter.ids.length > 0) {
 107      let pending = filter.ids.length;
 108      for (const id of filter.ids) {
 109        const req = store.get(id);
 110        req.onsuccess = () => {
 111          if (req.result) results.push(req.result);
 112          if (--pending === 0) {
 113            if (fn) fn(JSON.stringify(results.slice(0, limit)));
 114          }
 115        };
 116        req.onerror = () => {
 117          if (--pending === 0) {
 118            if (fn) fn(JSON.stringify(results.slice(0, limit)));
 119          }
 120        };
 121      }
 122      return;
 123    }
 124  
 125    // Use kind+created_at index when filtering by kinds.
 126    let source;
 127    if (filter.kinds && filter.kinds.length === 1) {
 128      const kind = filter.kinds[0];
 129      const idx = store.index('kind_created');
 130      const lower = [kind, Number(filter.since) || 0];
 131      const upper = [kind, Number(filter.until) || Date.now() / 1000 + 86400];
 132      source = idx.openCursor(IDBKeyRange.bound(lower, upper), 'prev');
 133    } else {
 134      const idx = store.index('created_at');
 135      source = idx.openCursor(null, 'prev');
 136    }
 137  
 138    source.onsuccess = (e) => {
 139      const cursor = e.target.result;
 140      if (!cursor || results.length >= limit) {
 141        if (fn) fn(JSON.stringify(results));
 142        return;
 143      }
 144      const ev = cursor.value;
 145      if (_matchesFilter(ev, filter)) {
 146        results.push(ev);
 147      }
 148      cursor.continue();
 149    };
 150    source.onerror = () => { if (fn) fn('[]'); };
 151  }
 152  
 153  function _matchesFilter(ev, f) {
 154    if (f.kinds && f.kinds.length > 0 && !f.kinds.includes(ev.kind)) return false;
 155    if (f.authors && f.authors.length > 0 && !f.authors.includes(ev.pubkey)) return false;
 156    if (f.since && ev.created_at < f.since) return false;
 157    if (f.until && ev.created_at > f.until) return false;
 158    // Tag filters (#e, #p, etc.)
 159    for (const key of Object.keys(f)) {
 160      if (key.startsWith('#') && key.length === 2) {
 161        const tag = key[1];
 162        const vals = f[key];
 163        if (vals && vals.length > 0) {
 164          const evTags = (ev.tags || []).filter(t => t[0] === tag).map(t => t[1]);
 165          if (!vals.some(v => evTags.includes(v))) return false;
 166        }
 167      }
 168    }
 169    return true;
 170  }
 171  
 172  export function SaveDM(dmJSON, fn) {
 173    if (!_db) { if (fn) fn('error'); return; }
 174    let dm;
 175    try { dm = JSON.parse(dmJSON); } catch(e) { if (fn) fn('error'); return; }
 176    if (!dm.id) { if (fn) fn('error'); return; }
 177    const tx = _db.transaction('dms', 'readwrite');
 178    const store = tx.objectStore('dms');
 179    const check = store.get(dm.id);
 180    check.onsuccess = () => {
 181      if (check.result) {
 182        // Check if this is an upgrade (e.g. decrypted version replacing encrypted).
 183        if (dm.content && (!check.result.content || check.result.content !== dm.content)) {
 184          store.put(dm);
 185          tx.oncomplete = () => { if (fn) fn('upgraded'); };
 186          tx.onerror = () => { if (fn) fn('error'); };
 187        } else {
 188          if (fn) fn('duplicate');
 189        }
 190      } else {
 191        store.put(dm);
 192        tx.oncomplete = () => { if (fn) fn('saved'); };
 193        tx.onerror = () => { if (fn) fn('error'); };
 194      }
 195    };
 196    check.onerror = () => { if (fn) fn('error'); };
 197  }
 198  
 199  export function QueryDMs(peer, limit, until, fn) {
 200    if (!_db) { if (fn) fn('[]'); return; }
 201    const tx = _db.transaction('dms', 'readonly');
 202    const store = tx.objectStore('dms');
 203    const idx = store.index('peer_created');
 204    const results = [];
 205    const max = limit || 50;
 206    const upper = Number(until) > 0 ? Number(until) : Date.now() / 1000 + 86400;
 207    const range = IDBKeyRange.bound([peer, 0], [peer, upper]);
 208    const req = idx.openCursor(range, 'prev');
 209    req.onsuccess = (e) => {
 210      const cursor = e.target.result;
 211      if (!cursor || results.length >= max) {
 212        if (fn) fn(JSON.stringify(results));
 213        return;
 214      }
 215      results.push(cursor.value);
 216      cursor.continue();
 217    };
 218    req.onerror = () => { if (fn) fn('[]'); };
 219  }
 220  
 221  export function GetConversationList(fn) {
 222    if (!_db) { if (fn) fn('[]'); return; }
 223    const tx = _db.transaction('dms', 'readonly');
 224    const store = tx.objectStore('dms');
 225    const convos = new Map(); // peer -> latest DM
 226    const req = store.openCursor();
 227    req.onsuccess = (e) => {
 228      const cursor = e.target.result;
 229      if (!cursor) {
 230        const list = Array.from(convos.values());
 231        list.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
 232        if (fn) fn(JSON.stringify(list));
 233        return;
 234      }
 235      const dm = cursor.value;
 236      const peer = dm.peer || '';
 237      if (peer) {
 238        const existing = convos.get(peer);
 239        if (!existing || (dm.created_at || 0) > (existing.created_at || 0)) {
 240          convos.set(peer, dm);
 241        }
 242      }
 243      cursor.continue();
 244    };
 245    req.onerror = () => { if (fn) fn('[]'); };
 246  }
 247  
 248  export function ClearDMsByPeer(peer, fn) {
 249    if (!_db) { if (fn) fn(); return; }
 250    const tx = _db.transaction('dms', 'readwrite');
 251    const store = tx.objectStore('dms');
 252    const idx = store.index('peer');
 253    const req = idx.openCursor(IDBKeyRange.only(peer));
 254    req.onsuccess = (e) => {
 255      const cursor = e.target.result;
 256      if (!cursor) return;
 257      cursor.delete();
 258      cursor.continue();
 259    };
 260    tx.oncomplete = () => { if (fn) fn(); };
 261    tx.onerror = () => { if (fn) fn(); };
 262  }
 263