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