indexed-db.service.ts raw

   1  import { ExtendedKind } from '@/constants'
   2  import { tagNameEquals } from '@/lib/tag'
   3  import { TDMDeletedState, TRelayInfo } from '@/types'
   4  import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
   5  
   6  type TValue<T = any> = {
   7    key: string
   8    value: T | null
   9    addedAt: number
  10  }
  11  
  12  const StoreNames = {
  13    PROFILE_EVENTS: 'profileEvents',
  14    RELAY_LIST_EVENTS: 'relayListEvents',
  15    FOLLOW_LIST_EVENTS: 'followListEvents',
  16    MUTE_LIST_EVENTS: 'muteListEvents',
  17    BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
  18    BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
  19    USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
  20    EMOJI_SET_EVENTS: 'emojiSetEvents',
  21    PIN_LIST_EVENTS: 'pinListEvents',
  22    FAVORITE_RELAYS: 'favoriteRelays',
  23    RELAY_SETS: 'relaySets',
  24    FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
  25    RELAY_INFOS: 'relayInfos',
  26    DECRYPTED_CONTENTS: 'decryptedContents',
  27    PINNED_USERS_EVENTS: 'pinnedUsersEvents',
  28    DM_EVENTS: 'dmEvents',
  29    DM_CONVERSATIONS: 'dmConversations',
  30    DM_MESSAGES: 'dmMessages',
  31    UNWRAPPED_GIFT_WRAPS: 'unwrappedGiftWraps',
  32    DM_DELETED_STATE: 'dmDeletedState',
  33    CACHED_EVENTS: 'cachedEvents', // General event cache for NRC cache relays
  34    RELAY_STATS: 'relayStats', // Per-relay per-network failure stats
  35    MANAGED_RELAYS: 'managedRelays', // Outbox relay approval state
  36    MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
  37    RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
  38  }
  39  
  40  class IndexedDbService {
  41    static instance: IndexedDbService
  42    static getInstance(): IndexedDbService {
  43      if (!IndexedDbService.instance) {
  44        IndexedDbService.instance = new IndexedDbService()
  45        IndexedDbService.instance.init()
  46      }
  47      return IndexedDbService.instance
  48    }
  49  
  50    private db: IDBDatabase | null = null
  51    private initPromise: Promise<void> | null = null
  52  
  53    init(): Promise<void> {
  54      if (!this.initPromise) {
  55        this.initPromise = new Promise((resolve, reject) => {
  56          const request = window.indexedDB.open('smesh', 16)
  57  
  58          request.onerror = (event) => {
  59            reject(event)
  60          }
  61  
  62          request.onsuccess = () => {
  63            this.db = request.result
  64            resolve()
  65          }
  66  
  67          request.onupgradeneeded = () => {
  68            const db = request.result
  69            if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
  70              db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
  71            }
  72            if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
  73              db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
  74            }
  75            if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
  76              db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
  77            }
  78            if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
  79              db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
  80            }
  81            if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
  82              db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' })
  83            }
  84            if (!db.objectStoreNames.contains(StoreNames.DECRYPTED_CONTENTS)) {
  85              db.createObjectStore(StoreNames.DECRYPTED_CONTENTS, { keyPath: 'key' })
  86            }
  87            if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
  88              db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
  89            }
  90            if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) {
  91              db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' })
  92            }
  93            if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
  94              db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
  95            }
  96            if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
  97              db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
  98            }
  99            if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) {
 100              db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' })
 101            }
 102            if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
 103              db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
 104            }
 105            if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) {
 106              db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
 107            }
 108            if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
 109              db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
 110            }
 111            if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
 112              db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
 113            }
 114            if (!db.objectStoreNames.contains(StoreNames.DM_EVENTS)) {
 115              db.createObjectStore(StoreNames.DM_EVENTS, { keyPath: 'key' })
 116            }
 117            if (!db.objectStoreNames.contains(StoreNames.DM_CONVERSATIONS)) {
 118              db.createObjectStore(StoreNames.DM_CONVERSATIONS, { keyPath: 'key' })
 119            }
 120            if (!db.objectStoreNames.contains(StoreNames.DM_MESSAGES)) {
 121              db.createObjectStore(StoreNames.DM_MESSAGES, { keyPath: 'key' })
 122            }
 123            if (!db.objectStoreNames.contains(StoreNames.UNWRAPPED_GIFT_WRAPS)) {
 124              db.createObjectStore(StoreNames.UNWRAPPED_GIFT_WRAPS, { keyPath: 'key' })
 125            }
 126            if (!db.objectStoreNames.contains(StoreNames.DM_DELETED_STATE)) {
 127              db.createObjectStore(StoreNames.DM_DELETED_STATE, { keyPath: 'key' })
 128            }
 129            if (!db.objectStoreNames.contains(StoreNames.CACHED_EVENTS)) {
 130              const store = db.createObjectStore(StoreNames.CACHED_EVENTS, { keyPath: 'id' })
 131              store.createIndex('kind', 'kind', { unique: false })
 132              store.createIndex('pubkey', 'pubkey', { unique: false })
 133              store.createIndex('created_at', 'created_at', { unique: false })
 134            }
 135  
 136            if (!db.objectStoreNames.contains(StoreNames.RELAY_STATS)) {
 137              db.createObjectStore(StoreNames.RELAY_STATS, { keyPath: 'key' })
 138            }
 139            if (!db.objectStoreNames.contains(StoreNames.MANAGED_RELAYS)) {
 140              db.createObjectStore(StoreNames.MANAGED_RELAYS, { keyPath: 'key' })
 141            }
 142  
 143            if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
 144              db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
 145            }
 146            if (db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
 147              db.deleteObjectStore(StoreNames.MUTE_DECRYPTED_TAGS)
 148            }
 149            this.db = db
 150          }
 151        })
 152        setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
 153      }
 154      return this.initPromise
 155    }
 156  
 157    async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
 158      const storeName = this.getStoreNameByKind(kind)
 159      if (!storeName) {
 160        return Promise.reject('store name not found')
 161      }
 162      await this.initPromise
 163      return new Promise((resolve, reject) => {
 164        if (!this.db) {
 165          return reject('database not initialized')
 166        }
 167        const transaction = this.db.transaction(storeName, 'readwrite')
 168        const store = transaction.objectStore(storeName)
 169  
 170        const key = this.getReplaceableEventKey(pubkey, d)
 171        const getRequest = store.get(key)
 172        getRequest.onsuccess = () => {
 173          const oldValue = getRequest.result as TValue<Event> | undefined
 174          if (oldValue) {
 175            transaction.commit()
 176            return resolve(oldValue.value)
 177          }
 178          const putRequest = store.put(this.formatValue(key, null))
 179          putRequest.onsuccess = () => {
 180            transaction.commit()
 181            resolve(null)
 182          }
 183  
 184          putRequest.onerror = (event) => {
 185            transaction.commit()
 186            reject(event)
 187          }
 188        }
 189  
 190        getRequest.onerror = (event) => {
 191          transaction.commit()
 192          reject(event)
 193        }
 194      })
 195    }
 196  
 197    async putReplaceableEvent(event: Event): Promise<Event> {
 198      const storeName = this.getStoreNameByKind(event.kind)
 199      if (!storeName) {
 200        return Promise.reject('store name not found')
 201      }
 202      await this.initPromise
 203      return new Promise((resolve, reject) => {
 204        if (!this.db) {
 205          return reject('database not initialized')
 206        }
 207        const transaction = this.db.transaction(storeName, 'readwrite')
 208        const store = transaction.objectStore(storeName)
 209  
 210        const key = this.getReplaceableEventKeyFromEvent(event)
 211        const getRequest = store.get(key)
 212        getRequest.onsuccess = () => {
 213          const oldValue = getRequest.result as TValue<Event> | undefined
 214          if (oldValue?.value && oldValue.value.created_at >= event.created_at) {
 215            transaction.commit()
 216            return resolve(oldValue.value)
 217          }
 218          const putRequest = store.put(this.formatValue(key, event))
 219          putRequest.onsuccess = () => {
 220            transaction.commit()
 221            resolve(event)
 222          }
 223  
 224          putRequest.onerror = (event) => {
 225            transaction.commit()
 226            reject(event)
 227          }
 228        }
 229  
 230        getRequest.onerror = (event) => {
 231          transaction.commit()
 232          reject(event)
 233        }
 234      })
 235    }
 236  
 237    async getReplaceableEventByCoordinate(coordinate: string): Promise<Event | undefined | null> {
 238      const [kind, pubkey, ...rest] = coordinate.split(':')
 239      const d = rest.length > 0 ? rest.join(':') : undefined
 240      return this.getReplaceableEvent(pubkey, parseInt(kind), d)
 241    }
 242  
 243    async getReplaceableEvent(
 244      pubkey: string,
 245      kind: number,
 246      d?: string
 247    ): Promise<Event | undefined | null> {
 248      const storeName = this.getStoreNameByKind(kind)
 249      if (!storeName) {
 250        return undefined
 251      }
 252      await this.initPromise
 253      return new Promise((resolve, reject) => {
 254        if (!this.db) {
 255          return reject('database not initialized')
 256        }
 257        const transaction = this.db.transaction(storeName, 'readonly')
 258        const store = transaction.objectStore(storeName)
 259        const key = this.getReplaceableEventKey(pubkey, d)
 260        const request = store.get(key)
 261  
 262        request.onsuccess = () => {
 263          transaction.commit()
 264          resolve((request.result as TValue<Event>)?.value)
 265        }
 266  
 267        request.onerror = (event) => {
 268          transaction.commit()
 269          reject(event)
 270        }
 271      })
 272    }
 273  
 274    async getManyReplaceableEvents(
 275      pubkeys: readonly string[],
 276      kind: number
 277    ): Promise<(Event | undefined | null)[]> {
 278      const storeName = this.getStoreNameByKind(kind)
 279      if (!storeName) {
 280        return Promise.reject('store name not found')
 281      }
 282      await this.initPromise
 283      return new Promise((resolve, reject) => {
 284        if (!this.db) {
 285          return reject('database not initialized')
 286        }
 287        const transaction = this.db.transaction(storeName, 'readonly')
 288        const store = transaction.objectStore(storeName)
 289        const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
 290        let count = 0
 291        pubkeys.forEach((pubkey, i) => {
 292          const request = store.get(this.getReplaceableEventKey(pubkey))
 293  
 294          request.onsuccess = () => {
 295            const event = (request.result as TValue<Event | null>)?.value
 296            if (event || event === null) {
 297              events[i] = event
 298            }
 299  
 300            if (++count === pubkeys.length) {
 301              transaction.commit()
 302              resolve(events)
 303            }
 304          }
 305  
 306          request.onerror = () => {
 307            if (++count === pubkeys.length) {
 308              transaction.commit()
 309              resolve(events)
 310            }
 311          }
 312        })
 313      })
 314    }
 315  
 316    async getDecryptedContent(key: string): Promise<string | null> {
 317      await this.initPromise
 318      return new Promise((resolve, reject) => {
 319        if (!this.db) {
 320          return reject('database not initialized')
 321        }
 322        const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readonly')
 323        const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
 324        const request = store.get(key)
 325  
 326        request.onsuccess = () => {
 327          transaction.commit()
 328          resolve((request.result as TValue<string>)?.value)
 329        }
 330  
 331        request.onerror = (event) => {
 332          transaction.commit()
 333          reject(event)
 334        }
 335      })
 336    }
 337  
 338    async putDecryptedContent(key: string, content: string): Promise<void> {
 339      await this.initPromise
 340      return new Promise((resolve, reject) => {
 341        if (!this.db) {
 342          return reject('database not initialized')
 343        }
 344        const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readwrite')
 345        const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
 346  
 347        const putRequest = store.put(this.formatValue(key, content))
 348        putRequest.onsuccess = () => {
 349          transaction.commit()
 350          resolve()
 351        }
 352  
 353        putRequest.onerror = (event) => {
 354          transaction.commit()
 355          reject(event)
 356        }
 357      })
 358    }
 359  
 360    async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> {
 361      await this.initPromise
 362      if (!this.db) {
 363        return
 364      }
 365  
 366      return new Promise<void>((resolve, reject) => {
 367        const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readwrite')
 368        const store = transaction.objectStore(StoreNames.PROFILE_EVENTS)
 369        const request = store.openCursor()
 370        request.onsuccess = (event) => {
 371          const cursor = (event.target as IDBRequest).result
 372          if (cursor) {
 373            const value = (cursor.value as TValue<Event>).value
 374            if (value) {
 375              callback(value)
 376            }
 377            cursor.continue()
 378          } else {
 379            transaction.commit()
 380            resolve()
 381          }
 382        }
 383  
 384        request.onerror = (event) => {
 385          transaction.commit()
 386          reject(event)
 387        }
 388      })
 389    }
 390  
 391    async putFollowingFavoriteRelays(pubkey: string, relays: [string, string[]][]): Promise<void> {
 392      await this.initPromise
 393      return new Promise((resolve, reject) => {
 394        if (!this.db) {
 395          return reject('database not initialized')
 396        }
 397        const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readwrite')
 398        const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
 399  
 400        const putRequest = store.put(this.formatValue(pubkey, relays))
 401        putRequest.onsuccess = () => {
 402          transaction.commit()
 403          resolve()
 404        }
 405  
 406        putRequest.onerror = (event) => {
 407          transaction.commit()
 408          reject(event)
 409        }
 410      })
 411    }
 412  
 413    async getFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][] | null> {
 414      await this.initPromise
 415      return new Promise((resolve, reject) => {
 416        if (!this.db) {
 417          return reject('database not initialized')
 418        }
 419        const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readonly')
 420        const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
 421        const request = store.get(pubkey)
 422  
 423        request.onsuccess = () => {
 424          transaction.commit()
 425          resolve((request.result as TValue<[string, string[]][]>)?.value)
 426        }
 427  
 428        request.onerror = (event) => {
 429          transaction.commit()
 430          reject(event)
 431        }
 432      })
 433    }
 434  
 435    async putRelayInfo(relayInfo: TRelayInfo): Promise<void> {
 436      await this.initPromise
 437      return new Promise((resolve, reject) => {
 438        if (!this.db) {
 439          return reject('database not initialized')
 440        }
 441        const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readwrite')
 442        const store = transaction.objectStore(StoreNames.RELAY_INFOS)
 443  
 444        const putRequest = store.put(this.formatValue(relayInfo.url, relayInfo))
 445        putRequest.onsuccess = () => {
 446          transaction.commit()
 447          resolve()
 448        }
 449  
 450        putRequest.onerror = (event) => {
 451          transaction.commit()
 452          reject(event)
 453        }
 454      })
 455    }
 456  
 457    async getRelayInfo(url: string): Promise<TRelayInfo | null> {
 458      await this.initPromise
 459      return new Promise((resolve, reject) => {
 460        if (!this.db) {
 461          return reject('database not initialized')
 462        }
 463        const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readonly')
 464        const store = transaction.objectStore(StoreNames.RELAY_INFOS)
 465        const request = store.get(url)
 466  
 467        request.onsuccess = () => {
 468          transaction.commit()
 469          resolve((request.result as TValue<TRelayInfo>)?.value)
 470        }
 471  
 472        request.onerror = (event) => {
 473          transaction.commit()
 474          reject(event)
 475        }
 476      })
 477    }
 478  
 479    // DM-related methods
 480    async putDMEvent(event: Event): Promise<void> {
 481      await this.initPromise
 482      return new Promise((resolve, reject) => {
 483        if (!this.db) {
 484          return reject('database not initialized')
 485        }
 486        const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readwrite')
 487        const store = transaction.objectStore(StoreNames.DM_EVENTS)
 488  
 489        const putRequest = store.put(this.formatValue(event.id, event))
 490        putRequest.onsuccess = () => {
 491          transaction.commit()
 492          resolve()
 493        }
 494  
 495        putRequest.onerror = (event) => {
 496          transaction.commit()
 497          reject(event)
 498        }
 499      })
 500    }
 501  
 502    async getDMEvent(eventId: string): Promise<Event | null> {
 503      await this.initPromise
 504      return new Promise((resolve, reject) => {
 505        if (!this.db) {
 506          return reject('database not initialized')
 507        }
 508        const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
 509        const store = transaction.objectStore(StoreNames.DM_EVENTS)
 510        const request = store.get(eventId)
 511  
 512        request.onsuccess = () => {
 513          transaction.commit()
 514          resolve((request.result as TValue<Event>)?.value ?? null)
 515        }
 516  
 517        request.onerror = (event) => {
 518          transaction.commit()
 519          reject(event)
 520        }
 521      })
 522    }
 523  
 524    async getAllDMEvents(userPubkey: string): Promise<Event[]> {
 525      await this.initPromise
 526      return new Promise((resolve, reject) => {
 527        if (!this.db) {
 528          return reject('database not initialized')
 529        }
 530        const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
 531        const store = transaction.objectStore(StoreNames.DM_EVENTS)
 532        const request = store.openCursor()
 533        const events: Event[] = []
 534  
 535        request.onsuccess = (event) => {
 536          const cursor = (event.target as IDBRequest).result
 537          if (cursor) {
 538            const dmEvent = (cursor.value as TValue<Event>).value
 539            if (dmEvent) {
 540              // Include events where user is sender or recipient
 541              const isUserEvent =
 542                dmEvent.pubkey === userPubkey ||
 543                dmEvent.tags.some((tag) => tag[0] === 'p' && tag[1] === userPubkey)
 544              if (isUserEvent) {
 545                events.push(dmEvent)
 546              }
 547            }
 548            cursor.continue()
 549          } else {
 550            transaction.commit()
 551            resolve(events)
 552          }
 553        }
 554  
 555        request.onerror = (event) => {
 556          transaction.commit()
 557          reject(event)
 558        }
 559      })
 560    }
 561  
 562    async putDMConversation(
 563      userPubkey: string,
 564      partnerPubkey: string,
 565      lastMessageAt: number,
 566      lastMessagePreview: string,
 567      encryptionType: 'nip04' | 'nip17' | null
 568    ): Promise<void> {
 569      await this.initPromise
 570      return new Promise((resolve, reject) => {
 571        if (!this.db) {
 572          return reject('database not initialized')
 573        }
 574        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
 575        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 576        const key = `${userPubkey}:${partnerPubkey}`
 577  
 578        const putRequest = store.put(
 579          this.formatValue(key, {
 580            partnerPubkey,
 581            lastMessageAt,
 582            lastMessagePreview,
 583            encryptionType
 584          })
 585        )
 586        putRequest.onsuccess = () => {
 587          transaction.commit()
 588          resolve()
 589        }
 590  
 591        putRequest.onerror = (event) => {
 592          transaction.commit()
 593          reject(event)
 594        }
 595      })
 596    }
 597  
 598    async getDMConversations(
 599      userPubkey: string
 600    ): Promise<
 601      Array<{
 602        partnerPubkey: string
 603        lastMessageAt: number
 604        lastMessagePreview: string
 605        encryptionType: 'nip04' | 'nip17' | null
 606      }>
 607    > {
 608      await this.initPromise
 609      return new Promise((resolve, reject) => {
 610        if (!this.db) {
 611          return reject('database not initialized')
 612        }
 613        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
 614        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 615        const request = store.openCursor()
 616        const conversations: Array<{
 617          partnerPubkey: string
 618          lastMessageAt: number
 619          lastMessagePreview: string
 620          encryptionType: 'nip04' | 'nip17' | null
 621        }> = []
 622  
 623        request.onsuccess = (event) => {
 624          const cursor = (event.target as IDBRequest).result
 625          if (cursor) {
 626            const key = cursor.key as string
 627            if (key.startsWith(`${userPubkey}:`)) {
 628              const value = (cursor.value as TValue).value
 629              if (value) {
 630                conversations.push(value)
 631              }
 632            }
 633            cursor.continue()
 634          } else {
 635            transaction.commit()
 636            // Sort by lastMessageAt descending
 637            conversations.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
 638            resolve(conversations)
 639          }
 640        }
 641  
 642        request.onerror = (event) => {
 643          transaction.commit()
 644          reject(event)
 645        }
 646      })
 647    }
 648  
 649    async putConversationRelaySettings(
 650      userPubkey: string,
 651      partnerPubkey: string,
 652      selectedRelays: string[]
 653    ): Promise<void> {
 654      await this.initPromise
 655      return new Promise((resolve, reject) => {
 656        if (!this.db) {
 657          return reject('database not initialized')
 658        }
 659        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
 660        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 661        const key = `${userPubkey}:${partnerPubkey}:relays`
 662  
 663        const putRequest = store.put(this.formatValue(key, { selectedRelays }))
 664        putRequest.onsuccess = () => {
 665          transaction.commit()
 666          resolve()
 667        }
 668  
 669        putRequest.onerror = (event) => {
 670          transaction.commit()
 671          reject(event)
 672        }
 673      })
 674    }
 675  
 676    async getConversationRelaySettings(
 677      userPubkey: string,
 678      partnerPubkey: string
 679    ): Promise<string[] | null> {
 680      await this.initPromise
 681      return new Promise((resolve, reject) => {
 682        if (!this.db) {
 683          return reject('database not initialized')
 684        }
 685        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
 686        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 687        const key = `${userPubkey}:${partnerPubkey}:relays`
 688        const request = store.get(key)
 689  
 690        request.onsuccess = () => {
 691          transaction.commit()
 692          const result = (request.result as TValue)?.value
 693          resolve(result?.selectedRelays ?? null)
 694        }
 695  
 696        request.onerror = (event) => {
 697          transaction.commit()
 698          reject(event)
 699        }
 700      })
 701    }
 702  
 703    async putConversationEncryptionPreference(
 704      userPubkey: string,
 705      partnerPubkey: string,
 706      preference: 'nip04' | 'nip17' | 'auto'
 707    ): Promise<void> {
 708      await this.initPromise
 709      return new Promise((resolve, reject) => {
 710        if (!this.db) {
 711          return reject('database not initialized')
 712        }
 713        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
 714        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 715        const key = `${userPubkey}:${partnerPubkey}:encryption`
 716  
 717        const putRequest = store.put(this.formatValue(key, { preference }))
 718        putRequest.onsuccess = () => {
 719          transaction.commit()
 720          resolve()
 721        }
 722  
 723        putRequest.onerror = (event) => {
 724          transaction.commit()
 725          reject(event)
 726        }
 727      })
 728    }
 729  
 730    async getConversationEncryptionPreference(
 731      userPubkey: string,
 732      partnerPubkey: string
 733    ): Promise<'nip04' | 'nip17' | 'auto' | null> {
 734      await this.initPromise
 735      return new Promise((resolve, reject) => {
 736        if (!this.db) {
 737          return reject('database not initialized')
 738        }
 739        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
 740        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 741        const key = `${userPubkey}:${partnerPubkey}:encryption`
 742        const request = store.get(key)
 743  
 744        request.onsuccess = () => {
 745          transaction.commit()
 746          const result = (request.result as TValue)?.value
 747          resolve(result?.preference ?? null)
 748        }
 749  
 750        request.onerror = (event) => {
 751          transaction.commit()
 752          reject(event)
 753        }
 754      })
 755    }
 756  
 757    async putConversationMessages(
 758      userPubkey: string,
 759      partnerPubkey: string,
 760      messages: Array<{
 761        id: string
 762        senderPubkey: string
 763        recipientPubkey: string
 764        content: string
 765        createdAt: number
 766        encryptionType: 'nip04' | 'nip17'
 767        seenOnRelays?: string[]
 768      }>
 769    ): Promise<void> {
 770      await this.initPromise
 771      return new Promise((resolve, reject) => {
 772        if (!this.db) {
 773          return reject('database not initialized')
 774        }
 775        const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readwrite')
 776        const store = transaction.objectStore(StoreNames.DM_MESSAGES)
 777        const key = `${userPubkey}:${partnerPubkey}`
 778  
 779        const putRequest = store.put(this.formatValue(key, messages))
 780        putRequest.onsuccess = () => {
 781          transaction.commit()
 782          resolve()
 783        }
 784  
 785        putRequest.onerror = (event) => {
 786          transaction.commit()
 787          reject(event)
 788        }
 789      })
 790    }
 791  
 792    async getConversationMessages(
 793      userPubkey: string,
 794      partnerPubkey: string
 795    ): Promise<
 796      Array<{
 797        id: string
 798        senderPubkey: string
 799        recipientPubkey: string
 800        content: string
 801        createdAt: number
 802        encryptionType: 'nip04' | 'nip17'
 803        seenOnRelays?: string[]
 804      }> | null
 805    > {
 806      await this.initPromise
 807      return new Promise((resolve, reject) => {
 808        if (!this.db) {
 809          return reject('database not initialized')
 810        }
 811        const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readonly')
 812        const store = transaction.objectStore(StoreNames.DM_MESSAGES)
 813        const key = `${userPubkey}:${partnerPubkey}`
 814        const request = store.get(key)
 815  
 816        request.onsuccess = () => {
 817          transaction.commit()
 818          resolve((request.result as TValue)?.value ?? null)
 819        }
 820  
 821        request.onerror = (event) => {
 822          transaction.commit()
 823          reject(event)
 824        }
 825      })
 826    }
 827  
 828    /**
 829     * Cache an unwrapped NIP-17 gift wrap inner event
 830     * This avoids repeated decryption just to identify the sender
 831     */
 832    async putUnwrappedGiftWrap(
 833      giftWrapId: string,
 834      innerEvent: {
 835        pubkey: string // actual sender
 836        recipientPubkey: string
 837        content: string
 838        createdAt: number
 839      }
 840    ): Promise<void> {
 841      await this.initPromise
 842      return new Promise((resolve, reject) => {
 843        if (!this.db) {
 844          return reject('database not initialized')
 845        }
 846        const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readwrite')
 847        const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
 848  
 849        const putRequest = store.put(this.formatValue(giftWrapId, innerEvent))
 850        putRequest.onsuccess = () => {
 851          transaction.commit()
 852          resolve()
 853        }
 854  
 855        putRequest.onerror = (event) => {
 856          transaction.commit()
 857          reject(event)
 858        }
 859      })
 860    }
 861  
 862    /**
 863     * Get a cached unwrapped NIP-17 gift wrap inner event
 864     */
 865    async getUnwrappedGiftWrap(
 866      giftWrapId: string
 867    ): Promise<{
 868      pubkey: string
 869      recipientPubkey: string
 870      content: string
 871      createdAt: number
 872    } | null> {
 873      await this.initPromise
 874      return new Promise((resolve, reject) => {
 875        if (!this.db) {
 876          return reject('database not initialized')
 877        }
 878        const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readonly')
 879        const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
 880        const request = store.get(giftWrapId)
 881  
 882        request.onsuccess = () => {
 883          transaction.commit()
 884          resolve((request.result as TValue)?.value ?? null)
 885        }
 886  
 887        request.onerror = (event) => {
 888          transaction.commit()
 889          reject(event)
 890        }
 891      })
 892    }
 893  
 894    /**
 895     * Clear all DM-related caches (for full refresh)
 896     */
 897    async clearAllDMCaches(): Promise<void> {
 898      await this.initPromise
 899      if (!this.db) {
 900        return
 901      }
 902  
 903      const storeNames = [
 904        StoreNames.DM_EVENTS,
 905        StoreNames.DM_CONVERSATIONS,
 906        StoreNames.DM_MESSAGES,
 907        StoreNames.UNWRAPPED_GIFT_WRAPS,
 908        StoreNames.DECRYPTED_CONTENTS
 909      ]
 910  
 911      const transaction = this.db.transaction(storeNames, 'readwrite')
 912  
 913      await Promise.all(
 914        storeNames.map(
 915          (storeName) =>
 916            new Promise<void>((resolve, reject) => {
 917              const store = transaction.objectStore(storeName)
 918              const request = store.clear()
 919              request.onsuccess = () => resolve()
 920              request.onerror = (event) => reject(event)
 921            })
 922        )
 923      )
 924  
 925      transaction.commit()
 926    }
 927  
 928    /**
 929     * Get the deleted messages state for a user (local cache only)
 930     */
 931    async getDeletedMessagesState(pubkey: string): Promise<TDMDeletedState | null> {
 932      await this.initPromise
 933      return new Promise((resolve, reject) => {
 934        if (!this.db) {
 935          return reject('database not initialized')
 936        }
 937        const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readonly')
 938        const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
 939        const request = store.get(pubkey)
 940  
 941        request.onsuccess = () => {
 942          transaction.commit()
 943          resolve((request.result as TValue<TDMDeletedState>)?.value ?? null)
 944        }
 945  
 946        request.onerror = (event) => {
 947          transaction.commit()
 948          reject(event)
 949        }
 950      })
 951    }
 952  
 953    /**
 954     * Store the deleted messages state for a user (local cache)
 955     */
 956    async putDeletedMessagesState(pubkey: string, state: TDMDeletedState): Promise<void> {
 957      await this.initPromise
 958      return new Promise((resolve, reject) => {
 959        if (!this.db) {
 960          return reject('database not initialized')
 961        }
 962        const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readwrite')
 963        const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
 964  
 965        const putRequest = store.put(this.formatValue(pubkey, state))
 966        putRequest.onsuccess = () => {
 967          transaction.commit()
 968          resolve()
 969        }
 970  
 971        putRequest.onerror = (event) => {
 972          transaction.commit()
 973          reject(event)
 974        }
 975      })
 976    }
 977  
 978    private getReplaceableEventKeyFromEvent(event: Event): string {
 979      if (
 980        [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
 981        (event.kind >= 10000 && event.kind < 20000)
 982      ) {
 983        return this.getReplaceableEventKey(event.pubkey)
 984      }
 985  
 986      const [, d] = event.tags.find(tagNameEquals('d')) ?? []
 987      return this.getReplaceableEventKey(event.pubkey, d)
 988    }
 989  
 990    private getReplaceableEventKey(pubkey: string, d?: string): string {
 991      return d === undefined ? pubkey : `${pubkey}:${d}`
 992    }
 993  
 994    private getStoreNameByKind(kind: number): string | undefined {
 995      switch (kind) {
 996        case kinds.Metadata:
 997          return StoreNames.PROFILE_EVENTS
 998        case kinds.RelayList:
 999          return StoreNames.RELAY_LIST_EVENTS
1000        case kinds.Contacts:
1001          return StoreNames.FOLLOW_LIST_EVENTS
1002        case kinds.Mutelist:
1003          return StoreNames.MUTE_LIST_EVENTS
1004        case ExtendedKind.BLOSSOM_SERVER_LIST:
1005          return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
1006        case kinds.Relaysets:
1007          return StoreNames.RELAY_SETS
1008        case ExtendedKind.FAVORITE_RELAYS:
1009          return StoreNames.FAVORITE_RELAYS
1010        case kinds.BookmarkList:
1011          return StoreNames.BOOKMARK_LIST_EVENTS
1012        case kinds.UserEmojiList:
1013          return StoreNames.USER_EMOJI_LIST_EVENTS
1014        case kinds.Emojisets:
1015          return StoreNames.EMOJI_SET_EVENTS
1016        case kinds.Pinlist:
1017          return StoreNames.PIN_LIST_EVENTS
1018        case ExtendedKind.PINNED_USERS:
1019          return StoreNames.PINNED_USERS_EVENTS
1020        default:
1021          return undefined
1022      }
1023    }
1024  
1025    private formatValue<T>(key: string, value: T): TValue<T> {
1026      return {
1027        key,
1028        value,
1029        addedAt: Date.now()
1030      }
1031    }
1032  
1033    /**
1034     * Query all events across all stores for NRC sync.
1035     * Returns events matching the provided filters.
1036     *
1037     * Note: This method queries all event-containing stores and filters
1038     * client-side using matchFilters. Device-specific event filtering
1039     * should be done by the caller.
1040     */
1041    async queryEventsForNRC(filters: Filter[]): Promise<Event[]> {
1042      await this.initPromise
1043      if (!this.db) {
1044        return []
1045      }
1046  
1047      // List of stores that contain Event objects
1048      const eventStores = [
1049        StoreNames.PROFILE_EVENTS,
1050        StoreNames.RELAY_LIST_EVENTS,
1051        StoreNames.FOLLOW_LIST_EVENTS,
1052        StoreNames.MUTE_LIST_EVENTS,
1053        StoreNames.BOOKMARK_LIST_EVENTS,
1054        StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1055        StoreNames.USER_EMOJI_LIST_EVENTS,
1056        StoreNames.EMOJI_SET_EVENTS,
1057        StoreNames.PIN_LIST_EVENTS,
1058        StoreNames.PINNED_USERS_EVENTS,
1059        StoreNames.FAVORITE_RELAYS,
1060        StoreNames.RELAY_SETS,
1061        StoreNames.DM_EVENTS
1062      ]
1063  
1064      const allEvents: Event[] = []
1065  
1066      // Query each store
1067      const transaction = this.db.transaction(eventStores, 'readonly')
1068  
1069      await Promise.all(
1070        eventStores.map(
1071          (storeName) =>
1072            new Promise<void>((resolve) => {
1073              const store = transaction.objectStore(storeName)
1074              const request = store.openCursor()
1075  
1076              request.onsuccess = (event) => {
1077                const cursor = (event.target as IDBRequest).result
1078                if (cursor) {
1079                  const value = cursor.value as TValue<Event | null>
1080                  if (value.value) {
1081                    // Check if event matches any of the filters
1082                    if (matchFilters(filters, value.value)) {
1083                      allEvents.push(value.value)
1084                    }
1085                  }
1086                  cursor.continue()
1087                } else {
1088                  resolve()
1089                }
1090              }
1091  
1092              request.onerror = () => {
1093                resolve() // Continue even if one store fails
1094              }
1095            })
1096        )
1097      )
1098  
1099      // Sort by created_at descending (newest first)
1100      allEvents.sort((a, b) => b.created_at - a.created_at)
1101  
1102      // Apply limit from filters if specified
1103      const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1104      if (limit !== Infinity && limit > 0) {
1105        return allEvents.slice(0, limit)
1106      }
1107  
1108      return allEvents
1109    }
1110  
1111    /**
1112     * Store an event in the general cache.
1113     * Used by NRC cache relays to cache events fetched from regular relays.
1114     */
1115    async putCachedEvent(event: Event): Promise<void> {
1116      await this.initPromise
1117      if (!this.db) {
1118        return
1119      }
1120  
1121      return new Promise((resolve, reject) => {
1122        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1123        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1124  
1125        // Store the event directly (it already has an 'id' field)
1126        const putRequest = store.put(event)
1127        putRequest.onsuccess = () => {
1128          transaction.commit()
1129          resolve()
1130        }
1131  
1132        putRequest.onerror = (event) => {
1133          transaction.commit()
1134          reject(event)
1135        }
1136      })
1137    }
1138  
1139    /**
1140     * Store multiple events in the general cache.
1141     */
1142    async putCachedEvents(events: Event[]): Promise<void> {
1143      if (events.length === 0) return
1144  
1145      await this.initPromise
1146      if (!this.db) {
1147        return
1148      }
1149  
1150      return new Promise((resolve) => {
1151        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1152        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1153  
1154        let completed = 0
1155        for (const event of events) {
1156          const putRequest = store.put(event)
1157          putRequest.onsuccess = () => {
1158            completed++
1159            if (completed === events.length) {
1160              transaction.commit()
1161              resolve()
1162            }
1163          }
1164          putRequest.onerror = () => {
1165            completed++
1166            if (completed === events.length) {
1167              transaction.commit()
1168              resolve()
1169            }
1170          }
1171        }
1172      })
1173    }
1174  
1175    /**
1176     * Get a cached event by ID.
1177     */
1178    async getCachedEvent(id: string): Promise<Event | null> {
1179      await this.initPromise
1180      if (!this.db) {
1181        return null
1182      }
1183  
1184      return new Promise((resolve, reject) => {
1185        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1186        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1187        const request = store.get(id)
1188  
1189        request.onsuccess = () => {
1190          transaction.commit()
1191          resolve(request.result ?? null)
1192        }
1193  
1194        request.onerror = (event) => {
1195          transaction.commit()
1196          reject(event)
1197        }
1198      })
1199    }
1200  
1201    /**
1202     * Query cached events matching the provided filters.
1203     * Returns events sorted by created_at descending.
1204     */
1205    async queryCachedEvents(filters: Filter[]): Promise<Event[]> {
1206      await this.initPromise
1207      if (!this.db) {
1208        return []
1209      }
1210  
1211      return new Promise((resolve) => {
1212        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1213        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1214        const request = store.openCursor()
1215        const events: Event[] = []
1216  
1217        request.onsuccess = (event) => {
1218          const cursor = (event.target as IDBRequest).result
1219          if (cursor) {
1220            const cachedEvent = cursor.value as Event
1221            if (cachedEvent && matchFilters(filters, cachedEvent)) {
1222              events.push(cachedEvent)
1223            }
1224            cursor.continue()
1225          } else {
1226            transaction.commit()
1227            // Sort by created_at descending
1228            events.sort((a, b) => b.created_at - a.created_at)
1229  
1230            // Apply limit from filters if specified
1231            const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1232            if (limit !== Infinity && limit > 0) {
1233              resolve(events.slice(0, limit))
1234            } else {
1235              resolve(events)
1236            }
1237          }
1238        }
1239  
1240        request.onerror = () => {
1241          transaction.commit()
1242          resolve([])
1243        }
1244      })
1245    }
1246  
1247    /**
1248     * Clean up expired cached events.
1249     * Removes events older than the specified number of days.
1250     */
1251    async cleanupExpiredCache(maxAgeDays: number = 7): Promise<number> {
1252      await this.initPromise
1253      if (!this.db) {
1254        return 0
1255      }
1256  
1257      const expirationTimestamp = Math.floor(Date.now() / 1000) - maxAgeDays * 24 * 60 * 60
1258  
1259      return new Promise((resolve) => {
1260        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1261        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1262        const index = store.index('created_at')
1263        const range = IDBKeyRange.upperBound(expirationTimestamp)
1264        const request = index.openCursor(range)
1265        let deletedCount = 0
1266  
1267        request.onsuccess = (event) => {
1268          const cursor = (event.target as IDBRequest).result
1269          if (cursor) {
1270            cursor.delete()
1271            deletedCount++
1272            cursor.continue()
1273          } else {
1274            transaction.commit()
1275            resolve(deletedCount)
1276          }
1277        }
1278  
1279        request.onerror = () => {
1280          transaction.commit()
1281          resolve(deletedCount)
1282        }
1283      })
1284    }
1285  
1286    /**
1287     * Get the count of cached events.
1288     */
1289    async getCachedEventCount(): Promise<number> {
1290      await this.initPromise
1291      if (!this.db) {
1292        return 0
1293      }
1294  
1295      return new Promise((resolve) => {
1296        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1297        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1298        const request = store.count()
1299  
1300        request.onsuccess = () => {
1301          transaction.commit()
1302          resolve(request.result)
1303        }
1304  
1305        request.onerror = () => {
1306          transaction.commit()
1307          resolve(0)
1308        }
1309      })
1310    }
1311  
1312    private async cleanUp() {
1313      await this.initPromise
1314      if (!this.db) {
1315        return
1316      }
1317  
1318      const stores = [
1319        {
1320          name: StoreNames.PROFILE_EVENTS,
1321          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1322        },
1323        {
1324          name: StoreNames.RELAY_LIST_EVENTS,
1325          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1326        },
1327        {
1328          name: StoreNames.FOLLOW_LIST_EVENTS,
1329          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1330        },
1331        {
1332          name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1333          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1334        },
1335        {
1336          name: StoreNames.RELAY_INFOS,
1337          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1338        },
1339        {
1340          name: StoreNames.PIN_LIST_EVENTS,
1341          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 days
1342        }
1343      ]
1344      // Also clean up cached events (separate cleanup due to different key structure)
1345      this.cleanupExpiredCache(7) // 7 days for cached events
1346      const transaction = this.db!.transaction(
1347        stores.map((store) => store.name),
1348        'readwrite'
1349      )
1350      await Promise.allSettled(
1351        stores.map(({ name, expirationTimestamp }) => {
1352          if (expirationTimestamp < 0) {
1353            return Promise.resolve()
1354          }
1355          return new Promise<void>((resolve, reject) => {
1356            const store = transaction.objectStore(name)
1357            const request = store.openCursor()
1358            request.onsuccess = (event) => {
1359              const cursor = (event.target as IDBRequest).result
1360              if (cursor) {
1361                const value: TValue = cursor.value
1362                if (value.addedAt < expirationTimestamp) {
1363                  cursor.delete()
1364                }
1365                cursor.continue()
1366              } else {
1367                resolve()
1368              }
1369            }
1370  
1371            request.onerror = (event) => {
1372              reject(event)
1373            }
1374          })
1375        })
1376      )
1377    }
1378  
1379    // ── Relay Stats CRUD ──
1380  
1381    async putRelayStats(key: string, value: unknown): Promise<void> {
1382      await this.initPromise
1383      if (!this.db) return
1384      const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1385      const store = transaction.objectStore(StoreNames.RELAY_STATS)
1386      store.put({ key, value, addedAt: Date.now() })
1387    }
1388  
1389    async getAllRelayStats(): Promise<Array<{ key: string; value: unknown }>> {
1390      await this.initPromise
1391      if (!this.db) return []
1392      return new Promise((resolve, reject) => {
1393        const transaction = this.db!.transaction(StoreNames.RELAY_STATS, 'readonly')
1394        const store = transaction.objectStore(StoreNames.RELAY_STATS)
1395        const request = store.getAll()
1396        request.onsuccess = () => resolve(request.result ?? [])
1397        request.onerror = () => reject(request.error)
1398      })
1399    }
1400  
1401    async deleteRelayStats(key: string): Promise<void> {
1402      await this.initPromise
1403      if (!this.db) return
1404      const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1405      const store = transaction.objectStore(StoreNames.RELAY_STATS)
1406      store.delete(key)
1407    }
1408  
1409    // ── Managed Relays CRUD ──
1410  
1411    async putManagedRelay(key: string, value: unknown): Promise<void> {
1412      await this.initPromise
1413      if (!this.db) return
1414      const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1415      const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1416      store.put({ key, value, addedAt: Date.now() })
1417    }
1418  
1419    async getAllManagedRelays(): Promise<Array<{ key: string; value: unknown }>> {
1420      await this.initPromise
1421      if (!this.db) return []
1422      return new Promise((resolve, reject) => {
1423        const transaction = this.db!.transaction(StoreNames.MANAGED_RELAYS, 'readonly')
1424        const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1425        const request = store.getAll()
1426        request.onsuccess = () => resolve(request.result ?? [])
1427        request.onerror = () => reject(request.error)
1428      })
1429    }
1430  
1431    async deleteManagedRelay(key: string): Promise<void> {
1432      await this.initPromise
1433      if (!this.db) return
1434      const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1435      const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1436      store.delete(key)
1437    }
1438  }
1439  
1440  const instance = IndexedDbService.getInstance()
1441  export default instance
1442