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.
1437 lines
48 KiB
1437 lines
48 KiB
import { ExtendedKind } from '@/constants' |
|
import { tagNameEquals } from '@/lib/tag' |
|
import { TRelayInfo } from '@/types' |
|
import { Event, kinds } from 'nostr-tools' |
|
import { isReplaceableEvent } 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 |
|
} |
|
|
|
const StoreNames = { |
|
PROFILE_EVENTS: 'profileEvents', |
|
RELAY_LIST_EVENTS: 'relayListEvents', |
|
FOLLOW_LIST_EVENTS: 'followListEvents', |
|
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' |
|
} |
|
|
|
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', 17) |
|
|
|
request.onerror = (event) => { |
|
reject(event) |
|
} |
|
|
|
request.onsuccess = () => { |
|
this.db = request.result |
|
resolve() |
|
} |
|
|
|
request.onupgradeneeded = (event) => { |
|
const db = (event.target as IDBOpenDBRequest).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.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.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 }) |
|
} |
|
} |
|
}) |
|
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> { |
|
// 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?.substring(0, 8), |
|
pubkey: cleanEvent.pubkey?.substring(0, 8), |
|
created_at: cleanEvent.created_at, |
|
fullEventId: cleanEvent.id |
|
}) |
|
|
|
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) { |
|
logger.error('[IndexedDB] Database not initialized', { storeName, kind: cleanEvent.kind }) |
|
return reject('database not initialized') |
|
} |
|
|
|
// 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?.substring(0, 8), |
|
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?.substring(0, 8) }) |
|
|
|
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?.substring(0, 8), |
|
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 (newer or same timestamp)', { |
|
storeName, |
|
key, |
|
existingEventId: oldValue.value.id?.substring(0, 8) |
|
}) |
|
transaction.commit() |
|
return resolve(oldValue.value) |
|
} |
|
|
|
logger.debug('[IndexedDB] Putting new event', { |
|
storeName, |
|
key, |
|
eventId: cleanEvent.id?.substring(0, 8), |
|
fullEventId: cleanEvent.id, |
|
content: cleanEvent.content?.substring(0, 50) |
|
}) |
|
const putRequest = store.put(this.formatValue(key, cleanEvent)) |
|
putRequest.onsuccess = () => { |
|
logger.debug('[IndexedDB] Successfully put event', { |
|
storeName, |
|
key, |
|
eventId: cleanEvent.id?.substring(0, 8), |
|
content: cleanEvent.content?.substring(0, 50) |
|
}) |
|
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 reject('database not initialized') |
|
} |
|
// 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 = () => { |
|
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 { |
|
// Events that are replaceable by pubkey only (no d-tag) |
|
// RSS_FEED_LIST (10895) is in the 10000-20000 range, so it's automatically handled |
|
if ( |
|
[kinds.Metadata, kinds.Contacts].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 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.PUBLICATION: |
|
case ExtendedKind.PUBLICATION_CONTENT: |
|
case ExtendedKind.WIKI_ARTICLE: |
|
case kinds.LongFormArticle: |
|
return StoreNames.PUBLICATION_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 reject('database not initialized') |
|
} |
|
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) |
|
} |
|
}) |
|
} |
|
|
|
private 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 reject('database not initialized') |
|
} |
|
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 reject('database not initialized') |
|
} |
|
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) |
|
} |
|
}) |
|
} |
|
|
|
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(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) |
|
} |
|
}) |
|
} |
|
|
|
async deleteStoreItem(storeName: string, key: string): Promise<void> { |
|
await this.initPromise |
|
if (!this.db || !this.db.objectStoreNames.contains(storeName)) { |
|
return Promise.reject('Store not found') |
|
} |
|
|
|
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 Promise.reject('Store not found') |
|
} |
|
|
|
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 Promise.reject('Store not found') |
|
} |
|
|
|
// 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.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 |
|
// 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 >= 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 = 14 |
|
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 |
|
// Create any missing stores |
|
Object.values(StoreNames).forEach(storeName => { |
|
if (!db.objectStoreNames.contains(storeName)) { |
|
db.createObjectStore(storeName, { keyPath: 'key' }) |
|
} |
|
}) |
|
} |
|
}) |
|
} |
|
|
|
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 |
|
}, |
|
{ |
|
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 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) |
|
} |
|
}) |
|
}) |
|
) |
|
} |
|
|
|
/** |
|
* 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) |
|
} |
|
}) |
|
} |
|
} |
|
|
|
const instance = IndexedDbService.getInstance() |
|
export default instance
|
|
|