You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

2177 lines
75 KiB

import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TNip66RelayDiscovery, TRelayInfo } from '@/types'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event'
import logger from '@/lib/logger'
type TValue<T = any> = {
key: string
value: T | null
addedAt: number
masterPublicationKey?: string // For nested publication events, link to master publication
}
export const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
FOLLOW_LIST_EVENTS: 'followListEvents',
/** NIP-51 follow sets (kind 30000). Key: pubkey:d */
FOLLOW_SET_EVENTS: 'followSetEvents',
MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
PIN_LIST_EVENTS: 'pinListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
INTEREST_LIST_EVENTS: 'interestListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays',
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
CACHE_RELAYS_EVENTS: 'cacheRelaysEvents',
RSS_FEED_LIST_EVENTS: 'rssFeedListEvents',
RSS_FEED_ITEMS: 'rssFeedItems',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
RELAY_INFO_EVENTS: 'relayInfoEvents', // deprecated
PUBLICATION_EVENTS: 'publicationEvents',
/** NIP-66: cached list of public lively relay URLs (from 30166 discovery). */
PUBLIC_LIVELY_RELAYS: 'publicLivelyRelays',
/** NIP-66: per-relay discovery cache (key = relay URL, value = { discovery, cachedAt }). */
NIP66_DISCOVERY: 'nip66Discovery',
/** NIP-A3 payment targets (kind 10133). */
PAYMENT_INFO_EVENTS: 'paymentInfoEvents',
/** Cached GIF list (parsed from kind 1063 + 1/1111). Key: 'gifList', value: { gifs, cachedAt }. */
GIF_CACHE: 'gifCache',
/** App settings (replaces in-memory/localStorage for persisted settings). Key: setting key, value: string. */
SETTINGS: 'settings',
/** NIP-A7 spell events (kind 777). Key: event id. */
SPELL_EVENTS: 'spellEvents',
/** Tombstone list for deleted events (kind 5). Key: event id or replaceable coordinate. */
TOMBSTONE_LIST: 'tombstoneList',
/** NIP-58 badge definitions (kind 30009). Key: pubkey:d */
BADGE_DEFINITION_EVENTS: 'badgeDefinitionEvents'
}
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 30
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
/** Convert IDB request.onerror Event to a proper Error for logging and UI */
function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): Error {
const request = ev.target as IDBRequest
const domError = request?.error
const message = domError?.message ?? 'IndexedDB operation failed'
return new Error(message)
}
/** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */
function ensureMissingObjectStores(db: IDBDatabase): void {
for (const storeName of Object.values(StoreNames)) {
if (db.objectStoreNames.contains(storeName)) continue
if (storeName === StoreNames.RSS_FEED_ITEMS) {
const store = db.createObjectStore(storeName, { keyPath: 'key' })
store.createIndex('feedUrl', 'feedUrl', { unique: false })
store.createIndex('pubDate', 'pubDate', { unique: false })
} else {
db.createObjectStore(storeName, { keyPath: 'key' })
}
}
}
class IndexedDbService {
static instance: IndexedDbService
static getInstance(): IndexedDbService {
if (!IndexedDbService.instance) {
IndexedDbService.instance = new IndexedDbService()
IndexedDbService.instance.init()
}
return IndexedDbService.instance
}
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.openDb()
}
return this.initPromise
}
private openDb(): Promise<void> {
return new Promise<void>((resolve) => {
const request = window.indexedDB.open('jumble', DB_VERSION)
request.onerror = (event) => {
const err = idbEventToError(event)
const isHigherVersion =
err.message.includes('higher version') || err.message.includes('version requested')
if (isHigherVersion) {
// Stored DB is newer than our DB_VERSION (e.g. other tab or previous deploy). We cannot
// open with a lower version. Use the existing DB at its version so the app keeps working.
// When we later bump DB_VERSION and ship new code, users at lower stored version will
// open with our new version and run onupgradeneeded as usual.
const probe = window.indexedDB.open('jumble')
probe.onerror = () => {
logger.warn('IndexedDB unavailable, running without local cache', err)
this.db = null
resolve()
}
probe.onsuccess = () => {
const probeDb = probe.result
const storedVersion = probeDb.version
probeDb.close()
const openWithStored = window.indexedDB.open('jumble', storedVersion)
openWithStored.onerror = (e) => {
logger.warn('IndexedDB unavailable, running without local cache', idbEventToError(e))
this.db = null
resolve()
}
openWithStored.onsuccess = () => {
this.db = openWithStored.result
setTimeout(() => this.cleanUp(), 1000 * 60)
resolve()
}
openWithStored.onupgradeneeded = () => {
// Should not fire when opening with existing version
}
}
return
}
logger.warn('IndexedDB unavailable, running without local cache', err)
this.db = null
resolve()
}
request.onsuccess = () => {
this.db = request.result
setTimeout(() => this.cleanUp(), 1000 * 60)
resolve()
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (
event.oldVersion < 26 &&
db.objectStoreNames.contains('spellListSourceEvents')
) {
db.deleteObjectStore('spellListSourceEvents')
}
if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_SET_EVENTS)) {
db.createObjectStore(StoreNames.FOLLOW_SET_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.INTEREST_LIST_EVENTS)) {
db.createObjectStore(StoreNames.INTEREST_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.BLOCKED_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.BLOCKED_RELAYS_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) {
db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) {
db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) {
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
}
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) {
db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.NIP66_DISCOVERY)) {
db.createObjectStore(StoreNames.NIP66_DISCOVERY, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RSS_FEED_LIST_EVENTS)) {
db.createObjectStore(StoreNames.RSS_FEED_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RSS_FEED_ITEMS)) {
const store = db.createObjectStore(StoreNames.RSS_FEED_ITEMS, { keyPath: 'key' })
store.createIndex('feedUrl', 'feedUrl', { unique: false })
store.createIndex('pubDate', 'pubDate', { unique: false })
}
if (!db.objectStoreNames.contains(StoreNames.PAYMENT_INFO_EVENTS)) {
db.createObjectStore(StoreNames.PAYMENT_INFO_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
db.createObjectStore(StoreNames.GIF_CACHE, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.SETTINGS)) {
db.createObjectStore(StoreNames.SETTINGS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
db.createObjectStore(StoreNames.SPELL_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.TOMBSTONE_LIST)) {
db.createObjectStore(StoreNames.TOMBSTONE_LIST, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) {
db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' })
}
ensureMissingObjectStores(db)
}
}
);
}
/** Whether {@link putReplaceableEvent} persists this kind (profile, lists, publications, …). */
hasReplaceableEventStoreForKind(kind: number): boolean {
return this.getStoreNameByKind(kind) !== undefined
}
async putReplaceableEvent(event: Event): Promise<Event> {
// Check if tombstoned before caching
const tombstoneKey = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
const isTombstoned = await this.isTombstoned(tombstoneKey)
if (isTombstoned) {
logger.debug('[IndexedDB] Skipping tombstoned event', { tombstoneKey, eventId: event.id })
return Promise.reject(new Error('Event is tombstoned'))
}
// Remove relayStatuses before storing (it's metadata for logging, not part of the event)
const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses
const storeName = this.getStoreNameByKind(cleanEvent.kind)
if (!storeName) {
logger.error('[IndexedDB] Store name not found for kind', { kind: cleanEvent.kind })
return Promise.reject('store name not found')
}
logger.debug('[IndexedDB] Putting replaceable event', {
kind: cleanEvent.kind,
storeName,
eventId: cleanEvent.id,
pubkey: cleanEvent.pubkey,
created_at: cleanEvent.created_at
})
await this.initPromise
// Wait a bit for database upgrade to complete if store doesn't exist
if (this.db && !this.db.objectStoreNames.contains(storeName)) {
logger.warn('[IndexedDB] Store not found, waiting for database upgrade', { storeName })
// Wait up to 2 seconds for store to be created (database upgrade)
let retries = 20
while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) {
await new Promise(resolve => setTimeout(resolve, 100))
retries--
}
}
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(cleanEvent)
}
// Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) {
logger.error('[IndexedDB] Store not found in database after waiting', {
storeName,
kind: cleanEvent.kind,
availableStores: Array.from(this.db.objectStoreNames),
dbVersion: this.db.version
})
logger.error('[IndexedDB] Store not found in database after waiting', {
storeName,
kind: cleanEvent.kind,
availableStores: Array.from(this.db.objectStoreNames)
})
// Return the event anyway (don't reject) - caching is optional
return resolve(cleanEvent)
}
logger.debug('[IndexedDB] Store exists, proceeding with save', {
storeName,
kind: cleanEvent.kind,
eventId: cleanEvent.id,
dbVersion: this.db.version,
allStores: Array.from(this.db.objectStoreNames)
})
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKeyFromEvent(cleanEvent)
logger.debug('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id })
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue?.value) {
logger.debug('[IndexedDB] Found existing event', {
storeName,
key,
oldEventId: oldValue.value.id,
oldCreatedAt: oldValue.value.created_at,
newCreatedAt: cleanEvent.created_at,
willUpdate: cleanEvent.created_at > oldValue.value.created_at
})
} else {
logger.debug('[IndexedDB] No existing event found', { storeName, key })
}
if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
logger.debug('[IndexedDB] Keeping existing event (strictly newer timestamp)', {
storeName,
key,
existingEventId: oldValue.value.id
})
transaction.commit()
return resolve(oldValue.value)
}
logger.debug('[IndexedDB] Putting new event', {
storeName,
key,
eventId: cleanEvent.id,
content: cleanEvent.content
})
const putRequest = store.put(this.formatValue(key, cleanEvent))
putRequest.onsuccess = () => {
logger.debug('[IndexedDB] Successfully put event', {
storeName,
key,
eventId: cleanEvent.id,
content: cleanEvent.content
})
transaction.commit()
resolve(cleanEvent)
}
putRequest.onerror = (event) => {
logger.error('[IndexedDB] Error putting event!', {
storeName,
key,
error: event,
target: (event.target as any)?.error,
errorMessage: (event.target as any)?.error?.message
})
logger.error('[IndexedDB] Error putting event', { storeName, key, error: event })
transaction.commit()
reject(event)
}
}
getRequest.onerror = (event) => {
logger.error('[IndexedDB] Error getting existing event', { storeName, key, error: event })
transaction.commit()
reject(event)
}
})
}
async getReplaceableEvent(
pubkey: string,
kind: number,
d?: string
): Promise<Event | undefined | null> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return Promise.reject('store name not found')
}
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(undefined)
}
// Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) {
logger.warn(`Store ${storeName} not found in database. Returning null.`)
return resolve(null)
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(pubkey, d)
const request = store.get(key)
request.onsuccess = () => {
const row = request.result as TValue<Event> | undefined
if (!row) {
logger.debug('[IndexedDB] getReplaceableEvent - no row found', {
pubkey,
kind,
d
})
transaction.commit()
return resolve(undefined)
}
// Invalidate profile and payment info cache when stale so they refetch regularly
// BUT: Always return cached profiles even if stale - we'll refresh in background
// This ensures profiles are always visible, even if slightly outdated
const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) {
// Profile is stale, but return it anyway - refresh will happen in background
// This prevents the "no profile" state when cache exists but is just old
logger.debug('[IndexedDB] Profile cache is stale but returning anyway', {
pubkey,
age: Date.now() - row.addedAt,
maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS,
eventId: row.value?.id
})
}
logger.debug('[IndexedDB] getReplaceableEvent - found', {
pubkey,
kind,
eventId: row.value?.id,
addedAt: row.addedAt
})
transaction.commit()
resolve(row.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Get the timestamp when a replaceable event was cached in IndexedDB
*/
async getReplaceableEventCachedAt(
pubkey: string,
kind: number,
d?: string
): Promise<number | undefined> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return Promise.resolve(undefined)
}
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(undefined)
}
if (!this.db.objectStoreNames.contains(storeName)) {
return resolve(undefined)
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(pubkey, d)
const request = store.get(key)
request.onsuccess = () => {
const row = request.result as TValue<Event> | undefined
transaction.commit()
resolve(row?.addedAt)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getManyReplaceableEvents(
pubkeys: readonly string[],
kind: number
): Promise<(Event | undefined | null)[]> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return Promise.reject('store name not found')
}
await this.initPromise
return new Promise<(Event | undefined | null)[]>((resolve) => {
if (!this.db) {
return resolve(new Array(pubkeys.length).fill(undefined))
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
let count = 0
pubkeys.forEach((pubkey, i) => {
const request = store.get(this.getReplaceableEventKey(pubkey))
request.onsuccess = () => {
const event = (request.result as TValue<Event | null>)?.value
if (event || event === null) {
events[i] = event
}
if (++count === pubkeys.length) {
transaction.commit()
resolve(events)
}
}
request.onerror = () => {
if (++count === pubkeys.length) {
transaction.commit()
resolve(events)
}
}
})
})
}
async getMuteDecryptedTags(id: string): Promise<string[][] | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readonly')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS)
const request = store.get(id)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<string[][]>)?.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putMuteDecryptedTags(id: string, tags: string[][]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readwrite')
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS)
const putRequest = store.put(this.formatValue(id, tags))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> {
await this.initPromise
if (!this.db) {
return
}
return new Promise<void>((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.PROFILE_EVENTS)
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value = (cursor.value as TValue<Event>).value
if (value) {
callback(value)
}
cursor.continue()
} else {
transaction.commit()
resolve()
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putFollowingFavoriteRelays(pubkey: string, relays: [string, string[]][]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readwrite')
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
const putRequest = store.put(this.formatValue(pubkey, relays))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][] | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readonly')
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
const request = store.get(pubkey)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<[string, string[]][]>)?.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putRelayInfo(relayInfo: TRelayInfo): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readwrite')
const store = transaction.objectStore(StoreNames.RELAY_INFOS)
const putRequest = store.put(this.formatValue(relayInfo.url, relayInfo))
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getRelayInfo(url: string): Promise<TRelayInfo | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readonly')
const store = transaction.objectStore(StoreNames.RELAY_INFOS)
const request = store.get(url)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<TRelayInfo>)?.value)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/** NIP-66: cache key for the single public lively relay list entry. */
private static PUBLIC_LIVELY_CACHE_KEY = 'list'
async getPublicLivelyRelayUrlsCache(): Promise<{ urls: string[]; cachedAt: number } | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.PUBLIC_LIVELY_RELAYS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLIC_LIVELY_RELAYS)
const request = store.get(IndexedDbService.PUBLIC_LIVELY_CACHE_KEY)
request.onsuccess = () => {
transaction.commit()
const row = request.result as TValue<{ urls: string[]; cachedAt: number }> | undefined
resolve(row?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async setPublicLivelyRelayUrlsCache(urls: string[]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.PUBLIC_LIVELY_RELAYS, 'readwrite')
const store = transaction.objectStore(StoreNames.PUBLIC_LIVELY_RELAYS)
const value = this.formatValue(IndexedDbService.PUBLIC_LIVELY_CACHE_KEY, {
urls,
cachedAt: Date.now()
})
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getNip66Discovery(relayUrl: string): Promise<{ discovery: TNip66RelayDiscovery; cachedAt: number } | null> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(null)
}
const transaction = this.db.transaction(StoreNames.NIP66_DISCOVERY, 'readonly')
const store = transaction.objectStore(StoreNames.NIP66_DISCOVERY)
const request = store.get(relayUrl)
request.onsuccess = () => {
transaction.commit()
const row = request.result as TValue<{ discovery: TNip66RelayDiscovery; cachedAt: number }> | undefined
resolve(row?.value ?? null)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async setNip66Discovery(relayUrl: string, discovery: TNip66RelayDiscovery): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve()
}
const transaction = this.db.transaction(StoreNames.NIP66_DISCOVERY, 'readwrite')
const store = transaction.objectStore(StoreNames.NIP66_DISCOVERY)
const value = this.formatValue(relayUrl, { discovery, cachedAt: Date.now() })
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve()
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string {
// Events that are replaceable by pubkey only (no d-tag)
// PAYMENT_INFO (10133), RSS_FEED_LIST (10895), etc. are in the 10000-20000 range
if (
[kinds.Metadata, kinds.Contacts, ExtendedKind.PAYMENT_INFO].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.WIKI_ARTICLE_MARKDOWN && event.kind !== kinds.LongFormArticle)
) {
return this.getReplaceableEventKey(event.pubkey)
}
// Publications and their nested content are replaceable by pubkey + d-tag
const [, d] = event.tags.find(tagNameEquals('d')) ?? []
return this.getReplaceableEventKey(event.pubkey, d)
}
private getReplaceableEventKey(pubkey: string, d?: string): string {
return d === undefined ? pubkey : `${pubkey}:${d}`
}
private getStoreNameByKind(kind: number): string | undefined {
switch (kind) {
case kinds.Metadata:
return StoreNames.PROFILE_EVENTS
case kinds.RelayList:
return StoreNames.RELAY_LIST_EVENTS
case kinds.Contacts:
return StoreNames.FOLLOW_LIST_EVENTS
case ExtendedKind.FOLLOW_SET:
return StoreNames.FOLLOW_SET_EVENTS
case kinds.Mutelist:
return StoreNames.MUTE_LIST_EVENTS
case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS
case 10001: // Pin list
return StoreNames.PIN_LIST_EVENTS
case 10015: // Interest list
return StoreNames.INTEREST_LIST_EVENTS
case ExtendedKind.BLOSSOM_SERVER_LIST:
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
case kinds.Relaysets:
return StoreNames.RELAY_SETS
case ExtendedKind.FAVORITE_RELAYS:
return StoreNames.FAVORITE_RELAYS
case ExtendedKind.BLOCKED_RELAYS:
return StoreNames.BLOCKED_RELAYS_EVENTS
case ExtendedKind.CACHE_RELAYS:
return StoreNames.CACHE_RELAYS_EVENTS
case ExtendedKind.RSS_FEED_LIST:
return StoreNames.RSS_FEED_LIST_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
case ExtendedKind.PAYMENT_INFO:
return StoreNames.PAYMENT_INFO_EVENTS
case ExtendedKind.PUBLICATION:
case ExtendedKind.PUBLICATION_CONTENT:
case ExtendedKind.WIKI_ARTICLE:
case kinds.LongFormArticle:
return StoreNames.PUBLICATION_EVENTS
case ExtendedKind.BADGE_DEFINITION:
return StoreNames.BADGE_DEFINITION_EVENTS
default:
return undefined
}
}
async putPublicationWithNestedEvents(masterEvent: Event, nestedEvents: Event[]): Promise<Event> {
// Store master publication as replaceable event
const masterKey = this.getReplaceableEventKeyFromEvent(masterEvent)
await this.putReplaceableEvent(masterEvent)
// Store nested events, linking them to the master
for (const nestedEvent of nestedEvents) {
// Check if this is a replaceable event kind
if (isReplaceableEvent(nestedEvent.kind)) {
await this.putReplaceableEventWithMaster(nestedEvent, masterKey)
} else {
// For non-replaceable events, store by event ID with master link
await this.putNonReplaceableEventWithMaster(nestedEvent, masterKey)
}
}
return masterEvent
}
private async putReplaceableEventWithMaster(event: Event, masterKey: string): Promise<Event> {
// Remove relayStatuses before storing (it's metadata for logging, not part of the event)
const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses
const storeName = this.getStoreNameByKind(cleanEvent.kind)
if (!storeName) {
return Promise.reject('store name not found')
}
await this.initPromise
// Wait a bit for database upgrade to complete if store doesn't exist
if (this.db && !this.db.objectStoreNames.contains(storeName)) {
let retries = 20
while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) {
await new Promise(resolve => setTimeout(resolve, 100))
retries--
}
}
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(cleanEvent)
}
if (!this.db.objectStoreNames.contains(storeName)) {
logger.warn(`Store ${storeName} not found in database. Cannot save event.`)
return resolve(cleanEvent)
}
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKeyFromEvent(cleanEvent)
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
// Update master key link even if event is not newer
if (oldValue.masterPublicationKey !== masterKey) {
const value = this.formatValue(key, oldValue.value)
value.masterPublicationKey = masterKey
store.put(value)
}
transaction.commit()
return resolve(oldValue.value)
}
// Store with master key link
const value = this.formatValue(key, cleanEvent)
value.masterPublicationKey = masterKey
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve(cleanEvent)
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
}
getRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise<Event> {
// For non-replaceable events, store by event ID in publication events store
const storeName = StoreNames.PUBLICATION_EVENTS
await this.initPromise
// Wait a bit for database upgrade to complete if store doesn't exist
if (this.db && !this.db.objectStoreNames.contains(storeName)) {
let retries = 20
while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) {
await new Promise(resolve => setTimeout(resolve, 100))
retries--
}
}
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(event)
}
if (!this.db.objectStoreNames.contains(storeName)) {
logger.warn(`Store ${storeName} not found in database. Cannot save event.`)
return resolve(event)
}
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
// For non-replaceable events, use event ID as key
const key = event.id
// For non-replaceable events, always update with master key link
const value = this.formatValue(key, event)
value.masterPublicationKey = masterKey
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
resolve(event)
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getPublicationEvent(coordinate: string): Promise<Event | undefined> {
// Parse coordinate (format: kind:pubkey:d-tag)
const coordinateParts = coordinate.split(':')
if (coordinateParts.length >= 2) {
const kind = parseInt(coordinateParts[0])
if (!isNaN(kind)) {
const pubkey = coordinateParts[1]
const d = coordinateParts[2] || undefined
const event = await this.getReplaceableEvent(pubkey, kind, d)
return event || undefined
}
}
return Promise.resolve(undefined)
}
async getEventFromPublicationStore(eventId: string): Promise<Event | undefined> {
// Get event from PUBLICATION_EVENTS store by event ID
// This is used for non-replaceable events stored as part of publications
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(undefined)
}
if (!this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return resolve(undefined)
}
const transaction = this.db.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.get(eventId)
request.onsuccess = () => {
transaction.commit()
const result = request.result as TValue<Event> | undefined
resolve(result?.value || undefined)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/**
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags
* match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first.
*/
async getCachedEventsForSearch(query: string, limit: number, allowedKinds: number[]): Promise<Event[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return []
}
const q = query.trim().toLowerCase()
if (!q || allowedKinds.length === 0) return []
const kindSet = new Set(allowedKinds)
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.openCursor()
const results: Event[] = []
request.onsuccess = () => {
const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || results.length >= limit) {
transaction.commit()
resolve(results)
return
}
const item = cursor.value as TValue<Event> | undefined
if (item?.value) {
const event = item.value as Event
if (kindSet.has(event.kind)) {
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
results.push(event)
}
}
}
cursor.continue()
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.openCursor()
const masterEvents = new Map<string, { key: string; value: any; addedAt: number; nestedCount: number }>()
const nestedEvents: Array<{ key: string; masterKey?: string }> = []
request.onsuccess = () => {
const cursor = (request as any).result
if (cursor) {
const item = cursor.value as TValue<Event>
const key = cursor.key as string
if (item?.value) {
const event = item.value as Event
// Check if this is a master publication (kind 30040) or a nested event
if (event.kind === ExtendedKind.PUBLICATION && !item.masterPublicationKey) {
// This is a master publication
masterEvents.set(key, {
key,
value: event,
addedAt: item.addedAt,
nestedCount: 0
})
} else if (item.masterPublicationKey) {
// This is a nested event - track it for counting
nestedEvents.push({
key,
masterKey: item.masterPublicationKey
})
}
}
cursor.continue()
} else {
// Count nested events for each master
nestedEvents.forEach(nested => {
if (nested.masterKey && masterEvents.has(nested.masterKey)) {
const master = masterEvents.get(nested.masterKey)!
master.nestedCount++
}
})
transaction.commit()
resolve(Array.from(masterEvents.values()))
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async deletePublicationAndNestedEvents(pubkey: string, d?: string): Promise<{ deleted: number }> {
const masterKey = this.getReplaceableEventKey(pubkey, d)
const storeName = StoreNames.PUBLICATION_EVENTS
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return Promise.resolve({ deleted: 0 })
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.openCursor()
const keysToDelete: string[] = []
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value = cursor.value as TValue<Event>
const key = cursor.key as string
// Delete if it's the master (matches masterKey) or linked to the master (has masterPublicationKey)
if (key === masterKey || value?.masterPublicationKey === masterKey) {
keysToDelete.push(key)
}
cursor.continue()
} else {
// Delete all identified keys
let deletedCount = 0
let completedCount = 0
if (keysToDelete.length === 0) {
transaction.commit()
return resolve({ deleted: 0 })
}
keysToDelete.forEach(key => {
const deleteRequest = store.delete(key)
deleteRequest.onsuccess = () => {
deletedCount++
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
resolve({ deleted: deletedCount })
}
}
deleteRequest.onerror = () => {
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
resolve({ deleted: deletedCount })
}
}
})
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private formatValue<T>(key: string, value: T): TValue<T> {
return {
key,
value,
addedAt: Date.now()
}
}
async clearAllCache(): Promise<void> {
await this.initPromise
if (!this.db) {
return
}
const allStoreNames = Array.from(this.db.objectStoreNames)
const transaction = this.db.transaction(allStoreNames, 'readwrite')
await Promise.allSettled(
allStoreNames.map(storeName => {
return new Promise<void>((resolve, reject) => {
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = (event) => reject(event)
})
})
)
}
async getStoreInfo(): Promise<Record<string, number>> {
await this.initPromise
if (!this.db) {
return {}
}
const storeInfo: Record<string, number> = {}
const allStoreNames = Array.from(this.db.objectStoreNames)
await Promise.allSettled(
allStoreNames.map(storeName => {
return new Promise<void>((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.count()
request.onsuccess = () => {
storeInfo[storeName] = request.result
resolve()
}
request.onerror = (event) => reject(idbEventToError(event))
})
})
)
return storeInfo
}
async getStoreItems(storeName: string): Promise<TValue<any>[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onsuccess = () => {
transaction.commit()
resolve(request.result as TValue<any>[])
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/** Remove a replaceable event from cache so the next fetch will load from relays. */
async invalidateReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<void> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) return
const key = this.getReplaceableEventKey(pubkey, d)
await this.deleteStoreItem(storeName, key)
}
async deleteStoreItem(storeName: string, key: string): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(key)
request.onsuccess = () => {
transaction.commit()
resolve()
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async clearStore(storeName: string): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => {
transaction.commit()
resolve()
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async cleanupDuplicateReplaceableEvents(storeName: string): Promise<{ deleted: number; kept: number }> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return { deleted: 0, kept: 0 }
}
// Get the kind for this store - only clean up replaceable event stores
const kind = this.getKindByStoreName(storeName)
if (!kind || !this.isReplaceableEventKind(kind)) {
return Promise.reject('Not a replaceable event store')
}
// First pass: identify duplicates
const allItems = await this.getStoreItems(storeName)
const eventMap = new Map<string, { key: string; event: Event; addedAt: number }>()
const keysToDelete: string[] = []
let invalidItemsCount = 0
for (const item of allItems) {
if (!item || !item.value) {
invalidItemsCount++
continue
}
// Skip if event doesn't have required fields
if (!item.value.pubkey || !item.value.kind || !item.value.created_at) {
invalidItemsCount++
continue
}
try {
const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value)
const existing = eventMap.get(replaceableKey)
if (!existing ||
item.value.created_at > existing.event.created_at ||
(item.value.created_at === existing.event.created_at &&
item.addedAt > existing.addedAt)) {
// This event is newer, mark the old one for deletion if it exists
if (existing) {
keysToDelete.push(existing.key)
}
eventMap.set(replaceableKey, {
key: item.key,
event: item.value,
addedAt: item.addedAt
})
} else {
// This event is older or same, mark it for deletion
keysToDelete.push(item.key)
}
} catch (error) {
// If we can't generate a replaceable key, skip this item
logger.warn('Failed to get replaceable key for item', { key: item.key, error })
invalidItemsCount++
continue
}
}
// Second pass: delete duplicates
const totalProcessed = eventMap.size + keysToDelete.length
const actualKept = eventMap.size
if (keysToDelete.length === 0) {
// No duplicates found, but verify counts match
if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch while cleaning up replaceable events', {
totalItems: allItems.length,
processed: totalProcessed,
invalid: invalidItemsCount
})
}
return Promise.resolve({ deleted: 0, kept: actualKept })
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
let deletedCount = 0
let completedCount = 0
keysToDelete.forEach(key => {
const deleteRequest = store.delete(key)
deleteRequest.onsuccess = () => {
deletedCount++
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
const actualKept = eventMap.size
const totalProcessed = actualKept + deletedCount
if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch after deletion', {
totalItems: allItems.length,
kept: actualKept,
deleted: deletedCount,
invalid: invalidItemsCount
})
}
resolve({ deleted: deletedCount, kept: actualKept })
}
}
deleteRequest.onerror = () => {
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
const actualKept = eventMap.size
resolve({ deleted: deletedCount, kept: actualKept })
}
}
})
})
}
private getKindByStoreName(storeName: string): number | undefined {
// Reverse lookup of getStoreNameByKind
if (storeName === StoreNames.PROFILE_EVENTS) return kinds.Metadata
if (storeName === StoreNames.RELAY_LIST_EVENTS) return kinds.RelayList
if (storeName === StoreNames.FOLLOW_LIST_EVENTS) return kinds.Contacts
if (storeName === StoreNames.FOLLOW_SET_EVENTS) return ExtendedKind.FOLLOW_SET
if (storeName === StoreNames.MUTE_LIST_EVENTS) return kinds.Mutelist
if (storeName === StoreNames.BOOKMARK_LIST_EVENTS) return kinds.BookmarkList
if (storeName === StoreNames.PIN_LIST_EVENTS) return 10001
if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015
if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST
if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets
if (storeName === StoreNames.FAVORITE_RELAYS) return ExtendedKind.FAVORITE_RELAYS
if (storeName === StoreNames.BLOCKED_RELAYS_EVENTS) return ExtendedKind.BLOCKED_RELAYS
if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS
if (storeName === StoreNames.RSS_FEED_LIST_EVENTS) return ExtendedKind.RSS_FEED_LIST
if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList
if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets
if (storeName === StoreNames.PAYMENT_INFO_EVENTS) return ExtendedKind.PAYMENT_INFO
if (storeName === StoreNames.BADGE_DEFINITION_EVENTS) return ExtendedKind.BADGE_DEFINITION
// PUBLICATION_EVENTS is not replaceable, so we don't handle it here
return undefined
}
private isReplaceableEventKind(kind: number): boolean {
// Check if this is a replaceable event kind
return (
kind === kinds.Metadata ||
kind === kinds.Contacts ||
kind === kinds.RelayList ||
kind === kinds.Mutelist ||
kind === kinds.BookmarkList ||
kind === ExtendedKind.FOLLOW_SET ||
(kind >= 10000 && kind < 20000) ||
kind === ExtendedKind.FAVORITE_RELAYS ||
kind === ExtendedKind.BLOCKED_RELAYS ||
kind === ExtendedKind.CACHE_RELAYS ||
kind === ExtendedKind.BLOSSOM_SERVER_LIST ||
kind === ExtendedKind.RSS_FEED_LIST
)
}
async forceDatabaseUpgrade(): Promise<void> {
// Close the database first
if (this.db) {
this.db.close()
this.db = null
this.initPromise = null
}
// Check current version
const checkRequest = window.indexedDB.open('jumble')
let currentVersion = DB_VERSION
checkRequest.onsuccess = () => {
const db = checkRequest.result
currentVersion = db.version
db.close()
}
checkRequest.onerror = () => {
// If we can't check, start fresh
currentVersion = 14
}
await new Promise(resolve => setTimeout(resolve, 100)) // Wait for version check
const newVersion = currentVersion + 1
// Open with new version to trigger upgrade
return new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', newVersion)
request.onerror = (event) => {
reject(event)
}
request.onsuccess = () => {
const db = request.result
// Don't close - keep it open for the service to use
this.db = db
this.initPromise = Promise.resolve()
resolve()
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
ensureMissingObjectStores(db)
}
})
}
private async cleanUp() {
await this.initPromise
if (!this.db) {
return
}
const stores = [
{ name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{ name: StoreNames.PAYMENT_INFO_EVENTS, expirationTimestamp: Date.now() - PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS }, // 5 min
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{
name: StoreNames.FOLLOW_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day
},
{
name: StoreNames.FOLLOW_SET_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day
},
{
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
},
{
name: StoreNames.RELAY_INFOS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
}
]
const names = this.db.objectStoreNames
const existingStores = stores.filter((s) => names.contains(s.name))
if (existingStores.length === 0) {
return
}
const transaction = this.db!.transaction(
existingStores.map((store) => store.name),
'readwrite'
)
await Promise.allSettled(
existingStores.map(({ name, expirationTimestamp }) => {
if (expirationTimestamp < 0) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
const store = transaction.objectStore(name)
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value: TValue = cursor.value
if (value.addedAt < expirationTimestamp) {
cursor.delete()
}
cursor.continue()
} else {
resolve()
}
}
request.onerror = (event) => {
reject(event)
}
})
})
)
}
/**
* Store RSS feed items in IndexedDB
*/
async putRssFeedItems(items: import('./rss-feed.service').RssFeedItem[]): Promise<void> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
logger.warn('[IndexedDB] RSS feed items store not found', { storeName })
return
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
let completed = 0
let errors = 0
items.forEach((item) => {
// Create a unique key from feedUrl and guid
const key = `${item.feedUrl}:${item.guid}`
// Store in TValue format for consistency with other stores
const value: TValue<import('./rss-feed.service').RssFeedItem> = {
key,
value: item,
addedAt: Date.now()
}
const request = store.put(value)
request.onsuccess = () => {
completed++
if (completed + errors === items.length) {
resolve()
}
}
request.onerror = () => {
errors++
if (completed + errors === items.length) {
resolve() // Don't reject, just log
}
}
})
if (items.length === 0) {
resolve()
}
})
}
/**
* Get all RSS feed items from IndexedDB
*/
async getRssFeedItems(): Promise<import('./rss-feed.service').RssFeedItem[]> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
logger.warn('[IndexedDB] RSS feed items store not found', { storeName })
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onsuccess = () => {
const items = request.result.map((entry: TValue<import('./rss-feed.service').RssFeedItem> | any) => {
let item: import('./rss-feed.service').RssFeedItem | null = null
// Handle new format (with value property)
if (entry.value) {
item = entry.value
}
// Fallback for old format (with item property)
else if ((entry as any).item) {
item = (entry as any).item as import('./rss-feed.service').RssFeedItem
}
if (!item) {
return null
}
// Ensure pubDate is properly handled (IndexedDB may serialize Date as string)
if (item.pubDate && typeof item.pubDate === 'string') {
item.pubDate = new Date(item.pubDate)
} else if (item.pubDate && typeof item.pubDate === 'number') {
item.pubDate = new Date(item.pubDate)
}
return item
}).filter((item): item is import('./rss-feed.service').RssFeedItem => item !== null)
logger.debug('[IndexedDB] Retrieved RSS feed items', {
totalRetrieved: request.result.length,
validItems: items.length
})
resolve(items)
}
request.onerror = () => {
reject(request.error)
}
})
}
/**
* Clear RSS feed items from IndexedDB
*/
async clearRssFeedItems(): Promise<void> {
await this.initPromise
const storeName = StoreNames.RSS_FEED_ITEMS
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => {
resolve()
}
request.onerror = () => {
reject(request.error)
}
})
}
private static readonly GIF_CACHE_KEY = 'gifList'
private static readonly MEME_CACHE_KEY = 'memeList'
/**
* Get cached GIF list from IndexedDB. Returns null if missing or store unavailable.
*/
async getGifCache(): Promise<{
gifs: {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[]
cachedAt: number
} | null> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return null
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readonly')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
const request = store.get(IndexedDbService.GIF_CACHE_KEY)
request.onsuccess = () => {
const row = request.result as { key: string; value: { gifs: unknown[]; cachedAt: number } } | undefined
if (row?.value?.gifs && typeof row.value.cachedAt === 'number') {
resolve({
gifs: row.value.gifs as {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: row.value.cachedAt
})
} else {
resolve(null)
}
}
request.onerror = () => resolve(null)
})
}
/**
* Write GIF list cache to IndexedDB.
*/
async setGifCache(
gifs: {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: number
): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readwrite')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
store.put({ key: IndexedDbService.GIF_CACHE_KEY, value: { gifs, cachedAt } })
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Cached memes (kind 1063 `memeamigo` only). Same store as GIF cache, different key.
*/
async getMemeCache(): Promise<{
memes: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[]
cachedAt: number
} | null> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return null
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readonly')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
const request = store.get(IndexedDbService.MEME_CACHE_KEY)
request.onsuccess = () => {
const row = request.result as
| { key: string; value: { memes: unknown[]; cachedAt: number } }
| undefined
if (row?.value?.memes && typeof row.value.cachedAt === 'number') {
resolve({
memes: row.value.memes as {
url: string
fallbackUrl?: string
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: row.value.cachedAt
})
} else {
resolve(null)
}
}
request.onerror = () => resolve(null)
})
}
async setMemeCache(
memes: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[],
cachedAt: number
): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readwrite')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
store.put({ key: IndexedDbService.MEME_CACHE_KEY, value: { memes, cachedAt } })
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Get a single setting value from IndexedDB. Returns null if missing.
*/
async getSetting(key: string): Promise<string | null> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SETTINGS)) {
return null
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(StoreNames.SETTINGS, 'readonly')
const store = transaction.objectStore(StoreNames.SETTINGS)
const request = store.get(key)
request.onsuccess = () => {
const row = request.result as { key: string; value: string } | undefined
resolve(row?.value ?? null)
}
request.onerror = () => resolve(null)
})
}
/**
* Get all settings from IndexedDB as a key -> value map.
*/
async getAllSettings(): Promise<Record<string, string>> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SETTINGS)) {
return {}
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SETTINGS, 'readonly')
const store = transaction.objectStore(StoreNames.SETTINGS)
const request = store.getAll()
request.onsuccess = () => {
const rows = (request.result || []) as { key: string; value: string }[]
const out: Record<string, string> = {}
rows.forEach((r) => {
if (r.key != null && r.value != null) out[r.key] = r.value
})
resolve(out)
}
request.onerror = () => reject(request.error)
})
}
/**
* Set a setting in IndexedDB.
*/
async setSetting(key: string, value: string): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SETTINGS)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SETTINGS, 'readwrite')
const store = transaction.objectStore(StoreNames.SETTINGS)
store.put({ key, value })
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/** Settings key for favorite spell event ids (JSON array of strings). */
static readonly SPELL_FAVORITE_IDS_KEY = 'spellFavoriteIds'
/**
* Store a NIP-A7 spell event (kind 777) in IndexedDB by event id.
*/
async putSpellEvent(event: Event): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
logger.warn('[IndexedDB] Spell events store not found')
return
}
const cleanEvent = { ...event }
delete (cleanEvent as any).relayStatuses
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SPELL_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.SPELL_EVENTS)
const key = cleanEvent.id
const value: TValue<Event> = {
key,
value: cleanEvent,
addedAt: Date.now()
}
store.put(value)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Delete a spell event from IndexedDB by event id.
*/
async deleteSpellEvent(eventId: string): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
logger.warn('[IndexedDB] Spell events store not found')
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SPELL_EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.SPELL_EVENTS)
store.delete(eventId)
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Get all spell events from IndexedDB.
*/
async getSpellEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.SPELL_EVENTS)) {
return []
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.SPELL_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.SPELL_EVENTS)
const request = store.getAll()
request.onsuccess = () => {
const rows = (request.result || []) as TValue<Event>[]
const events = rows
.filter((r) => r?.value != null)
.map((r) => r.value as Event)
resolve(events)
}
request.onerror = () => reject(request.error)
})
}
/**
* Get favorite spell ids from settings (JSON array of event ids).
*/
async getSpellFavoriteIds(): Promise<string[]> {
const raw = await this.getSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY)
if (!raw) return []
try {
const arr = JSON.parse(raw) as unknown
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
/**
* Set favorite spell ids in settings.
*/
async setSpellFavoriteIds(ids: string[]): Promise<void> {
await this.setSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY, JSON.stringify(ids))
}
/**
* Check if an event is tombstoned (deleted)
*/
async isTombstoned(key: string): Promise<boolean> {
await this.initPromise
return new Promise((resolve) => {
if (!this.db) {
return resolve(false)
}
if (!this.db.objectStoreNames.contains(StoreNames.TOMBSTONE_LIST)) {
return resolve(false)
}
const transaction = this.db.transaction(StoreNames.TOMBSTONE_LIST, 'readonly')
const store = transaction.objectStore(StoreNames.TOMBSTONE_LIST)
const request = store.get(key)
request.onsuccess = () => {
const row = request.result as TValue | undefined
transaction.commit()
resolve(row !== undefined && row.value !== null)
}
request.onerror = () => {
transaction.commit()
resolve(false)
}
})
}
/**
* Add event to tombstone list (mark as deleted)
* Key format: event ID for non-replaceable events, or "kind:pubkey" or "kind:pubkey:d" for replaceable events
*/
async addTombstone(key: string, deletedAt: number = Date.now()): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject(new Error('Database not initialized'))
}
if (!this.db.objectStoreNames.contains(StoreNames.TOMBSTONE_LIST)) {
return reject(new Error('Tombstone store not found'))
}
const transaction = this.db.transaction(StoreNames.TOMBSTONE_LIST, 'readwrite')
const store = transaction.objectStore(StoreNames.TOMBSTONE_LIST)
const value = this.formatValue(key, { deletedAt })
const request = store.put(value)
request.onsuccess = () => {
transaction.commit()
resolve()
}
request.onerror = (event) => {
transaction.commit()
reject(idbEventToError(event))
}
})
}
/**
* Get all tombstoned keys
*/
async getAllTombstones(): Promise<Set<string>> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return resolve(new Set())
}
if (!this.db.objectStoreNames.contains(StoreNames.TOMBSTONE_LIST)) {
return resolve(new Set())
}
const transaction = this.db.transaction(StoreNames.TOMBSTONE_LIST, 'readonly')
const store = transaction.objectStore(StoreNames.TOMBSTONE_LIST)
const request = store.getAll()
request.onsuccess = () => {
const rows = request.result as TValue[]
const keys = new Set<string>()
for (const row of rows) {
if (row.value !== null) {
keys.add(row.key)
}
}
transaction.commit()
resolve(keys)
}
request.onerror = (event) => {
transaction.commit()
reject(idbEventToError(event))
}
})
}
/**
* Remove tombstoned events from cache (cleanup)
*/
async removeTombstonedFromCache(): Promise<number> {
const tombstones = await this.getAllTombstones()
let removed = 0
for (const key of tombstones) {
// Parse key format: could be event id or "kind:pubkey" or "kind:pubkey:d" (replaceable coordinate)
// Or just event ID for non-replaceable events
const parts = key.split(':')
if (parts.length === 1) {
// Event ID - remove from publication store
try {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key)
removed++
} catch {
// Ignore errors
}
} else if (parts.length >= 2) {
// Replaceable coordinate: kind:64-hex-pubkey[:d...] (d may contain ':' per NIP-33)
const kind = parseInt(parts[0]!, 10)
const pubkey = parts[1]!
const d = parts.length > 2 ? parts.slice(2).join(':') : undefined
if (!isNaN(kind) && /^[0-9a-f]{64}$/i.test(pubkey)) {
try {
const storeName = this.getStoreNameByKind(kind)
if (storeName) {
await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d))
removed++
}
} catch {
// Ignore errors
}
}
}
}
return removed
}
}
const instance = IndexedDbService.getInstance()
export default instance