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..2b2741e8 100644 --- a/docs/agents/backlog/client-reliability-7.json +++ b/docs/agents/backlog/client-reliability-7.json @@ -1,10 +1,11 @@ { "id": "client-reliability-7", "area": "client-reliability", - "status": "open", + "status": "done", "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": "Service fixfecf4294 durablylandedb1174163556e2705ba85868e21adcaf51b44bd5f viaf39622da;unchanged normalgateexit0/432discovered+unique starts;new7/7,mergedfocused27/5files,nine naturallykilledmutants,lint11/11,ancestry0. Fullslotreleased16:21:30UTC;dispatch previewbuild+QA actualapp retestpending,notclaimed. Final evidence test_logs/TEST_LOG_2026-10-20_16-22-00_client-reliability-7-landed.md;IDLE/no optionaltasks.", + "closedOn": "2026-10-20" } diff --git a/docs/agents/prompts/client-reliability.md b/docs/agents/prompts/client-reliability.md index 14c5c726..949b3c30 100644 --- a/docs/agents/prompts/client-reliability.md +++ b/docs/agents/prompts/client-reliability.md @@ -4,9 +4,9 @@ QA actual-app passive reproduction at386cc60e: fresh Trending/cache0, local relay delivers3 valid signed VIDEO events+EOSE; raw/all/active/appvideo maps3,actual filtered3 and every access/include true,whitelist/lockdown false. Connected visible view remains current0/rendered0/signatureEMPTY/DOM0 after15s;metadata reasoncache. Service emits videos:cache3 then videos:updated(subscription3),but app refresh owner is onVideos callback. Source loadVideos cache/optimistic paths call onVideos;live subscription branch does not. This is a demonstrated local-review blocker,not optional work. -Temporary item ownership: ONLY js/services/nostrService.js loadVideos live-subscription notification path;new focused regression (prefer tests/nostr-service-live-feed-notification.test.mjs),unique integrity/evidence/backlog. Read app/feed/bootstrap and existing tests;do not edit them or sharedTODO without routing. No app.js,cache/filter policy,signer/protocol,runner/config/kit/styling changes. Current service area exclusion is overridden only for this method/item. +Temporary item ownership: ONLY js/services/nostrService.js loadVideos live-subscription notification path plus minimal service-owned current-consumer state/subscription-clear lifecycle if needed to prevent stale consumer delivery;new focused regression (prefer tests/nostr-service-live-feed-notification.test.mjs),unique integrity/evidence/backlog. Read app/feed/bootstrap and existing tests;do not edit them or sharedTODO without routing. No app.js,cache/filter policy,signer/protocol,runner/config/kit/styling changes. Current service area exclusion is overridden only for this method/item. -Merge unstable non-destructively,claimitem7,read currentrules/KNOWN_ISSUES/test-integrity/localNIP/schema docs. Before edits reproduce actual service coldcache→controlled subscribeVideos callback→actualonVideos consumer notification absent;prove cache0 theneligible updates/deletion callbacks,not event-string assertions. Preserve filtered full-list ordering,deleted updates,tombstone removal,existing videos:updated payload and persistence,callbackthrow isolation/no event loss,optimistic/cache semantics. After natural failing regression,implement smallest method-only callback delivery fix and restored failing mutants. Retain no product-policy change or forced refresh substitute. +Merge unstable non-destructively,claimitem7,read currentrules/KNOWN_ISSUES/test-integrity/localNIP/schema docs. Before edits reproduce actual service coldcache→controlled subscribeVideos callback→actualonVideos consumer notification absent;prove cache0 theneligible updates/deletion callbacks,not event-string assertions. Preserve filtered full-list ordering,deleted updates,tombstone removal,existing videos:updated payload and persistence,callbackthrow isolation/no event loss,optimistic/cache semantics. Reused subscription must notify latest consumer after subsequent load/feed switch; forceFetch/clear must not let late obsolete callbacks notify old or replacement consumers. Include event-only consumer compatibility; do not blindly close over first onVideos. After natural failing regression,implement smallest method-only callback delivery fix and restored failing mutants. Retain no product-policy change or forced refresh substitute. Cheap focused tests and lint now;no browser/full/build until slot grant. QA owns actual-app execution and will verify cold feed plus original core/mobile baseline after durable fix/preview rebuild. Send reproduction/controls/files before widening;ready event with focused counts/mutants/lint. One unchanged normal full gate can be authoritative after merged focused/lint;avoid duplicate full solely for logs. Normal commit/merge/queue/ancestry/closure rules apply. No external operations/newoptional tasks;idle once local-review blocker is closed. diff --git a/fleet/RULE-CHANGES.md b/fleet/RULE-CHANGES.md index d925321d..718b1adb 100644 --- a/fleet/RULE-CHANGES.md +++ b/fleet/RULE-CHANGES.md @@ -9,7 +9,7 @@ a landing rule changes. ## 2026-10-20 -- Local-review hard blocker measured: coldTrending cache0 then relayEVENT3/EOSE ingests eligible3 but view remainsEMPTY/DOM0. client-reliability-7 temporary ownership ONLY nostrService.loadVideos subscription notification path + new focused regression/integrity/evidence; reproduce current method before minimal fix. Existing app/feed modules read-only unless additional evidence/routing. Preserve access/filter/deletion/cache/event semantics; no forcedrefresh baseline substitute. QA retains actual-app/browser qualification; client cheapfocused until explicit fullslot. +- Local-review hard blocker measured: coldTrending cache0 then relayEVENT3/EOSE ingests eligible3 but view remainsEMPTY/DOM0. client-reliability-7 temporary ownership ONLY nostrService.loadVideos subscription notification path, minimal service-owned current-consumer state/subscription-clear lifecycle where proven necessary, + new focused regression/integrity/evidence; reproduce current method before minimal fix. Existing app/feed modules read-only unless additional evidence/routing. Preserve access/filter/deletion/cache/event semantics; no forcedrefresh baseline substitute. QA retains actual-app/browser qualification; client cheapfocused until explicit fullslot. - Owner stopmilestone is Ready for me to review and test locally. Finish current storage2/history6/faithful actual-app baseline+preview/handoff,then no new optional tasks before review. Reconcile upstream docs-only8d4622bb via area merge+normal queue;new playback promises stay explicit/open,do not churn current scope or declare them done from partial/static evidence. Genuine cached offline dependency mirror is testprep only,not CDN/deployed/offline production proof. 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/test_logs/TEST_LOG_2026-10-20_16-22-00_client-reliability-7-landed.md b/test_logs/TEST_LOG_2026-10-20_16-22-00_client-reliability-7-landed.md new file mode 100644 index 00000000..db115c6c --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-20_16-22-00_client-reliability-7-landed.md @@ -0,0 +1,11 @@ +# Item7 landed service fix + +- Fix commit `fecf42948fb9c44dcd1147612d2f88b2c5dd3b51`;merged verified unstable `d7d632f88ba27496d1370e68825cae4abab689ca` to submission `f39622da3ba3a172c643641dab816a998a380489`. Actual tested candidate/durable landed SHA `b1174163556e2705ba85868e21adcaf51b44bd5f` at16:20:56.834UTC. `land_status` landed;`git log -1 unstable` same;`git merge-base --is-ancestor f39622da3ba3a172c643641dab816a998a380489 unstable` exit0. Product changes19added lines in service constructor/clear/loadVideos only;current consumer replaces stale first callback and cleared/replaced subscription callbacks are ignored. +- Slot request16:14:32UTC;dispatch grant16:14:59.388UTC,wait27seconds. Merge/focused verification began before grant using cheap checks only; normal submit16:15:12.477UTC. Full slot RELEASE event16:21:30UTC. No browser/build run by worker. +- Merged focused5files27passing (new7,access-ready4,access5,service6,channelcache5),each processnaturalexit0;access5 has pre-existing forced-exit afterhook/no final summary but five TAP ok records. `/tmp/item7-merged-focused.log`. Otherfourfiles0fail/cancel/skip. Merged lint `env -u NODE_TEST_CONTEXT npm run lint` exit0,11stages `/tmp/item7-merged-lint.log`. +- Original baseline and firstcallback ownership failures plus nine runtime mutants mapped in ready log/integrity. Final new7/7 also passes within full gate,0fail/cancel/skip. +- SINGLE authoritative normal full gate launched `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD UNIT_TEST_TIMEOUT_MS=60000 node tools/fleet/bin/landq.mjs submit`. Existing unchanged shell: `if [ -f package.json ]; then if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then npm ci --ignore-scripts || npm install; else npm install; fi; npm test; else echo 'set gates.land' >&2; exit 1; fi`. Durable result explicitly exit0. No duplicate full diagnostic run;runner/package/config blobs identical to d7d632f8. +- Independent configured discovery at tested b1174163:432files using unchanged recursive runner selection,empty quarantine,no filters/shard;retained log432unique file starts. Full gate completed exit0;not every file uses same framework/summary format. +- Durable original result `/home/user/.local/state/agent-fleet/bitvid/landq/results/f39622da3ba3a172c643641dab816a998a380489.json`;preserved `/tmp/client-reliability-7-normal-result.json`,3201bytes,SHA256 `328fc0b0f78e55b79509c0087141aa6308eb37378b590f5bdcdb8d2fd4ba2776`. Detail is gate exit0+stderr tail;All unit tests passed stdout marker is absent,not invented. +- Existing runner artifact `/home/user/Documents/GitHub/bitvid-lander/artifacts/test_unit.log` preserved before overwrite at `/tmp/client-reliability-7-normal-unit.log`,707001bytes,SHA256 `fef7966f8e138ce2d04887cf8cac0d353f1c98b6982dbb4eb45025ef60b9c1e3`. Retained unit stdout/stderr/file starts,not entire npm-install/full-process transcript or guarantee of final marker flush. Raw logs uncommitted. +- Implementation task closed after durable service landing;dispatch+QA informed. Dispatch owns preview rebuild/artifact verification;QA owns original actual-app cold3card/core/mobile retest. Those results are pending and are NOT implied by unit gate. No forced refresh substitute. Real relay/signing/live-peer/browser/mobile proof remains unverified here. No optional task;IDLE after metadata closure. 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. diff --git a/todo/TODO_2026-10-20_16-22-00_client-reliability-7-closed.md b/todo/TODO_2026-10-20_16-22-00_client-reliability-7-closed.md new file mode 100644 index 00000000..c934ba2c --- /dev/null +++ b/todo/TODO_2026-10-20_16-22-00_client-reliability-7-closed.md @@ -0,0 +1,10 @@ +# Item7 implementation completed + +- [x] Reproduce actual service cold feed notification failure before edits. +- [x] Latest-consumer/deletion/event-only/throw/warm/force-clear controls;19line service-only fix. +- [x] New7,focused27/5files,nine killed mutants,lint11;merged checks green. +- [x] Single unchanged normal gate432files exit0;durable landed b1174163 and ancestry0;log hashes preserved. +- [x] Release full slot;close implementation backlog;handoff dispatch build/QA actual-app retest explicitly pending. +- [ ] Documentation-only closure landing thenIDLE;no optional task. + +Do not claim owner local-review baseline complete until QA reports actual-app/core/mobile outcome.