338 lines
11 KiB
TypeScript
338 lines
11 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 as NostrEvent } from 'nostr-tools'
|
|
import type { AuthorPresentationArticle } from '@/types/nostr'
|
|
import { buildObjectId } from './urlGenerator'
|
|
|
|
export type ObjectType = 'author' | 'series' | 'publication' | 'review' | 'purchase' | 'sponsoring' | 'review_tip' | 'payment_note'
|
|
|
|
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: NostrEvent
|
|
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 = window.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<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 = (): void => {
|
|
resolve(request.result)
|
|
}
|
|
request.onerror = (): void => {
|
|
reject(request.error)
|
|
}
|
|
})
|
|
} 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
|
|
*/
|
|
async set(
|
|
objectType: ObjectType,
|
|
hash: string,
|
|
event: NostrEvent,
|
|
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 = (): void => {
|
|
resolve()
|
|
}
|
|
request.onerror = (): void => {
|
|
reject(request.error)
|
|
}
|
|
})
|
|
} catch (cacheError) {
|
|
console.error(`Error caching ${objectType} object:`, cacheError)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<unknown | null>((resolve, reject) => {
|
|
const request = hashIndex.openCursor(IDBKeyRange.only(hash))
|
|
const objects: CachedObject[] = []
|
|
|
|
request.onsuccess = (event: globalThis.Event): void => {
|
|
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 = (): 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<unknown | null> {
|
|
try {
|
|
const db = await this.initDB(objectType)
|
|
const transaction = db.transaction(['objects'], 'readonly')
|
|
const store = transaction.objectStore('objects')
|
|
|
|
return new Promise<unknown | null>((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 (retrieveByIdError) {
|
|
console.error(`Error retrieving ${objectType} object by ID from cache:`, retrieveByIdError)
|
|
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: globalThis.Event): void => {
|
|
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 = (): 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<unknown[]> {
|
|
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<IDBCursorWithValue>).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<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 = (): void => {
|
|
resolve()
|
|
}
|
|
request.onerror = (): void => {
|
|
reject(request.error)
|
|
}
|
|
})
|
|
} catch (clearError) {
|
|
console.error(`Error clearing ${objectType} cache:`, clearError)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const objectCache = new ObjectCacheService()
|