diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4eb0a42bbe..77a311629e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -342,6 +342,7 @@ export function AppShell() { mutedRootIds, muteThread, unmuteThread, + getProjection, } = useUnreadChannels(sidebarChannels, activeChannel, { pubkey: identityQuery.data?.pubkey, relayClient, @@ -404,8 +405,6 @@ export function AppShell() { mutedRootIds, channels, ); - - // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( homeFeedQuery.data, @@ -733,6 +732,7 @@ export function AppShell() { threadActivityFeedItems, feedItemState, onOpenSettings: handleOpenSettings, + getProjection, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 4a64de0cb2..dde4d8c947 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; +import type { ReadStateProjection } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; import type { FeedItem } from "@/shared/api/types"; @@ -14,7 +15,7 @@ type AppShellContextValue = { readAt: string | null | undefined, options?: { topLevelOnly?: boolean }, ) => void; - markChannelUnread: (channelId: string) => void; + markChannelUnread: (channelId: string) => boolean; openBrowseChannels: () => void; openCreateChannel: () => void; openChannelManagement: (channelId?: string) => void; @@ -51,12 +52,15 @@ type AppShellContextValue = { // that render under AppShell (channel, home, projects, pulse, agents). // Used by config-nudge cards to deep-link to Settings → Agents. onOpenSettings: ((section: SettingsSection) => void) | null; + // Manager projection accessor for community rail polls — re-read per poll, + // never cached across notification boundaries. + getProjection: () => ReadStateProjection | null; }; const AppShellContext = React.createContext({ markAllChannelsRead: () => {}, markChannelRead: () => {}, - markChannelUnread: () => {}, + markChannelUnread: () => false, openBrowseChannels: () => {}, openCreateChannel: () => {}, openChannelManagement: () => {}, @@ -83,6 +87,7 @@ const AppShellContext = React.createContext({ unreadSet: EMPTY_SET, }, onOpenSettings: null, + getProjection: () => null, }); export function AppShellProvider({ diff --git a/desktop/src/features/channels/forcedUnreadStore.ts b/desktop/src/features/channels/forcedUnreadStore.ts index c6a6fd0758..2bdc5da653 100644 --- a/desktop/src/features/channels/forcedUnreadStore.ts +++ b/desktop/src/features/channels/forcedUnreadStore.ts @@ -1,5 +1,5 @@ /** - * Per-pubkey localStorage record store for channels manually marked unread via + * Per-pubkey localStorage cache of channels manually marked unread via * right-click → "mark unread". Keyed by channelId (not thread-root). Persisted * so the sidebar badge survives reload and the rail observer can read it for * inactive communities. @@ -13,8 +13,12 @@ * On identity change, the in-memory map is swapped to the current pubkey's * persisted data. Old pubkey data is NOT wiped from localStorage. * - * NOT synced to the relay — NIP-RS markers are monotonic and cannot represent - * a retrograde "unread" state. localStorage is best-effort (per-device). + * This store is the local-device cache of the NIP-RS manual-unread override + * layer (kind:30078 `ov_*` entries). Marking a channel unread publishes a + * NIP-RS override event to the relay via ReadStateManager so the override + * propagates to other devices. Marking a channel read clears the override + * both locally and on the relay. The `markerAtWhenForced` baseline still drives + * the "cross-device read already covered this" gate for the rail observer. */ /** channelId → NIP-RS read marker (unix seconds) at force-time, or null */ diff --git a/desktop/src/features/channels/nip-rs-unread-ui.test.mjs b/desktop/src/features/channels/nip-rs-unread-ui.test.mjs new file mode 100644 index 0000000000..e622caa290 --- /dev/null +++ b/desktop/src/features/channels/nip-rs-unread-ui.test.mjs @@ -0,0 +1,622 @@ +/** + * NIP-RS manual-unread UI layer tests (slice 3). + * + * Tests exercise production code — applyOverrideUnread, applyOverrideRead, + * persistForcedUnread, overrideErrorMessage — by importing and invoking them + * directly with injected manager/store/toast dependencies. + * + * Behavioral requirements: + * 1. mark-unread calls the manager's NIP-RS override path and blocks the + * local cache update when the manager refuses. + * 2. mark-read completes only when resulting override_active == false (spec + * MUST at NIP-RS:537-539). null liveness ≠ inactive — pre-init fails + * closed. + * 3. budget_exhausted, uint32_overflow, and load_incomplete refusals produce + * distinct non-empty user-visible messages; already_inactive is silent. + * 4. forcedUnreadStore is the local cache of the NIP-RS override layer. + * 5. persistForcedUnread refreshes an existing entry (re-mark after override + * died uses the fresh baseline). + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyMarkAllReadTransition, + applyOverrideRead, + applyOverrideUnread, + overrideErrorMessage, + persistForcedUnread, +} from "./readStateOverride.ts"; +import { forcedUnreadStore } from "./forcedUnreadStore.ts"; +import { resolveChannelReadMarker } from "./useUnreadChannels.ts"; +import { applyMarkUnreadDividerOverlay } from "./ui/useChannelUnreadState.ts"; + +// ── localStorage mock ──────────────────────────────────────────────────────── + +if (typeof globalThis.window === "undefined") { + const store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }, + }; +} + +function makeIsolatedStorage() { + const store = new Map(); + const prev = globalThis.window.localStorage; + globalThis.window.localStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; + return { + store, + restore: () => { + globalThis.window.localStorage = prev; + }, + }; +} + +// ── Toast capture mock ─────────────────────────────────────────────────────── +// applyOverrideUnread / applyOverrideRead call toast.error() from sonner. +// Capture calls by patching the module-level toast reference through a closure. +// Since we can't monkey-patch ES module imports in Node, we drive the +// production toast-policy logic directly via overrideErrorMessage instead. + +// ── Tests: overrideErrorMessage (toast policy) ─────────────────────────────── + +test("overrideErrorMessage_budget_exhausted_unread_contains_budget_keyword", () => { + const msg = overrideErrorMessage("unread", "budget_exhausted"); + assert.ok(msg?.includes("budget exhausted"), `got: ${msg}`); +}); + +test("overrideErrorMessage_uint32_overflow_unread_contains_counter_keyword", () => { + const msg = overrideErrorMessage("unread", "uint32_overflow"); + assert.ok(msg?.includes("counter limit"), `got: ${msg}`); +}); + +test("overrideErrorMessage_uint32_overflow_read_contains_counter_keyword", () => { + const msg = overrideErrorMessage("read", "uint32_overflow"); + assert.ok(msg?.includes("counter limit"), `got: ${msg}`); +}); + +test("overrideErrorMessage_load_incomplete_unread_contains_loading_keyword", () => { + const msg = overrideErrorMessage("unread", "load_incomplete"); + assert.ok(msg?.includes("still loading"), `got: ${msg}`); +}); + +test("overrideErrorMessage_load_incomplete_read_contains_loading_keyword", () => { + const msg = overrideErrorMessage("read", "load_incomplete"); + assert.ok(msg?.includes("still loading"), `got: ${msg}`); +}); + +test("overrideErrorMessage_already_inactive_returns_null_both_ops", () => { + assert.equal(overrideErrorMessage("unread", "already_inactive"), null); + assert.equal(overrideErrorMessage("read", "already_inactive"), null); +}); + +// ── Tests: applyOverrideUnread ─────────────────────────────────────────────── + +test("applyOverrideUnread_success_returns_true", () => { + const toasts = []; + const result = applyOverrideUnread("ch-1", { + markChannelUnread: () => ({ success: true }), + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => null, + }); + assert.equal(result, true); + assert.equal(toasts.length, 0); +}); + +for (const reason of [ + "budget_exhausted", + "uint32_overflow", + "load_incomplete", +]) { + test(`applyOverrideUnread_${reason}_returns_false`, () => { + const result = applyOverrideUnread("ch-1", { + markChannelUnread: () => ({ success: false, reason }), + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => null, + }); + assert.equal(result, false); + }); +} + +// ── Tests: applyOverrideRead ───────────────────────────────────────────────── +// +// New semantics (NIP-RS:537-539): +// 1. !isReadStateReady → fail closed (overrideStillActive) +// 2. isReadStateReady + null liveness → known absence (overrideCleared, no C-bump) +// 3. isReadStateReady + register exists → ALWAYS attempt C-bump, then re-check + +// Helper: builds a minimal OverrideAPIs object with sensible defaults. +function makeApis(overrides = {}) { + return { + isReadStateReady: true, + markChannelUnread: () => ({ success: true }), + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => null, + ...overrides, + }; +} + +test("applyOverrideRead_manager_not_ready_fails_closed_overrideStillActive", () => { + // Step 1: !isReadStateReady → fail closed without calling manager. + let bumped = false; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: false, + markChannelRead: () => { + bumped = true; + return { success: true }; + }, + getOverrideLiveness: () => ({ active: true, frontier: 50 }), + }), + ); + assert.equal(result, "overrideStillActive"); + assert.equal(bumped, false, "C-bump must not fire when manager not ready"); +}); + +test("applyOverrideRead_ready_null_liveness_no_register_overrideCleared", () => { + // Step 2: ready + null liveness = known no-register → cleared, no C-bump. + let bumped = false; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => { + bumped = true; + return { success: true }; + }, + getOverrideLiveness: () => null, + }), + ); + assert.equal(result, "overrideCleared"); + assert.equal(bumped, false, "no C-bump when no register exists"); +}); + +test("applyOverrideRead_inactive_register_cbump_attempted_then_clears", () => { + // Step 3 with inactive register: C-bump must be attempted (NIP-RS:537-539). + // After the bump, post-call liveness is still inactive → overrideCleared. + let bumped = false; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => { + bumped = true; + return { success: true }; + }, + getOverrideLiveness: () => ({ active: false, frontier: 200 }), + }), + ); + assert.equal(result, "overrideCleared"); + assert.equal(bumped, true, "C-bump attempted even when already inactive"); +}); + +test("applyOverrideRead_active_liveness_success_then_inactive_overrideCleared", () => { + // Active register → C-bump succeeds → re-read shows inactive → cleared. + let callCount = 0; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => { + callCount++; + return callCount === 1 + ? { active: true, frontier: 50 } + : { active: false, frontier: 50 }; + }, + }), + ); + assert.equal(result, "overrideCleared"); +}); + +test("applyOverrideRead_active_liveness_load_incomplete_overrideStillActive", () => { + // Active → C-bump refuses (load_incomplete) → liveness still active → retained. + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => ({ success: false, reason: "load_incomplete" }), + getOverrideLiveness: () => ({ active: true, frontier: 50 }), + }), + ); + assert.equal(result, "overrideStillActive"); +}); + +test("applyOverrideRead_already_inactive_race_overrideCleared", () => { + // already_inactive race: cleared by another device → silent success. + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => ({ success: false, reason: "already_inactive" }), + getOverrideLiveness: () => ({ active: true, frontier: 50 }), + }), + ); + assert.equal(result, "overrideCleared"); +}); + +test("applyOverrideRead_post_call_null_liveness_treated_as_cleared", () => { + // Post-bump liveness is null (register deleted remotely) → overrideCleared. + let callCount = 0; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: true, + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => { + callCount++; + // Pre-call: active register. Post-call: register gone (null). + return callCount === 1 ? { active: true, frontier: 50 } : null; + }, + }), + ); + assert.equal(result, "overrideCleared"); +}); + +// ── Tests: persistForcedUnread ─────────────────────────────────────────────── + +test("persistForcedUnread_new_entry_adds_and_returns_true", () => { + const { restore } = makeIsolatedStorage(); + try { + const forcedMap = {}; + const result = persistForcedUnread("ch-1", forcedMap, () => 1000, "pk"); + assert.equal(result, true); + assert.equal(Object.hasOwn(forcedMap, "ch-1"), true); + assert.equal(forcedMap["ch-1"], 1000); + } finally { + restore(); + } +}); + +test("persistForcedUnread_existing_entry_with_same_ts_noop_returns_false", () => { + const { restore } = makeIsolatedStorage(); + try { + const forcedMap = { "ch-1": 1000 }; + const result = persistForcedUnread("ch-1", forcedMap, () => 1000, "pk"); + assert.equal(result, false, "no change when ts identical"); + } finally { + restore(); + } +}); + +test("persistForcedUnread_existing_entry_with_new_ts_refreshes_baseline", () => { + // Re-mark after old override died: baseline must update to current frontier + const { restore } = makeIsolatedStorage(); + try { + const forcedMap = { "ch-1": 500 }; + const result = persistForcedUnread("ch-1", forcedMap, () => 1200, "pk"); + assert.equal(result, true, "returns true when baseline updated"); + assert.equal(forcedMap["ch-1"], 1200, "new baseline stored"); + } finally { + restore(); + } +}); + +test("persistForcedUnread_persists_to_store", () => { + const { restore } = makeIsolatedStorage(); + try { + const forcedMap = {}; + persistForcedUnread("ch-x", forcedMap, () => 999, "my-pk"); + const stored = forcedUnreadStore.read("my-pk"); + assert.equal(stored["ch-x"], 999, "entry visible from store"); + } finally { + restore(); + } +}); + +test("persistForcedUnread_null_timestamp_stores_null", () => { + const { restore } = makeIsolatedStorage(); + try { + const forcedMap = {}; + persistForcedUnread("ch-null", forcedMap, () => null, "pk"); + assert.equal(forcedMap["ch-null"], null); + } finally { + restore(); + } +}); + +// ── Tests: resolveChannelReadMarker (read-path gate) ───────────────────────── + +test("resolve_channel_read_marker_uses_max_of_caller_and_observed", () => { + const { markAt } = resolveChannelReadMarker( + "2024-01-01T00:00:00.000Z", + 1704067999, + ); + assert.equal( + markAt, + 1704067999, + "observed latest wins when newer than caller", + ); +}); + +test("resolve_channel_read_marker_uses_caller_when_newer", () => { + const future = new Date(Date.now() + 10_000_000).toISOString(); + const { markAt } = resolveChannelReadMarker(future, 1); + assert.ok(markAt !== null && markAt > 1, "caller wins when newer"); +}); + +test("resolve_channel_read_marker_returns_null_when_both_null", () => { + const { markAt } = resolveChannelReadMarker(null, undefined); + assert.equal(markAt, null); +}); + +test("resolve_channel_read_marker_clears_observed_when_covered", () => { + const { clearObserved } = resolveChannelReadMarker( + "2024-01-01T00:00:01.000Z", + 1704067201, + ); + assert.equal( + clearObserved, + true, + "observed cleared when read marker covers it", + ); +}); + +// ── Tests: forcedUnreadStore persistence ───────────────────────────────────── + +test("forced_unread_store_read_returns_empty_for_unknown_pubkey", () => { + const { restore } = makeIsolatedStorage(); + try { + assert.deepEqual(forcedUnreadStore.read("unknown-pk"), {}); + } finally { + restore(); + } +}); + +test("forced_unread_store_write_and_read_round_trips", () => { + const { restore } = makeIsolatedStorage(); + try { + const pk = "test-pk"; + forcedUnreadStore.write(pk, { "channel-a": 12345, "channel-b": null }); + assert.deepEqual(forcedUnreadStore.read(pk), { + "channel-a": 12345, + "channel-b": null, + }); + } finally { + restore(); + } +}); + +// ── Tests: refusal-does-not-mutate-overlay (active-channel path) ───────────── +// +// Witnesses the requirement that a refused mark-unread does NOT update the +// caller's local state. applyOverrideUnread returns false on any manager +// refusal; callers gate cache writes on that return value. This covers the +// active-channel session-overlay scenario (useChannelUnreadState.handleMarkUnread) +// where the overlay must NOT be set before or on a refused call. + +test("applyOverrideUnread_refusal_caller_must_not_mutate_overlay", () => { + const _toasts = []; + const apis = { + markChannelUnread: () => ({ success: false, reason: "budget_exhausted" }), + markChannelRead: () => ({ success: true }), + getOverrideLiveness: () => null, + }; + // Simulate caller logic: only update local state when applyOverrideUnread + // returns true. On false, the overlay map must remain unmodified. + const overlayMap = {}; + const result = applyOverrideUnread("ch-overlay", apis); + if (result) { + overlayMap["ch-overlay"] = true; // would be set on success + } + assert.equal(result, false, "refusal returns false"); + assert.equal( + Object.hasOwn(overlayMap, "ch-overlay"), + false, + "overlay not mutated on refusal", + ); +}); + +// ── Tests: applyMarkUnreadDividerOverlay (active-channel handleMarkUnread) ──── +// +// This is the extracted production transition from handleMarkUnread in +// useChannelUnreadState. Tests verify: success adds to overlay, refusal does not. + +test("applyMarkUnreadDividerOverlay_success_adds_to_overlay_returns_true", () => { + const overlay = new Set(); + const result = applyMarkUnreadDividerOverlay( + "ch-active", + () => true, // markFn returns true = accepted + overlay, + ); + assert.equal(result, true, "returns true on acceptance"); + assert.equal(overlay.has("ch-active"), true, "adds to overlay on success"); +}); + +test("applyMarkUnreadDividerOverlay_refusal_does_not_add_to_overlay_returns_false", () => { + const overlay = new Set(); + const result = applyMarkUnreadDividerOverlay( + "ch-refused", + () => false, // markFn returns false = refused + overlay, + ); + assert.equal(result, false, "returns false on refusal"); + assert.equal( + overlay.has("ch-refused"), + false, + "overlay not mutated on refusal", + ); +}); + +// ── Tests: markAllChannelsRead partial refusal pattern ──────────────────────── +// +// Witnesses that per-channel gating in markAllChannelsRead correctly clears +// only channels whose override is confirmed inactive. Channels where the +// C-bump refuses and liveness remains active must keep their local state. + +test("applyOverrideRead_partial_refusal_pattern_clears_only_inactive", () => { + // ch-cleared: liveness inactive after successful C-bump → cleared + // ch-stuck: liveness active and C-bump refuses → stays unread + const livenessMap = { + "ch-cleared": { active: true, frontier: 100 }, + "ch-stuck": { active: true, frontier: 100 }, + }; + let clearCallCount = 0; + const apis = { + isReadStateReady: true, + markChannelUnread: () => ({ success: true }), + markChannelRead: (id) => { + clearCallCount++; + if (id === "ch-cleared") { + livenessMap["ch-cleared"] = { active: false, frontier: 101 }; + return { success: true }; + } + // ch-stuck: refuse with overflow; liveness stays active + return { success: false, reason: "uint32_overflow" }; + }, + getOverrideLiveness: (id) => livenessMap[id] ?? null, + }; + + // Simulate the per-channel loop in markAllChannelsRead + const forcedMap = { "ch-cleared": 99, "ch-stuck": 99 }; + for (const channelId of ["ch-cleared", "ch-stuck"]) { + if (applyOverrideRead(channelId, apis) === "overrideCleared") { + delete forcedMap[channelId]; + } + } + + assert.equal( + Object.hasOwn(forcedMap, "ch-cleared"), + false, + "cleared channel removed from map", + ); + assert.equal( + Object.hasOwn(forcedMap, "ch-stuck"), + true, + "stuck channel remains in map", + ); + assert.equal(clearCallCount, 2, "C-bump attempted for both channels"); +}); + +// ── Adversarial witnesses (pass-3 closures) ─────────────────────────────────── + +test("adversarial-3: incomplete_load_null_liveness_not_known_absent_overrideStillActive", () => { + // IMPORTANT: isReadStateReady = false (load incomplete) + null getOverrideLiveness + // must NOT be treated as known register absence. The old bug: isReadStateReady was + // set on initialization (not load-complete), so a partially-loaded manager reported + // null liveness which was silently treated as "no register" and the local hint was + // cleared. With isReadStateReady now meaning loadComplete, this path is fail-closed. + // + // This test drives applyOverrideRead with isReadStateReady=false (semantically: + // load is not complete) and null liveness — must return overrideStillActive. + let markCalled = false; + const result = applyOverrideRead( + "ch-1", + makeApis({ + isReadStateReady: false, // load not complete + markChannelRead: () => { + markCalled = true; + return { success: false, reason: "load_incomplete" }; + }, + getOverrideLiveness: () => null, // null from incomplete load ≠ known absent + }), + ); + assert.equal( + result, + "overrideStillActive", + "incomplete load must fail closed", + ); + assert.equal( + markCalled, + false, + "must not call manager when load not complete", + ); +}); + +test("adversarial-4: applyMarkAllReadTransition_both_branches", () => { + // IMPORTANT: applyMarkAllReadTransition is the extracted production transition + // used inside markAllChannelsRead. Testing it directly (not a simulation) + // ensures production regressions to unconditional deletion are caught. + // + // ch-cleared: applyOverrideRead succeeds (liveness goes inactive) → + // forcedUnread, latestByChannel, observedUnreadEvents all removed. + // ch-refused: applyOverrideRead refused (liveness stays active) → + // all three maps preserved intact. + const livenessMap = { + "ch-cleared": { active: true, frontier: 100 }, + "ch-refused": { active: true, frontier: 100 }, // uint32_overflow refusal + }; + + const apis = { + isReadStateReady: true, + markChannelUnread: () => ({ success: true }), + markChannelRead: (id) => { + if (id === "ch-cleared") { + livenessMap["ch-cleared"] = { active: false, frontier: 101 }; + return { success: true }; + } + return { success: false, reason: "uint32_overflow" }; + }, + getOverrideLiveness: (id) => livenessMap[id] ?? null, + }; + + const latestByChannel = new Map([ + ["ch-cleared", 200], + ["ch-refused", 200], + ]); + const observedEvents = new Map([ + ["ch-cleared", new Map([["ev1", { kind: 9, createdAt: 200 }]])], + ["ch-refused", new Map([["ev2", { kind: 9, createdAt: 200 }]])], + ]); + const forcedMap = { "ch-cleared": 99, "ch-refused": 99 }; + const maps = { + forcedUnread: forcedMap, + latestByChannel, + observedUnreadEvents: observedEvents, + }; + + // Drive the production transition directly (not a reimplementation). + const clearedOutcome = applyMarkAllReadTransition("ch-cleared", apis, maps); + const refusedOutcome = applyMarkAllReadTransition("ch-refused", apis, maps); + + assert.equal(clearedOutcome, "overrideCleared", "cleared channel: outcome"); + assert.equal( + refusedOutcome, + "overrideStillActive", + "refused channel: outcome", + ); + + // ch-cleared: override confirmed inactive → all three maps purged. + assert.equal( + latestByChannel.has("ch-cleared"), + false, + "cleared: latestByChannel removed", + ); + assert.equal( + observedEvents.has("ch-cleared"), + false, + "cleared: observedEvents removed", + ); + assert.equal( + Object.hasOwn(forcedMap, "ch-cleared"), + false, + "cleared: forcedMap removed", + ); + + // ch-refused: override still active → all three maps preserved. + assert.equal( + latestByChannel.has("ch-refused"), + true, + "refused: latestByChannel preserved", + ); + assert.equal( + observedEvents.has("ch-refused"), + true, + "refused: observedEvents preserved", + ); + assert.equal( + Object.hasOwn(forcedMap, "ch-refused"), + true, + "refused: forcedMap preserved", + ); +}); diff --git a/desktop/src/features/channels/readState/readStateFencedLoader.ts b/desktop/src/features/channels/readState/readStateFencedLoader.ts new file mode 100644 index 0000000000..368523ff76 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateFencedLoader.ts @@ -0,0 +1,191 @@ +/** + * NIP-RS full-state fenced loader. + * + * Implements the NIP-RS §Full-State Load procedure (NIP-RS.md:321-377): + * - EOSE-established fence via `relay.subscribeFenced()` — resolves only on + * this subscription's own EOSE, never via the 250 ms fallback or CLOSED + * (NIP-RS.md:341-345). Events are delivered synchronously; no timer drain. + * - ANY lapse (before EOSE, mid-enumeration, post-enumeration) forces + * `complete: false` regardless of query results. + * - Descending `until` cursor with pinned-window check (NIP-RS.md:350-352). + * - `T === 0` terminates complete after the pinned window is discharged — + * no lower standard timestamp exists, so history is fully exhausted. + * - Coordinate deduplicated by `d` tag (greatest `created_at`, lowest id). + * - Returns the deduped parsed records directly so the caller can process them + * once for merge, metadata, conflict rotation, and `maxFetchedCreatedAt`. + */ + +import type { RelayClient } from "@/shared/api/relayClientSession"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_READ_STATE } from "@/shared/constants/kinds"; +import { + parseReadStateEvent, + type ParsedReadStateEvent, +} from "@/features/channels/readState/readStateSnapshot"; +import { isValidReadStateDTag } from "@/features/channels/readState/readStateFormat"; + +/** Number of events per enumeration band. MUST be ≥ L=2 (NIP-RS.md:347). */ +const BAND_LIMIT = 500; +/** NIP-RS §Floor: relay MUST deliver ≥ L events when ≥ L exist (NIP-RS.md:360). */ +const L = 2; + +export type FencedLoadResult = + | { complete: true; events: ParsedReadStateEvent[] } + | { complete: false; events: ParsedReadStateEvent[] }; + +/** + * Perform one full-state fenced enumeration for `pubkey`. + * + * Returns the coordinate-deduped parsed records and whether the load was proven + * complete per the NIP-RS §Full-State Load procedure. A `complete: false` + * result carries best-effort events for baseline display. + */ +export async function fencedEnumerationLoad( + relay: RelayClient, + pubkey: string, +): Promise { + const baseFilter = { + kinds: [KIND_READ_STATE], + authors: [pubkey], + limit: BAND_LIMIT, + }; + + // ── Step 1: Establish the fence BEFORE the first query ────────────────── + // subscribeFenced() resolves `established` ONLY on EOSE — no fallback timer. + // Events are delivered synchronously to `fenceEvents`; no drain timer needed. + const fenceEvents: RelayEvent[] = []; + let fence: Awaited>; + try { + fence = await relay.subscribeFenced(baseFilter, (ev) => { + fenceEvents.push(ev); + }); + } catch { + return emptyIncomplete(); + } + + if (fence.lapsed) { + return emptyIncomplete(); + } + + // Wait for EOSE — resolves only when the relay confirms this subscription. + await fence.established; + + if (fence.lapsed) { + await fence.unsubscribe(); + return emptyIncomplete(); + } + + // ── Step 2: Descending enumeration ────────────────────────────────────── + let C = 0; + let until: number | undefined; + const bandEvents: RelayEvent[] = []; + let complete = false; + + while (!fence.lapsed) { + const filter = { ...baseFilter, ...(until !== undefined ? { until } : {}) }; + let band: RelayEvent[]; + try { + band = await relay.fetchEvents(filter); + } catch { + break; + } + if (fence.lapsed) break; + + if (band.length === 0) { + complete = true; + break; + } + + if (band.length > C) C = band.length; + for (const ev of band) bandEvents.push(ev); + + // T = lowest created_at in this band (NIP-RS.md:350). + let T = band[0].created_at; + for (const ev of band) if (ev.created_at < T) T = ev.created_at; + + // ── Pinned window (NIP-RS.md:350-351) ─────────────────────────────── + if (fence.lapsed) break; + let pinned: RelayEvent[]; + try { + pinned = await relay.fetchEvents({ ...baseFilter, since: T, until: T }); + } catch { + break; + } + if (fence.lapsed) break; + + for (const ev of pinned) bandEvents.push(ev); + if (pinned.length > C) C = pinned.length; + + if (pinned.length >= Math.max(C, L)) { + // Pinned window not discharged — potentially incomplete (spec :351). + break; + } + + if (T === 0) { + // T=0: the pinned window was just discharged (pinned.length < max(C,L)). + // No event can have created_at < 0, so the history is fully exhausted — + // terminate complete directly. An `until:0` continuation would be an + // inclusive re-fetch of the same second-zero events and cannot prove + // anything additional (spec :352). + complete = true; + break; + } + + until = T - 1; + } + + // ── Unsubscribe fence ──────────────────────────────────────────────────── + // Events delivered synchronously — no timer drain needed. + await fence.unsubscribe(); + + // A lapse at ANY point (including after `complete` was tentatively set) + // forces complete:false. + if (fence.lapsed) { + const all = [...bandEvents, ...fenceEvents]; + return buildResult(false, all, pubkey); + } + + const all = [...bandEvents, ...fenceEvents]; + return buildResult(complete, all, pubkey); +} + +/** + * Deduplicate events by `d` tag: retain the event with the greatest + * `created_at`; break ties by the lexicographically lowest event id. + * Prevents superseded coordinate versions from being merged as concurrent + * replicas (NIP-RS.md:348). + */ +export function deduplicateByCoordinate(events: RelayEvent[]): RelayEvent[] { + const best = new Map(); + for (const ev of events) { + const dTag = ev.tags.find((t) => t[0] === "d")?.[1]; + if (!dTag || !isValidReadStateDTag(dTag)) continue; + const existing = best.get(dTag); + if ( + !existing || + ev.created_at > existing.created_at || + (ev.created_at === existing.created_at && ev.id < existing.id) + ) { + best.set(dTag, ev); + } + } + return [...best.values()]; +} + +function emptyIncomplete(): FencedLoadResult { + return { complete: false, events: [] }; +} + +async function buildResult( + complete: boolean, + events: RelayEvent[], + pubkey: string, +): Promise { + const deduped = deduplicateByCoordinate(events); + const parsed: ParsedReadStateEvent[] = []; + for (const ev of deduped) { + const p = await parseReadStateEvent(ev, pubkey); + if (p) parsed.push(p); + } + return { complete, events: parsed }; +} diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 89d092fae9..162dccdf24 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -9,6 +9,8 @@ import { trimContextsToBudget, } from "./readStateManager.ts"; +import { createFencedSubscription } from "../../../shared/api/relayClientShared.ts"; + // ── ReadStateManager integration helpers ───────────────────────────────────── // Provide browser globals required by ReadStateManager (localStorage, // window.setTimeout/clearTimeout). Each test that uses ReadStateManager @@ -47,145 +49,103 @@ function makeLocalStorage() { }); } +/** + * Build a FenceHandle-shaped fake for use in test relay objects. + * + * @param {object} opts + * @param {boolean} opts.eose — if true, `established` resolves immediately (EOSE received). + * @param {boolean} opts.lapsesAfterEose — if true, `lapsed` becomes true after established resolves. + * @param {boolean} opts.lapseBeforeEose — if true, sets lapsed=true and resolves established together. + * @param {(ev: object) => void} [opts.captureHandler] — called with the onEvent handler so tests can deliver events. + */ +function makeFenceHandle({ + eose = true, + lapsesAfterEose = false, + lapseBeforeEose = false, +} = {}) { + let lapsed = lapseBeforeEose; + let resolveEstablished; + const established = new Promise((r) => { + resolveEstablished = r; + }); + + if (lapseBeforeEose) { + // Lapsed before EOSE: resolve immediately with lapsed=true. + resolveEstablished(); + } else if (eose) { + resolveEstablished(); + if (lapsesAfterEose) lapsed = true; + } + // If neither, `established` never resolves (hung fence — caller will lapse via reconnect). + + return { + established, + get lapsed() { + return lapsed; + }, + unsubscribe: async () => {}, + /** For tests that need to trigger a lapse mid-enumeration. */ + _lapse() { + lapsed = true; + }, + }; +} + const threadKey = `thread:${"a".repeat(64)}`; const channelKey = "channel-1"; const channelResolver = (ctx) => ctx.startsWith("thread:") ? channelKey : null; -test("resolveEffectiveTimestamp returns own value when context has no parent", () => { - const effectiveState = new Map([[channelKey, 200]]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: channelKey, - parentResolver: channelResolver, +// biome-ignore format: compact table rows +const resolveEffectiveTimestampCases = [ + { name: "returns own value when context has no parent", effectiveState: new Map([[channelKey, 200]]), contextId: channelKey, resolver: channelResolver, expected: 200 }, + { name: "inherits the channel frontier when it is newer than the thread", effectiveState: new Map([[threadKey, 100], [channelKey, 300]]), contextId: threadKey, resolver: channelResolver, expected: 300 }, + { name: "keeps the thread frontier when it is newer than the channel", effectiveState: new Map([[threadKey, 400], [channelKey, 300]]), contextId: threadKey, resolver: channelResolver, expected: 400 }, + { name: "returns the channel frontier when the thread was never read", effectiveState: new Map([[channelKey, 300]]), contextId: threadKey, resolver: channelResolver, expected: 300 }, + { name: "degrades to the thread's own value when the root is unresolvable", effectiveState: new Map([[threadKey, 100], [channelKey, 300]]), contextId: threadKey, resolver: () => null, expected: 100 }, + { name: "degrades to own value when no resolver is set", effectiveState: new Map([[threadKey, 100], [channelKey, 300]]), contextId: threadKey, resolver: null, expected: 100 }, + { name: "returns null when neither context nor parent has a value", effectiveState: new Map(), contextId: threadKey, resolver: channelResolver, expected: null }, +]; +for (const row of resolveEffectiveTimestampCases) { + test(`resolveEffectiveTimestamp ${row.name}`, () => { + assert.equal( + resolveEffectiveTimestamp({ + effectiveState: row.effectiveState, + contextId: row.contextId, + parentResolver: row.resolver, + }), + row.expected, + ); }); - assert.equal(result, 200); -}); +} -test("resolveEffectiveTimestamp inherits the channel frontier when it is newer than the thread", () => { - // Channel-read clears its threads: marking the channel read at 300 must - // dominate a thread last read at 100. - const effectiveState = new Map([ - [threadKey, 100], - [channelKey, 300], - ]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: threadKey, - parentResolver: channelResolver, +// biome-ignore format: compact table rows +const applyRemoteContextTimestampCases = [ + { name: "ignores older remote read markers from newer sync events", initEffective: 200, initSourceCreatedAt: 10, timestamp: 100, eventCreatedAt: 11, expectedResult: "unchanged", expectedEffective: 200, expectedSourceCreatedAt: 11 }, + { name: "advances to newer remote read markers", initEffective: 100, initSourceCreatedAt: 10, timestamp: 200, eventCreatedAt: 11, expectedResult: "advanced", expectedEffective: 200, expectedSourceCreatedAt: 11 }, + { name: "keeps read markers monotonic even if sync events arrive out of order", initEffective: 100, initSourceCreatedAt: 11, timestamp: 200, eventCreatedAt: 10, expectedResult: "advanced", expectedEffective: 200, expectedSourceCreatedAt: 11 }, +]; +for (const row of applyRemoteContextTimestampCases) { + test(`applyRemoteContextTimestamp ${row.name}`, () => { + const effectiveState = new Map([["channel-1", row.initEffective]]); + const contextSourceCreatedAt = new Map([ + ["channel-1", row.initSourceCreatedAt], + ]); + const result = applyRemoteContextTimestamp({ + effectiveState, + contextSourceCreatedAt, + contextId: "channel-1", + timestamp: row.timestamp, + eventCreatedAt: row.eventCreatedAt, + }); + assert.equal(result, row.expectedResult); + assert.equal(effectiveState.get("channel-1"), row.expectedEffective); + assert.equal( + contextSourceCreatedAt.get("channel-1"), + row.expectedSourceCreatedAt, + ); }); - assert.equal(result, 300); -}); - -test("resolveEffectiveTimestamp keeps the thread frontier when it is newer than the channel", () => { - const effectiveState = new Map([ - [threadKey, 400], - [channelKey, 300], - ]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: threadKey, - parentResolver: channelResolver, - }); - assert.equal(result, 400); -}); - -test("resolveEffectiveTimestamp returns the channel frontier when the thread was never read", () => { - const effectiveState = new Map([[channelKey, 300]]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: threadKey, - parentResolver: channelResolver, - }); - assert.equal(result, 300); -}); - -test("resolveEffectiveTimestamp degrades to the thread's own value when the root is unresolvable", () => { - // Resolver returns null (root not in the event graph) → own term only. - const effectiveState = new Map([ - [threadKey, 100], - [channelKey, 300], - ]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: threadKey, - parentResolver: () => null, - }); - assert.equal(result, 100); -}); - -test("resolveEffectiveTimestamp degrades to own value when no resolver is set", () => { - const effectiveState = new Map([ - [threadKey, 100], - [channelKey, 300], - ]); - const result = resolveEffectiveTimestamp({ - effectiveState, - contextId: threadKey, - parentResolver: null, - }); - assert.equal(result, 100); -}); - -test("resolveEffectiveTimestamp returns null when neither context nor parent has a value", () => { - const result = resolveEffectiveTimestamp({ - effectiveState: new Map(), - contextId: threadKey, - parentResolver: channelResolver, - }); - assert.equal(result, null); -}); - -test("applyRemoteContextTimestamp ignores older remote read markers from newer sync events", () => { - const effectiveState = new Map([["channel-1", 200]]); - const contextSourceCreatedAt = new Map([["channel-1", 10]]); - - const result = applyRemoteContextTimestamp({ - effectiveState, - contextSourceCreatedAt, - contextId: "channel-1", - timestamp: 100, - eventCreatedAt: 11, - }); - - assert.equal(result, "unchanged"); - assert.equal(effectiveState.get("channel-1"), 200); - assert.equal(contextSourceCreatedAt.get("channel-1"), 11); -}); - -test("applyRemoteContextTimestamp advances to newer remote read markers", () => { - const effectiveState = new Map([["channel-1", 100]]); - const contextSourceCreatedAt = new Map([["channel-1", 10]]); - - const result = applyRemoteContextTimestamp({ - effectiveState, - contextSourceCreatedAt, - contextId: "channel-1", - timestamp: 200, - eventCreatedAt: 11, - }); - - assert.equal(result, "advanced"); - assert.equal(effectiveState.get("channel-1"), 200); - assert.equal(contextSourceCreatedAt.get("channel-1"), 11); -}); - -test("applyRemoteContextTimestamp keeps read markers monotonic even if sync events arrive out of order", () => { - const effectiveState = new Map([["channel-1", 100]]); - const contextSourceCreatedAt = new Map([["channel-1", 11]]); - - const result = applyRemoteContextTimestamp({ - effectiveState, - contextSourceCreatedAt, - contextId: "channel-1", - timestamp: 200, - eventCreatedAt: 10, - }); - - assert.equal(result, "advanced"); - assert.equal(effectiveState.get("channel-1"), 200); - assert.equal(contextSourceCreatedAt.get("channel-1"), 11); -}); +} // ── trimContextsToBudget ────────────────────────────────────────────────────── @@ -528,7 +488,11 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { const fakeRelay = { fetchEvents: async () => [], publishEvent: async () => {}, - subscribeLive: () => () => {}, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, }; const pubkey = "b".repeat(64); @@ -561,6 +525,10 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { } }; + // Simulate a completed full-state load so the truncation guard does not + // block the publish path. The guard is tested separately. + mgr.isLoadComplete = true; + // First publish: contexts differ from lastPublishedContexts ({}) → must publish. await mgr.publish(); const callsAfterFirst = publishOneSlotCallCount; @@ -577,3 +545,1818 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { mgr.destroy(); }); + +// ── NIP-RS override layer: mandatory acceptance tests ───────────────────────── + +// Helper: build a ReadStateManager with mocked relay and localStorage. +// subscribeFenced returns an immediately-established fence (happy path). +// subscribeLive is still wired for the live subscription path. +function makeManager(pubkey = "a".repeat(64)) { + globalThis.window.localStorage = makeLocalStorage(); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + }; + return new ReadStateManager(pubkey, fakeRelay); +} + +// ── Test 1: no ov_* key ever reaches a non-primary slot ────────────────────── +test("splitContextsIntoBudgetedSlots_noOverrideKeyInNonPrimarySlot", () => { + // Build channel entries that include ov_* keys for two contexts. + // Also include enough plain channel entries to force multi-slot distribution. + const ctx1 = "a".repeat(64); + const ctx2 = "b".repeat(64); + + const ovEntries = [ + [`ov_s:${ctx1}`, 1], + [`ov_c:${ctx1}`, 0], + [`ov_b:${ctx1}`, 100], + [ctx1, 100], // frontier for ctx1 (normal, no escape needed) + [`ov_s:${ctx2}`, 2], + [`ov_c:${ctx2}`, 1], + [`ov_b:${ctx2}`, 200], + [ctx2, 200], // frontier for ctx2 + ]; + + // Add enough plain channel entries to force at least 2 slots. + // We need the round-robin entries (NOT the pinned ov entries) to overflow. + const plainChannelEntries = []; + for (let i = 0; i < 30; i++) { + plainChannelEntries.push([makeChannelKey(i), i + 1]); + } + const channelEntries = [...ovEntries, ...plainChannelEntries]; + + const encoder = new TextEncoder(); + // Compute the size of a slot containing only the 8 override+frontier entries. + const ovOnlyContexts = Object.fromEntries(ovEntries); + const ovOnlySize = encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: ovOnlyContexts }), + ).length; + // Budget: fits the override entries + ~10 plain entries per slot, + // but not all 30 plain entries in one slot (forces at least 3 slots). + // Add 15 plain entries to the ov-only size to get a safe per-slot budget. + const fifteenPlain = Object.fromEntries(plainChannelEntries.slice(0, 15)); + const fifteenPlainSize = encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: fifteenPlain }), + ).length; + const budget = ovOnlySize + fifteenPlainSize; + + const result = splitContextsIntoBudgetedSlots({ + channelEntries, + threadMsgEntries: [], + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: budget, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed"); + assert.ok(result.slots.length >= 2, "should use at least 2 slots"); + + // Verify: no ov_* key appears in any non-primary slot. + for (let slotIdx = 1; slotIdx < result.slots.length; slotIdx++) { + const slot = result.slots[slotIdx]; + for (const key of Object.keys(slot)) { + assert.ok( + !key.startsWith("ov_"), + `ov_* key "${key}" must not appear in slot ${slotIdx} (non-primary)`, + ); + } + } + + // Verify: ov_* keys and their frontier siblings ARE in slot 0. + const slot0 = result.slots[0]; + assert.ok(`ov_s:${ctx1}` in slot0, "ov_s:ctx1 must be in slot 0"); + assert.ok(`ov_c:${ctx1}` in slot0, "ov_c:ctx1 must be in slot 0"); + assert.ok(`ov_b:${ctx1}` in slot0, "ov_b:ctx1 must be in slot 0"); + assert.ok(ctx1 in slot0, "frontier for ctx1 must be in slot 0"); + assert.ok(`ov_s:${ctx2}` in slot0, "ov_s:ctx2 must be in slot 0"); + assert.ok(ctx2 in slot0, "frontier for ctx2 must be in slot 0"); +}); + +// ── Test 1b: reserved esc: raw ID — unescape-before-group rule ─────────────── +test("splitContextsIntoBudgetedSlots_escapedFrontierKeyStaysWithItsOverrideGroup", () => { + // A context whose raw ID starts with "ov_" must be escaped to "esc:ov_s:evil" + // as a frontier wire key. The ov_* siblings are keyed by the RAW suffix + // "ov_s:evil". The splitter must unescape "esc:ov_s:evil" → "ov_s:evil" + // and recognise it as belonging to the same group as ov_s:ov_s:evil etc. + const rawCtx = "ov_s:evil"; + const wireKey = `esc:${rawCtx}`; // what currentContexts() emits + + const channelEntries = [ + [`ov_s:${rawCtx}`, 1], // ov_s:ov_s:evil + [`ov_c:${rawCtx}`, 0], // ov_c:ov_s:evil + [`ov_b:${rawCtx}`, 50], // ov_b:ov_s:evil + [wireKey, 50], // esc:ov_s:evil (escaped frontier) + ]; + // Add plain entries to force multi-slot so the splitter actually partitions. + const plain = []; + for (let i = 0; i < 20; i++) plain.push([makeChannelKey(i), i + 1]); + const allEntries = [...channelEntries, ...plain]; + + const encoder = new TextEncoder(); + // Budget: fits the override group + 5 plain entries, not all 20. + const groupOnly = Object.fromEntries(channelEntries); + const fivePlain = Object.fromEntries(plain.slice(0, 5)); + const budget = + encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: groupOnly }), + ).length + + encoder.encode( + JSON.stringify({ v: 1, client_id: CLIENT_ID, contexts: fivePlain }), + ).length; + + const result = splitContextsIntoBudgetedSlots({ + channelEntries: allEntries, + threadMsgEntries: [], + clientId: CLIENT_ID, + initialSlotCount: 1, + maxSlots: 8, + maxBytes: budget, + slotIdGenerator: deterministicSlotId, + }); + + assert.ok(result !== null, "should succeed"); + assert.ok(result.slots.length >= 2, "should split"); + const slot0 = result.slots[0]; + // The escaped frontier key and all three ov_* siblings must be in slot 0. + assert.ok( + wireKey in slot0, + `${wireKey} (escaped frontier) must be in slot 0`, + ); + assert.ok(`ov_s:${rawCtx}` in slot0, "ov_s: sibling must be in slot 0"); + assert.ok(`ov_c:${rawCtx}` in slot0, "ov_c: sibling must be in slot 0"); + assert.ok(`ov_b:${rawCtx}` in slot0, "ov_b: sibling must be in slot 0"); + // No ov_* or esc: entry in non-primary slots. + for (let i = 1; i < result.slots.length; i++) { + for (const key of Object.keys(result.slots[i])) { + assert.ok( + !key.startsWith("ov_") && !key.startsWith("esc:"), + `reserved key "${key}" must not appear in slot ${i}`, + ); + } + } +}); + +// ── Test 2: NIP-RS fenced enumeration — EOSE, lapse, epoch-zero, retry ──────── +// All fetchAndMerge tests use subscribeFenced returning a proper FenceHandle so +// the loader only declares complete after an EOSE-established fence. + +// Helper to build a minimal valid-looking relay event for the pubkey. +function makeFakeEvent(pubkey, createdAt) { + return { + id: `${createdAt.toString(16).padStart(8, "0")}${"0".repeat(56)}`, + pubkey, + kind: 30078, + content: "", + tags: [], + created_at: createdAt, + sig: "s".repeat(128), + }; +} + +// ── 2a: empty relay + EOSE → complete ──────────────────────────────────────── +test("fetchAndMerge_emptyRelay_setsLoadComplete", async () => { + // A relay with no events + EOSE-established fence → empty first band → complete. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a".repeat(64); + let subscribeFencedCallCount = 0; + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => { + subscribeFencedCallCount++; + return makeFenceHandle({ eose: true }); + }, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + true, + "empty relay with EOSE-established fence must produce complete load", + ); + assert.equal( + subscribeFencedCallCount, + 1, + "subscribeFenced must be called exactly once for the fence", + ); + mgr.destroy(); +}); + +// ── 2b/2c: lapse before EOSE (two scenarios: 250ms fallback path, terminal CLOSED) ─ +// Both reduce to lapseBeforeEose:true on the fence handle — the loader must +// conclude complete:false in either case. +// biome-ignore format: compact table rows +const lapseBeforeEoseCases = [ + { name: "lapseBeforeEose_setsLoadIncomplete", pubkey: "a1".repeat(32), desc: "lapse before EOSE (e.g. 250 ms fallback path) must produce incomplete load" }, + { name: "terminalClosed_setsLoadIncomplete", pubkey: "a2".repeat(32), desc: "terminal CLOSED (fence lapse before EOSE) must produce incomplete load" }, +]; +for (const { name, pubkey, desc } of lapseBeforeEoseCases) { + test(`fetchAndMerge_${name}`, async () => { + globalThis.window.localStorage = makeLocalStorage(); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: false, lapseBeforeEose: true }), + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal(mgr.isLoadComplete, false, desc); + mgr.destroy(); + }); +} + +// ── 2d: reconnect during post-empty barrier → lapse after tentative complete ── +test("fetchAndMerge_lapseAfterEmptyBand_forcesIncomplete", async () => { + // Empty first band → loader sets complete=true tentatively; fence lapses + // after EOSE (simulates mid-load reconnect). Final fence.lapsed check must + // override and force complete:false. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a3".repeat(32); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true, lapsesAfterEose: true }), + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + false, + "lapse after tentative complete must override and force incomplete", + ); + mgr.destroy(); +}); + +// ── 2e: single event completes after pinned-window discharge ────────────────── +test("fetchAndMerge_singleEvent_completesAfterPinnedWindowDischarge", async () => { + // Single event at T=1000: band=1, C=1, L=2 → max(C,L)=2. + // Pinned window {since:1000, until:1000} returns 1 event → 1 < 2 → discharged. + // Continuation {until:999} returns 0 → complete. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "b".repeat(64); + const event = makeFakeEvent(pubkey, 1000); + const fakeRelay = { + fetchEvents: async (filter) => { + if (filter.since !== undefined && filter.until !== undefined) + return [event]; // pinned + if (filter.until !== undefined && filter.until < 1000) return []; // continuation + return [event]; // initial band + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true }), + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + true, + "single-event relay must produce complete load after pinned-window discharge", + ); + mgr.destroy(); +}); + +// ── 2f: pinned-window-at-cap → incomplete ──────────────────────────────────── +test("fetchAndMerge_pinnedWindowAtCap_setsLoadIncomplete", async () => { + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "c".repeat(64); + const events = [ + makeFakeEvent(pubkey, 2000), + makeFakeEvent(pubkey, 2000), + makeFakeEvent(pubkey, 2000), + ]; + const fakeRelay = { + fetchEvents: async (filter) => { + if (filter.since !== undefined) return events; // pinned window returns cap-many + return events; + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _handler) => + makeFenceHandle({ eose: true }), + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + false, + "pinned window returning ≥ max(C,L) must produce incomplete load", + ); + mgr.destroy(); +}); + +// ── 2g: epoch-zero termination ─────────────────────────────────────────────── +test("fetchAndMerge_epochZero_completesAfterPinnedWindowDischarged", async () => { + // T=0: an event at created_at=0 → T=0. Once the pinned window is discharged, + // no event can have created_at < 0, so the load terminates complete directly. + // The loader must NOT issue an `until:0` continuation (which is inclusive + // and would re-fetch the same epoch-zero event, making completion unprovable). + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a4".repeat(32); + const event = makeFakeEvent(pubkey, 0); // created_at=0 + let continuationCalled = false; + const fakeRelay = { + fetchEvents: async (filter) => { + // Pinned window: {since:0, until:0} → 1 event, 1 < max(1,2)=2 → discharged. + if (filter.since !== undefined && filter.until !== undefined) + return [event]; + // Any `until:0` without `since` would be the old continuation path — + // it must NOT be issued; flag it if it is. + if (filter.until === 0 && filter.since === undefined) { + continuationCalled = true; + return [event]; // a conforming relay returns the event (inclusive filter) + } + return [event]; // initial band + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true }), + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + true, + "T=0 pinned-window-discharged must produce complete load directly", + ); + assert.equal( + continuationCalled, + false, + "loader must NOT issue until:0 continuation after epoch-zero pinned-window discharge", + ); + mgr.destroy(); +}); + +// ── 2h: subscribeFenced throws → fence fails → incomplete ──────────────────── +test("fetchAndMerge_fenceFails_setsLoadIncomplete", async () => { + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "d".repeat(64); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async () => { + throw new Error("connection refused"); + }, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + false, + "subscribeFenced failure must produce incomplete load", + ); + mgr.destroy(); +}); + +// ── 2i: incomplete load blocks gated operations ─────────────────────────────── +test("fetchAndMerge_incompleteLoad_blocksGatedOperations", async () => { + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "e".repeat(64); + const events = [ + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + ]; + let publishCalls = 0; + const fakeRelay = { + fetchEvents: async (filter) => { + if (filter.since !== undefined) return events; // pinned window + return events; // band + }, + publishEvent: async () => { + publishCalls++; + }, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.markContextRead("ch", 1000); + await mgr.fetchAndMerge(); + + assert.equal(mgr.isLoadComplete, false, "precondition: load is incomplete"); + + mgr.fetchOwnBlobBeforePublish = async () => true; + await mgr.publish(); + assert.equal(publishCalls, 0, "publish must be blocked when load incomplete"); + + const ur = mgr.markChannelUnread("ch"); + assert.equal(ur.success, false); + assert.equal(ur.reason, "load_incomplete"); + + const rr = mgr.markChannelRead("ch"); + assert.equal(rr.success, false); + assert.equal(rr.reason, "load_incomplete"); + + mgr.extraSlotIds = ["fakeextraslot0000000000000000000"]; + await mgr.deleteExtraSlots(); + assert.equal( + publishCalls, + 0, + "deleteExtraSlots must not publish when load incomplete", + ); + + mgr.destroy(); +}); + +// ── 2j: production retry path fires on reconnect ───────────────────────────── +test("retryLoad_firesOnReconnect_andClearsIncomplete", async () => { + // startLiveSubscription wires retryLoad() via subscribeToReconnects. + // (1) reconnect listener registered; (2) firing it re-runs fetchAndMerge. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a5".repeat(32); + let reconnectCb = null; + let callRound = 0; + const fakeRelay = { + fetchEvents: async (filter) => { + callRound++; + if (callRound <= 3) { + if (filter.since !== undefined) + return [ + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + ]; + return [ + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + ]; + } + return []; + }, + publishEvent: async () => {}, + subscribeToReconnects: (cb) => { + reconnectCb = cb; + return () => { + reconnectCb = null; + }; + }, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.initialize(); + assert.equal( + mgr.isLoadComplete, + false, + "precondition: first load is incomplete", + ); + assert.ok( + reconnectCb !== null, + "subscribeToReconnects listener must be registered", + ); + + callRound = 999; + reconnectCb(); + await new Promise((r) => setTimeout(r, 50)); + assert.equal( + mgr.isLoadComplete, + true, + "reconnect-triggered retry must clear incomplete when relay is now empty", + ); + mgr.destroy(); +}); + +// ── 2k: direct retry clears incomplete ─────────────────────────────────────── +test("fetchAndMerge_retryClears_incomplete", async () => { + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "f".repeat(64); + let callRound = 0; + const fakeRelay = { + fetchEvents: async (filter) => { + callRound++; + if (callRound <= 3) { + if (filter.since !== undefined) + return [ + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + ]; + return [ + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + makeFakeEvent(pubkey, 1000), + ]; + } + return []; + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal(mgr.isLoadComplete, false, "first load must be incomplete"); + + callRound = 999; + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + true, + "retry with empty relay must set complete", + ); + mgr.destroy(); +}); + +// ── Test 3: live events go through structured ingest ────────────────────────── +test("handleIncomingEvent_liveOverride_updatesRegisterViaIngest", async () => { + // Construct a fake event whose content decrypts to a NIP-RS blob with an + // override register. Use a __TAURI_INTERNALS__ mock so parseReadStateEvent + // can decrypt without a real NIP-44 key. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "aa".repeat(32); + const rawCtx = `live-channel-${"x".repeat(51)}`; + const channelFrontier = 100; + // Build a blob where the register (S=3, C=1, B=50) is ACTIVE: + // isOverrideActive = S>0 && F<=B && S>C. B=50, frontier=100 → F>B → INACTIVE. + // Corrected: to make this active we need B >= F, e.g. B=100 with F=100 (boundary). + // Using B=100 so the register is genuinely active: 3>0, 100<=100, 3>1 → active. + const blobContexts = { + [rawCtx]: channelFrontier, + [`ov_s:${rawCtx}`]: 3, + [`ov_c:${rawCtx}`]: 1, + [`ov_b:${rawCtx}`]: 100, // B=100 >= F=100 → active + }; + const plaintext = JSON.stringify({ + v: 1, + client_id: "other-device", + contexts: blobContexts, + }); + + // Install Tauri IPC mock so nip44_decrypt_from_self returns our plaintext. + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "nip44_decrypt_from_self") { + if (args.ciphertext === "FAKE_CIPHER") return plaintext; + throw new Error("unknown ciphertext"); + } + throw new Error(`Unexpected Tauri command: ${command}`); + }, + }; + + let liveHandler = null; + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => + makeFenceHandle({ eose: true }), + subscribeLive: async (_filter, handler) => { + liveHandler = handler; + return () => {}; + }, + }; + try { + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.initialize(); + + // Build fake NIP-RS event. + const fakeEvent = { + id: "b".repeat(64), + pubkey, + created_at: 2_000_000, + kind: 30078, + tags: [ + ["d", "read-state:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"], + ["t", "read-state"], + ], + content: "FAKE_CIPHER", + sig: "s".repeat(128), + }; + + // Deliver via the live subscription callback (same path as relay push). + // Track decrypt count: each live event must decrypt exactly once. + let decryptCount = 0; + const origInvoke = globalThis.window.__TAURI_INTERNALS__.invoke; + globalThis.window.__TAURI_INTERNALS__.invoke = async (command, args) => { + if (command === "nip44_decrypt_from_self") decryptCount++; + return origInvoke(command, args); + }; + assert.ok(liveHandler !== null, "live subscription must be established"); + liveHandler(fakeEvent); // void-wrapped; wait for async completion + await new Promise((r) => setTimeout(r, 50)); + assert.equal( + decryptCount, + 1, + "live event must decrypt exactly once (no double-parse)", + ); + + // The override register must now reflect the ingested remote values. + const reg = mgr.overrideRegisters.get(rawCtx); + assert.ok(reg, "override register must exist after live delivery"); + assert.equal(reg.s, 3, "S must be 3 from live event"); + assert.equal(reg.c, 1, "C must be 1 from live event"); + assert.equal(reg.b, 100, "B must be 100 from live event"); + + // Verify the register is genuinely active: isOverrideActive(S=3,C=1,B=100,F=100). + const livenessActive = mgr.getOverrideLiveness(rawCtx); + assert.ok( + livenessActive !== null, + "liveness must be available after live delivery", + ); + assert.equal( + livenessActive.active, + true, + "register must be active (B=100 >= F=100, S>C)", + ); + + // ── existing-key live clear (higher C defeating S) ─────────────────── + // A follow-up event with S=3, C=4 (C > S → inactive/tombstone). + const blobClear = { + [rawCtx]: channelFrontier, + [`ov_c:${rawCtx}`]: 4, // tombstone floor: max(S=3,C=1)+1=4 + }; + const ptClear = JSON.stringify({ + v: 1, + client_id: "other-device-2", + contexts: blobClear, + }); + const fakeEventClear = { + id: "c".repeat(64), + pubkey, + created_at: 2_000_001, + kind: 30078, + tags: [ + ["d", "read-state:b1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"], + ["t", "read-state"], + ], + content: "FAKE_CIPHER_2", + sig: "s".repeat(128), + }; + globalThis.window.__TAURI_INTERNALS__.invoke = async (command, args) => { + if (command === "nip44_decrypt_from_self") { + if (args.ciphertext === "FAKE_CIPHER_2") return ptClear; + if (args.ciphertext === "FAKE_CIPHER") return plaintext; + throw new Error("unknown ciphertext"); + } + throw new Error(`Unexpected Tauri command: ${command}`); + }; + liveHandler(fakeEventClear); // void-wrapped; wait for async completion + await new Promise((r) => setTimeout(r, 50)); + const regAfterClear = mgr.overrideRegisters.get(rawCtx); + assert.ok(regAfterClear, "register must still exist after clear event"); + // tombstone floor: only ov_c:ctx=4 is present → S=0, C=4, B=0 merged via componentwise max + // after merge with prior (S=3, C=1, B=100): S=3, C=4, B=100 — override_active = S <= C = inactive + assert.equal( + regAfterClear.c, + 4, + "C must be 4 (tombstone floor from clear event)", + ); + const liveness = mgr.getOverrideLiveness(rawCtx); + assert.ok(liveness !== null, "liveness must be available"); + assert.equal( + liveness.active, + false, + "override must be inactive after clear", + ); + + mgr.destroy(); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + } +}); + +// ── Test 4: fetch-before-write failure → zero publishes ────────────────────── +test("publish_fetchOwnBlobFails_doesNotPublish", async () => { + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "bb".repeat(32); + let publishCalls = 0; + const fakeRelay = { + fetchEvents: async (filter) => { + // Own-blob fetch (has #d filter) → fail. + if (filter["#d"]) throw new Error("relay unreachable"); + return []; + }, + publishEvent: async () => { + publishCalls++; + }, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.isLoadComplete = true; + mgr.markContextRead("ch", 1000); + await mgr.publish(); + assert.equal( + publishCalls, + 0, + "publish must not call publishEvent when fetchOwnBlobBeforePublish fails", + ); + mgr.destroy(); +}); + +// ── Test 5: durability — register survives restart before debounce ──────────── +test("overrideRegister_survivesRestartBeforeDebounce", () => { + // markChannelUnread persists the register via persistLocalState. + // A new ReadStateManager constructed from the same localStorage must see it. + const ls = makeLocalStorage(); + globalThis.window.localStorage = ls; + const pubkey = "cc".repeat(32); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr1 = new ReadStateManager(pubkey, fakeRelay); + mgr1.isLoadComplete = true; + mgr1.effectiveState.set("restart-ch", 1000); + mgr1.publishableContextIds.add("restart-ch"); + const result = mgr1.markChannelUnread("restart-ch"); + assert.equal(result.success, true, "mark-unread must succeed"); + mgr1.destroy(); + + // Construct a new manager on the same localStorage — no relay fetch yet. + globalThis.window.localStorage = ls; + const mgr2 = new ReadStateManager(pubkey, fakeRelay); + mgr2.hydrateFromLocalStorage(); + const liveness = mgr2.getOverrideLiveness("restart-ch"); + assert.ok(liveness !== null, "register must be hydrated after restart"); + assert.equal( + liveness.active, + true, + "override must still be active after restart", + ); + mgr2.destroy(); +}); + +// ── Test 6: durability — tombstone floor survives restart with fetch failure ── +test("overrideRegister_tombstoneFloorSurvivesRestartWithFetchFailure", async () => { + const ls = makeLocalStorage(); + globalThis.window.localStorage = ls; + const pubkey = "dd".repeat(32); + const goodRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + // Establish a mark-read tombstone floor: S=1, C=max(S,C)+1=2 (clear-wins → inactive). + const mgr1 = new ReadStateManager(pubkey, goodRelay); + mgr1.isLoadComplete = true; + mgr1.effectiveState.set("tombstone-ch", 500); + mgr1.publishableContextIds.add("tombstone-ch"); + mgr1.markChannelUnread("tombstone-ch"); // S→max(0,0)+1=1, C=0, B→frontier + mgr1.markChannelRead("tombstone-ch"); // C→max(1,0)+1=2 (clear-wins) + const tomb = mgr1.overrideRegisters.get("tombstone-ch"); + assert.ok(tomb, "tombstone register must exist"); + assert.equal(tomb.s, 1); + assert.equal(tomb.c, 2); // max(S=1,C=0)+1 = 2 + mgr1.destroy(); + + // New manager: initialize() with a relay that throws on fetchEvents. + // Tombstone must still be present even after failed fetch. + globalThis.window.localStorage = ls; + const failRelay = { + fetchEvents: async () => { + throw new Error("network unavailable"); + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async () => { + throw new Error("network unavailable"); + }, + subscribeLive: async () => { + throw new Error("network unavailable"); + }, + }; + const mgr2 = new ReadStateManager(pubkey, failRelay); + await mgr2.initialize(); // fetch fails, but hydration must have run first + const reg = mgr2.overrideRegisters.get("tombstone-ch"); + assert.ok(reg, "tombstone register must survive restart with fetch failure"); + assert.equal(reg.s, 1, "S must be preserved"); + assert.equal(reg.c, 2, "C must be preserved (tombstone floor: max(1,0)+1=2)"); + mgr2.destroy(); +}); + +// ── Test 7: budget planner ──────────────────────────────────────────────────── +test("markChannelUnread_visibleRefusal_atBudgetExhaustionAndUint32Max", () => { + const mgr = makeManager(); + // Simulate completed load so mark operations are not blocked by load_incomplete. + mgr.isLoadComplete = true; + + // ── uint32 max refusal ──────────────────────────────────────────────────── + const UINT32_MAX = 0xffffffff; + const overflowCtx = "overflow-channel"; + mgr.overrideRegisters.set(overflowCtx, { + s: UINT32_MAX, + c: UINT32_MAX, + b: 0, + }); + mgr.publishableContextIds.add(overflowCtx); + mgr.effectiveState.set(overflowCtx, 100); + + const overflowResult = mgr.markChannelUnread(overflowCtx); + assert.equal(overflowResult.success, false); + assert.equal( + overflowResult.reason, + "uint32_overflow", + "markChannelUnread must refuse with uint32_overflow when S is at max", + ); + + // ── multi-slot success: 700 frontier-only channels → split planner must succeed ─ + // Thufir's deterministic witness: 700 plain frontier channels overflow a single slot + // but the splitter can spread them across ≤ 8 slots. A new override group in the + // primary must succeed because the pure candidate planner tries the split path. + const splitMgr = makeManager("f".repeat(64)); + splitMgr.isLoadComplete = true; + for (let i = 0; i < 700; i++) { + const ctx = `frontier-ch-${i.toString().padStart(60, "0")}`; + splitMgr.effectiveState.set(ctx, 1000 + i); + splitMgr.publishableContextIds.add(ctx); + } + const splitCtx = `new-override-ctx-${"n".repeat(48)}`; + const splitResult = splitMgr.markChannelUnread(splitCtx); + assert.equal( + splitResult.success, + true, + "700 frontier-only channels must allow a new override via multi-slot split", + ); + assert.ok( + splitMgr.overrideRegisters.has(splitCtx), + "override register must be committed on split success", + ); + splitMgr.destroy(); + + // ── near-limit refusal: all 8 slots insufficient → budget_exhausted, no mutation ─ + // Pack so many non-evictable override groups that even 8 slots cannot accommodate + // the new entry. Each override group contributes ov_s+ov_c+ov_b+frontier ≈ 4 keys + // × ~75 bytes each + JSON overhead. 200 groups × 4 keys ≈ 300 bytes each ≈ 60 KB + // per slot if split across 8 → ~7.5 KB per slot, which fits. We need them to NOT fit. + // Easiest: fill primary to near-capacity with 250 big-key override groups so even + // splitting all 8 slots still cannot carry the new group in primary (which must + // hold ALL override groups per spec, so they all go in slot 0). + const fullMgr = makeManager("aa".repeat(32)); + fullMgr.isLoadComplete = true; + // 250 override groups with long context IDs ≈ 250 × (4 keys × ~100 bytes) = 100 KB + // → exceeds READ_STATE_MAX_PLAINTEXT_BYTES (32 KB) even in slot 0 alone. + for (let i = 0; i < 250; i++) { + const ctx = `ov-ch-${i.toString().padStart(60, "0")}`; + fullMgr.overrideRegisters.set(ctx, { s: 1, c: 0, b: 0 }); + fullMgr.effectiveState.set(ctx, 1000 + i); + fullMgr.publishableContextIds.add(ctx); + } + const nearCtx = `near-limit-ctx-${"z".repeat(49)}`; + const nearResult = fullMgr.markChannelUnread(nearCtx); + assert.equal( + nearResult.success, + false, + "near-limit manager with 250 non-evictable override groups must refuse", + ); + assert.equal(nearResult.reason, "budget_exhausted"); + assert.ok( + !fullMgr.overrideRegisters.has(nearCtx), + "budget_exhausted must not mutate overrideRegisters", + ); + fullMgr.destroy(); + + // Verify the uint32_overflow register was not mutated in the original mgr. + const reg = mgr.overrideRegisters.get(overflowCtx); + assert.ok(reg, "overflow channel register must still exist"); + assert.equal(reg.s, UINT32_MAX, "overflow register S must be unchanged"); + + mgr.destroy(); +}); + +// ── Test 8: persistence failure — storage_failed + rollback + coherent restart ─ +test("markChannelUnread_storageFailure_returnsStorageFailed", () => { + // Use a throwing localStorage to simulate quota failure. + const throwingLS = makeLocalStorage(); + const originalSetItem = throwingLS.setItem.bind(throwingLS); + // Allow initial writes (ClientId, slotId), then fail on override writes. + let writeCount = 0; + throwingLS.setItem = (key, value) => { + writeCount++; + if (writeCount > 2) throw new Error("QuotaExceededError"); + originalSetItem(key, value); + }; + globalThis.window.localStorage = throwingLS; + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + }; + const mgr = new ReadStateManager("st".repeat(32), fakeRelay); + mgr.isLoadComplete = true; + mgr.effectiveState.set("storage-test-ch", 1000); + + // Snapshot pre-mutation state for rollback verification. + const regBefore = mgr.overrideRegisters.get("storage-test-ch"); + const wasPublishableBefore = mgr.publishableContextIds.has("storage-test-ch"); + + const result = mgr.markChannelUnread("storage-test-ch"); + assert.equal( + result.success, + false, + "markChannelUnread must fail when localStorage throws", + ); + assert.equal(result.reason, "storage_failed"); + + // Contract 3 rollback: manager state must be unchanged after storage_failed. + const regAfter = mgr.overrideRegisters.get("storage-test-ch"); + assert.deepEqual( + regAfter, + regBefore, + "overrideRegisters must be rolled back after storage failure", + ); + assert.equal( + mgr.publishableContextIds.has("storage-test-ch"), + wasPublishableBefore, + "publishableContextIds must be rolled back after storage failure", + ); + + // Coherent restart: a new manager on the same (throwing) storage must see no + // orphaned register — the failed write must not have partially persisted. + const mgr2 = new ReadStateManager("st".repeat(32), fakeRelay); + mgr2.hydrateFromLocalStorage(); + const regOnRestart = mgr2.overrideRegisters.get("storage-test-ch"); + assert.equal( + regOnRestart, + undefined, + "a failed mark must not persist any register (coherent restart)", + ); + mgr.destroy(); + mgr2.destroy(); +}); + +// ── Test 9: inactive existing register still gets C-bump on markChannelRead ─── +test("markChannelRead_inactiveExistingRegister_performsCBump", () => { + const mgr = makeManager(); + mgr.isLoadComplete = true; + // Inject an inactive register: S=1, C=2 (clear-wins: C > S → inactive). + const ctx = "inactive-ch"; + mgr.overrideRegisters.set(ctx, { s: 1, c: 2, b: 0 }); + mgr.publishableContextIds.add(ctx); + mgr.effectiveState.set(ctx, 100); + + // Verify it is indeed inactive. + const livenessBefore = mgr.getOverrideLiveness(ctx); + assert.ok(livenessBefore !== null, "register must exist"); + assert.equal(livenessBefore.active, false, "register must be inactive"); + + // markChannelRead must still perform the C-bump (spec NIP-RS.md:537-539). + const result = mgr.markChannelRead(ctx); + assert.equal( + result.success, + true, + "markChannelRead must succeed on inactive register", + ); + + const reg = mgr.overrideRegisters.get(ctx); + assert.ok(reg, "register must still exist after markChannelRead"); + // newC = max(S=1, C=2) + 1 = 3 + assert.equal( + reg.c, + 3, + "C must be bumped to max(S,C)+1=3 even when already inactive", + ); + assert.equal(reg.s, 1, "S must be unchanged"); + mgr.destroy(); +}); + +// ── Test 10: coordinate dedupe — newer version wins, older version dropped ──── +test("deduplicateByCoordinate_newerVersionWins_olderDropped", async () => { + const { deduplicateByCoordinate } = await import( + "./readStateFencedLoader.ts" + ); + const pubkey = "de".repeat(32); + const dTag = "read-state:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"; + const older = { + id: "b".repeat(64), + pubkey, + created_at: 1_000, + kind: 30078, + tags: [["d", dTag]], + content: "older", + sig: "s".repeat(128), + }; + const newer = { + id: "a".repeat(64), // lower id — for tie-break test below + pubkey, + created_at: 2_000, + kind: 30078, + tags: [["d", dTag]], + content: "newer", + sig: "s".repeat(128), + }; + const deduped = deduplicateByCoordinate([older, newer]); + assert.equal(deduped.length, 1, "dedup must yield one event"); + assert.equal(deduped[0].content, "newer", "newer created_at must win"); + + // Tie-break: same created_at, lower id wins. + const tie1 = { ...older, created_at: 3_000, id: "c".repeat(64) }; + const tie2 = { ...newer, created_at: 3_000, id: "a".repeat(64) }; + const tieDuped = deduplicateByCoordinate([tie1, tie2]); + assert.equal(tieDuped.length, 1, "tie-break dedup must yield one event"); + assert.equal(tieDuped[0].id, "a".repeat(64), "lower id must win on tie"); +}); + +// ── Test 11: lapse mid-enumeration → incomplete ─────────────────────────────── +test("fetchAndMerge_lapseMidEnumeration_setsLoadIncomplete", async () => { + // Simulate a connection lapse after the first band is fetched but before + // the pinned window returns. The fence.lapsed flag is set mid-enumeration. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "ef".repeat(32); + const event = makeFakeEvent(pubkey, 1000); + + // Build a fence that starts unlapsed but lapses when the pinned window fires. + const fence = makeFenceHandle({ eose: true }); + const fakeRelay = { + fetchEvents: async (filter) => { + if (filter.since !== undefined) { + // Pinned window: trigger lapse mid-enumeration. + fence._lapse(); + return [event]; + } + return [event]; // initial band + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_filter, _onEvent) => fence, + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + await mgr.fetchAndMerge(); + assert.equal( + mgr.isLoadComplete, + false, + "lapse during enumeration must produce incomplete load", + ); + mgr.destroy(); +}); + +// ── Test 12: foreign client_id at initial load triggers slot rotation ───────── +test("fetchAndMerge_foreignClientId_rotatesSlotAndUpdatesMetadata", async () => { + // If a fetched event carries our slot coordinate but a different client_id, + // the manager must rotate slotId and record maxFetchedCreatedAt. + // Also validates read-before-write path: fetchOwnBlobBeforePublish runs the + // same parsed-record metadata path (Contract 2). + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a6".repeat(32); + + // Install Tauri IPC mock. + const slotId = "deadbeefdeadbeef0123456789abcdef"; // will be the initial slotId + const foreignClientId = "other-client-uuid-9999"; + const blob = JSON.stringify({ + v: 1, + client_id: foreignClientId, + contexts: { "ch-conflict": 500 }, + }); + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "nip44_decrypt_from_self") { + if (args.ciphertext === "CONFLICT_CIPHER") return blob; + return JSON.stringify({ + v: 1, + client_id: foreignClientId, + contexts: {}, + }); + } + throw new Error(`Unexpected: ${command}`); + }, + }; + + // Build a fetched event at our slot coordinate but with foreign client_id. + const conflictEvent = { + id: "f0".repeat(32), + pubkey, + created_at: 12345, + kind: 30078, + tags: [ + ["d", `read-state:${slotId}`], + ["t", "read-state"], + ], + content: "CONFLICT_CIPHER", + sig: "s".repeat(128), + }; + + const fakeRelay = { + // Respect `until` so the loader terminates: once `until` drops below the + // event's created_at the relay returns empty, ending the enumeration. + fetchEvents: async (filter) => { + if (filter.until !== undefined && conflictEvent.created_at > filter.until) + return []; + return [conflictEvent]; + }, + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + + try { + const mgr = new ReadStateManager(pubkey, fakeRelay); + // Pre-seed the manager's slotId to match the conflict event's d-tag. + mgr.slotId = slotId; + await mgr.fetchAndMerge(); + + // Slot rotation: slotId must differ from the conflicting coordinate. + assert.notEqual( + mgr.slotId, + slotId, + "slotId must rotate when fetched event carries foreign client_id at our coordinate", + ); + + // maxFetchedCreatedAt must reflect the fetched event's created_at. + assert.equal( + mgr.maxFetchedCreatedAt, + 12345, + "maxFetchedCreatedAt must be updated from fetched event created_at", + ); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + } +}); + +// ── Test 13: frontier-only advance schedules canonical convergence ───────────── +test("ingest_frontierAdvanceFlipsRegister_schedulesCanonicalConvergence", async () => { + // A frontier advance that flips an override register from live→tombstone must + // set canonicalChanged=true and trigger schedulePublish (debounce timer set). + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "a7".repeat(32); + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.isLoadComplete = true; + + // Seed an active register: S=5, C=0, B=10, F=5. + // isOverrideActive(S=5,C=0,B=10,F=5) = 5>0 && 5<=10 && 5>0 → active. + const ctx = "convergence-ch"; + mgr.overrideRegisters.set(ctx, { s: 5, c: 0, b: 10 }); + mgr.effectiveState.set(ctx, 5); + mgr.publishableContextIds.add(ctx); + + // Verify it's active before the frontier advance. + const before = mgr.getOverrideLiveness(ctx); + assert.ok(before?.active, "register must be active before frontier advance"); + + // Deliver a frontier advance F=11 (> B=10) → register becomes dead. + // Simulate via a live event that carries only the frontier for this ctx. + // Build a minimal fake event that encodes just the frontier key. + const frontierBlob = JSON.stringify({ + v: 1, + client_id: "peer-device", + contexts: { [ctx]: 11 }, // frontier advance → F=11 > B=10 → inactive + }); + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (command, _args) => { + if (command === "nip44_decrypt_from_self") return frontierBlob; + throw new Error(`Unexpected: ${command}`); + }, + }; + + try { + // Fire the live subscription callback directly. + const fakeEvent = { + id: "cc".repeat(32), + pubkey, + created_at: 9999, + kind: 30078, + tags: [ + ["d", "read-state:cc99aa00112233445566778899aabbcc"], + ["t", "read-state"], + ], + content: "FRONTIER_CIPHER", + sig: "s".repeat(128), + }; + + // Access the private handleIncomingEvent via the test helper path. + await mgr.handleIncomingEvent(fakeEvent); + + // Register must now be inactive. + const after = mgr.getOverrideLiveness(ctx); + assert.ok(after !== null, "liveness must still be available"); + assert.equal( + after.active, + false, + "frontier advance past B must flip register to inactive", + ); + + // debounceTimer must be set (schedulePublish was called for convergence). + assert.ok( + mgr.debounceTimer !== null, + "frontier-only canonical deactivation must schedule a convergence publish", + ); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + mgr.destroy(); + } +}); + +// ── Test 14: createFencedSubscription — EOSE establishes the fence ──────────── +test("createFencedSubscription_eoseEstablishes_notLapsed", async () => { + // Drive the real primitive: EOSE resolves established with lapsed=false. + const subscriptions = new Map(); + let _capturedSubId = null; + let capturedFencedSub = null; + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async (subId) => { + _capturedSubId = subId; + capturedFencedSub = subscriptions.get(subId); + }, + closeSub: async () => {}, + establishmentTimeoutMs: 5_000, // long enough; won't fire in test + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + assert.ok(!handle.lapsed, "fence must not be lapsed before EOSE"); + assert.ok(capturedFencedSub, "fenced sub must be registered"); + + // Simulate EOSE arriving. + capturedFencedSub.resolveEstablished(); + await handle.established; + + assert.equal( + handle.lapsed, + false, + "EOSE must establish fence with lapsed=false", + ); + await handle.unsubscribe(); +}); + +// ── Test 14b: createFencedSubscription — EOSE during sendReq cancels timer ──── +test("createFencedSubscription_eoseDuringSend_timerCancelledAndStaysUnlapsed", async () => { + // Thufir's mandated EOSE-during-send witness: if EOSE is delivered while the + // sendReq() promise is still resolving (e.g. synchronous delivery), the + // establishment timer must be cancelled immediately and the fence must remain + // unlapsed past the original timeout window. + const subscriptions = new Map(); + const timeoutMs = 12; // short so the test can observe no late lapse + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async (subId) => { + // Synchronously fire EOSE before sendReq() returns — races the timer. + const sub = subscriptions.get(subId); + if (sub && sub.mode === "fenced") { + sub.resolveEstablished(); // fires EOSE handler: cancels timer, sets alreadyEstablished + } + }, + closeSub: async () => {}, + establishmentTimeoutMs: timeoutMs, + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + // EOSE already fired during sendReq — established resolves with lapsed=false. + await handle.established; + assert.equal( + handle.lapsed, + false, + "EOSE during sendReq must establish fence with lapsed=false", + ); + + // Wait well past the timeout to confirm the timer does NOT fire and lapse + // an already-established fence. + await new Promise((r) => setTimeout(r, timeoutMs * 3)); + assert.equal( + handle.lapsed, + false, + "timer must not lapse an already-established fence after EOSE-during-send", + ); + await handle.unsubscribe(); +}); + +// ── Test 15: createFencedSubscription — CLOSED lapses before EOSE ──────────── +test("createFencedSubscription_closedBeforeEose_lapses", async () => { + // Drive the real primitive: CLOSED sets lapsed=true and resolves established. + const subscriptions = new Map(); + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async (subId) => { + // Simulate CLOSED immediately after REQ (before EOSE). + const sub = subscriptions.get(subId); + if (sub && sub.mode === "fenced") { + sub.lapsed = true; + sub.resolveEstablished(); + subscriptions.delete(subId); + } + }, + closeSub: async () => {}, + establishmentTimeoutMs: 5_000, + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + await handle.established; + assert.equal(handle.lapsed, true, "CLOSED before EOSE must set lapsed=true"); +}); + +// ── Test 16: createFencedSubscription — resetConnection lapses all fences ───── +test("createFencedSubscription_resetConnection_lapses", async () => { + // Drive the real primitive: simulated resetConnection sets lapsed on all + // pending fenced subscriptions and resolves their established promises. + const subscriptions = new Map(); + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async () => {}, // REQ sent; EOSE never arrives + closeSub: async () => {}, + establishmentTimeoutMs: 5_000, + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + assert.equal(handle.lapsed, false, "fence must not be lapsed before reset"); + + // Simulate resetConnection: mark all fenced subs as lapsed. + for (const [subId, sub] of subscriptions) { + if (sub.mode === "fenced") { + sub.lapsed = true; + sub.resolveEstablished(); + subscriptions.delete(subId); + } + } + + await handle.established; + assert.equal( + handle.lapsed, + true, + "resetConnection must set lapsed=true on pending fence", + ); +}); + +// ── Test 17: createFencedSubscription — establishment timeout lapses fence ──── +test("createFencedSubscription_establishmentTimeout_lapses", async () => { + // A relay that keeps the socket alive but never sends EOSE must lapse the + // fence after the establishment timeout — never hang forever. + const subscriptions = new Map(); + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async () => {}, // REQ sent; EOSE never arrives + closeSub: async () => {}, + establishmentTimeoutMs: 10, // fast for test + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + assert.equal( + handle.lapsed, + false, + "fence must not be lapsed immediately after REQ", + ); + + // Wait for the timeout to fire. + await handle.established; + + assert.equal( + handle.lapsed, + true, + "establishment timeout must lapse the fence", + ); + assert.equal( + subscriptions.size, + 0, + "timed-out fence must be removed from subscriptions map", + ); +}); + +// ── Test 18: createFencedSubscription — timeout does NOT count as establishment +test("createFencedSubscription_timeout_doesNotCountAsEstablishment", async () => { + // Even though established resolves after the timeout, lapsed must be true — + // the timeout NEVER counts as EOSE-based establishment. + const subscriptions = new Map(); + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async () => {}, + closeSub: async () => {}, + establishmentTimeoutMs: 10, + }; + const handle = await createFencedSubscription( + deps, + { kinds: [30078], limit: 500 }, + () => {}, + ); + await handle.established; // timeout fires here + assert.equal( + handle.lapsed, + true, + "establishment timeout must never count as successful establishment", + ); +}); + +// ── Test 19: establishment timeout → no-EOSE relay produces incomplete load ─── +test("fetchAndMerge_establishmentTimeout_setsLoadIncomplete", async () => { + // Thufir's exact witness: a relay that stays alive but never sends EOSE must + // cause the load to conclude complete:false, not hang initialize() forever. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "b0".repeat(32); + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + // subscribeFenced delegates to the real createFencedSubscription with a + // fast establishment timeout so the test runs quickly. + subscribeFenced: async (filter, onEvent) => { + const subscriptions = new Map(); + const deps = { + connectionGeneration: () => 0, + subscriptions, + sendReq: async () => {}, // REQ sent; EOSE never arrives + closeSub: async () => {}, + establishmentTimeoutMs: 20, // fast for test + }; + return createFencedSubscription(deps, filter, onEvent); + }, + }; + const mgr = new ReadStateManager(pubkey, fakeRelay); + const raceMs = 500; // generous but bounded + const result = await Promise.race([ + mgr.fetchAndMerge().then(() => "settled"), + new Promise((r) => setTimeout(() => r("timeout"), raceMs)), + ]); + assert.equal( + result, + "settled", + `fetchAndMerge must settle within ${raceMs}ms when EOSE never arrives (no-EOSE relay must not hang initialize)`, + ); + assert.equal( + mgr.isLoadComplete, + false, + "no-EOSE relay with establishment timeout must produce incomplete load", + ); + mgr.destroy(); +}); + +// ── Test 20: early-write-fails / v2-succeeds → outcome + restart agree ──────── +test("markChannelUnread_earlyWriteFails_v2Succeeds_outcomeAndRestartAgree", () => { + // Thufir's mandated witness: the v2 write is the commit point. If the v2 + // write succeeds, the action succeeds regardless of ancillary write failures. + // A reconstructed manager must see the committed action, and the context must + // be publishable so it reaches the relay (serialized primary not empty). + // + // New write order in writeStoredReadState: v2 (ok4) first, then ancillary + // writes (ok1/ok2/ok3). Writes during manager construction: ClientId (1), + // SlotId (2). First mark write = v2 override write (3) → let it through. + // Ancillary writes follow (4+) → throw to prove they don't affect outcome. + const throwingLS = makeLocalStorage(); + const originalSetItem = throwingLS.setItem.bind(throwingLS); + let writeCount = 0; + let blockAncillary = false; + throwingLS.setItem = (key, value) => { + writeCount++; + if (blockAncillary && writeCount > 3) throw new Error("QuotaExceededError"); + originalSetItem(key, value); + }; + globalThis.window.localStorage = throwingLS; + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + }; + const pubkey = "ea".repeat(32); + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.isLoadComplete = true; + mgr.effectiveState.set("committed-ch", 1000); + + // Enable ancillary-write failure before the mark call. + blockAncillary = true; + const result = mgr.markChannelUnread("committed-ch"); + blockAncillary = false; + + // Write #3 (v2) succeeded → action must succeed. + assert.equal( + result.success, + true, + "markChannelUnread must succeed when v2 write succeeds even if ancillary writes fail", + ); + + // v2 key must contain the committed register. + const v2Key = `buzz.nip-rs.override-state.v2:${pubkey}`; + const stored = throwingLS.getItem(v2Key); + assert.ok(stored !== null, "v2 key must be present after committed action"); + const parsed = JSON.parse(stored); + assert.ok( + "committed-ch" in parsed, + "v2 record must contain the committed channel register for coherent restart", + ); + + // Reconstruct a real manager from the same storage — simulates restart. + // The v2 entry is inherently publishable so hydrateFromLocalStorage must + // add it to publishableContextIds without the ancillary key. + writeCount = 0; // reset counter so reconstruction writes go through + const mgr2 = new ReadStateManager(pubkey, fakeRelay); + mgr2.hydrateFromLocalStorage(); // simulate the init-time hydration step + mgr2.isLoadComplete = true; + + // publishableContextIds must contain the committed context — hydration from + // v2 must not require the ancillary publishable key. + assert.ok( + mgr2.publishableContextIds.has("committed-ch"), + "reconstructed manager must mark the v2-committed context as publishable", + ); + + // currentContexts() must include the register so the committed action is + // serialized and can be published to the relay. + const contexts2 = mgr2.currentContexts(); + assert.ok( + contexts2 !== null, + "reconstructed manager currentContexts must not be null", + ); + const hasEntry = Object.keys(contexts2 ?? {}).some( + (k) => k.startsWith("committed-ch") || k.includes("committed-ch"), + ); + assert.ok( + hasEntry, + "reconstructed manager serialized primary must include the committed register/frontier", + ); + + mgr.destroy(); + mgr2.destroy(); +}); + +// ── Test 21: v2-write-fails → storage_failed, slot IDs unchanged ───────────── +test("markChannelUnread_v2WriteFails_slotIdsUnchanged", () => { + // When the v2 write (commit point) fails, the action must return storage_failed + // AND extra slot IDs must not persist (neither in memory nor in the + // extra-slot-ids key). Uses 700 channel contexts to force split planning + // (splitContextsIntoSlots allocates an extra slot), matching Thufir's exact + // 700-frontier witness. + const throwingLS = makeLocalStorage(); + const originalSetItem = throwingLS.setItem.bind(throwingLS); + let blockV2 = false; + throwingLS.setItem = (key, value) => { + if (blockV2 && key.startsWith("buzz.nip-rs.override-state.v2:")) { + throw new Error("QuotaExceededError"); + } + originalSetItem(key, value); + }; + globalThis.window.localStorage = throwingLS; + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + }; + + const pubkey = "eb".repeat(32); + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.isLoadComplete = true; + + // Populate 700 publishable channel contexts so the split planner is forced + // to allocate an extra slot (exceeds READ_STATE_MAX_PLAINTEXT_BYTES per slot). + for (let i = 0; i < 700; i++) { + const ctx = `channel-id-${String(i).padStart(5, "0")}-${"x".repeat(30)}`; + mgr.effectiveState.set(ctx, 1_700_000_000 + i); + mgr.publishableContextIds.add(ctx); + } + // Also add the target channel for the mark call. + mgr.effectiveState.set("slot-test-ch", 1000); + + const prevExtraSlotIdsMem = mgr.extraSlotIds.slice(); + const extraSlotKey = `buzz.nip-rs.extra-slot-ids:${pubkey}`; + const prevExtraSlotIdsStored = throwingLS.getItem(extraSlotKey); + + // Block v2 writes. + blockV2 = true; + const result = mgr.markChannelUnread("slot-test-ch"); + blockV2 = false; + + assert.equal( + result.success, + false, + "markChannelUnread must fail when v2 write fails", + ); + assert.equal(result.reason, "storage_failed"); + + // Memory slot IDs must be unchanged. + assert.deepEqual( + mgr.extraSlotIds, + prevExtraSlotIdsMem, + "extraSlotIds in memory must be rolled back on v2 write failure", + ); + + // Persisted slot IDs must be storage-identical to before the action — + // including absent-vs-present: if the key was absent before, it must still + // be absent (not `[]`). + const afterExtraSlotIdsStored = throwingLS.getItem(extraSlotKey); + assert.equal( + afterExtraSlotIdsStored, + prevExtraSlotIdsStored, + "buzz.nip-rs.extra-slot-ids must be storage-identical after a failed v2 write (absent=absent, not written as [])", + ); + + // v2 key must not contain the failed register. + const v2Key = `buzz.nip-rs.override-state.v2:${pubkey}`; + const v2Stored = throwingLS.getItem(v2Key); + const v2Parsed = v2Stored ? JSON.parse(v2Stored) : {}; + assert.equal( + "slot-test-ch" in v2Parsed, + false, + "v2 record must not contain the rolled-back channel register after failed action", + ); + + mgr.destroy(); +}); + +// ── Test 22b: stale ancillary frontier suppressed by authoritative v2 frontier ─ +test("hydrateFromLocalStorage_staleAncillaryFrontier_v2FrontierWins", () => { + // Thufir's exact policy-edge witness: when a v2-success/ancillary-failure + // commit leaves the ordinary frontier key stale, hydration must apply + // max(existing, e.f) — not skip the update when the key is present. + // + // Setup: ordinary frontier key = 50 (stale) + // v2 override entry = {s:5, c:0, b:100, f:101} + // isOverrideActive(S=5,C=0,B=100, frontier=50) = 50<=100 → ACTIVE (wrong) + // isOverrideActive(S=5,C=0,B=100, frontier=101) = 101>100 → INACTIVE (correct) + // The test asserts the reconstructed manager uses frontier=101 so the + // register is inactive and serializes as a tombstone (ov_c: only, no ov_s:). + const ls = makeLocalStorage(); + globalThis.window.localStorage = ls; + + const pubkey = "ec".repeat(32); + const ctx = "stale-frontier-ch"; + + // Seed the stale ancillary frontier (epoch-seconds → ISO timestamp). + const staleFrontierKey = `buzz.channel-read-state.v2:${pubkey}`; + ls.setItem( + staleFrontierKey, + JSON.stringify({ [ctx]: new Date(50 * 1_000).toISOString() }), + ); + + // Seed the v2 override key with the authoritative frontier f=101. + const v2Key = `buzz.nip-rs.override-state.v2:${pubkey}`; + ls.setItem(v2Key, JSON.stringify({ [ctx]: { s: 5, c: 0, b: 100, f: 101 } })); + + const fakeRelay = { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.hydrateFromLocalStorage(); + + // v2 is authoritative: effectiveState must hold 101, not the stale 50. + assert.equal( + mgr.effectiveState.get(ctx), + 101, + "hydration must apply max(staleAncillary=50, v2.f=101) → 101", + ); + + // With frontier=101 > B=100, the register is INACTIVE (tombstone). + // currentContexts() must serialize only ov_c: for this channel — no ov_s:. + mgr.isLoadComplete = true; + const contexts = mgr.currentContexts(); + assert.ok(contexts !== null, "currentContexts must not be null"); + const ovSKey = `ov_s:${ctx}`; + const ovCKey = `ov_c:${ctx}`; + assert.equal( + ovSKey in contexts, + false, + "inactive override must NOT serialize ov_s: (would mark channel active/unread)", + ); + assert.ok( + ovCKey in contexts, + "inactive override must serialize ov_c: tombstone floor", + ); + + mgr.destroy(); +}); + +// ── Test 22: fetchOwnBlobBeforePublish processes foreign client_id ──────────── +test("fetchOwnBlobBeforePublish_foreignClientId_rotatesSlotAndUpdatesMetadata", async () => { + // Thufir's mandated witness: fetchOwnBlobBeforePublish must run the same + // parsed-record metadata path — a foreign client_id at our coordinate must + // trigger slot rotation and maxFetchedCreatedAt update via that path. + // This is distinct from the fetchAndMerge path (test 12) — covers + // read-before-write semantics. + globalThis.window.localStorage = makeLocalStorage(); + const pubkey = "b1".repeat(32); + const slotId = "aabbccddeeff00112233445566778899"; + const foreignClientId = "foreign-client-read-before-write"; + const blob = JSON.stringify({ + v: 1, + client_id: foreignClientId, + contexts: {}, + }); + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (command, _args) => { + if (command === "nip44_decrypt_from_self") return blob; + throw new Error(`Unexpected: ${command}`); + }, + }; + + const blobEvent = { + id: "f1".repeat(32), + pubkey, + created_at: 55555, + kind: 30078, + tags: [ + ["d", `read-state:${slotId}`], + ["t", "read-state"], + ], + content: "BLOB_CIPHER", + sig: "s".repeat(128), + }; + + const fakeRelay = { + fetchEvents: async () => [blobEvent], + publishEvent: async () => {}, + subscribeToReconnects: () => () => {}, + getConnectionGeneration: () => 0, + subscribeFenced: async (_f, _h) => makeFenceHandle({ eose: true }), + subscribeLive: async (_f, _h) => () => {}, + }; + + try { + const mgr = new ReadStateManager(pubkey, fakeRelay); + mgr.slotId = slotId; + mgr.isLoadComplete = true; + + // Call fetchOwnBlobBeforePublish directly to test the read-before-write path. + await mgr.fetchOwnBlobBeforePublish(); + + // Slot must have rotated because the blob carried a foreign client_id at + // our coordinate. + assert.notEqual( + mgr.slotId, + slotId, + "fetchOwnBlobBeforePublish must rotate slotId for foreign client_id at our coordinate", + ); + + // maxFetchedCreatedAt must reflect the blob event's created_at. + assert.equal( + mgr.maxFetchedCreatedAt, + 55555, + "fetchOwnBlobBeforePublish must update maxFetchedCreatedAt from the fetched event", + ); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + } +}); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 382a60f20e..1c0dc47c35 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -5,26 +5,60 @@ import { KIND_READ_STATE } from "@/shared/constants/kinds"; import { READ_STATE_D_TAG_PREFIX, READ_STATE_FETCH_LIMIT, - READ_STATE_HORIZON_SECONDS, READ_STATE_MAX_PLAINTEXT_BYTES, READ_STATE_MAX_SLOTS, MSG_PREFIX, THREAD_PREFIX, + isOverrideKey, + encodeOverrideGroup, + isOverrideActive, + escapeFrontierKey, + unescapeFrontierKey, localExtraSlotIdsKey, type ReadStateBlob, + type OverrideRegister, + type OverrideLiveness, } from "@/features/channels/readState/readStateFormat"; -import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot"; +import { + parseReadStateEvent, + mergeOverrideRegisterMaps, + type MergedReadState, + type ParsedReadStateEvent, +} from "@/features/channels/readState/readStateSnapshot"; import { readStoredReadState, writeStoredReadState, } from "@/features/channels/readState/readStateStorage"; +import { + fencedEnumerationLoad, + deduplicateByCoordinate, +} from "@/features/channels/readState/readStateFencedLoader"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; -import { truncatePubkey } from "@/shared/lib/pubkey"; const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; const DEBOUNCE_MS = 5_000; +export type MarkResult = + | { success: true } + | { + success: false; + reason: + | "uint32_overflow" + | "budget_exhausted" + | "load_incomplete" + | "already_inactive" + | "storage_failed"; + }; + +/** Explicit delta returned by `ingest()`. */ +export type IngestDelta = { + /** Contexts whose frontier or override register changed. */ + changedContexts: Set; + /** True if any canonical form changed (triggers convergence publish). */ + canonicalChanged: boolean; +}; + function generateHex(bytes: number): string { const arr = new Uint8Array(bytes); crypto.getRandomValues(arr); @@ -40,14 +74,6 @@ function getOrCreatePersisted(key: string, generator: () => string): string { return value; } -function clientIdKey(pubkey: string): string { - return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; -} - -function slotIdKey(pubkey: string): string { - return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; -} - function loadExtraSlotIds(pubkey: string): string[] { try { const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey)); @@ -73,17 +99,14 @@ export type ApplyRemoteContextResult = "unchanged" | "advanced"; export type ContextParentResolver = (contextId: string) => string | null; -/** - * NIP-RS Hierarchical Frontier Rule (NIP-RS.md:141-167): - * `effective(ctx) = max(merged[ctx], effective(parent(ctx)))`. - * - * The thread→channel relationship is NOT serialized into the blob - * (NIP-RS.md:136-139); it is derived from the event graph at evaluation time - * via `parentResolver`. When the resolver yields no parent (channels, or an - * unresolvable thread root), the frontier degrades to the context's own merged - * value alone (NIP-RS.md:165-167). Returns null when the context has never been - * read and no parent term covers it. - */ +/** Coherent projection (completeness + frontiers + overrides) for UI consumption via `getProjection()`. */ +export type ReadStateProjection = { + loadComplete: boolean; + frontiers: ReadonlyMap; + overrides: ReadonlyMap; +}; + +/** NIP-RS Hierarchical Frontier: `effective(ctx) = max(merged[ctx], effective(parent(ctx)))`. */ export function resolveEffectiveTimestamp(args: { effectiveState: Map; contextId: string; @@ -91,27 +114,14 @@ export function resolveEffectiveTimestamp(args: { }): number | null { const { effectiveState, contextId, parentResolver } = args; const own = effectiveState.get(contextId) ?? null; - const parentId = parentResolver?.(contextId) ?? null; if (parentId === null) return own; - const parent = effectiveState.get(parentId) ?? null; if (parent === null) return own; if (own === null) return parent; return Math.max(own, parent); } -function resolveRemoteContextTimestamp(args: { - current: number; - timestamp: number; -}): { next: number; result: ApplyRemoteContextResult } { - const next = Math.max(args.current, args.timestamp); - return { - next, - result: next === args.current ? "unchanged" : "advanced", - }; -} - export function applyRemoteContextTimestamp(args: { effectiveState: Map; contextSourceCreatedAt: Map; @@ -128,47 +138,22 @@ export function applyRemoteContextTimestamp(args: { } = args; const sourceCreatedAt = contextSourceCreatedAt.get(contextId) ?? 0; const current = effectiveState.get(contextId) ?? 0; - const { next, result } = resolveRemoteContextTimestamp({ - current, - timestamp, - }); - - if (result === "advanced") { - effectiveState.set(contextId, next); - } - if (eventCreatedAt > sourceCreatedAt) { + const next = Math.max(current, timestamp); + const result: ApplyRemoteContextResult = + next === current ? "unchanged" : "advanced"; + if (result === "advanced") effectiveState.set(contextId, next); + if (eventCreatedAt > sourceCreatedAt) contextSourceCreatedAt.set(contextId, eventCreatedAt); - } return result; } -/** - * Result of a `splitContextsIntoBudgetedSlots` call. - */ +/** Result of `splitContextsIntoBudgetedSlots`. */ export interface SlotSplitResult { - /** Contexts record for each slot (primary slot first). */ slots: Array>; - /** - * Extra slot IDs allocated beyond the first. Length is `slots.length - 1`. - * The caller is responsible for persisting these. - */ extraSlotIds: string[]; } -/** - * Partition `channelEntries` across slots so each slot's blob fits within - * `maxBytes`. Thread/msg entries are added to the primary slot (index 0) and - * trimmed to budget. - * - * `initialSlotCount` is the number of slots already available (≥ 1). If the - * initial distribution doesn't fit, new slot IDs are generated via - * `slotIdGenerator` until everything fits or `maxSlots` is reached. - * - * Returns `{ slots, extraSlotIds }` on success, or `null` when even `maxSlots` - * slots can't accommodate all channel keys. - * - * Exported for unit testing; callers should prefer `splitContextsIntoSlots()`. - */ +/** Partition `channelEntries` across slots, override groups pinned to slot 0. Exported for testing. */ export function splitContextsIntoBudgetedSlots(args: { channelEntries: [string, number][]; threadMsgEntries: [string, number][]; @@ -192,19 +177,31 @@ export function splitContextsIntoBudgetedSlots(args: { const blobFor = (c: Record) => JSON.stringify({ v: 1, client_id: clientId, contexts: c }); + const overrideRawIds = new Set(); + for (const [key] of channelEntries) { + if (isOverrideKey(key)) overrideRawIds.add(key.slice(5)); + } + const pinnedEntries: [string, number][] = []; + const roundRobinEntries: [string, number][] = []; + for (const [key, ts] of channelEntries) { + if (isOverrideKey(key) || overrideRawIds.has(unescapeFrontierKey(key))) { + pinnedEntries.push([key, ts]); + } else { + roundRobinEntries.push([key, ts]); + } + } let slotCount = initialSlotCount; const extraSlotIds: string[] = []; - - // Distribute channel keys and check fit. Grow slot count until all fit. const distribute = (count: number): Array> => { const slotContexts: Array> = Array.from( { length: count }, () => ({}), ); - for (let i = 0; i < channelEntries.length; i++) { - const [key, ts] = channelEntries[i]; + for (let i = 0; i < roundRobinEntries.length; i++) { + const [key, ts] = roundRobinEntries[i]; slotContexts[i % count][key] = ts; } + for (const [key, ts] of pinnedEntries) slotContexts[0][key] = ts; return slotContexts; }; @@ -217,42 +214,19 @@ export function splitContextsIntoBudgetedSlots(args: { slotCount++; slotContexts = distribute(slotCount); } - - if (slotContexts.some((c) => encoder.encode(blobFor(c)).length > maxBytes)) { + if (slotContexts.some((c) => encoder.encode(blobFor(c)).length > maxBytes)) return null; - } - - // Add thread/msg entries to the primary slot and trim to budget. - for (const [key, ts] of threadMsgEntries) { - slotContexts[0][key] = ts; - } + for (const [key, ts] of threadMsgEntries) slotContexts[0][key] = ts; trimContextsToBudget(slotContexts[0], clientId, maxBytes); - return { slots: slotContexts, extraSlotIds }; } -/** - * Result of a `trimContextsToBudget` call. - */ export interface TrimResult { - /** Number of entries removed from `contexts`. */ evicted: number; - /** True when the serialized blob fits within `maxBytes` after trimming. */ fitsAfterTrim: boolean; } -/** - * Trim a contexts map to fit within `maxBytes` when serialized as the JSON - * blob `{v:1, client_id, contexts}`. Evicts oldest `msg:` entries first - * (lowest timestamp), then oldest `thread:` entries. Channel keys are never - * evicted. Mutates `contexts` in place. - * - * Returns `{ evicted, fitsAfterTrim }`. `fitsAfterTrim` is false when the - * remaining blob (channel keys only) still exceeds `maxBytes` — the caller - * must not publish in that case. - * - * Exported for unit testing; callers should prefer `currentContexts()`. - */ +/** Trim a contexts map to fit within `maxBytes`. Evicts oldest msg:, then thread:. Channel keys and ov_* entries are never evicted. Mutates in place. Returns `{ evicted, fitsAfterTrim }`. */ export function trimContextsToBudget( contexts: Record, clientId: string, @@ -270,34 +244,19 @@ export function trimContextsToBudget( const msgEntries: [string, number][] = []; const threadEntries: [string, number][] = []; for (const [key, ts] of Object.entries(contexts)) { - if (key.startsWith(MSG_PREFIX)) { - msgEntries.push([key, ts]); - } else if (key.startsWith(THREAD_PREFIX)) { - threadEntries.push([key, ts]); - } + if (isOverrideKey(key)) continue; + if (key.startsWith(MSG_PREFIX)) msgEntries.push([key, ts]); + else if (key.startsWith(THREAD_PREFIX)) threadEntries.push([key, ts]); } - // Oldest-first within each tier. msgEntries.sort((a, b) => a[1] - b[1]); threadEntries.sort((a, b) => a[1] - b[1]); - - // O(n) pass: subtract each entry's byte contribution from currentBytes and - // collect entries to evict. The per-entry estimate is `,"key":timestamp` - // (key.length + 3 bytes for `"`, `"`, `:` plus 1 comma) + timestamp digits. - // This is an approximation — the final encode below is the authoritative check. const toEvict: string[] = []; for (const [key, ts] of [...msgEntries, ...threadEntries]) { if (currentBytes <= maxBytes) break; - // Contribution: `,"key":timestamp` — comma + quoted key + colon + value currentBytes -= key.length + 3 + String(ts).length + 1; toEvict.push(key); } - - for (const key of toEvict) { - delete contexts[key]; - } - - // Final authoritative check — handles JSON comma-accounting edge cases - // (e.g. last-entry comma disappears) that the per-entry estimate ignores. + for (const key of toEvict) delete contexts[key]; const fitsAfterTrim = encoder.encode(blobFor(contexts)).length <= maxBytes; return { evicted: toEvict.length, fitsAfterTrim }; } @@ -320,14 +279,17 @@ export class ReadStateManager { private pendingSyncedAdvances = new Set(); private destroyed = false; private parentResolver: ContextParentResolver | null = null; + private overrideRegisters = new Map(); + private isLoadComplete = false; constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; this.relayClient = relayClient; - this.clientId = getOrCreatePersisted(clientIdKey(pubkey), () => - crypto.randomUUID(), + this.clientId = getOrCreatePersisted( + `${CLIENT_ID_KEY_PREFIX}:${pubkey}`, + () => crypto.randomUUID(), ); - this.slotId = getOrCreatePersisted(slotIdKey(pubkey), () => + this.slotId = getOrCreatePersisted(`${SLOT_ID_KEY_PREFIX}:${pubkey}`, () => generateHex(16), ); this.extraSlotIds = loadExtraSlotIds(pubkey); @@ -335,28 +297,19 @@ export class ReadStateManager { async initialize(): Promise { if (this.initialized || this.destroyed) return; - console.debug( - `[ReadStateManager] initialize pubkey=${truncatePubkey(this.pubkey)} clientId=${this.clientId.substring(0, 8)}… slotId=${this.slotId}`, - ); - this.hydrateFromLocalStorage(); - await this.fetchAndMerge(); if (this.destroyed) return; await this.startLiveSubscription(); if (this.destroyed) return; const initContexts = this.currentContexts(); - if (initContexts === null) { - // Channel keys exceed single-slot budget — schedule a multi-slot publish. - this.schedulePublish(); - } else if (!this.isIdenticalToLastPublished(initContexts)) { + if ( + initContexts === null || + !this.isIdenticalToLastPublished(initContexts) + ) { this.schedulePublish(); } - this.initialized = true; - console.debug( - `[ReadStateManager] initialize complete maxFetchedCreatedAt=${this.maxFetchedCreatedAt} contexts=${this.effectiveState.size}`, - ); this.notifyListeners(); } @@ -379,25 +332,18 @@ export class ReadStateManager { ): void { const current = this.effectiveState.get(contextId) ?? 0; if (unixTimestamp <= current) { - if (!options.publishable || this.publishableContextIds.has(contextId)) { + if (!options.publishable || this.publishableContextIds.has(contextId)) return; - } - this.publishableContextIds.add(contextId); this.persistLocalState(); this.schedulePublish(); return; } - this.effectiveState.set(contextId, unixTimestamp); - if (options.publishable) { - this.publishableContextIds.add(contextId); - } + if (options.publishable) this.publishableContextIds.add(contextId); this.persistLocalState(); this.notifyListeners(); - if (options.publishable) { - this.schedulePublish(); - } + if (options.publishable) this.schedulePublish(); } getEffectiveTimestamp(contextId: string): number | null { @@ -408,23 +354,10 @@ export class ReadStateManager { }); } - /** - * The context's OWN merged read marker, WITHOUT the hierarchical parent term. - * Callers that evaluate a `thread:` context outside the active channel - * (e.g. the sidebar unread scan over background channels) must use this: - * getEffectiveTimestamp folds in parentResolver, which is installed by the - * active ChannelScreen and maps every thread to the *active* channel — using - * it for a background channel's thread would borrow the wrong channel marker. - */ getOwnTimestamp(contextId: string): number | null { return this.effectiveState.get(contextId) ?? null; } - /** - * Inject the thread→channel parent resolver derived from the React event - * graph (NIP-RS.md:136-139). The hierarchical max in getEffectiveTimestamp - * is a no-op until this is set. - */ setContextParentResolver(resolver: ContextParentResolver | null): void { this.parentResolver = resolver; } @@ -438,121 +371,165 @@ export class ReadStateManager { destroy(): void { this.destroyed = true; - // Flush any pending writes immediately if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; void this.publish(); } - if (this.unsubscribeLive) { void this.unsubscribeLive(); this.unsubscribeLive = null; } - this.listeners.clear(); } private async fetchAndMerge(): Promise { - let events: RelayEvent[]; - try { - events = await this.relayClient.fetchEvents({ - kinds: [KIND_READ_STATE], - authors: [this.pubkey], - "#t": ["read-state"], - since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS, - limit: READ_STATE_FETCH_LIMIT, - }); - } catch (error) { - console.debug("[ReadStateManager] fetchAndMerge failed:", error); - // If fetch fails, proceed with local state only - return; - } - - await this.mergeEvents(events); + if (this.destroyed) return; + const result = await fencedEnumerationLoad(this.relayClient, this.pubkey); + if (this.destroyed) return; + this.isLoadComplete = result.complete; + if (!result.complete) + console.warn( + "[ReadStateManager] fetchAndMerge: load incomplete — gated ops blocked", + ); + this.processLoadedParsedEvents(result.events); + const merged = mergeParsedEvents(result.events); + await this.ingest(merged); this.persistLocalState(); this.notifyListeners(); } - private async mergeEvents(events: RelayEvent[]): Promise { - // Collect all own blobs (keyed by slot d-tag) to union them all. - // NIP-RS: multiple own-slot blobs must be max-merged, not winner-takes-all. - const ownBlobsBySlot = new Map< - string, - { blob: ReadStateBlob; createdAt: number } - >(); + /** Retry a failed load: resets isLoadComplete, re-runs the full enumeration. */ + async retryLoad(): Promise { + if (this.destroyed) return; + this.isLoadComplete = false; + await this.fetchAndMerge(); + } - for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); - if (!parsed) continue; + /** Shared ingest: accept pre-merged state and return semantic delta (changed contexts, canonical flip). */ + private async ingest(merged: MergedReadState): Promise { + const delta: IngestDelta = { + changedContexts: new Set(), + canonicalChanged: false, + }; + // Snapshot canonical forms before frontier merge (a frontier advance can flip live→tombstone). + const prevCanonicalByCtx = new Map(); + for (const rawCtx of merged.frontiers.keys()) { + const reg = this.overrideRegisters.get(rawCtx); + if (reg !== undefined) + prevCanonicalByCtx.set( + rawCtx, + canonicalKey(reg, this.channelFrontier(rawCtx)), + ); + } + + for (const [rawCtx, ts] of merged.frontiers) { + const result = applyRemoteContextTimestamp({ + effectiveState: this.effectiveState, + contextSourceCreatedAt: this.contextSourceCreatedAt, + contextId: rawCtx, + eventCreatedAt: 0, + timestamp: ts, + }); + if (result !== "unchanged") { + delta.changedContexts.add(rawCtx); + this.pendingSyncedAdvances.add(rawCtx); + this.publishableContextIds.add(rawCtx); + } + } + + for (const [rawCtx, prevCanon] of prevCanonicalByCtx) { + const reg = this.overrideRegisters.get(rawCtx); + if (reg !== undefined) { + const newCanon = canonicalKey(reg, this.channelFrontier(rawCtx)); + if (newCanon !== prevCanon) delta.canonicalChanged = true; + } + } + + for (const [rawCtx, reg] of merged.overrides) { + const ex = this.overrideRegisters.get(rawCtx); + const m: OverrideRegister = ex + ? { + s: Math.max(ex.s, reg.s), + c: Math.max(ex.c, reg.c), + b: Math.max(ex.b, reg.b), + } + : reg; + const changed = !ex || m.s !== ex.s || m.c !== ex.c || m.b !== ex.b; + if (changed) { + this.overrideRegisters.set(rawCtx, m); + delta.changedContexts.add(rawCtx); + this.publishableContextIds.add(rawCtx); + const prevCanon = ex + ? canonicalKey(ex, this.channelFrontier(rawCtx)) + : null; + if (prevCanon !== canonicalKey(m, this.channelFrontier(rawCtx))) + delta.canonicalChanged = true; + } + } + + return delta; + } + + /** Process already-parsed events for metadata, conflict rotation, and maxFetchedCreatedAt. */ + private processLoadedParsedEvents(events: ParsedReadStateEvent[]): void { + for (const parsed of events) { this.maxFetchedCreatedAt = Math.max( this.maxFetchedCreatedAt, parsed.createdAt, ); - - for (const [ctx, ts] of Object.entries(parsed.blob.contexts)) { - const result = applyRemoteContextTimestamp({ - effectiveState: this.effectiveState, - contextSourceCreatedAt: this.contextSourceCreatedAt, - contextId: ctx, - timestamp: ts, - eventCreatedAt: parsed.createdAt, - }); - if (result !== "unchanged") { - this.pendingSyncedAdvances.add(ctx); - this.publishableContextIds.add(ctx); - } + for (const rawCtx of parsed.contexts.frontiers.keys()) { + const src = this.contextSourceCreatedAt.get(rawCtx) ?? 0; + if (parsed.createdAt > src) + this.contextSourceCreatedAt.set(rawCtx, parsed.createdAt); } - - if (parsed.blob.client_id === this.clientId) { - const existing = ownBlobsBySlot.get(parsed.dTag); - if (!existing || parsed.createdAt > existing.createdAt) { - ownBlobsBySlot.set(parsed.dTag, { - blob: parsed.blob, - createdAt: parsed.createdAt, - }); - } - } - } - - // Conflict detection: check if another client_id is squatting on our - // d-tag coordinate. If so, rotate our slotId to avoid clobbering. - for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); - if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue; - if (parsed.blob.client_id !== this.clientId) { + // Slot-conflict rotation (spec MUST NOT, NIP-RS.md:55-64,402-406). + if ( + parsed.dTag === `read-state:${this.slotId}` && + parsed.blob.client_id !== this.clientId + ) { this.slotId = generateHex(16); - setLocalStorageItemWithRecovery(slotIdKey(this.pubkey), this.slotId); - break; + setLocalStorageItemWithRecovery( + `${SLOT_ID_KEY_PREFIX}:${this.pubkey}`, + this.slotId, + ); } - } - - // Union all own-slot blobs into lastPublishedContexts (max-merge). - if (ownBlobsBySlot.size > 0) { - const unionContexts: Record = {}; - for (const { blob } of ownBlobsBySlot.values()) { - for (const [key, ts] of Object.entries(blob.contexts)) { - const existing = unionContexts[key]; - if (existing === undefined || ts > existing) { - unionContexts[key] = ts; - } + if (parsed.blob.client_id === this.clientId) { + // Own-blob: union lastPublishedContexts and mark publishable. + const unionContexts: Record = { + ...this.lastPublishedContexts, + }; + for (const [key, ts] of Object.entries(parsed.blob.contexts)) { + const ex = unionContexts[key]; + if (ex === undefined || ts > ex) unionContexts[key] = ts; } - for (const contextId of Object.keys(blob.contexts)) { + for (const contextId of Object.keys(parsed.blob.contexts)) this.publishableContextIds.add(contextId); - } + this.lastPublishedContexts = unionContexts; } - this.lastPublishedContexts = unionContexts; } } + /** Ingest parsed events: process metadata then merge into frontier/override maps. */ + private async ingestParsedEvents( + events: ParsedReadStateEvent[], + ): Promise { + this.processLoadedParsedEvents(events); + const merged = mergeParsedEvents(events); + return this.ingest(merged); + } + private async startLiveSubscription(): Promise { + const unsubReconnect = this.relayClient.subscribeToReconnects(() => { + if (this.destroyed) return; + void this.retryLoad(); + }); try { const unsub = await this.relayClient.subscribeLive( { kinds: [KIND_READ_STATE], authors: [this.pubkey], - "#t": ["read-state"], limit: READ_STATE_FETCH_LIMIT, }, (event: RelayEvent) => { @@ -561,70 +538,35 @@ export class ReadStateManager { ); if (this.destroyed) { unsub(); + unsubReconnect(); return; } - this.unsubscribeLive = unsub; - console.debug("[ReadStateManager] live subscription established"); - } catch (error) { - console.debug("[ReadStateManager] live subscription FAILED:", error); - // Non-fatal: we can still work with local state + this.unsubscribeLive = () => { + unsub(); + unsubReconnect(); + }; + } catch { + unsubReconnect(); + // Live subscription is best-effort; missed events will be caught on reconnect. } } private async handleIncomingEvent(event: RelayEvent): Promise { - if (event.pubkey !== this.pubkey) return; - if (this.destroyed) return; - console.debug( - `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`, - ); - + if (event.pubkey !== this.pubkey || this.destroyed) return; const parsed = await parseReadStateEvent(event, this.pubkey); if (!parsed) return; - - this.maxFetchedCreatedAt = Math.max( - this.maxFetchedCreatedAt, - parsed.createdAt, - ); - - const { blob } = parsed; - let anyAdvanced = false; - for (const [ctx, ts] of Object.entries(blob.contexts)) { - const result = applyRemoteContextTimestamp({ - effectiveState: this.effectiveState, - contextSourceCreatedAt: this.contextSourceCreatedAt, - contextId: ctx, - timestamp: ts, - eventCreatedAt: parsed.createdAt, - }); - if (result === "advanced") { - this.pendingSyncedAdvances.add(ctx); - anyAdvanced = true; - } - if (!this.publishableContextIds.has(ctx)) { - this.publishableContextIds.add(ctx); - anyAdvanced = true; - } - } - console.debug( - `[ReadStateManager] incoming result anyAdvanced=${anyAdvanced} clientId=${blob.client_id.substring(0, 8)}…`, - ); - - if (anyAdvanced) { + const delta = await this.ingestParsedEvents([parsed]); + if (delta.changedContexts.size > 0 || this.pendingSyncedAdvances.size > 0) { this.persistLocalState(); this.notifyListeners(); - - // If this was from another client instance, schedule a re-publish - // so our blob converges - if (blob.client_id !== this.clientId) { + if (delta.canonicalChanged && parsed.blob.client_id !== this.clientId) { this.schedulePublish(); } } } private schedulePublish(): void { - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } + if (this.debounceTimer !== null) window.clearTimeout(this.debounceTimer); this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; void this.publish(); @@ -632,59 +574,33 @@ export class ReadStateManager { } private async publish(): Promise { - console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`); - await this.fetchOwnBlobBeforePublish(); - - // Build blob from contexts this client is allowed to publish. + if (!this.isLoadComplete) return; + if (!(await this.fetchOwnBlobBeforePublish())) { + console.warn( + "[ReadStateManager] publish aborted: read-before-write failed", + ); + return; + } const contexts = this.currentContexts(); - if (contexts === null) { - // Channel keys alone exceed the single-slot budget — split across slots. await this.publishSplitSlots(); return; } - - // Transitioning from split to single mode: delete stale extra-slot blobs - // from the relay so fetchOwnBlobBeforePublish stops re-inflating - // lastPublishedContexts from them. Reset lastPublishedContexts here (inside - // the guard) so stale keys from the previous split don't cause - // isIdenticalToLastPublished to return false forever. The reset must stay - // inside the guard — resetting unconditionally would clear the relay-fetched - // state on every debounce cycle and reintroduce the retry storm. if (this.extraSlotIds.length > 0) { await this.deleteExtraSlots(); this.lastPublishedContexts = {}; } - if (this.isIdenticalToLastPublished(contexts)) return; - await this.publishOneSlot(this.slotId, contexts); } - /** - * Publish a single slot's blob. Updates lastPublishedContexts and - * maxFetchedCreatedAt on success. - */ private async publishOneSlot( slotId: string, contexts: Record, ): Promise { - const blob: ReadStateBlob = { - v: 1, - client_id: this.clientId, - contexts, - }; - + const blob: ReadStateBlob = { v: 1, client_id: this.clientId, contexts }; try { - const plaintext = JSON.stringify(blob); - const ciphertext = await nip44EncryptToSelf(plaintext); - - const dTagValue = `read-state:${slotId}`; - const tags: string[][] = [ - ["d", dTagValue], - ["t", "read-state"], - ]; - + const ciphertext = await nip44EncryptToSelf(JSON.stringify(blob)); const createdAt = Math.max( Math.floor(Date.now() / 1_000), this.maxFetchedCreatedAt + 1, @@ -693,49 +609,34 @@ export class ReadStateManager { kind: KIND_READ_STATE, content: ciphertext, createdAt, - tags, + tags: [ + ["d", `read-state:${slotId}`], + ["t", "read-state"], + ], }); - await this.relayClient.publishEvent( event, "Timed out publishing read state.", "Failed to publish read state.", ); - console.debug( - `[ReadStateManager] publish accepted slotId=${slotId} createdAt=${createdAt}`, - ); - for (const key of Object.keys(contexts)) { - if (this.lastPublishedContexts[key] !== contexts[key]) { + if (this.lastPublishedContexts[key] !== contexts[key]) this.contextSourceCreatedAt.set(key, createdAt); - } } - // Merge this slot's contexts into lastPublishedContexts (union). - for (const [key, ts] of Object.entries(contexts)) { + for (const [key, ts] of Object.entries(contexts)) this.lastPublishedContexts[key] = ts; - } this.maxFetchedCreatedAt = Math.max( this.maxFetchedCreatedAt, event.created_at, ); } catch (error) { - // Non-fatal: will retry on next debounce console.warn("[ReadStateManager] publish failed:", error); } } - /** - * Multi-slot publish path. Invoked when channel keys alone exceed the - * single-slot byte budget. Partitions channel keys across slots and - * publishes each independently. - */ private async publishSplitSlots(): Promise { const slots = this.splitContextsIntoSlots(); - if (slots === null) return; // Truly degenerate — already logged. - - // No-op suppression: compute the union of all slot contexts and skip if - // nothing changed since the last publish. Without this, every debounce - // cycle in split mode would re-publish all slots unconditionally. + if (slots === null) return; const unionContexts: Record = {}; for (const { contexts } of slots) { for (const [key, ts] of Object.entries(contexts)) { @@ -744,23 +645,13 @@ export class ReadStateManager { } } if (this.isIdenticalToLastPublished(unionContexts)) return; - - // Reset lastPublishedContexts before the multi-slot publish so we can - // rebuild it as the union of all slots. this.lastPublishedContexts = {}; - - for (const { slotId, contexts } of slots) { + for (const { slotId, contexts } of slots) await this.publishOneSlot(slotId, contexts); - } } - /** - * Publish NIP-09 kind:5 delete events for all extra slot blobs, then clear - * extraSlotIds. Called when transitioning from split mode back to single-slot - * mode to prevent stale extra-slot blobs from re-inflating lastPublishedContexts - * via fetchOwnBlobBeforePublish on every subsequent publish cycle. - */ private async deleteExtraSlots(): Promise { + if (!this.isLoadComplete) return; for (const slotId of this.extraSlotIds) { try { const aTagValue = `${KIND_READ_STATE}:${this.pubkey}:${READ_STATE_D_TAG_PREFIX}${slotId}`; @@ -774,23 +665,18 @@ export class ReadStateManager { "Timed out deleting extra read-state slot.", "Failed to delete extra read-state slot.", ); - console.debug(`[ReadStateManager] deleted extra slot slotId=${slotId}`); - } catch (error) { - console.debug( - `[ReadStateManager] deleteExtraSlots failed for slotId=${slotId}:`, - error, - ); - // Non-fatal: stale blob will expire from relay within the horizon window. + } catch { + /* Non-fatal */ } } this.extraSlotIds = []; saveExtraSlotIds(this.pubkey, []); } - private async fetchOwnBlobBeforePublish(): Promise { - // Fetch all own slots — primary + any extra slots allocated for splitting. - const allSlotIds = [this.slotId, ...this.extraSlotIds]; - const dTags = allSlotIds.map((id) => `${READ_STATE_D_TAG_PREFIX}${id}`); + private async fetchOwnBlobBeforePublish(): Promise { + const dTags = [this.slotId, ...this.extraSlotIds].map( + (id) => `${READ_STATE_D_TAG_PREFIX}${id}`, + ); try { const events = await this.relayClient.fetchEvents({ kinds: [KIND_READ_STATE], @@ -798,78 +684,81 @@ export class ReadStateManager { "#d": dTags, limit: READ_STATE_FETCH_LIMIT, }); - - await this.mergeEvents(events); + const deduped = deduplicateByCoordinate(events); + // Parse once: use same path as full-state load (Contract 2 — metadata/conflict). + const parsed: ParsedReadStateEvent[] = []; + for (const ev of deduped) { + const p = await parseReadStateEvent(ev, this.pubkey); + if (p) parsed.push(p); + } + this.processLoadedParsedEvents(parsed); + const merged = mergeParsedEvents(parsed); + await this.ingest(merged); this.persistLocalState(); - } catch (error) { - console.debug( - "[ReadStateManager] fetchOwnBlobBeforePublish failed:", - error, - ); - // Per NIP-RS, proceed with reachable data and merge on a later fetch. + return true; + } catch { + return false; } } private isIdenticalToLastPublished( contexts: Record, ): boolean { - const lastKeys = Object.keys(this.lastPublishedContexts); const currentKeys = Object.keys(contexts); - if (lastKeys.length !== currentKeys.length) return false; + if (Object.keys(this.lastPublishedContexts).length !== currentKeys.length) + return false; for (const key of currentKeys) { if (this.lastPublishedContexts[key] !== contexts[key]) return false; } return true; } + /** Effective frontier including parent resolver. */ + private channelFrontier(channelId: string): number { + return ( + resolveEffectiveTimestamp({ + effectiveState: this.effectiveState, + contextId: channelId, + parentResolver: this.parentResolver, + }) ?? 0 + ); + } + + /** + * Build the single-slot contexts record. Uses `channelFrontier()` for + * canonical serialization — same resolver as `getOverrideLiveness()`. + * Returns null when the record doesn't fit in one slot. + */ private currentContexts(): Record | null { const contexts: Record = {}; for (const [ctx, ts] of this.effectiveState) { - if (!this.publishableContextIds.has(ctx)) { - continue; - } - contexts[ctx] = ts; + if (this.publishableContextIds.has(ctx)) + contexts[escapeFrontierKey(ctx)] = ts; + } + for (const [rawCtx, reg] of this.overrideRegisters) { + if (!this.publishableContextIds.has(rawCtx)) continue; + // Use channelFrontier — same resolver as getOverrideLiveness. + const frontier = this.channelFrontier(rawCtx); + const wireEntries = encodeOverrideGroup(rawCtx, reg, frontier); + for (const [key, val] of Object.entries(wireEntries)) contexts[key] = val; } - - // Byte-budget trim (reactive backstop). - // Evict oldest msg: then thread: entries until the blob fits 32 KB. - // Channel keys are never evicted here. const { evicted, fitsAfterTrim } = trimContextsToBudget( contexts, this.clientId, READ_STATE_MAX_PLAINTEXT_BYTES, ); - if (evicted > 0) { + if (evicted > 0) console.warn( - `[ReadStateManager] currentContexts trimmed ${evicted} entries to fit byte budget`, + `[ReadStateManager] currentContexts trimmed ${evicted} entries`, ); - } - if (!fitsAfterTrim) { - // Channel keys alone exceed budget — caller must use multi-slot split. - console.warn( - "[ReadStateManager] currentContexts: channel keys exceed byte budget — will split across slots", - ); - return null; - } - + if (!fitsAfterTrim) return null; return contexts; } - /** - * Partition the full publishable contexts across multiple slots when channel - * keys alone exceed READ_STATE_MAX_PLAINTEXT_BYTES. Returns one contexts - * record per slot (primary slot first, extra slots following). Returns null - * when even READ_STATE_MAX_SLOTS slots can't accommodate all channel keys. - * - * Channel keys are distributed round-robin across all slots. Thread: and - * msg: entries are added to the primary slot and trimmed by the - * byte-budget guard there. - */ private splitContextsIntoSlots(): Array<{ slotId: string; contexts: Record; }> | null { - // Separate channel keys from thread/msg entries. const channelEntries: [string, number][] = []; const threadMsgEntries: [string, number][] = []; for (const [ctx, ts] of this.effectiveState) { @@ -877,10 +766,17 @@ export class ReadStateManager { if (ctx.startsWith(MSG_PREFIX) || ctx.startsWith(THREAD_PREFIX)) { threadMsgEntries.push([ctx, ts]); } else { - channelEntries.push([ctx, ts]); + channelEntries.push([escapeFrontierKey(ctx), ts]); } } - + for (const [rawCtx, reg] of this.overrideRegisters) { + if (!this.publishableContextIds.has(rawCtx)) continue; + const frontier = this.channelFrontier(rawCtx); + for (const [key, val] of Object.entries( + encodeOverrideGroup(rawCtx, reg, frontier), + )) + channelEntries.push([key, val]); + } const allSlotIds = [this.slotId, ...this.extraSlotIds]; const result = splitContextsIntoBudgetedSlots({ channelEntries, @@ -891,24 +787,12 @@ export class ReadStateManager { maxBytes: READ_STATE_MAX_PLAINTEXT_BYTES, slotIdGenerator: () => generateHex(16), }); - - if (result === null) { - console.error( - `[ReadStateManager] splitContextsIntoSlots: ${channelEntries.length} channel keys exceed ${READ_STATE_MAX_SLOTS}-slot budget — suppressing publish`, - ); - return null; - } - - // Persist any newly allocated extra slot IDs. Length comparison is - // sufficient: splitContextsIntoBudgetedSlots only appends new IDs (via - // slotIdGenerator) and never replaces existing ones — initialSlotCount - // ensures the existing slots are reused in place. + if (result === null) return null; const newExtraSlotIds = [...allSlotIds.slice(1), ...result.extraSlotIds]; if (newExtraSlotIds.length !== this.extraSlotIds.length) { this.extraSlotIds = newExtraSlotIds; saveExtraSlotIds(this.pubkey, this.extraSlotIds); } - const finalSlotIds = [...allSlotIds, ...result.extraSlotIds]; return finalSlotIds.map((slotId, i) => ({ slotId, @@ -916,26 +800,162 @@ export class ReadStateManager { })); } + getOverrideLiveness(channelId: string): OverrideLiveness | null { + const reg = this.overrideRegisters.get(channelId); + if (!reg) return null; + const effectiveFrontier = this.channelFrontier(channelId); + return { + active: isOverrideActive(reg, effectiveFrontier), + frontier: effectiveFrontier, + }; + } + + /** Coherent snapshot of completeness + frontiers + overrides. Maps are live read-only views — re-read on manager notifications; clone for snapshot semantics. */ + getProjection(): ReadStateProjection { + return { + loadComplete: this.isLoadComplete, + frontiers: this.effectiveState, + overrides: this.overrideRegisters, + }; + } + + /** Candidate planner: trials candidate against single- and multi-slot paths. May mutate extraSlotIds as a side effect — caller must snapshot and restore on failure. */ + private tryCandidatePlan(rawCtxId: string, reg: OverrideRegister): boolean { + const prev = this.overrideRegisters.get(rawCtxId); + const wasPublishable = this.publishableContextIds.has(rawCtxId); + this.overrideRegisters.set(rawCtxId, reg); + this.publishableContextIds.add(rawCtxId); + const single = this.currentContexts(); + const fits = single !== null || this.splitContextsIntoSlots() !== null; + if (prev === undefined) { + this.overrideRegisters.delete(rawCtxId); + } else { + this.overrideRegisters.set(rawCtxId, prev); + } + if (!wasPublishable) this.publishableContextIds.delete(rawCtxId); + return fits; + } + + /** Restore extra slot IDs (action rollback). If prior was empty, removes the key (absent → absent). */ + private restoreExtraSlotIds(prev: string[]): void { + const changed = + prev.length !== this.extraSlotIds.length || + prev.some((id, i) => id !== this.extraSlotIds[i]); + this.extraSlotIds = prev; + if (changed) + prev.length === 0 + ? localStorage.removeItem(localExtraSlotIdsKey(this.pubkey)) + : saveExtraSlotIds(this.pubkey, this.extraSlotIds); + } + + markChannelUnread(channelId: string): MarkResult { + if (!this.isLoadComplete) + return { success: false, reason: "load_incomplete" }; + const existing = this.overrideRegisters.get(channelId); + const s = existing?.s ?? 0; + const c = existing?.c ?? 0; + const b = existing?.b ?? 0; + const newS = Math.max(s, c) + 1; + if (newS > 0xffffffff) return { success: false, reason: "uint32_overflow" }; + const newReg: OverrideRegister = { + s: newS, + c, + b: Math.max(b, this.channelFrontier(channelId)), + }; + // Snapshot extraSlotIds before the probe (probe mutates it via splitContextsIntoSlots). + const prevExtraSlotIds = this.extraSlotIds.slice(); + if (!this.tryCandidatePlan(channelId, newReg)) { + this.restoreExtraSlotIds(prevExtraSlotIds); + return { success: false, reason: "budget_exhausted" }; + } + const prevReg = this.overrideRegisters.get(channelId); + const wasPublishable = this.publishableContextIds.has(channelId); + this.overrideRegisters.set(channelId, newReg); + this.publishableContextIds.add(channelId); + if (!this.persistLocalState()) { + if (prevReg === undefined) this.overrideRegisters.delete(channelId); + else this.overrideRegisters.set(channelId, prevReg); + if (!wasPublishable) this.publishableContextIds.delete(channelId); + this.restoreExtraSlotIds(prevExtraSlotIds); + return { success: false, reason: "storage_failed" }; + } + this.notifyListeners(); + this.schedulePublish(); + return { success: true }; + } + + markChannelRead(channelId: string): MarkResult { + if (!this.isLoadComplete) + return { success: false, reason: "load_incomplete" }; + const reg = this.overrideRegisters.get(channelId); + const effectiveFrontier = this.channelFrontier(channelId); + if (!reg) return { success: false, reason: "already_inactive" }; + // Always attempt C-bump (spec NIP-RS.md:537-539). + const newC = Math.max(reg.s, reg.c) + 1; + if (newC > 0xffffffff) return { success: false, reason: "uint32_overflow" }; + const newReg: OverrideRegister = { s: reg.s, c: newC, b: reg.b }; + if (isOverrideActive(newReg, effectiveFrontier)) { + console.error( + "[ReadStateManager] markChannelRead: override still active after bump", + ); + return { success: false, reason: "already_inactive" }; + } + const wasPublishable = this.publishableContextIds.has(channelId); + this.overrideRegisters.set(channelId, newReg); + this.publishableContextIds.add(channelId); + if (!this.persistLocalState()) { + // v2 write failed — roll back. + this.overrideRegisters.set(channelId, reg); + if (!wasPublishable) this.publishableContextIds.delete(channelId); + return { success: false, reason: "storage_failed" }; + } + this.notifyListeners(); + this.schedulePublish(); + return { success: true }; + } + private hydrateFromLocalStorage(): void { const stored = readStoredReadState(this.pubkey); - for (const [contextId, timestamp] of stored.contexts) { + for (const [contextId, timestamp] of stored.contexts) this.effectiveState.set(contextId, timestamp); - } - for (const contextId of stored.publishableContextIds) { + for (const contextId of stored.publishableContextIds) this.publishableContextIds.add(contextId); - } - for (const [contextId, createdAt] of stored.contextSourceCreatedAt) { + for (const [contextId, createdAt] of stored.contextSourceCreatedAt) this.contextSourceCreatedAt.set(contextId, createdAt); + for (const [ctx, e] of stored.overrideRegisters) { + const ex = this.overrideRegisters.get(ctx); + const merged = ex + ? { + s: Math.max(ex.s, e.s), + c: Math.max(ex.c, e.c), + b: Math.max(ex.b, e.b), + } + : { s: e.s, c: e.c, b: e.b }; + this.overrideRegisters.set(ctx, merged); + if (e.f > 0) + this.effectiveState.set( + ctx, + Math.max(this.effectiveState.get(ctx) ?? 0, e.f), + ); + this.publishableContextIds.add(ctx); // v2 entry is inherently publishable } this.persistLocalState(); } - private persistLocalState(): void { - writeStoredReadState( + /** Persist local state. Returns false only if the v2 override write failed (commit point). */ + private persistLocalState(): boolean { + const entries = new Map( + [...this.overrideRegisters].map(([ctx, r]) => [ + ctx, + { s: r.s, c: r.c, b: r.b, f: this.channelFrontier(ctx) }, + ]), + ); + return writeStoredReadState( this.pubkey, this.effectiveState, this.publishableContextIds, this.contextSourceCreatedAt, + entries, ); } @@ -951,8 +971,29 @@ export class ReadStateManager { listener(); } catch (error) { console.debug("[ReadStateManager] listener threw:", error); - // Don't let a broken listener break the manager } } } } + +/** Canonical key for an override register: encodes liveness + component values. */ +function canonicalKey(reg: OverrideRegister, frontier: number): string { + const active = isOverrideActive(reg, frontier); + if (active) return `live:${reg.s},${reg.c},${reg.b}`; + const floor = Math.max(reg.s, reg.c); + return floor > 0 ? `dead:${floor}` : "virgin"; +} + +/** Build MergedReadState from pre-parsed events — no second decrypt. */ +function mergeParsedEvents(events: ParsedReadStateEvent[]): MergedReadState { + const frontiers = new Map(); + const overrideMaps: Array> = []; + for (const p of events) { + for (const [rawCtx, ts] of p.contexts.frontiers) { + const current = frontiers.get(rawCtx) ?? 0; + if (ts > current) frontiers.set(rawCtx, ts); + } + overrideMaps.push(p.contexts.overrides); + } + return { frontiers, overrides: mergeOverrideRegisterMaps(...overrideMaps) }; +} diff --git a/desktop/src/features/channels/readState/readStateStorage.test.mjs b/desktop/src/features/channels/readState/readStateStorage.test.mjs index ad7eb031eb..49d6107ee6 100644 --- a/desktop/src/features/channels/readState/readStateStorage.test.mjs +++ b/desktop/src/features/channels/readState/readStateStorage.test.mjs @@ -97,6 +97,7 @@ test("writeStoredReadState prunes all three keys consistently", () => { [staleThread, stale], [freshThread, nowSeconds], ]), + new Map(), ); const state = JSON.parse( @@ -128,15 +129,21 @@ test("writeStoredReadState round-trips through readStoredReadState", () => { new Map([["channel-9", nowSeconds]]), new Set(["channel-9"]), new Map([["channel-9", nowSeconds]]), + new Map([["channel-9", { s: 3, c: 1, b: nowSeconds, f: nowSeconds }]]), ); const stored = readStoredReadState(pubkey); assert.equal(stored.contexts.get("channel-9"), nowSeconds); assert.equal(stored.publishableContextIds.has("channel-9"), true); assert.equal(stored.contextSourceCreatedAt.get("channel-9"), nowSeconds); + const reg = stored.overrideRegisters.get("channel-9"); + assert.ok(reg, "overrideRegisters must round-trip"); + assert.equal(reg.s, 3, "register S must round-trip"); + assert.equal(reg.c, 1, "register C must round-trip"); + assert.equal(reg.b, nowSeconds, "register B must round-trip"); }); -test("writeStoredReadState survives a throwing localStorage.setItem", () => { +test("writeStoredReadState_survives_throwing_localStorage_and_returns_false", () => { const ls = installLocalStorage(); ls.setItem = () => { throw new Error("QuotaExceededError"); @@ -144,12 +151,19 @@ test("writeStoredReadState survives a throwing localStorage.setItem", () => { const pubkey = "d".repeat(64); const nowSeconds = Math.floor(Date.now() / 1_000); + let result; assert.doesNotThrow(() => { - writeStoredReadState( + result = writeStoredReadState( pubkey, new Map([["channel-1", nowSeconds]]), new Set(["channel-1"]), new Map([["channel-1", nowSeconds]]), + new Map(), ); }); + assert.equal( + result, + false, + "writeStoredReadState must return false on quota failure", + ); }); diff --git a/desktop/src/features/channels/readState/readStateStorage.ts b/desktop/src/features/channels/readState/readStateStorage.ts index f5ac899613..83ad74bbd3 100644 --- a/desktop/src/features/channels/readState/readStateStorage.ts +++ b/desktop/src/features/channels/readState/readStateStorage.ts @@ -8,6 +8,7 @@ import { MSG_PREFIX, READ_STATE_HORIZON_SECONDS, THREAD_PREFIX, + type OverrideRegister, } from "@/features/channels/readState/readStateFormat"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; @@ -15,6 +16,7 @@ export type StoredReadState = { contexts: Map; publishableContextIds: Set; contextSourceCreatedAt: Map; + overrideRegisters: Map; }; function mergeLocalStorageKey( @@ -100,6 +102,7 @@ export function readStoredReadState(pubkey: string): StoredReadState { contexts, publishableContextIds: readPublishableContextIds(pubkey), contextSourceCreatedAt: readContextSourceCreatedAt(pubkey), + overrideRegisters: readOverrideState(pubkey), }; } @@ -109,6 +112,87 @@ function isPrunableContextKey(contextId: string): boolean { ); } +// Key for persisted override state: registers + their frontier timestamps in one atomic write. +// v2 stores {s, c, b, f} per context where f = the last known effective frontier. +function localOverrideStateKey(pubkey: string): string { + return `buzz.nip-rs.override-state.v2:${pubkey}`; +} + +// Legacy key retained for migration reads only — never written to after v2 migration. +function localOverrideRegistersKeyLegacy(pubkey: string): string { + return `buzz.nip-rs.override-registers.v1:${pubkey}`; +} + +export type StoredOverrideEntry = OverrideRegister & { f: number }; + +function readOverrideState(pubkey: string): Map { + const result = new Map(); + const raw = localStorage.getItem(localOverrideStateKey(pubkey)); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (isPlainRecord(parsed)) { + for (const [rawCtx, value] of Object.entries(parsed)) { + if (!isPlainRecord(value)) continue; + const { s, c, b, f } = value; + if ( + typeof s === "number" && + Number.isInteger(s) && + s >= 0 && + s <= 0xffffffff && + typeof c === "number" && + Number.isInteger(c) && + c >= 0 && + c <= 0xffffffff && + typeof b === "number" && + Number.isInteger(b) && + b >= 0 && + typeof f === "number" && + Number.isInteger(f) && + f >= 0 + ) { + result.set(rawCtx, { s, c, b, f }); + } + } + } + } catch { + // Corrupt storage — return empty; repopulated on next ingest. + } + return result; + } + // Migration: read from legacy v1 key if v2 key absent. + const legacyRaw = localStorage.getItem( + localOverrideRegistersKeyLegacy(pubkey), + ); + if (!legacyRaw) return result; + try { + const parsed = JSON.parse(legacyRaw); + if (!isPlainRecord(parsed)) return result; + for (const [rawCtx, value] of Object.entries(parsed)) { + if (!isPlainRecord(value)) continue; + const { s, c, b } = value; + if ( + typeof s === "number" && + Number.isInteger(s) && + s >= 0 && + s <= 0xffffffff && + typeof c === "number" && + Number.isInteger(c) && + c >= 0 && + c <= 0xffffffff && + typeof b === "number" && + Number.isInteger(b) && + b >= 0 + ) { + result.set(rawCtx, { s, c, b, f: 0 }); // frontier unknown from legacy key + } + } + } catch { + // Corrupt legacy storage — ignore. + } + return result; +} + /** * Drops msg:/thread: markers older than the relay's 7-day horizon, then caps * the survivors at LOCAL_MAX_PRUNABLE_CONTEXTS (oldest first). Channel keys @@ -147,7 +231,8 @@ export function writeStoredReadState( contexts: ReadonlyMap, publishableContextIds: ReadonlySet, contextSourceCreatedAt: ReadonlyMap, -): void { + overrideRegisters: ReadonlyMap, +): boolean { const pruned = pruneStaleContexts(contexts, Math.floor(Date.now() / 1_000)); const state: Record = {}; @@ -155,6 +240,31 @@ export function writeStoredReadState( state[contextId] = new Date(timestamp * 1_000).toISOString(); } + // Persist override registers atomically with their frontier timestamps (v2). + // Registers and frontiers in one JSON blob — a single write ensures they are + // never torn: a register cannot be present without its associated frontier. + // This is the ACTION COMMIT POINT: if ok4 is true the action is durably + // committed; ancillary frontier/cache write failures (ok1-ok3) do not fail + // the action. If ok4 is false, the caller must roll back ALL state. + const overrideState: Record< + string, + { s: number; c: number; b: number; f: number } + > = {}; + for (const [rawCtx, entry] of overrideRegisters) { + overrideState[rawCtx] = { s: entry.s, c: entry.c, b: entry.b, f: entry.f }; + } + const ok4 = setLocalStorageItemWithRecovery( + localOverrideStateKey(pubkey), + JSON.stringify(overrideState), + ); + + // If the commit-point write failed, return immediately — do NOT write any + // ancillary keys. The caller will roll back all in-memory and slot state. + // "v2 false ⇒ nothing durable anywhere" is the contract. + if (!ok4) return false; + + // Ancillary writes: frontier cache, publishable set, source timestamps. + // Best-effort — failures do not fail the action. setLocalStorageItemWithRecovery( localReadStateKey(pubkey), JSON.stringify(state), @@ -174,4 +284,7 @@ export function writeStoredReadState( localSourceCreatedAtKey(pubkey), JSON.stringify(sourceState), ); + + // The commit point: only the v2 override write determines action success. + return ok4; } diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index 8326a2bd0a..68723bcef2 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -2,13 +2,22 @@ import * as React from "react"; import { ReadStateManager, type ContextParentResolver, + type MarkResult, + type ReadStateProjection, } from "@/features/channels/readState/readStateManager"; +import type { OverrideLiveness } from "@/features/channels/readState/readStateFormat"; import type { RelayClient } from "@/shared/api/relayClientSession"; const noopGetTimestamp = () => null; const noopMarkRead = () => {}; const noopDrainAdvances = (): ReadonlySet => new Set(); const noopSetResolver = () => {}; +const noopMarkOverride = (): MarkResult => ({ + success: false, + reason: "load_incomplete", +}); +const noopGetOverrideLiveness = (): OverrideLiveness | null => null; +const noopGetProjection = (): ReadStateProjection | null => null; /** * React hook that creates and manages a ReadStateManager instance. @@ -93,31 +102,80 @@ export function useReadState( [], ); + // Override APIs for the NIP-RS manual-unread layer (slice 3 seam). + const markChannelUnread = React.useCallback( + (channelId: string): MarkResult => { + return ( + managerRef.current?.markChannelUnread(channelId) ?? { + success: false, + reason: "load_incomplete", + } + ); + }, + [], + ); + + const markChannelRead = React.useCallback((channelId: string): MarkResult => { + return ( + managerRef.current?.markChannelRead(channelId) ?? { + success: false, + reason: "load_incomplete", + } + ); + }, []); + + const getOverrideLiveness = React.useCallback( + (channelId: string): OverrideLiveness | null => { + return managerRef.current?.getOverrideLiveness(channelId) ?? null; + }, + [], + ); + + const getProjection = React.useCallback((): ReadStateProjection | null => { + return managerRef.current?.getProjection() ?? null; + }, []); + const isReady = Boolean( pubkey && relayClient && initializedPubkey === pubkey, ); + // loadComplete is true only when the manager's full-state enumeration has + // terminated with a complete verdict. Distinct from isReady (initialized): + // a manager can be initialized with an incomplete load. + const isLoadComplete = + isReady && (managerRef.current?.getProjection().loadComplete ?? false); + if (!pubkey || !relayClient) { return { getEffectiveTimestamp: noopGetTimestamp, isReady: false, + isLoadComplete: false, markContextRead: noopMarkRead, seedContextRead: noopMarkRead, drainSyncedAdvances: noopDrainAdvances, setContextParentResolver: noopSetResolver, readStateVersion: 0, getOwnTimestamp: noopGetTimestamp, + markChannelUnread: noopMarkOverride, + markChannelRead: noopMarkOverride, + getOverrideLiveness: noopGetOverrideLiveness, + getProjection: noopGetProjection, }; } return { getEffectiveTimestamp, isReady, + isLoadComplete, markContextRead, seedContextRead, drainSyncedAdvances, setContextParentResolver, readStateVersion, getOwnTimestamp, + markChannelUnread, + markChannelRead, + getOverrideLiveness, + getProjection, }; } diff --git a/desktop/src/features/channels/readStateOverride.ts b/desktop/src/features/channels/readStateOverride.ts new file mode 100644 index 0000000000..f93ea4744a --- /dev/null +++ b/desktop/src/features/channels/readStateOverride.ts @@ -0,0 +1,194 @@ +/** + * UI-layer helpers for the NIP-RS manual-unread override layer (slice 3). + * + * Provides the seam between useUnreadChannels and the ReadStateManager's + * override APIs (markChannelUnread / markChannelRead / getOverrideLiveness), + * added by slice 2 (nip-rs-unread-manager). + */ +import { toast } from "sonner"; + +import type { OverrideLiveness } from "@/features/channels/readState/readStateFormat"; +import type { MarkResult } from "@/features/channels/readState/readStateManager"; +import { + forcedUnreadStore, + type ForcedUnreadMap, +} from "@/features/channels/forcedUnreadStore"; +import type { ObservedUnreadEvent } from "@/features/channels/unreadChannelCounts"; + +export type { MarkResult, OverrideLiveness }; + +/** + * User-visible error message for a failed NIP-RS override operation. + * Returns null for silent failure reasons (already_inactive race condition). + */ +export function overrideErrorMessage( + op: "unread" | "read", + reason: string, +): string | null { + if (reason === "budget_exhausted") + return "Could not mark unread: override budget exhausted. Clear some channels first."; + if (reason === "uint32_overflow") + return op === "unread" + ? "Could not mark unread: counter limit reached for this channel." + : "Could not clear unread override: counter limit reached."; + if (reason === "load_incomplete") + return op === "unread" + ? "Could not mark unread: read state is still loading. Try again shortly." + : "Could not clear unread override: read state is still loading."; + if (reason === "already_inactive") return null; + return op === "unread" + ? "Could not mark unread." + : "Could not clear unread override."; +} + +/** Slice-2 override APIs from ReadStateManager, proxied through useReadState. */ +export type OverrideAPIs = { + /** True only when the manager's full-state load is complete (loadComplete). + * A manager that finished initializing with an incomplete load must NOT be + * treated as ready — null liveness after an incomplete load means "not found + * in a partial view", not "known absent register". */ + isReadStateReady: boolean; + markChannelUnread: (channelId: string) => MarkResult; + markChannelRead: (channelId: string) => MarkResult; + getOverrideLiveness: (channelId: string) => OverrideLiveness | null; +}; + +/** + * Call the manager's override-unread path (S bump + B set to effective + * frontier). Returns true when the local cache should be updated; returns false + * and shows a toast when the manager refuses. + */ +export function applyOverrideUnread( + channelId: string, + apis: OverrideAPIs, +): boolean { + const result = apis.markChannelUnread(channelId); + if (!result.success) { + const msg = overrideErrorMessage("unread", result.reason); + if (msg) toast.error(msg); + return false; + } + return true; +} + +/** + * Outcome of the explicit mark-read transition for one channel. + * + * `overrideCleared` — the NIP-RS override is now known inactive (no register + * exists, the frontier deactivated it, or the C-bump succeeded). Callers + * should clear local presentation. + * + * `overrideStillActive` — the manager is unavailable (fail-closed) or the + * C-bump was refused and liveness remains active. Callers MUST NOT clear + * local unread presentation; NIP-RS:537-539 requires success only when + * resulting override_active == false. + */ +export type OverrideReadOutcome = "overrideCleared" | "overrideStillActive"; + +/** + * Attempt to clear the NIP-RS override for `channelId` as part of an explicit + * mark-read transition. Models the transition as a single outcome: + * + * 1. Load not complete (!isReadStateReady): fail-closed — + * return overrideStillActive (null liveness after an incomplete load is + * ambiguous, not known absence). + * 2. liveness === null (load complete, no register): return overrideCleared + * (known absence after a complete load; no C-bump needed). + * 3. liveness exists (active or inactive): always call markChannelRead — + * spec NIP-RS:537-539 requires the C-bump even when the frontier already + * deactivated the override. Then re-read liveness. + * a. Resulting liveness inactive (or already_inactive race): cleared. + * b. uint32 refusal AND F > B (frontier already deactivated): cleared, + * no error toast (representable C-bump was not possible, frontier + * already provides the inactive verdict). + * c. Other refusal keeping liveness active: show toast, overrideStillActive. + */ +export function applyOverrideRead( + channelId: string, + apis: OverrideAPIs, +): OverrideReadOutcome { + // Step 1: manager unavailable → fail closed. + if (!apis.isReadStateReady) return "overrideStillActive"; + + const liveness = apis.getOverrideLiveness(channelId); + + // Step 2: load complete but no register at all → known absence, cleared. + if (liveness === null) return "overrideCleared"; + + // Step 3: register exists — always attempt the C-bump (NIP-RS:537-539). + const result = apis.markChannelRead(channelId); + + if (!result.success && result.reason === "already_inactive") { + // Race: cleared by another device between our check and the call. + return "overrideCleared"; + } + + // Re-read liveness after the attempt. + const afterLiveness = apis.getOverrideLiveness(channelId); + // Covers: successful C-bump (inactive after), F>B deactivation (inactive after), + // and uint32 refusal where F>B already deactivated the register. + if (afterLiveness === null || !afterLiveness.active) { + return "overrideCleared"; + } + + // Refused and still active. + if (!result.success) { + const msg = overrideErrorMessage("read", result.reason); + if (msg) toast.error(msg); + } + return "overrideStillActive"; +} + +/** + * Persist `channelId` into the forced-unread local cache with the current own + * timestamp as baseline. Always refreshes an existing entry (so a successful + * re-mark after the old override died uses the fresh baseline). Returns true + * when the map changed. + */ +export function persistForcedUnread( + channelId: string, + forcedMap: ForcedUnreadMap, + getOwnTimestamp: (id: string) => number | null, + pubkey: string | undefined, +): boolean { + const newTs = getOwnTimestamp(channelId); + if (Object.hasOwn(forcedMap, channelId) && forcedMap[channelId] === newTs) { + return false; + } + forcedMap[channelId] = newTs; + if (pubkey) forcedUnreadStore.write(pubkey, forcedMap); + return true; +} + +/** + * Per-channel evidence maps that `markAllChannelsRead` mutates. + * Extracted so the per-channel transition can be tested without the full hook. + */ +export type MarkAllEvidenceMaps = { + forcedUnread: ForcedUnreadMap; + latestByChannel: Map; + observedUnreadEvents: Map>; +}; + +/** + * Apply the per-channel mark-all-read transition for a single channel ID. + * + * Calls `applyOverrideRead` to attempt the NIP-RS C-bump. On `overrideCleared`, + * removes the channel's entry from all three evidence maps. On + * `overrideStillActive` (refused or load incomplete), all maps are left + * intact — the caller must NOT wipe evidence for channels whose clear was + * refused. Returns the outcome for caller use. + */ +export function applyMarkAllReadTransition( + channelId: string, + apis: OverrideAPIs, + maps: MarkAllEvidenceMaps, +): OverrideReadOutcome { + const outcome = applyOverrideRead(channelId, apis); + if (outcome === "overrideCleared") { + delete maps.forcedUnread[channelId]; + maps.latestByChannel.delete(channelId); + maps.observedUnreadEvents.delete(channelId); + } + return outcome; +} diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index dc24023555..843f342d36 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -26,6 +26,26 @@ import { isConversationalUnreadKind } from "@/shared/constants/kinds"; import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression"; +/** + * Pure helper: apply the mark-unread divider overlay. + * + * Calls `markFn(channelId)` and, if it returns true (accepted), adds the + * channel to the `overlay` set. Returns the same boolean so callers can gate + * any additional side-effects on success. + * + * Extracted for testability: `handleMarkUnread` calls this instead of + * inlining the same two-step logic. + */ +export function applyMarkUnreadDividerOverlay( + channelId: string, + markFn: (id: string) => boolean, + overlay: Set, +): boolean { + const accepted = markFn(channelId); + if (accepted) overlay.add(channelId); + return accepted; +} + type UseChannelUnreadStateOptions = { activeChannelId: string | null; timelineMessages: TimelineMessage[]; @@ -36,7 +56,7 @@ type UseChannelUnreadStateOptions = { openThreadMessages?: MainTimelineEntry[]; getChannelReadAt: (channelId: string) => number | null; getMessageReadAt: (messageId: string) => number | null; - markChannelUnread: (channelId: string) => void; + markChannelUnread: (channelId: string) => boolean; markMessageRead: (messageId: string, timestamp: number) => void; isThreadMuted: (rootId: string) => boolean; readStateVersion: number; @@ -404,11 +424,12 @@ export function useChannelUnreadState({ const handleMarkUnread = React.useCallback(() => { if (!activeChannelId) return; - // Mirror the deliberate mark-unread locally so the timeline marker is - // suppressed (see forcedUnreadRef above). Re-render so the memo re-runs. - forcedUnreadRef.current.add(activeChannelId); + applyMarkUnreadDividerOverlay( + activeChannelId, + markChannelUnread, + forcedUnreadRef.current, + ); forceUnreadRender(); - markChannelUnread(activeChannelId); }, [activeChannelId, markChannelUnread]); // Mark a message's directly-revealed children read (LP4 v3 open-at-level): diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7b..c2bf1b426d 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -21,6 +21,13 @@ import { forcedUnreadStore, type ForcedUnreadMap, } from "@/features/channels/forcedUnreadStore"; +import { + applyMarkAllReadTransition, + applyOverrideRead, + applyOverrideUnread, + persistForcedUnread, + type OverrideAPIs, +} from "@/features/channels/readStateOverride"; import { getThreadReference, isBroadcastReply, @@ -98,14 +105,10 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null { } // Resolve where the read marker should land when a channel is marked read. -// Folds the caller's timeline position together with the newest event this -// client has observed live (`observedLatest`), so an explicit "mark read" still -// covers messages that arrived faster than channel metadata — this fold is -// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, -// all of which pass a null/stale caller value. `clearObserved` reports whether -// the resulting marker covers the observed timestamp, signalling the caller to -// drop its observed refs so the unread memo sees `latest === undefined` until a -// genuinely newer event arrives. +// Folds caller position + observed-live latest so explicit "mark read" covers +// messages that arrived faster than channel metadata (load-bearing for Esc, +// sidebar mark-read, and empty-channel open). Returns `clearObserved` when the +// marker covers the observed timestamp so callers can drop stale refs. export function resolveChannelReadMarker( callerReadAt: string | null | undefined, observedLatest: number | undefined, @@ -147,15 +150,12 @@ export function useUnreadChannels( } = options; const activeChannelId = activeChannel?.id ?? null; const normalizedPubkey = pubkey?.toLowerCase() ?? null; - // Scoped relay key for activity storage; empty string when relay not yet known - // so rows from an unknown relay never load into the wrong community. + // Scoped relay key; empty string when relay not yet known. const normalizedRelayUrl = relayUrlOption ? normalizeRelayUrl(relayUrlOption) : ""; - // Single identity for the in-memory thread-activity buffer — computed once - // per render and used at reset, both writers, and the return fence. The - // helper returns "" when either value is absent, which never matches a valid - // loaded scope, so the fence returns [] until the buffer is seeded. + // Activity scope key: "" when pubkey/relay absent (never matches a valid + // loaded scope, so the fence returns [] until the buffer is seeded). const currentActivityScope = activityScopeKey( normalizedPubkey, normalizedRelayUrl, @@ -164,20 +164,35 @@ export function useUnreadChannels( const { getEffectiveTimestamp, isReady: isReadStateReady, + isLoadComplete, markContextRead, drainSyncedAdvances, setContextParentResolver, readStateVersion, getOwnTimestamp, + markChannelUnread: markChannelOverrideUnread, + markChannelRead: markChannelOverrideRead, + getOverrideLiveness, + getProjection, } = useReadState(pubkey, relayClient); + const overrideApis = React.useMemo( + () => ({ + isReadStateReady: isLoadComplete, // loadComplete, not initialized — null after incomplete load ≠ known absent + markChannelUnread: markChannelOverrideUnread, + markChannelRead: markChannelOverrideRead, + getOverrideLiveness, + }), + [ + getOverrideLiveness, + isLoadComplete, + markChannelOverrideRead, + markChannelOverrideUnread, + ], + ); - // Observed "latest external trigger event" per channel (unix seconds). This - // is *derived relay evidence*, not source-of-truth: it's populated from a - // one-shot catch-up REQ per channel (keyed on the NIP-RS read marker) plus - // ongoing live events. The only thing we ever do with it is compare against - // the NIP-RS read marker — see the unread memo below. Reset on identity - // change. Stale entries for channels the user has left are silently - // ignored by the memo (it iterates the current channels list, not the map). + // Observed "latest external trigger event" per channel — derived relay evidence + // used only to compare against the NIP-RS read marker. Reset on identity change; + // stale entries for left channels are ignored (memo iterates current channel list). const latestByChannelRef = React.useRef(new Map()); const observedUnreadEventsByChannelRef = React.useRef( new Map>(), @@ -186,13 +201,10 @@ export function useUnreadChannels( const channelsRef = React.useRef(channels); channelsRef.current = channels; - // Channels manually marked unread this session (e.g., right-click → "mark - // unread"). Because NIP-RS read markers are monotonic, this flag is what - // makes the badge appear without lowering synced read state. Each entry - // stores the channel's NIP-RS read marker (unix seconds) at force-time, or - // null if no marker existed yet. Persisted to localStorage - // (buzz-forced-unread.v1) so it survives reload and is visible to the rail - // observer for inactive communities. + // Channels manually marked unread this session via right-click (NIP-RS local + // override cache). Monotonic read markers mean we can't lower them; this flag + // makes the badge appear without affecting synced state. Persisted so it + // survives reload and is visible to the rail observer for inactive communities. const forcedUnreadRef = React.useRef( pubkey ? forcedUnreadStore.read(pubkey) : {}, ); @@ -217,39 +229,28 @@ export function useUnreadChannels( } }, [readStateVersion, drainSyncedAdvances]); - // Root event IDs of threads where the current user has replied at least once. - // Used to determine if thread replies should trigger unread notifications. + // Root event IDs of threads the current user has replied to (notification gating). const participatedRootIdsRef = React.useRef(new Set()); // Root event IDs of top-level messages authored by the current user. - // Used to notify the author when someone replies to their posts. const authoredRootIdsRef = React.useRef(new Set()); - // Root event IDs of threads where an external message @-mentioned the user. - // ORed into the badge gate so a mention recipient who never participated, - // authored, or followed the thread still gets the thread-unread badge. + // Thread roots with an external @-mention; ORed into badge gate for mention recipients. const mentionedRootIdsRef = React.useRef(new Set()); - // Root event IDs of threads the user has explicitly muted. Takes precedence - // over participation, follow, and authorship for notification suppression. + // Thread roots the user has explicitly muted (takes precedence over all gating). const mutedRootIdsRef = React.useRef(new Set()); - // Stable ref for the caller-supplied muted channel IDs. Updated every render - // so the catch-up loop always reads the latest set without being a dep. + // Caller-supplied muted channel IDs — updated each render, not a dep. const mutedChannelIdsRef = React.useRef>(new Set()); mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set(); - // Thread reply events that triggered notifications — surfaced in the Home - // activity feed as synthetic FeedItems. + // Thread reply events that triggered notifications (surfaced in Home feed). const threadActivityRef = React.useRef([]); - // Tracks the (pubkey:relayUrl) scope currently loaded into threadActivityRef. - // Writers guard against this before merging so in-flight writes from a prior - // scope cannot corrupt the new one; renders return [] until it matches. + // (pubkey:relayUrl) scope in threadActivityRef; guards in-flight scope drift. const threadActivityScopeRef = React.useRef(""); - // Tracks which channels we've already issued a catch-up REQ for this - // session. Prevents re-fetching on every channels-list refetch, while still - // letting newly-joined channels be caught up. Reset on identity change. + // Channels already caught-up this session; reset on identity change. const caughtUpChannelsRef = React.useRef(new Set()); const [latestVersion, bumpLatestVersion] = React.useReducer( @@ -257,10 +258,8 @@ export function useUnreadChannels( 0, ); - // Version signal bumped only when the participated/authored/mentioned - // root-id sets change, so the gate snapshots (re-derived below) don't - // re-allocate on every observed external message the way reusing - // latestVersion would. + // Separate from latestVersion: bumped only when badge-gating set membership + // changes, so gate snapshots don't re-allocate on every external message. const [membershipVersion, bumpMembershipVersion] = React.useReducer( (x: number) => x + 1, 0, @@ -296,29 +295,19 @@ export function useUnreadChannels( bumpMembershipVersion(); }, [pubkey, relayClient, normalizedRelayUrl]); - // `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the - // caller's `readAt` is already the newest TOP-LEVEL message, so the marker - // must land exactly there without folding in `observedLatest` (which counts - // thread replies) and without clearing observed refs. Leaving the refs intact - // keeps the sidebar dot lit for a channel whose only unread is an unopened - // thread reply — viewing the channel no longer absorbs the reply, so the dot - // persists until an explicit mark-read (Esc, sidebar, mark-all) or a newer - // top-level message advances the channel marker past it. Those explicit - // "mark read" actions omit the flag and keep the fold, since they mean - // "clear everything in this channel." + // `topLevelOnly` = passive channel-open path (NIP-RS Option 1): caller's + // `readAt` is already the newest top-level message; skip `observedLatest` + // (which counts thread replies) and keep refs intact so the sidebar dot + // persists until an explicit mark-read covers the reply or a newer top-level + // message advances past it. Explicit "mark read" actions omit the flag. const markChannelRead = React.useCallback( ( channelId: string, readAt: string | null | undefined, { topLevelOnly = false }: { topLevelOnly?: boolean } = {}, ) => { - if (Object.hasOwn(forcedUnreadRef.current, channelId)) { - delete forcedUnreadRef.current[channelId]; - if (pubkey) { - forcedUnreadStore.write(pubkey, forcedUnreadRef.current); - } - bumpLatestVersion(); - } + // Frontier advance first (spec order: advance → C-bump → inspect liveness). + // The advance may make F > B, so applyOverrideRead sees an inactive override. const observedLatest = topLevelOnly ? undefined : latestByChannelRef.current.get(channelId); @@ -326,37 +315,47 @@ export function useUnreadChannels( readAt, observedLatest, ); - if (markAt === null) return; - markContextRead(channelId, markAt); - // Clear observed-latest refs when the read marker covers them so the - // unread memo sees `latest === undefined` until a genuinely new event - // arrives. Without this, `latest > readAt` resolves to `T > T` (false) - // but the channel lingers in the set when advanceContext's monotonic - // guard suppresses the readStateVersion bump. - if (clearObserved) { + if (markAt !== null) { + markContextRead(channelId, markAt); + } + // C-bump; clear local presentation only when liveness is confirmed inactive. + // null ≠ inactive — pre-init fails closed. + const outcome = applyOverrideRead(channelId, overrideApis); + if (outcome === "overrideCleared") { + if (Object.hasOwn(forcedUnreadRef.current, channelId)) { + delete forcedUnreadRef.current[channelId]; + if (pubkey) { + forcedUnreadStore.write(pubkey, forcedUnreadRef.current); + } + bumpLatestVersion(); + } + } + if (markAt !== null && clearObserved) { latestByChannelRef.current.delete(channelId); observedUnreadEventsByChannelRef.current.delete(channelId); bumpLatestVersion(); } }, - [markContextRead, pubkey], + [markContextRead, overrideApis, pubkey], ); - // Manually mark a channel unread (e.g., right-click → "mark unread"). Persists - // the current NIP-RS read marker as baseline to localStorage so the rail - // observer can detect when a cross-device read has since covered the force. - // NIP-RS markers are monotonic, so we do not publish a lower timestamp. + // Manually mark a channel unread (right-click). Returns true when the manager + // accepted (use to gate session-local UI overlays on success only). const markChannelUnread = React.useCallback( - (channelId: string) => { - if (!Object.hasOwn(forcedUnreadRef.current, channelId)) { - forcedUnreadRef.current[channelId] = getOwnTimestamp(channelId); - if (pubkey) { - forcedUnreadStore.write(pubkey, forcedUnreadRef.current); - } + (channelId: string): boolean => { + if (!applyOverrideUnread(channelId, overrideApis)) return false; + if ( + persistForcedUnread( + channelId, + forcedUnreadRef.current, + getOwnTimestamp, + pubkey, + ) + ) bumpLatestVersion(); - } + return true; }, - [getOwnTimestamp, pubkey], + [getOwnTimestamp, overrideApis, pubkey], ); // Record the thread root of an EXTERNAL message that @-mentioned the user. @@ -844,7 +843,9 @@ export function useUnreadChannels( for (const channel of channels) { if (channel.id === activeChannelId) continue; - if (Object.hasOwn(forcedUnreadRef.current, channel.id)) { + // Manager liveness is authoritative (isReadStateReady). Use + // getOverrideLiveness, not forcedUnreadRef which may be stale. + if (getOverrideLiveness(channel.id)?.active === true) { // Forced-unread is dot tier only — not high-priority. unread.add(channel.id); counts.set(channel.id, 1); @@ -953,7 +954,6 @@ export function useUnreadChannels( const markAllChannelsRead = React.useCallback(() => { for (const channelId of unreadChannelIdsRef.current) { - delete forcedUnreadRef.current[channelId]; const unixSeconds = latestByChannelRef.current.get(channelId) ?? getEffectiveTimestamp(channelId) ?? @@ -961,19 +961,20 @@ export function useUnreadChannels( if (unixSeconds !== null) { markContextRead(channelId, unixSeconds); } - latestByChannelRef.current.delete(channelId); - observedUnreadEventsByChannelRef.current.delete(channelId); + applyMarkAllReadTransition(channelId, overrideApis, { + forcedUnread: forcedUnreadRef.current, + latestByChannel: latestByChannelRef.current, + observedUnreadEvents: observedUnreadEventsByChannelRef.current, + }); } if (pubkey) { forcedUnreadStore.write(pubkey, forcedUnreadRef.current); } bumpLatestVersion(); - }, [getEffectiveTimestamp, markContextRead, pubkey]); + }, [getEffectiveTimestamp, markContextRead, overrideApis, pubkey]); - // Identity-stable snapshots of the membership sets for the notify gate. - // Re-derived only when membershipVersion bumps (a set actually changed), so - // `isNotifiedForThread`'s useCallback deps invalidate on async discovery - // while live consumers keep reading the mutable refs directly. + // Stable snapshots of membership sets — re-derived only when membershipVersion + // bumps; live consumers read the mutable refs directly between bumps. // biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal const participatedRootIds = React.useMemo( () => new Set(participatedRootIdsRef.current) as ReadonlySet, @@ -998,10 +999,8 @@ export function useUnreadChannels( markAllChannelsRead, markChannelRead, markChannelUnread, - // Exposed so other surfaces (e.g. Home) can project per-item read state - // off the same NIP-RS read marker without instantiating a second - // ReadStateManager. readStateVersion is the invalidation signal callers - // should include in memo deps. + // Exposed for surfaces projecting per-item read state (Home) off the shared + // NIP-RS marker without a second ReadStateManager; readStateVersion = invalidation signal. getEffectiveTimestamp, getOwnTimestamp, readStateVersion, @@ -1017,5 +1016,6 @@ export function useUnreadChannels( mutedRootIds: mutedRootIdsRef.current as ReadonlySet, muteThread, unmuteThread, + getProjection, }; } diff --git a/desktop/src/features/communities/communityUnreadObserver.test.mjs b/desktop/src/features/communities/communityUnreadObserver.test.mjs index 6386d06102..8b86707c4e 100644 --- a/desktop/src/features/communities/communityUnreadObserver.test.mjs +++ b/desktop/src/features/communities/communityUnreadObserver.test.mjs @@ -544,104 +544,70 @@ function baseRelay(unreadEvent, mutesPayload = null) { ]); } -test("fetchCommunityUnread threaded reply in untracked root → hasUnread:false", async () => { - const relay = baseRelay(threadedReplyEvent()); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 100, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - // No root in any set → gate rejects the threaded reply - readThreadRelationships: readRelationships(), - }); - - assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); -}); - -test("fetchCommunityUnread threaded reply in participatedRootIds → hasUnread:true", async () => { - const relay = baseRelay(threadedReplyEvent()); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 100, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - readThreadRelationships: readRelationships({ - participatedRootIds: new Set([THREAD_ROOT_2]), - }), - }); - - assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); -}); - -test("fetchCommunityUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => { - // A @mention of the user bypasses the follow/participation gate - const relay = baseRelay( - threadedReplyEvent({ +const THREAD_GATE_CASES = [ + { + name: "threaded reply in untracked root → hasUnread:false", + unreadEvent: threadedReplyEvent(), + relationships: {}, + expected: { hasUnread: false, mentionCount: 0 }, + }, + { + name: "threaded reply in participatedRootIds → hasUnread:true", + unreadEvent: threadedReplyEvent(), + relationships: { participatedRootIds: new Set([THREAD_ROOT_2]) }, + expected: { hasUnread: true, mentionCount: 0 }, + }, + { + name: "#p-mention reply in untracked root → hasUnread:true (mention overrides)", + unreadEvent: threadedReplyEvent({ id: "mention-reply".padEnd(64, "0"), extraTags: [["p", PUBKEY]], }), - ); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 100, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - readThreadRelationships: readRelationships(), - }); - - assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); -}); - -test("fetchCommunityUnread top-level post → hasUnread:true (no thread gate)", async () => { - // Top-level posts have no parentId — shouldNotifyForEvent returns true - const relay = baseRelay( - event({ + relationships: {}, + expected: { hasUnread: true, mentionCount: 0 }, + }, + { + name: "top-level post → hasUnread:true (no thread gate)", + unreadEvent: event({ id: "toplevel".padEnd(64, "0"), created_at: 20, tags: [["h", CHANNEL_ID]], }), - ); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 100, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - readThreadRelationships: readRelationships(), - }); - - assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); -}); - -test("fetchCommunityUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => { - const relay = baseRelay( - threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }), - ); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 100, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - // Root is participated but also muted — mute wins - readThreadRelationships: readRelationships({ + relationships: {}, + expected: { hasUnread: true, mentionCount: 0 }, + }, + { + name: "threaded reply whose root is in mutedRootIds → hasUnread:false", + unreadEvent: threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }), + relationships: { participatedRootIds: new Set([THREAD_ROOT_2]), mutedRootIds: new Set([THREAD_ROOT_2]), - }), + }, + expected: { hasUnread: false, mentionCount: 0 }, + }, +]; + +for (const { + name, + unreadEvent, + relationships, + expected, +} of THREAD_GATE_CASES) { + test(`fetchCommunityUnread ${name}`, async () => { + const relay = baseRelay(unreadEvent); + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(relationships), + }); + assert.deepEqual(result, expected); }); +} - assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); -}); - -// ── Forced-unread persistence gate tests ───────────────────────────────── +// ── Override register authoritative source tests ────────────────────────── // A relay that serves one channel (CHANNEL_ID) as a member channel, // with no read state and no incoming events. @@ -727,8 +693,12 @@ function quietRelayWithReadState(readAtSeconds) { ]); } -test("fetchCommunityUnread forced-unread channel lights rail dot (hasUnread:true)", async () => { +test("fetchCommunityUnread stored active register lights rail (hasUnread:true)", async () => { + // Authoritative source: complete projection with active register (S=1,C=0,B=0 with F=0). + // The fetch returns no override events — projection is the authority. const relay = quietRelay(); + // Active register: S=1, C=0, B=0 (baseline), frontier=0 → override_active = true + const overrides = new Map([[CHANNEL_ID, { s: 1, c: 0, b: 0 }]]); const result = await fetchCommunityUnread({ client: relay, @@ -737,20 +707,22 @@ test("fetchCommunityUnread forced-unread channel lights rail dot (hasUnread:true decryptReadState: async (v) => v, decryptMutes: async (v) => v, readThreadRelationships: readRelationships(), - // Forced-unread map: CHANNEL_ID forced when marker was null (no prior read) - readForcedUnread: () => ({ [CHANNEL_ID]: null }), + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides, + }), }); assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); }); -test("fetchCommunityUnread forced-unread channel not in member list → hasUnread:false", async () => { - // The channel is marked forced-unread but the user is not a member of ANY - // channel — forced-unread is not consulted when the channel set is empty. - const relay = relayFor([ - // 1. member events — empty - () => [], - ]); +test("fetchCommunityUnread stored tombstoned register does NOT light rail (hasUnread:false)", async () => { + // Authoritative source: complete projection with tombstoned register (S=1,C=1 → inactive). + // Must not light the dot. + const relay = quietRelay(); + // Tombstoned: S=1, C=1, B=0 → override_active = false (S > C fails: 1 > 1 = false) + const overrides = new Map([[CHANNEL_ID, { s: 1, c: 1, b: 0 }]]); const result = await fetchCommunityUnread({ client: relay, @@ -759,13 +731,46 @@ test("fetchCommunityUnread forced-unread channel not in member list → hasUnrea decryptReadState: async (v) => v, decryptMutes: async (v) => v, readThreadRelationships: readRelationships(), - readForcedUnread: () => ({ [CHANNEL_ID]: null }), + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides, + }), }); assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); }); -test("fetchCommunityUnread forced-unread channel that is also muted → hasUnread:false", async () => { +test("fetchCommunityUnread old active register beyond horizon still lights rail", async () => { + // An active register that is older than the 7-day horizon must still light + // the rail. The fetch is tag-free and has no `since`, so it returns no + // events here, but the complete projection is the authority. + const relay = quietRelay(); + const VERY_OLD_FRONTIER = 1; // far beyond any 7-day horizon + // S=1, C=0, B=VERY_OLD_FRONTIER, and frontier=VERY_OLD_FRONTIER → active + const overrides = new Map([ + [CHANNEL_ID, { s: 1, c: 0, b: VERY_OLD_FRONTIER }], + ]); + + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100 + 7 * 24 * 3600 + 1, // past the old 7-day cutoff + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides, + }), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchCommunityUnread no stored register and empty fetch → falls through to relay gate", async () => { + // No stored registers, no override event fetched, but a real unread event exists. const relay = relayFor([ // 1. member events () => [ @@ -787,16 +792,20 @@ test("fetchCommunityUnread forced-unread channel that is also muted → hasUnrea ], // 3. visibility () => [], - // 4. read-state (parallel with mutes) + // 4. read-state — empty () => [], - // 5. mutes — CHANNEL_ID is muted + // 5. mutes + () => [], + // 6. unread events — one real unread () => [ event({ - pubkey: PUBKEY, - content: mutesContent([CHANNEL_ID]), + id: "real-unread".padEnd(64, "0"), + created_at: 20, + tags: [["h", CHANNEL_ID]], }), ], - // No per-channel fetches expected — muted channel is skipped + // 7. mention events + () => [], ]); const result = await fetchCommunityUnread({ @@ -806,15 +815,37 @@ test("fetchCommunityUnread forced-unread channel that is also muted → hasUnrea decryptReadState: async (v) => v, decryptMutes: async (v) => v, readThreadRelationships: readRelationships(), - // CHANNEL_ID is both forced-unread AND muted — mute wins - readForcedUnread: () => ({ [CHANNEL_ID]: null }), + // No projection — no override registers; falls through to relay evidence gate + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchCommunityUnread channel not in member list → hasUnread:false", async () => { + // No members — register lookup is never reached. + const relay = relayFor([ + () => [], // member events empty + ]); + + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides: new Map([[CHANNEL_ID, { s: 1, c: 0, b: 0 }]]), + }), }); assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); }); -test("fetchCommunityUnread readForcedUnread returns empty map → falls through to relay gate", async () => { - // No forced-unread, but there IS a real unread event → hasUnread:true via relay +test("fetchCommunityUnread active register muted channel → hasUnread:false", async () => { + // Override is active, but the channel is muted — mute wins. const relay = relayFor([ // 1. member events () => [ @@ -838,18 +869,14 @@ test("fetchCommunityUnread readForcedUnread returns empty map → falls through () => [], // 4. read-state () => [], - // 5. mutes - () => [], - // 6. unread events + // 5. mutes — CHANNEL_ID is muted () => [ event({ - id: "real-unread".padEnd(64, "0"), - created_at: 20, - tags: [["h", CHANNEL_ID]], + pubkey: PUBKEY, + content: mutesContent([CHANNEL_ID]), }), ], - // 7. mention events - () => [], + // No per-channel fetches expected — muted channel is skipped ]); const result = await fetchCommunityUnread({ @@ -859,33 +886,23 @@ test("fetchCommunityUnread readForcedUnread returns empty map → falls through decryptReadState: async (v) => v, decryptMutes: async (v) => v, readThreadRelationships: readRelationships(), - readForcedUnread: () => ({}), // empty — no forced-unread - }); - - assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); -}); - -test("fetchCommunityUnread forced-unread + synced marker advanced PAST baseline → hasUnread:false", async () => { - // markerAtWhenForced = 50, observed readAt = 100 (100 > 50 → cross-device read wins) - const relay = quietRelayWithReadState(100); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 200, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - readThreadRelationships: readRelationships(), - // Forced at marker=50; synced marker is now 100 — covers the force - readForcedUnread: () => ({ [CHANNEL_ID]: 50 }), + // Active register, but channel is muted — mute wins + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides: new Map([[CHANNEL_ID, { s: 1, c: 0, b: 0 }]]), + }), }); assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); }); -test("fetchCommunityUnread forced-unread + synced marker NOT advanced past baseline → hasUnread:true", async () => { - // markerAtWhenForced = 100, observed readAt = 100 (equal → force still stands) +test("fetchCommunityUnread frontier advance deactivates stored register → hasUnread:false", async () => { + // Register: S=1, C=0, B=50 (active only when F<=50). + // Fetched frontier: 100 > 50 → override_active = false. const relay = quietRelayWithReadState(100); + // B=50 means active only when F<=50; fetched frontier is 100 → inactive + const overrides = new Map([[CHANNEL_ID, { s: 1, c: 0, b: 50 }]]); const result = await fetchCommunityUnread({ client: relay, @@ -894,28 +911,281 @@ test("fetchCommunityUnread forced-unread + synced marker NOT advanced past basel decryptReadState: async (v) => v, decryptMutes: async (v) => v, readThreadRelationships: readRelationships(), - // Forced at marker=100; synced marker is still 100 — no newer read - readForcedUnread: () => ({ [CHANNEL_ID]: 100 }), - }); - - assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); -}); - -test("fetchCommunityUnread forced-unread with null baseline + synced marker present → hasUnread:false", async () => { - // markerAtWhenForced = null (no marker at force-time), but readAt = 50 now - // A cross-device read appeared after the force → do NOT light the dot - const relay = quietRelayWithReadState(50); - - const result = await fetchCommunityUnread({ - client: relay, - pubkey: PUBKEY, - nowSeconds: 200, - decryptReadState: async (v) => v, - decryptMutes: async (v) => v, - readThreadRelationships: readRelationships(), - // Forced when no marker existed; a cross-device read has since appeared - readForcedUnread: () => ({ [CHANNEL_ID]: null }), + getProjection: () => ({ + loadComplete: true, + frontiers: new Map(), + overrides, + }), }); assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); }); + +// ── Adversarial witness tests ───────────────────────────────────────────────── +// +// These witnesses verify that the rail's override evaluation cannot resurrect +// a superseded register or produce a false verdict from a torn/partial state. + +test("fetchCommunityUnread adversarial-1: stale live register + fetched tombstone → dark when no complete projection", async () => { + // The OLD per-field max join would: max(stale_live, tombstone) = live (resurrection). + // Without a complete projection, the fetched coordinate-deduped tombstone is + // authoritative; the register is inactive and the rail must remain dark. + // + // Setup: fetched event carries tombstone (S=0,C=4,B=0) for CHANNEL_ID. + const SLOT = "a".repeat(32); // valid 32-hex slot + const relay = relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility + () => [], + // 4. read-state: tombstone event (S=0, C=4, B=0 → inactive, S not > C) + () => [ + event({ + pubkey: PUBKEY, + created_at: 300, + tags: [ + ["d", `read-state:${SLOT}`], + ["t", "read-state"], + ], + // Contexts blob: channel has tombstone register (ov_s=0 absent means S=0, ov_c=4) + content: JSON.stringify({ + v: 1, + client_id: "client", + contexts: { + [CHANNEL_ID]: 50, + [`ov_c:${CHANNEL_ID}`]: 4, + }, + }), + }), + ], + // 5. mutes + () => [], + // 6. unread events — none (the override is inactive, no message events either) + () => [], + // 7. mention events + () => [], + ]); + + // No complete projection — use fetched-only (no stored merge). + // Previously this would be called with readStoredRegisters returning a stale + // live register (S=5,C=0,B=100), which when max-joined with fetched tombstone + // (S=0,C=4,B=0) produced (S=5,C=4,B=100) → active (resurrection). + // New behavior: fetched state alone → (S=0,C=4,B=0) → inactive → dark. + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 400, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + // No getProjection: fetched-deduped is the sole authority. + }); + + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); + +test("fetchCommunityUnread adversarial-2: projection with frontier deactivates register → dark", async () => { + // CRITICAL 2 fix: the rail now reads projection.frontiers, not only fetched frontiers. + // A register (S=5,C=0,B=100) with projection frontier F=101 must evaluate as + // inactive (101 > 100) — previously the frontier was not in the stored projection, + // so it defaulted to F=0 and the register appeared active. + // + // The fetched relay state returns empty read-state (frontier not in fetched events). + // The projection has the committed frontier F=101 from a successful mark-read. + const relay = quietRelay(); // no read-state events fetched + + const overrides = new Map([[CHANNEL_ID, { s: 5, c: 0, b: 100 }]]); + // projection.frontiers has F=101 for CHANNEL_ID → deactivates the register + const frontiers = new Map([[CHANNEL_ID, 101]]); + + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 200, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + getProjection: () => ({ loadComplete: true, frontiers, overrides }), + }); + + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); + +test("fetchCommunityUnread adversarial-5: complete_projection_live_register_not_darkened_by_stale_fetched_tombstone", async () => { + // A fresh local mark-unread committed into the projection (S=5,C=0,B=100,F=50) + // must NOT be darkened by an independently fetched tombstone (S=0,C=4,B=0). + // + // The observer cannot adjudicate cross-source ordering: the manager owns + // coordinate dedupe and CRDT ingest. A categorical tombstone-wins rule would + // hide the user's mark-unread until the relay publish catches up (the + // false-negative direction), which is the more harmful failure. + // + // When the projection is complete, projection.overrides is the SOLE liveness + // authority. The fetched register shape is irrelevant. Any transient + // false-positive resolves when the manager ingests the tombstone via its own + // CRDT path. + const SLOT = "a".repeat(32); // valid 32-hex slot + const relay = relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events + () => [], + // 4. read-state: tombstone wire format (ov_c present, ov_s absent → s=0,c=4,b=0) + // Frontier F=50 is also present so the projection frontier is not strictly + // newer — only the live-register vs concurrent-tombstone ordering is under test. + () => [ + event({ + pubkey: PUBKEY, + created_at: 400, + tags: [ + ["d", `read-state:${SLOT}`], + ["t", "read-state"], + ], + content: JSON.stringify({ + v: 1, + client_id: "client", + contexts: { + [CHANNEL_ID]: 50, + [`ov_c:${CHANNEL_ID}`]: 4, + // ov_s: intentionally absent → tombstone (s=0) + }, + }), + }), + ], + // 5. mutes + () => [], + // 6. unread events — none (liveness from override register, not messages) + () => [], + // 7. mention events — none + () => [], + ]); + + // Complete projection: live register S=5,C=0,B=100 and frontier F=50. + // F=50 ≤ B=100, S=5 > C=0 → isOverrideActive returns true. + const overrides = new Map([[CHANNEL_ID, { s: 5, c: 0, b: 100 }]]); + const frontiers = new Map([[CHANNEL_ID, 50]]); + + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 500, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + getProjection: () => ({ loadComplete: true, frontiers, overrides }), + }); + + // Projection is the sole override authority when complete. The fetched + // tombstone does not suppress the live projection register — the rail lights. + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchCommunityUnread adversarial-6: torn fetched register (ov_s+ov_c without ov_b) → rail stays dark", async () => { + // Integration witness: a fetched read-state event carrying an illegal partial + // override group — ov_s and ov_c present, ov_b ABSENT — must not light the + // rail. The parser drops incomplete groups (any shape other than all-three or + // C-only tombstone), so the register never reaches liveness evaluation. + // + // This locks the parser → structured merge → rail path at the observer level. + // The readStateOverride parser-matrix tests confirm the drop at the unit level; + // this witness confirms the integration: a torn fetched group cannot influence + // fetchCommunityUnread() rail output. + const SLOT = "b".repeat(32); // valid 32-hex slot, distinct from other tests + const relay = relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events + () => [], + // 4. read-state: torn group — ov_s + ov_c present, ov_b absent (S+C shape). + // The parser treats any shape other than {S,C,B} or {C-only tombstone} + // as invalid and drops the entire group. No override register reaches + // the authoritative map. No message events follow either. + () => [ + event({ + pubkey: PUBKEY, + created_at: 300, + tags: [ + ["d", `read-state:${SLOT}`], + ["t", "read-state"], + ], + content: JSON.stringify({ + v: 1, + client_id: "client", + contexts: { + [`ov_s:${CHANNEL_ID}`]: 5, + [`ov_c:${CHANNEL_ID}`]: 0, + // ov_b: intentionally absent → illegal S+C partial group, dropped + }, + }), + }), + ], + // 5. mutes + () => [], + // 6. unread events — none + () => [], + // 7. mention events — none + () => [], + ]); + + const result = await fetchCommunityUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 400, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + // No getProjection — fetched-deduped is sole authority, torn group is dropped. + }); + + // Torn group is silently dropped by the parser; no override register present; + // no message events — rail must remain dark. + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); diff --git a/desktop/src/features/communities/communityUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts index 5bc3298413..b858e1283d 100644 --- a/desktop/src/features/communities/communityUnreadObserver.ts +++ b/desktop/src/features/communities/communityUnreadObserver.ts @@ -1,14 +1,14 @@ import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; -import { - forcedUnreadStore, - type ForcedUnreadMap, -} from "@/features/channels/forcedUnreadStore"; import { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind"; -import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot"; +import { mergeReadStateEventsStructured } from "@/features/channels/readState/readStateSnapshot"; import { + isOverrideActive, maxReadAt, msgContextKey, + type OverrideRegister, } from "@/features/channels/readState/readStateFormat"; +import { deduplicateByCoordinate } from "@/features/channels/readState/readStateFencedLoader"; +import type { ReadStateProjection } from "@/features/channels/readState/readStateManager"; import { getThreadReference, isBroadcastReply, @@ -86,7 +86,6 @@ const METADATA_LIMIT = 1000; const UNREAD_EXISTENCE_LIMIT = 50; const MENTION_COUNT_LIMIT = 100; const READ_STATE_FETCH_LIMIT = 500; -const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60; export type CommunityUnreadObserverResult = { hasUnread: boolean; @@ -146,9 +145,10 @@ export async function fetchObservedChannels( export async function pollCommunityUnread( community: Community, pubkey: string, + getProjection?: () => ReadStateProjection | null, ): Promise { return withReadOnlyRelayClient(community.relayUrl, (client) => - fetchCommunityUnread({ client, pubkey }), + fetchCommunityUnread({ client, pubkey, getProjection }), ); } @@ -159,16 +159,22 @@ export async function fetchCommunityUnread(args: { decryptReadState?: (ciphertext: string) => Promise; decryptMutes?: (ciphertext: string) => Promise; readThreadRelationships?: (pubkey: string) => ThreadRelationships; - readForcedUnread?: (pubkey: string) => ForcedUnreadMap; + /** Coherent manager projection for override evaluation. When provided and + * `loadComplete` is true, the projection's `overrides` and `frontiers` are + * the authoritative source — the fetched registers are still used for + * frontier-only readAt, but override liveness is decided solely from the + * projection (no per-field max join with fetched registers). + * + * When null or `loadComplete` is false, use fetched coordinate-deduped + * registers only — do not fall back to a partial stored view whose + * per-field merge can resurrect a superseded register. */ + getProjection?: () => ReadStateProjection | null; }): Promise { const { client, pubkey } = args; const normalizedPubkey = pubkey.toLowerCase(); - const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000); const decryptMutes = args.decryptMutes ?? nip44DecryptFromSelf; const readRelationships = args.readThreadRelationships ?? defaultReadThreadRelationships; - const readForcedUnread = - args.readForcedUnread ?? ((pk) => forcedUnreadStore.read(pk)); const channels = await fetchObservedChannels(client, pubkey); if (channels.length === 0) { @@ -176,11 +182,11 @@ export async function fetchCommunityUnread(args: { } const [readStateEvents, mutesEvents] = await Promise.all([ + // Override state is exempt from finite-horizon fetching: a register may be + // older than seven days and must not be missed. Tag-free, no `since` filter. client.fetchEvents({ kinds: [KIND_READ_STATE], authors: [pubkey], - "#t": ["read-state"], - since: nowSeconds - READ_STATE_HORIZON_SECONDS, limit: READ_STATE_FETCH_LIMIT, }), client.fetchEvents({ @@ -191,12 +197,69 @@ export async function fetchCommunityUnread(args: { }), ]); - const readState = await mergeReadStateEvents( - readStateEvents, + // Authoritative source (b): coordinate-deduped registers decoded from fetched + // events. deduplicateByCoordinate retains greatest created_at, lowest id. + const readState = await mergeReadStateEventsStructured( + deduplicateByCoordinate(readStateEvents), pubkey, args.decryptReadState, ); + // Override liveness source: the manager projection when complete, otherwise + // the fetched state only. Never per-field max the two — a projection register + // and a fetched register for the same coordinate are NOT concurrent CRDT + // replicas; the fetched register may be a newer tombstone superseding a stale + // projection entry, and joining them per-field would resurrect the dead one. + // + // When the projection is complete (loadComplete=true) it is the most + // authoritative source because it includes committed local override actions. + // Use projection.overrides as the sole override verdict source. For frontiers, + // take max(projection, fetched) per channel so a locally-committed frontier + // advance is honoured even if the relay hasn't seen it yet. + // + // When no complete projection is available, use fetched-deduped state alone. + const projection = args.getProjection?.() ?? null; + const completeProjection = projection?.loadComplete ? projection : null; + + // The observer must not adjudicate cross-source ordering it cannot see. + // The manager owns event-level coordinate dedupe and CRDT ingest; a fetched + // relay snapshot carries no coordinate/version provenance that would let the + // UI compare it against a locally-committed projection register. + // + // Therefore: when a complete projection is available, projection.overrides is + // the SOLE override-liveness authority — no exceptions for fetched tombstone + // shapes. A transient false-positive (e.g. projection live register vs stale + // fetched tombstone) resolves when the manager ingests the tombstone through + // its own CRDT path. A categorical tombstone-wins rule would produce the + // inverse false-negative: hiding a user's fresh local mark-unread until the + // relay publish catches up, which is the more harmful failure. + const authoritative: ReadonlyMap = + completeProjection !== null + ? completeProjection.overrides + : readState.overrides; + + // Effective frontier: max of fetched and (when available) projection frontier. + const effectiveFrontier = (channelId: string): number | null => { + const fetched = readState.frontiers.get(channelId) ?? null; + if (completeProjection === null) return fetched; + const projected = completeProjection.frontiers.get(channelId) ?? null; + if (fetched === null) return projected; + if (projected === null) return fetched; + return Math.max(fetched, projected); + }; + + // Build a unified frontier map (fetched + projection max) for per-event + // msg/thread context lookups inside isUnreadExternalEvent. + const effectiveFrontierMap: ReadonlyMap = (() => { + if (completeProjection === null) return readState.frontiers; + const merged = new Map(readState.frontiers); + for (const [ctx, pf] of completeProjection.frontiers) { + const existing = merged.get(ctx); + merged.set(ctx, existing !== undefined ? Math.max(existing, pf) : pf); + } + return merged; + })(); + let mutedIds = new Set(); if (mutesEvents.length > 0) { try { @@ -217,32 +280,21 @@ export async function fetchCommunityUnread(args: { mutedRootIds, } = readRelationships(normalizedPubkey); - // Channels manually marked unread on this device. Stored as a record of - // { channelId: markerAtWhenForced } so the observer can gate the dot on - // whether a cross-device read has since advanced past the stored baseline. - const forcedUnreadMap = readForcedUnread(normalizedPubkey); - let hasUnread = false; let mentionCount = 0; for (const channel of channels) { if (mutedIds.has(channel.id)) continue; - // Compute readAt first so the forced-unread gate can compare against it. - const readAt = readState.get(channel.id) ?? null; + // Compute readAt from effective frontiers (max of fetched + projection). + const readAt = effectiveFrontier(channel.id); - // Forced-unread lights the dot without a relay fetch, but only if the - // synced read marker has NOT advanced past the stored baseline. This - // prevents stale forced-unread from lighting the rail after a cross-device - // read has covered the channel (the drain path in useUnreadChannels only - // runs while the community is active, so the store may not be pruned for - // inactive communities). - if (!hasUnread && Object.hasOwn(forcedUnreadMap, channel.id)) { - const markerAtWhenForced = forcedUnreadMap[channel.id]; - if ( - readAt === null || - (markerAtWhenForced !== null && readAt <= markerAtWhenForced) - ) { + // Override liveness: evaluate the authoritative register against the + // effective frontier. Uses projection.overrides when complete (no + // per-field max with fetched); fetched-deduped otherwise. + if (!hasUnread) { + const reg = authoritative.get(channel.id); + if (reg !== undefined && isOverrideActive(reg, readAt ?? 0)) { hasUnread = true; } } @@ -274,7 +326,12 @@ export async function fetchCommunityUnread(args: { if (!hasUnread) { hasUnread = unreadEvents.some( (event) => - isUnreadExternalEvent(event, readState, readAt, normalizedPubkey) && + isUnreadExternalEvent( + event, + effectiveFrontierMap, + readAt, + normalizedPubkey, + ) && shouldNotifyForEvent(event, normalizedPubkey, { participatedRootIds, followedRootIds, @@ -287,7 +344,12 @@ export async function fetchCommunityUnread(args: { } mentionCount += mentionEvents.filter((event) => - isUnreadExternalEvent(event, readState, readAt, normalizedPubkey), + isUnreadExternalEvent( + event, + effectiveFrontierMap, + readAt, + normalizedPubkey, + ), ).length; } diff --git a/desktop/src/features/communities/useCommunityUnread.ts b/desktop/src/features/communities/useCommunityUnread.ts index 851c77c955..9985e1e887 100644 --- a/desktop/src/features/communities/useCommunityUnread.ts +++ b/desktop/src/features/communities/useCommunityUnread.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { markCommunityRead } from "@/features/communities/communityMarkRead"; import { pollCommunityUnread } from "@/features/communities/communityUnreadObserver"; +import { useAppShell } from "@/app/AppShellContext"; import type { Community } from "./types"; @@ -54,6 +55,7 @@ export function useCommunityUnread( unreadByCommunity: Record; markCommunityRead: (communityId: string) => Promise; } { + const { getProjection } = useAppShell(); const [unreadByCommunity, setUnreadByCommunity] = React.useState< Record >(() => seedCommunityStates(communities, {})); @@ -131,7 +133,11 @@ export function useCommunityUnread( if (cancelled) return; markLoading(community.id); try { - const result = await pollCommunityUnread(community, pubkey); + const result = await pollCommunityUnread( + community, + pubkey, + getProjection, + ); if (cancelled) return; markReady(community.id, result); } catch (error) { @@ -155,7 +161,7 @@ export function useCommunityUnread( window.clearTimeout(pollTimer); } }; - }, [activeCommunityId, communities]); + }, [activeCommunityId, communities, getProjection]); const communitiesRef = React.useRef(communities); communitiesRef.current = communities; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 53d541ff0f..57afb425e6 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -14,7 +14,9 @@ import { } from "@/shared/constants/kinds"; import { getTextPayload, + createFencedSubscription, type ConnectionState, + type FenceHandle, type PendingEvent, type RelaySubscription, type RelaySubscriptionFilter, @@ -145,7 +147,10 @@ export class RelayClient { } for (const [subId, sub] of this.subscriptions) { - if (sub.mode !== "live") { + if (sub.mode === "fenced") { + sub.lapsed = true; + sub.resolveEstablished(); + } else if (sub.mode !== "live") { window.clearTimeout(sub.timeout); sub.reject(error); } else { @@ -413,6 +418,25 @@ export class RelayClient { return this.subscribe(filter, onEvent); } + /** @see `createFencedSubscription` in relayClientShared for contract. */ + async subscribeFenced( + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, + ): Promise { + await this.ensureConnected(); + const deps = { + connectionGeneration: () => this.connectionGeneration, + subscriptions: this.subscriptions, + sendReq: (id: string, f: RelaySubscriptionFilter) => + this.sendRawWithReconnectRetry( + ["REQ", id, f], + "Failed to establish fenced subscription.", + ), + closeSub: (id: string) => this.closeSubscription(id), + }; + return createFencedSubscription(deps, filter, onEvent); + } + async subscribeToChannelMentionEvents( channelId: string, pubkey: string, @@ -425,10 +449,7 @@ export class RelayClient { } async preconnect() { - // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. A manual - // reconnect also bypasses the current delay once; ordinary operations do - // not, so background traffic cannot continuously defeat backoff. + // Re-engage after terminal state; bypasses current delay once. this.terminal = false; this.keepAliveRequested = true; if (this.reconnectTimeout !== null) { @@ -448,33 +469,25 @@ export class RelayClient { subscribeToReconnects(listener: () => void) { this.reconnectListeners.add(listener); - return () => { this.reconnectListeners.delete(listener); }; } - + getConnectionGeneration(): number { + return this.connectionGeneration; + } /** Current connection state — synchronous read. */ getConnectionState(): ConnectionState { return this.connectionStateEmitter.get(); } - - /** - * Subscribe to connection-state transitions. The listener is invoked - * immediately with the current state so callers don't need a separate - * `getConnectionState()` call to seed their UI. - */ + /** Subscribe to connection-state transitions; listener fires immediately with current state. */ subscribeToConnectionState(listener: (state: ConnectionState) => void) { return this.connectionStateEmitter.subscribe(listener); } private async ensureConnected() { if (shouldRefuseConnect({ terminal: this.terminal })) { - // Session is terminal (e.g. relay rejected auth). Refuse to connect - // until an explicit re-engagement (disconnect()/preconnect()) clears - // the flag. Without this, the reconnect timer's catch handler — and - // the retry wrappers in publishEvent / sendRawWithReconnectRetry — - // would race the terminal "disconnected" state back to "reconnecting". + // Terminal after auth rejection — refuse until preconnect() re-engages. throw new Error("Relay session is terminal; cannot reconnect."); } @@ -491,9 +504,7 @@ export class RelayClient { hasPendingReconnect: this.reconnectTimeout !== null, }) ) { - // The reconnect coordinator owns outage pacing. Query, publish, and - // subscription callers must wait for its scheduled attempt instead of - // clearing the timer and creating an immediate reconnect storm. + // Reconnect coordinator owns pacing; wait rather than creating a storm. return this.waitForScheduledReconnect(); } @@ -633,23 +644,16 @@ export class RelayClient { } private async sendRaw(payload: unknown[]) { - if (this.wsId === null) { - throw new Error("Relay socket is not connected."); - } - + if (this.wsId === null) throw new Error("Relay socket is not connected."); await invoke("plugin:websocket|send", { id: this.wsId, - message: { - type: "Text", - data: JSON.stringify(payload), - }, + message: { type: "Text", data: JSON.stringify(payload) }, }); } private normalizeRelayError(error: unknown, fallbackMessage: string) { return error instanceof Error ? error : new Error(fallbackMessage); } - private recoverFromSocketFailure( error: unknown, fallbackMessage: string, @@ -670,7 +674,6 @@ export class RelayClient { error, fallbackMessage, ); - try { await this.ensureConnected(); await this.sendRaw(payload); @@ -684,11 +687,7 @@ export class RelayClient { } private async closeSubscription(subId: string) { - if (this.wsId === null) { - return; - } - - await this.sendRaw(["CLOSE", subId]); + if (this.wsId !== null) await this.sendRaw(["CLOSE", subId]); } async publishEvent( @@ -819,8 +818,7 @@ export class RelayClient { if (type === "NOTICE" && typeof rest[0] === "string") { const notice: string = rest[0]; - // Relay back-pressure signal — activate the gate so pending operations - // back off until the window expires. + // Relay rate-limit notice — activate backoff gate. if (notice.startsWith("rate-limited:")) { activateRateLimit(parseRateLimitHint(notice)); } @@ -968,9 +966,7 @@ export class RelayClient { return; } - // Apply ±25% jitter so a fleet of clients reconnecting simultaneously - // spreads their AUTH storms across a 50% window instead of all hitting - // the relay at the same instant. + // Apply ±25% jitter to spread reconnect storms. const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5); const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS); this.reconnectDelayMs = Math.min( @@ -1011,12 +1007,7 @@ export class RelayClient { } } - private resetConnection( - error: Error, - options?: { - reconnect?: boolean; - }, - ) { + private resetConnection(error: Error, options?: { reconnect?: boolean }) { this.onMessageChannel = null; this.stallWatchdog.stop(); this.connectionGeneration++; @@ -1062,6 +1053,12 @@ export class RelayClient { } for (const [subId, subscription] of this.subscriptions) { + if (subscription.mode === "fenced") { + subscription.lapsed = true; + subscription.resolveEstablished(); + this.subscriptions.delete(subId); + continue; + } if (subscription.mode !== "live") { window.clearTimeout(subscription.timeout); subscription.reject(error); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 9108f7b6d7..dec016bb74 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -64,6 +64,173 @@ type LiveSubscription = { closedRetryTimeout?: number; }; +/** + * Fenced subscription for NIP-RS full-state loads. + * + * Unlike `live`, a fenced subscription: + * - Resolves `established` ONLY on this subscription's own EOSE — never via + * a fallback timer or terminal CLOSED. + * - Delivers events synchronously (bypasses the EVENT_BATCH_MS buffer) so + * the loader's synchronous drain barrier needs no timer. + * - On CLOSED or any connection lapse, sets `lapsed = true` and resolves + * `established` (in case the loader is still awaiting it), then removes + * itself without retrying. + */ +export type FencedSubscription = { + mode: "fenced"; + filter: RelaySubscriptionFilter; + onEvent: (event: RelayEvent) => void; + /** Connection generation at subscribe time — mismatch = lapsed. */ + generation: number; + /** Resolves on EOSE or lapse (check `lapsed` after awaiting). */ + resolveEstablished: () => void; + /** True after CLOSED, reconnect, or generation change. */ + lapsed: boolean; +}; + +/** + * Handle returned by `RelayClient.subscribeFenced()`. + * + * - `established` resolves ONLY on the subscription's own EOSE, or resolves + * after setting `lapsed = true` if the connection lapses before EOSE. + * - `lapsed` is synchronously readable — check it after awaiting `established` + * and at any point during enumeration. + * - `unsubscribe()` closes the relay subscription when the load is complete. + */ +export type FenceHandle = { + /** Resolves on EOSE (check `lapsed` after awaiting). */ + established: Promise; + /** True if the connection lapsed before or after EOSE. */ + readonly lapsed: boolean; + /** Close the relay subscription. */ + unsubscribe(): Promise; +}; + +export function buildFenceHandle( + sub: FencedSubscription, + established: Promise, + unsubscribe: () => Promise, +): FenceHandle { + return { + established, + get lapsed() { + return sub.lapsed; + }, + unsubscribe, + }; +} + +/** + * Millis to wait for EOSE after the REQ is sent. If the relay keeps the + * socket alive but never sends this subscription's EOSE, the fence lapses and + * the load concludes `complete: false`. Reconnect / `retryLoad` owns recovery. + * Must NEVER count as establishment. + */ +export const FENCED_ESTABLISHMENT_TIMEOUT_MS = 30_000; + +/** + * Core of `RelayClient.subscribeFenced()` extracted for testability. + * + * Registers a fenced subscription, sends REQ, and returns a `FenceHandle` + * whose `established` resolves ONLY on EOSE — never on a fallback timer or + * CLOSED. Any lapse (generation mismatch, send failure, `resetConnection`, + * or establishment timeout) sets `lapsed = true` and resolves `established` + * so no caller suspends forever. + */ +export async function createFencedSubscription( + deps: { + connectionGeneration: () => number; + subscriptions: Map; + sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise; + closeSub: (subId: string) => Promise; + /** Override the establishment timeout (ms). Defaults to FENCED_ESTABLISHMENT_TIMEOUT_MS. */ + establishmentTimeoutMs?: number; + }, + filter: RelaySubscriptionFilter, + onEvent: (event: RelayEvent) => void, +): Promise { + const generation = deps.connectionGeneration(); + let resolveEstablished = () => {}; + const established = new Promise((r) => { + resolveEstablished = r; + }); + const subId = `fenced-${crypto.randomUUID()}`; + const fencedSub: FencedSubscription = { + mode: "fenced", + filter, + onEvent, + generation, + resolveEstablished, + lapsed: false, + }; + const lapseAndReturn = (): FenceHandle => { + fencedSub.lapsed = true; + resolveEstablished(); + deps.subscriptions.delete(subId); + return buildFenceHandle(fencedSub, established, async () => {}); + }; + if (deps.connectionGeneration() !== generation) return lapseAndReturn(); + deps.subscriptions.set(subId, fencedSub); + + // ── Establishment timeout ─────────────────────────────────────────────── + // Install the timeout and wrapped resolver BEFORE sending REQ so that EOSE + // delivered synchronously during sendReq() (e.g. from a fake or fast relay) + // cancels the timer correctly and never lapses an already-established fence. + // + // A relay that stays alive but never delivers this subscription's EOSE must + // not hang initialize() forever. The timeout lapses the fence exactly like + // a CLOSED or reconnect — it NEVER counts as establishment. + const timeoutMs = + deps.establishmentTimeoutMs ?? FENCED_ESTABLISHMENT_TIMEOUT_MS; + let establishmentTimer: ReturnType | null = null; + let alreadyEstablished = false; + + // Wrap resolveEstablished so the timer is cancelled on normal EOSE. + const originalResolve = fencedSub.resolveEstablished; + fencedSub.resolveEstablished = () => { + alreadyEstablished = true; + if (establishmentTimer !== null) { + clearTimeout(establishmentTimer); + establishmentTimer = null; + } + originalResolve(); + }; + // Also patch the closed reference so the promise resolves via the wrapper. + resolveEstablished = fencedSub.resolveEstablished; + + establishmentTimer = setTimeout(() => { + establishmentTimer = null; + // Only lapse if EOSE has not already established the fence. + if ( + !alreadyEstablished && + !fencedSub.lapsed && + deps.subscriptions.get(subId) === fencedSub + ) { + fencedSub.lapsed = true; + fencedSub.resolveEstablished(); + deps.subscriptions.delete(subId); + } + }, timeoutMs); + + try { + await deps.sendReq(subId, filter); + } catch { + return lapseAndReturn(); + } + if (deps.connectionGeneration() !== generation || fencedSub.lapsed) + return lapseAndReturn(); + + return buildFenceHandle(fencedSub, established, async () => { + if (establishmentTimer !== null) { + clearTimeout(establishmentTimer); + establishmentTimer = null; + } + if (deps.subscriptions.get(subId) !== fencedSub) return; + deps.subscriptions.delete(subId); + await deps.closeSub(subId); + }); +} + export type PendingEvent = { event: RelayEvent; resolve: (event: RelayEvent) => void; @@ -74,7 +241,8 @@ export type PendingEvent = { export type RelaySubscription = | HistorySubscription | FirstEventSubscription - | LiveSubscription; + | LiveSubscription + | FencedSubscription; export function sortEvents(events: RelayEvent[]) { return [...events].sort((left, right) => { diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index fd366671fd..edb360966c 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -36,6 +36,13 @@ export function handleRelayClosed({ const subscription = subscriptions.get(subId); if (!subscription) return; if (subscription.mode !== "live") { + if (subscription.mode === "fenced") { + // CLOSED lapses the fence — mark and resolve (unblocks any awaiter). + subscription.lapsed = true; + subscription.resolveEstablished(); + subscriptions.delete(subId); + return; + } // Classify before rejecting so a `rate-limited:` history CLOSED arms the // gate for concurrent ops. A history sub can't be retried (the caller holds // the promise), so we still reject immediately after arming. @@ -136,6 +143,11 @@ export function prepareSubscriptionEvent( if (subscription.mode === "first") { return false; } + if (subscription.mode === "fenced") { + // Fenced events are delivered synchronously — caller delivers directly. + subscription.onEvent(event); + return false; // handled here; do NOT enqueue in the batch buffer + } subscription.closedRetryAttempt = 0; clearClosedRetry(subscription); subscription.lastSeenCreatedAt = Math.max( @@ -156,6 +168,11 @@ export function handleSubscriptionEose({ }) { const subscription = subscriptions.get(subId); if (!subscription) return; + if (subscription.mode === "fenced") { + // EOSE proves this subscription's fence is established. + subscription.resolveEstablished(); + return; + } if (subscription.mode === "live") { subscription.resolveReady?.(); subscription.resolveReady = undefined;