Files
eranos/src/lib/profileCache.ts
T
Alex Gleason 2ecd557430 Fix IndexedDB crash on iOS Lockdown Mode
openDatabase() now catches errors from idb's openDB() (which throws
synchronously when indexedDB is undefined) and returns null. All
consumers — profileCache, nip05Cache, dmMessageStore — check for null
and silently degrade to in-memory only.

The DM message store also stops re-throwing errors, which previously
could produce unhandled rejections in DMProvider.
2026-04-06 13:41:32 -05:00

120 lines
3.9 KiB
TypeScript

import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { openDatabase, STORE } from '@/lib/db';
// ============================================================================
// Kind 0 Profile IndexedDB Cache
//
// Caches kind 0 profile events so repeat visits render author names, avatars,
// and other metadata instantly instead of showing loading skeletons.
// Each entry stores the raw NostrEvent, the pre-parsed metadata, and a
// `lastFetched` timestamp so the caller can decide when to re-check.
//
// Storing the parsed metadata avoids re-running expensive Zod validation
// on every render when used as TanStack Query `initialData`.
// ============================================================================
export interface ProfileCacheEntry {
/** Hex pubkey of the profile author */
pubkey: string;
/** The raw kind 0 NostrEvent */
event: NostrEvent;
/** Pre-parsed metadata (avoids re-running Zod on every render) */
metadata?: NostrMetadata;
/** Unix-ms timestamp of the last successful fetch */
lastFetched: number;
}
// ---------------------------------------------------------------------------
// In-memory mirror — hydrated once from IndexedDB so React hooks can read
// synchronously on first render (no async waterfall).
// ---------------------------------------------------------------------------
const memoryCache = new Map<string, ProfileCacheEntry>();
let hydrated = false;
let hydratePromise: Promise<void> | null = null;
/** Ensure the in-memory mirror is populated. Safe to call many times. */
export function hydrateProfileCache(): Promise<void> {
if (hydrated) return Promise.resolve();
if (hydratePromise) return hydratePromise;
hydratePromise = (async () => {
try {
const db = await openDatabase();
if (!db) return; // IndexedDB unavailable — skip hydration.
const entries: ProfileCacheEntry[] = await db.getAll(STORE.PROFILES);
for (const entry of entries) {
memoryCache.set(entry.pubkey, entry);
}
} catch {
// IndexedDB read failure — silently degrade.
} finally {
hydrated = true;
}
})();
return hydratePromise;
}
/** Read a cached profile synchronously from the in-memory mirror. */
export function getProfileCached(pubkey: string): ProfileCacheEntry | undefined {
return memoryCache.get(pubkey);
}
/**
* Persist a kind 0 profile event with its pre-parsed metadata.
* Only writes if the event is newer than (or equal to) what we already have,
* so out-of-order arrivals don't downgrade the cache.
* Updates both the in-memory mirror and IndexedDB.
*
* Passing `metadata` is optional but recommended — it avoids re-running Zod
* validation when the cached entry is later used as TanStack Query `initialData`.
*/
export async function setProfileCached(event: NostrEvent, metadata?: NostrMetadata): Promise<void> {
const existing = memoryCache.get(event.pubkey);
if (existing && existing.event.created_at > event.created_at) {
return; // Don't overwrite a newer event with an older one.
}
const entry: ProfileCacheEntry = {
pubkey: event.pubkey,
event,
metadata,
lastFetched: Date.now(),
};
memoryCache.set(event.pubkey, entry);
try {
const db = await openDatabase();
if (db) await db.put(STORE.PROFILES, entry, event.pubkey);
} catch {
// Write failure is non-critical — the in-memory cache still works.
}
}
/** Remove a single profile entry. */
export async function deleteProfileCached(pubkey: string): Promise<void> {
memoryCache.delete(pubkey);
try {
const db = await openDatabase();
if (db) await db.delete(STORE.PROFILES, pubkey);
} catch {
// Non-critical.
}
}
/** Clear the entire profile cache. */
export async function clearProfileCache(): Promise<void> {
memoryCache.clear();
try {
const db = await openDatabase();
if (db) await db.clear(STORE.PROFILES);
} catch {
// Non-critical.
}
}