idb.mjs raw

   1  // TinyJS Runtime — IndexedDB store for events and DMs
   2  // Port of smesh2/db.js with same schema and query logic.
   3  
   4  const DB_NAME = 'sm3sh';
   5  const DB_VERSION = 4;
   6  
   7  let _db = null;
   8  let _dbPromise = null;
   9  let _expectedVersion = '';
  10  
  11  function openDB() {
  12    return new Promise((resolve, reject) => {
  13      const req = indexedDB.open(DB_NAME, DB_VERSION);
  14      req.onupgradeneeded = (e) => {
  15        const db = e.target.result;
  16        if (!db.objectStoreNames.contains('profiles')) db.createObjectStore('profiles');
  17        if (!db.objectStoreNames.contains('relays')) db.createObjectStore('relays');
  18        if (!db.objectStoreNames.contains('events')) {
  19          const store = db.createObjectStore('events', { keyPath: 'id' });
  20          store.createIndex('pubkey', 'pubkey', { unique: false });
  21          store.createIndex('kind', 'kind', { unique: false });
  22          store.createIndex('pubkey_kind', ['pubkey', 'kind'], { unique: false });
  23          store.createIndex('created_at', 'created_at', { unique: false });
  24        }
  25        if (!db.objectStoreNames.contains('dms')) {
  26          const dms = db.createObjectStore('dms', { keyPath: 'id' });
  27          dms.createIndex('peer', 'peer', { unique: false });
  28          dms.createIndex('peer_ts', ['peer', 'created_at'], { unique: false });
  29        }
  30        if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta');
  31      };
  32      req.onblocked = () => { console.warn('[sm3sh-sw] IDB upgrade blocked'); };
  33      req.onsuccess = () => {
  34        const db = req.result;
  35        db._closed = false;
  36        db.onclose = () => { db._closed = true; };
  37        db.onversionchange = () => { db.close(); db._closed = true; _db = null; };
  38        if (_expectedVersion) {
  39          _epochCheck(db).then(() => resolve(db)).catch(() => resolve(db));
  40        } else {
  41          resolve(db);
  42        }
  43      };
  44      req.onerror = () => reject(req.error);
  45    });
  46  }
  47  
  48  function _epochCheck(db) {
  49    return new Promise((resolve) => {
  50      const tx = db.transaction('meta', 'readonly');
  51      const store = tx.objectStore('meta');
  52      const req = store.get('_version');
  53      req.onsuccess = () => {
  54        const stored = req.result;
  55        if (stored === _expectedVersion) { resolve(); return; }
  56        console.warn('[sm3sh-sw] version epoch mismatch: stored=' + (stored || 'none') + ' running=' + _expectedVersion + ' — flushing IDB');
  57        _flushAndStamp(db).then(resolve);
  58      };
  59      req.onerror = () => { _flushAndStamp(db).then(resolve); };
  60    });
  61  }
  62  
  63  function _flushAndStamp(db) {
  64    return new Promise((resolve) => {
  65      const names = ['profiles', 'relays', 'events', 'dms'];
  66      const tx = db.transaction([...names, 'meta'], 'readwrite');
  67      for (const name of names) tx.objectStore(name).clear();
  68      tx.objectStore('meta').put(_expectedVersion, '_version');
  69      tx.oncomplete = () => { resolve(); };
  70      tx.onerror = () => { resolve(); };
  71    });
  72  }
  73  
  74  async function getDB() {
  75    if (_db && !_db._closed) return _db;
  76    if (!_dbPromise) _dbPromise = openDB();
  77    _db = await _dbPromise;
  78    _dbPromise = null;
  79    return _db;
  80  }
  81  
  82  // --- Exports for Go jsbridge ---
  83  
  84  export function Open(fn) {
  85    getDB().then(() => fn());
  86  }
  87  
  88  export function SaveEvent(eventJSON, fn) {
  89    let event;
  90    try { event = JSON.parse(eventJSON); } catch (e) {
  91      console.error('SaveEvent JSON parse error:', e.message);
  92      fn(false); return;
  93    }
  94    getDB().then(db => {
  95      try {
  96        const tx = db.transaction('events', 'readwrite');
  97        const store = tx.objectStore('events');
  98        const req = store.put(event);
  99        req.onsuccess = () => { try { fn(true); } catch(e) { _busErr('SaveEvent cb', e); } };
 100        req.onerror = () => {
 101          if (req.error?.name === 'ConstraintError') fn(false);
 102          else { console.warn('SaveEvent error:', req.error); fn(false); }
 103        };
 104      } catch (e) {
 105        console.error('SaveEvent tx error:', e.message);
 106        _busErr('SaveEvent tx', e);
 107        fn(false);
 108      }
 109    }).catch(e => { console.error('SaveEvent getDB error:', e.message); _busErr('SaveEvent getDB', e); fn(false); });
 110  }
 111  
 112  function _busErr(ctx, e) {
 113    if (self._busPort) {
 114      self._busPort.postMessage('{"from":"relay","to":"shell","msg":["LOG","relay","IDB CRASH ' + ctx + ': ' + String(e.message).replace(/"/g, '\\"').replace(/\n/g, ' ') + '"]}');
 115    }
 116  }
 117  
 118  export function QueryEvents(filterJSON, fn) {
 119    const filter = JSON.parse(filterJSON);
 120    getDB().then(db => {
 121      const tx = db.transaction('events', 'readonly');
 122      const store = tx.objectStore('events');
 123      const results = [];
 124  
 125      let source;
 126      if (filter.authors?.length === 1 && filter.kinds?.length === 1) {
 127        const idx = store.index('pubkey_kind');
 128        source = idx.openCursor(IDBKeyRange.only([filter.authors[0], filter.kinds[0]]), 'prev');
 129      } else if (filter.authors?.length === 1) {
 130        source = store.index('pubkey').openCursor(IDBKeyRange.only(filter.authors[0]), 'prev');
 131      } else if (filter.kinds?.length === 1) {
 132        source = store.index('kind').openCursor(IDBKeyRange.only(filter.kinds[0]), 'prev');
 133      } else {
 134        source = store.index('created_at').openCursor(null, 'prev');
 135      }
 136  
 137      source.onsuccess = (e) => {
 138        const cursor = e.target.result;
 139        if (!cursor) { fn(JSON.stringify(results)); return; }
 140  
 141        const ev = cursor.value;
 142        let match = true;
 143  
 144        if (filter.ids && !filter.ids.includes(ev.id)) match = false;
 145        if (filter.authors?.length > 1 && !filter.authors.includes(ev.pubkey)) match = false;
 146        if (filter.kinds?.length > 1 && !filter.kinds.includes(ev.kind)) match = false;
 147        if (filter.since && ev.created_at < filter.since) match = false;
 148        if (filter.until && ev.created_at > filter.until) match = false;
 149  
 150        if (match) {
 151          for (const [k, v] of Object.entries(filter)) {
 152            if (k.startsWith('#') && k.length === 2) {
 153              const tagName = k[1];
 154              if (!ev.tags?.some(t => t[0] === tagName && v.includes(t[1]))) { match = false; break; }
 155            }
 156          }
 157        }
 158  
 159        if (match) results.push(ev);
 160        if (filter.limit && results.length >= filter.limit) { fn(JSON.stringify(results)); return; }
 161        cursor.continue();
 162      };
 163      source.onerror = () => { console.warn('QueryEvents error:', source.error); fn('[]'); };
 164    });
 165  }
 166  
 167  export function SaveDM(dmJSON, fn) {
 168    const dm = JSON.parse(dmJSON);
 169    getDB().then(db => {
 170      const tx = db.transaction('dms', 'readwrite');
 171      const store = tx.objectStore('dms');
 172      const getReq = store.get(dm.id);
 173      getReq.onsuccess = () => {
 174        const existing = getReq.result;
 175        if (existing) {
 176          if (dm.protocol === 'nip17' && existing.protocol === 'nip04') {
 177            store.put(dm);
 178            fn('upgraded');
 179          } else {
 180            fn('duplicate');
 181          }
 182        } else {
 183          store.put(dm);
 184          fn('saved');
 185        }
 186      };
 187      getReq.onerror = () => { console.warn('SaveDM error:', getReq.error); fn('error'); };
 188    });
 189  }
 190  
 191  export function QueryDMs(peer, limit, until, fn) {
 192    getDB().then(db => {
 193      const tx = db.transaction('dms', 'readonly');
 194      const store = tx.objectStore('dms');
 195      const idx = store.index('peer_ts');
 196      const results = [];
 197  
 198      const upper = until > 0 ? [peer, until] : [peer, Date.now() / 1000 + 86400];
 199      const range = IDBKeyRange.bound([peer, 0], upper);
 200      const req = idx.openCursor(range, 'prev');
 201  
 202      req.onsuccess = (e) => {
 203        const cursor = e.target.result;
 204        if (!cursor || results.length >= limit) { fn(JSON.stringify(results)); return; }
 205        results.push(cursor.value);
 206        cursor.continue();
 207      };
 208      req.onerror = () => { console.warn('QueryDMs error:', req.error); fn('[]'); };
 209    });
 210  }
 211  
 212  export function GetConversationList(fn) {
 213    getDB().then(db => {
 214      const tx = db.transaction('dms', 'readonly');
 215      const store = tx.objectStore('dms');
 216      const idx = store.index('peer_ts');
 217      const list = [];
 218      const seen = new Set();
 219  
 220      // Reverse cursor on [peer, created_at] — for each peer, the last
 221      // entry in sort order is the newest message. After grabbing it,
 222      // advance past the rest of that peer's messages.
 223      const req = idx.openCursor(null, 'prev');
 224      req.onsuccess = (e) => {
 225        const cursor = e.target.result;
 226        if (!cursor) {
 227          list.sort((a, b) => b.lastTs - a.lastTs);
 228          fn(JSON.stringify(list));
 229          return;
 230        }
 231        const dm = cursor.value;
 232        if (!seen.has(dm.peer)) {
 233          seen.add(dm.peer);
 234          list.push({
 235            peer: dm.peer,
 236            lastMessage: dm.content.slice(0, 80),
 237            lastTs: dm.created_at,
 238            from: dm.from,
 239          });
 240          // Skip to the previous peer — advance cursor past all entries
 241          // for this peer by setting upper bound to [peer, 0].
 242          cursor.continue([dm.peer, 0]);
 243        } else {
 244          // Still in same peer range (shouldn't happen with continue skip,
 245          // but handle gracefully).
 246          cursor.continue();
 247        }
 248      };
 249      req.onerror = () => { console.warn('GetConversationList error:', req.error); fn('[]'); };
 250    });
 251  }
 252  
 253  export function ClearDMsByPeer(peer, fn) {
 254    getDB().then(db => {
 255      const tx = db.transaction('dms', 'readwrite');
 256      const store = tx.objectStore('dms');
 257      const idx = store.index('peer_ts');
 258      const range = IDBKeyRange.bound([peer, 0], [peer, Infinity]);
 259      const req = idx.openCursor(range);
 260      req.onsuccess = (e) => {
 261        const cursor = e.target.result;
 262        if (!cursor) { fn(); return; }
 263        cursor.delete();
 264        cursor.continue();
 265      };
 266      req.onerror = () => fn();
 267    });
 268  }
 269  
 270  export function SetVersion(v) {
 271    _expectedVersion = v;
 272  }
 273