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 putConversationExpirationPreference(
 758      userPubkey: string,
 759      partnerPubkey: string,
 760      seconds: number
 761    ): Promise<void> {
 762      await this.initPromise
 763      return new Promise((resolve, reject) => {
 764        if (!this.db) {
 765          return reject('database not initialized')
 766        }
 767        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
 768        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 769        const key = `${userPubkey}:${partnerPubkey}:expiration`
 770  
 771        const putRequest = store.put(this.formatValue(key, { seconds }))
 772        putRequest.onsuccess = () => {
 773          transaction.commit()
 774          resolve()
 775        }
 776  
 777        putRequest.onerror = (event) => {
 778          transaction.commit()
 779          reject(event)
 780        }
 781      })
 782    }
 783  
 784    async getConversationExpirationPreference(
 785      userPubkey: string,
 786      partnerPubkey: string
 787    ): Promise<number> {
 788      await this.initPromise
 789      return new Promise((resolve, reject) => {
 790        if (!this.db) {
 791          return reject('database not initialized')
 792        }
 793        const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
 794        const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
 795        const key = `${userPubkey}:${partnerPubkey}:expiration`
 796        const request = store.get(key)
 797  
 798        request.onsuccess = () => {
 799          transaction.commit()
 800          const result = (request.result as TValue)?.value
 801          resolve(result?.seconds ?? 0)
 802        }
 803  
 804        request.onerror = (event) => {
 805          transaction.commit()
 806          reject(event)
 807        }
 808      })
 809    }
 810  
 811    async putConversationMessages(
 812      userPubkey: string,
 813      partnerPubkey: string,
 814      messages: Array<{
 815        id: string
 816        senderPubkey: string
 817        recipientPubkey: string
 818        content: string
 819        createdAt: number
 820        encryptionType: 'nip04' | 'nip17'
 821        seenOnRelays?: string[]
 822      }>
 823    ): Promise<void> {
 824      await this.initPromise
 825      return new Promise((resolve, reject) => {
 826        if (!this.db) {
 827          return reject('database not initialized')
 828        }
 829        const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readwrite')
 830        const store = transaction.objectStore(StoreNames.DM_MESSAGES)
 831        const key = `${userPubkey}:${partnerPubkey}`
 832  
 833        const putRequest = store.put(this.formatValue(key, messages))
 834        putRequest.onsuccess = () => {
 835          transaction.commit()
 836          resolve()
 837        }
 838  
 839        putRequest.onerror = (event) => {
 840          transaction.commit()
 841          reject(event)
 842        }
 843      })
 844    }
 845  
 846    async getConversationMessages(
 847      userPubkey: string,
 848      partnerPubkey: string
 849    ): Promise<
 850      Array<{
 851        id: string
 852        senderPubkey: string
 853        recipientPubkey: string
 854        content: string
 855        createdAt: number
 856        encryptionType: 'nip04' | 'nip17'
 857        seenOnRelays?: string[]
 858      }> | null
 859    > {
 860      await this.initPromise
 861      return new Promise((resolve, reject) => {
 862        if (!this.db) {
 863          return reject('database not initialized')
 864        }
 865        const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readonly')
 866        const store = transaction.objectStore(StoreNames.DM_MESSAGES)
 867        const key = `${userPubkey}:${partnerPubkey}`
 868        const request = store.get(key)
 869  
 870        request.onsuccess = () => {
 871          transaction.commit()
 872          resolve((request.result as TValue)?.value ?? null)
 873        }
 874  
 875        request.onerror = (event) => {
 876          transaction.commit()
 877          reject(event)
 878        }
 879      })
 880    }
 881  
 882    /**
 883     * Cache an unwrapped NIP-17 gift wrap inner event
 884     * This avoids repeated decryption just to identify the sender
 885     */
 886    async putUnwrappedGiftWrap(
 887      giftWrapId: string,
 888      innerEvent: {
 889        pubkey: string // actual sender
 890        recipientPubkey: string
 891        content: string
 892        createdAt: number
 893        innerEventId?: string
 894      }
 895    ): Promise<void> {
 896      await this.initPromise
 897      return new Promise((resolve, reject) => {
 898        if (!this.db) {
 899          return reject('database not initialized')
 900        }
 901        const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readwrite')
 902        const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
 903  
 904        const putRequest = store.put(this.formatValue(giftWrapId, innerEvent))
 905        putRequest.onsuccess = () => {
 906          transaction.commit()
 907          resolve()
 908        }
 909  
 910        putRequest.onerror = (event) => {
 911          transaction.commit()
 912          reject(event)
 913        }
 914      })
 915    }
 916  
 917    /**
 918     * Get a cached unwrapped NIP-17 gift wrap inner event
 919     */
 920    async getUnwrappedGiftWrap(
 921      giftWrapId: string
 922    ): Promise<{
 923      pubkey: string
 924      recipientPubkey: string
 925      content: string
 926      createdAt: number
 927      innerEventId?: string
 928    } | null> {
 929      await this.initPromise
 930      return new Promise((resolve, reject) => {
 931        if (!this.db) {
 932          return reject('database not initialized')
 933        }
 934        const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readonly')
 935        const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
 936        const request = store.get(giftWrapId)
 937  
 938        request.onsuccess = () => {
 939          transaction.commit()
 940          resolve((request.result as TValue)?.value ?? null)
 941        }
 942  
 943        request.onerror = (event) => {
 944          transaction.commit()
 945          reject(event)
 946        }
 947      })
 948    }
 949  
 950    /**
 951     * Clear all DM-related caches (for full refresh)
 952     */
 953    async clearAllDMCaches(): Promise<void> {
 954      await this.initPromise
 955      if (!this.db) {
 956        return
 957      }
 958  
 959      const storeNames = [
 960        StoreNames.DM_EVENTS,
 961        StoreNames.DM_CONVERSATIONS,
 962        StoreNames.DM_MESSAGES,
 963        StoreNames.UNWRAPPED_GIFT_WRAPS,
 964        StoreNames.DECRYPTED_CONTENTS
 965      ]
 966  
 967      const transaction = this.db.transaction(storeNames, 'readwrite')
 968  
 969      await Promise.all(
 970        storeNames.map(
 971          (storeName) =>
 972            new Promise<void>((resolve, reject) => {
 973              const store = transaction.objectStore(storeName)
 974              const request = store.clear()
 975              request.onsuccess = () => resolve()
 976              request.onerror = (event) => reject(event)
 977            })
 978        )
 979      )
 980  
 981      transaction.commit()
 982    }
 983  
 984    /**
 985     * Get the deleted messages state for a user (local cache only)
 986     */
 987    async getDeletedMessagesState(pubkey: string): Promise<TDMDeletedState | null> {
 988      await this.initPromise
 989      return new Promise((resolve, reject) => {
 990        if (!this.db) {
 991          return reject('database not initialized')
 992        }
 993        const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readonly')
 994        const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
 995        const request = store.get(pubkey)
 996  
 997        request.onsuccess = () => {
 998          transaction.commit()
 999          resolve((request.result as TValue<TDMDeletedState>)?.value ?? null)
1000        }
1001  
1002        request.onerror = (event) => {
1003          transaction.commit()
1004          reject(event)
1005        }
1006      })
1007    }
1008  
1009    /**
1010     * Store the deleted messages state for a user (local cache)
1011     */
1012    async putDeletedMessagesState(pubkey: string, state: TDMDeletedState): Promise<void> {
1013      await this.initPromise
1014      return new Promise((resolve, reject) => {
1015        if (!this.db) {
1016          return reject('database not initialized')
1017        }
1018        const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readwrite')
1019        const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
1020  
1021        const putRequest = store.put(this.formatValue(pubkey, state))
1022        putRequest.onsuccess = () => {
1023          transaction.commit()
1024          resolve()
1025        }
1026  
1027        putRequest.onerror = (event) => {
1028          transaction.commit()
1029          reject(event)
1030        }
1031      })
1032    }
1033  
1034    private getReplaceableEventKeyFromEvent(event: Event): string {
1035      if (
1036        [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
1037        (event.kind >= 10000 && event.kind < 20000)
1038      ) {
1039        return this.getReplaceableEventKey(event.pubkey)
1040      }
1041  
1042      const [, d] = event.tags.find(tagNameEquals('d')) ?? []
1043      return this.getReplaceableEventKey(event.pubkey, d)
1044    }
1045  
1046    private getReplaceableEventKey(pubkey: string, d?: string): string {
1047      return d === undefined ? pubkey : `${pubkey}:${d}`
1048    }
1049  
1050    private getStoreNameByKind(kind: number): string | undefined {
1051      switch (kind) {
1052        case kinds.Metadata:
1053          return StoreNames.PROFILE_EVENTS
1054        case kinds.RelayList:
1055          return StoreNames.RELAY_LIST_EVENTS
1056        case kinds.Contacts:
1057          return StoreNames.FOLLOW_LIST_EVENTS
1058        case kinds.Mutelist:
1059          return StoreNames.MUTE_LIST_EVENTS
1060        case ExtendedKind.BLOSSOM_SERVER_LIST:
1061          return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
1062        case kinds.Relaysets:
1063          return StoreNames.RELAY_SETS
1064        case ExtendedKind.FAVORITE_RELAYS:
1065          return StoreNames.FAVORITE_RELAYS
1066        case kinds.BookmarkList:
1067          return StoreNames.BOOKMARK_LIST_EVENTS
1068        case kinds.UserEmojiList:
1069          return StoreNames.USER_EMOJI_LIST_EVENTS
1070        case kinds.Emojisets:
1071          return StoreNames.EMOJI_SET_EVENTS
1072        case kinds.Pinlist:
1073          return StoreNames.PIN_LIST_EVENTS
1074        case ExtendedKind.PINNED_USERS:
1075          return StoreNames.PINNED_USERS_EVENTS
1076        default:
1077          return undefined
1078      }
1079    }
1080  
1081    private formatValue<T>(key: string, value: T): TValue<T> {
1082      return {
1083        key,
1084        value,
1085        addedAt: Date.now()
1086      }
1087    }
1088  
1089    /**
1090     * Query all events across all stores for NRC sync.
1091     * Returns events matching the provided filters.
1092     *
1093     * Note: This method queries all event-containing stores and filters
1094     * client-side using matchFilters. Device-specific event filtering
1095     * should be done by the caller.
1096     */
1097    async queryEventsForNRC(filters: Filter[]): Promise<Event[]> {
1098      await this.initPromise
1099      if (!this.db) {
1100        return []
1101      }
1102  
1103      // List of stores that contain Event objects
1104      const eventStores = [
1105        StoreNames.PROFILE_EVENTS,
1106        StoreNames.RELAY_LIST_EVENTS,
1107        StoreNames.FOLLOW_LIST_EVENTS,
1108        StoreNames.MUTE_LIST_EVENTS,
1109        StoreNames.BOOKMARK_LIST_EVENTS,
1110        StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1111        StoreNames.USER_EMOJI_LIST_EVENTS,
1112        StoreNames.EMOJI_SET_EVENTS,
1113        StoreNames.PIN_LIST_EVENTS,
1114        StoreNames.PINNED_USERS_EVENTS,
1115        StoreNames.FAVORITE_RELAYS,
1116        StoreNames.RELAY_SETS,
1117        StoreNames.DM_EVENTS
1118      ]
1119  
1120      const allEvents: Event[] = []
1121  
1122      // Query each store
1123      const transaction = this.db.transaction(eventStores, 'readonly')
1124  
1125      await Promise.all(
1126        eventStores.map(
1127          (storeName) =>
1128            new Promise<void>((resolve) => {
1129              const store = transaction.objectStore(storeName)
1130              const request = store.openCursor()
1131  
1132              request.onsuccess = (event) => {
1133                const cursor = (event.target as IDBRequest).result
1134                if (cursor) {
1135                  const value = cursor.value as TValue<Event | null>
1136                  if (value.value) {
1137                    // Check if event matches any of the filters
1138                    if (matchFilters(filters, value.value)) {
1139                      allEvents.push(value.value)
1140                    }
1141                  }
1142                  cursor.continue()
1143                } else {
1144                  resolve()
1145                }
1146              }
1147  
1148              request.onerror = () => {
1149                resolve() // Continue even if one store fails
1150              }
1151            })
1152        )
1153      )
1154  
1155      // Sort by created_at descending (newest first)
1156      allEvents.sort((a, b) => b.created_at - a.created_at)
1157  
1158      // Apply limit from filters if specified
1159      const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1160      if (limit !== Infinity && limit > 0) {
1161        return allEvents.slice(0, limit)
1162      }
1163  
1164      return allEvents
1165    }
1166  
1167    /**
1168     * Store an event in the general cache.
1169     * Used by NRC cache relays to cache events fetched from regular relays.
1170     */
1171    async putCachedEvent(event: Event): Promise<void> {
1172      await this.initPromise
1173      if (!this.db) {
1174        return
1175      }
1176  
1177      return new Promise((resolve, reject) => {
1178        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1179        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1180  
1181        // Store the event directly (it already has an 'id' field)
1182        const putRequest = store.put(event)
1183        putRequest.onsuccess = () => {
1184          transaction.commit()
1185          resolve()
1186        }
1187  
1188        putRequest.onerror = (event) => {
1189          transaction.commit()
1190          reject(event)
1191        }
1192      })
1193    }
1194  
1195    /**
1196     * Store multiple events in the general cache.
1197     */
1198    async putCachedEvents(events: Event[]): Promise<void> {
1199      if (events.length === 0) return
1200  
1201      await this.initPromise
1202      if (!this.db) {
1203        return
1204      }
1205  
1206      return new Promise((resolve) => {
1207        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1208        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1209  
1210        let completed = 0
1211        for (const event of events) {
1212          const putRequest = store.put(event)
1213          putRequest.onsuccess = () => {
1214            completed++
1215            if (completed === events.length) {
1216              transaction.commit()
1217              resolve()
1218            }
1219          }
1220          putRequest.onerror = () => {
1221            completed++
1222            if (completed === events.length) {
1223              transaction.commit()
1224              resolve()
1225            }
1226          }
1227        }
1228      })
1229    }
1230  
1231    /**
1232     * Get a cached event by ID.
1233     */
1234    async getCachedEvent(id: string): Promise<Event | null> {
1235      await this.initPromise
1236      if (!this.db) {
1237        return null
1238      }
1239  
1240      return new Promise((resolve, reject) => {
1241        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1242        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1243        const request = store.get(id)
1244  
1245        request.onsuccess = () => {
1246          transaction.commit()
1247          resolve(request.result ?? null)
1248        }
1249  
1250        request.onerror = (event) => {
1251          transaction.commit()
1252          reject(event)
1253        }
1254      })
1255    }
1256  
1257    /**
1258     * Query cached events matching the provided filters.
1259     * Returns events sorted by created_at descending.
1260     */
1261    async queryCachedEvents(filters: Filter[]): Promise<Event[]> {
1262      await this.initPromise
1263      if (!this.db) {
1264        return []
1265      }
1266  
1267      return new Promise((resolve) => {
1268        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1269        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1270        const request = store.openCursor()
1271        const events: Event[] = []
1272  
1273        request.onsuccess = (event) => {
1274          const cursor = (event.target as IDBRequest).result
1275          if (cursor) {
1276            const cachedEvent = cursor.value as Event
1277            if (cachedEvent && matchFilters(filters, cachedEvent)) {
1278              events.push(cachedEvent)
1279            }
1280            cursor.continue()
1281          } else {
1282            transaction.commit()
1283            // Sort by created_at descending
1284            events.sort((a, b) => b.created_at - a.created_at)
1285  
1286            // Apply limit from filters if specified
1287            const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1288            if (limit !== Infinity && limit > 0) {
1289              resolve(events.slice(0, limit))
1290            } else {
1291              resolve(events)
1292            }
1293          }
1294        }
1295  
1296        request.onerror = () => {
1297          transaction.commit()
1298          resolve([])
1299        }
1300      })
1301    }
1302  
1303    /**
1304     * Clean up expired cached events.
1305     * Removes events older than the specified number of days.
1306     */
1307    async cleanupExpiredCache(maxAgeDays: number = 7): Promise<number> {
1308      await this.initPromise
1309      if (!this.db) {
1310        return 0
1311      }
1312  
1313      const expirationTimestamp = Math.floor(Date.now() / 1000) - maxAgeDays * 24 * 60 * 60
1314  
1315      return new Promise((resolve) => {
1316        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1317        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1318        const index = store.index('created_at')
1319        const range = IDBKeyRange.upperBound(expirationTimestamp)
1320        const request = index.openCursor(range)
1321        let deletedCount = 0
1322  
1323        request.onsuccess = (event) => {
1324          const cursor = (event.target as IDBRequest).result
1325          if (cursor) {
1326            cursor.delete()
1327            deletedCount++
1328            cursor.continue()
1329          } else {
1330            transaction.commit()
1331            resolve(deletedCount)
1332          }
1333        }
1334  
1335        request.onerror = () => {
1336          transaction.commit()
1337          resolve(deletedCount)
1338        }
1339      })
1340    }
1341  
1342    /**
1343     * Get the count of cached events.
1344     */
1345    async getCachedEventCount(): Promise<number> {
1346      await this.initPromise
1347      if (!this.db) {
1348        return 0
1349      }
1350  
1351      return new Promise((resolve) => {
1352        const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1353        const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1354        const request = store.count()
1355  
1356        request.onsuccess = () => {
1357          transaction.commit()
1358          resolve(request.result)
1359        }
1360  
1361        request.onerror = () => {
1362          transaction.commit()
1363          resolve(0)
1364        }
1365      })
1366    }
1367  
1368    private async cleanUp() {
1369      await this.initPromise
1370      if (!this.db) {
1371        return
1372      }
1373  
1374      const stores = [
1375        {
1376          name: StoreNames.PROFILE_EVENTS,
1377          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1378        },
1379        {
1380          name: StoreNames.RELAY_LIST_EVENTS,
1381          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1382        },
1383        {
1384          name: StoreNames.FOLLOW_LIST_EVENTS,
1385          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1386        },
1387        {
1388          name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1389          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1390        },
1391        {
1392          name: StoreNames.RELAY_INFOS,
1393          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1394        },
1395        {
1396          name: StoreNames.PIN_LIST_EVENTS,
1397          expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 days
1398        }
1399      ]
1400      // Also clean up cached events (separate cleanup due to different key structure)
1401      this.cleanupExpiredCache(7) // 7 days for cached events
1402      const transaction = this.db!.transaction(
1403        stores.map((store) => store.name),
1404        'readwrite'
1405      )
1406      await Promise.allSettled(
1407        stores.map(({ name, expirationTimestamp }) => {
1408          if (expirationTimestamp < 0) {
1409            return Promise.resolve()
1410          }
1411          return new Promise<void>((resolve, reject) => {
1412            const store = transaction.objectStore(name)
1413            const request = store.openCursor()
1414            request.onsuccess = (event) => {
1415              const cursor = (event.target as IDBRequest).result
1416              if (cursor) {
1417                const value: TValue = cursor.value
1418                if (value.addedAt < expirationTimestamp) {
1419                  cursor.delete()
1420                }
1421                cursor.continue()
1422              } else {
1423                resolve()
1424              }
1425            }
1426  
1427            request.onerror = (event) => {
1428              reject(event)
1429            }
1430          })
1431        })
1432      )
1433    }
1434  
1435    // ── Relay Stats CRUD ──
1436  
1437    async putRelayStats(key: string, value: unknown): Promise<void> {
1438      await this.initPromise
1439      if (!this.db) return
1440      const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1441      const store = transaction.objectStore(StoreNames.RELAY_STATS)
1442      store.put({ key, value, addedAt: Date.now() })
1443    }
1444  
1445    async getAllRelayStats(): Promise<Array<{ key: string; value: unknown }>> {
1446      await this.initPromise
1447      if (!this.db) return []
1448      return new Promise((resolve, reject) => {
1449        const transaction = this.db!.transaction(StoreNames.RELAY_STATS, 'readonly')
1450        const store = transaction.objectStore(StoreNames.RELAY_STATS)
1451        const request = store.getAll()
1452        request.onsuccess = () => resolve(request.result ?? [])
1453        request.onerror = () => reject(request.error)
1454      })
1455    }
1456  
1457    async deleteRelayStats(key: string): Promise<void> {
1458      await this.initPromise
1459      if (!this.db) return
1460      const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1461      const store = transaction.objectStore(StoreNames.RELAY_STATS)
1462      store.delete(key)
1463    }
1464  
1465    // ── Managed Relays CRUD ──
1466  
1467    async putManagedRelay(key: string, value: unknown): Promise<void> {
1468      await this.initPromise
1469      if (!this.db) return
1470      const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1471      const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1472      store.put({ key, value, addedAt: Date.now() })
1473    }
1474  
1475    async getAllManagedRelays(): Promise<Array<{ key: string; value: unknown }>> {
1476      await this.initPromise
1477      if (!this.db) return []
1478      return new Promise((resolve, reject) => {
1479        const transaction = this.db!.transaction(StoreNames.MANAGED_RELAYS, 'readonly')
1480        const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1481        const request = store.getAll()
1482        request.onsuccess = () => resolve(request.result ?? [])
1483        request.onerror = () => reject(request.error)
1484      })
1485    }
1486  
1487    async deleteManagedRelay(key: string): Promise<void> {
1488      await this.initPromise
1489      if (!this.db) return
1490      const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1491      const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1492      store.delete(key)
1493    }
1494  }
1495  
1496  const instance = IndexedDbService.getInstance()
1497  export default instance
1498