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
This commit is contained in:
Claude
2026-01-16 21:08:02 +00:00
parent 97f18de358
commit e088042519
7 changed files with 571 additions and 507 deletions

View File

@@ -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) => {

View File

@@ -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]);
}

View File

@@ -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<string, CachedBlossomServerList>();
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<CachedBlossomServerList | undefined> {
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<void> {
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<string[] | null> {
// 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<boolean> {
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<void> {
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<void> {
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);
}
}

View File

@@ -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<Profile>;
nip05!: Table<Nip05>;
@@ -96,6 +108,7 @@ class GrimoireDb extends Dexie {
relayLists!: Table<CachedRelayList>;
relayLiveness!: Table<RelayLivenessEntry>;
blossomServers!: Table<CachedBlossomServerList>;
replaceableEvents!: Table<CachedReplaceableEvent>;
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
@@ -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",
});
}
}

View File

@@ -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<string, CachedRelayList>();
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<CachedRelayList | undefined> {
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<void> {
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<string[] | null> {
// 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<string[] | null> {
// 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<boolean> {
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<void> {
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<void> {
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);
}
}

View File

@@ -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`,
);

View File

@@ -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<string, CachedReplaceableEvent>();
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<CachedReplaceableEvent | undefined> {
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<void> {
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<NostrEvent | null> {
// 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<NostrEvent[]> {
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<boolean> {
const cached = await this.get(pubkey, kind, d);
return cached !== undefined;
}
/**
* Invalidate (delete) cache entry
*/
async invalidate(
pubkey: string,
kind: number,
d: string = "",
): Promise<void> {
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<void> {
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<void> {
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<number, number>;
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<number, number> = {};
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<number> {
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;