fix(watch-history): logged-out (local) delete now rewrites the local queue

The other half of the synced-vs-local design: for logged-out / session
(local-only) actors, watch history lives in a localStorage queue (loadLatest ->
collectQueueItems), but snapshot no-op'd for local-only actors — so a removal
never touched the queue and the item reappeared on reload (same symptom as the
logged-in bug, different layer).

Now a REPLACE snapshot for a local-only actor rewrites the local queue to exactly
the provided items (new replaceLocalQueue helper): removals prune it, an empty
replace clears it. Normal watch-tracking adds still flow through the queue via
recordVideoView, unchanged.

Tests + mutation-verified: local replace seeds then prunes the queue; empty
replace clears it. Reverting to the no-op fails both.

Build + full lint green (watchHistoryService 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 18:45:42 -04:00
1 parent 4c6415c2c4
commit 2f95d4486e
2 files changed
+105

No files matched your search

+33
View File
@@ -426,6 +426,30 @@ function clearQueue(actorKey) {
notifyQueueChange(actorKey);
}
// Replace the local watch-history queue with exactly `items` (used by a
// local-only REPLACE snapshot, i.e. a logged-out removal/clear). Source of truth
// for local history is loadLatest -> collectQueueItems(queue), so this is what
// makes a local delete persist.
function replaceLocalQueue(actorKey, items) {
const queue = ensureQueue(actorKey);
queue.items.clear();
queue.throttle.clear();
const now = Date.now();
for (const item of Array.isArray(items) ? items : []) {
const pointer = normalizePointerInput(item);
if (!pointer) {
continue;
}
const key = pointerKey(pointer);
if (!key) {
continue;
}
queue.items.set(key, { pointer, addedAt: now, updatedAt: now });
}
persistQueueState();
notifyQueueChange(actorKey);
}
function pruneQueueAfterSnapshot(actorKey, queue, keysToClear, snapshotStart) {
if (!actorKey || !queue) {
return;
@@ -794,6 +818,15 @@ async function snapshot(items, options = {}) {
}
if (!isFeatureEnabled(actorKey)) {
// Local-only actors (logged out / session) keep watch history in the local
// queue, not on relays. A REPLACE snapshot (i.e. a removal/clear) must
// rewrite that queue so the deletion actually sticks — otherwise the removed
// item lingers locally and reappears on reload. Normal watch-tracking adds
// still flow through the queue via recordVideoView.
if (replace && Array.isArray(items)) {
replaceLocalQueue(actorKey, items);
return { ok: true, local: true, reason: "local-replaced", itemCount: items.length };
}
devLogger.debug(
"[watchHistoryService] Snapshot skipped because this actor is limited to local watch history.",
{
+72
View File
@@ -0,0 +1,72 @@
// Logged-out / local-only watch history lives in a localStorage queue, not on
// relays. A removal there is a REPLACE snapshot — it must rewrite the local queue
// so the deletion sticks. Previously snapshot no-op'd for local-only actors, so
// removing an item locally did nothing and it reappeared on reload.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { watchHistoryService } from "../js/watchHistoryService.js";
import { nostrClient } from "../js/nostrClientFacade.js";
// Make `actor` local-only by making it the session actor (feature disabled for
// the session actor → local queue path).
function makeLocalActor(actor) {
nostrClient.pubkey = "";
nostrClient.sessionActor = { pubkey: actor };
}
test("local-only replace snapshot seeds and then prunes the local queue", async () => {
const actor = "f".repeat(64);
makeLocalActor(actor);
// Seed three items via a replace snapshot (the local queue path).
const seed = await watchHistoryService.snapshot(
[
{ type: "e", value: "local-a" },
{ type: "e", value: "local-b" },
{ type: "e", value: "local-c" },
],
{ actor, replace: true },
);
assert.equal(seed.skipped, undefined, "local replace must NOT be skipped");
let items = await watchHistoryService.loadLatest(actor, { allowStale: false });
assert.deepEqual(
items.map((i) => i.value).sort(),
["local-a", "local-b", "local-c"],
"seeded local queue should have all three",
);
// Remove "local-b": replace with the remaining two.
await watchHistoryService.snapshot(
[
{ type: "e", value: "local-a" },
{ type: "e", value: "local-c" },
],
{ actor, replace: true },
);
items = await watchHistoryService.loadLatest(actor, { allowStale: false });
assert.deepEqual(
items.map((i) => i.value).sort(),
["local-a", "local-c"],
"removed item must be gone from the local queue (delete sticks)",
);
});
test("local-only clear (empty replace) empties the local queue", async () => {
const actor = "e".repeat(64);
makeLocalActor(actor);
await watchHistoryService.snapshot([{ type: "e", value: "only" }], {
actor,
replace: true,
});
let items = await watchHistoryService.loadLatest(actor, { allowStale: false });
assert.equal(items.length, 1);
await watchHistoryService.snapshot([], { actor, replace: true });
items = await watchHistoryService.loadLatest(actor, { allowStale: false });
assert.equal(items.length, 0, "clearing the last local item must empty the queue");
});