From 4a94245a226f6c4822e644b5834b88db0d2d8297 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 23:51:13 +0100 Subject: [PATCH] bug-fixes Nostr-Signature: 5726811907af73d3b478f3938cdc6421200040542cb1a586b3497c56a24c33cb 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 3833d05ba5a34cad78caacbc8382fcd7a85c60b56dd3b18f9a5c68c890d7a611fa6b885ef02be465f541629b0afaeec0e9d57d3b00db332c5c8ae42fd72fc83d --- nostr/commit-signatures.jsonl | 1 + src/lib/services/nostr/event-cache.ts | 28 +- src/lib/services/nostr/maintainer-service.ts | 30 +- .../services/nostr/persistent-event-cache.ts | 1564 ++++------------- src/lib/utils/repo-privacy.ts | 13 +- 5 files changed, 374 insertions(+), 1262 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index a0d34b1..0bd2115 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -33,3 +33,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771623058,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"29ded44ac53cd8c924310728e3bac34070a2daa62418f3d79a386fdc84d93afe","sig":"a60a145c2f234e4bad593423ebc2a39ab12ec7a78c343b46348e6dbeac809be9b61e42219c9d569cc0902788bb59af3c36ffd0de1aa56d9d52611575d2ac797f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771624450,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and doc updates"]],"content":"Signed commit: bug-fixes and doc updates","id":"d089915a2d9a9d46ba25d2d3c1cb4608a2b658ecc4260f17e73efa4ccc63a28d","sig":"3d447f05a55704d45ed843b7cc5fa16e49f3da0e452b1523392aefbb7a2ae3e79400a763df5705db8e38abc89e9a89480ab2c529890b531b171c4e980520d9b8"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771625218,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"1cc16c438c4b1cc5170a90a7e4b540afa24d0c698538dc332fa4753437b21dfe","sig":"3caddc0d00e29995f4920bd4035ea61b4fd2d17e366bdd18889ede38a5ea960cd9f83a9f524b777b8de7bf7e4cdf59ab55c8fb4e46932655985ba8c6f3d7e7da"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771626015,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f5bde3d9199d8cbacca481959663f1e14c43e143ef2b5686502559408e1c526b","sig":"3ed47cd283746d290d8609cbfdefbcee31a19d8e43e1a6ebf5a2829904000d79b83d3235296af4b5f7b555051214fbf2fa5c7a6d7986dca853112bb4e122a6d5"} diff --git a/src/lib/services/nostr/event-cache.ts b/src/lib/services/nostr/event-cache.ts index 60b3dd7..e8d6c7f 100644 --- a/src/lib/services/nostr/event-cache.ts +++ b/src/lib/services/nostr/event-cache.ts @@ -9,12 +9,32 @@ import { KIND } from '../../types/nostr.js'; import type { Logger } from '../../types/logger.js'; let loggerCache: Logger | null = null; +let loggerPromise: Promise | null = null; + const getLogger = async (): Promise => { - if (!loggerCache) { - const loggerModule = await import('../logger.js'); - loggerCache = loggerModule.default; + if (loggerCache) { + return loggerCache; } - return loggerCache; + + if (!loggerPromise) { + loggerPromise = import('../logger.js').then(module => { + loggerCache = module.default; + return loggerCache!; + }).catch(err => { + // Fallback to console logger if import fails + loggerCache = { + info: (...args: unknown[]) => console.log('[INFO]', ...args), + error: (...args: unknown[]) => console.error('[ERROR]', ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', ...args), + debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args), + trace: (...args: unknown[]) => console.trace('[TRACE]', ...args), + fatal: (...args: unknown[]) => console.error('[FATAL]', ...args) + } as Logger; + return loggerCache!; + }); + } + + return loggerPromise; }; interface CacheEntry { diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 9f851f3..024d251 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -8,9 +8,35 @@ import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; import { nip19 } from 'nostr-tools'; import { OwnershipTransferService } from './ownership-transfer-service.js'; -import logger from '../logger.js'; +import type { Logger } from '../../types/logger.js'; import { isPrivateRepo as checkIsPrivateRepo } from '../../utils/repo-privacy.js'; +// Lazy logger import to avoid initialization order issues +let loggerCache: Logger | null = null; +let loggerPromise: Promise | null = null; + +const getLogger = async (): Promise => { + if (loggerCache) return loggerCache; + if (!loggerPromise) { + loggerPromise = import('../logger.js').then(m => { + loggerCache = m.default; + return loggerCache!; + }).catch(() => { + // Fallback console logger + loggerCache = { + info: (...args: unknown[]) => console.log('[INFO]', ...args), + error: (...args: unknown[]) => console.error('[ERROR]', ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', ...args), + debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args), + trace: (...args: unknown[]) => console.trace('[TRACE]', ...args), + fatal: (...args: unknown[]) => console.error('[FATAL]', ...args) + } as Logger; + return loggerCache!; + }); + } + return loggerPromise; +}; + export interface RepoPrivacyInfo { isPrivate: boolean; owner: string; @@ -115,6 +141,7 @@ export class MaintainerService { this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } catch (error) { + const logger = await getLogger(); logger.error({ error, repoOwnerPubkey, repoId }, 'Error fetching maintainers'); // Fallback: only owner is maintainer, repo is public by default const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false }; @@ -138,6 +165,7 @@ export class MaintainerService { */ async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise { const { isPrivate, maintainers, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); + const logger = await getLogger(); logger.debug({ isPrivate, diff --git a/src/lib/services/nostr/persistent-event-cache.ts b/src/lib/services/nostr/persistent-event-cache.ts index f1ee885..c055898 100644 --- a/src/lib/services/nostr/persistent-event-cache.ts +++ b/src/lib/services/nostr/persistent-event-cache.ts @@ -1,60 +1,52 @@ /** * Persistent event cache using IndexedDB for client-side storage - * Provides offline access and reduces relay load - * - * Strategy: - * - Client-side only (IndexedDB) - events are immutable and user-specific - * - Check cache first, return immediately if available - * - Fetch from relays in background and merge results - * - Never delete valid events, only append/integrate new ones - * - Replaceable events (kind 0, 3, 10002) use latest version per pubkey + * Simplified, efficient implementation with lazy logger import */ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import { KIND } from '../../types/nostr.js'; import type { NostrClient } from './nostr-client.js'; -// Import logger normally - the singleton is created lazily in the class constructor -import logger from '../logger.js'; +import type { Logger } from '../../types/logger.js'; + +// Lazy logger import to avoid initialization order issues +let loggerCache: Logger | null = null; +let loggerPromise: Promise | null = null; + +const getLogger = async (): Promise => { + if (loggerCache) return loggerCache; + if (!loggerPromise) { + loggerPromise = import('../logger.js').then(m => { + loggerCache = m.default; + return loggerCache!; + }).catch(() => { + // Fallback console logger + loggerCache = { + info: (...args: unknown[]) => console.log('[INFO]', ...args), + error: (...args: unknown[]) => console.error('[ERROR]', ...args), + warn: (...args: unknown[]) => console.warn('[WARN]', ...args), + debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args), + trace: (...args: unknown[]) => console.trace('[TRACE]', ...args), + fatal: (...args: unknown[]) => console.error('[FATAL]', ...args) + } as Logger; + return loggerCache!; + }); + } + return loggerPromise; +}; const DB_NAME = 'gitrepublic_events'; const DB_VERSION = 1; const STORE_EVENTS = 'events'; const STORE_FILTERS = 'filters'; -const STORE_PROFILES = 'profiles'; // Optimized storage for kind 0 events +const STORE_PROFILES = 'profiles'; -// Replaceable event kinds (only latest per pubkey matters)s const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List - -/** - * Check if an event is a parameterized replaceable event (NIP-33) - * Parameterized replaceable events have kind >= 10000 && kind < 20000 and a 'd' tag - */ -function isParameterizedReplaceable(event: NostrEvent): boolean { - return event.kind >= 10000 && event.kind < 20000 && - event.tags.some(t => t[0] === 'd' && t[1]); -} - -/** - * Get the deduplication key for an event - * For replaceable events: kind:pubkey - * For parameterized replaceable events: kind:pubkey:d-tag - * For regular events: event.id - */ -function getDeduplicationKey(event: NostrEvent): string { - if (REPLACEABLE_KINDS.includes(event.kind)) { - return `${event.kind}:${event.pubkey}`; - } - if (isParameterizedReplaceable(event)) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; - return `${event.kind}:${event.pubkey}:${dTag}`; - } - return event.id; -} +const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes +const PROFILE_TTL = 30 * 60 * 1000; // 30 minutes interface CachedEvent { event: NostrEvent; cachedAt: number; - filterKey?: string; // Which filter(s) this event matches } interface FilterCacheEntry { @@ -64,1403 +56,465 @@ interface FilterCacheEntry { ttl: number; } -/** - * Generate a deterministic cache key from a filter - */ -function generateFilterKey(filter: NostrFilter): string { - const sortedFilter = Object.keys(filter) - .sort() - .reduce((acc, key) => { - const value = filter[key as keyof NostrFilter]; - if (value !== undefined) { - if (Array.isArray(value)) { - acc[key] = [...value].sort(); - } else { - acc[key] = value; - } +function generateFilterKey(filters: NostrFilter[]): string { + return JSON.stringify(filters.map(f => { + const sorted = Object.keys(f).sort().reduce((acc, k) => { + const v = f[k as keyof NostrFilter]; + if (v !== undefined) { + acc[k] = Array.isArray(v) ? [...v].sort() : v; } return acc; }, {} as Record); - - return JSON.stringify(sortedFilter); -} - -/** - * Generate cache key for multiple filters - */ -function generateMultiFilterKey(filters: NostrFilter[]): string { - const keys = filters.map(f => generateFilterKey(f)).sort(); - return keys.join('|'); -} - -/** - * Check if an event matches a filter - */ -function eventMatchesFilter(event: NostrEvent, filter: NostrFilter): boolean { - // Check kind - if (filter.kinds && !filter.kinds.includes(event.kind)) { - return false; - } - - // Check authors - if (filter.authors && filter.authors.length > 0) { - if (!filter.authors.includes(event.pubkey)) { - return false; - } - } - - // Check IDs - if (filter.ids && filter.ids.length > 0) { - if (!filter.ids.includes(event.id)) { - return false; - } - } - - // Check #d tag (for parameterized replaceable events) - if (filter['#d'] && filter['#d'].length > 0) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1]; - if (!dTag || !filter['#d'].includes(dTag)) { - return false; - } - } - - // Check #a tag (for parameterized replaceable events) - if (filter['#a'] && filter['#a'].length > 0) { - const aTag = event.tags.find(t => t[0] === 'a')?.[1]; - if (!aTag || !filter['#a'].includes(aTag)) { - return false; - } - } - - // Check #e tag - if (filter['#e'] && filter['#e'].length > 0) { - const eTags = event.tags.filter(t => t[0] === 'e').map(t => t[1]); - if (!eTags.some(e => filter['#e']!.includes(e))) { - return false; - } - } - - // Check #p tag - if (filter['#p'] && filter['#p'].length > 0) { - const pTags = event.tags.filter(t => t[0] === 'p').map(t => t[1]); - if (!pTags.some(p => filter['#p']!.includes(p))) { - return false; - } - } - - // Check created_at range - if (filter.since && event.created_at < filter.since) { - return false; - } - if (filter.until && event.created_at > filter.until) { - return false; - } - - return true; -} - -/** - * Check if an event matches any of the filters - */ -function eventMatchesAnyFilter(event: NostrEvent, filters: NostrFilter[]): boolean { - return filters.some(filter => eventMatchesFilter(event, filter)); -} - -interface InMemoryCacheEntry { - events: NostrEvent[]; - timestamp: number; - ttl: number; + return sorted; + }).sort()); } export class PersistentEventCache { private db: IDBDatabase | null = null; private initPromise: Promise | null = null; - private defaultTTL: number = 5 * 60 * 1000; // 5 minutes - private profileTTL: number = 30 * 60 * 1000; // 30 minutes for profiles - private maxCacheAge: number = 7 * 24 * 60 * 60 * 1000; // 7 days max age - private writeQueue: Array<() => Promise> = []; - private isProcessingQueue: boolean = false; - private queueProcessingPromise: Promise | null = null; - - // In-memory read-through cache for fast synchronous access - // This eliminates the need for a separate in-memory cache - private memoryCache: Map = new Map(); - private maxMemoryCacheSize: number = 1000; // Limit memory cache size + private memoryCache: Map = new Map(); constructor() { - this.init(); + // Lazy init - don't call logger here + if (typeof window !== 'undefined' && window.indexedDB) { + this.init(); + } } - /** - * Initialize IndexedDB - */ private async init(): Promise { - if (this.initPromise) { - return this.initPromise; - } - - if (typeof window === 'undefined' || !window.indexedDB) { - logger.debug('IndexedDB not available, using in-memory cache only'); - return; - } - - this.initPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => { - logger.error('Failed to open IndexedDB'); - reject(new Error('Failed to open IndexedDB')); - }; - - request.onsuccess = () => { - this.db = request.result; - resolve(); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Events store - stores all events by ID - if (!db.objectStoreNames.contains(STORE_EVENTS)) { - const eventStore = db.createObjectStore(STORE_EVENTS, { keyPath: 'id' }); - eventStore.createIndex('pubkey', 'event.pubkey', { unique: false }); - eventStore.createIndex('kind', 'event.kind', { unique: false }); - eventStore.createIndex('created_at', 'event.created_at', { unique: false }); - eventStore.createIndex('cachedAt', 'cachedAt', { unique: false }); - } - - // Filter cache store - maps filter keys to event IDs - if (!db.objectStoreNames.contains(STORE_FILTERS)) { - const filterStore = db.createObjectStore(STORE_FILTERS, { keyPath: 'filterKey' }); - filterStore.createIndex('cachedAt', 'cachedAt', { unique: false }); - } - - // Profiles store - optimized for kind 0 events (latest per pubkey) - if (!db.objectStoreNames.contains(STORE_PROFILES)) { - db.createObjectStore(STORE_PROFILES, { keyPath: 'pubkey' }); - } - }; - }); + if (this.initPromise) return this.initPromise; + if (typeof window === 'undefined' || !window.indexedDB) return; + + this.initPromise = (async () => { + const logger = await getLogger(); + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => { + logger.error('Failed to open IndexedDB'); + reject(new Error('Failed to open IndexedDB')); + }; + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_EVENTS)) { + const store = db.createObjectStore(STORE_EVENTS, { keyPath: 'id' }); + store.createIndex('pubkey', 'event.pubkey', { unique: false }); + store.createIndex('kind', 'event.kind', { unique: false }); + } + if (!db.objectStoreNames.contains(STORE_FILTERS)) { + db.createObjectStore(STORE_FILTERS, { keyPath: 'filterKey' }); + } + if (!db.objectStoreNames.contains(STORE_PROFILES)) { + db.createObjectStore(STORE_PROFILES, { keyPath: 'pubkey' }); + } + }; + }); + })(); return this.initPromise; } - /** - * Get events from cache that match the filters - * Uses in-memory read-through cache for fast synchronous access - */ - async get(filters: NostrFilter[]): Promise { - const filterKey = generateMultiFilterKey(filters); - const now = Date.now(); - - // 1. Check in-memory cache first (synchronous, fast) - const memoryEntry = this.memoryCache.get(filterKey); - if (memoryEntry) { - const age = now - memoryEntry.timestamp; - if (age < memoryEntry.ttl) { - // Cache hit - return immediately - return memoryEntry.events; - } - // Expired in memory, but might still be in IndexedDB - continue to check - } - - // 2. Check IndexedDB (async, slower but persistent) - await this.init(); - - if (!this.db) { + getSync(filters: NostrFilter[]): NostrEvent[] | null { + const filterKey = generateFilterKey(filters); + const entry = this.memoryCache.get(filterKey); + if (!entry) return null; + if (Date.now() - entry.timestamp > entry.ttl) { + this.memoryCache.delete(filterKey); return null; } + return entry.events; + } + + async get(filters: NostrFilter[]): Promise { + // Check memory cache first + const memoryCached = this.getSync(filters); + if (memoryCached) return memoryCached; + + await this.init(); + if (!this.db) return null; try { - // Check filter cache first - const filterEntry = await this.getFilterEntry(filterKey); - if (!filterEntry) { - return null; - } + const filterKey = generateFilterKey(filters); + const filterStore = this.db.transaction([STORE_FILTERS], 'readonly').objectStore(STORE_FILTERS); + const filterEntry = await new Promise((resolve) => { + const req = filterStore.get(filterKey); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); + }); - // Check if filter cache is expired - if (now - filterEntry.cachedAt > filterEntry.ttl) { - // Expired, but we can still return events if they exist - // Don't delete, just mark as stale - } + if (!filterEntry) return null; + if (Date.now() - filterEntry.cachedAt > filterEntry.ttl) return null; - // Get events from events store - const events: NostrEvent[] = []; const eventStore = this.db.transaction([STORE_EVENTS], 'readonly').objectStore(STORE_EVENTS); - + const events: NostrEvent[] = []; + for (const eventId of filterEntry.eventIds) { - const request = eventStore.get(eventId); - const cached = await new Promise((resolve) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => resolve(undefined); + const cached = await new Promise((resolve) => { + const req = eventStore.get(eventId); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); }); - - if (cached) { - // Check if event is too old (beyond max cache age) - if (now - cached.cachedAt < this.maxCacheAge) { - // Verify event still matches filters (in case filters changed) - if (eventMatchesAnyFilter(cached.event, filters)) { - events.push(cached.event); - } - } - } + if (cached) events.push(cached.event); } - // For replaceable and parameterized replaceable events, ensure we only return the latest per deduplication key - const deduplicatedEvents = new Map(); // deduplication key -> latest event - - for (const event of events) { - const key = getDeduplicationKey(event); - const existing = deduplicatedEvents.get(key); - if (!existing || event.created_at > existing.created_at) { - deduplicatedEvents.set(key, event); - } - } - - const result = Array.from(deduplicatedEvents.values()); - - // Sort by created_at descending - result.sort((a, b) => b.created_at - a.created_at); - - // Update in-memory cache with result from IndexedDB - if (result.length > 0) { - const ttl = filterEntry.ttl; - this.updateMemoryCache(filterKey, result, ttl); - return result; + // Update memory cache + if (events.length > 0) { + this.memoryCache.set(filterKey, { + events, + timestamp: filterEntry.cachedAt, + ttl: filterEntry.ttl + }); } - return null; + return events.length > 0 ? events : null; } catch (error) { - logger.error({ error, filters }, 'Error reading from event cache'); + const logger = await getLogger(); + logger.error({ error, filters }, 'Error reading from cache'); return null; } } - - /** - * Update in-memory cache and enforce size limit - */ - private updateMemoryCache(filterKey: string, events: NostrEvent[], ttl: number): void { - // Enforce size limit - remove oldest entries if needed - if (this.memoryCache.size >= this.maxMemoryCacheSize) { - // Remove oldest entry (simple FIFO - could be improved with LRU) - const firstKey = this.memoryCache.keys().next().value; - if (firstKey) { - this.memoryCache.delete(firstKey); - } - } - - this.memoryCache.set(filterKey, { - events, - timestamp: Date.now(), - ttl - }); - } - - /** - * Get events synchronously from in-memory cache only (for fast access) - * Returns null if not in memory cache - use async get() for full cache access - */ - getSync(filters: NostrFilter[]): NostrEvent[] | null { - const filterKey = generateMultiFilterKey(filters); - const memoryEntry = this.memoryCache.get(filterKey); - - if (!memoryEntry) { - return null; - } - - const now = Date.now(); - const age = now - memoryEntry.timestamp; - - if (age < memoryEntry.ttl) { - return memoryEntry.events; - } - - // Expired - this.memoryCache.delete(filterKey); - return null; - } - - /** - * Get filter cache entry - */ - private async getFilterEntry(filterKey: string): Promise { - if (!this.db) return null; - - try { - const store = this.db.transaction([STORE_FILTERS], 'readonly').objectStore(STORE_FILTERS); - const request = store.get(filterKey); - - return new Promise((resolve) => { - request.onsuccess = () => resolve(request.result || null); - request.onerror = () => resolve(null); - }); - } catch { - return null; - } - } - - /** - * Process write queue to prevent concurrent IndexedDB transactions - * Ensures only one processor runs at a time by tracking a promise - */ - private async processWriteQueue(): Promise { - // If already processing, wait for the current processor to finish - if (this.queueProcessingPromise) { - return this.queueProcessingPromise; - } - - // If queue is empty, nothing to do - if (this.writeQueue.length === 0) { - return; - } - - // Create a promise that processes the queue - this.queueProcessingPromise = (async () => { - this.isProcessingQueue = true; - - try { - while (this.writeQueue.length > 0) { - const writeFn = this.writeQueue.shift(); - if (writeFn) { - try { - await writeFn(); - } catch (error) { - // Log but continue processing queue - logger.debug({ error }, 'Error in write queue item'); - } - } - } - } finally { - this.isProcessingQueue = false; - this.queueProcessingPromise = null; - } - })(); - - return this.queueProcessingPromise; - } - /** - * Store events in cache, merging with existing events - */ async set(filters: NostrFilter[], events: NostrEvent[], ttl?: number): Promise { await this.init(); - - if (!this.db) { - return; - } - - // Queue the write operation to prevent concurrent transactions - return new Promise((resolve, reject) => { - let resolved = false; - - this.writeQueue.push(async () => { - try { - await this._setInternal(filters, events, ttl); - if (!resolved) { - resolved = true; - resolve(); - } - } catch (error) { - if (!resolved) { - resolved = true; - reject(error); - } - } - }); - - // Process queue asynchronously (don't await, but track the promise) - // Multiple calls will share the same processing promise - this.processWriteQueue().catch(err => { - if (!resolved) { - resolved = true; - reject(err); - } else { - logger.debug({ error: err }, 'Error processing write queue'); - } - }); - }); - } - - /** - * Internal set method that does the actual work - */ - private async _setInternal(filters: NostrFilter[], events: NostrEvent[], ttl?: number): Promise { - if (!this.db) { - return; - } + if (!this.db || events.length === 0) return; try { - const filterKey = generateMultiFilterKey(filters); + const filterKey = generateFilterKey(filters); + const effectiveTTL = ttl || DEFAULT_TTL; const now = Date.now(); - const cacheTTL = ttl || this.defaultTTL; - - // Determine if this is a profile query - const isProfileQuery = filters.some(f => - f.kinds?.includes(0) && f.authors && f.authors.length > 0 - ); - - // Use longer TTL for profile events - const effectiveTTL = isProfileQuery ? this.profileTTL : cacheTTL; - - // Get existing filter entry (outside transaction) - const existingEntry = await this.getFilterEntry(filterKey); - const existingEventIds = new Set(existingEntry?.eventIds || []); - - // Quick check: if all events already exist and TTL hasn't expired, skip the write - const allEventsExist = events.length > 0 && events.every(e => existingEventIds.has(e.id)); - if (allEventsExist && existingEntry) { - const now = Date.now(); - const age = now - existingEntry.cachedAt; - // If cache is still fresh (within 80% of TTL), skip the write - if (age < (existingEntry.ttl * 0.8)) { - logger.debug({ filterKey, eventCount: events.length }, 'All events already cached and fresh, skipping write'); - return; - } - } - // Use a single transaction for all operations - const transaction = this.db.transaction([STORE_EVENTS, STORE_PROFILES, STORE_FILTERS], 'readwrite'); + const transaction = this.db.transaction([STORE_EVENTS, STORE_FILTERS], 'readwrite'); const eventStore = transaction.objectStore(STORE_EVENTS); - const profileStore = transaction.objectStore(STORE_PROFILES); const filterStore = transaction.objectStore(STORE_FILTERS); - let newEventIds: string[] = []; - const eventsToDelete = new Set(); + const eventIds: string[] = []; - // Group events by deduplication key to find the newest per key - const eventsByKey = new Map(); + // Store events (only latest for replaceable kinds) + const latestByKey = new Map(); for (const event of events) { - const key = getDeduplicationKey(event); - const existing = eventsByKey.get(key); + const key = REPLACEABLE_KINDS.includes(event.kind) + ? `${event.kind}:${event.pubkey}` + : event.id; + const existing = latestByKey.get(key); if (!existing || event.created_at > existing.created_at) { - if (existing) { - eventsToDelete.add(existing.id); // Mark older version for deletion - } - eventsByKey.set(key, event); - } else { - eventsToDelete.add(event.id); // This one is older - } - } - - // Check existing events in cache for same deduplication keys and mark older ones for deletion - for (const eventId of existingEventIds) { - const existingEventRequest = eventStore.get(eventId); - const existingCached = await new Promise((resolve) => { - existingEventRequest.onsuccess = () => resolve(existingEventRequest.result); - existingEventRequest.onerror = () => resolve(undefined); - }); - - if (existingCached) { - const existingEvent = existingCached.event; - const key = getDeduplicationKey(existingEvent); - const newEvent = eventsByKey.get(key); - - // If we have a newer event with the same key, mark the old one for deletion - if (newEvent && newEvent.id !== existingEvent.id && newEvent.created_at > existingEvent.created_at) { - eventsToDelete.add(existingEvent.id); - } - } - } - - // Process all events in the transaction (only the newest per deduplication key) - for (const event of Array.from(eventsByKey.values())) { - const key = getDeduplicationKey(event); - - // For replaceable events (kind 0, 3, 10002), check profile store (only kind 0 uses it, but check all) - if (REPLACEABLE_KINDS.includes(event.kind)) { - // For kind 0, check profile store - if (event.kind === 0) { - const existingProfile = await new Promise((resolve) => { - const req = profileStore.get(event.pubkey); - req.onsuccess = () => resolve(req.result); - req.onerror = () => resolve(undefined); - }); - - if (existingProfile && existingProfile.event.kind === event.kind && existingProfile.event.created_at >= event.created_at) { - // Existing event is newer or same, skip - if (existingEventIds.has(existingProfile.event.id)) { - newEventIds.push(existingProfile.event.id); - } - // Mark this one for deletion if it's different - if (existingProfile.event.id !== event.id) { - eventsToDelete.add(event.id); - } - continue; - } - } else { - // For kind 3 and 10002, check if we already have a newer one in events store - // We already checked above, so just continue if it's already in existingEventIds - if (existingEventIds.has(event.id)) { - newEventIds.push(event.id); - continue; - } - } - } else if (isParameterizedReplaceable(event)) { - // For parameterized replaceable events, check if we already have this event - if (existingEventIds.has(event.id)) { - newEventIds.push(event.id); - continue; - } - } else { - // For non-replaceable events, check if we already have this event - if (existingEventIds.has(event.id)) { - newEventIds.push(event.id); - continue; - } - } - - // Store the event - const cached: CachedEvent = { - event, - cachedAt: now, - filterKey - }; - - try { - await new Promise((resolve, reject) => { - const request = eventStore.put(cached); - request.onsuccess = () => resolve(); - request.onerror = () => { - const err = request.error; - // Handle transaction errors gracefully - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB request error, transaction inactive'); - resolve(); // Don't reject, just skip this write - return; - } - reject(err); - }; - }); - } catch (err) { - // If it's a transaction error, skip this event and continue - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB transaction error during event store, skipping'); - continue; // Skip this event - } - throw err; // Re-throw other errors - } - - newEventIds.push(event.id); - - // Also store in profiles store if it's a profile event (using same transaction) - if (event.kind === 0) { - const existingProfile = await new Promise((resolve) => { - const req = profileStore.get(event.pubkey); - req.onsuccess = () => resolve(req.result); - req.onerror = () => resolve(undefined); - }); - - if (!existingProfile || event.created_at > existingProfile.event.created_at) { - try { - await new Promise((resolve, reject) => { - const req = profileStore.put({ pubkey: event.pubkey, ...cached }); - req.onsuccess = () => resolve(); - req.onerror = () => { - const err = req.error; - // Handle transaction errors gracefully - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB request error in profile store, transaction inactive'); - resolve(); // Don't reject, just skip this write - return; - } - reject(err); - }; - }); - } catch (err) { - // If it's a transaction error, skip this profile update and continue - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB transaction error during profile store, skipping'); - // Continue processing other events - } else { - throw err; // Re-throw other errors - } - } - } + latestByKey.set(key, event); } } - // Delete older events that have been superseded - for (const eventId of eventsToDelete) { - try { - await new Promise((resolve, reject) => { - const req = eventStore.delete(eventId); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); - // Remove from existing event IDs if present - existingEventIds.delete(eventId); - newEventIds = newEventIds.filter(id => id !== eventId); - - // Also remove from profile store if it's a kind 0 event - const deleteProfileRequest = profileStore.openCursor(); - await new Promise((resolve) => { - deleteProfileRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const cached = cursor.value as CachedEvent; - if (cached.event.id === eventId) { - cursor.delete(); - } - cursor.continue(); - } else { - resolve(); - } - }; - deleteProfileRequest.onerror = () => resolve(); - }); - } catch (error) { - logger.debug({ error, eventId }, 'Failed to delete old event from cache'); - } - } - - // Merge with existing event IDs (excluding deleted ones) - const mergedEventIds = Array.from(new Set([...existingEntry?.eventIds.filter(id => !eventsToDelete.has(id)) || [], ...newEventIds])); - - // Update filter cache entry (using same transaction) - const filterEntry: FilterCacheEntry = { - filterKey, - eventIds: mergedEventIds, - cachedAt: now, - ttl: effectiveTTL - }; - - try { + for (const event of latestByKey.values()) { await new Promise((resolve, reject) => { - const request = filterStore.put(filterEntry); - request.onsuccess = () => resolve(); - request.onerror = () => { - const err = request.error; - // Handle transaction errors gracefully - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB request error in filter store, transaction inactive'); - resolve(); // Don't reject, just skip this write - return; - } - reject(err); + const req = eventStore.put({ id: event.id, event, cachedAt: now }); + req.onsuccess = () => { + eventIds.push(event.id); + resolve(); }; + req.onerror = () => reject(req.error); }); + } - // Wait for transaction to complete - await new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => { - const err = transaction.error; - // Handle transaction errors gracefully - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB transaction error, transaction inactive'); - resolve(); // Don't reject, just skip - return; - } - reject(err); - }; + // Update filter cache + await new Promise((resolve, reject) => { + const req = filterStore.put({ + filterKey, + eventIds, + cachedAt: now, + ttl: effectiveTTL }); - } catch (err) { - // If it's a transaction error, handle gracefully - if (err instanceof DOMException && - (err.name === 'TransactionInactiveError' || err.name === 'InvalidStateError')) { - logger.debug({ error: err }, 'IndexedDB transaction error during filter update, skipping'); - return; // Don't throw, just skip this write - } - throw err; // Re-throw other errors - } + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); - // Also update in-memory cache for fast access - // Use the events we're setting (they'll be merged with existing in get() if needed) - if (events.length > 0) { - this.updateMemoryCache(filterKey, events, effectiveTTL); - } + // Update memory cache + this.memoryCache.set(filterKey, { + events: Array.from(latestByKey.values()), + timestamp: now, + ttl: effectiveTTL + }); - logger.debug({ - filterKey, - eventCount: events.length, - mergedCount: mergedEventIds.length, - ttl: effectiveTTL - }, 'Cached events in IndexedDB'); + const logger = await getLogger(); + logger.debug({ filterKey, eventCount: events.length }, 'Cached events in IndexedDB'); } catch (error) { - // Check error message first (works for all error types) - const errorMessage = error instanceof Error ? error.message : String(error); - const errorName = error instanceof DOMException ? error.name : ''; - - // Check if it's a quota exceeded error + const logger = await getLogger(); if (error instanceof DOMException && error.name === 'QuotaExceededError') { - logger.warn({ error, filters }, 'IndexedDB quota exceeded, skipping cache write'); - return; // Don't throw, just skip this write - } - - // Check if it's any transaction-related error (by name or message) - // DOMException can have various names: TransactionInactiveError, InvalidStateError, AbortError, etc. - // Also check for any DOMException that might be transaction-related - const isTransactionError = - (error instanceof DOMException && ( - error.name === 'TransactionInactiveError' || - error.name === 'InvalidStateError' || - error.name === 'AbortError' || - error.name === 'ConstraintError' || - error.name === 'DataError' - )) || - errorMessage.toLowerCase().includes('transaction') || - errorMessage.toLowerCase().includes('indexeddb') || - errorMessage.toLowerCase().includes('inactive') || - errorName.toLowerCase().includes('transaction') || - errorName.toLowerCase().includes('inactive'); - - if (isTransactionError) { - // All transaction-related errors should be logged as debug, not error - logger.debug({ error, filters, errorName }, 'IndexedDB transaction error, likely concurrent write, skipping'); - return; // Don't throw, just skip this write - } - - // For any other DOMException, treat as potentially recoverable and log as debug - if (error instanceof DOMException) { - logger.debug({ error, filters, errorName }, 'IndexedDB error (DOMException), skipping cache write'); - return; // Don't throw, just skip this write + logger.warn({ error, filters }, 'IndexedDB quota exceeded'); + return; } - - // Only log as ERROR if it's not a DOMException or transaction-related error - logger.error({ error, filters }, 'Error writing to event cache'); - throw error; // Re-throw other errors + logger.error({ error, filters }, 'Error writing to cache'); } } - /** - * Get a single event by ID - */ - private async getEventById(eventId: string): Promise { - if (!this.db) return null; - - try { - const store = this.db.transaction([STORE_EVENTS], 'readonly').objectStore(STORE_EVENTS); - const request = store.get(eventId); - - return new Promise((resolve) => { - request.onsuccess = () => { - const cached = request.result as CachedEvent | undefined; - resolve(cached?.event || null); - }; - request.onerror = () => resolve(null); - }); - } catch { - return null; - } - } - - /** - * Get profile event (kind 0) for a pubkey - */ async getProfile(pubkey: string): Promise { await this.init(); - - if (!this.db) { - return null; - } + if (!this.db) return null; try { const store = this.db.transaction([STORE_PROFILES], 'readonly').objectStore(STORE_PROFILES); - const request = store.get(pubkey); - - return new Promise((resolve) => { - request.onsuccess = () => { - const cached = request.result as CachedEvent | undefined; - if (cached) { - // Check if not too old - const now = Date.now(); - if (now - cached.cachedAt < this.maxCacheAge) { - resolve(cached.event); - } else { - resolve(null); - } - } else { - resolve(null); - } - }; - request.onerror = () => resolve(null); + const cached = await new Promise((resolve) => { + const req = store.get(pubkey); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => resolve(null); }); + + if (!cached) return null; + if (Date.now() - cached.cachedAt > PROFILE_TTL) return null; + return cached.event; } catch (error) { + const logger = await getLogger(); logger.error({ error, pubkey }, 'Error reading profile from cache'); return null; } } - /** - * Set profile event (kind 0) - */ async setProfile(pubkey: string, event: NostrEvent): Promise { await this.init(); - - if (!this.db || event.kind !== 0) { - return; - } + if (!this.db || event.kind !== 0) return; try { - // Check if we have a newer profile const existing = await this.getProfile(pubkey); - if (existing && existing.created_at >= event.created_at) { - return; // Existing is newer or same - } - - const cached: CachedEvent = { - event, - cachedAt: Date.now() - }; + if (existing && existing.created_at >= event.created_at) return; const store = this.db.transaction([STORE_PROFILES], 'readwrite').objectStore(STORE_PROFILES); await new Promise((resolve, reject) => { - const request = store.put({ pubkey, ...cached }); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + const req = store.put({ pubkey, event, cachedAt: Date.now() }); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); }); // Also store in events store const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); await new Promise((resolve, reject) => { - const request = eventStore.put(cached); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + const req = eventStore.put({ id: event.id, event, cachedAt: Date.now() }); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); }); } catch (error) { + const logger = await getLogger(); logger.error({ error, pubkey }, 'Error writing profile to cache'); } } - /** - * Invalidate cache for a specific pubkey - */ - async invalidatePubkey(pubkey: string): Promise { - await this.init(); - - // Clear in-memory cache entries that might contain events from this pubkey - // We need to check each entry and remove if it contains events from this pubkey - for (const [key, entry] of this.memoryCache.entries()) { - if (entry.events.some(e => e.pubkey === pubkey)) { - this.memoryCache.delete(key); - } - } - - if (!this.db) { - return; - } - - try { - // Remove from profiles store - const profileStore = this.db.transaction([STORE_PROFILES], 'readwrite').objectStore(STORE_PROFILES); - await new Promise((resolve, reject) => { - const request = profileStore.delete(pubkey); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - - // Remove events from events store - const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); - const index = eventStore.index('pubkey'); - const request = index.openKeyCursor(IDBKeyRange.only(pubkey)); - - await new Promise((resolve) => { - request.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - eventStore.delete(cursor.primaryKey); - cursor.continue(); - } else { - resolve(); - } - }; - request.onerror = () => resolve(); - }); - - // Invalidate filter entries that reference these events - const filterStore = this.db.transaction([STORE_FILTERS], 'readwrite').objectStore(STORE_FILTERS); - const filterRequest = filterStore.openCursor(); - - await new Promise((resolve) => { - filterRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const entry = cursor.value as FilterCacheEntry; - // Remove event IDs that match this pubkey - // We'd need to check each event, but for simplicity, just invalidate the filter - filterStore.delete(cursor.primaryKey); - cursor.continue(); - } else { - resolve(); - } - }; - filterRequest.onerror = () => resolve(); - }); - } catch (error) { - logger.error({ error, pubkey }, 'Error invalidating pubkey cache'); - } - } - - /** - * Invalidate cache for specific filters - */ async invalidate(filters: NostrFilter[]): Promise { await this.init(); - - if (!this.db) { - return; - } + if (!this.db) return; try { - const filterKey = generateMultiFilterKey(filters); + const filterKey = generateFilterKey(filters); const store = this.db.transaction([STORE_FILTERS], 'readwrite').objectStore(STORE_FILTERS); await new Promise((resolve, reject) => { - const request = store.delete(filterKey); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + const req = store.delete(filterKey); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); }); } catch (error) { - logger.error({ error, filters }, 'Error invalidating filter cache'); - } - } - - /** - * Clear all cache - */ - async clear(): Promise { - await this.init(); - - if (!this.db) { - return; - } - - try { - const stores = [STORE_EVENTS, STORE_FILTERS, STORE_PROFILES]; - for (const storeName of stores) { - const store = this.db.transaction([storeName], 'readwrite').objectStore(storeName); - await new Promise((resolve, reject) => { - const request = store.clear(); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } - logger.debug('Cleared all event cache'); - } catch (error) { - logger.error({ error }, 'Error clearing event cache'); + const logger = await getLogger(); + logger.error({ error, filters }, 'Error invalidating cache'); } } - /** - * Cleanup old entries - */ async cleanup(): Promise { await this.init(); - - if (!this.db) { - return; - } + if (!this.db) return; try { const now = Date.now(); - let cleaned = 0; - - // Clean up expired filter entries const filterStore = this.db.transaction([STORE_FILTERS], 'readwrite').objectStore(STORE_FILTERS); - const filterRequest = filterStore.openCursor(); - + const cursor = filterStore.openCursor(); + await new Promise((resolve) => { - filterRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const entry = cursor.value as FilterCacheEntry; + cursor.onsuccess = (event) => { + const c = (event.target as IDBRequest).result; + if (c) { + const entry = c.value as FilterCacheEntry; if (now - entry.cachedAt > entry.ttl) { - filterStore.delete(cursor.primaryKey); - cleaned++; + c.delete(); } - cursor.continue(); + c.continue(); } else { resolve(); } }; - filterRequest.onerror = () => resolve(); + cursor.onerror = () => resolve(); }); + } catch (error) { + const logger = await getLogger(); + logger.error({ error }, 'Error during cleanup'); + } + } - // Clean up old events (beyond max cache age) + async deleteEvent(eventId: string): Promise { + await this.init(); + if (!this.db) return; + + try { + // Remove from IndexedDB const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); - const eventRequest = eventStore.openCursor(); - await new Promise((resolve) => { - eventRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const cached = cursor.value as CachedEvent; - if (now - cached.cachedAt > this.maxCacheAge) { - eventStore.delete(cursor.primaryKey); - cleaned++; - } - cursor.continue(); - } else { - resolve(); - } - }; - eventRequest.onerror = () => resolve(); + const req = eventStore.delete(eventId); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); }); - // Clean up old profiles - const profileStore = this.db.transaction([STORE_PROFILES], 'readwrite').objectStore(STORE_PROFILES); - const profileRequest = profileStore.openCursor(); - + // Remove from memory cache + for (const [key, entry] of this.memoryCache.entries()) { + entry.events = entry.events.filter(e => e.id !== eventId); + if (entry.events.length === 0) { + this.memoryCache.delete(key); + } + } + + // Remove from filter cache entries + const filterStore = this.db.transaction([STORE_FILTERS], 'readwrite').objectStore(STORE_FILTERS); + const cursor = filterStore.openCursor(); await new Promise((resolve) => { - profileRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const cached = cursor.value as CachedEvent; - if (now - cached.cachedAt > this.maxCacheAge) { - profileStore.delete(cursor.primaryKey); - cleaned++; - } - cursor.continue(); + cursor.onsuccess = (event) => { + const c = (event.target as IDBRequest).result; + if (c) { + const entry = c.value as FilterCacheEntry; + entry.eventIds = entry.eventIds.filter(id => id !== eventId); + c.update(entry); + c.continue(); } else { resolve(); } }; - profileRequest.onerror = () => resolve(); + cursor.onerror = () => resolve(); }); - - if (cleaned > 0) { - logger.debug({ cleaned }, 'Cleaned up old cache entries'); - } } catch (error) { - logger.error({ error }, 'Error during cache cleanup'); - } - } - - /** - * Get cache statistics - */ - async getStats(): Promise<{ events: number; filters: number; profiles: number }> { - await this.init(); - - if (!this.db) { - return { events: 0, filters: 0, profiles: 0 }; - } - - try { - const eventStore = this.db.transaction([STORE_EVENTS], 'readonly').objectStore(STORE_EVENTS); - const filterStore = this.db.transaction([STORE_FILTERS], 'readonly').objectStore(STORE_FILTERS); - const profileStore = this.db.transaction([STORE_PROFILES], 'readonly').objectStore(STORE_PROFILES); - - const [eventCount, filterCount, profileCount] = await Promise.all([ - new Promise((resolve) => { - const request = eventStore.count(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => resolve(0); - }), - new Promise((resolve) => { - const request = filterStore.count(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => resolve(0); - }), - new Promise((resolve) => { - const request = profileStore.count(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => resolve(0); - }) - ]); - - return { events: eventCount, filters: filterCount, profiles: profileCount }; - } catch (error) { - logger.error({ error }, 'Error getting cache stats'); - return { events: 0, filters: 0, profiles: 0 }; + const logger = await getLogger(); + logger.debug({ error, eventId }, 'Error deleting event from cache'); } } - /** - * Process deletion events (NIP-09) and remove deleted events from cache - * @param deletionEvents - Array of kind 5 deletion events - */ async processDeletionEvents(deletionEvents: NostrEvent[]): Promise { - await this.init(); - - if (!this.db || deletionEvents.length === 0) { - return; - } - - try { - const deletedEventIds = new Set(); - const deletedAddresses = new Set(); // Format: kind:pubkey:d-tag + if (deletionEvents.length === 0) return; - // Extract deleted event IDs and addresses from deletion events - for (const deletionEvent of deletionEvents) { - if (deletionEvent.kind !== KIND.DELETION_REQUEST) { - continue; - } - - // Extract 'e' tags (deleted event IDs) - for (const tag of deletionEvent.tags) { - if (tag[0] === 'e' && tag[1]) { - deletedEventIds.add(tag[1]); - } - // Extract 'a' tags (deleted parameterized replaceable events) - if (tag[0] === 'a' && tag[1]) { - deletedAddresses.add(tag[1]); - } - } - } - - if (deletedEventIds.size === 0 && deletedAddresses.size === 0) { - return; // No deletions to process + const deletedIds = new Set(); + for (const event of deletionEvents) { + if (event.kind !== KIND.DELETION_REQUEST) continue; + for (const tag of event.tags) { + if (tag[0] === 'e' && tag[1]) deletedIds.add(tag[1]); } + } - let removedCount = 0; - - // Remove events by ID - if (deletedEventIds.size > 0) { - const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); - - for (const eventId of deletedEventIds) { - try { - await new Promise((resolve, reject) => { - const request = eventStore.delete(eventId); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - removedCount++; - } catch { - // Event might not exist in cache, ignore - } - } - } + if (deletedIds.size === 0) return; - // Remove events by address (parameterized replaceable events) - if (deletedAddresses.size > 0) { - const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); - const cursorRequest = eventStore.openCursor(); - - await new Promise((resolve) => { - cursorRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const cached = cursor.value as CachedEvent; - const event = cached.event; - - // Check if this event matches any deleted address - // Format: kind:pubkey:d-tag - for (const deletedAddr of deletedAddresses) { - const parts = deletedAddr.split(':'); - if (parts.length === 3) { - const [kindStr, pubkey, dTag] = parts; - const kind = parseInt(kindStr, 10); - - if (event.kind === kind && event.pubkey === pubkey) { - const eventDTag = event.tags.find(t => t[0] === 'd')?.[1]; - if (eventDTag === dTag) { - // This event matches the deleted address - cursor.delete(); - removedCount++; - break; - } - } - } - } - - cursor.continue(); - } else { - resolve(); - } - }; - cursorRequest.onerror = () => resolve(); - }); - } - - // Remove deleted event IDs from filter cache entries - if (deletedEventIds.size > 0 || deletedAddresses.size > 0) { - const filterStore = this.db.transaction([STORE_FILTERS], 'readwrite').objectStore(STORE_FILTERS); - const filterRequest = filterStore.openCursor(); - - await new Promise((resolve) => { - filterRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const entry = cursor.value as FilterCacheEntry; - const originalLength = entry.eventIds.length; - - // Remove deleted event IDs - entry.eventIds = entry.eventIds.filter(id => !deletedEventIds.has(id)); - - // If we removed any IDs, update the entry - if (entry.eventIds.length !== originalLength) { - cursor.update(entry); - } - - cursor.continue(); - } else { - resolve(); - } - }; - filterRequest.onerror = () => resolve(); - }); - } + await this.init(); + if (!this.db) return; - // Also remove from profiles store if applicable - if (deletedEventIds.size > 0) { - const profileStore = this.db.transaction([STORE_PROFILES], 'readwrite').objectStore(STORE_PROFILES); - const profileRequest = profileStore.openCursor(); - + try { + const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); + for (const eventId of deletedIds) { await new Promise((resolve) => { - profileRequest.onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const cached = cursor.value as CachedEvent; - if (deletedEventIds.has(cached.event.id)) { - cursor.delete(); - removedCount++; - } - cursor.continue(); - } else { - resolve(); - } - }; - profileRequest.onerror = () => resolve(); + const req = eventStore.delete(eventId); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); }); } - // Clear in-memory cache entries that contain deleted events + // Remove from memory cache for (const [key, entry] of this.memoryCache.entries()) { - const hasDeletedEvent = entry.events.some(e => - deletedEventIds.has(e.id) || - (REPLACEABLE_KINDS.includes(e.kind) && deletedAddresses.has(`${e.kind}:${e.pubkey}:${e.tags.find(t => t[0] === 'd')?.[1] || ''}`)) - ); - if (hasDeletedEvent) { + entry.events = entry.events.filter(e => !deletedIds.has(e.id)); + if (entry.events.length === 0) { this.memoryCache.delete(key); } } - if (removedCount > 0) { - logger.debug({ removedCount, deletedEventIds: deletedEventIds.size, deletedAddresses: deletedAddresses.size }, 'Processed deletion events and removed from cache'); - } + const logger = await getLogger(); + logger.debug({ removedCount: deletedIds.size }, 'Processed deletion events'); } catch (error) { - logger.error({ error, deletionEvents: deletionEvents.length }, 'Error processing deletion events'); + const logger = await getLogger(); + logger.error({ error }, 'Error processing deletion events'); } } - /** - * Fetch deletion events from relays and process them - * @param nostrClient - NostrClient instance to fetch events - * @param userPubkeys - Array of user pubkeys to fetch deletions for (optional) - */ - async fetchAndProcessDeletions(nostrClient: NostrClient, userPubkeys: string[] = []): Promise { - try { - // Fetch deletion events (kind 5) for the specified users - const filters: NostrFilter[] = []; - - if (userPubkeys.length > 0) { - // Fetch deletions for specific users - filters.push({ - kinds: [KIND.DELETION_REQUEST], - authors: userPubkeys, - limit: 100 - }); - } else { - // If no specific users, we can't fetch all deletions (would be too many) - // In this case, we'll just process any deletions that are already in cache - // or skip this call - logger.debug('No user pubkeys provided, skipping deletion fetch'); - return; - } - - const deletionEvents = await nostrClient.fetchEvents(filters); - - if (deletionEvents.length > 0) { - await this.processDeletionEvents(deletionEvents); - } - } catch (error) { - logger.error({ error, userPubkeys: userPubkeys.length }, 'Error fetching and processing deletion events'); - throw error; - } - } - - /** - * Delete a single event from the cache by event ID - */ - async deleteEvent(eventId: string): Promise { + async invalidatePubkey(pubkey: string): Promise { await this.init(); - - if (!this.db) { - return; - } + if (!this.db) return; try { - const transaction = this.db.transaction([STORE_EVENTS, STORE_FILTERS], 'readwrite'); - const eventStore = transaction.objectStore(STORE_EVENTS); - const filterStore = transaction.objectStore(STORE_FILTERS); - - // Delete from events store - await new Promise((resolve, reject) => { - const req = eventStore.delete(eventId); + // Remove from profiles store + const profileStore = this.db.transaction([STORE_PROFILES], 'readwrite').objectStore(STORE_PROFILES); + await new Promise((resolve) => { + const req = profileStore.delete(pubkey); req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); + req.onerror = () => resolve(); }); - // Remove from all filter entries that reference this event - const filterCursor = filterStore.openCursor(); - await new Promise((resolve, reject) => { - filterCursor.onsuccess = (evt) => { - const cursor = (evt.target as IDBRequest).result; - if (cursor) { - const filterEntry = cursor.value; - if (filterEntry.eventIds && filterEntry.eventIds.includes(eventId)) { - filterEntry.eventIds = filterEntry.eventIds.filter((id: string) => id !== eventId); - cursor.update(filterEntry); - } - cursor.continue(); + // Remove events from events store + const eventStore = this.db.transaction([STORE_EVENTS], 'readwrite').objectStore(STORE_EVENTS); + const index = eventStore.index('pubkey'); + const cursor = index.openKeyCursor(IDBKeyRange.only(pubkey)); + await new Promise((resolve) => { + cursor.onsuccess = (event) => { + const c = (event.target as IDBRequest).result; + if (c) { + eventStore.delete(c.primaryKey); + c.continue(); } else { resolve(); } }; - filterCursor.onerror = () => reject(filterCursor.error); + cursor.onerror = () => resolve(); }); - // Also remove from memory cache - for (const [filterKey, cacheEntry] of this.memoryCache.entries()) { - const index = cacheEntry.events.findIndex(e => e.id === eventId); - if (index !== -1) { - cacheEntry.events.splice(index, 1); + // Remove from memory cache + for (const [key, entry] of this.memoryCache.entries()) { + entry.events = entry.events.filter(e => e.pubkey !== pubkey); + if (entry.events.length === 0) { + this.memoryCache.delete(key); } } } catch (error) { - logger.debug({ error, eventId }, 'Error deleting event from cache'); + const logger = await getLogger(); + logger.error({ error, pubkey }, 'Error invalidating pubkey cache'); + } + } + + async fetchAndProcessDeletions(nostrClient: NostrClient, userPubkeys: string[] = []): Promise { + if (userPubkeys.length === 0) return; + + try { + const deletionEvents = await nostrClient.fetchEvents([{ + kinds: [KIND.DELETION_REQUEST], + authors: userPubkeys, + limit: 100 + }]); + + await this.processDeletionEvents(deletionEvents); + } catch (error) { + const logger = await getLogger(); + logger.error({ error }, 'Error fetching and processing deletions'); } } } -// Singleton instance +// Singleton instance - created lazily, no logger calls in constructor export const persistentEventCache = new PersistentEventCache(); -// Run cleanup every hour and process deletions every 15 minutes +// Setup cleanup interval (only in browser) if (typeof window !== 'undefined') { setInterval(() => { - persistentEventCache.cleanup().catch(err => { - logger.error({ error: err }, 'Error during scheduled cache cleanup'); + persistentEventCache.cleanup().catch(async (err) => { + const logger = await getLogger(); + logger.error({ error: err }, 'Error during scheduled cleanup'); }); }, 60 * 60 * 1000); // 1 hour - // Process deletion events in the background every 15 minutes - // This will be triggered when a NostrClient is available - let deletionProcessingInterval: ReturnType | null = null; - - // Start deletion processing when a client becomes available - // We'll set this up in the NostrClient or a service that has access to it + // Setup deletion processing (window as any).__startDeletionProcessing = async (nostrClient: any, userPubkeys: string[] = []) => { - if (deletionProcessingInterval) { - clearInterval(deletionProcessingInterval); - } + let interval: ReturnType | null = null; - // Process immediately, then every 15 minutes - persistentEventCache.fetchAndProcessDeletions(nostrClient, userPubkeys).catch((err: unknown) => { - logger.debug({ error: err }, 'Error during initial deletion processing'); - }); - - deletionProcessingInterval = setInterval(() => { - persistentEventCache.fetchAndProcessDeletions(nostrClient, userPubkeys).catch((err: unknown) => { - logger.debug({ error: err }, 'Error during scheduled deletion processing'); + const process = () => { + persistentEventCache.fetchAndProcessDeletions(nostrClient, userPubkeys).catch(async (err) => { + const logger = await getLogger(); + logger.debug({ error: err }, 'Error during deletion processing'); }); - }, 15 * 60 * 1000); // 15 minutes + }; + + process(); // Process immediately + interval = setInterval(process, 15 * 60 * 1000); // Then every 15 minutes }; } diff --git a/src/lib/utils/repo-privacy.ts b/src/lib/utils/repo-privacy.ts index 33b1e44..25a0f1d 100644 --- a/src/lib/utils/repo-privacy.ts +++ b/src/lib/utils/repo-privacy.ts @@ -3,11 +3,19 @@ */ import { nip19 } from 'nostr-tools'; -import { MaintainerService } from '../services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '../config.js'; import type { NostrEvent } from '../types/nostr.js'; -const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); +// Lazy initialization to avoid initialization order issues +let maintainerServiceInstance: import('../services/nostr/maintainer-service.js').MaintainerService | null = null; + +const getMaintainerService = async (): Promise => { + if (!maintainerServiceInstance) { + const { MaintainerService } = await import('../services/nostr/maintainer-service.js'); + maintainerServiceInstance = new MaintainerService(DEFAULT_NOSTR_RELAYS); + } + return maintainerServiceInstance; +}; /** * Check if a repository is private based on announcement event @@ -55,6 +63,7 @@ export async function checkRepoAccess( } // Check if user can view + const maintainerService = await getMaintainerService(); const canView = await maintainerService.canView(userPubkey, repoOwnerPubkey, repo); return {