From 32f04b94cb5befb32753c251fc82cedbfd252b61 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:01:55 -0400 Subject: [PATCH] Ensure watch history snapshots advance created_at --- js/nostr.js | 30 +++++++++++++++- tests/watch-history.test.mjs | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/js/nostr.js b/js/nostr.js index 2f944d75..cf8a49d4 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1528,7 +1528,33 @@ class NostrClient { } const totalChunks = chunkItemsList.length || 1; - const baseTimestamp = Math.floor(Date.now() / 1000); + + const nowSeconds = Math.floor(Date.now() / 1000); + let lastCreatedAt = 0; + + const pointerCandidates = []; + if (existingEntry?.pointerEvent) { + pointerCandidates.push(existingEntry.pointerEvent); + } + + const cachedPointerEvent = this.watchHistoryCache.get(normalizedActor)?.pointerEvent; + if (cachedPointerEvent) { + pointerCandidates.push(cachedPointerEvent); + } + + for (const candidate of pointerCandidates) { + if (!candidate || typeof candidate !== "object") { + continue; + } + const createdAt = Number.isFinite(candidate.created_at) + ? Math.floor(candidate.created_at) + : null; + if (createdAt !== null && createdAt > lastCreatedAt) { + lastCreatedAt = createdAt; + } + } + + const baseTimestamp = Math.max(nowSeconds, lastCreatedAt + totalChunks); const chunkResults = []; let overallSuccess = true; @@ -1578,6 +1604,8 @@ class NostrClient { tags.splice(2, 0, ["head", "1"]); } + // Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots + // even when multiple publishes land within the same wall-clock second. const event = { kind: WATCH_HISTORY_KIND, pubkey: normalizedActor, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 338c39f5..9c6536dd 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -158,6 +158,74 @@ assert.deepEqual( "chunked fetch should preserve pointer order" ); +const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; +const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = + createPublishingClient(MONOTONIC_ACTOR); + +const monotonicOriginalDateNow = Date.now; +try { + const monotonicBaseSeconds = 1_700_123_456; + Date.now = () => monotonicBaseSeconds * 1000; + + const firstMonotonicPointers = [ + { type: "e", value: "event-monotonic-1", relay: null }, + { type: "e", value: "event-monotonic-2", relay: null }, + ]; + + const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( + MONOTONIC_ACTOR, + firstMonotonicPointers + ); + + const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry( + firstSnapshot.event, + firstMonotonicPointers, + Date.now() + ); + monotonicPublishingClient.watchHistoryCache.set( + MONOTONIC_ACTOR, + baselineEntry + ); + + const secondMonotonicPointers = [ + { type: "e", value: "event-monotonic-3", relay: null }, + { type: "e", value: "event-monotonic-1", relay: null }, + ]; + + const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( + MONOTONIC_ACTOR, + secondMonotonicPointers, + baselineEntry + ); + + assert( + secondSnapshot.event.created_at > firstSnapshot.event.created_at, + "new snapshot should advance created_at when reusing the same second" + ); + + const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR); + monotonicDecryptClient.pool = { list: async () => monotonicEvents }; + + const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory( + MONOTONIC_ACTOR + ); + + assert.deepEqual( + monotonicFetched.items.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + [ + { type: "e", value: "event-monotonic-3", relay: null }, + { type: "e", value: "event-monotonic-1", relay: null }, + ], + "newer snapshot should win pointer ordering" + ); +} finally { + Date.now = monotonicOriginalDateNow; +} + localStorage.clear(); const originalDateNow = Date.now;