Browse Source

remove in-memory profile and replaceables cache

imwald
Silberengel 1 month ago
parent
commit
61bf136119
  1. 54
      nip66-cron/index.mjs
  2. 7
      src/components/Sidebar/AccountButton.tsx
  3. 63
      src/hooks/useFetchProfile.tsx
  4. 2
      src/providers/NostrProvider/index.tsx
  5. 200
      src/services/client-replaceable-events.service.ts
  6. 11
      src/services/indexed-db.service.ts

54
nip66-cron/index.mjs

@ -179,9 +179,30 @@ async function fetchRelayUrlsFromKind10002 (authorPubkey, queryRelayUrls) {
try { try {
ws = new WebSocket(relayUrl, { handshakeTimeout: 12000 }) ws = new WebSocket(relayUrl, { handshakeTimeout: 12000 })
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ws.on('open', resolve) let timeoutId
ws.on('error', reject) let resolved = false
setTimeout(() => reject(new Error('open timeout')), 15000) const onOpen = () => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
resolve()
}
const onError = (err) => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
ws.removeListener('open', onOpen)
reject(err)
}
timeoutId = setTimeout(() => {
if (resolved) return
resolved = true
ws.removeListener('open', onOpen)
ws.removeListener('error', onError)
reject(new Error('open timeout'))
}, 15000)
ws.once('open', onOpen)
ws.on('error', onError)
}) })
ws.send(JSON.stringify(['REQ', subId, filter])) ws.send(JSON.stringify(['REQ', subId, filter]))
const events = await new Promise((resolve) => { const events = await new Promise((resolve) => {
@ -306,9 +327,30 @@ async function publishEvent (relayUrls, event) {
try { try {
const ws = new WebSocket(url, { handshakeTimeout: 8000 }) const ws = new WebSocket(url, { handshakeTimeout: 8000 })
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ws.on('open', resolve) let timeoutId
ws.on('error', reject) let resolved = false
setTimeout(() => reject(new Error('open timeout')), 10000) const onOpen = () => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
resolve()
}
const onError = (err) => {
if (resolved) return
resolved = true
clearTimeout(timeoutId)
ws.removeListener('open', onOpen)
reject(err)
}
timeoutId = setTimeout(() => {
if (resolved) return
resolved = true
ws.removeListener('open', onOpen)
ws.removeListener('error', onError)
reject(new Error('open timeout'))
}, 10000)
ws.once('open', onOpen)
ws.on('error', onError)
}) })
conns.push(ws) conns.push(ws)
ws.send(msg) ws.send(msg)

7
src/components/Sidebar/AccountButton.tsx

@ -8,7 +8,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { toWallet } from '@/lib/link' import { toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey' import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager' import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react' import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react'
@ -39,7 +39,10 @@ function ProfileButton() {
if (!pubkey) return null if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey) const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar } // Fallback to formatted npub if no profile
const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar }
return ( return (
<DropdownMenu> <DropdownMenu>

63
src/hooks/useFetchProfile.tsx

@ -2,9 +2,9 @@ import { getProfileFromEvent } from '@/lib/event-metadata'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { replaceableEventService } from '@/services/client.service' import { replaceableEventService } from '@/services/client.service'
import { kinds } from 'nostr-tools'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import logger from '@/lib/logger'
export function useFetchProfile(id?: string, skipCache = false) { export function useFetchProfile(id?: string, skipCache = false) {
const { profile: currentAccountProfile } = useNostr() const { profile: currentAccountProfile } = useNostr()
@ -15,18 +15,29 @@ export function useFetchProfile(id?: string, skipCache = false) {
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null) const checkIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Function to check for profile updates // Function to check for profile updates
const checkProfile = async (pubkey: string, cancelled: { current: boolean }) => { // fetchProfileEvent already checks: 1) in-memory cache, 2) IndexedDB, 3) network (with author's relays)
if (cancelled.current) return // Memoize to prevent recreation on every render
const checkProfile = useCallback(async (pubkey: string, cancelled: { current: boolean }) => {
if (cancelled.current) return false
try { try {
// Re-check cache (might have been updated by background fetch) // Use fetchProfileEvent which includes author's relay list for better profile discovery
const profileEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata) // fetchProfileEvent handles all cache layers:
// 1. In-memory cache (instant return)
// 2. IndexedDB (fast async)
// 3. Network (with author's relay list for better discovery)
const profileEvent = await replaceableEventService.fetchProfileEvent(pubkey, skipCache)
if (cancelled.current) return if (cancelled.current) return false
if (profileEvent) { if (profileEvent) {
// getProfileFromEvent always returns a profile object (with fallback username)
const newProfile = getProfileFromEvent(profileEvent) const newProfile = getProfileFromEvent(profileEvent)
if (newProfile) { logger.debug('[useFetchProfile] Profile found', {
pubkey: pubkey.substring(0, 8),
username: newProfile.username,
hasAvatar: !!newProfile.avatar
})
setProfile(newProfile) setProfile(newProfile)
setIsFetching(false) setIsFetching(false)
// Clear interval once we have a profile // Clear interval once we have a profile
@ -36,15 +47,18 @@ export function useFetchProfile(id?: string, skipCache = false) {
} }
return true return true
} }
} logger.debug('[useFetchProfile] No profile event found', {
pubkey: pubkey.substring(0, 8)
})
return false return false
} catch (err) { } catch (err) {
if (!cancelled.current) { if (!cancelled.current) {
setError(err as Error) setError(err as Error)
setIsFetching(false)
} }
return false return false
} }
} }, [skipCache])
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@ -57,24 +71,33 @@ export function useFetchProfile(id?: string, skipCache = false) {
const cancelled = { current: false } const cancelled = { current: false }
const pubkey = userIdToPubkey(id) const pubkey = userIdToPubkey(id)
if (!pubkey) {
setProfile(null)
setPubkey(null)
setIsFetching(false)
setError(new Error('Invalid id: could not extract pubkey'))
return
}
setPubkey(pubkey) setPubkey(pubkey)
const run = async () => { const run = async () => {
setIsFetching(true) setIsFetching(true)
setError(null) setError(null)
// Initial fetch // Initial fetch - fetchReplaceableEvent checks: 1) in-memory, 2) IndexedDB, 3) network
const found = await checkProfile(pubkey, cancelled) const found = await checkProfile(pubkey, cancelled)
if (cancelled.current) return if (cancelled.current) return
if (found) { if (found) {
// Profile found, we're done // Profile found (from cache or network), we're done
return return
} }
// No profile found yet - set fetching to false but keep checking in background // No profile found yet - set fetching to false so UI can show fallback
// The profile will remain null, allowing components to show npub fallback
setIsFetching(false) setIsFetching(false)
setError(null) // Clear any previous errors
// If no profile was found, periodically re-check (profiles might load asynchronously) // If no profile was found, periodically re-check (profiles might load asynchronously)
// Check every 2 seconds for up to 30 seconds (15 checks) // Check every 2 seconds for up to 30 seconds (15 checks)
@ -110,18 +133,26 @@ export function useFetchProfile(id?: string, skipCache = false) {
checkIntervalRef.current = null checkIntervalRef.current = null
} }
} }
}, [id, skipCache]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, skipCache]) // checkProfile is memoized and stable, no need to include it
useEffect(() => { useEffect(() => {
if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) { // Only use currentAccountProfile if it matches the pubkey we're looking for
// Use pubkey from the profile object to avoid reference equality issues
if (currentAccountProfile?.pubkey && pubkey && pubkey === currentAccountProfile.pubkey) {
// Only update if we don't have a profile yet (avoid unnecessary updates)
// Using a ref to track if we've already set it to prevent loops
if (!profile) {
setProfile(currentAccountProfile) setProfile(currentAccountProfile)
setIsFetching(false)
// Clear interval if we got the profile from current account // Clear interval if we got the profile from current account
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current) clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null checkIntervalRef.current = null
} }
} }
}, [currentAccountProfile, pubkey]) }
}, [currentAccountProfile?.pubkey, pubkey]) // Removed profile?.pubkey to prevent loops
return { isFetching, error, profile } return { isFetching, error, profile }
} }

