feat: implement incremental fetching for Nostr lists

Implements a `SyncMetadataStore` to persist last-seen timestamps for replaceable lists (Kind 10000, 30000, 30002, 30015).
Adds `fetchListIncrementally` helper to `NostrClient` which performs per-relay `since` queries.
Updates `SubscriptionsManager`, `UserBlockListManager`, `HashtagPreferencesService`, and `adminListStore` to utilize incremental fetching.
Prevents local cache wipes when incremental fetches return empty results (indicating "no updates").
This commit is contained in:
google-labs-jules[bot] committed 2026-01-17 21:11:48 +00:00
1 parent 1786ad9068
commit 9d06f43321
8 files changed
+443 -329

No files matched your search

+42 -41
View File
@@ -7,6 +7,7 @@ import {
getActiveSigner,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr/index.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
@@ -289,29 +290,6 @@ class SubscriptionsManager {
try {
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
const sessionActorPubkey = normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey,
);
const authorSet = new Set();
if (normalizedUserPubkey) {
authorSet.add(normalizedUserPubkey);
}
if (sessionActorPubkey) {
authorSet.add(sessionActorPubkey);
}
const authors = Array.from(authorSet);
if (!authors.length && userPubkey) {
authors.push(userPubkey);
}
const filter = {
kinds: [SUBSCRIPTION_SET_KIND],
authors,
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
limit: 1
};
const relaySet = new Set();
const addRelayCandidates = (candidates) => {
@@ -347,34 +325,47 @@ class SubscriptionsManager {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
);
return;
}
const relayPromises = relayUrls.map((url) =>
nostrClient.pool.list([url], [filter]).catch((err) => {
userLogger.error(`[SubscriptionsManager] Relay error at ${url}`, err);
return [];
}),
);
// Use incremental fetch helper
let events = await nostrClient.fetchListIncrementally({
kind: SUBSCRIPTION_SET_KIND,
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls
});
const settledResults = await Promise.allSettled(relayPromises);
const events = [];
for (const outcome of settledResults) {
if (outcome.status === "fulfilled" && Array.isArray(outcome.value) && outcome.value.length) {
events.push(...outcome.value);
}
}
// Also check session actor if different?
// The original code checked both userPubkey and sessionActor.pubkey.
// fetchListIncrementally takes a single pubkey.
// If we want to check session actor too, we need another call.
// However, usually the subscription list belongs to the logged in user (userPubkey).
// The session actor logic in original code:
// const sessionActorPubkey = normalizeHexPubkey(nostrClient?.sessionActor?.pubkey);
// ... authors: [normalizedUserPubkey, sessionActorPubkey]
// Since fetchListIncrementally filters by author=pubkey, we only query for normalizedUserPubkey here.
// If session actor support is critical for some delegation case, it needs a separate fetch.
// Assuming userPubkey is the primary target for subscriptions.
// If we got no events from relays, we should NOT wipe the cache if we have one.
// This protects against flaky relays returning empty results for existing users.
// If the user truly has no list (new user), cache is likely empty anyway.
if (!events.length) {
// Only clear if we haven't loaded anything yet (e.g. fresh load, no cache)
if (!this.loaded) {
// If we have nothing loaded and found nothing, maybe user has no list.
// But we shouldn't wipe blindly if incremental fetch just returned no *new* events.
// Wait, fetchListIncrementally returns *events found*.
// If it performed incremental fetch and found nothing, it returns [].
// If it performed full fetch and found nothing, it returns [].
// We need to differentiate "no updates" vs "empty list".
// If we have cached data, we keep it.
// If we have NO cached data (this.loaded = false), and we get [], we assume empty list.
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.loaded = true;
} else {
devLogger.warn("[SubscriptionsManager] Relays returned no events, keeping existing/cached list.");
// We have data loaded, and relays returned nothing.
// This means no updates. We keep what we have.
devLogger.log("[SubscriptionsManager] No updates from relays.");
}
return;
}
@@ -382,6 +373,16 @@ class SubscriptionsManager {
// Sort by created_at desc, pick newest
events.sort((a, b) => b.created_at - a.created_at);
const newest = events[0];
// Check if newest is actually newer than what we have
// If we loaded from cache, we might not have the event object, just the pubkeys.
// But we don't store the event timestamp in cache currently?
// profileCache only stores the array.
// Wait, we don't persist the event ID or created_at in profileCache for subscriptions.
// So we can't strictly compare timestamps against cache.
// However, fetchListIncrementally already handles the "newer than last sync" logic per relay.
// So any event returned here is effectively "new information" (or re-fetched full state).
this.subsEventId = newest.id;
const signer = getActiveSigner();