diff --git a/context/CONTEXT_2026-10-20_16-14-00_client-reliability-7.md b/context/CONTEXT_2026-10-20_16-14-00_client-reliability-7.md new file mode 100644 index 00000000..b6f68339 --- /dev/null +++ b/context/CONTEXT_2026-10-20_16-14-00_client-reliability-7.md @@ -0,0 +1,6 @@ +# Item7 cold live feed notification + +- Goal: cold cache then eligible live updates must notify actual loadVideos consumer;latest feed owns reused subscription and clear invalidates late callbacks. +- Scope: nostrService loadVideos notification state plus constructor/clear lifecycle only;new focused service test and unique evidence/backlog. No app/feed/filter/cache policy/runner changes. +- Plan: reproduce original service with controlled subscribeVideos boundary;prove latest/force/clear controls;minimal current-consumer token;mutants/focused/lint;explicit full slot then one unchanged normal gate;QA owns actual-app browser after landing/preview rebuild. +- Original4e904349: new first5 scenarios4fail1pass/0cancel;first8line callback fix later5pass2fail on7. Revised service19 added lines passes7/7. No actual browser/full/build yet. diff --git a/decisions/DECISIONS_2026-10-20_16-14-00_client-reliability-7.md b/decisions/DECISIONS_2026-10-20_16-14-00_client-reliability-7.md new file mode 100644 index 00000000..1d887b7c --- /dev/null +++ b/decisions/DECISIONS_2026-10-20_16-14-00_client-reliability-7.md @@ -0,0 +1,6 @@ +# Item7 notification ownership + +- Live updates notify the same full ordered cached list emitted/persisted already,including deletion-only empty results. Preserve event payload/deleted metadata and filtering. +- A service-owned notification object carries latest loadVideos consumer across reused subscription. Creating replacement subscription uses fresh identity;clear invalidates before unsub. Old subscription callback cannot change cache,notify consumer,emit or persist after disposal. +- Interpreter independently identified stale captured consumer;our A→B and force/clear tests fail firstcallbackpatch,pass revisedtoken. Service lifecycle state explicitly routed by dispatch16:10:24. +- Keep existing cache/persistent callbacks and callback-throw isolation. Event-only consumers remain supported. No productpolicy change/forced UI refresh. Browser qualification belongs to QA. diff --git a/docs/agents/backlog/client-reliability-7.json b/docs/agents/backlog/client-reliability-7.json index 1cc9d0b5..cd269f29 100644 --- a/docs/agents/backlog/client-reliability-7.json +++ b/docs/agents/backlog/client-reliability-7.json @@ -1,10 +1,10 @@ { "id": "client-reliability-7", "area": "client-reliability", - "status": "open", + "status": "in-progress", "title": "Restore cold-feed live subscription onVideos notification after eligible relay updates", "blockedOn": null, "addedOn": "2026-10-20", "filedAt": "386cc60e1fa3ba6d9b67a1171a1ab89c03df84ca", - "evidence": "QA actual app at386cc60e:cache0\u2192EVENT3/EOSE\u2192filtered3/access/include true\u2192viewEMPTY/DOM0. live service branch emits videos:updated but omits onVideos callback used by actual feed refresh. Scope/reproduction/controls in area prompt;minimal method-only fix after natural failing regression." + "evidence": "Reproduced actual method original4fail1pass at4e904349,firstcallbackpatch5pass2fail(latest/forceclear),revised7/7;9mutants naturallyexit1/0cancel;focused5files27passing,lint11/11. Service19addedlines loadVideos+constructor/clear only. Ready note test_logs/TEST_LOG_2026-10-20_16-14-00_client-reliability-7-ready.md;await fullslot,no full/browser/build yet." } diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 5ddfa95d..ac862b99 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -619,6 +619,7 @@ export class NostrService { } }); this.videosMap = null; + this.videoFeedNotification = null; this.videosByAuthorIndex = null; this.authorIndexDirty = false; this.moderationService = moderationService || null; @@ -1429,6 +1430,7 @@ export class NostrService { } clearVideoSubscription() { + this.videoFeedNotification = null; const current = getStoredVideoSubscription(); if (current && typeof current.unsub === "function") { try { @@ -1713,6 +1715,12 @@ export class NostrService { this.clearVideoSubscription(); } + // Reused relay subscriptions follow the currently displayed feed consumer. + const notification = + (getStoredVideoSubscription() && this.videoFeedNotification) || {}; + notification.onVideos = onVideos; + this.videoFeedNotification = notification; + const applyAndNotify = (videos, reason) => { const filtered = this.filterVideos(videos, { blacklistedEventIds, @@ -1793,6 +1801,9 @@ export class NostrService { }; const subscription = this.nostrClient.subscribeVideos( (updatedVideos) => { + if (this.videoFeedNotification !== notification) { + return; + } if (!Array.isArray(updatedVideos) || !updatedVideos.length) { return; } @@ -1837,6 +1848,14 @@ export class NostrService { return bCreated - aCreated; }); + if (typeof notification.onVideos === "function") { + try { + notification.onVideos(fullList, { reason: "subscription" }); + } catch (error) { + userLogger.warn("[nostrService] onVideos handler threw", error); + } + } + this.emit("videos:updated", { videos: fullList, deleted: deletedUpdates, diff --git a/test_logs/TEST_INTEGRITY_client-reliability-7.md b/test_logs/TEST_INTEGRITY_client-reliability-7.md new file mode 100644 index 00000000..4fa3d781 --- /dev/null +++ b/test_logs/TEST_INTEGRITY_client-reliability-7.md @@ -0,0 +1,55 @@ +# Item7 live feed consumer notification + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-cold-live-feed + given: "actual service cold cache and controlled eligible/ineligible subscription arrivals" + when: "two live batches arrive" + then: "consumer,events and persisted cache expose same full ordered eligible snapshots" + - id: SCN-latest-feed-consumer + given: "one live relay subscription and successive loadVideos consumers" + when: "consumer switches then becomes event-only" + then: "only latest consumer receives live updates;event-only updates still emit and persist" + - id: SCN-replaced-feed-subscription + given: "live subscription is force-replaced then cleared" + when: "old controlled callbacks arrive after unsubscribe" + then: "no stale callback effects;current subscription notifies and persists normally" + - id: SCN-live-deletion + given: "two cached videos" + when: "deletion-only,mixed deletion+addition,and final deletion arrive" + then: "consumer gets reduced,updated and empty full snapshots;deleted metadata and persistence preserved" + - id: SCN-live-consumer-throw + given: "consumer throws on first live arrival" + when: "two live batches arrive" + then: "service events,persistence and later callback delivery remain intact" + - id: SCN-warm-live-feed + given: "persistent warm cache and empty in-memory input" + when: "load then live arrival" + then: "optimistic data survives without empty flash;live snapshot includes old and new entries" + - id: SCN-event-only-filtered-feed + given: "no onVideos consumer" + when: "empty,ineligible,then eligible updates arrive" + then: "empty/ineligible updates do not notify/cache;eligible update emits and persists without consumer" + observable_outcomes: + - "real loadVideos callback values,service events,shared map and localStorage snapshots" + - "latest callback ownership,unsubscribe lifecycle and no stale effects" + determinism_controls: + - "controlled nostrClient.subscribeVideos boundary;actual service filter/cache/persistence/emitter" + - "fixed videos/order,isolated shared state/storage;global network guards;no sleeps/retries/public traffic" + - "teardown restores globals/map/subscription/storage and verifies owned subscriptions close" + anti_cheat_rationale: + prevents: + - "event-only/source-string assertion mistaken for actual consumer delivery" + - "notify delta or stale first consumer instead of current full-list consumer" + - "lose deletion/event/persistence on callback exceptions or no consumer" + - "filter bypass or optimistic cache flash" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +Nine isolated runtime loader mutants all naturally exit1/0cancel and restore at process termination: missing-live kills scenarios1-6;delta-only kills1/4/5/6;ignore-deletions kills4;uncaught-consumer kills5;first-consumer kills2;late-callback kills3;event-only-drop kills2/7;retained-on-clear kills3;filter-bypass kills1/7. No source file changed during mutation runs; no arbitrary sleeps/retries/relaxed assertions. Outputs `/tmp/item7-mutant-*.log` and `/tmp/item7-mutants.json`. + +Scope is service notification contract with controlled client IO;not real wire parser/signing/relay/bootstrap/DOM/mobile proof. QA measured actual app failure independently and owns postlanding qualification. Existing suites are unchanged. diff --git a/test_logs/TEST_LOG_2026-10-20_16-14-00_client-reliability-7-ready.md b/test_logs/TEST_LOG_2026-10-20_16-14-00_client-reliability-7-ready.md new file mode 100644 index 00000000..feedef28 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-20_16-14-00_client-reliability-7-ready.md @@ -0,0 +1,9 @@ +# Item7 ready evidence + +- Baseline4e90434992471c3c6b40b017c6eae133aec92b62;filed386cc60e source unchanged. `env -u NODE_TEST_CONTEXT NODE_ENV=test node tests/nostr-service-live-feed-notification.test.mjs` beforeproductedit naturalexit1:4fail1pass/0cancel/0skip (`/tmp/item7-before.log`);cached0→eligiblefull3/persisted3 but consumer receives only cache0. Deletion/warm/throw scenarios also fail;ineligible/event-only controls pass. +- First8line closed-over callback addition5/5 passed;then latest-consumer/force/clear controls failnaturally,5pass2fail/0cancel (`/tmp/item7-first-callback-controls.log`). Revised service19added lines (loadVideos+constructor/clear state)7/7 naturalexit0,0fail/cancel/skip (`/tmp/item7-owned-after.log`). No existing tests changed. +- `python3 /tmp/item7-mutants.py`:9/9 isolated source-loader mutants each naturalexit1,0cancelled,named assertions;no disk source mutation. Summary `/tmp/item7-mutants.json`,outputs `/tmp/item7-mutant-*.log`;mapping unique integrity note. +- Five focused files executed sequentially with `env` NODE_TEST_CONTEXT removed,NODE_ENV=test,each `node --import ./tests/test-helpers/setup-localstorage.mjs ` and existing60s/file diagnostic bound. New7+access-ready4+access5+service6+channelcache5=27pass,all5processesexit0 (`/tmp/item7-focused.log`). Access5 prints five TAP ok lines but has pre-existing forced-exit afterhook/no final summary;not edited. Otherfiles0fail/cancel/skip. +- `env -u NODE_TEST_CONTEXT npm run lint` naturalexit0,11stages (`/tmp/item7-lint.log`). `git diff --check` exit0. +- No live network,signer/protocol changes,full/build/browser run. Fake client only controls subscription/cache input and existing access boundary;actual service filter/cache/emitter/persistence. Cleanup restores shared app map/subscription/storage/globals and closes owned subscriptions. Service moderation disabled in fixture to avoid unrelated singleton refresh side effects. +- QA owns actual app retest and dispatch preview rebuild. Waiting explicit full normal slot;single unchanged normal gate authoritative full candidate run,no duplicate full solely for logs. Live relay/signing/real peers/core/mobile remain unverified by these service tests. diff --git a/tests/nostr-service-live-feed-notification.test.mjs b/tests/nostr-service-live-feed-notification.test.mjs new file mode 100644 index 00000000..9be3abf2 --- /dev/null +++ b/tests/nostr-service-live-feed-notification.test.mjs @@ -0,0 +1,227 @@ +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { NostrService } from "../js/services/nostrService.js"; +import { + getVideosMap, + setVideosMap, + getVideoSubscription, + setVideoSubscription, +} from "../js/state/appState.js"; + +const storageKey = "bitvid:filtered-videos:v1"; +const video = (id, created_at, extra = {}) => ({ + id, + created_at, + pubkey: "a".repeat(64), + title: id, + url: "https://example.invalid/video.mp4", + ...extra, +}); +const ids = (videos) => videos.map((entry) => entry.id); + +function fixture(t, { cached = [], persisted = [] } = {}) { + const previousMap = getVideosMap(); + const previousSubscription = getVideoSubscription(); + const previousStorage = window.localStorage.getItem(storageKey); + setVideosMap(new Map()); + setVideoSubscription(null); + window.localStorage.setItem(storageKey, JSON.stringify(persisted)); + const service = new NostrService(); + service.moderationService = null; + const events = []; + const subscriptions = []; + const unsubscribe = service.on("videos:updated", (detail) => { + events.push({ ...detail, videos: detail.videos.slice() }); + }); + let receive; + let networkAttempts = 0; + const globals = ["fetch", "WebSocket"].map((key) => [ + key, Object.getOwnPropertyDescriptor(globalThis, key), + ]); + globalThis.fetch = () => { + networkAttempts++; + throw new Error("Unexpected live-feed network IO"); + }; + globalThis.WebSocket = class { + constructor() { + networkAttempts++; + throw new Error("Unexpected live-feed socket"); + } + }; + t.after(() => { + service.clearVideoSubscription(); + unsubscribe(); + setVideosMap(previousMap); + setVideoSubscription(previousSubscription); + if (previousStorage === null) window.localStorage.removeItem(storageKey); + else window.localStorage.setItem(storageKey, previousStorage); + for (const [key, descriptor] of globals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + assert.equal(networkAttempts, 0); + assert.ok(subscriptions.every((entry) => entry.closed)); + }); + service.nostrClient = { + pubkey: null, + getActiveVideos: () => cached, + getLatestCachedCreatedAt: () => cached[0]?.created_at || 0, + clampVideoRequestLimit: (limit) => limit, + subscribeVideos(callback, options) { + receive = callback; + const subscription = { closed: false, unsub() { this.closed = true; } }; + subscriptions.push(subscription); + return subscription; + }, + }; + service.accessControl = { + isHydrated: () => true, + ensureReady: async () => {}, + canAccess: (entry) => entry.denied !== true, + }; + return { + service, + events, + subscriptions, + currentReceiver: () => receive, + receive: (updates) => receive(updates), + persisted: () => JSON.parse(window.localStorage.getItem(storageKey)), + }; +} + +test("cold feed consumer receives full ordered eligible snapshots after live arrivals", async (t) => { + const f = fixture(t); + const notifications = []; + const initial = await f.service.loadVideos({ + blacklistedEventIds: new Set(["blocked-event"]), + isAuthorBlocked: (pubkey) => pubkey === "b".repeat(64), + onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }), + }); + assert.deepEqual(initial, []); + assert.deepEqual(notifications, [{ ids: [], reason: "cache" }]); + f.receive([ + video("older", 10), video("newer", 30), + video("private", 40, { isPrivate: true }), + video("denied", 50, { denied: true }), + video("blocked-event", 60), + video("blocked-author", 70, { pubkey: "b".repeat(64) }), + ]); + f.receive([video("middle", 20)]); + assert.deepEqual(ids(Array.from(f.service.getVideosMap().values())), ["older", "newer", "middle"]); + assert.deepEqual(ids(f.persisted()), ["newer", "middle", "older"]); + assert.deepEqual(notifications, [ + { ids: [], reason: "cache" }, + { ids: ["newer", "older"], reason: "subscription" }, + { ids: ["newer", "middle", "older"], reason: "subscription" }, + ]); + assert.deepEqual(f.events.map((entry) => ids(entry.videos)), notifications.map((entry) => entry.ids)); +}); + +test("a reused live subscription notifies only the latest feed consumer", async (t) => { + const f = fixture(t); + const first = []; + const latest = []; + await f.service.loadVideos({ onVideos: (videos, detail) => first.push({ ids: ids(videos), ...detail }) }); + await f.service.loadVideos({ onVideos: (videos, detail) => latest.push({ ids: ids(videos), ...detail }) }); + f.receive([video("live", 10)]); + assert.equal(f.subscriptions.length, 1, "feed switch reuses the live relay subscription"); + assert.deepEqual(first, [{ ids: [], reason: "cache" }]); + assert.deepEqual(latest, [{ ids: [], reason: "cache" }, { ids: ["live"], reason: "subscription" }]); + await f.service.loadVideos(); + const beforeDetach = latest.length; + f.receive([video("event-only", 20)]); + assert.equal(latest.length, beforeDetach, "an event-only load detaches the prior feed consumer"); + assert.deepEqual(ids(f.events.at(-1).videos), ["event-only", "live"]); +}); + +test("force-fetch and clear invalidate late callbacks from replaced subscriptions", async (t) => { + const f = fixture(t); + const first = []; + const current = []; + await f.service.loadVideos({ onVideos: (videos) => first.push(ids(videos)) }); + const obsolete = f.currentReceiver(); + await f.service.loadVideos({ forceFetch: true, onVideos: (videos) => current.push(ids(videos)) }); + assert.equal(f.subscriptions.length, 2); + assert.equal(f.subscriptions[0].closed, true); + obsolete([video("obsolete", 10)]); + assert.equal(f.service.getVideosMap().has("obsolete"), false); + assert.deepEqual(first, [[]]); + f.receive([video("current", 20)]); + assert.deepEqual(current, [[], ["current"]]); + const afterClear = f.currentReceiver(); + f.service.clearVideoSubscription(); + const eventsBefore = f.events.length; + afterClear([video("late-after-clear", 30)]); + assert.equal(f.events.length, eventsBefore); + assert.deepEqual(current, [[], ["current"]]); + assert.deepEqual(ids(f.persisted()), ["current"]); +}); + +test("deletion-only and mixed live updates notify reduced and empty feed snapshots", async (t) => { + const first = video("first", 10); + const second = video("second", 20); + const f = fixture(t, { cached: [second, first] }); + const notifications = []; + await f.service.loadVideos({ onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }) }); + const deletedFirst = { ...first, deleted: true }; + f.receive([deletedFirst]); + assert.equal(f.service.getVideosMap().has("first"), false); + assert.deepEqual(f.events.at(-1).deleted, [deletedFirst]); + assert.deepEqual(ids(f.persisted()), ["second"]); + const third = video("third", 30); + const deletedSecond = { ...second, deleted: true }; + f.receive([deletedSecond, third]); + assert.deepEqual(f.events.at(-1).deleted, [deletedSecond]); + assert.deepEqual(ids(f.persisted()), ["third"]); + f.receive([{ ...third, deleted: true }]); + assert.equal(f.service.getVideosMap().size, 0); + assert.deepEqual(f.persisted(), []); + assert.deepEqual(notifications, [ + { ids: ["second", "first"], reason: "cache" }, + { ids: ["second"], reason: "subscription" }, + { ids: ["third"], reason: "subscription" }, + { ids: [], reason: "subscription" }, + ]); +}); + +test("throwing live consumer does not lose service events, persistence or later notifications", async (t) => { + const f = fixture(t); + const notifications = []; + await f.service.loadVideos({ onVideos(videos, detail) { + notifications.push({ ids: ids(videos), ...detail }); + if (detail.reason === "subscription" && videos.length === 1) throw new Error("consumer failed"); + } }); + assert.doesNotThrow(() => f.receive([video("first", 10)])); + assert.deepEqual(ids(f.events.at(-1).videos), ["first"]); + assert.deepEqual(ids(f.persisted()), ["first"]); + f.receive([video("second", 20)]); + assert.deepEqual(notifications.at(-1), { ids: ["second", "first"], reason: "subscription" }); + assert.deepEqual(ids(f.events.at(-1).videos), ["second", "first"]); + assert.deepEqual(ids(f.persisted()), ["second", "first"]); +}); + +test("warm persistent feed keeps optimistic notification without an empty cache flash", async (t) => { + const f = fixture(t, { persisted: [video("warm", 10)] }); + const notifications = []; + const initial = await f.service.loadVideos({ onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }) }); + assert.deepEqual(ids(initial), ["warm"]); + assert.deepEqual(notifications, [{ ids: ["warm"], reason: "persistent-cache" }]); + f.receive([video("new", 20)]); + assert.deepEqual(notifications.at(-1), { ids: ["new", "warm"], reason: "subscription" }); + assert.deepEqual(ids(f.persisted()), ["new", "warm"]); +}); + +test("absent consumer and empty or ineligible updates preserve notification and cache contracts", async (t) => { + const f = fixture(t); + await f.service.loadVideos(); + f.receive(null); + f.receive([]); + f.receive([video("private", 10, { isPrivate: true }), video("denied", 20, { denied: true })]); + assert.equal(f.events.length, 1); + assert.equal(f.service.getVideosMap().size, 0); + assert.deepEqual(f.persisted(), []); + assert.doesNotThrow(() => f.receive([video("public", 30)])); + assert.deepEqual(ids(f.events.at(-1).videos), ["public"]); + assert.deepEqual(ids(f.persisted()), ["public"]); +}); diff --git a/todo/TODO_2026-10-20_16-14-00_client-reliability-7.md b/todo/TODO_2026-10-20_16-14-00_client-reliability-7.md new file mode 100644 index 00000000..c6db0f70 --- /dev/null +++ b/todo/TODO_2026-10-20_16-14-00_client-reliability-7.md @@ -0,0 +1,7 @@ +# Item7 + +- [x] Sync/claim;currentHEAD original failure+eligibility/persistence controls. +- [x] Latest-consumer/feed-switch and force/clear controls;minimal service-owned notification token. +- [x] Seven new scenarios,nine isolated failing runtime mutants,focused5files27passing scenarios,lint11/11. +- [ ] Explicit fullslot,commit/merge+mergedfocused/lint,one unchanged normal gate/durable landed+ancestry. +- [ ] QA actual-app retest/dispatch preview handoff;close backlog and IDLE.