fix(watch-history): a snapshot succeeds if ANY relay accepts (not all)

This is why logged-in removal "worked but threw a flag error", and likely why
new watches stopped appearing in history. publishMonthRecord reported success
only when EVERY write relay accepted (acceptedCount === relays.length). With a
20+ relay list many always reject (auth-required/rate-limited/not-in-write-set),
so every snapshot — removals AND new-watch tracking — was marked
"partial-relay-acceptance" -> ok:false -> "watch-history-snapshot-failed". The
data WAS published (major relays accepted), but the UI showed a failure and the
queue treated it as not-yet-synced.

A watch-history event is durable if any relay accepted it (reads take the newest
copy per d-tag), so success = acceptedCount > 0; only a zero-accept publish is a
retryable failure. Also dropped the redundant partialAcceptance alias to stay
within the file-size budget.

Tests + mutation-verified: 1-of-3 accept -> ok/non-retryable; 0-of-3 ->
failure/retryable. Reverting to the all-relays criterion fails the partial case.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 19:13:32 -04:00
1 parent 0756dd36d8
commit 565a961823
2 files changed
+82 -5

No files matched your search

+5 -5
View File
@@ -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,
});
@@ -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();
}
});