story-research-zapwall/lib/objectCache.ts
2026-01-06 14:41:02 +01:00

284 lines
9.0 KiB
TypeScript

/**
* 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>_<index>_<version>
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<ObjectType, IDBDatabase> = new Map()
private async initDB(objectType: ObjectType): Promise<IDBDatabase> {
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) => {
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<number> {
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 = () => resolve(request.result)
request.onerror = () => 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<void> {
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<void>((resolve, reject) => {
const request = store.put(cached)
request.onsuccess = () => resolve()
request.onerror = () => 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<unknown | null> {
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) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).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 = () => 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<unknown | null> {
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 = () => {
const obj = request.result as CachedObject | undefined
if (obj && !obj.hidden) {
resolve(obj.parsed)
} else {
resolve(null)
}
}
request.onerror = () => 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<AuthorPresentationArticle | null> {
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) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).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 = () => 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<void> {
try {
const db = await this.initDB(objectType)
const transaction = db.transaction(['objects'], 'readwrite')
const store = transaction.objectStore('objects')
await new Promise<void>((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error(`Error clearing ${objectType} cache:`, error)
}
}
}
export const objectCache = new ObjectCacheService()