diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 0fe5767..049673f 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -18,3 +18,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771584107,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","pubkey lookup for maintainer\ninclude all tags in the r.a. preset\nupdate client tags on publish\nadd verification/correction step"]],"content":"Signed commit: pubkey lookup for maintainer\ninclude all tags in the r.a. preset\nupdate client tags on publish\nadd verification/correction step","id":"cc27d54e23cecca7e126e7a1b9e0881ee9c9addf39a97841992ac35422221e5d","sig":"7c5e7173e4bfc17a71cec49c8ac2fad15ecab3a84ef53ac90ba7ab6f1c051e2e6d108cecfa075917b6be8a9d1d54d3995595a0b95c004995ec89fe8a621315cd"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771584611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix login persistence"]],"content":"Signed commit: fix login persistence","id":"e02d4dbaf56fb0498ca6871ae25bd5da1061eeca1d28c88d54ff5f6549982f11","sig":"647fa0385224b33546c55c786b3c2cf3b2cfab5de9f9748ce814e40e8c6819131ebb9e86d7682bffa327e3b690297f17bcfb2f6b2d5fb6b65e1d9474d66659b1"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771587832,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","implemented IndexedDB to organize the persistent event cache\nbackground deletion removal\ncorrected and expanded search and added cancel button\nshow maintainers on the search result cards\nremove code search\nremoved hard-coded theme classes"]],"content":"Signed commit: implemented IndexedDB to organize the persistent event cache\nbackground deletion removal\ncorrected and expanded search and added cancel button\nshow maintainers on the search result cards\nremove code search\nremoved hard-coded theme classes","id":"8080f3cad9abacfc9a5fe08bc26744ff8444d0228ea8a6e8a449c8c2704885d6","sig":"70120c99f5e8a1e9df6d74af756a51641c4998265b9233d5a7d187d9e21302dc6377ae274b07be4d6515af1dabfada43fa9af1a087a34e2879b028ac34e551ca"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771604372,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"9a1ba983e0b0db8cff3675a078a376df5c9ad351c3988ea893f3e8084a65a1e6","sig":"724a326cbd6a33f1ff6a2c37b242c7571e35149281609e9eb1c6a197422a13834d9ac2f5d0719026bc66126bd0022df49adf50aa08af93dd95076f407b0f0456"} diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index d856ddd..0b6a23d 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -6,7 +6,7 @@ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import logger from '../logger.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; -import { eventCache } from './event-cache.js'; +// Removed separate in-memory cache - persistent cache now has built-in memory layer import { KIND } from '../../types/nostr.js'; // Replaceable event kinds (only latest per pubkey matters) @@ -223,10 +223,24 @@ export class NostrClient { const hasSearchQuery = filters.some(f => f.search && f.search.trim().length > 0); if (!hasSearchQuery) { - // 1. Check persistent cache first (IndexedDB) - only in browser + // Check persistent cache (has built-in in-memory layer for fast access) const persistentCache = await getPersistentCache(); if (persistentCache) { try { + // First try synchronous memory cache (fast) + const memoryCached = persistentCache.getSync(filters); + if (memoryCached && memoryCached.length > 0) { + logger.debug({ filters, cachedCount: memoryCached.length }, 'Returning cached events from memory'); + + // Return cached events immediately, but also fetch from relays in background to update cache + this.fetchAndMergeFromRelays(filters, memoryCached).catch(err => { + logger.debug({ error: err, filters }, 'Background fetch failed, using cached events'); + }); + + return memoryCached; + } + + // If not in memory, check IndexedDB (async) const cachedEvents = await persistentCache.get(filters); if (cachedEvents && cachedEvents.length > 0) { logger.debug({ filters, cachedCount: cachedEvents.length }, 'Returning cached events from IndexedDB'); @@ -242,24 +256,6 @@ export class NostrClient { logger.debug({ error, filters }, 'Error reading from persistent cache, falling back'); } } - - // 2. Check in-memory cache as fallback - const memoryCached = eventCache.get(filters); - if (memoryCached !== null && memoryCached.length > 0) { - logger.debug({ filters, cachedCount: memoryCached.length }, 'Returning cached events from memory'); - - // Also store in persistent cache and fetch from relays in background - if (persistentCache) { - persistentCache.set(filters, memoryCached).catch(err => { - logger.debug({ error: err }, 'Failed to persist cache'); - }); - } - this.fetchAndMergeFromRelays(filters, memoryCached).catch(err => { - logger.debug({ error: err, filters }, 'Background fetch failed'); - }); - - return memoryCached; - } } else { logger.debug({ filters }, 'Skipping cache for search query'); } @@ -332,12 +328,11 @@ export class NostrClient { // Get persistent cache once (if available) const persistentCache = await getPersistentCache(); - // Cache in both persistent and in-memory caches + // Cache in persistent cache (has built-in in-memory layer) // For kind 0 (profile) events, also cache individually by pubkey const profileEvents = finalEvents.filter(e => e.kind === 0); for (const profileEvent of profileEvents) { - eventCache.setProfile(profileEvent.pubkey, profileEvent); - // Also cache in persistent cache if available + // Cache profile in persistent cache (which also updates its memory layer) if (persistentCache) { persistentCache.setProfile(profileEvent.pubkey, profileEvent).catch(err => { logger.debug({ error: err, pubkey: profileEvent.pubkey }, 'Failed to cache profile'); @@ -352,10 +347,7 @@ export class NostrClient { // Cache successful fetches for 5 minutes, empty results for 1 minute const ttl = finalEvents.length > 0 ? 5 * 60 * 1000 : 60 * 1000; - // Update in-memory cache - eventCache.set(filters, finalEvents, ttl); - - // Update persistent cache (async, don't wait) - only in browser + // Update persistent cache (which also updates its built-in memory layer) if (persistentCache) { persistentCache.set(filters, finalEvents, ttl).catch(err => { logger.debug({ error: err, filters }, 'Failed to update persistent cache'); @@ -427,10 +419,7 @@ export class NostrClient { const deletionEvents = Array.from(uniqueDeletionEvents.values()); if (deletionEvents.length > 0) { - // Process deletions in in-memory cache - eventCache.processDeletionEvents(deletionEvents); - - // Process deletions in persistent cache + // Process deletions in persistent cache (which also handles its memory layer) const persistentCache = await getPersistentCache(); if (persistentCache && typeof persistentCache.processDeletionEvents === 'function') { await persistentCache.processDeletionEvents(deletionEvents); @@ -599,9 +588,7 @@ export class NostrClient { // Invalidate cache for events from this pubkey (new event published) // This ensures fresh data on next fetch if (success.length > 0) { - eventCache.invalidatePubkey(event.pubkey); - - // Also invalidate persistent cache + // Invalidate persistent cache (which also handles its memory layer) const persistentCache = await getPersistentCache(); if (persistentCache) { persistentCache.invalidatePubkey(event.pubkey).catch(err => { diff --git a/src/lib/services/nostr/persistent-event-cache.ts b/src/lib/services/nostr/persistent-event-cache.ts index 2a0ac68..1610b62 100644 --- a/src/lib/services/nostr/persistent-event-cache.ts +++ b/src/lib/services/nostr/persistent-event-cache.ts @@ -139,6 +139,12 @@ function eventMatchesAnyFilter(event: NostrEvent, filters: NostrFilter[]): boole return filters.some(filter => eventMatchesFilter(event, filter)); } +interface InMemoryCacheEntry { + events: NostrEvent[]; + timestamp: number; + ttl: number; +} + export class PersistentEventCache { private db: IDBDatabase | null = null; private initPromise: Promise | null = null; @@ -147,6 +153,12 @@ export class PersistentEventCache { 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 constructor() { this.init(); @@ -208,8 +220,24 @@ export class PersistentEventCache { /** * 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) { @@ -217,8 +245,6 @@ export class PersistentEventCache { } try { - const filterKey = generateMultiFilterKey(filters); - // Check filter cache first const filterEntry = await this.getFilterEntry(filterKey); if (!filterEntry) { @@ -226,7 +252,6 @@ export class PersistentEventCache { } // Check if filter cache is expired - const now = Date.now(); if (now - filterEntry.cachedAt > filterEntry.ttl) { // Expired, but we can still return events if they exist // Don't delete, just mark as stale @@ -274,12 +299,63 @@ export class PersistentEventCache { // Sort by created_at descending result.sort((a, b) => b.created_at - a.created_at); - return result.length > 0 ? result : null; + // Update in-memory cache with result from IndexedDB + if (result.length > 0) { + const ttl = filterEntry.ttl; + this.updateMemoryCache(filterKey, result, ttl); + return result; + } + + return null; } catch (error) { logger.error({ error, filters }, 'Error reading from event 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 @@ -302,27 +378,42 @@ export class PersistentEventCache { /** * 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 (this.isProcessingQueue || this.writeQueue.length === 0) { - return; + // If already processing, wait for the current processor to finish + if (this.queueProcessingPromise) { + return this.queueProcessingPromise; } - this.isProcessingQueue = true; + // If queue is empty, nothing to do + if (this.writeQueue.length === 0) { + return; + } - 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'); + // 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; } - } + })(); - this.isProcessingQueue = false; + return this.queueProcessingPromise; } /** @@ -354,7 +445,8 @@ export class PersistentEventCache { } }); - // Process queue asynchronously + // 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; @@ -391,6 +483,18 @@ export class PersistentEventCache { 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 eventStore = transaction.objectStore(STORE_EVENTS); @@ -404,11 +508,17 @@ export class PersistentEventCache { // For replaceable events, check if we have a newer version for this pubkey if (REPLACEABLE_KINDS.includes(event.kind)) { // Check if we already have a newer replaceable event for this pubkey - const existingProfile = await this.getProfile(event.pubkey); - if (existingProfile && existingProfile.kind === event.kind && existingProfile.created_at >= event.created_at) { + // Use the same transaction instead of calling getProfile (which creates a new transaction) + 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.id)) { - newEventIds.push(existingProfile.id); + if (existingEventIds.has(existingProfile.event.id)) { + newEventIds.push(existingProfile.event.id); } continue; } @@ -427,11 +537,31 @@ export class PersistentEventCache { filterKey }; - await new Promise((resolve, reject) => { - const request = eventStore.put(cached); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); + 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); @@ -444,11 +574,32 @@ export class PersistentEventCache { }); if (!existingProfile || event.created_at > existingProfile.event.created_at) { - await new Promise((resolve, reject) => { - const req = profileStore.put({ pubkey: event.pubkey, ...cached }); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); + 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 + } + } } } } @@ -464,17 +615,53 @@ export class PersistentEventCache { ttl: effectiveTTL }; - await new Promise((resolve, reject) => { - const request = filterStore.put(filterEntry); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); + try { + 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); + }; + }); - // Wait for transaction to complete - await new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.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); + }; + }); + } 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 + } + + // 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); + } logger.debug({ filterKey, @@ -483,16 +670,46 @@ export class PersistentEventCache { ttl: effectiveTTL }, 'Cached events in IndexedDB'); } catch (error) { - // Check if it's a quota exceeded error or other recoverable 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 + 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) { - if (error.name === 'QuotaExceededError') { - logger.warn({ error, filters }, 'IndexedDB quota exceeded, skipping cache write'); - return; // Don't throw, just skip this write - } else if (error.name === 'TransactionInactiveError' || error.name === 'InvalidStateError') { - logger.debug({ error, filters }, 'IndexedDB transaction error, likely concurrent write, skipping'); - return; // Don't throw, just skip this write - } + logger.debug({ error, filters, errorName }, 'IndexedDB error (DOMException), skipping cache write'); + return; // Don't throw, just skip this write } + + // 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 } @@ -604,6 +821,14 @@ export class PersistentEventCache { 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; } @@ -983,6 +1208,17 @@ export class PersistentEventCache { }); } + // Clear in-memory cache entries that contain deleted events + 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) { + this.memoryCache.delete(key); + } + } + if (removedCount > 0) { logger.debug({ removedCount, deletedEventIds: deletedEventIds.size, deletedAddresses: deletedAddresses.size }, 'Processed deletion events and removed from cache'); }