feat: add cache hydration and ACTIVE_USER_KINDS config

Implement configuration-driven cache hydration and active user sync:

- Add ACTIVE_USER_KINDS constant [3, 10002, 10063] for configurable sync
- Add hydrateEventStore() method to load cached events on startup
- Refactor useAccountSync to loop through ACTIVE_USER_KINDS dynamically
- Remove deprecated useRelayListCacheSync/useBlossomServerCacheSync hooks
- Call hydration in AppShell on mount to solve "orphaned cache" problem

Benefits:
- Adding new kinds is just updating ACTIVE_USER_KINDS array
- Cache is immediately available on startup (no wasted bandwidth)
- Cleaner, more maintainable code with single loop
- Kind 3 (contacts) now auto-synced for $contacts alias
This commit is contained in:
Claude
2026-01-16 22:05:43 +00:00
parent e088042519
commit 1283964d01
3 changed files with 146 additions and 94 deletions

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, ReactNode } from "react";
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 { useEventStore } from "applesauce-react/hooks";
import relayStateManager from "@/services/relay-state-manager";
import replaceableEventCache from "@/services/replaceable-event-cache";
import { TabBar } from "../TabBar";
import CommandLauncher from "../CommandLauncher";
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
@@ -20,19 +20,21 @@ interface AppShellProps {
export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
const eventStore = useEventStore();
// Sync active account and fetch relay lists
useAccountSync();
// Auto-cache kind:10002 relay lists from EventStore to Dexie
useRelayListCacheSync();
// Auto-cache kind:10063 blossom server lists from EventStore to Dexie
useBlossomServerCacheSync();
// Hydrate EventStore from Dexie cache on startup (solves orphaned cache problem)
useEffect(() => {
replaceableEventCache.hydrateEventStore(eventStore).catch((err) => {
console.error("Failed to hydrate EventStore from cache:", err);
});
}, [eventStore]);
// Auto-cache generic replaceable events (contacts, relay lists, blossom servers, emoji lists, etc.)
useReplaceableEventCacheSync();
// Sync active account and fetch configured kinds
useAccountSync();
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {

View File

@@ -1,14 +1,18 @@
import { useEffect } from "react";
import { useEventStore, use$ } from "applesauce-react/hooks";
import type { Subscription } from "rxjs";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
import { addressLoader } from "@/services/loaders";
import { ACTIVE_USER_KINDS } from "@/services/replaceable-event-cache";
import type { RelayInfo } from "@/types/app";
import { normalizeRelayURL } from "@/lib/relay-url";
import { getServersFromEvent } from "@/services/blossom";
import type { NostrEvent } from "@/types/nostr";
/**
* Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
* Hook that syncs active account with Grimoire state and fetches configured replaceable events
* Automatically fetches and watches all kinds in ACTIVE_USER_KINDS
*/
export function useAccountSync() {
const {
@@ -26,103 +30,93 @@ export function useAccountSync() {
setActiveAccount(activeAccount?.pubkey);
}, [activeAccount?.pubkey, setActiveAccount]);
// Fetch and watch relay list (kind 10002) when account changes
// Fetch and watch all configured kinds for active user
useEffect(() => {
if (!activeAccount?.pubkey) {
return;
}
const pubkey = activeAccount.pubkey;
let lastRelayEventId: string | undefined;
const subscriptions: Subscription[] = [];
const lastEventIds = new Map<number, string>();
// Subscribe to kind 10002 (relay list)
const subscription = addressLoader({
kind: 10002,
pubkey,
identifier: "",
}).subscribe();
// Subscribe to all configured kinds
for (const kind of ACTIVE_USER_KINDS) {
// Fetch from relays
const fetchSub = addressLoader({
kind,
pubkey,
identifier: "",
}).subscribe();
// Watch for relay list event in store
const storeSubscription = eventStore
.replaceable(10002, pubkey, "")
.subscribe((relayListEvent) => {
if (!relayListEvent) return;
// Watch for updates in EventStore
const storeSub = eventStore
.replaceable(kind, pubkey, "")
.subscribe((event: NostrEvent | undefined) => {
if (!event) return;
// Only process if this is a new event
if (relayListEvent.id === lastRelayEventId) return;
lastRelayEventId = relayListEvent.id;
// Only process if this is a new event
if (event.id === lastEventIds.get(kind)) return;
lastEventIds.set(kind, event.id);
// Parse relays from tags (NIP-65 format)
// Tag format: ["r", "relay-url", "read|write"]
// If no marker, relay is used for both read and write
const relays: RelayInfo[] = [];
const seenUrls = new Set<string>();
for (const tag of relayListEvent.tags) {
if (tag[0] === "r" && tag[1]) {
try {
const url = normalizeRelayURL(tag[1]);
if (seenUrls.has(url)) continue;
seenUrls.add(url);
const marker = tag[2];
relays.push({
url,
read: !marker || marker === "read",
write: !marker || marker === "write",
});
} catch (error) {
console.warn(
`Skipping invalid relay URL in Kind 10002 event: ${tag[1]}`,
error,
);
}
// Handle specific kinds
if (kind === 10002) {
// Parse relay list (NIP-65)
const relays = parseRelayList(event);
setActiveAccountRelays(relays);
} else if (kind === 10063) {
// Parse blossom server list (BUD-03)
const servers = getServersFromEvent(event);
setActiveAccountBlossomServers(servers);
}
}
// Kind 3 (contacts) is auto-cached but doesn't need UI state updates
// Kind 10030 (emoji list) is auto-cached but doesn't need UI state updates
});
setActiveAccountRelays(relays);
});
return () => {
subscription.unsubscribe();
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
// Fetch and watch blossom server list (kind 10063) when account changes
useEffect(() => {
if (!activeAccount?.pubkey) {
return;
subscriptions.push(fetchSub, storeSub);
}
const pubkey = activeAccount.pubkey;
let lastBlossomEventId: string | undefined;
// Subscribe to kind 10063 (blossom server list)
const subscription = addressLoader({
kind: 10063,
pubkey,
identifier: "",
}).subscribe();
// Watch for blossom server list event in store
const storeSubscription = eventStore
.replaceable(10063, pubkey, "")
.subscribe((blossomListEvent) => {
if (!blossomListEvent) return;
// Only process if this is a new event
if (blossomListEvent.id === lastBlossomEventId) return;
lastBlossomEventId = blossomListEvent.id;
// Parse servers from event
const servers = getServersFromEvent(blossomListEvent);
setActiveAccountBlossomServers(servers);
});
return () => {
subscription.unsubscribe();
storeSubscription.unsubscribe();
subscriptions.forEach((sub) => sub.unsubscribe());
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]);
}, [
activeAccount?.pubkey,
eventStore,
setActiveAccountRelays,
setActiveAccountBlossomServers,
]);
}
/**
* Parse relay list event (NIP-65 format)
* Tag format: ["r", "relay-url", "read|write"]
* If no marker, relay is used for both read and write
*/
function parseRelayList(event: NostrEvent): RelayInfo[] {
const relays: RelayInfo[] = [];
const seenUrls = new Set<string>();
for (const tag of event.tags) {
if (tag[0] === "r" && tag[1]) {
try {
const url = normalizeRelayURL(tag[1]);
if (seenUrls.has(url)) continue;
seenUrls.add(url);
const marker = tag[2];
relays.push({
url,
read: !marker || marker === "read",
write: !marker || marker === "write",
});
} catch (error) {
console.warn(
`Skipping invalid relay URL in kind:10002 event: ${tag[1]}`,
error,
);
}
}
}
return relays;
}

View File

@@ -30,6 +30,20 @@ const MAX_MEMORY_CACHE = 200; // LRU cache size
*/
export const CACHED_KINDS = [3, 10002, 10063, 10030];
/**
* Kinds to always fetch and keep synced for active user
* These will be:
* - Hydrated from cache on startup
* - Auto-fetched from relays when user logs in
* - Kept up-to-date via addressLoader subscriptions
*/
export const ACTIVE_USER_KINDS = [
3, // Contacts - for $contacts alias resolution
10002, // Relay list - for outbox relay selection
10063, // Blossom servers - for media uploads
// 10030, // Emoji list - optional, uncomment to enable
];
/**
* Check if a kind is parameterized replaceable (30000-39999)
*/
@@ -83,6 +97,48 @@ class ReplaceableEventCache {
}
}
/**
* Hydrate EventStore with fresh cached events on startup
* Only loads events newer than TTL to avoid stale data
* This solves the "orphaned cache" problem where Dexie has data but EventStore doesn't
*/
async hydrateEventStore(eventStore: IEventStore): Promise<void> {
try {
const cutoff = Date.now() - this.ttl;
const fresh = await db.replaceableEvents
.where("updatedAt")
.above(cutoff)
.toArray();
console.log(
`[ReplaceableEventCache] Hydrating EventStore with ${fresh.length} cached events`,
);
// Add all fresh events to EventStore
for (const entry of fresh) {
await eventStore.add(entry.event);
// Also populate memory cache for fast access
const cacheKey = buildCacheKey(entry.pubkey, entry.kind, entry.d);
this.memoryCache.set(cacheKey, entry);
this.cacheOrder.push(cacheKey);
}
// Clean up excess memory cache entries
this.evictOldest();
console.log(
`[ReplaceableEventCache] Hydration complete. Memory cache: ${this.memoryCache.size} entries`,
);
} catch (error) {
console.error(
"[ReplaceableEventCache] Error hydrating EventStore:",
error,
);
}
}
/**
* Get cached event for a pubkey+kind (and optional d-tag)
* Returns undefined if not cached or stale