From e0880425190e6e3723836b764eddfd3bd6a316c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 21:08:02 +0000 Subject: [PATCH] feat: implement generic replaceable event cache Add configurable cache system for replaceable Nostr events (contacts, relay lists, blossom servers, emoji lists, etc). Stores raw events and parses on-demand using applesauce helpers for better performance. Changes: - Add replaceableEvents Dexie table with composite key [pubkey+kind+d] - Implement ReplaceableEventCache service with LRU memory cache - Refactor RelayListCache and BlossomServerCache to use generic cache - Add useReplaceableEventCacheSync hook to AppShell - Configure CACHED_KINDS: [3, 10002, 10063, 10030] Benefits: - Adding new cached kinds requires only updating CACHED_KINDS array - No pre-parsing overhead - applesauce helpers cache internally - Single cache implementation eliminates code duplication - Backward compatible API maintained for existing services --- src/components/layouts/AppShell.tsx | 4 + src/hooks/useReplaceableEventCacheSync.ts | 26 ++ src/services/blossom-server-cache.ts | 263 ++----------- src/services/db.ts | 28 ++ src/services/relay-list-cache.ts | 320 +++------------- src/services/relay-selection.ts | 6 +- src/services/replaceable-event-cache.ts | 431 ++++++++++++++++++++++ 7 files changed, 571 insertions(+), 507 deletions(-) create mode 100644 src/hooks/useReplaceableEventCacheSync.ts create mode 100644 src/services/replaceable-event-cache.ts diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx index 3f82958..5449aa3 100644 --- a/src/components/layouts/AppShell.tsx +++ b/src/components/layouts/AppShell.tsx @@ -3,6 +3,7 @@ import { Terminal } from "lucide-react"; import { useAccountSync } from "@/hooks/useAccountSync"; import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync"; import { useBlossomServerCacheSync } from "@/hooks/useBlossomServerCacheSync"; +import { useReplaceableEventCacheSync } from "@/hooks/useReplaceableEventCacheSync"; import { useRelayState } from "@/hooks/useRelayState"; import relayStateManager from "@/services/relay-state-manager"; import { TabBar } from "../TabBar"; @@ -29,6 +30,9 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) { // Auto-cache kind:10063 blossom server lists from EventStore to Dexie useBlossomServerCacheSync(); + // Auto-cache generic replaceable events (contacts, relay lists, blossom servers, emoji lists, etc.) + useReplaceableEventCacheSync(); + // Initialize global relay state manager useEffect(() => { relayStateManager.initialize().catch((err) => { diff --git a/src/hooks/useReplaceableEventCacheSync.ts b/src/hooks/useReplaceableEventCacheSync.ts new file mode 100644 index 0000000..96d0c7b --- /dev/null +++ b/src/hooks/useReplaceableEventCacheSync.ts @@ -0,0 +1,26 @@ +/** + * Hook to keep generic replaceable event cache in sync with EventStore + * + * Subscribes to configured kinds (contacts, relay lists, blossom servers, emoji lists, etc.) + * and automatically caches them in Dexie. + * + * Should be used once at app root level. + */ + +import { useEffect } from "react"; +import { useEventStore } from "applesauce-react/hooks"; +import replaceableEventCache from "@/services/replaceable-event-cache"; + +export function useReplaceableEventCacheSync() { + const eventStore = useEventStore(); + + useEffect(() => { + // Subscribe to EventStore for auto-caching + replaceableEventCache.subscribeToEventStore(eventStore); + + // Cleanup on unmount + return () => { + replaceableEventCache.unsubscribe(); + }; + }, [eventStore]); +} diff --git a/src/services/blossom-server-cache.ts b/src/services/blossom-server-cache.ts index 23ddc09..95324c3 100644 --- a/src/services/blossom-server-cache.ts +++ b/src/services/blossom-server-cache.ts @@ -1,151 +1,39 @@ /** * Blossom Server Cache Service * - * Caches BUD-03 blossom server lists (kind:10063) in Dexie for fast access. - * Reduces network requests and improves cold start performance. + * Wrapper around generic ReplaceableEventCache for BUD-03 blossom server lists (kind:10063). + * Provides convenient helpers for accessing blossom servers. * - * Auto-caches kind:10063 events from EventStore when subscribed. + * Now uses the generic cache for storage - parsing happens on-demand. */ -import type { NostrEvent } from "@/types/nostr"; import { getServersFromEvent } from "./blossom"; -import db, { CachedBlossomServerList } from "./db"; +import replaceableEventCache from "./replaceable-event-cache"; import type { IEventStore } from "applesauce-core/event-store"; -import type { Subscription } from "rxjs"; -const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours -const MAX_MEMORY_CACHE = 100; // LRU cache size +const BLOSSOM_SERVER_LIST_KIND = 10063; class BlossomServerCache { - private eventStoreSubscription: Subscription | null = null; - private memoryCache = new Map(); - private cacheOrder: string[] = []; - /** * Subscribe to EventStore to auto-cache kind:10063 events + * @deprecated - Now handled by generic ReplaceableEventCache + * Kept for backward compatibility with existing code */ - subscribeToEventStore(eventStore: IEventStore): void { - if (this.eventStoreSubscription) { - console.warn("[BlossomServerCache] Already subscribed to EventStore"); - return; - } - - // Subscribe to kind:10063 events - this.eventStoreSubscription = eventStore - .filters({ kinds: [10063] }) - .subscribe((event: NostrEvent) => { - // Cache each kind:10063 event as it arrives - this.set(event); - }); - - console.log( - "[BlossomServerCache] Subscribed to EventStore for kind:10063 events", + subscribeToEventStore(_eventStore: IEventStore): void { + console.warn( + "[BlossomServerCache] subscribeToEventStore is deprecated - kind:10063 is now auto-cached by ReplaceableEventCache", ); } /** * Unsubscribe from EventStore + * @deprecated - Now handled by generic ReplaceableEventCache + * Kept for backward compatibility with existing code */ unsubscribe(): void { - if (this.eventStoreSubscription) { - this.eventStoreSubscription.unsubscribe(); - this.eventStoreSubscription = null; - console.log("[BlossomServerCache] Unsubscribed from EventStore"); - } - } - - /** - * Get cached blossom server list for a pubkey - * Returns undefined if not cached or stale - */ - async get(pubkey: string): Promise { - try { - const cached = await db.blossomServers.get(pubkey); - - // Check if stale (>24 hours) - if (cached && Date.now() - cached.updatedAt < CACHE_TTL) { - return cached; - } - - // Stale or not found - if (cached) { - console.debug( - `[BlossomServerCache] Cached server list for ${pubkey.slice(0, 8)} is stale (${Math.floor((Date.now() - cached.updatedAt) / 1000 / 60)}min old)`, - ); - } - - return undefined; - } catch (error) { - console.error( - `[BlossomServerCache] Error reading cache for ${pubkey.slice(0, 8)}:`, - error, - ); - return undefined; - } - } - - /** - * Store blossom server list event in cache - */ - async set(event: NostrEvent): Promise { - try { - if (event.kind !== 10063) { - console.warn( - `[BlossomServerCache] Attempted to cache non-10063 event (kind ${event.kind})`, - ); - return; - } - - // Parse servers from event - const servers = getServersFromEvent(event); - - // Store in Dexie and memory cache - const cachedEntry: CachedBlossomServerList = { - pubkey: event.pubkey, - event, - servers, - updatedAt: Date.now(), - }; - - await db.blossomServers.put(cachedEntry); - - // Also populate memory cache - this.memoryCache.set(event.pubkey, cachedEntry); - this.cacheOrder.push(event.pubkey); - this.evictOldest(); - - console.debug( - `[BlossomServerCache] Cached ${servers.length} server(s) for ${event.pubkey.slice(0, 8)}`, - ); - } catch (error) { - console.error( - `[BlossomServerCache] Error caching server list for ${event.pubkey.slice(0, 8)}:`, - error, - ); - } - } - - /** - * Update LRU order for a pubkey - */ - private updateLRU(pubkey: string): void { - const index = this.cacheOrder.indexOf(pubkey); - if (index > -1) { - this.cacheOrder.splice(index, 1); - } - this.cacheOrder.push(pubkey); - } - - /** - * Evict oldest entries from memory cache if over limit - */ - private evictOldest(): void { - while (this.cacheOrder.length > MAX_MEMORY_CACHE) { - const oldest = this.cacheOrder.shift(); - if (oldest) { - this.memoryCache.delete(oldest); - } - } + console.warn( + "[BlossomServerCache] unsubscribe is deprecated - managed by ReplaceableEventCache", + ); } /** @@ -154,129 +42,42 @@ class BlossomServerCache { * Returns null if not in memory cache */ getServersSync(pubkey: string): string[] | null { - const memCached = this.memoryCache.get(pubkey); - if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { - this.updateLRU(pubkey); - return memCached.servers; - } - return null; + const event = replaceableEventCache.getSync( + pubkey, + BLOSSOM_SERVER_LIST_KIND, + ); + if (!event) return null; + + // Parse on-demand + return getServersFromEvent(event); } /** * Get blossom servers for a pubkey from cache */ async getServers(pubkey: string): Promise { - // Check memory cache first (< 1ms) - const memCached = this.memoryCache.get(pubkey); - if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { - this.updateLRU(pubkey); - return memCached.servers; - } + const event = await replaceableEventCache.getEvent( + pubkey, + BLOSSOM_SERVER_LIST_KIND, + ); + if (!event) return null; - // Then check Dexie (5-10ms) - const cached = await this.get(pubkey); - if (cached) { - // Populate memory cache for next time - this.memoryCache.set(pubkey, cached); - this.cacheOrder.push(pubkey); - this.evictOldest(); - return cached.servers; - } - - return null; + // Parse on-demand + return getServersFromEvent(event); } /** * Check if we have a valid cache entry for a pubkey */ async has(pubkey: string): Promise { - const cached = await this.get(pubkey); - return cached !== undefined; + return replaceableEventCache.has(pubkey, BLOSSOM_SERVER_LIST_KIND); } /** * Invalidate (delete) cache entry for a pubkey */ async invalidate(pubkey: string): Promise { - try { - await db.blossomServers.delete(pubkey); - // Also remove from memory cache - this.memoryCache.delete(pubkey); - const index = this.cacheOrder.indexOf(pubkey); - if (index > -1) { - this.cacheOrder.splice(index, 1); - } - console.debug( - `[BlossomServerCache] Invalidated cache for ${pubkey.slice(0, 8)}`, - ); - } catch (error) { - console.error( - `[BlossomServerCache] Error invalidating cache for ${pubkey.slice(0, 8)}:`, - error, - ); - } - } - - /** - * Clear all cached blossom server lists - */ - async clear(): Promise { - try { - await db.blossomServers.clear(); - // Also clear memory cache - this.memoryCache.clear(); - this.cacheOrder = []; - console.log("[BlossomServerCache] Cleared all cached server lists"); - } catch (error) { - console.error("[BlossomServerCache] Error clearing cache:", error); - } - } - - /** - * Get cache statistics - */ - async getStats(): Promise<{ - count: number; - oldestEntry: number | null; - newestEntry: number | null; - memoryCacheSize: number; - memoryCacheLimit: number; - }> { - try { - const count = await db.blossomServers.count(); - const all = await db.blossomServers.toArray(); - - if (all.length === 0) { - return { - count: 0, - oldestEntry: null, - newestEntry: null, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } - - const timestamps = all.map((entry) => entry.updatedAt); - const oldest = Math.min(...timestamps); - const newest = Math.max(...timestamps); - - return { - count, - oldestEntry: oldest, - newestEntry: newest, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } catch (error) { - console.error("[BlossomServerCache] Error getting stats:", error); - return { - count: 0, - oldestEntry: null, - newestEntry: null, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } + return replaceableEventCache.invalidate(pubkey, BLOSSOM_SERVER_LIST_KIND); } } diff --git a/src/services/db.ts b/src/services/db.ts index 6e9ee79..4e346e3 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -87,6 +87,18 @@ export interface LocalSpellbook { deletedAt?: number; } +/** + * Generic cache for replaceable events (kind 3, 10002, 10063, 10030, etc.) + * Stores raw events - parsing happens on-demand using applesauce helpers + */ +export interface CachedReplaceableEvent { + pubkey: string; + kind: number; + d: string; // d-tag for parameterized replaceables (30000-39999), empty string for normal + event: NostrEvent; + updatedAt: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -96,6 +108,7 @@ class GrimoireDb extends Dexie { relayLists!: Table; relayLiveness!: Table; blossomServers!: Table; + replaceableEvents!: Table; spells!: Table; spellbooks!: Table; @@ -333,6 +346,21 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 16: Add generic replaceable event cache + this.version(16).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + replaceableEvents: "[pubkey+kind+d], [pubkey+kind], kind, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + }); } } diff --git a/src/services/relay-list-cache.ts b/src/services/relay-list-cache.ts index 13a91aa..f0a7aa4 100644 --- a/src/services/relay-list-cache.ts +++ b/src/services/relay-list-cache.ts @@ -1,177 +1,40 @@ /** * Relay List Cache Service * - * Caches NIP-65 relay lists (kind:10002) in Dexie for fast access. - * Reduces network requests and improves cold start performance. + * Wrapper around generic ReplaceableEventCache for NIP-65 relay lists (kind:10002). + * Provides convenient helpers for accessing inbox/outbox relays. * - * Auto-caches kind:10002 events from EventStore when subscribed. + * Now uses the generic cache for storage - parsing happens on-demand using applesauce helpers. */ -import type { NostrEvent } from "@/types/nostr"; import { getInboxes, getOutboxes } from "applesauce-core/helpers"; import { normalizeRelayURL } from "@/lib/relay-url"; -import db, { CachedRelayList } from "./db"; +import replaceableEventCache from "./replaceable-event-cache"; import type { IEventStore } from "applesauce-core/event-store"; -import type { Subscription } from "rxjs"; -const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours -const MAX_MEMORY_CACHE = 100; // LRU cache size +const RELAY_LIST_KIND = 10002; class RelayListCache { - private eventStoreSubscription: Subscription | null = null; - private memoryCache = new Map(); - private cacheOrder: string[] = []; - /** * Subscribe to EventStore to auto-cache kind:10002 events + * @deprecated - Now handled by generic ReplaceableEventCache + * Kept for backward compatibility with existing code */ - subscribeToEventStore(eventStore: IEventStore): void { - if (this.eventStoreSubscription) { - console.warn("[RelayListCache] Already subscribed to EventStore"); - return; - } - - // Subscribe to kind:10002 events - this.eventStoreSubscription = eventStore - .filters({ kinds: [10002] }) - .subscribe((event: NostrEvent) => { - // Cache each kind:10002 event as it arrives - this.set(event); - }); - - console.log( - "[RelayListCache] Subscribed to EventStore for kind:10002 events", + subscribeToEventStore(_eventStore: IEventStore): void { + console.warn( + "[RelayListCache] subscribeToEventStore is deprecated - kind:10002 is now auto-cached by ReplaceableEventCache", ); } /** * Unsubscribe from EventStore + * @deprecated - Now handled by generic ReplaceableEventCache + * Kept for backward compatibility with existing code */ unsubscribe(): void { - if (this.eventStoreSubscription) { - this.eventStoreSubscription.unsubscribe(); - this.eventStoreSubscription = null; - console.log("[RelayListCache] Unsubscribed from EventStore"); - } - } - - /** - * Get cached relay list for a pubkey - * Returns undefined if not cached or stale - */ - async get(pubkey: string): Promise { - try { - const cached = await db.relayLists.get(pubkey); - - // Check if stale (>24 hours) - if (cached && Date.now() - cached.updatedAt < CACHE_TTL) { - return cached; - } - - // Stale or not found - if (cached) { - console.debug( - `[RelayListCache] Cached relay list for ${pubkey.slice(0, 8)} is stale (${Math.floor((Date.now() - cached.updatedAt) / 1000 / 60)}min old)`, - ); - } - - return undefined; - } catch (error) { - console.error( - `[RelayListCache] Error reading cache for ${pubkey.slice(0, 8)}:`, - error, - ); - return undefined; - } - } - - /** - * Store relay list event in cache - */ - async set(event: NostrEvent): Promise { - try { - if (event.kind !== 10002) { - console.warn( - `[RelayListCache] Attempted to cache non-10002 event (kind ${event.kind})`, - ); - return; - } - - // Parse relays from event - const readRelays = getInboxes(event); - const writeRelays = getOutboxes(event); - - // Normalize URLs and filter invalid ones - const normalizedRead = readRelays - .map((url) => { - try { - return normalizeRelayURL(url); - } catch { - console.warn(`[RelayListCache] Invalid read relay URL: ${url}`); - return null; - } - }) - .filter((url): url is string => url !== null); - - const normalizedWrite = writeRelays - .map((url) => { - try { - return normalizeRelayURL(url); - } catch { - console.warn(`[RelayListCache] Invalid write relay URL: ${url}`); - return null; - } - }) - .filter((url): url is string => url !== null); - - // Store in Dexie and memory cache - const cachedEntry: CachedRelayList = { - pubkey: event.pubkey, - event, - read: normalizedRead, - write: normalizedWrite, - updatedAt: Date.now(), - }; - - await db.relayLists.put(cachedEntry); - - // Also populate memory cache - this.memoryCache.set(event.pubkey, cachedEntry); - this.cacheOrder.push(event.pubkey); - this.evictOldest(); - - console.debug( - `[RelayListCache] Cached relay list for ${event.pubkey.slice(0, 8)} (${normalizedWrite.length} write, ${normalizedRead.length} read)`, - ); - } catch (error) { - console.error( - `[RelayListCache] Error caching relay list for ${event.pubkey.slice(0, 8)}:`, - error, - ); - } - } - - /** - * Update LRU order for a pubkey - */ - private updateLRU(pubkey: string): void { - const index = this.cacheOrder.indexOf(pubkey); - if (index > -1) { - this.cacheOrder.splice(index, 1); - } - this.cacheOrder.push(pubkey); - } - - /** - * Evict oldest entries from memory cache if over limit - */ - private evictOldest(): void { - while (this.cacheOrder.length > MAX_MEMORY_CACHE) { - const oldest = this.cacheOrder.shift(); - if (oldest) { - this.memoryCache.delete(oldest); - } - } + console.warn( + "[RelayListCache] unsubscribe is deprecated - managed by ReplaceableEventCache", + ); } /** @@ -180,153 +43,66 @@ class RelayListCache { * Returns null if not in memory cache */ getOutboxRelaysSync(pubkey: string): string[] | null { - const memCached = this.memoryCache.get(pubkey); - if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { - this.updateLRU(pubkey); - return memCached.write; - } - return null; + const event = replaceableEventCache.getSync(pubkey, RELAY_LIST_KIND); + if (!event) return null; + + // Parse and normalize on-demand (applesauce caches this) + const writeRelays = getOutboxes(event); + return this.normalizeRelays(writeRelays); } /** * Get outbox (write) relays for a pubkey from cache */ async getOutboxRelays(pubkey: string): Promise { - // Check memory cache first (< 1ms) - const memCached = this.memoryCache.get(pubkey); - if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { - this.updateLRU(pubkey); - return memCached.write; - } + const event = await replaceableEventCache.getEvent(pubkey, RELAY_LIST_KIND); + if (!event) return null; - // Then check Dexie (5-10ms) - const cached = await this.get(pubkey); - if (cached) { - // Populate memory cache for next time - this.memoryCache.set(pubkey, cached); - this.cacheOrder.push(pubkey); - this.evictOldest(); - return cached.write; - } - - return null; + // Parse and normalize on-demand (applesauce caches this) + const writeRelays = getOutboxes(event); + return this.normalizeRelays(writeRelays); } /** * Get inbox (read) relays for a pubkey from cache */ async getInboxRelays(pubkey: string): Promise { - // Check memory cache first (< 1ms) - const memCached = this.memoryCache.get(pubkey); - if (memCached && Date.now() - memCached.updatedAt < CACHE_TTL) { - this.updateLRU(pubkey); - return memCached.read; - } + const event = await replaceableEventCache.getEvent(pubkey, RELAY_LIST_KIND); + if (!event) return null; - // Then check Dexie (5-10ms) - const cached = await this.get(pubkey); - if (cached) { - // Populate memory cache for next time - this.memoryCache.set(pubkey, cached); - this.cacheOrder.push(pubkey); - this.evictOldest(); - return cached.read; - } + // Parse and normalize on-demand (applesauce caches this) + const readRelays = getInboxes(event); + return this.normalizeRelays(readRelays); + } - return null; + /** + * Normalize relay URLs and filter invalid ones + */ + private normalizeRelays(relays: string[]): string[] { + return relays + .map((url) => { + try { + return normalizeRelayURL(url); + } catch { + console.warn(`[RelayListCache] Invalid relay URL: ${url}`); + return null; + } + }) + .filter((url): url is string => url !== null); } /** * Check if we have a valid cache entry for a pubkey */ async has(pubkey: string): Promise { - const cached = await this.get(pubkey); - return cached !== undefined; + return replaceableEventCache.has(pubkey, RELAY_LIST_KIND); } /** * Invalidate (delete) cache entry for a pubkey */ async invalidate(pubkey: string): Promise { - try { - await db.relayLists.delete(pubkey); - // Also remove from memory cache - this.memoryCache.delete(pubkey); - const index = this.cacheOrder.indexOf(pubkey); - if (index > -1) { - this.cacheOrder.splice(index, 1); - } - console.debug( - `[RelayListCache] Invalidated cache for ${pubkey.slice(0, 8)}`, - ); - } catch (error) { - console.error( - `[RelayListCache] Error invalidating cache for ${pubkey.slice(0, 8)}:`, - error, - ); - } - } - - /** - * Clear all cached relay lists - */ - async clear(): Promise { - try { - await db.relayLists.clear(); - // Also clear memory cache - this.memoryCache.clear(); - this.cacheOrder = []; - console.log("[RelayListCache] Cleared all cached relay lists"); - } catch (error) { - console.error("[RelayListCache] Error clearing cache:", error); - } - } - - /** - * Get cache statistics - */ - async getStats(): Promise<{ - count: number; - oldestEntry: number | null; - newestEntry: number | null; - memoryCacheSize: number; - memoryCacheLimit: number; - }> { - try { - const count = await db.relayLists.count(); - const all = await db.relayLists.toArray(); - - if (all.length === 0) { - return { - count: 0, - oldestEntry: null, - newestEntry: null, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } - - const timestamps = all.map((entry) => entry.updatedAt); - const oldest = Math.min(...timestamps); - const newest = Math.max(...timestamps); - - return { - count, - oldestEntry: oldest, - newestEntry: newest, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } catch (error) { - console.error("[RelayListCache] Error getting stats:", error); - return { - count: 0, - oldestEntry: null, - newestEntry: null, - memoryCacheSize: this.memoryCache.size, - memoryCacheLimit: MAX_MEMORY_CACHE, - }; - } + return replaceableEventCache.invalidate(pubkey, RELAY_LIST_KIND); } } diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 93aaed0..4262584 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -123,8 +123,7 @@ async function getOutboxRelaysForPubkey( return []; } - // Cache the event for next time - relayListCache.set(event); + // Event will be auto-cached by ReplaceableEventCache via EventStore subscription console.debug( `[RelaySelection] Cache miss for ${pubkey.slice(0, 8)}, loaded from EventStore`, ); @@ -221,8 +220,7 @@ async function getInboxRelaysForPubkey( return []; } - // Cache the event for next time - relayListCache.set(event); + // Event will be auto-cached by ReplaceableEventCache via EventStore subscription console.debug( `[RelaySelection] Cache miss for ${pubkey.slice(0, 8)}, loaded from EventStore`, ); diff --git a/src/services/replaceable-event-cache.ts b/src/services/replaceable-event-cache.ts new file mode 100644 index 0000000..283df82 --- /dev/null +++ b/src/services/replaceable-event-cache.ts @@ -0,0 +1,431 @@ +/** + * Generic Replaceable Event Cache Service + * + * Caches configured replaceable/parameterized events in Dexie for fast access. + * Stores raw events - parsing happens on-demand using applesauce helpers. + * + * Supports: + * - Normal replaceable events (10000-19999) - one per pubkey+kind + * - Parameterized replaceable events (30000-39999) - multiple per pubkey+kind (by d-tag) + * - Contact lists (kind 3) - special case, treated as replaceable + * + * Auto-caches events from EventStore when subscribed. + */ + +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers/event"; +import db, { CachedReplaceableEvent } from "./db"; +import type { IEventStore } from "applesauce-core/event-store"; +import type { Subscription } from "rxjs"; + +const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours +const MAX_MEMORY_CACHE = 200; // LRU cache size + +/** + * Kinds to cache (add more as needed) + * - 3: Contact list (kind:3, NIP-02) + * - 10002: Relay list (kind:10002, NIP-65) + * - 10063: Blossom server list (kind:10063, BUD-03) + * - 10030: User emoji list (kind:10030, NIP-30) + */ +export const CACHED_KINDS = [3, 10002, 10063, 10030]; + +/** + * Check if a kind is parameterized replaceable (30000-39999) + */ +function isParameterizedReplaceable(kind: number): boolean { + return kind >= 30000 && kind <= 39999; +} + +/** + * Build cache key for memory cache: "pubkey:kind:d" + */ +function buildCacheKey(pubkey: string, kind: number, d: string = ""): string { + return `${pubkey}:${kind}:${d}`; +} + +class ReplaceableEventCache { + private eventStoreSubscription: Subscription | null = null; + private memoryCache = new Map(); + private cacheOrder: string[] = []; + private ttl: number = DEFAULT_TTL; + + /** + * Subscribe to EventStore to auto-cache configured kinds + */ + subscribeToEventStore(eventStore: IEventStore): void { + if (this.eventStoreSubscription) { + console.warn("[ReplaceableEventCache] Already subscribed to EventStore"); + return; + } + + // Subscribe to all configured kinds + this.eventStoreSubscription = eventStore + .filters({ kinds: CACHED_KINDS }) + .subscribe((event: NostrEvent) => { + // Cache each event as it arrives + this.set(event); + }); + + console.log( + `[ReplaceableEventCache] Subscribed to EventStore for kinds: ${CACHED_KINDS.join(", ")}`, + ); + } + + /** + * Unsubscribe from EventStore + */ + unsubscribe(): void { + if (this.eventStoreSubscription) { + this.eventStoreSubscription.unsubscribe(); + this.eventStoreSubscription = null; + console.log("[ReplaceableEventCache] Unsubscribed from EventStore"); + } + } + + /** + * Get cached event for a pubkey+kind (and optional d-tag) + * Returns undefined if not cached or stale + */ + async get( + pubkey: string, + kind: number, + d: string = "", + ): Promise { + try { + const cached = await db.replaceableEvents.get([pubkey, kind, d]); + + // Check if stale + if (cached && Date.now() - cached.updatedAt < this.ttl) { + return cached; + } + + // Stale or not found + if (cached) { + const age = Math.floor((Date.now() - cached.updatedAt) / 1000 / 60); + console.debug( + `[ReplaceableEventCache] kind:${kind} for ${pubkey.slice(0, 8)}${d ? `/${d}` : ""} is stale (${age}min old)`, + ); + } + + return undefined; + } catch (error) { + console.error( + `[ReplaceableEventCache] Error reading kind:${kind} for ${pubkey.slice(0, 8)}:`, + error, + ); + return undefined; + } + } + + /** + * Store replaceable event in cache + */ + async set(event: NostrEvent): Promise { + try { + if (!CACHED_KINDS.includes(event.kind)) { + console.warn( + `[ReplaceableEventCache] Attempted to cache unconfigured kind ${event.kind}`, + ); + return; + } + + // Extract d-tag for parameterized replaceable events + const d = isParameterizedReplaceable(event.kind) + ? getTagValue(event, "d") || "" + : ""; + + // Store in Dexie and memory cache + const cachedEntry: CachedReplaceableEvent = { + pubkey: event.pubkey, + kind: event.kind, + d, + event, + updatedAt: Date.now(), + }; + + await db.replaceableEvents.put(cachedEntry); + + // Also populate memory cache + const cacheKey = buildCacheKey(event.pubkey, event.kind, d); + this.memoryCache.set(cacheKey, cachedEntry); + this.cacheOrder.push(cacheKey); + this.evictOldest(); + + console.debug( + `[ReplaceableEventCache] Cached kind:${event.kind} for ${event.pubkey.slice(0, 8)}${d ? `/${d}` : ""}`, + ); + } catch (error) { + console.error( + `[ReplaceableEventCache] Error caching kind:${event.kind} for ${event.pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Update LRU order for a cache key + */ + private updateLRU(cacheKey: string): void { + const index = this.cacheOrder.indexOf(cacheKey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + this.cacheOrder.push(cacheKey); + } + + /** + * Evict oldest entries from memory cache if over limit + */ + private evictOldest(): void { + while (this.cacheOrder.length > MAX_MEMORY_CACHE) { + const oldest = this.cacheOrder.shift(); + if (oldest) { + this.memoryCache.delete(oldest); + } + } + } + + /** + * Get event from memory cache only (synchronous, fast) + * Returns null if not in memory cache or stale + */ + getSync(pubkey: string, kind: number, d: string = ""): NostrEvent | null { + const cacheKey = buildCacheKey(pubkey, kind, d); + const memCached = this.memoryCache.get(cacheKey); + + if (memCached && Date.now() - memCached.updatedAt < this.ttl) { + this.updateLRU(cacheKey); + return memCached.event; + } + + return null; + } + + /** + * Get event for a pubkey+kind from cache (async, checks Dexie) + */ + async getEvent( + pubkey: string, + kind: number, + d: string = "", + ): Promise { + // Check memory cache first (< 1ms) + const cacheKey = buildCacheKey(pubkey, kind, d); + const memCached = this.memoryCache.get(cacheKey); + + if (memCached && Date.now() - memCached.updatedAt < this.ttl) { + this.updateLRU(cacheKey); + return memCached.event; + } + + // Then check Dexie (5-10ms) + const cached = await this.get(pubkey, kind, d); + if (cached) { + // Populate memory cache for next time + this.memoryCache.set(cacheKey, cached); + this.cacheOrder.push(cacheKey); + this.evictOldest(); + return cached.event; + } + + return null; + } + + /** + * Get all events for a pubkey+kind (for parameterized replaceables) + * Returns array of events, useful for kinds like 30000-39999 with multiple d-tags + */ + async getAllForKind(pubkey: string, kind: number): Promise { + try { + const cached = await db.replaceableEvents + .where("[pubkey+kind]") + .equals([pubkey, kind]) + .toArray(); + + // Filter out stale entries + const fresh = cached.filter( + (entry) => Date.now() - entry.updatedAt < this.ttl, + ); + + return fresh.map((entry) => entry.event); + } catch (error) { + console.error( + `[ReplaceableEventCache] Error reading all kind:${kind} for ${pubkey.slice(0, 8)}:`, + error, + ); + return []; + } + } + + /** + * Check if we have a valid cache entry + */ + async has(pubkey: string, kind: number, d: string = ""): Promise { + const cached = await this.get(pubkey, kind, d); + return cached !== undefined; + } + + /** + * Invalidate (delete) cache entry + */ + async invalidate( + pubkey: string, + kind: number, + d: string = "", + ): Promise { + try { + await db.replaceableEvents.delete([pubkey, kind, d]); + + // Also remove from memory cache + const cacheKey = buildCacheKey(pubkey, kind, d); + this.memoryCache.delete(cacheKey); + const index = this.cacheOrder.indexOf(cacheKey); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + + console.debug( + `[ReplaceableEventCache] Invalidated kind:${kind} for ${pubkey.slice(0, 8)}${d ? `/${d}` : ""}`, + ); + } catch (error) { + console.error( + `[ReplaceableEventCache] Error invalidating kind:${kind} for ${pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Invalidate all entries for a pubkey+kind (useful for parameterized replaceables) + */ + async invalidateKind(pubkey: string, kind: number): Promise { + try { + const count = await db.replaceableEvents + .where("[pubkey+kind]") + .equals([pubkey, kind]) + .delete(); + + // Also remove from memory cache + const keysToDelete: string[] = []; + for (const key of this.memoryCache.keys()) { + if (key.startsWith(`${pubkey}:${kind}:`)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.memoryCache.delete(key); + const index = this.cacheOrder.indexOf(key); + if (index > -1) { + this.cacheOrder.splice(index, 1); + } + } + + console.debug( + `[ReplaceableEventCache] Invalidated ${count} kind:${kind} entries for ${pubkey.slice(0, 8)}`, + ); + } catch (error) { + console.error( + `[ReplaceableEventCache] Error invalidating kind:${kind} for ${pubkey.slice(0, 8)}:`, + error, + ); + } + } + + /** + * Clear all cached events + */ + async clear(): Promise { + try { + await db.replaceableEvents.clear(); + this.memoryCache.clear(); + this.cacheOrder = []; + console.log("[ReplaceableEventCache] Cleared all cached events"); + } catch (error) { + console.error("[ReplaceableEventCache] Error clearing cache:", error); + } + } + + /** + * Get cache statistics + */ + async getStats(): Promise<{ + count: number; + byKind: Record; + oldestEntry: number | null; + newestEntry: number | null; + memoryCacheSize: number; + memoryCacheLimit: number; + }> { + try { + const count = await db.replaceableEvents.count(); + const all = await db.replaceableEvents.toArray(); + + const byKind: Record = {}; + for (const entry of all) { + byKind[entry.kind] = (byKind[entry.kind] || 0) + 1; + } + + if (all.length === 0) { + return { + count: 0, + byKind: {}, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + + const timestamps = all.map((entry) => entry.updatedAt); + const oldest = Math.min(...timestamps); + const newest = Math.max(...timestamps); + + return { + count, + byKind, + oldestEntry: oldest, + newestEntry: newest, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } catch (error) { + console.error("[ReplaceableEventCache] Error getting stats:", error); + return { + count: 0, + byKind: {}, + oldestEntry: null, + newestEntry: null, + memoryCacheSize: this.memoryCache.size, + memoryCacheLimit: MAX_MEMORY_CACHE, + }; + } + } + + /** + * Clean up stale entries older than TTL + */ + async cleanStale(): Promise { + try { + const cutoff = Date.now() - this.ttl; + const count = await db.replaceableEvents + .where("updatedAt") + .below(cutoff) + .delete(); + + console.log( + `[ReplaceableEventCache] Cleaned ${count} stale entries older than ${Math.floor(this.ttl / 1000 / 60 / 60)}h`, + ); + + return count; + } catch (error) { + console.error("[ReplaceableEventCache] Error cleaning stale:", error); + return 0; + } + } +} + +// Singleton instance +export const replaceableEventCache = new ReplaceableEventCache(); +export default replaceableEventCache;