diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 6db95547..9c1c07a3 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -1572,13 +1572,13 @@ class WatchHistoryManager { } } this.lastCreatedAt = createdAtCursor; - const partialAcceptance = anyPartial; - const success = acceptedCount === relays.length && !anyRejected; + // Durable if ANY relay accepted (reads take newest per d-tag); all-relays was a false failure on large lists. + const success = acceptedCount > 0; let errorCode = null; if (!success) { if (anyRejected) { errorCode = "publish-rejected"; - } else if (partialAcceptance) { + } else if (anyPartial) { errorCode = "partial-relay-acceptance"; } } @@ -1597,7 +1597,7 @@ class WatchHistoryManager { }, skippedCount: skipped.length, source: options.source || "manual", - partial: partialAcceptance, + partial: anyPartial, }; if (!success && errorCode) { result.error = errorCode; @@ -1607,7 +1607,7 @@ class WatchHistoryManager { actor: actorKey, monthIdentifier, success, - partialAcceptance, + partialAcceptance: anyPartial, error: result.error || null, acceptedCount, }); diff --git a/tests/watch-history-partial-accept.test.mjs b/tests/watch-history-partial-accept.test.mjs new file mode 100644 index 00000000..63a7be51 --- /dev/null +++ b/tests/watch-history-partial-accept.test.mjs @@ -0,0 +1,77 @@ +// A watch-history snapshot must succeed when ANY relay accepts it — a large/flaky +// relay list always has rejectors, and reads take the newest copy per d-tag, so +// one accepting relay is durable. Requiring ALL relays made every removal report +// a false "watch-history-snapshot-failed" and stopped new watches from +// persisting. Only a zero-accept publish is a (retryable) failure. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWatchHistoryManager } from "../js/nostr/watchHistory.js"; + +const ACTOR = "a".repeat(64); +const SESSION_KEY = "b".repeat(64); +const GOOD = "wss://good.relay"; + +function makeManager(acceptUrls) { + const accepted = new Set(acceptUrls); + const toolkit = { + nip04: { encrypt: async (_p, _pub, txt) => `enc:${txt}` }, + }; + return createWatchHistoryManager({ + getActivePubkey: () => ACTOR, + getSessionActor: () => ({ pubkey: ACTOR, privateKey: SESSION_KEY }), + ensureSessionActor: async () => ACTOR, + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: (event) => ({ ...event, id: `id-${event.created_at}-${Math.random()}`, sig: "sig" }), + ensureNostrTools: async () => toolkit, + getCachedNostrTools: () => toolkit, + getWriteRelays: () => [GOOD, "wss://bad1.relay", "wss://bad2.relay"], + getRelayFallback: () => [GOOD], + getPool: () => ({ + publish(urls) { + const url = Array.isArray(urls) ? urls[0] : urls; + const ok = accepted.has(url); + return { + on(name, handler) { + if (ok && name === "ok") setTimeout(handler, 0); + if (!ok && name === "failed") setTimeout(() => handler("rejected"), 0); + return this; + }, + }; + }, + async list() { + return []; + }, + }), + }); +} + +test("a month accepted by only ONE of several relays is a success (not a false failure)", async () => { + const manager = makeManager([GOOD]); // 1 of 3 relays accepts + try { + const result = await manager.publishRecords( + { "2026-06": [{ type: "e", value: "watched-x", watchedAt: 1 }] }, + { actorPubkey: ACTOR }, + ); + assert.equal(result.ok, true, "partial acceptance must report success"); + assert.equal(result.retryable, false, "a partial accept is not retryable"); + } finally { + manager.clear(); + } +}); + +test("a month rejected by ALL relays is a retryable failure", async () => { + const manager = makeManager([]); // zero relays accept + try { + const result = await manager.publishRecords( + { "2026-06": [{ type: "e", value: "watched-x", watchedAt: 1 }] }, + { actorPubkey: ACTOR }, + ); + assert.equal(result.ok, false, "zero acceptance is a failure"); + assert.equal(result.retryable, true, "a total failure should be retryable"); + } finally { + manager.clear(); + } +});