fix(watch-history): publish empty month events so clears propagate

Third and final watch-history-delete gap: removing the LAST item of a month (or
clearing all) left the file's items live on relays. updateWatchHistoryList
already sets records[month]=[] to clear an emptied month, but publishMonthRecord
early-returned on an empty month (`if (!items.length) return {ok:true}`), so the
clearing event was never published and relays kept serving the old, full copy.

Now an empty month still publishes a newest, EMPTY replaceable event for that
d-tag, overwriting the old copy so the clear actually takes effect (and reads —
which take the newest per d-tag after fff2bef2 — see it as empty).

Tests + mutation-verified: an emptied month publishes a clearing event with the
right d-tag and no pointer tags, while a month that still has items publishes its
remaining pointers. Restoring the early-return fails the clearing scenario.

Build + full lint green (watchHistory.js under file-size budget).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 17:47:21 -04:00
1 parent 421e50a90f
commit a3799f3fba
2 files changed
+96 -1

No files matched your search

+2 -1
View File
@@ -1302,7 +1302,8 @@ class WatchHistoryManager {
}
async publishMonthRecord(monthIdentifier, items, options = {}) {
if (!items || !items.length) return { ok: true };
// Empty month = "clear it": still publish a newest EMPTY event for the d-tag (skipping it left removed items live on relays).
items = Array.isArray(items) ? items : [];
const pool = typeof this.deps.getPool === "function" ? this.deps.getPool() : null;
if (!pool) {
+94
View File
@@ -0,0 +1,94 @@
// Clearing a month (removing its last item) must publish an EMPTY replaceable
// event for that month's d-tag, so the newest copy on relays has no items and the
// removed items don't linger. publishMonthRecord used to early-return on an empty
// month, so the clear silently did nothing and the old items stayed live.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createWatchHistoryManager } from "../js/nostr/watchHistory.js";
function makeManager(publishCalls) {
const actorPubkey = "d".repeat(64);
const sessionPrivateKey = "e".repeat(64);
const toolkit = {
nip04: { encrypt: async (_priv, _pub, plaintext) => `enc:${plaintext}` },
};
const manager = createWatchHistoryManager({
getActivePubkey: () => actorPubkey,
getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }),
ensureSessionActor: async () => actorPubkey,
resolveActiveSigner: () => null,
shouldRequestExtensionPermissions: () => false,
ensureExtensionPermissions: async () => ({ ok: true }),
ensureNostrTools: async () => toolkit,
getCachedNostrTools: () => toolkit,
signEventWithPrivateKey: (event) => ({
...event,
id: `id-${event.created_at}`,
sig: "sig",
}),
getWriteRelays: () => ["wss://relay.test"],
getRelayFallback: () => ["wss://relay.test"],
getPool: () => ({
publish(urls, event) {
publishCalls.push({ urls, event });
return {
on(name, handler) {
if (name === "ok") {
setTimeout(handler, 0);
}
return this;
},
};
},
async list() {
return [];
},
}),
});
return { manager, actorPubkey };
}
test("an emptied month still publishes a clearing event with the right d-tag and no pointers", async () => {
const publishCalls = [];
const { manager, actorPubkey } = makeManager(publishCalls);
try {
const result = await manager.publishRecords(
{ "2026-06": [] },
{ actorPubkey },
);
assert.equal(result.ok, true, "the clearing publish should report ok");
assert.equal(
publishCalls.length,
1,
"an empty month MUST still publish a (clearing) event — not be skipped",
);
const ev = publishCalls[0].event;
const dTag = ev.tags.find((t) => t[0] === "d");
assert.equal(dTag?.[1], "2026-06", "clears the correct month address");
const pointerTags = ev.tags.filter((t) => t[0] === "e" || t[0] === "a");
assert.equal(pointerTags.length, 0, "the clearing event carries no pointer tags");
} finally {
manager.clear();
}
});
test("a month that still has items publishes its remaining pointers", async () => {
const publishCalls = [];
const { manager, actorPubkey } = makeManager(publishCalls);
try {
await manager.publishRecords(
{ "2026-06": [{ type: "e", value: "keep-1", watchedAt: 5 }] },
{ actorPubkey },
);
assert.equal(publishCalls.length, 1);
const pointerTags = publishCalls[0].event.tags.filter(
(t) => t[0] === "e" || t[0] === "a",
);
assert.equal(pointerTags.length, 1, "the surviving pointer is published");
assert.equal(pointerTags[0][1], "keep-1");
} finally {
manager.clear();
}
});