2
src/providers/NostrProvider/index.tsx

@ -439,6 +439,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (profileEvent) { if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) { if (updatedProfileEvent.id === profileEvent.id) {
// Update in-memory cache so it's immediately available
await replaceableEventService.updateReplaceableEventCache(updatedProfileEvent)
setProfileEvent(updatedProfileEvent) setProfileEvent(updatedProfileEvent)
setProfile(getProfileFromEvent(updatedProfileEvent)) setProfile(getProfileFromEvent(updatedProfileEvent))
} }

200
src/services/client-replaceable-events.service.ts

@ -22,18 +22,6 @@ export class ReplaceableEventService {
max: 50, max: 50,
ttl: 1000 * 60 * 60 ttl: 1000 * 60 * 60
}) })
// In-memory cache for profiles - instant access, no IndexedDB blocking
private profileMemoryCache = new LRUCache<string, NEvent>({
max: 1000, // Cache up to 1000 profiles in memory
ttl: 1000 * 60 * 30, // 30 minutes TTL
updateAgeOnGet: true // Refresh TTL on access
})
// In-memory cache for all replaceable events - fast access
private replaceableEventMemoryCache = new LRUCache<string, NEvent>({
max: 2000, // Cache up to 2000 events in memory
ttl: 1000 * 60 * 30, // 30 minutes TTL
updateAgeOnGet: true
})
private replaceableEventFromBigRelaysDataloader: DataLoader< private replaceableEventFromBigRelaysDataloader: DataLoader<
{ pubkey: string; kind: number }, { pubkey: string; kind: number },
NEvent | null, NEvent | null,
@ -146,36 +134,25 @@ export class ReplaceableEventService {
d?: string, d?: string,
containingEventRelays: string[] = [] containingEventRelays: string[] = []
): Promise<NEvent | undefined> { ): Promise<NEvent | undefined> {
const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}` // 1. Check IndexedDB (async but faster than network)
// 1. Check in-memory cache FIRST - instant return, no async overhead
const memoryCached = this.replaceableEventMemoryCache.get(cacheKey)
if (memoryCached) {
// Check tombstone in background (non-blocking)
this.checkTombstoneAndUpdateCache(memoryCached, kind).catch(() => {})
// Fetch in background to update cache if newer version exists
this.refreshInBackground(pubkey, kind, d).catch(() => {})
return memoryCached
}
// 2. Check IndexedDB (async but faster than network)
try { try {
const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d) const indexedDbCached = await indexedDb.getReplaceableEvent(pubkey, kind, d)
if (indexedDbCached) { if (indexedDbCached) {
// Check tombstone (non-blocking - check in background) // Check tombstone in background (non-blocking)
const tombstoneKey = isReplaceableEvent(kind) const tombstoneKey = isReplaceableEvent(kind)
? getReplaceableCoordinateFromEvent(indexedDbCached) ? getReplaceableCoordinateFromEvent(indexedDbCached)
: indexedDbCached.id : indexedDbCached.id
// Check tombstone in background, don't block
indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => { indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => {
if (isTombstoned) { if (isTombstoned) {
// Remove from caches if tombstoned // Event is tombstoned - will be handled by IndexedDB cleanup
this.replaceableEventMemoryCache.delete(cacheKey) logger.debug('[ReplaceableEventService] Event is tombstoned', {
} else { pubkey: formatPubkey(pubkey),
// Add to memory cache for next time kind
this.replaceableEventMemoryCache.set(cacheKey, indexedDbCached) })
} }
}).catch(() => {}) }).catch(() => {
// If tombstone check fails, keep it in cache (better to show stale than nothing)
})
// Fetch in background to update cache if newer version exists // Fetch in background to update cache if newer version exists
this.refreshInBackground(pubkey, kind, d).catch(() => {}) this.refreshInBackground(pubkey, kind, d).catch(() => {})
@ -183,9 +160,14 @@ export class ReplaceableEventService {
} }
} catch (error) { } catch (error) {
// IndexedDB error - continue to network fetch // IndexedDB error - continue to network fetch
logger.warn('[ReplaceableEventService] IndexedDB error', {
pubkey: formatPubkey(pubkey),
kind,
error: error instanceof Error ? error.message : String(error)
})
} }
// 3. Not in cache, fetch from network // 2. Not in cache, fetch from network
// Note: DataLoader will use comprehensive relay list from batch load function // Note: DataLoader will use comprehensive relay list from batch load function
// For profiles: if we have containingEventRelays (from fetchProfileEvent), include them // For profiles: if we have containingEventRelays (from fetchProfileEvent), include them
// Profiles are often on the same relays where the author publishes their events // Profiles are often on the same relays where the author publishes their events
@ -218,12 +200,6 @@ export class ReplaceableEventService {
// Extract relay hints from the found event (for future related fetches) // Extract relay hints from the found event (for future related fetches)
const eventRelayHints = this.extractRelayHintsFromEvent(event) const eventRelayHints = this.extractRelayHintsFromEvent(event)
// Add to memory cache for instant access next time
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(pubkey, event)
}
// If we found relay hints, log them (they're already used in the batch load function) // If we found relay hints, log them (they're already used in the batch load function)
if (eventRelayHints.length > 0) { if (eventRelayHints.length > 0) {
logger.debug('[ReplaceableEventService] Found relay hints in event', { logger.debug('[ReplaceableEventService] Found relay hints in event', {
@ -238,8 +214,7 @@ export class ReplaceableEventService {
// Log when no event is found (helps debug relay failures) // Log when no event is found (helps debug relay failures)
if (kind === kinds.Metadata) { if (kind === kinds.Metadata) {
logger.debug('[ReplaceableEventService] No profile found for pubkey', { logger.debug('[ReplaceableEventService] No profile found for pubkey', {
pubkey: formatPubkey(pubkey), pubkey: formatPubkey(pubkey)
cacheKey
}) })
} }
} catch (error) { } catch (error) {
@ -255,22 +230,6 @@ export class ReplaceableEventService {
return undefined return undefined
} }
/**
* Check tombstone and update cache (non-blocking background operation)
*/
private async checkTombstoneAndUpdateCache(event: NEvent, kind: number): Promise<void> {
const tombstoneKey = isReplaceableEvent(kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
const isTombstoned = await indexedDb.isTombstoned(tombstoneKey)
if (isTombstoned) {
const cacheKey = isReplaceableEvent(kind)
? `${kind}:${event.pubkey}`
: `${kind}:${event.pubkey}:${event.id}`
this.replaceableEventMemoryCache.delete(cacheKey)
}
}
/** /**
* Refresh event in background (non-blocking) * Refresh event in background (non-blocking)
*/ */
@ -279,11 +238,7 @@ export class ReplaceableEventService {
if (d) { if (d) {
await this.replaceableEventDataLoader.load({ pubkey, kind, d }) await this.replaceableEventDataLoader.load({ pubkey, kind, d })
} else { } else {
const event = await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind })
if (event) {
const cacheKey = `${kind}:${pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
}
} }
} catch { } catch {
// Ignore errors in background refresh // Ignore errors in background refresh
@ -292,47 +247,31 @@ export class ReplaceableEventService {
/** /**
* Batch fetch replaceable events from profile fetch relays * Batch fetch replaceable events from profile fetch relays
* Optimized: checks memory cache first (instant), then IndexedDB, then network * Checks IndexedDB first, then network
*/ */
async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> { async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> {
// First check memory cache (instant) const results: (NEvent | undefined)[] = []
const memoryCached: (NEvent | undefined)[] = [] const misses: { pubkey: string; index: number }[] = []
const memoryMisses: { pubkey: string; index: number }[] = []
pubkeys.forEach((pubkey, i) => {
const cacheKey = `${kind}:${pubkey}`
const cached = this.replaceableEventMemoryCache.get(cacheKey)
if (cached) {
memoryCached[i] = cached
} else {
memoryMisses.push({ pubkey, index: i })
}
})
// For memory misses, check IndexedDB in parallel // Check IndexedDB in parallel
const indexedDbPromises = memoryMisses.map(async ({ pubkey, index }) => { const indexedDbPromises = pubkeys.map(async (pubkey, index) => {
try { try {
const event = await indexedDb.getReplaceableEvent(pubkey, kind) const event = await indexedDb.getReplaceableEvent(pubkey, kind)
if (event) { if (event) {
// Add to memory cache results[index] = event
const cacheKey = `${kind}:${pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(pubkey, event)
}
memoryCached[index] = event
return { index, event } return { index, event }
} }
} catch { } catch {
// Ignore errors // Ignore errors
} }
misses.push({ pubkey, index })
return null return null
}) })
await Promise.allSettled(indexedDbPromises) await Promise.allSettled(indexedDbPromises)
// Find what's still missing and fetch from network // Find what's still missing and fetch from network
const stillMissing = memoryMisses.filter(({ index }) => memoryCached[index] === undefined) const stillMissing = misses.filter(({ index }) => results[index] === undefined)
if (stillMissing.length > 0) { if (stillMissing.length > 0) {
const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany(
stillMissing.map(({ pubkey }) => ({ pubkey, kind })) stillMissing.map(({ pubkey }) => ({ pubkey, kind }))
@ -341,27 +280,20 @@ export class ReplaceableEventService {
if (event && !(event instanceof Error)) { if (event && !(event instanceof Error)) {
const { index } = stillMissing[idx]! const { index } = stillMissing[idx]!
if (index !== undefined) { if (index !== undefined) {
memoryCached[index] = event ?? undefined results[index] = event ?? undefined
// Add to memory cache
if (event) {
const cacheKey = `${kind}:${stillMissing[idx]!.pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(stillMissing[idx]!.pubkey, event)
}
}
} }
} }
}) })
} }
return memoryCached return results
} }
/** /**
* Update replaceable event cache * Update replaceable event cache
*/ */
async updateReplaceableEventCache(event: NEvent): Promise<void> { async updateReplaceableEventCache(event: NEvent): Promise<void> {
// Update DataLoader cache and IndexedDB
await this.updateReplaceableEventFromBigRelaysCache(event) await this.updateReplaceableEventFromBigRelaysCache(event)
} }
@ -371,28 +303,6 @@ export class ReplaceableEventService {
clearCaches(): void { clearCaches(): void {
this.replaceableEventFromBigRelaysDataloader.clearAll() this.replaceableEventFromBigRelaysDataloader.clearAll()
this.replaceableEventDataLoader.clearAll() this.replaceableEventDataLoader.clearAll()
this.replaceableEventMemoryCache.clear()
this.profileMemoryCache.clear()
}
/**
* Pre-load profiles into memory cache for instant access
*/
async preloadProfiles(pubkeys: string[]): Promise<void> {
// Load from IndexedDB in parallel
const promises = pubkeys.map(async (pubkey) => {
try {
const event = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (event) {
const cacheKey = `${kinds.Metadata}:${pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
this.profileMemoryCache.set(pubkey, event)
}
} catch {
// Ignore errors
}
})
await Promise.allSettled(promises)
} }
/** /**
@ -457,25 +367,10 @@ export class ReplaceableEventService {
} }
for (const event of events) { for (const event of events) {
// Check tombstone in background (non-blocking)
const tombstoneKey = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
// Don't block on tombstone check - do it in background
indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => {
if (isTombstoned) {
const cacheKey = `${event.kind}:${event.pubkey}`
this.replaceableEventMemoryCache.delete(cacheKey)
}
}).catch(() => {})
const key = `${event.pubkey}:${event.kind}` const key = `${event.pubkey}:${event.kind}`
const existing = eventsMap.get(key) const existing = eventsMap.get(key)
if (!existing || existing.created_at < event.created_at) { if (!existing || existing.created_at < event.created_at) {
eventsMap.set(key, event) eventsMap.set(key, event)
// Add to memory cache
const cacheKey = `${event.kind}:${event.pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
} }
} }
}) })
@ -485,12 +380,6 @@ export class ReplaceableEventService {
const key = `${pubkey}:${kind}` const key = `${pubkey}:${kind}`
const event = eventsMap.get(key) const event = eventsMap.get(key)
if (event) { if (event) {
// Add to memory cache for instant access
const cacheKey = `${kind}:${pubkey}`
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(pubkey, event)
}
indexedDb.putReplaceableEvent(event) indexedDb.putReplaceableEvent(event)
return event return event
} else { } else {
@ -537,25 +426,10 @@ export class ReplaceableEventService {
}) })
for (const event of events) { for (const event of events) {
// Check tombstone in background (non-blocking)
const tombstoneKey = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: event.id
// Don't block on tombstone check - do it in background
indexedDb.isTombstoned(tombstoneKey).then(isTombstoned => {
if (isTombstoned) {
const cacheKey = `${event.kind}:${event.pubkey}:${d ?? ''}`
this.replaceableEventMemoryCache.delete(cacheKey)
}
}).catch(() => {})
const eventKey = `${event.pubkey}:${event.kind}:${d ?? ''}` const eventKey = `${event.pubkey}:${event.kind}:${d ?? ''}`
const existing = eventsMap.get(eventKey) const existing = eventsMap.get(eventKey)
if (!existing || existing.created_at < event.created_at) { if (!existing || existing.created_at < event.created_at) {
eventsMap.set(eventKey, event) eventsMap.set(eventKey, event)
// Add to memory cache
const cacheKey = `${event.kind}:${event.pubkey}:${d ?? ''}`
this.replaceableEventMemoryCache.set(cacheKey, event)
} }
} }
}) })
@ -565,12 +439,6 @@ export class ReplaceableEventService {
const eventKey = `${pubkey}:${kind}:${d ?? ''}` const eventKey = `${pubkey}:${kind}:${d ?? ''}`
const event = eventsMap.get(eventKey) const event = eventsMap.get(eventKey)
if (event) { if (event) {
// Add to memory cache for instant access
const cacheKey = `${kind}:${pubkey}:${d ?? ''}`
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(pubkey, event)
}
indexedDb.putReplaceableEvent(event) indexedDb.putReplaceableEvent(event)
return event return event
} else { } else {
@ -589,6 +457,7 @@ export class ReplaceableEventService {
{ pubkey: event.pubkey, kind: event.kind }, { pubkey: event.pubkey, kind: event.kind },
Promise.resolve(event) Promise.resolve(event)
) )
// Store in IndexedDB
await indexedDb.putReplaceableEvent(event) await indexedDb.putReplaceableEvent(event)
} }
@ -599,7 +468,7 @@ export class ReplaceableEventService {
/** /**
* Fetch profile event by id (hex, npub, nprofile) * Fetch profile event by id (hex, npub, nprofile)
*/ */
async fetchProfileEvent(id: string, skipCache: boolean = false): Promise<NEvent | undefined> { async fetchProfileEvent(id: string, _skipCache: boolean = false): Promise<NEvent | undefined> {
let pubkey: string | undefined let pubkey: string | undefined
let relays: string[] = [] let relays: string[] = []
if (/^[0-9a-f]{64}$/.test(id)) { if (/^[0-9a-f]{64}$/.test(id)) {
@ -620,12 +489,6 @@ export class ReplaceableEventService {
if (!pubkey) { if (!pubkey) {
throw new Error('Invalid id') throw new Error('Invalid id')
} }
if (!skipCache) {
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (localProfile) {
return localProfile
}
}
// For profiles: get author's relay list (from cache if available) and use those relays // For profiles: get author's relay list (from cache if available) and use those relays
// Profiles are often on the same relays where the author publishes their events // Profiles are often on the same relays where the author publishes their events
@ -650,6 +513,7 @@ export class ReplaceableEventService {
}) })
} }
// Use fetchReplaceableEvent which checks IndexedDB then network
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, relays) const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, relays)
if (profileEvent) { if (profileEvent) {
await this.indexProfile(profileEvent) await this.indexProfile(profileEvent)

11
src/services/indexed-db.service.ts

@ -452,10 +452,17 @@ class IndexedDbService {
return resolve(undefined) return resolve(undefined)
} }
// Invalidate profile and payment info cache when stale so they refetch regularly // Invalidate profile and payment info cache when stale so they refetch regularly
// BUT: Always return cached profiles even if stale - we'll refresh in background
// This ensures profiles are always visible, even if slightly outdated
const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) { if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) {
transaction.commit() // Profile is stale, but return it anyway - refresh will happen in background
return resolve(undefined) // This prevents the "no profile" state when cache exists but is just old
logger.debug('[IndexedDB] Profile cache is stale but returning anyway', {
pubkey: pubkey.substring(0, 8),
age: Date.now() - row.addedAt,
maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS
})
} }
transaction.commit() transaction.commit()
resolve(row.value) resolve(row.value)

Loading…
Cancel
Save