db.js raw

   1  // db.js — IndexedDB operations for events and DMs
   2  
   3  import { sha256, bytesToHex } from './crypto.js'
   4  
   5  const DB_NAME = 'smesh2'
   6  const DB_VERSION = 2
   7  
   8  function openDB() {
   9    return new Promise((resolve, reject) => {
  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 store = db.createObjectStore('events', { keyPath: 'id' })
  15          store.createIndex('pubkey', 'pubkey', { unique: false })
  16          store.createIndex('kind', 'kind', { unique: false })
  17          store.createIndex('pubkey_kind', ['pubkey', 'kind'], { unique: false })
  18          store.createIndex('created_at', 'created_at', { unique: false })
  19        }
  20        if (!db.objectStoreNames.contains('dms')) {
  21          const dms = db.createObjectStore('dms', { keyPath: 'id' })
  22          dms.createIndex('peer', 'peer', { unique: false })
  23          dms.createIndex('peer_ts', ['peer', 'created_at'], { unique: false })
  24        }
  25      }
  26      req.onsuccess = () => resolve(patchDBClose(req.result))
  27      req.onerror = () => reject(req.error)
  28    })
  29  }
  30  
  31  let dbPromise = openDB()
  32  
  33  export async function getDB() {
  34    const db = await dbPromise
  35    if (db._closed) {
  36      dbPromise = openDB()
  37      return dbPromise
  38    }
  39    return db
  40  }
  41  
  42  function patchDBClose(db) {
  43    db._closed = false
  44    db.onclose = () => { db._closed = true }
  45    db.addEventListener('close', () => { db._closed = true })
  46    return db
  47  }
  48  
  49  // ─── events ─────────────────────────────────────────────────────────
  50  
  51  export async function saveEvent(event) {
  52    const db = await getDB()
  53    return new Promise((resolve, reject) => {
  54      const tx = db.transaction('events', 'readwrite')
  55      const store = tx.objectStore('events')
  56      const req = store.put(event)
  57      req.onsuccess = () => resolve(true)
  58      req.onerror = () => {
  59        if (req.error?.name === 'ConstraintError') resolve(false)
  60        else reject(req.error)
  61      }
  62    })
  63  }
  64  
  65  export async function queryEvents(filter) {
  66    const db = await getDB()
  67    return new Promise((resolve, reject) => {
  68      const tx = db.transaction('events', 'readonly')
  69      const store = tx.objectStore('events')
  70      const results = []
  71  
  72      let source
  73      if (filter.authors?.length === 1 && filter.kinds?.length === 1) {
  74        const idx = store.index('pubkey_kind')
  75        const key = IDBKeyRange.only([filter.authors[0], filter.kinds[0]])
  76        source = idx.openCursor(key, 'prev')
  77      } else if (filter.authors?.length === 1) {
  78        source = store.index('pubkey').openCursor(
  79          IDBKeyRange.only(filter.authors[0]), 'prev'
  80        )
  81      } else if (filter.kinds?.length === 1) {
  82        source = store.index('kind').openCursor(
  83          IDBKeyRange.only(filter.kinds[0]), 'prev'
  84        )
  85      } else {
  86        source = store.index('created_at').openCursor(null, 'prev')
  87      }
  88  
  89      source.onsuccess = (e) => {
  90        const cursor = e.target.result
  91        if (!cursor) { resolve(results); return }
  92  
  93        const ev = cursor.value
  94        let match = true
  95  
  96        if (filter.ids && !filter.ids.includes(ev.id)) match = false
  97        if (filter.authors?.length > 1 && !filter.authors.includes(ev.pubkey)) match = false
  98        if (filter.kinds?.length > 1 && !filter.kinds.includes(ev.kind)) match = false
  99        if (filter.since && ev.created_at < filter.since) match = false
 100        if (filter.until && ev.created_at > filter.until) match = false
 101  
 102        if (match) {
 103          for (const [k, v] of Object.entries(filter)) {
 104            if (k.startsWith('#') && k.length === 2) {
 105              const tagName = k[1]
 106              const hasTag = ev.tags?.some(
 107                (t) => t[0] === tagName && v.includes(t[1])
 108              )
 109              if (!hasTag) { match = false; break }
 110            }
 111          }
 112        }
 113  
 114        if (match) results.push(ev)
 115        if (filter.limit && results.length >= filter.limit) {
 116          resolve(results)
 117          return
 118        }
 119        cursor.continue()
 120      }
 121      source.onerror = () => reject(source.error)
 122    })
 123  }
 124  
 125  // ─── DMs ────────────────────────────────────────────────────────────
 126  
 127  export function dmDedupId(peerPubkey, content, createdAt) {
 128    const contentHash = bytesToHex(sha256(new TextEncoder().encode(content)))
 129    const timeWindow = Math.floor(createdAt / 300)
 130    return bytesToHex(sha256(new TextEncoder().encode(peerPubkey + contentHash + timeWindow)))
 131  }
 132  
 133  export async function saveDM(dm) {
 134    const db = await getDB()
 135    return new Promise((resolve, reject) => {
 136      const tx = db.transaction('dms', 'readwrite')
 137      const store = tx.objectStore('dms')
 138      const getReq = store.get(dm.id)
 139      getReq.onsuccess = () => {
 140        const existing = getReq.result
 141        if (existing) {
 142          if (dm.protocol === 'nip17' && existing.protocol === 'nip04') {
 143            store.put(dm)
 144            resolve('upgraded')
 145          } else {
 146            resolve('duplicate')
 147          }
 148        } else {
 149          store.put(dm)
 150          resolve('saved')
 151        }
 152      }
 153      getReq.onerror = () => reject(getReq.error)
 154    })
 155  }
 156  
 157  export async function queryDMs(peer, limit = 50, until = null) {
 158    const db = await getDB()
 159    return new Promise((resolve, reject) => {
 160      const tx = db.transaction('dms', 'readonly')
 161      const store = tx.objectStore('dms')
 162      const idx = store.index('peer_ts')
 163      const results = []
 164  
 165      const upper = until ? [peer, until] : [peer, Date.now() / 1000 + 86400]
 166      const lower = [peer, 0]
 167      const range = IDBKeyRange.bound(lower, upper)
 168      const req = idx.openCursor(range, 'prev')
 169  
 170      req.onsuccess = (e) => {
 171        const cursor = e.target.result
 172        if (!cursor || results.length >= limit) { resolve(results); return }
 173        results.push(cursor.value)
 174        cursor.continue()
 175      }
 176      req.onerror = () => reject(req.error)
 177    })
 178  }
 179  
 180  export async function getConversationList() {
 181    const db = await getDB()
 182    return new Promise((resolve, reject) => {
 183      const tx = db.transaction('dms', 'readonly')
 184      const store = tx.objectStore('dms')
 185      const idx = store.index('peer')
 186      const conversations = new Map()
 187  
 188      const req = idx.openCursor(null, 'prev')
 189      req.onsuccess = (e) => {
 190        const cursor = e.target.result
 191        if (!cursor) {
 192          resolve([...conversations.values()].sort((a, b) => b.lastTs - a.lastTs))
 193          return
 194        }
 195        const dm = cursor.value
 196        if (!conversations.has(dm.peer)) {
 197          conversations.set(dm.peer, {
 198            peer: dm.peer,
 199            lastMessage: dm.content.slice(0, 80),
 200            lastTs: dm.created_at,
 201            from: dm.from,
 202          })
 203        } else {
 204          const existing = conversations.get(dm.peer)
 205          if (dm.created_at > existing.lastTs) {
 206            existing.lastMessage = dm.content.slice(0, 80)
 207            existing.lastTs = dm.created_at
 208            existing.from = dm.from
 209          }
 210        }
 211        cursor.continue()
 212      }
 213      req.onerror = () => reject(req.error)
 214    })
 215  }
 216