Files
smesh/src/services/indexed-db.service.ts
codytseng 485ca82e30 fix: 🐛
2025-11-09 22:37:07 +08:00

555 lines
17 KiB
TypeScript

import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools'
type TValue<T = any> = {
key: string
value: T | null
addedAt: number
}
const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
FOLLOW_LIST_EVENTS: 'followListEvents',
MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
PIN_LIST_EVENTS: 'pinListEvents',
FAVORITE_RELAYS: 'favoriteRelays',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
}
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 = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 9)
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.BOOKMARK_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BOOKMARK_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.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.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
}
this.db = db
}
})
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
}
return this.initPromise
}
async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
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, 'readwrite')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(pubkey, d)
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue) {
transaction.commit()
return resolve(oldValue.value)
}
const putRequest = store.put(this.formatValue(key, null))
putRequest.onsuccess = () => {
transaction.commit()
resolve(null)
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
}
getRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async putReplaceableEvent(event: Event): Promise<Event> {
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.getReplaceableEventKeyFromEvent(event)
const getRequest = store.get(key)
getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined
if (oldValue?.value && 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 getReplaceableEventByCoordinate(coordinate: string): Promise<Event | undefined | null> {
const [kind, pubkey, ...rest] = coordinate.split(':')
const d = rest.length > 0 ? rest.join(':') : undefined
return this.getReplaceableEvent(pubkey, parseInt(kind), d)
}
async getReplaceableEvent(
pubkey: string,
kind: number,
d?: string
): Promise<Event | undefined | null> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) {
return undefined
}
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 = this.getReplaceableEventKey(pubkey, d)
const request = store.get(key)
request.onsuccess = () => {
transaction.commit()
resolve((request.result as TValue<Event>)?.value)
}
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((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 | 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 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<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 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 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 reject('database not initialized')
}
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 reject('database not initialized')
}
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 reject('database not initialized')
}
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 reject('database not initialized')
}
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)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string {
if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000)
) {
return this.getReplaceableEventKey(event.pubkey)
}
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 kinds.Mutelist:
return StoreNames.MUTE_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 kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
case kinds.Pinlist:
return StoreNames.PIN_LIST_EVENTS
default:
return undefined
}
}
private formatValue<T>(key: string, value: T): TValue<T> {
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 * 30 // 30 day
},
{
name: StoreNames.RELAY_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
},
{
name: StoreNames.FOLLOW_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
},
{
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
},
{
name: StoreNames.RELAY_INFOS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
},
{
name: StoreNames.PIN_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 days
}
]
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<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)
}
})
})
)
}
}
const instance = IndexedDbService.getInstance()
export default instance