/** * 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 } from 'nostr-tools' import type { AuthorPresentationArticle } from '@/types/nostr' import { buildObjectId } from './urlGenerator' export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' 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: Event parsed: unknown // Parsed object (AuthorPresentationArticle, Series, etc.) version: number hidden: boolean createdAt: number cachedAt: number } const DB_PREFIX = 'nostr_objects_' const DB_VERSION = 2 // Incremented to add id, hash, index fields class ObjectCacheService { private dbs: Map = new Map() private async initDB(objectType: ObjectType): Promise { if (this.dbs.has(objectType)) { return this.dbs.get(objectType)! } return new Promise((resolve, reject) => { if (typeof window === 'undefined' || !window.indexedDB) { reject(new Error('IndexedDB is not available')) return } const dbName = `${DB_PREFIX}${objectType}` const request = indexedDB.open(dbName, DB_VERSION) request.onerror = (): void => { reject(new Error(`Failed to open IndexedDB: ${request.error}`)) } request.onsuccess = (): void => { const db = request.result this.dbs.set(objectType, db) resolve(db) } request.onupgradeneeded = (event: IDBVersionChangeEvent): void => { const db = (event.target as IDBOpenDBRequest).result if (!db.objectStoreNames.contains('objects')) { const store = db.createObjectStore('objects', { keyPath: 'id' }) store.createIndex('hash', 'hash', { unique: false }) store.createIndex('hashId', 'hashId', { unique: false }) // Legacy index store.createIndex('version', 'version', { unique: false }) store.createIndex('index', 'index', { unique: false }) store.createIndex('hidden', 'hidden', { unique: false }) store.createIndex('cachedAt', 'cachedAt', { unique: false }) } else { // Migration: add new indexes if they don't exist const {transaction} = (event.target as IDBOpenDBRequest) if (transaction) { const store = transaction.objectStore('objects') if (!store.indexNames.contains('hash')) { store.createIndex('hash', 'hash', { unique: false }) } if (!store.indexNames.contains('index')) { store.createIndex('index', 'index', { unique: false }) } } } } }) } /** * Count objects with the same hash to determine the index */ private async countObjectsWithHash(objectType: ObjectType, hash: string): Promise { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readonly') const store = transaction.objectStore('objects') const index = store.index('hash') return new Promise((resolve, reject) => { const request = index.count(IDBKeyRange.only(hash)) request.onsuccess = (): void => { resolve(request.result) } request.onerror = (): void => { reject(request.error) } }) } catch (error) { console.error(`Error counting objects with hash ${hash}:`, error) return 0 } } /** * Store an object in cache * Verifies and sets the index before insertion */ async set( objectType: ObjectType, hash: string, event: Event, parsed: unknown, version: number, hidden: boolean, index?: number ): Promise { try { const db = await this.initDB(objectType) // If index is not provided, calculate it by counting objects with the same hash let finalIndex = index if (finalIndex === undefined) { const count = await this.countObjectsWithHash(objectType, hash) finalIndex = count } const id = buildObjectId(hash, finalIndex, version) const transaction = db.transaction(['objects'], 'readwrite') const store = transaction.objectStore('objects') const cached: CachedObject = { id, hash, hashId: hash, // Legacy field for backward compatibility index: finalIndex, event, parsed, version, hidden, createdAt: event.created_at, cachedAt: Date.now(), } await new Promise((resolve, reject) => { const request = store.put(cached) request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject(request.error) } }) } catch (error) { console.error(`Error caching ${objectType} object:`, error) } } /** * 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: 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 (error) { console.error(`Error retrieving ${objectType} object from cache:`, error) return null } } /** * Get an object from cache by ID */ async getById(objectType: ObjectType, id: string): 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.get(id) request.onsuccess = (): void => { const obj = request.result as CachedObject | undefined if (obj && !obj.hidden) { resolve(obj.parsed) } else { resolve(null) } } request.onerror = (): void => { reject(request.error) } }) } catch (error) { console.error(`Error retrieving ${objectType} object by ID from cache:`, error) 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: 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 (error) { console.error('Error retrieving author from cache by pubkey:', error) return null } } /** * Clear cache for an object type */ async clear(objectType: ObjectType): Promise { try { const db = await this.initDB(objectType) const transaction = db.transaction(['objects'], 'readwrite') const store = transaction.objectStore('objects') await new Promise((resolve, reject) => { const request = store.clear() request.onsuccess = (): void => { resolve() } request.onerror = (): void => { reject(request.error) } }) } catch (error) { console.error(`Error clearing ${objectType} cache:`, error) } } } export const objectCache = new ObjectCacheService()