story-research-zapwall/lib/objectCache.ts
2026-01-09 09:22:30 +01:00

408 lines
13 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'
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>_<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
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<ObjectType, IndexedDBHelper> = 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<IDBDatabase> {
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<number> {
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<void> {
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<CachedObject>(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<void> {
try {
const helper = this.getDBHelper(objectType)
const existing = await helper.get<CachedObject>(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<Array<{ id: string; event: NostrEvent }>> {
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<IDBCursorWithValue>).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<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) {
resolve(null)
return
}
objects.sort((a, b) => b.version - a.version)
resolve(objects[0]?.parsed ?? 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 helper = this.getDBHelper(objectType)
const obj = await helper.get<CachedObject>(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<NostrEvent | null> {
try {
const helper = this.getDBHelper(objectType)
const obj = await helper.get<CachedObject>(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<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) {
resolve(null)
return
}
objects.sort((a, b) => b.version - a.version)
resolve((objects[0]?.parsed ?? null) as AuthorPresentationArticle | 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 helper = this.getDBHelper(objectType)
await helper.clear()
} catch (clearError) {
console.error(`Error clearing ${objectType} cache:`, clearError)
}
}
}
export const objectCache = new ObjectCacheService()