import { ExtendedKind } from '@/constants' import { tagNameEquals } from '@/lib/tag' import { Event, kinds } from 'nostr-tools' type TValue = { key: string value: T addedAt: number } const StoreNames = { PROFILE_EVENTS: 'profileEvents', RELAY_LIST_EVENTS: 'relayListEvents', FOLLOW_LIST_EVENTS: 'followListEvents', MUTE_LIST_EVENTS: 'muteListEvents', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', RELAY_INFO_EVENTS: 'relayInfoEvents', FAVORITE_RELAYS: 'favoriteRelays', RELAY_SETS: 'relaySets' } 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 | null = null init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { const request = window.indexedDB.open('jumble', 3) request.onerror = (event) => { reject(event) } request.onsuccess = () => { this.db = request.result resolve() } request.onupgradeneeded = () => { const db = request.result 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.MUTE_LIST_EVENTS)) { db.createObjectStore(StoreNames.MUTE_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.RELAY_INFO_EVENTS)) { db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' }) } if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) { db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' }) } if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) { db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' }) } this.db = db } }) setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute } return this.initPromise } async putReplaceableEvent(event: Event): Promise { const storeName = this.getStoreNameByKind(event.kind) if (!storeName) { return Promise.reject('store name not found') } await this.initPromise return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } const transaction = this.db.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) const key = this.getReplaceableEventKey(event) const getRequest = store.get(key) getRequest.onsuccess = () => { const oldValue = getRequest.result as TValue | undefined if (oldValue && oldValue.value.created_at >= event.created_at) { transaction.commit() return resolve(oldValue.value) } const putRequest = store.put(this.formatValue(key, event)) putRequest.onsuccess = () => { transaction.commit() resolve(event) } putRequest.onerror = (event) => { transaction.commit() reject(event) } } getRequest.onerror = (event) => { transaction.commit() reject(event) } }) } async getReplaceableEvent(pubkey: string, kind: number, d?: string): Promise { 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 reject('database not initialized') } const transaction = this.db.transaction(storeName, 'readonly') const store = transaction.objectStore(storeName) const key = d === undefined ? pubkey : `${pubkey}:${d}` const request = store.get(key) request.onsuccess = () => { transaction.commit() resolve((request.result as TValue)?.value) } request.onerror = (event) => { transaction.commit() reject(event) } }) } async getManyReplaceableEvents( pubkeys: readonly string[], kind: number ): Promise<(Event | undefined)[]> { 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 reject('database not initialized') } const transaction = this.db.transaction(storeName, 'readonly') const store = transaction.objectStore(storeName) const events: Event[] = new Array(pubkeys.length).fill(undefined) let count = 0 pubkeys.forEach((pubkey, i) => { const request = store.get(pubkey) request.onsuccess = () => { const event = (request.result as TValue)?.value if (event) { 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 { await this.initPromise return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } 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)?.value) } request.onerror = (event) => { transaction.commit() reject(event) } }) } async putMuteDecryptedTags(id: string, tags: string[][]): Promise { await this.initPromise return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } 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 getAllRelayInfoEvents(): Promise { await this.initPromise return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readonly') const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS) const request = store.getAll() request.onsuccess = () => { transaction.commit() resolve((request.result as TValue[])?.map((item) => item.value)) } request.onerror = (event) => { transaction.commit() reject(event) } }) } async putRelayInfoEvent(event: Event): Promise { await this.initPromise return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } const dValue = event.tags.find(tagNameEquals('d'))?.[1] if (!dValue) { return resolve() } const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readwrite') const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS) const putRequest = store.put(this.formatValue(dValue, event)) putRequest.onsuccess = () => { transaction.commit() resolve() } putRequest.onerror = (event) => { transaction.commit() reject(event) } }) } async iterateProfileEvents(callback: (event: Event) => Promise): Promise { await this.initPromise if (!this.db) { return } return new Promise((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) { callback((cursor.value as TValue).value) cursor.continue() } else { transaction.commit() resolve() } } request.onerror = (event) => { transaction.commit() reject(event) } }) } private getReplaceableEventKey(event: Event): string { if ( [kinds.Metadata, kinds.Contacts].includes(event.kind) || (event.kind >= 10000 && event.kind < 20000) ) { return event.pubkey } const [, d] = event.tags.find(tagNameEquals('d')) ?? [] return `${event.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 kinds.Mutelist: return StoreNames.MUTE_LIST_EVENTS case kinds.Relaysets: return StoreNames.RELAY_SETS case ExtendedKind.FAVORITE_RELAYS: return StoreNames.FAVORITE_RELAYS default: return undefined } } private formatValue(key: string, value: T): TValue { return { key, value, addedAt: Date.now() } } 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.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 ] const transaction = this.db!.transaction( stores.map((store) => store.name), 'readwrite' ) await Promise.allSettled( stores.map(({ name, expirationTimestamp }) => { if (expirationTimestamp < 0) { return Promise.resolve() } return new Promise((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 // 10% chance to delete if (value.addedAt < expirationTimestamp && Math.random() < 0.1) { cursor.delete() } cursor.continue() } else { resolve() } } request.onerror = (event) => { reject(event) } }) }) ) } } const instance = IndexedDbService.getInstance() export default instance