/** * IndexedDB cache for Nostr objects (authors, series, publications, reviews) * Objects are indexed by their hash ID for fast retrieval * One database per object type */ import type { Event as NostrEvent } from 'nostr-tools' import type { AuthorPresentationArticle } from '@/types/nostr' import { buildObjectId } from './urlGenerator' import { createIndexedDBHelper, type IndexedDBHelper } from './helpers/indexedDBHelper' export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' | 'payment_note' export interface CachedObject { id: string // Format: __ hash: string // SHA-256 hash of the object hashId: string // Legacy field for backward compatibility index: number // Index for duplicates event: NostrEvent parsed: unknown // Parsed object (AuthorPresentationArticle, Series, etc.) version: number hidden: boolean createdAt: number cachedAt: number published: false | string[] // false if not published, array of relay URLs that successfully published } const DB_PREFIX = 'nostr_objects_' const DB_VERSION = 3 // Incremented to add published field const STORE_NAME = 'objects' class ObjectCacheService { private readonly dbHelpers: Map = new Map() private getDBHelper(objectType: ObjectType): IndexedDBHelper { if (!this.dbHelpers.has(objectType)) { const helper = createIndexedDBHelper({ dbName: `${DB_PREFIX}${objectType}`, version: DB_VERSION, storeName: STORE_NAME, keyPath: 'id', indexes: [ { name: 'hash', keyPath: 'hash', unique: false }, { name: 'hashId', keyPath: 'hashId', unique: false }, // Legacy index { name: 'version', keyPath: 'version', unique: false }, { name: 'index', keyPath: 'index', unique: false }, { name: 'hidden', keyPath: 'hidden', unique: false }, { name: 'cachedAt', keyPath: 'cachedAt', unique: false }, { name: 'published', keyPath: 'published', unique: false }, ], onUpgrade: (_db: IDBDatabase, event: IDBVersionChangeEvent): void => { // Migration: add new indexes if they don't exist const target = event.target as IDBOpenDBRequest const { transaction } = target if (transaction) { const store = transaction.objectStore(STORE_NAME) if (!store.indexNames.contains('hash')) { store.createIndex('hash', 'hash', { unique: false }) } if (!store.indexNames.contains('index')) { store.createIndex('index', 'index', { unique: false }) } if (!store.indexNames.contains('published')) { store.createIndex('published', 'published', { unique: false }) } } }, }) this.dbHelpers.set(objectType, helper) } const helper = this.dbHelpers.get(objectType) if (!helper) { throw new Error(`Database helper not found for ${objectType}`) } return helper } /** * Initialize database and return IDBDatabase instance * Used for direct database access when needed */ private async initDB(objectType: ObjectType): Promise { const helper = this.getDBHelper(objectType) return helper.init() } /** * Count objects with the same hash to determine the index */ private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise { try { const helper = this.getDBHelper(objectType) return await helper.countByIndex('hash', IDBKeyRange.only(hash)) } catch (countError) { console.error(`Error counting objects with hash ${hash}:`, countError) return 0 } } /** * Store an object in cache * Verifies and sets the index before insertion * @param published - false if not published, or array of relay URLs that successfully published */ async set(params: { objectType: ObjectType hash: string event: NostrEvent parsed: unknown version: number hidden: boolean index?: number published?: false | string[] }): Promise { try { const helper = this.getDBHelper(params.objectType) // If index is not provided, calculate it by counting objects with the same hash let finalIndex = params.index if (finalIndex === undefined) { const count = await this.countObjectsWithHash(params.objectType, params.hash) finalIndex = count } const id = buildObjectId(params.hash, finalIndex, params.version) // Check if object already exists to preserve published status if updating const existing = await helper.get(id).catch(() => null) // If updating and published is not provided, preserve existing published status const published = params.published ?? false const finalPublished = existing && published === false ? existing.published : published const cached: CachedObject = { id, hash: params.hash, hashId: params.hash, // Legacy field for backward compatibility index: finalIndex, event: params.event, parsed: params.parsed, version: params.version, hidden: params.hidden, createdAt: params.event.created_at, cachedAt: Date.now(), published: finalPublished, } await helper.put(cached) } catch (cacheError) { console.error(`Error caching ${params.objectType} object:`, cacheError) } } /** * Update published status for an object */ async updatePublished( objectType: ObjectType, id: string, published: false | string[] ): Promise { try { const helper = this.getDBHelper(objectType) const existing = await helper.get(id) if (!existing) { console.warn(`Object ${id} not found in cache, cannot update published status`) return } const oldPublished = existing.published const updated: CachedObject = { ...existing, published, } await helper.put(updated) // Notify about published status change (false -> array of relays) if (oldPublished === false && Array.isArray(published) && published.length > 0) { const eventId = id.split(':')[1] ?? id void import('./notificationDetector') .then(({ notificationDetector }) => { void notificationDetector.checkObjectChange({ objectType, objectId: id, eventId, oldPublished, newPublished: published, }) }) .catch((error) => { console.error('Failed to notify published status change:', error) }) } } catch (updateError) { console.error(`Error updating published status for ${objectType} object:`, updateError) } } /** * Get all objects that are not published (published === false) */ async getUnpublished(objectType: ObjectType): Promise> { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') return new Promise((resolve, reject) => { const request = store.openCursor() const unpublished: Array<{ id: string; event: NostrEvent }> = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result if (cursor) { const obj = cursor.value as CachedObject // Check if published is false (not an array) if (obj.published === false && !obj.hidden) { unpublished.push({ id: obj.id, event: obj.event }) } cursor.continue() } else { resolve(unpublished) } } request.onerror = (): void => { reject(request.error) } }) } catch (getUnpublishedError) { console.error(`Error retrieving unpublished ${objectType} objects:`, getUnpublishedError) return [] } } /** * Get an object from cache by hash * Returns the latest non-hidden version */ async get(objectType: ObjectType, hash: string): Promise { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') const hashIndex = store.index('hash') return new Promise((resolve, reject) => { const request = hashIndex.openCursor(IDBKeyRange.only(hash)) const objects: CachedObject[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result if (cursor) { const obj = cursor.value as CachedObject if (obj?.hash === hash && !obj.hidden) { objects.push(obj) } cursor.continue() } else { // Sort by version descending and return the latest if (objects.length > 0) { objects.sort((a, b) => b.version - a.version) resolve(objects[0]?.parsed ?? null) } else { resolve(null) } } } request.onerror = (): void => { reject(request.error) } }) } catch (retrieveError) { console.error(`Error retrieving ${objectType} object from cache:`, retrieveError) return null } } /** * Get an object from cache by ID */ async getById(objectType: ObjectType, id: string): Promise { try { const helper = this.getDBHelper(objectType) const obj = await helper.get(id) if (obj && !obj.hidden) { return obj.parsed } return null } catch (retrieveByIdError) { console.error(`Error retrieving ${objectType} object by ID from cache:`, retrieveByIdError) return null } } /** * Get the raw event from cache by ID */ async getEventById(objectType: ObjectType, id: string): Promise { try { const helper = this.getDBHelper(objectType) const obj = await helper.get(id) if (obj && !obj.hidden) { return obj.event } return null } catch (retrieveByIdError) { console.error(`Error retrieving ${objectType} event by ID from cache:`, retrieveByIdError) return null } } /** * Get an author presentation by pubkey (searches all cached authors) */ async getAuthorByPubkey(pubkey: string): Promise { try { const db = await this.initDB('author') const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') return new Promise((resolve, reject) => { const request = store.openCursor() const objects: CachedObject[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result if (cursor) { const obj = cursor.value as CachedObject if (obj?.event.pubkey === pubkey && !obj.hidden) { objects.push(obj) } cursor.continue() } else { // Sort by version descending and return the latest if (objects.length > 0) { objects.sort((a, b) => b.version - a.version) resolve((objects[0]?.parsed ?? null) as AuthorPresentationArticle | null) } else { resolve(null) } } } request.onerror = (): void => { reject(request.error) } }) } catch (authorRetrieveError) { console.error('Error retrieving author from cache by pubkey:', authorRetrieveError) return null } } /** * Get all objects of a type from cache (non-hidden only) */ async getAll(objectType: ObjectType): Promise { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') return new Promise((resolve, reject) => { const request = store.openCursor() const objects: unknown[] = [] request.onsuccess = (event: globalThis.Event): void => { const cursor = (event.target as IDBRequest).result if (cursor) { const obj = cursor.value as CachedObject if (!obj.hidden) { objects.push(obj.parsed) } cursor.continue() } else { resolve(objects) } } request.onerror = (): void => { reject(request.error) } }) } catch (getAllError) { console.error(`Error retrieving all ${objectType} objects from cache:`, getAllError) return [] } } /** * Clear cache for an object type */ async clear(objectType: ObjectType): Promise { try { const helper = this.getDBHelper(objectType) await helper.clear() } catch (clearError) { console.error(`Error clearing ${objectType} cache:`, clearError) } } } export const objectCache = new ObjectCacheService()