diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index f506eb93f1f..7b084b898ae 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -4,34 +4,57 @@ import test from "node:test"; import { boundMuteStore, MAX_CHANNEL_MUTE_ENTRIES, - parseMutePayload, mergeStores, + parseMutePayload, mutedChannelIdsFromStore, } from "./channelMutesStorage.ts"; // ── parseMutePayload ────────────────────────────────────────────────────────── -test("parseMutePayload: valid payload with channels returns store", () => { +test("parseMutePayload: valid payload with channels returns store (rev preserved)", () => { const payload = { version: 1, channels: { - "chan-1": { muted: true, updatedAt: 1000 }, - "chan-2": { muted: false, updatedAt: 2000 }, + "chan-1": { muted: true, updatedAt: 1000, rev: 3 }, + "chan-2": { muted: false, updatedAt: 2000, rev: 0 }, }, }; - const result = parseMutePayload(payload); - assert.deepEqual(result, { + assert.deepEqual(parseMutePayload(payload), payload); +}); + +test("parseMutePayload: missing rev normalizes to 0 (old-build blob, entry kept)", () => { + const result = parseMutePayload({ + version: 1, + channels: { "chan-1": { muted: true, updatedAt: 1000 } }, + }); + assert.deepEqual(result.channels["chan-1"], { + muted: true, + updatedAt: 1000, + rev: 0, + }); +}); + +test("parseMutePayload: malformed rev (string / negative / non-integer / NaN) normalizes to 0", () => { + const result = parseMutePayload({ version: 1, channels: { - "chan-1": { muted: true, updatedAt: 1000 }, - "chan-2": { muted: false, updatedAt: 2000 }, + str: { muted: true, updatedAt: 1, rev: "5" }, + neg: { muted: true, updatedAt: 1, rev: -2 }, + frac: { muted: true, updatedAt: 1, rev: 1.5 }, + nan: { muted: true, updatedAt: 1, rev: NaN }, }, }); + for (const id of ["str", "neg", "frac", "nan"]) { + assert.equal(result.channels[id].rev, 0, `${id} rev normalized to 0`); + assert.equal(result.channels[id].muted, true, `${id} entry kept`); + } }); test("parseMutePayload: missing version returns null", () => { assert.equal( - parseMutePayload({ channels: { "chan-1": { muted: true, updatedAt: 1 } } }), + parseMutePayload({ + channels: { "chan-1": { muted: true, updatedAt: 1 } }, + }), null, ); }); @@ -46,18 +69,15 @@ test("parseMutePayload: wrong version returns null", () => { ); }); -test("parseMutePayload: null input returns null", () => { +test("parseMutePayload: null / non-object input returns null", () => { assert.equal(parseMutePayload(null), null); -}); - -test("parseMutePayload: non-object input returns null", () => { assert.equal(parseMutePayload("string"), null); assert.equal(parseMutePayload(42), null); assert.equal(parseMutePayload(true), null); }); test("parseMutePayload: malformed channel entries missing muted/updatedAt are filtered out", () => { - const payload = { + const result = parseMutePayload({ version: 1, channels: { "no-muted": { updatedAt: 1000 }, @@ -67,252 +87,251 @@ test("parseMutePayload: malformed channel entries missing muted/updatedAt are fi "updated-at-wrong-type": { muted: true, updatedAt: "now" }, null: null, }, - }; - const result = parseMutePayload(payload); + }); assert.deepEqual(result, { version: 1, - channels: { - valid: { muted: false, updatedAt: 500 }, - }, + channels: { valid: { muted: false, updatedAt: 500, rev: 0 } }, }); }); test("parseMutePayload: NaN/Infinity/negative updatedAt entries are filtered out", () => { - const payload = { + const result = parseMutePayload({ version: 1, channels: { nan: { muted: true, updatedAt: NaN }, inf: { muted: true, updatedAt: Infinity }, "neg-inf": { muted: true, updatedAt: -Infinity }, neg: { muted: true, updatedAt: -1 }, - valid: { muted: true, updatedAt: 100 }, + valid: { muted: true, updatedAt: 100, rev: 2 }, }, - }; - const result = parseMutePayload(payload); + }); assert.deepEqual(result, { version: 1, - channels: { valid: { muted: true, updatedAt: 100 } }, + channels: { valid: { muted: true, updatedAt: 100, rev: 2 } }, }); }); -test("parseMutePayload: empty channels returns store with empty channels", () => { - const result = parseMutePayload({ version: 1, channels: {} }); - assert.deepEqual(result, { version: 1, channels: {} }); +test("parseMutePayload: empty channels / no channels key returns empty store", () => { + assert.deepEqual(parseMutePayload({ version: 1, channels: {} }), { + version: 1, + channels: {}, + }); + assert.deepEqual(parseMutePayload({ version: 1 }), { + version: 1, + channels: {}, + }); }); -test("parseMutePayload: version 1 with no channels key returns store with empty channels", () => { - const result = parseMutePayload({ version: 1 }); - assert.deepEqual(result, { version: 1, channels: {} }); -}); +// ── mergeStores: tuple order (updatedAt → rev → value) ──────────────────────── -// ── mergeStores ─────────────────────────────────────────────────────────────── +const E = (muted, updatedAt, rev) => ({ muted, updatedAt, rev }); +const S = (entry) => ({ version: 1, channels: { c: entry } }); -test("mergeStores: non-overlapping channels returns union of both", () => { - const local = { - version: 1, - channels: { "chan-a": { muted: true, updatedAt: 100 } }, - }; - const remote = { - version: 1, - channels: { "chan-b": { muted: false, updatedAt: 200 } }, - }; - const result = mergeStores(local, remote); +test("mergeStores: non-overlapping channels returns union", () => { + const result = mergeStores( + { version: 1, channels: { a: E(true, 100, 1) } }, + { version: 1, channels: { b: E(false, 200, 1) } }, + ); assert.deepEqual(result, { version: 1, - channels: { - "chan-a": { muted: true, updatedAt: 100 }, - "chan-b": { muted: false, updatedAt: 200 }, - }, + channels: { a: E(true, 100, 1), b: E(false, 200, 1) }, }); }); -test("mergeStores: overlapping channel with remote newer takes remote", () => { - const local = { - version: 1, - channels: { "chan-a": { muted: false, updatedAt: 100 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { muted: true, updatedAt: 200 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 200 }); +test("mergeStores: strictly-later updatedAt wins regardless of rev (primary key)", () => { + // Later updatedAt with LOWER rev still wins — updatedAt is primary. This is + // the old-build interop case: an old build's rev-0 fresh edit beats a stale + // rev-bearing new-build entry. + const result = mergeStores(S(E(false, 200, 0)), S(E(true, 100, 7))); + assert.deepEqual(result.channels.c, E(false, 200, 0)); }); -test("mergeStores: overlapping channel with local newer takes local", () => { - const local = { - version: 1, - channels: { "chan-a": { muted: true, updatedAt: 300 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { muted: false, updatedAt: 100 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 300 }); +test("mergeStores: equal updatedAt → higher rev wins (same-second tiebreak)", () => { + const result = mergeStores(S(E(false, 100, 5)), S(E(true, 100, 2))); + assert.deepEqual(result.channels.c, E(false, 100, 5)); }); -test("mergeStores: overlapping channel with same updatedAt local wins", () => { - const local = { - version: 1, - channels: { "chan-a": { muted: true, updatedAt: 500 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { muted: false, updatedAt: 500 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { muted: true, updatedAt: 500 }); +test("mergeStores: equal updatedAt AND equal rev → muted=true wins (leaf)", () => { + const result = mergeStores(S(E(false, 100, 3)), S(E(true, 100, 3))); + assert.deepEqual(result.channels.c, E(true, 100, 3)); }); test("mergeStores: unmute with higher updatedAt overrides mute", () => { - const local = { - version: 1, - channels: { "chan-a": { muted: true, updatedAt: 100 } }, + const result = mergeStores(S(E(true, 100, 9)), S(E(false, 999, 1))); + assert.deepEqual(result.channels.c, E(false, 999, 1)); +}); + +test("mergeStores: empty local / empty remote / both empty", () => { + assert.deepEqual( + mergeStores({ version: 1, channels: {} }, S(E(true, 42, 1))).channels.c, + E(true, 42, 1), + ); + assert.deepEqual( + mergeStores(S(E(false, 10, 2)), { version: 1, channels: {} }).channels.c, + E(false, 10, 2), + ); + assert.deepEqual( + mergeStores({ version: 1, channels: {} }, { version: 1, channels: {} }), + { version: 1, channels: {} }, + ); +}); + +// ── mergeStores: algebra (commutativity, associativity, idempotence) ────────── + +function randEntry(rng) { + return { + muted: rng() > 0.5, + updatedAt: Math.floor(rng() * 5), + rev: Math.floor(rng() * 5), }; - const remote = { - version: 1, - channels: { "chan-a": { muted: false, updatedAt: 999 } }, +} +function randStore(rng, ids) { + const channels = {}; + for (const id of ids) if (rng() > 0.3) channels[id] = randEntry(rng); + return { version: 1, channels }; +} +// Deterministic LCG so failures reproduce. +function lcg(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { muted: false, updatedAt: 999 }); +} + +test("mergeStores: commutative — merge(a,b) === merge(b,a)", () => { + const rng = lcg(12345); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + assert.deepEqual(mergeStores(a, b), mergeStores(b, a)); + } +}); + +test("mergeStores: associative — merge(merge(a,b),c) === merge(a,merge(b,c))", () => { + const rng = lcg(67890); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const c = randStore(rng, ids); + assert.deepEqual( + mergeStores(mergeStores(a, b), c), + mergeStores(a, mergeStores(b, c)), + ); + } +}); + +test("mergeStores: idempotent — merge(a, merge(a,b)) === merge(a,b)", () => { + const rng = lcg(24680); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const ab = mergeStores(a, b); + assert.deepEqual(mergeStores(a, ab), ab); + assert.deepEqual(mergeStores(ab, ab), ab); + } }); -test("mergeStores: empty local returns remote entries", () => { - const local = { version: 1, channels: {} }; - const remote = { +// ── v1-blob bidirectional compatibility ─────────────────────────────────────── + +test("v1 compat: a rev-carrying blob round-trips through a rev-less parser view", () => { + // Simulate an old build reading our blob: JSON-serialize our rev-carrying + // payload, parse it back — version stays 1 so it is NOT rejected, and the + // core fields survive (old build simply ignores rev). + const ours = { version: 1, - channels: { "chan-b": { muted: true, updatedAt: 42 } }, + channels: { c: { muted: true, updatedAt: 100, rev: 7 } }, }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels, { - "chan-b": { muted: true, updatedAt: 42 }, - }); + const roundTripped = parseMutePayload(JSON.parse(JSON.stringify(ours))); + assert.equal(roundTripped.version, 1, "version stays 1 — old build accepts"); + assert.equal(roundTripped.channels.c.muted, true); + assert.equal(roundTripped.channels.c.updatedAt, 100); }); -test("mergeStores: empty remote returns local entries", () => { - const local = { +test("v1 compat: old-build unmute (no rev, updatedAt+1) beats our stale mute", () => { + // New build wrote {muted:true, rev:7, updatedAt:t}; old build (no rev) + // unmutes producing {muted:false, updatedAt:t+1}. The unmute wins on the + // primary updatedAt key — old builds can still edit upgraded channels. + const ours = S(E(true, 100, 7)); + const oldBuildUnmute = parseMutePayload({ version: 1, - channels: { "chan-a": { muted: false, updatedAt: 10 } }, - }; - const remote = { version: 1, channels: {} }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels, { - "chan-a": { muted: false, updatedAt: 10 }, + channels: { c: { muted: false, updatedAt: 101 } }, + }); + assert.deepEqual(mergeStores(ours, oldBuildUnmute).channels.c, { + muted: false, + updatedAt: 101, + rev: 0, }); }); -test("mergeStores: both empty returns empty", () => { - const result = mergeStores( - { version: 1, channels: {} }, - { version: 1, channels: {} }, - ); - assert.deepEqual(result, { version: 1, channels: {} }); -}); +// ── boundMuteStore ──────────────────────────────────────────────────────────── test("boundMuteStore: retains newest entries regardless of muted value", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ - `active-${index}`, - { muted: true, updatedAt: index + 1 }, + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), ]), ); - channels["old-false"] = { muted: false, updatedAt: 0 }; - channels["new-false"] = { muted: false, updatedAt: 9999 }; - + channels["old-false"] = E(false, 0, 0); + channels["new-false"] = E(false, 9999, 0); const result = boundMuteStore({ version: 1, channels }); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); assert.equal(result.channels["old-false"], undefined); - assert.deepEqual(result.channels["new-false"], { - muted: false, - updatedAt: 9999, - }); + assert.deepEqual(result.channels["new-false"], E(false, 9999, 0)); assert.equal(result.channels["active-0"], undefined); - assert.deepEqual(result.channels["active-1"], { muted: true, updatedAt: 2 }); }); test("boundMuteStore: uses channel ID as an updatedAt tie-breaker", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES + 1 }, (_, index) => [ - `channel-${String(MAX_CHANNEL_MUTE_ENTRIES - index).padStart(3, "0")}`, - { muted: true, updatedAt: 1 }, + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES + 1 }, (_, i) => [ + `channel-${String(MAX_CHANNEL_MUTE_ENTRIES - i).padStart(3, "0")}`, + E(true, 1, 0), ]), ); - const result = boundMuteStore({ version: 1, channels }); - assert.equal(result.channels["channel-000"], undefined); - assert.deepEqual(result.channels["channel-500"], { - muted: true, - updatedAt: 1, - }); + assert.deepEqual(result.channels["channel-500"], E(true, 1, 0)); }); -test("boundMuteStore: preserves a same-second mute mutation by key", () => { +test("boundMuteStore: preserves a same-second mutation by key", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ - `z-channel-${String(index).padStart(3, "0")}`, - { muted: true, updatedAt: 1 }, + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, i) => [ + `z-channel-${String(i).padStart(3, "0")}`, + E(true, 1, 0), ]), ); - channels["a-target"] = { muted: true, updatedAt: 1 }; - + channels["a-target"] = E(false, 1, 1); const result = boundMuteStore({ version: 1, channels }, "a-target"); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); - assert.deepEqual(result.channels["a-target"], { - muted: true, - updatedAt: 1, - }); - assert.equal(result.channels["z-channel-000"], undefined); -}); - -test("boundMuteStore: preserves a same-second unmute mutation by key", () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ - `z-channel-${String(index).padStart(3, "0")}`, - { muted: true, updatedAt: 1 }, - ]), - ); - channels["a-target"] = { muted: false, updatedAt: 1 }; - - const result = boundMuteStore({ version: 1, channels }, "a-target"); - - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); - assert.deepEqual(result.channels["a-target"], { - muted: false, - updatedAt: 1, - }); + assert.deepEqual(result.channels["a-target"], E(false, 1, 1)); assert.equal(result.channels["z-channel-000"], undefined); }); test("mergeStores: a fresh at-capacity unmute defeats an older remote mute", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ - `active-${index}`, - { muted: true, updatedAt: index + 1 }, + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), ]), ); - channels["unmuted"] = { muted: false, updatedAt: 9999 }; + channels.unmuted = E(false, 9999, 1); const bounded = boundMuteStore({ version: 1, channels }); - const result = mergeStores(bounded, { version: 1, - channels: { unmuted: { muted: true, updatedAt: 9998 } }, - }); - - assert.deepEqual(result.channels.unmuted, { - muted: false, - updatedAt: 9999, + channels: { unmuted: E(true, 9998, 5) }, }); + assert.deepEqual(result.channels.unmuted, E(false, 9999, 1)); }); test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { const localChannels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ - `active-${index}`, - { muted: true, updatedAt: index + 10 }, + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 10, 0), ]), ); const result = mergeStores( @@ -320,56 +339,109 @@ test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimme { version: 1, channels: { - "evicted-id": { muted: true, updatedAt: 9999 }, - "active-0": { muted: false, updatedAt: 9998 }, + "evicted-id": E(true, 9999, 0), + "active-0": E(false, 9998, 0), }, }, ); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); - assert.deepEqual(result.channels["evicted-id"], { - muted: true, - updatedAt: 9999, - }); - assert.deepEqual(result.channels["active-0"], { - muted: false, - updatedAt: 9998, - }); + assert.deepEqual(result.channels["evicted-id"], E(true, 9999, 0)); + assert.deepEqual(result.channels["active-0"], E(false, 9998, 0)); assert.equal(result.channels["active-1"], undefined); - assert.deepEqual(result.channels["active-2"], { muted: true, updatedAt: 12 }); }); -// ── mutedChannelIdsFromStore ────────────────────────────────────────────────── +// ── Eviction / remount (finding 3) ──────────────────────────────────────────── + +// Easy branch: X evicted at an OLDER second → remount (high-water lost) → click +// X at the current second → merge a remote carrying X at a high rev but an old +// updatedAt. The click's newer updatedAt wins on the primary key; the lost rev +// high-water is irrelevant. Closed by construction for all cross-second cases. +test("finding 3 easy branch: a fresh click beats an evicted high-rev entry at an older updatedAt", () => { + // Remount state: the user clicks X fresh at updatedAt=now, empty high-water + // (evicted), so rev mints to 1. + const click = S(E(true, 1000, 1)); + // The previously observed remote X sits at an OLD updatedAt with a high rev. + const remote = S(E(false, 500, 100)); + const merged = mergeStores(click, remote); + assert.deepEqual( + merged.channels.c, + E(true, 1000, 1), + "fresh click wins on the primary updatedAt key", + ); +}); -test("mutedChannelIdsFromStore: returns set of IDs where muted=true", () => { - const store = { - version: 1, - channels: { - "chan-a": { muted: true, updatedAt: 100 }, - "chan-b": { muted: true, updatedAt: 200 }, - "chan-c": { muted: false, updatedAt: 300 }, - }, - }; - const result = mutedChannelIdsFromStore(store); - assert.equal(result.has("chan-a"), true); - assert.equal(result.has("chan-b"), true); - assert.equal(result.has("chan-c"), false); - assert.equal(result.size, 2); +// Hard branch (Thufir's exact equal-second counterexample): >500 entries all at +// the CURRENT second → X evicted by the id tiebreak (not because it is old) → +// remount in the same second → click X at rev 1 (empty high-water) → merge the +// previously observed remote X at rev 100, EQUAL updatedAt. updatedAt ties, rev +// decides, 100 > 1 — the click LOSES. Documented deterministic residual, proven +// here as the hard branch (not disguised as safety). +test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)", () => { + const NOW = 777; + const TARGET = "aaa-target"; // lexicographically small → evicted by the id tiebreak + + // >500 entries all at the CURRENT second (NOW). With MAX+1 equal-updatedAt + // entries, boundMuteStore sorts ascending by (updatedAt, id) and keeps the + // highest MAX, so the lowest id is evicted — TARGET, NOT because it is old. + const channels = { [TARGET]: E(true, NOW, 7) }; + for (let i = 0; i < MAX_CHANNEL_MUTE_ENTRIES; i++) { + channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); + } + const bounded = boundMuteStore({ version: 1, channels }); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_MUTE_ENTRIES, + "bound trims to the cap", + ); + assert.equal( + bounded.channels[TARGET], + undefined, + "TARGET evicted by the id tiebreak at equal updatedAt", + ); + + // Remount in the same second: TARGET's rev high-water is gone with the entry, + // so a fresh click mints rev 1 at updatedAt=NOW. + const click = { version: 1, channels: { [TARGET]: E(true, NOW, 1) } }; + // The previously observed remote for TARGET at the same second, rev 100 (it + // may precede the remount — not genuinely concurrent). + const remote = { version: 1, channels: { [TARGET]: E(false, NOW, 100) } }; + + // equal updatedAt → rev decides → 100 > 1: the click LOSES. Documented + // deterministic residual, proven here through real eviction+remount, not a + // pre-shrunk tuple. Deterministic in both merge orders — a lost click, never + // a divergence. + assert.deepEqual( + mergeStores(click, remote).channels[TARGET], + E(false, NOW, 100), + "equal updatedAt → higher rev wins deterministically (documented residual)", + ); + assert.deepEqual( + mergeStores(remote, click).channels[TARGET], + E(false, NOW, 100), + ); }); -test("mutedChannelIdsFromStore: excludes IDs where muted=false", () => { - const store = { +// ── mutedChannelIdsFromStore ──────────────────────────────────────────────── + +test("mutedChannelIdsFromStore: returns set of IDs where muted=true", () => { + const result = mutedChannelIdsFromStore({ version: 1, channels: { - "chan-x": { muted: false, updatedAt: 1 }, - "chan-y": { muted: false, updatedAt: 2 }, + a: E(true, 100, 0), + b: E(true, 200, 0), + c: E(false, 300, 0), }, - }; - const result = mutedChannelIdsFromStore(store); - assert.equal(result.size, 0); + }); + assert.deepEqual([...result].sort(), ["a", "b"]); }); -test("mutedChannelIdsFromStore: empty channels returns empty set", () => { - const result = mutedChannelIdsFromStore({ version: 1, channels: {} }); - assert.equal(result.size, 0); +test("mutedChannelIdsFromStore: all-false / empty returns empty set", () => { + assert.equal( + mutedChannelIdsFromStore({ + version: 1, + channels: { x: E(false, 1, 0) }, + }).size, + 0, + ); + assert.equal(mutedChannelIdsFromStore({ version: 1, channels: {} }).size, 0); }); diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts index 1bf315d268d..0653e3f1871 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.ts +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts @@ -1,9 +1,22 @@ +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + clearOwnOutbox, + enumerateOutbox, + reclaimOutbox, + writeOwnOutbox, +} from "./sidebarSyncWatermark"; + const STORAGE_KEY_PREFIX = "buzz-channel-mutes.v1"; export const MAX_CHANNEL_MUTE_ENTRIES = 500; export type ChannelMuteEntry = { muted: boolean; updatedAt: number; + // Per-channel Lamport revision. Breaks a same-second `updatedAt` tie that the + // integer clock cannot resolve. Absent in blobs from an older build ⇒ read as + // 0 (a valid, mergeable value), so the payload stays `version: 1` and older + // builds still parse our blobs. + rev: number; }; export type ChannelMuteStore = { @@ -29,8 +42,8 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null { obj.channels !== null && !Array.isArray(obj.channels) ? Object.fromEntries( - Object.entries(obj.channels as Record).filter( - (entry): entry is [string, ChannelMuteEntry] => { + Object.entries(obj.channels as Record) + .filter((entry): entry is [string, Record] => { const v = entry[1]; return ( typeof v === "object" && @@ -42,8 +55,27 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null { ) && ((v as Record).updatedAt as number) >= 0 ); - }, - ), + }) + // Normalize `rev`: accept a non-negative integer, otherwise 0. An + // entry is never dropped solely because `rev` is absent (older + // build) or malformed — absence is a valid mergeable value. + .map(([id, v]) => { + const rawRev = v.rev; + const rev = + typeof rawRev === "number" && + Number.isInteger(rawRev) && + rawRev >= 0 + ? rawRev + : 0; + return [ + id, + { + muted: v.muted as boolean, + updatedAt: v.updatedAt as number, + rev, + }, + ]; + }), ) : {}; return boundMuteStore({ version: 1, channels }); @@ -93,40 +125,74 @@ export function boundMuteStore( }; } +/** + * Persist the main store. Writes the passed store as-is (bounded) — no read of + * the shared key, so it is never a shared-key read-modify-write. Callers merge + * peer state into the window's OWN React state (via the storage-event handler + * and applyRemote) before calling here, so the write carries an owned, merged + * value. Returns the bounded store, or `null` on write failure. + * + * Cross-window convergence of the on-disk cache is eventual: a peer's storage + * event folds into this window's state, and the relay reconcile writes the + * merged head back. Durable no-loss of an unpublished click is held by the + * per-window outbox, not this cache. + */ export function writeChannelMutesStore( pubkey: string, store: ChannelMuteStore, -): boolean { + preservedKey?: string, +): ChannelMuteStore | null { try { - window.localStorage.setItem( - storageKey(pubkey), - JSON.stringify(boundMuteStore(store)), - ); - return true; + const bounded = boundMuteStore(store, preservedKey); + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(bounded)); + return bounded; } catch { - return false; + return null; } } +/** + * Merge two mute stores by a per-channel total order: + * `updatedAt` DESC → `rev` DESC → `muted === true` wins. This order is + * commutative, associative, and idempotent (before bounding), so every + * observation path (bootstrap, live, reconnect, reconcile, pre-publish, + * cross-window storage) applies it with no ordering or ownership overlay and + * all replicas converge. + * + * `updatedAt` is primary so a strictly-later edit — from any build, whether it + * carries `rev` or (older build) reads `rev: 0` — wins outright. `rev` breaks + * only a same-second `updatedAt` tie: the ambiguous integer-second window the + * clock cannot resolve, where a click that minted `rev = maxSeen + 1` dominates + * any same-second state it observed. On a full tie (equal `updatedAt` AND equal + * `rev`) `true` wins as the deterministic leaf. + */ export function mergeStores( - local: ChannelMuteStore, - remote: ChannelMuteStore, + a: ChannelMuteStore, + b: ChannelMuteStore, + preservedKey?: string, ): ChannelMuteStore { const allIds = new Set([ - ...Object.keys(local.channels), - ...Object.keys(remote.channels), + ...Object.keys(a.channels), + ...Object.keys(b.channels), ]); const merged: Record = {}; for (const id of allIds) { - const l = local.channels[id]; - const r = remote.channels[id]; - if (l && r) { - merged[id] = l.updatedAt >= r.updatedAt ? l : r; - } else { - merged[id] = (l ?? r) as ChannelMuteEntry; - } + const l = a.channels[id]; + const r = b.channels[id]; + merged[id] = l && r ? pickMuteEntry(l, r) : ((l ?? r) as ChannelMuteEntry); } - return boundMuteStore({ version: 1, channels: merged }); + return boundMuteStore({ version: 1, channels: merged }, preservedKey); +} + +/** The winner of two entries under `updatedAt` → `rev` → `muted` order. */ +function pickMuteEntry( + l: ChannelMuteEntry, + r: ChannelMuteEntry, +): ChannelMuteEntry { + if (l.updatedAt !== r.updatedAt) return l.updatedAt > r.updatedAt ? l : r; + if (l.rev !== r.rev) return l.rev > r.rev ? l : r; + if (l.muted !== r.muted) return l.muted ? l : r; + return l; } export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set { @@ -136,3 +202,112 @@ export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set { .map(([id]) => id), ); } + +const OUTBOX_KEY_PREFIX = "buzz-channel-mutes-outbox.v1"; + +// The single shared key written by builds before the outbox was keyed +// per-window. Enumerated as one more record so an edit persisted by a prior +// build still resumes, and reclaimed by the same relay-gated rule. +function legacyOutboxKey(pubkey: string, relayUrl: string): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** + * Persist this window's unpublished edit under its own outbox key. Written + * synchronously on every click as a single unconditional `setItem` (no shared- + * key read-modify-write); resumed by merging every window's record on next + * mount so a click made <2s before quit/community-switch is never dropped. + */ +export function writeChannelMutesOutbox( + pubkey: string, + store: ChannelMuteStore, + relayUrl: string, +): void { + writeOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl, boundMuteStore(store)); +} + +/** + * Merge every window's persisted unpublished edit into one store for resume, or + * null when none exists. Per-entry `mergeStores` is order-independent, so two + * windows' concurrent clicks on different channels both survive. + */ +export function readChannelMutesOutbox( + pubkey: string, + relayUrl: string, +): ChannelMuteStore | null { + const records = enumerateOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseMutePayload, + ); + if (records.length === 0) return null; + return records.reduce( + (acc, r) => mergeStores(acc, r.store), + DEFAULT_STORE, + ); +} + +/** Clear this window's own outbox key (its edit published or is a no-op). */ +export function clearChannelMutesOutbox( + pubkey: string, + relayUrl: string, +): void { + clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl); +} + +/** + * True when the fetched relay `head` already reflects every entry in + * `candidate` — merging the candidate into the head leaves it unchanged. Used + * both to reclaim a subsumed foreign key and to skip a redundant boot-time + * replay publish of a fold the head already carries (e.g. only the + * never-deleted legacy key lingers). + */ +export function isMutesStoreSubsumedBy( + candidate: ChannelMuteStore, + head: ChannelMuteStore, +): boolean { + return muteStoresEqual(mergeStores(head, candidate), head); +} + +/** + * Reclaim foreign outbox keys the fetched relay head already subsumes: a record + * is redundant when merging it into `head` yields `head` unchanged (the head + * carries an entry at least as new for every channel). Never touches this + * window's own key; a still-unpublished peer edit the head does not yet reflect + * is kept. Call only after a successful head fetch. + */ +export function reclaimSubsumedMutesOutbox( + pubkey: string, + relayUrl: string, + head: ChannelMuteStore, +): void { + reclaimOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseMutePayload, + (record) => isMutesStoreSubsumedBy(record.store, head), + ); +} + +/** Deep per-channel equality of two mute stores (order-independent). */ +function muteStoresEqual(a: ChannelMuteStore, b: ChannelMuteStore): boolean { + const aKeys = Object.keys(a.channels); + const bKeys = Object.keys(b.channels); + if (aKeys.length !== bKeys.length) return false; + for (const id of aKeys) { + const l = a.channels[id]; + const r = b.channels[id]; + if ( + !r || + l.muted !== r.muted || + l.updatedAt !== r.updatedAt || + l.rev !== r.rev + ) + return false; + } + return true; +} diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs index 845e5a5accc..be28fe77129 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; +import { readChannelMutesOutbox } from "./channelMutesStorage.ts"; import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; import { - makeFakeWindow, installFakeWindow, + installTauriMock, + makeFakeWindow, } from "./sidebarSyncTestHelpers.mjs"; const RELAY = "wss://r.test"; @@ -14,12 +16,74 @@ const RELAY_KEY = encodeURIComponent(RELAY); function makeStore(channels = {}) { return { version: 1, channels }; } +const E = (muted, updatedAt, rev) => ({ muted, updatedAt, rev }); + +// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors +// the sections suite convention (channelSectionsSync.test.mjs:407-432). +function makeMultiTimerWindow() { + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const win = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + return { + win, + storage, + timers, + fireDelay: async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + }, + hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), + }; +} + +// ─── observe() / high-water ingestion ───────────────────────────────────────── + +test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const m = new ChannelMuteSyncManager("pk", RELAY); + m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) })); + assert.equal(m.maxRevSeen("a"), 3); + assert.equal(m.maxUpdatedAtSeen("a"), 100); + // A later observation raises each dimension independently; a lower one + // never regresses either. + m.observe(makeStore({ a: E(true, 90, 5) })); + assert.equal(m.maxRevSeen("a"), 5, "rev raised"); + assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); + m.observe(makeStore({ a: E(true, 200, 2) })); + assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); + assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); + // Unseen channel reports zero on both dimensions. + assert.equal(m.maxRevSeen("never"), 0); + assert.equal(m.maxUpdatedAtSeen("never"), 0); + } finally { + restore(); + } +}); // ─── destroy() must cancel pending publish, not flush ───────────────────────── -// Regression guard for the community-switch cross-relay publish vector: -// mute a channel in relay A → destroy() called (relayUrl dep change) → -// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -31,9 +95,9 @@ test("destroy: cancels pending publish without flushing to the relay", () => { const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-test", RELAY); - manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); manager.destroy(); - assert.equal(publishCalls.length, 0); + assert.equal(publishCalls.length, 0, "no publish after destroy"); assert.equal(manager.getPendingMuteStore(), null); } finally { restore(); @@ -60,12 +124,16 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-race", RELAY); - manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); fw._fireTimer(); manager.destroy(); releaseFetch(); await new Promise((r) => setTimeout(r, 0)); - assert.equal(publishCalls.length, 0); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); } finally { restore(); mock.reset(); @@ -83,9 +151,183 @@ test("destroy: is safe to call with no pending publish", () => { } }); +// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ────── + +// Finding 2 (A succeeds): an older in-flight publish that completes after a +// newer edit is queued must NOT clear the newer edit's pending store/outbox, +// and B must reach the relay via the completion re-drive. Mutation: dropping the +// generation CAS in discardPending lets A's success null out B's pending+outbox. +test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => { + let releaseFirst = null; + const publishedContents = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + if (releaseFirst === null && publishedContents.length === 0) { + return new Promise((res) => { + releaseFirst = res; + }); + } + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelMuteSyncManager("pk-ab", RELAY); + const storeA = makeStore({ a: E(true, 100, 1) }); + const storeB = makeStore({ b: E(true, 101, 1) }); + + manager.publishMutes(storeA); + await t.fireDelay(2000); // doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + // B arrives while A is in flight. + manager.publishMutes(storeB); + assert.deepEqual( + Object.keys(manager.getPendingMuteStore().channels), + ["b"], + "B is now pending", + ); + assert.ok(readChannelMutesOutbox("pk-ab", RELAY), "outbox holds B"); + + // A completes — must NOT clear B. + releaseFirst(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.deepEqual( + Object.keys(manager.getPendingMuteStore()?.channels ?? {}), + ["b"], + "older A completion leaves B pending", + ); + assert.ok( + readChannelMutesOutbox("pk-ab", RELAY), + "older A completion leaves B outbox", + ); + + // B's own debounce fires and B reaches the relay (published) with no kick. + const capturedBefore = tauri.capturedPlaintext(); + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok( + captured && captured !== capturedBefore && captured.includes('"b"'), + "B is published to the relay", + ); + assert.equal( + manager.getPendingMuteStore(), + null, + "B cleared after publish", + ); + assert.equal( + readChannelMutesOutbox("pk-ab", RELAY), + null, + "B outbox cleared", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// Finding 2 (A fails): A's publish rejects after B is queued. B must remain +// pending and be published by the serialized re-drive / retry — no manual kick. +test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => { + let rejectFirst = null; + let publishCount = 0; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + publishCount++; + if (publishCount === 1) { + return new Promise((_res, rej) => { + rejectFirst = () => rej(new Error("socket error")); + }); + } + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelMuteSyncManager("pk-abfail", RELAY); + manager.publishMutes(makeStore({ a: E(true, 100, 1) })); + await t.fireDelay(2000); + while (rejectFirst === null) await Promise.resolve(); + + manager.publishMutes(makeStore({ b: E(true, 101, 1) })); + rejectFirst(); // A fails + for (let i = 0; i < 50; i++) await Promise.resolve(); + + assert.deepEqual( + Object.keys(manager.getPendingMuteStore()?.channels ?? {}), + ["b"], + "B still pending after A's failure", + ); + assert.ok( + readChannelMutesOutbox("pk-abfail", RELAY), + "B outbox intact after A's failure", + ); + + // B's debounce fires and B publishes successfully. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok(captured?.includes('"b"'), "B published"); + assert.equal(manager.getPendingMuteStore(), null, "B cleared"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─ + +// Finding 2: a transient publish failure with the socket open and NO further +// click must self-heal via the bounded-backoff retry — the pending edit is kept +// and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the +// edit stranded (Will's "make another change to kick it" symptom). +test("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => { + let publishCount = 0; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + publishCount++; + if (publishCount === 1) return Promise.reject(new Error("timeout")); + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelMuteSyncManager("pk-retry", RELAY); + manager.publishMutes(makeStore({ a: E(true, 100, 1) })); + await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects + assert.ok( + manager.getPendingMuteStore() !== null, + "pending edit retained after failure", + ); + assert.ok(t.hasDelay(2000), "a retry timer at RETRY_BASE_MS is scheduled"); + + // The retry fires and the second publish succeeds → pending cleared. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.equal(publishCount, 2, "retry re-published"); + assert.equal( + manager.getPendingMuteStore(), + null, + "pending cleared on retry success", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + // ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── -// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -95,9 +337,7 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap( - makeStore({ ch1: { muted: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingMuteStore(), null); } finally { @@ -106,7 +346,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -118,16 +357,7 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap( - makeStore({ ch1: { muted: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingMuteStore(), null); } finally { @@ -136,7 +366,6 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot } }); -// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -144,15 +373,7 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`, - ), - null, - ); - const result = await manager.bootstrap( - makeStore({ ch1: { muted: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.ok(manager.getPendingMuteStore() !== null); } finally { @@ -161,8 +382,6 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. relay-A / relay-B watermark isolation -// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { const relayA = "wss://a.relay.test"; const relayB = "wss://b.relay.test"; @@ -176,16 +395,7 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B const restore = installFakeWindow(fw); try { const managerB = new ChannelMuteSyncManager("pk-iso", relayB); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`, - ), - null, - "relay B watermark must be independent of relay A head", - ); - const result = await managerB.bootstrap( - makeStore({ ch1: { muted: true, updatedAt: 1 } }), - ); + const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.ok( managerB.getPendingMuteStore() !== null, @@ -196,3 +406,50 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B mock.reset(); } }); + +// ─── Timestamp clamp (Carl P2): a far-future remote head must not push our +// published createdAt past the relay's ±15min future-drift window. +// Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), +// which exceeds now + MAX_PUBLISH_FUTURE_SECS. +test("timestamp clamp: published createdAt stays inside the relay future window", async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + let call = 0; + mock.method(relayClient, "fetchEvents", () => { + call++; + // First call primes lastRemoteCreatedAt to farFutureHead (undecryptable so + // it does not merge into the store); pre-publish call returns created_at=0. + return Promise.resolve([ + { + pubkey: "pk-clamp", + content: "bad-cipher", + created_at: call === 1 ? farFutureHead : 0, + id: "evt-clamp", + }, + ]); + }); + let signedCreatedAt = null; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(JSON.stringify({ version: 1, channels: {} })); + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new ChannelMuteSyncManager("pk-clamp", RELAY); + await manager.fetchRemoteMutes(); // prime lastRemoteCreatedAt + manager.publishMutes(makeStore({ ch1: E(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); + assert.ok( + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 5e8a17e74d9..cc8b47be48a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -7,12 +7,15 @@ import { import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_MUTES } from "@/shared/constants/kinds"; import { + clearChannelMutesOutbox, mergeStores, parseMutePayload, + writeChannelMutesOutbox, type ChannelMuteStore, } from "./channelMutesStorage"; import { advanceWatermark, + clampPublishCreatedAt, readWatermark, runBootstrap, type FetchResult, @@ -22,6 +25,12 @@ const D_TAG = "channel-mutes"; const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; +// Bounded backoff for a retained pending edit whose publish failed transiently +// (timeout / socket error) on an otherwise-healthy socket, so it does not wait +// for a reconnect that may never fire. +const RETRY_BASE_MS = 2_000; +const RETRY_MAX_MS = 30_000; + export type RemoteMutes = { store: ChannelMuteStore; createdAt: number; @@ -43,10 +52,30 @@ export class ChannelMuteSyncManager { private pubkey: string; private relayUrl: string; private debounceTimer: number | null = null; + private retryTimer: number | null = null; + private retryDelayMs = RETRY_BASE_MS; private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; + // Monotonic id for the current pending edit. Every publishMutes() bumps it; + // every scheduled publish/retry captures the value it was queued for. A + // completion (success or no-op) may only clear pending state via + // compare-and-swap on this generation, so an older in-flight publish can + // never erase a newer edit that arrived while it was in flight. + private pendingGeneration = 0; + // Publish cycles are serialized: at most one runs at a time. A newer edit + // queued while a cycle is in flight defers; the in-flight cycle's completion + // re-drives it. Serialization guarantees there is never more than one + // fetch/publish sequence touching shared manager state. + private publishInFlight = false; private lastPublishedStore: ChannelMuteStore | null = null; private destroyed = false; + // Per-channel high-water of every `rev` and `updatedAt` this manager has + // observed (bootstrap, live, reconnect, reconcile, pre-publish, cross-window + // storage, and initial persisted state). A click reads both so its minted + // `updatedAt = max(now, maxUpdatedAtSeen)` never regresses below observed + // state (the read-state logical-monotonic idiom), and `rev = maxRevSeen + 1` + // wins the resulting same-second tie. + private highWater = new Map(); constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; @@ -54,6 +83,29 @@ export class ChannelMuteSyncManager { this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } + /** + * Ingest a store into the per-channel high-water. Called synchronously before + * any merge is applied to React state, so a click that follows reads a current + * watermark on both dimensions. Monotonic (`Math.max`) and idempotent. + */ + observe(store: ChannelMuteStore): void { + for (const [id, entry] of Object.entries(store.channels)) { + const cur = this.highWater.get(id) ?? { rev: 0, updatedAt: 0 }; + this.highWater.set(id, { + rev: Math.max(cur.rev, entry.rev), + updatedAt: Math.max(cur.updatedAt, entry.updatedAt), + }); + } + } + + maxRevSeen(id: string): number { + return this.highWater.get(id)?.rev ?? 0; + } + + maxUpdatedAtSeen(id: string): number { + return this.highWater.get(id)?.updatedAt ?? 0; + } + async fetchRemoteMutes(): Promise> { try { const events = await relayClient.fetchEvents({ @@ -71,6 +123,7 @@ export class ChannelMuteSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.observe(result.store); return { status: "found", data: result, @@ -94,6 +147,10 @@ export class ChannelMuteSyncManager { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; } + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } } getPendingMuteStore(): ChannelMuteStore | null { @@ -102,15 +159,53 @@ export class ChannelMuteSyncManager { publishMutes(store: ChannelMuteStore): void { this.pendingStore = store; + ++this.pendingGeneration; + // Persist synchronously so a click made <2s before quit/community-switch + // survives teardown and resumes on next mount (durable outbox). This + // window's own key is the only one written — a single unconditional + // setItem, never a shared-key read-modify-write. + writeChannelMutesOutbox(this.pubkey, store, this.relayUrl); if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } + // A fresh edit supersedes any retry scheduled for the previous generation. + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = RETRY_BASE_MS; this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; - void this.doPublish(store); + this.startCycle(); }, DEBOUNCE_MS); } + /** + * Serialize publish cycles: at most one runs at a time. A debounce/retry timer + * that fires while a cycle is in flight defers — the in-flight cycle's + * completion re-drives if a pending edit still needs publishing. A newer edit + * queued during a cycle cannot start its own concurrent cycle, so a stale + * generation can never publish after a newer edit exists. + */ + private startCycle(): void { + if (this.destroyed || this.pendingStore === null) return; + if (this.publishInFlight) return; + const store = this.pendingStore; + const gen = this.pendingGeneration; + this.publishInFlight = true; + void this.doPublish(store, gen).finally(() => { + this.publishInFlight = false; + if ( + !this.destroyed && + this.pendingStore !== null && + this.debounceTimer === null && + this.retryTimer === null + ) { + this.startCycle(); + } + }); + } + private async fetchOwnBlobBeforePublish( store: ChannelMuteStore, ): Promise { @@ -127,6 +222,9 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; + this.observe(remote.store); + // Max-merge: the local edit's per-entry winners survive by construction + // and any newer remote entries fold in, so no adopt step is needed. return mergeStores(store, remote.store); } catch { return store; @@ -144,22 +242,55 @@ export class ChannelMuteSyncManager { if ( !last || last.muted !== current.muted || - last.updatedAt !== current.updatedAt + last.updatedAt !== current.updatedAt || + last.rev !== current.rev ) return false; } return true; } - private async doPublish(store: ChannelMuteStore): Promise { + /** + * Clear the in-memory pending edit and this window's own durable outbox key — + * but only if the completing publish still owns the current generation. A + * publish for an older edit that finishes after a newer edit was queued must + * leave the newer edit (and its retry state) untouched. + */ + private discardPending(gen: number): void { + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelMutesOutbox(this.pubkey, this.relayUrl); + } + + /** Schedule a bounded-backoff retry of the retained pending edit. */ + private scheduleRetry(gen: number): void { + if (this.destroyed || this.pendingStore === null) return; + // A newer edit has superseded this one; its own timer owns the retry. + if (gen !== this.pendingGeneration) return; + if (this.retryTimer !== null) return; + const delay = this.retryDelayMs; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, RETRY_MAX_MS); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.startCycle(); + }, delay); + } + + private async doPublish(store: ChannelMuteStore, gen: number): Promise { + // A newer edit was queued after this publish was scheduled; it owns the + // pending state and will publish the latest store — abandon this stale run. + if (gen !== this.pendingGeneration) return; try { const merged = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. + // was awaited (community switch during in-flight fetch). if (this.destroyed) return; + // A newer edit was queued while we awaited the pre-publish fetch. It owns + // convergence now; the serialized cycle re-drives for it once this run + // unwinds. + if (gen !== this.pendingGeneration) return; if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + this.discardPending(gen); return; } const payload = { @@ -167,10 +298,10 @@ export class ChannelMuteSyncManager { channels: merged.channels, }; const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); + // Clamp inside the relay's future-drift window so a skewed remote head + // can never make us stamp an unbounded future timestamp that wedges every + // subsequent publish; we adopt such a head on the next fetch instead. + const createdAt = clampPublishCreatedAt(this.lastRemoteCreatedAt); const event = await signRelayEvent({ kind: KIND_CHANNEL_MUTES, content: ciphertext, @@ -180,17 +311,32 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + // Final guard immediately before the network call: a newer edit may have + // been queued during the encrypt/sign await, or the manager destroyed. + if (this.destroyed || gen !== this.pendingGeneration) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", ); this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; + this.observe(merged); + // Only claim this store as the published head if it is still the current + // edit; a newer edit queued mid-flight owns lastPublishedStore now. + if (gen === this.pendingGeneration) { + this.lastPublishedStore = merged; + this.retryDelayMs = RETRY_BASE_MS; + } + this.discardPending(gen); } catch (error) { + if (this.destroyed) return; + // Transient publish failure (timeout / socket error). Keep the pending + // edit and retry with backoff rather than waiting for a reconnect that a + // healthy socket never fires. Max-merge makes a duplicate publish + // idempotent, so a lost-ACK write that the relay actually accepted is + // harmless to re-send. console.warn("[channelMutesSync] publish failed:", error); + this.scheduleRetry(gen); } } @@ -211,6 +357,7 @@ export class ChannelMuteSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { + this.observe(result.store); onUpdate(result); } }); @@ -223,6 +370,9 @@ export class ChannelMuteSyncManager { * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ async bootstrap(localStore: ChannelMuteStore) { + // Seed the high-water from the caller's persisted local store so a click + // before the remote fetch resolves already reflects retained entries. + this.observe(localStore); const fetchResult = await this.fetchRemoteMutes(); return runBootstrap({ fetchResult, @@ -236,10 +386,10 @@ export class ChannelMuteSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's state to relay B via the shared relayClient - // singleton. Local entries survive because the apply/publish paths merge - // per-entry via mergeStores, so no local work is permanently lost. + // Debounce-window changes are NOT lost: publishMutes persisted them to the + // durable outbox synchronously, and the next mount resumes them. Flushing + // here is still avoided — it could publish relay A's state to relay B via + // the shared relayClient singleton. this.destroyed = true; this.cancelPendingMutePublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index f01154751bd..03e0c9ac7cf 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,4 +1,11 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + clearOwnOutbox, + markLegacyConsumed, + reclaimOutbox, + resumeWholeBlobOutbox, + writeOwnOutbox, +} from "./sidebarSyncWatermark"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; export const MAX_CHANNEL_SECTIONS = 100; @@ -194,3 +201,101 @@ export function writeChannelSectionsStore( return false; } } + +const OUTBOX_KEY_PREFIX = "buzz-channel-sections-outbox.v1"; + +// The single shared key written by builds before the outbox was keyed +// per-window. Enumerated as one more record so an edit persisted by a prior +// build still resumes, and reclaimed by the same relay-gated rule. +function legacyOutboxKey(pubkey: string, relayUrl: string): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** + * Persist this window's unpublished edit under its own outbox key. Written + * synchronously on every edit as a single unconditional `setItem` (no shared- + * key read-modify-write); resumed on next mount so an edit made <2s before + * quit/community-switch is never dropped. `queuedAt` stamps the write so resume + * replays only the newest queued blob (whole-blob LWW). + */ +export function writeChannelSectionsOutbox( + pubkey: string, + store: ChannelSectionStore, + relayUrl: string, +): void { + writeOwnOutbox( + OUTBOX_KEY_PREFIX, + pubkey, + relayUrl, + boundChannelSectionsStore(store), + ); +} + +/** + * The whole-blob outbox record to resume on boot, or null when none exists. + * Whole-blob LWW: only the max-`queuedAt` record is replayed. Returns the + * winning store plus, when that winner is a not-yet-consumed legacy blob, the + * raw string the caller marks consumed (via `markChannelSectionsLegacyConsumed`) + * once it has durably re-queued the intent — the legacy key is never deleted, + * so this one-shot marker is what stops it republishing above the head forever. + */ +export function readChannelSectionsOutbox( + pubkey: string, + relayUrl: string, +): { store: ChannelSectionStore; legacyRawToConsume: string | null } | null { + return resumeWholeBlobOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseChannelSectionPayload, + ); +} + +/** + * Mark a replayed legacy sections blob consumed so it is not resumed again on a + * later boot. Call only AFTER the intent is durably held in this window's own + * v2 key (its synchronous publish path), so a crash before this write replays + * the legacy blob once more rather than losing it. + */ +export function markChannelSectionsLegacyConsumed( + pubkey: string, + relayUrl: string, + raw: string, +): void { + markLegacyConsumed(OUTBOX_KEY_PREFIX, pubkey, relayUrl, raw); +} + +/** Clear this window's own outbox key (its edit published or is a no-op). */ +export function clearChannelSectionsOutbox( + pubkey: string, + relayUrl: string, +): void { + clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl); +} + +/** + * Reclaim foreign outbox keys the relay head itself STRICTLY supersedes: a + * whole-blob record queued strictly before the durable head's `created_at` + * (`queuedAt` < `headCreatedAt`) lost LWW to a blob the relay already holds, so + * dropping it matches the relay's own resolution. A same-second record + * (`queuedAt` == `headCreatedAt`) is kept — one-second clock granularity cannot + * prove it lost, so it drains only when a strictly-newer head lands. A record + * queued after the head is live intent and is likewise kept. Records are + * write-once so the delete needs no recheck; never touches this window's own + * keys or the legacy shared key. Call only after a successful fetch. + */ +export function reclaimSupersededSectionsOutbox( + pubkey: string, + relayUrl: string, + headCreatedAt: number, +): void { + reclaimOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseChannelSectionPayload, + (record) => record.queuedAt < headCreatedAt, + ); +} diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 904ac1f3f24..a967a647071 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; +import { readChannelSectionsOutbox } from "./channelSectionsStorage.ts"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; import { makeFakeWindow, @@ -179,25 +180,28 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { +// 4. Adopt-winner: a newer remote head at pre-publish time supersedes the local +// edit — the manager must NOT publish, must hand the remote to the adopt +// sink, and must clear the pending/outbox so the loser can't be replayed. +// Mutation test: reverting adopt→republish makes onRemoteAdopted never fire and +// publishEvent fire instead. +test("adopt-winner: newer remote head at pre-publish adopts remote and skips publish", async () => { const REMOTE_ID = "remote-section-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ { pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, + content: "good-cipher", + created_at: 200, + id: "evt-remote", }, - ]); + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); const tauri = installTauriMock( @@ -209,24 +213,119 @@ test("revert-fix: sections LWW — newer decryptable pre-publish event selected ); try { const manager = new ChannelSectionSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSections(); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + manager.publishSections( + makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + ); + // Outbox persisted synchronously on the edit. assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, + readChannelSectionsOutbox("pk-lww", RELAY) !== null, + "edit must be persisted to the durable outbox", + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish when a newer remote head wins LWW", + ); + assert.equal(adopted.length, 1, "adopt sink must receive the remote"); + assert.ok( + adopted[0].store.sections.some((s) => s.id === REMOTE_ID), + "adopted store must be the remote content", + ); + assert.equal(manager.getPendingStore(), null, "pending must be cleared"); + assert.equal( + readChannelSectionsOutbox("pk-lww", RELAY), + null, + "outbox must be cleared on adopt so the loser is never replayed", + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 4b. Local edit wins (no newer remote head): publishes and clears the outbox. +test("adopt-winner: local edit at/ahead of head publishes and clears outbox", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-win", RELAY); + manager.publishSections( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 1, "local edit must be published"); + assert.equal( + readChannelSectionsOutbox("pk-win", RELAY), + null, + "outbox must be cleared once the edit is published", ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 4c. Timestamp clamp: a remote head far in the future must not make the +// published createdAt walk past the relay's ±15min window. +// Mutation test: removing the Math.min clamp lets createdAt = lastRemote+1 +// (~now+3600), which exceeds now + MAX_PUBLISH_FUTURE_SECS. +test("timestamp clamp: published createdAt stays inside the relay future window", async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + let call = 0; + mock.method(relayClient, "fetchEvents", () => { + call++; + // First call: fetchRemoteSections during a manual head prime; subsequent: + // pre-publish fetch. Return the far-future undecryptable head each time so + // lastRemoteCreatedAt is pushed to farFutureHead but the store still + // publishes (local edit is what we're stamping). + return Promise.resolve([ + { + pubkey: "pk-clamp", + content: "good-cipher", + created_at: call === 1 ? farFutureHead : 0, + id: "evt-clamp", + }, + ]); + }); + let signedCreatedAt = null; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ version: 1, sections: [], assignments: {} }), + ); + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new ChannelSectionSyncManager("pk-clamp", RELAY); + // Prime lastRemoteCreatedAt to the far-future head. + await manager.fetchRemoteSections(); manager.publishSections( - makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), ); + // Fire debounce; pre-publish fetch returns created_at=0 so local wins. fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); assert.ok( - JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), - `remote sections must win LWW merge — got: ${pt}`, + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, ); } finally { tauri.restore(); @@ -280,3 +379,744 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att mock.reset(); } }); + +// 6. Overlapping publishes (fix 1): an older in-flight publish must not clear a +// newer edit queued while it was in flight. Regression for the generation +// compare-and-swap on discardPending — reverting the gen guard makes the +// older completion null out B's pendingStore + outbox. +test("overlapping publishes: older completion does not erase a newer queued edit", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + // First publish blocks until we release it; a second publish is queued while + // the first is in flight. + let releaseFirst = null; + let publishCalls = 0; + mock.method(relayClient, "publishEvent", () => { + publishCalls++; + if (publishCalls === 1) { + return new Promise((res) => { + releaseFirst = res; + }); + } + return Promise.resolve(); + }); + // A multi-slot timer fake: each setTimeout is retained by delay so we can fire + // the debounce independently and inspect what remains. + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + await Promise.resolve(); + await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-overlap", RELAY); + const storeA = makeSectionsStore([{ id: "a", name: "A", order: 0 }]); + const storeB = makeSectionsStore([{ id: "b", name: "B", order: 0 }]); + + manager.publishSections(storeA); + await fireDelay(2000); // debounce → doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + // Edit B arrives while A is still in flight. + manager.publishSections(storeB); + assert.deepEqual( + manager.getPendingStore()?.sections.map((s) => s.id), + ["b"], + "B is now the pending edit", + ); + assert.equal( + readChannelSectionsOutbox("pk-overlap", RELAY).store.sections[0].id, + "b", + "outbox holds B", + ); + + // A completes — its success path must NOT clear B's pending/outbox. + releaseFirst(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual( + manager.getPendingStore()?.sections.map((s) => s.id), + ["b"], + "older completion must leave B pending", + ); + assert.ok( + readChannelSectionsOutbox("pk-overlap", RELAY) !== null, + "older completion must leave B's outbox intact", + ); + assert.ok( + [...timers.values()].some((t) => t.ms === 2000), + "B's debounce timer must survive so it still publishes", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 7. Live remote during debounce (pass-2 finding 1): a remote head accepted +// while a local edit is debouncing must be adopted at pre-publish, not +// overwritten. The live event advances the watermark before doPublish runs, +// so comparing the fetched head against the mutable watermark would see +// equality and publish over the newer remote. The pre-publish check compares +// against the baseline frozen at publishSections instead. Mutation: comparing +// against lastRemoteCreatedAt rather than publishBaseline republishes local. +test("live remote during debounce is adopted at pre-publish, not overwritten", async () => { + const remoteEvent = { + id: "remote-event", + pubkey: "pk-livedebounce", + content: "good-cipher", + created_at: 1_700_000_100, + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }; + // Pre-publish fetch returns the same live head that arrived during debounce. + mock.method(relayClient, "fetchEvents", () => Promise.resolve([remoteEvent])); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + let live = null; + mock.method(relayClient, "subscribeLive", async (_filter, cb) => { + live = cb; + return async () => {}; + }); + + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), + ); + try { + const manager = new ChannelSectionSyncManager("pk-livedebounce", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((remote) => adopted.push(remote.eventId)); + await manager.subscribeToSections(() => {}); + assert.ok(live, "live subscription installed"); + + manager.publishSections( + makeSectionsStore([{ id: "local", name: "Local", order: 0 }]), + ); + // A genuinely later remote head is accepted and delivered while local is + // pending — this advances the watermark past the frozen baseline. + live(remoteEvent); + for (let i = 0; i < 50; i++) await Promise.resolve(); + + await fireDelay(2000); + + assert.equal( + publishCalls.length, + 0, + "later remote head must prevent the local publish", + ); + assert.deepEqual( + adopted, + ["remote-event"], + "the remote accepted after the edit began must be adopted", + ); + assert.equal(manager.getPendingStore(), null, "pending cleared on adopt"); + assert.equal( + readChannelSectionsOutbox("pk-livedebounce", RELAY), + null, + "outbox cleared on adopt so the loser can't replay", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 8. Serialized generations (fix round 3, pass-3 finding 1): an older in-flight +// publish that completes after a newer edit is queued must NOT be mistaken +// for a remote that advanced past the newer edit's baseline. A blocks in +// publishEvent; B is queued; A succeeds; B's pre-publish fetch returns A's +// accepted head. Because the baseline is frozen at B's own cycle start — +// after A's completion recorded its head — B publishes above A instead of +// adopting it. Mutation: freezing the baseline at publishSections (before the +// prior cycle completes) makes B see A as a post-baseline remote and adopt it. +test("serialized generations: older completion does not make the newer edit adopt it", async () => { + let releaseFirst = null; + let publishCalls = 0; + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + mock.method(relayClient, "publishEvent", (event) => { + publishCalls++; + if (publishCalls === 1) { + // A's publish blocks; when it resolves, its event becomes the stored head + // the next pre-publish fetch will return. + return new Promise((res) => { + releaseFirst = () => { + storedHead = [ + { + id: "event-a", + pubkey: "pk-serial", + content: "good-cipher", + created_at: event.created_at, + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }, + ]; + res(); + }; + }); + } + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + // A's decrypted head must parse; the pre-publish check reads its created_at/id. + const tauri = installTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: "a", name: "A", order: 0 }], + assignments: {}, + }), + ); + try { + const manager = new ChannelSectionSyncManager("pk-serial", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((remote) => adopted.push(remote.eventId)); + + manager.publishSections( + makeSectionsStore([{ id: "a", name: "A", order: 0 }]), + ); + await fireDelay(2000); // A's cycle → publishEvent(A) blocks + while (releaseFirst === null) await Promise.resolve(); + + // B is queued while A is still in flight; its cycle must defer. + manager.publishSections( + makeSectionsStore([{ id: "b", name: "B", order: 0 }]), + ); + assert.deepEqual( + manager.getPendingStore()?.sections.map((s) => s.id), + ["b"], + "B is the pending edit while A is in flight", + ); + + releaseFirst(); // A completes, recording its head; the freed lane drives B + for (let i = 0; i < 100; i++) await Promise.resolve(); + // If B's cycle did not auto-drive on the freed lane, its debounce timer is + // still pending — fire it. + if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); + + assert.deepEqual(adopted, [], "B must not adopt the older generation A"); + assert.equal( + publishCalls, + 2, + "B publishes above A rather than adopting A's accepted head", + ); + assert.equal( + manager.getPendingStore(), + null, + "B's pending clears via its own successful publish, not A's completion", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 9. Serialized generations (fix round 3, pass-3 finding 1): a stale generation +// must never sign/publish after a newer edit is queued. A blocks in the +// pre-publish fetch; B is queued during that await; A must abort before +// signing. Mutation: dropping the post-fetch generation re-check in doPublish +// lets the stale A continue to publishEvent. +test("serialized generations: a stale generation aborts before publishing", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSectionSyncManager("pk-stale", RELAY); + manager.publishSections( + makeSectionsStore([{ id: "a", name: "A", order: 0 }]), + ); + await fireDelay(2000); // A's cycle → doPublish(A) awaits fetchEvents + while (releaseFetch === null) await Promise.resolve(); + + // B is queued while A is blocked in its pre-publish fetch. + manager.publishSections( + makeSectionsStore([{ id: "b", name: "B", order: 0 }]), + ); + + releaseFetch(); // A's fetch resolves; A must see gen moved and abort + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal( + publishCalls.length, + 0, + "stale generation A must not sign/publish after B was queued", + ); + assert.deepEqual( + manager.getPendingStore()?.sections.map((s) => s.id), + ["b"], + "B remains the pending edit, owning convergence", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// Installs a Tauri mock whose sign_event returns a caller-controlled id per +// call (so overlapping publishes get distinct event ids) and whose +// nip44_encrypt_to_self can be made to block, exposing the encrypt/sign await +// window. `signIds` is consumed in order; the encrypt block is armed on demand. +function installSeamTauriMock(payload, signIds) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let signCall = 0; + let releaseEncrypt = null; + let blockNextEncrypt = false; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") return Promise.resolve(payload); + if (cmd === "nip44_encrypt_to_self") { + if (!blockNextEncrypt) return Promise.resolve("ct"); + blockNextEncrypt = false; + return new Promise((res) => { + releaseEncrypt = () => res("ct"); + }); + } + if (cmd === "sign_event") { + const id = signIds[Math.min(signCall, signIds.length - 1)]; + signCall++; + return Promise.resolve( + JSON.stringify({ + id, + pubkey: "pk", + content: "ct", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + } + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + armEncryptBlock: () => { + blockNextEncrypt = true; + }, + releaseEncrypt: () => releaseEncrypt?.(), + hasEncryptBlocked: () => releaseEncrypt !== null, + }; +} + +// 10. Ambiguous ACK (fix round 4, pass-4 finding 1): the relay accepts A but the +// client's ACK is lost (publish promise rejects as a timeout). B was queued +// mid-flight. When B's pre-publish fetch returns A's accepted head, it must +// recognise A as OUR OWN accepted predecessor — fold it forward and publish +// above it — not adopt it and erase B. Mutation: dropping the +// ambiguousAttemptIds fold makes B classify A as a foreign advance and adopt. +test("ambiguous ACK: an accepted-but-unacked A does not make B adopt and disappear", async () => { + let releaseFirst = null; + let publishCalls = 0; + let storedHead = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve(storedHead)); + mock.method(relayClient, "publishEvent", (event) => { + publishCalls++; + if (publishCalls === 1) { + // A reaches the relay and is stored, but the ACK never arrives: the + // promise rejects as a timeout after the frame has left. + return new Promise((_res, reject) => { + releaseFirst = () => { + storedHead = [ + { + id: "event-a", + pubkey: "pk-ambiguous", + content: "good-cipher", + created_at: event.created_at, + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }, + ]; + reject(new Error("Timed out publishing channel sections.")); + }; + }); + } + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installSeamTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: "a", name: "A", order: 0 }], + assignments: {}, + }), + ["event-a", "event-b"], + ); + try { + const manager = new ChannelSectionSyncManager("pk-ambiguous", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r.eventId)); + + manager.publishSections( + makeSectionsStore([{ id: "a", name: "A", order: 0 }]), + ); + await fireDelay(2000); // A's cycle → publishEvent(A) blocks + while (releaseFirst === null) await Promise.resolve(); + + manager.publishSections( + makeSectionsStore([{ id: "b", name: "B", order: 0 }]), + ); + + releaseFirst(); // A's ACK is lost; A is stored on the relay regardless + for (let i = 0; i < 100; i++) await Promise.resolve(); + if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.deepEqual( + adopted, + [], + "B must not adopt A when A was accepted but its ACK was lost", + ); + assert.equal( + publishCalls, + 2, + "B publishes above A's ambiguously-accepted head", + ); + assert.equal( + manager.getPendingStore(), + null, + "B's own successful publish clears its pending edit", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 11. Ambiguous ACK, negative case (fix round 4, pass-4 finding 1): the fold is +// gated on an exact id-match against a prior attempt. An advancing head +// whose id is NOT one of our attempts is a genuine foreign winner and must +// be ADOPTED, never folded. A's ACK is lost (transient) so its id stays in +// the ambiguous set, but the head that surfaces is a DIFFERENT foreign id — +// proof the relay never accepted A. B must adopt the foreign winner, not +// fold it and publish above it. Mutation: dropping the ambiguousAttemptIds +// id-guard (folding any advance) makes B erase the foreign winner. +test("ambiguous ACK: a foreign head is adopted, not folded as our own", async () => { + let publishCalls = 0; + let fetchCalls = 0; + mock.method(relayClient, "fetchEvents", () => { + fetchCalls++; + // 1: A's pre-publish fetch (empty → A publishes, then its ACK times out). + // 2+: B's pre-publish fetch surfaces a FOREIGN winner (id != A's attempt). + if (fetchCalls === 1) return Promise.resolve([]); + return Promise.resolve([ + { + id: "foreign-winner", + pubkey: "pk-reject", + content: "good-cipher", + created_at: 500, + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => { + publishCalls++; + // A's ACK is lost as a transient timeout; the relay never stored A. + if (publishCalls === 1) + return Promise.reject( + new Error("Timed out publishing channel sections."), + ); + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installSeamTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: "a", name: "A", order: 0 }], + assignments: {}, + }), + ["event-a", "event-b"], + ); + try { + const manager = new ChannelSectionSyncManager("pk-reject", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r.eventId)); + + manager.publishSections( + makeSectionsStore([{ id: "a", name: "A", order: 0 }]), + ); + await fireDelay(2000); // A's cycle → publishEvent rejects (ACK lost) + for (let i = 0; i < 100; i++) await Promise.resolve(); + + // B is queued; it supersedes A's scheduled retry. + manager.publishSections( + makeSectionsStore([{ id: "b", name: "B", order: 0 }]), + ); + if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.deepEqual( + adopted, + ["foreign-winner"], + "B adopts the foreign head; its id is not one of our attempts", + ); + assert.equal( + manager.getPendingStore(), + null, + "adopt clears the pending edit", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 12. Pre-sign generation guard seam (fix round 4, pass-4 review): the guard +// immediately before signing/publishing (post-encrypt) must be individually +// load-bearing. B arrives DURING A's encrypt/sign await — past the +// post-fetch guard — so only the pre-sign guard can stop A publishing a +// stale store. Mutation: dropping the gen re-check at the pre-sign guard +// lets stale A reach publishEvent after B was queued. +test("serialized generations: a newer edit during encrypt/sign aborts the pre-sign publish", async () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (event) => { + publishCalls.push(event); + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 100; i++) await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installSeamTauriMock("{}", ["event-a", "event-b"]); + try { + const manager = new ChannelSectionSyncManager("pk-seam", RELAY); + tauri.armEncryptBlock(); // A's encrypt will block, exposing the sign window + manager.publishSections( + makeSectionsStore([{ id: "a", name: "A", order: 0 }]), + ); + await fireDelay(2000); // A's cycle → passes post-fetch guard, blocks in encrypt + while (!tauri.hasEncryptBlocked()) await Promise.resolve(); + + // B is queued while A is mid encrypt/sign — after A's post-fetch guard. + manager.publishSections( + makeSectionsStore([{ id: "b", name: "B", order: 0 }]), + ); + + tauri.releaseEncrypt(); // A resumes; the pre-sign guard must abort it + for (let i = 0; i < 100; i++) await Promise.resolve(); + if ([...timers.values()].some((t) => t.ms === 2000)) await fireDelay(2000); + for (let i = 0; i < 100; i++) await Promise.resolve(); + + assert.equal( + publishCalls.length, + 1, + "only B publishes; stale A aborts at the pre-sign guard", + ); + assert.equal( + publishCalls[0].id, + "event-b", + "the surviving publish is B, not the stale A", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 858b62430f3..8ae1ac8f6d9 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -7,12 +7,15 @@ import { import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; import { + clearChannelSectionsOutbox, parseChannelSectionPayload, + writeChannelSectionsOutbox, type ChannelSection, type ChannelSectionStore, } from "./channelSectionsStorage"; import { advanceWatermark, + clampPublishCreatedAt, readWatermark, runBootstrap, type FetchResult, @@ -22,12 +25,75 @@ const D_TAG = "channel-sections"; const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; +// Bounded backoff for a retained pending edit whose publish failed transiently +// (timeout / socket error) on an otherwise-healthy socket, so it does not wait +// for a reconnect that may never fire. +const RETRY_BASE_MS = 2_000; +const RETRY_MAX_MS = 30_000; + export type RemoteSections = { store: ChannelSectionStore; createdAt: number; eventId: string; }; +/** + * Outcome of the pre-publish head check. + * + * - `publish` — local edit is at or ahead of the head; publish it. + * - `adopt` — a newer remote head exists; the local edit lost whole-blob + * LWW and must be discarded in favour of the remote store so UI + * and relay converge (see the fix-2 design note). The manager + * hands the remote back to the hook and never publishes. + */ +type PublishDecision = + | { kind: "publish"; store: ChannelSectionStore } + | { kind: "adopt"; remote: RemoteSections }; + +/** + * The canonical remote head as it stood when an edit was queued. The pre-publish + * check compares the fetched head against this frozen baseline — never against + * the mutable in-memory watermark, which a live event observed during the + * debounce window may already have advanced to that same head (silently + * suppressing the adopt). + */ +type PublishBaseline = { createdAt: number; eventId: string }; + +/** + * True when `head` is the canonical winner over the baseline the edit was queued + * against — i.e. the head advanced since the edit began. Canonical order is + * `created_at DESC, id ASC`: a strictly-later head wins, and a same-second head + * wins only with a strictly-lower id. A same-second head is comparable only once + * the baseline id is known (empty id = no prior head seen → not superseded). + */ +function remoteAdvancedSince( + head: RemoteSections, + baseline: PublishBaseline, +): boolean { + if (head.createdAt > baseline.createdAt) return true; + return ( + head.createdAt === baseline.createdAt && + baseline.eventId !== "" && + head.eventId < baseline.eventId + ); +} + +/** + * True when tuple `a` is the canonical winner over `b` (`created_at DESC, + * id ASC`). An empty id means "no head seen yet" and always loses. + */ +function canonicalGreater(a: PublishBaseline, b: PublishBaseline): boolean { + if (a.eventId === "") return false; + if (b.eventId === "") return true; + if (a.createdAt !== b.createdAt) return a.createdAt > b.createdAt; + return a.eventId < b.eventId; +} + +/** The canonical-greater of two head tuples (`created_at DESC, id ASC`). */ +function canonicalMax(a: PublishBaseline, b: PublishBaseline): PublishBaseline { + return canonicalGreater(a, b) ? a : b; +} + async function decryptAndParse( event: RelayEvent, ): Promise { @@ -45,10 +111,50 @@ export class ChannelSectionSyncManager { private pubkey: string; private relayUrl: string; private debounceTimer: number | null = null; + private retryTimer: number | null = null; + private retryDelayMs = RETRY_BASE_MS; private lastRemoteCreatedAt: number; + // Canonical best head observed so far (`created_at DESC, id ASC`). Frozen into + // a per-edit baseline at publishSections so the pre-publish check can tell + // whether the head advanced *since the edit was queued*, independent of the + // mutable watermark that a live event during the debounce window may advance. + private lastRemoteHead: PublishBaseline = { createdAt: 0, eventId: "" }; + // The canonical head this pending edit is racing against, frozen when the + // edit was queued (publishSections) and advanced ONLY by our own successful + // publishes. Freezing at queue time is what makes a genuine remote observed + // during the debounce window still adopt-worthy (pass-2): the mutable + // watermark advanced to that remote, but the baseline did not. Folding our + // own published head forward is what stops a newer edit from adopting an + // older generation's own accepted write (pass-3): our prior publish is our + // baseline, not a competing remote. + private publishBaseline: PublishBaseline = { createdAt: 0, eventId: "" }; private pendingStore: ChannelSectionStore | null = null; + // Monotonic id for the current pending edit. Every publishSections() bumps + // it; every scheduled publish/retry captures the value it was queued for. + // A completion (success, adopt, or no-op) may only clear pending state via + // compare-and-swap on this generation, so an older in-flight publish can + // never erase a newer edit that arrived while it was in flight. + private pendingGeneration = 0; + // Publish cycles are serialized: at most one runs at a time. A newer edit + // queued while a cycle is in flight does NOT start its own concurrent cycle; + // it defers, and the in-flight cycle's completion schedules it. Serialization + // guarantees there is never more than one baseline/fetch/publish sequence + // touching shared manager state, so a stale generation can never sign or + // publish after a newer edit exists. + private publishInFlight = false; + // Event ids we signed and sent to the relay but whose ACK never arrived (the + // publish promise rejected as a timeout/socket error after the frame left). + // The relay MAY have accepted such a write, so if a later cycle's pre-publish + // fetch returns a head whose id is in this set, that head is OUR OWN accepted + // predecessor — fold it forward and publish above it, rather than adopting it + // and erasing the queued edit. An attempt the relay never accepted can never + // surface as the head, so an id match is proof of our own accepted write. + private ambiguousAttemptIds = new Set(); private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; + // Set by the hook so an adopted remote head (local edit lost whole-blob LWW) + // is written through to React state + localStorage. + private onRemoteAdopted: ((remote: RemoteSections) => void) | null = null; constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; @@ -58,6 +164,11 @@ export class ChannelSectionSyncManager { this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } + /** Register the hook's adopt-remote sink (write-through to UI + storage). */ + setOnRemoteAdopted(cb: (remote: RemoteSections) => void): void { + this.onRemoteAdopted = cb; + } + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ @@ -73,7 +184,7 @@ export class ChannelSectionSyncManager { // An event exists — record its created_at regardless of whether we can // decrypt it, so seed-publish is blocked even when the payload is // unreadable (e.g. wrong key). - this.recordRemoteHead(event.created_at); + this.recordRemoteHead(event.created_at, event.id); const result = await decryptAndParse(event); if (!result) { return { status: "failed", createdAt: event.created_at }; @@ -89,11 +200,22 @@ export class ChannelSectionSyncManager { } } - /** Update in-memory + persisted watermark. */ - private recordRemoteHead(createdAt: number): void { + /** Update in-memory + persisted watermark and the canonical head tuple. */ + private recordRemoteHead(createdAt: number, eventId: string): void { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } + // Track the canonical-best head (`created_at DESC, id ASC`): a later head + // always wins; a same-second head wins only with a strictly-lower id. This + // mirrors the relay's stored winner so a frozen baseline reflects reality. + if ( + createdAt > this.lastRemoteHead.createdAt || + (createdAt === this.lastRemoteHead.createdAt && + (this.lastRemoteHead.eventId === "" || + eventId < this.lastRemoteHead.eventId)) + ) { + this.lastRemoteHead = { createdAt, eventId }; + } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } @@ -102,26 +224,123 @@ export class ChannelSectionSyncManager { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; } + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } } getPendingStore(): ChannelSectionStore | null { return this.pendingStore; } + /** True while an unpublished local edit is queued (debouncing or retrying). */ + hasPendingEdit(): boolean { + return this.pendingStore !== null; + } + + /** + * Adopt a remote store that superseded a local edit: hand it to the hook for + * write-through, advance the watermark, and drop the losing pending edit — + * including the durable outbox, so the outbox can never replay an edit that + * adopt just decided lost (which would reintroduce divergence). + * + * Compare-and-swap on `gen`: this adopt was decided against the edit queued at + * generation `gen`. If a newer edit arrived while this publish was in flight, + * the generation has moved on and that newer edit is the latest writer — it + * will publish and win LWW — so a stale adopt must not clear its pending state + * or overwrite its optimistic UI. We still advance the watermark (monotonic + * and always safe) so the newer edit stamps above this head. + */ + private adoptRemote(remote: RemoteSections, gen: number): void { + this.recordRemoteHead(remote.createdAt, remote.eventId); + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelSectionsOutbox(this.pubkey, this.relayUrl); + this.lastPublishedStore = remote.store; + if (this.destroyed) return; + this.onRemoteAdopted?.(remote); + } + + /** + * Clear the in-memory pending edit and this window's own durable outbox key — + * but only if the completing publish still owns the current generation. A + * publish for an older edit that finishes after a newer edit was queued must + * leave the newer edit (and its retry state) untouched. + */ + private discardPending(gen: number): void { + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelSectionsOutbox(this.pubkey, this.relayUrl); + } + publishSections(store: ChannelSectionStore): void { this.pendingStore = store; + ++this.pendingGeneration; + // Freeze the canonical head this edit is racing against at queue time. The + // pre-publish check compares the fetched head against this baseline, not the + // mutable watermark — a live event applied during the debounce window + // advances the watermark to a new remote head, which would otherwise make + // the pre-publish comparison see equality and fall through to a publish that + // overwrites a remote that became head *after* this edit was queued. The + // baseline only advances via our own successful publishes (see doPublish), + // so a prior generation's own accepted write is folded in rather than + // mistaken for a competing remote. + this.publishBaseline = { ...this.lastRemoteHead }; + // Persist synchronously so an edit made <2s before quit/community-switch + // survives teardown and resumes on next mount (durable outbox). This + // window's own key is the only one written — a single unconditional + // setItem, never a shared-key read-modify-write; `queuedAt` stamps it so + // resume replays only the newest queued blob (whole-blob LWW). + writeChannelSectionsOutbox(this.pubkey, store, this.relayUrl); if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } + // A fresh edit supersedes any retry scheduled for the previous generation. + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = RETRY_BASE_MS; this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; - void this.doPublish(store); + this.startCycle(); }, DEBOUNCE_MS); } + /** + * Serialize publish cycles: at most one runs at a time. A debounce/retry + * timer that fires while a cycle is in flight defers — the in-flight cycle's + * completion re-drives if a pending edit still needs publishing. This kills + * the cross-generation race class by construction: a newer edit queued during + * a cycle cannot start its own concurrent cycle, so there is never more than + * one baseline/fetch/publish sequence competing over shared manager state. + */ + private startCycle(): void { + if (this.destroyed || this.pendingStore === null) return; + if (this.publishInFlight) return; + const store = this.pendingStore; + const gen = this.pendingGeneration; + this.publishInFlight = true; + void this.doPublish(store, gen).finally(() => { + this.publishInFlight = false; + // A newer edit queued during the cycle (or a cycle that ended without + // clearing its pending edit) still needs publishing and has no timer + // pending to drive it — drive the next cycle now that the lane is free. + if ( + !this.destroyed && + this.pendingStore !== null && + this.debounceTimer === null && + this.retryTimer === null + ) { + this.startCycle(); + } + }); + } + private async fetchOwnBlobBeforePublish( store: ChannelSectionStore, - ): Promise { + ): Promise { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SECTIONS], @@ -129,23 +348,37 @@ export class ChannelSectionSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; + if (events.length === 0 || events[0].pubkey !== this.pubkey) + return { kind: "publish", store }; const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); - if (!remote) return store; - // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; + // Record the head after decrypt attempt so the watermark/head-tuple + // advance even for an undecryptable payload. + this.recordRemoteHead(event.created_at, event.id); + if (!remote) return { kind: "publish", store }; + // Sections use whole-blob LWW. Compare the fetched head against the + // baseline frozen when this edit was queued — NOT the live watermark, + // which a passive live event during debounce may already have advanced to + // this same head. If the canonical head advanced since the edit began, the + // local edit lost and is adopted-away rather than republished over it. + if (remoteAdvancedSince(remote, this.publishBaseline)) { + // Unless the advancing head is a prior publish of OURS whose ACK was + // lost: the relay accepted it, but our promise rejected before we could + // fold it forward, so it is our own accepted predecessor — not a + // competing remote. Fold it into the baseline and publish above it so + // the queued edit survives instead of adopting our own stale write away. + if (this.ambiguousAttemptIds.has(remote.eventId)) { + this.publishBaseline = canonicalMax(this.publishBaseline, { + createdAt: remote.createdAt, + eventId: remote.eventId, + }); + return { kind: "publish", store }; + } + return { kind: "adopt", remote }; } - return store; + return { kind: "publish", store }; } catch { - return store; + return { kind: "publish", store }; } } @@ -176,15 +409,45 @@ export class ChannelSectionSyncManager { return true; } - private async doPublish(store: ChannelSectionStore): Promise { + /** Schedule a bounded-backoff retry of the retained pending edit. */ + private scheduleRetry(gen: number): void { + if (this.destroyed || this.pendingStore === null) return; + // A newer edit has superseded this one; its own timer owns the retry. + if (gen !== this.pendingGeneration) return; + if (this.retryTimer !== null) return; + const delay = this.retryDelayMs; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, RETRY_MAX_MS); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.startCycle(); + }, delay); + } + + private async doPublish( + store: ChannelSectionStore, + gen: number, + ): Promise { + // A newer edit was queued after this publish was scheduled; it owns the + // pending state and will publish the latest store — abandon this stale run. + if (gen !== this.pendingGeneration) return; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const decision = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. if (this.destroyed) return; + // A newer edit was queued while we awaited the pre-publish fetch. It owns + // convergence now; abort so we neither publish this stale store nor adopt + // over the newer pending edit. The serialized cycle re-drives for the + // newer generation once this one unwinds. + if (gen !== this.pendingGeneration) return; + if (decision.kind === "adopt") { + this.adoptRemote(decision.remote, gen); + return; + } + const merged = decision.store; if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + this.discardPending(gen); return; } const payload = { @@ -193,10 +456,12 @@ export class ChannelSectionSyncManager { assignments: merged.assignments, }; const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); + // Clamp inside the relay's future-drift window: never manufacture a + // timestamp so far ahead that this or a later publish is rejected for + // drift and wedges. If a skewed remote head sits beyond the window we + // will lose LWW and adopt it on the next pre-publish fetch rather than + // walking past it. + const createdAt = clampPublishCreatedAt(this.lastRemoteCreatedAt); const event = await signRelayEvent({ kind: KIND_CHANNEL_SECTIONS, content: ciphertext, @@ -208,18 +473,57 @@ export class ChannelSectionSyncManager { }); // Final guard immediately before the network call — sign/encrypt are // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; + // different community by the time we reach this point, or a newer edit + // may have been queued during the encrypt/sign await (invariant: a stale + // generation never signs/publishes after a newer edit exists). + if (this.destroyed || gen !== this.pendingGeneration) return; + // Record this signed id as an in-flight attempt of unknown fate before we + // send it. If the ACK is lost below, a later cycle that fetches this id as + // the head recognises it as our own accepted write and folds it forward + // rather than adopting it away. + this.ambiguousAttemptIds.add(event.id); await relayClient.publishEvent( event, "Timed out publishing channel sections.", "Failed to publish channel sections.", ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; + this.recordRemoteHead(event.created_at, event.id); + // This write is now the confirmed accepted head; it dominates every prior + // attempt (`created_at DESC, id ASC`), so no earlier ambiguous id can ever + // be the canonical head again. Clear the set to keep it bounded. + this.ambiguousAttemptIds.clear(); + // Fold our own accepted head into the pending edit's baseline. This is + // unconditional across generations: even a stale generation's own write, + // completing after a newer edit was queued, must advance the current + // pending baseline so the newer edit's pre-publish check does not mistake + // OUR prior publish for a competing remote and adopt it away (pass-3). + // Genuine remotes never fold in here — they only advance the watermark — + // so a remote that became head during the debounce window still adopts + // (pass-2). canonicalMax keeps the advance monotonic (`created_at DESC, + // id ASC`). + this.publishBaseline = canonicalMax(this.publishBaseline, { + createdAt: event.created_at, + eventId: event.id, + }); + // Only claim this store as the published head if it is still the current + // edit; a newer edit queued mid-flight owns lastPublishedStore now. + if (gen === this.pendingGeneration) { + this.lastPublishedStore = merged; + this.retryDelayMs = RETRY_BASE_MS; + } + this.discardPending(gen); } catch (error) { + if (this.destroyed) return; + // Ambiguous outcome: the publish promise rejected (timeout / socket + // error), but the relay may already have accepted the write before the + // ACK was lost. Keep the pending edit and retry with backoff rather than + // waiting for a reconnect that a healthy socket never fires. The attempt + // id stays in ambiguousAttemptIds: if the relay did accept it, a later + // cycle that fetches this id as the head folds it forward as our own + // accepted predecessor (see fetchOwnBlobBeforePublish) instead of + // adopting it away and erasing the queued edit. console.warn("[channelSectionsSync] publish failed:", error); + this.scheduleRetry(gen); } } @@ -237,7 +541,7 @@ export class ChannelSectionSyncManager { if (event.pubkey !== this.pubkey) return; // Record the raw head before decrypt so an undecryptable live event // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); + this.recordRemoteHead(event.created_at, event.id); void decryptAndParse(event).then((result) => { if (result) { onUpdate(result); @@ -265,10 +569,10 @@ export class ChannelSectionSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sections to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. + // Debounce-window changes are NOT lost: publishSections persisted them to + // the durable outbox synchronously, and the next mount resumes them. + // Flushing here is still avoided — it could publish relay A's sections to + // relay B via the shared relayClient singleton. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 4de9768d848..e22653fb81e 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -1,5 +1,12 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; +import { + clearOwnOutbox, + markLegacyConsumed, + reclaimOutbox, + resumeWholeBlobOutbox, + writeOwnOutbox, +} from "./sidebarSyncWatermark"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; export const MAX_CHANNEL_SORT_GROUPS = 104; @@ -133,6 +140,101 @@ export function writeChannelSortStore( } } +const OUTBOX_KEY_PREFIX = "buzz-channel-sort-outbox.v1"; + +// The single shared key written by builds before the outbox was keyed +// per-window. Enumerated as one more record so an edit persisted by a prior +// build still resumes, and reclaimed by the same relay-gated rule. +function legacyOutboxKey(pubkey: string, relayUrl: string): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** + * Persist this window's unpublished sort edit under its own outbox key. Written + * synchronously on every edit as a single unconditional `setItem` (no shared- + * key read-modify-write); resumed on next mount so an edit made <2s before + * quit/community-switch is never dropped. `queuedAt` stamps the write so resume + * replays only the newest queued blob (whole-blob LWW). + */ +export function writeChannelSortOutbox( + pubkey: string, + store: ChannelSortStore, + relayUrl: string, +): void { + writeOwnOutbox( + OUTBOX_KEY_PREFIX, + pubkey, + relayUrl, + boundChannelSortStore(store), + ); +} + +/** + * The whole-blob outbox record to resume on boot, or null when none exists. + * Whole-blob LWW: only the max-`queuedAt` record is replayed. Returns the + * winning store plus, when that winner is a not-yet-consumed legacy blob, the + * raw string the caller marks consumed (via `markChannelSortLegacyConsumed`) + * once it has durably re-queued the intent — the legacy key is never deleted, + * so this one-shot marker is what stops it republishing above the head forever. + */ +export function readChannelSortOutbox( + pubkey: string, + relayUrl: string, +): { store: ChannelSortStore; legacyRawToConsume: string | null } | null { + return resumeWholeBlobOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseChannelSortPayload, + ); +} + +/** + * Mark a replayed legacy sort blob consumed so it is not resumed again on a + * later boot. Call only AFTER the intent is durably held in this window's own + * v2 key (its synchronous publish path), so a crash before this write replays + * the legacy blob once more rather than losing it. + */ +export function markChannelSortLegacyConsumed( + pubkey: string, + relayUrl: string, + raw: string, +): void { + markLegacyConsumed(OUTBOX_KEY_PREFIX, pubkey, relayUrl, raw); +} + +/** Clear this window's own outbox key (its edit published or is a no-op). */ +export function clearChannelSortOutbox(pubkey: string, relayUrl: string): void { + clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl); +} + +/** + * Reclaim foreign outbox keys the relay head itself STRICTLY supersedes: a + * whole-blob record queued strictly before the durable head's `created_at` + * (`queuedAt` < `headCreatedAt`) lost LWW to a blob the relay already holds, so + * dropping it matches the relay's own resolution. A same-second record + * (`queuedAt` == `headCreatedAt`) is kept — one-second clock granularity cannot + * prove it lost, so it drains only when a strictly-newer head lands. A record + * queued after the head is live intent and is likewise kept. Records are + * write-once so the delete needs no recheck; never touches this window's own + * keys or the legacy shared key. Call only after a successful fetch. + */ +export function reclaimSupersededSortOutbox( + pubkey: string, + relayUrl: string, + headCreatedAt: number, +): void { + reclaimOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseChannelSortPayload, + (record) => record.queuedAt < headCreatedAt, + ); +} + export function sortModeForGroup( store: ChannelSortStore, group: ChannelSortGroupKey, diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 28159eedd34..2d28c146cd0 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; +import { readChannelSortOutbox } from "./channelSortPreference.ts"; import { ChannelSortSyncManager } from "./channelSortSync.ts"; import { makeFakeWindow, @@ -92,8 +93,7 @@ test("destroy: is safe to call with no pending publish", () => { // Wiring tests 1-3 drive the production bootstrap() path; policy tested once // in sidebarSyncWatermark.test.mjs. -// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls -// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -112,8 +112,7 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) -// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +// 2. absent + persisted head > 0 → hold, zero publish calls (dev-build stale-copy case) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -125,13 +124,6 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); const result = await manager.bootstrap(makeStore({ channels: "recent" })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStore(), null); @@ -142,7 +134,6 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot }); // 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) -// Mutation: removing the absent+head-0 seed call leaves pendingStore null. test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -150,12 +141,6 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelSortSyncManager("pk-fresh", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, - ), - null, - ); const result = await manager.bootstrap(makeStore({ channels: "recent" })); assert.equal(result.action, "hold"); assert.ok(manager.getPendingStore() !== null); @@ -165,25 +150,30 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. LWW baseline: newer decryptable pre-publish event still wins after an -// undecryptable head was recorded. -// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison -// 200>200=false → local wins instead of remote → wrong content encrypted. -test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { +// ─── Whole-blob LWW: adopt-winner and local-winner ──────────────────────────── + +// 4a. Adopt-winner: a newer remote head at pre-publish time supersedes the local +// edit — the manager must NOT publish, must hand the remote to the adopt +// sink, and must clear pending/outbox so the loser can't be replayed. +// Mutation: reverting adopt→republish makes onRemoteAdopted never fire and +// publishEvent fire instead. +test("adopt-winner: newer remote head at pre-publish adopts remote and skips publish", async () => { const REMOTE_KEY = "remote-group-from-relay"; - let callCount = 0; - mock.method(relayClient, "fetchEvents", () => { - callCount++; - return Promise.resolve([ + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ { pubkey: "pk-lww", - content: callCount === 1 ? "bad-cipher" : "good-cipher", - created_at: callCount === 1 ? 100 : 200, - id: `evt-${callCount}`, + content: "good-cipher", + created_at: 200, + id: "evt-remote", }, - ]); + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); }); - mock.method(relayClient, "publishEvent", () => Promise.resolve()); const fw = makeFakeWindow(); const restore = installFakeWindow(fw); const tauri = installTauriMock( @@ -191,23 +181,347 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte ); try { const manager = new ChannelSortSyncManager("pk-lww", RELAY); - await manager.fetchRemoteSortPrefs(); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + manager.publishSortPrefs(makeStore({ "local-group": "recent" })); assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, - ) ?? "0", - ) >= 100, + readChannelSortOutbox("pk-lww", RELAY) !== null, + "edit must be persisted to the durable outbox", ); - manager.publishSortPrefs(makeStore({ "local-group": "recent" })); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); - const pt = tauri.capturedPlaintext(); - assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.equal( + publishCalls.length, + 0, + "must not publish when a newer remote head wins LWW", + ); + assert.equal(adopted.length, 1, "adopt sink must receive the remote"); + assert.ok( + REMOTE_KEY in adopted[0].store.groups, + "adopted store must be the remote content", + ); + assert.equal(manager.getPendingStore(), null, "pending must be cleared"); + assert.equal( + readChannelSortOutbox("pk-lww", RELAY), + null, + "outbox must be cleared on adopt so the loser is never replayed", + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 4b. Local edit wins (no newer remote head): publishes and clears the outbox. +test("adopt-winner: local edit at/ahead of head publishes and clears outbox", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSortSyncManager("pk-win", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 1, "local edit must be published"); + assert.equal( + readChannelSortOutbox("pk-win", RELAY), + null, + "outbox must be cleared once the edit is published", + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 4c. Timestamp clamp: a remote head far in the future must not make the +// published createdAt walk past the relay's ±15min window. +// Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), +// which exceeds now + MAX_PUBLISH_FUTURE_SECS. +test("timestamp clamp: published createdAt stays inside the relay future window", async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + let call = 0; + mock.method(relayClient, "fetchEvents", () => { + call++; + // First call primes lastRemoteCreatedAt to farFutureHead (undecryptable so + // no adopt); pre-publish call returns created_at=0 so the local edit wins. + return Promise.resolve([ + { + pubkey: "pk-clamp", + content: "bad-cipher", + created_at: call === 1 ? farFutureHead : 0, + id: "evt-clamp", + }, + ]); + }); + let signedCreatedAt = null; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(JSON.stringify({ version: 1, groups: {} })); + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new ChannelSortSyncManager("pk-clamp", RELAY); + await manager.fetchRemoteSortPrefs(); // prime lastRemoteCreatedAt + manager.publishSortPrefs(makeStore({ channels: "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); + assert.ok( + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Durable lane: outbox resume, retry, and cross-generation safety ────────── + +// 5. Durable outbox resume: an edit made <2s before quit (destroy inside the +// debounce) is persisted, and a fresh manager resuming from that outbox +// publishes it — the edit is not silently dropped at teardown. +// Mutation: dropping writeChannelSortOutbox leaves the outbox null → no resume. +test("durable outbox: edit destroyed inside the debounce resumes and publishes on remount", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + // Window 1: edit then destroy before the debounce fires. + const m1 = new ChannelSortSyncManager("pk-resume", RELAY); + m1.publishSortPrefs(makeStore({ channels: "recent" })); + const persisted = readChannelSortOutbox("pk-resume", RELAY); + assert.ok(persisted !== null, "edit must be persisted before teardown"); + m1.destroy(); + assert.equal(publishCalls.length, 0, "destroy must not flush"); + + // Window 2: resume the persisted outbox (the hook does this after bootstrap + // via readChannelSortOutbox, which enumerates every window's outbox key). + const m2 = new ChannelSortSyncManager("pk-resume", RELAY); + m2.publishSortPrefs(persisted.store); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(publishCalls.length, 1, "resumed edit must publish"); + assert.equal( + readChannelSortOutbox("pk-resume", RELAY), + null, + "outbox must be cleared once the resumed edit publishes", + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 6. Failed publish retries with no later edit: a transient publish rejection +// keeps the pending edit and schedules a bounded-backoff retry that succeeds, +// rather than logging-and-dropping (the pre-fix behaviour). +// Mutation: reverting scheduleRetry to a bare console.warn leaves pending null +// and never re-publishes. +test("failed publish retries the retained edit without a later edit", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + let attempts = 0; + mock.method(relayClient, "publishEvent", () => { + attempts++; + if (attempts === 1) return Promise.reject(new Error("socket timeout")); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSortSyncManager("pk-retry", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + fw._fireTimer(); // debounce → doPublish → publish rejects → scheduleRetry + await new Promise((r) => setTimeout(r, 20)); + assert.equal(attempts, 1, "first publish attempt rejected"); + assert.ok( + manager.getPendingStore() !== null, + "failed publish must retain the pending edit", + ); + assert.ok(fw._hasTimer(), "a bounded-backoff retry must be scheduled"); + fw._fireTimer(); // retry → doPublish → publish resolves + await new Promise((r) => setTimeout(r, 20)); + assert.equal(attempts, 2, "retry must re-attempt the publish"); + assert.equal( + manager.getPendingStore(), + null, + "successful retry must clear the pending edit", + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 7. Overlapping publishes: an older completion must not erase a newer queued +// edit or its outbox (generation compare-and-swap). +// Mutation: dropping the gen guard in discardPending clears B on A's completion. +test("overlapping publishes: older completion does not erase a newer queued edit", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + let releaseFirst = null; + let publishCalls = 0; + mock.method(relayClient, "publishEvent", () => { + publishCalls++; + if (publishCalls === 1) { + return new Promise((res) => { + releaseFirst = res; + }); + } + return Promise.resolve(); + }); + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const fakeWindow = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + const fireDelay = async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + await Promise.resolve(); + await Promise.resolve(); + }; + const restore = installFakeWindow(fakeWindow); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelSortSyncManager("pk-overlap", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); // A + await fireDelay(2000); // debounce → doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + manager.publishSortPrefs(makeStore({ dms: "alpha" })); // B while A in flight + assert.deepEqual( + Object.keys(manager.getPendingStore()?.groups ?? {}), + ["dms"], + "B is now the pending edit", + ); + assert.deepEqual( + Object.keys( + readChannelSortOutbox("pk-overlap", RELAY)?.store.groups ?? {}, + ), + ["dms"], + "outbox holds B", + ); + + releaseFirst(); // A completes — must NOT clear B's pending/outbox + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual( + Object.keys(manager.getPendingStore()?.groups ?? {}), + ["dms"], + "older completion must leave B pending", + ); assert.ok( - JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, - `remote groups must win LWW merge — got: ${pt}`, + readChannelSortOutbox("pk-overlap", RELAY) !== null, + "older completion must leave B's outbox intact", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 8. Live remote during debounce is adopted at pre-publish, not overwritten. The +// live event advances the mutable watermark before doPublish runs, so +// comparing the fetched head against the watermark would see equality and +// publish over the newer remote. The pre-publish check compares against the +// baseline frozen at publishSortPrefs instead. +// Mutation: comparing against lastRemoteCreatedAt rather than publishBaseline +// republishes local over the newer remote. +test("live remote during debounce is adopted at pre-publish, not overwritten", async () => { + const REMOTE_KEY = "remote-during-debounce"; + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + // Pre-publish fetch returns the same newer head the live event delivered. + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ + { + pubkey: "pk-live-deb", + content: "good-cipher", + created_at: 500, + id: "evt-live", + }, + ]), + ); + const publishCalls = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), + ); + try { + const manager = new ChannelSortSyncManager("pk-live-deb", RELAY); + const adopted = []; + manager.setOnRemoteAdopted((r) => adopted.push(r)); + await manager.subscribeToSortPrefs(() => {}); + // Local edit queued first, freezing the baseline at the empty head. + manager.publishSortPrefs(makeStore({ "local-group": "alpha" })); + // A newer remote head arrives during the debounce window (advances the + // mutable watermark to created_at=500). + liveCallback({ + pubkey: "pk-live-deb", + content: "good-cipher", + created_at: 500, + id: "evt-live", + }); + await new Promise((r) => setTimeout(r, 0)); + fw._fireTimer(); // debounce → doPublish → pre-publish sees advanced head + await new Promise((r) => setTimeout(r, 20)); + assert.equal( + publishCalls.length, + 0, + "must not publish over a remote that became head during the debounce", ); + assert.equal(adopted.length, 1, "the newer remote must be adopted"); } finally { tauri.restore(); restore(); @@ -215,7 +529,7 @@ test("revert-fix: sort LWW — newer decryptable pre-publish event selected afte } }); -// 5. live-sub: undecryptable event on live path records head before decrypt +// 9. live-sub: undecryptable event on live path records head before decrypt. // Mutation test: removing recordRemoteHead before decrypt in the live callback // leaves watermark at 0 after a live event. test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index fe71fe62dfa..206acd4c479 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -7,11 +7,14 @@ import { import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_SORT } from "@/shared/constants/kinds"; import { + clearChannelSortOutbox, parseChannelSortPayload, + writeChannelSortOutbox, type ChannelSortStore, } from "./channelSortPreference"; import { advanceWatermark, + clampPublishCreatedAt, readWatermark, runBootstrap, type FetchResult, @@ -21,12 +24,75 @@ const D_TAG = "channel-sort"; const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; +// Bounded backoff for a retained pending edit whose publish failed transiently +// (timeout / socket error) on an otherwise-healthy socket, so it does not wait +// for a reconnect that may never fire. +const RETRY_BASE_MS = 2_000; +const RETRY_MAX_MS = 30_000; + export type RemoteSortPrefs = { store: ChannelSortStore; createdAt: number; eventId: string; }; +/** + * Outcome of the pre-publish head check. + * + * - `publish` — local edit is at or ahead of the head; publish it. + * - `adopt` — a newer remote head exists; the local edit lost whole-blob LWW + * and must be discarded in favour of the remote store so UI and + * relay converge. The manager hands the remote back to the hook + * and never publishes. + */ +type PublishDecision = + | { kind: "publish"; store: ChannelSortStore } + | { kind: "adopt"; remote: RemoteSortPrefs }; + +/** + * The canonical remote head as it stood when an edit was queued. The pre-publish + * check compares the fetched head against this frozen baseline — never against + * the mutable in-memory watermark, which a live event observed during the + * debounce window may already have advanced to that same head (silently + * suppressing the adopt). + */ +type PublishBaseline = { createdAt: number; eventId: string }; + +/** + * True when `head` is the canonical winner over the baseline the edit was queued + * against — i.e. the head advanced since the edit began. Canonical order is + * `created_at DESC, id ASC`: a strictly-later head wins, and a same-second head + * wins only with a strictly-lower id. A same-second head is comparable only once + * the baseline id is known (empty id = no prior head seen → not superseded). + */ +function remoteAdvancedSince( + head: RemoteSortPrefs, + baseline: PublishBaseline, +): boolean { + if (head.createdAt > baseline.createdAt) return true; + return ( + head.createdAt === baseline.createdAt && + baseline.eventId !== "" && + head.eventId < baseline.eventId + ); +} + +/** + * True when tuple `a` is the canonical winner over `b` (`created_at DESC, + * id ASC`). An empty id means "no head seen yet" and always loses. + */ +function canonicalGreater(a: PublishBaseline, b: PublishBaseline): boolean { + if (a.eventId === "") return false; + if (b.eventId === "") return true; + if (a.createdAt !== b.createdAt) return a.createdAt > b.createdAt; + return a.eventId < b.eventId; +} + +/** The canonical-greater of two head tuples (`created_at DESC, id ASC`). */ +function canonicalMax(a: PublishBaseline, b: PublishBaseline): PublishBaseline { + return canonicalGreater(a, b) ? a : b; +} + async function decryptAndParse( event: RelayEvent, ): Promise { @@ -48,15 +114,48 @@ async function decryptAndParse( * low-frequency preference blob, so whole-blob LWW (like sections) is * sufficient — per-key merge (like stars/mutes) would be unnecessary * complexity here. + * + * The durable lane (outbox + generation/CAS + bounded retry) is mirrored from + * sections so a sort edit made just before quit/community-switch, or one whose + * publish times out, self-heals rather than being silently dropped. */ export class ChannelSortSyncManager { private pubkey: string; private relayUrl: string; private debounceTimer: number | null = null; + private retryTimer: number | null = null; + private retryDelayMs = RETRY_BASE_MS; private lastRemoteCreatedAt: number; + // Canonical best head observed so far (`created_at DESC, id ASC`). Frozen into + // a per-edit baseline at publishSortPrefs so the pre-publish check can tell + // whether the head advanced *since the edit was queued*, independent of the + // mutable watermark that a live event during the debounce window may advance. + private lastRemoteHead: PublishBaseline = { createdAt: 0, eventId: "" }; + // The canonical head this pending edit is racing against, frozen when the edit + // was queued and advanced ONLY by our own successful publishes. + private publishBaseline: PublishBaseline = { createdAt: 0, eventId: "" }; private pendingStore: ChannelSortStore | null = null; + // Monotonic id for the current pending edit. Every publishSortPrefs() bumps + // it; every scheduled publish/retry captures the value it was queued for. A + // completion (success, adopt, or no-op) may only clear pending state via + // compare-and-swap on this generation, so an older in-flight publish can + // never erase a newer edit that arrived while it was in flight. + private pendingGeneration = 0; + // Publish cycles are serialized: at most one runs at a time. A newer edit + // queued while a cycle is in flight defers; the in-flight cycle's completion + // re-drives it. This kills the cross-generation race class by construction. + private publishInFlight = false; + // Event ids we signed and sent to the relay but whose ACK never arrived. The + // relay MAY have accepted such a write, so if a later cycle's pre-publish + // fetch returns a head whose id is in this set, that head is OUR OWN accepted + // predecessor — fold it forward and publish above it, rather than adopting it + // and erasing the queued edit. + private ambiguousAttemptIds = new Set(); private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; + // Set by the hook so an adopted remote head (local edit lost whole-blob LWW) + // is written through to React state + localStorage. + private onRemoteAdopted: ((remote: RemoteSortPrefs) => void) | null = null; constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; @@ -64,6 +163,11 @@ export class ChannelSortSyncManager { this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } + /** Register the hook's adopt-remote sink (write-through to UI + storage). */ + setOnRemoteAdopted(cb: (remote: RemoteSortPrefs) => void): void { + this.onRemoteAdopted = cb; + } + async fetchRemoteSortPrefs(): Promise> { try { const events = await relayClient.fetchEvents({ @@ -76,7 +180,10 @@ export class ChannelSortSyncManager { return { status: "absent" }; } const event = events[0]; - this.recordRemoteHead(event.created_at); + // An event exists — record its created_at regardless of whether we can + // decrypt it, so seed-publish is blocked even when the payload is + // unreadable (e.g. wrong key). + this.recordRemoteHead(event.created_at, event.id); const result = await decryptAndParse(event); if (!result) { return { status: "failed", createdAt: event.created_at }; @@ -92,10 +199,21 @@ export class ChannelSortSyncManager { } } - private recordRemoteHead(createdAt: number): void { + /** Update in-memory + persisted watermark and the canonical head tuple. */ + private recordRemoteHead(createdAt: number, eventId: string): void { if (createdAt > this.lastRemoteCreatedAt) { this.lastRemoteCreatedAt = createdAt; } + // Track the canonical-best head (`created_at DESC, id ASC`): a later head + // always wins; a same-second head wins only with a strictly-lower id. + if ( + createdAt > this.lastRemoteHead.createdAt || + (createdAt === this.lastRemoteHead.createdAt && + (this.lastRemoteHead.eventId === "" || + eventId < this.lastRemoteHead.eventId)) + ) { + this.lastRemoteHead = { createdAt, eventId }; + } advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } @@ -104,26 +222,113 @@ export class ChannelSortSyncManager { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; } + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } } getPendingStore(): ChannelSortStore | null { return this.pendingStore; } + /** True while an unpublished local edit is queued (debouncing or retrying). */ + hasPendingEdit(): boolean { + return this.pendingStore !== null; + } + + /** + * Adopt a remote store that superseded a local edit: hand it to the hook for + * write-through, advance the watermark, and drop the losing pending edit — + * including the durable outbox, so the outbox can never replay an edit that + * adopt just decided lost. + * + * Compare-and-swap on `gen`: if a newer edit arrived while this publish was in + * flight, the generation has moved on and that newer edit is the latest writer + * — a stale adopt must not clear its pending state or overwrite its optimistic + * UI. We still advance the watermark (monotonic and always safe). + */ + private adoptRemote(remote: RemoteSortPrefs, gen: number): void { + this.recordRemoteHead(remote.createdAt, remote.eventId); + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelSortOutbox(this.pubkey, this.relayUrl); + this.lastPublishedStore = remote.store; + if (this.destroyed) return; + this.onRemoteAdopted?.(remote); + } + + /** + * Clear the in-memory pending edit and this window's own durable outbox key — + * but only if the completing publish still owns the current generation. A + * publish for an older edit that finishes after a newer edit was queued must + * leave the newer edit (and its retry state) untouched. + */ + private discardPending(gen: number): void { + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelSortOutbox(this.pubkey, this.relayUrl); + } + publishSortPrefs(store: ChannelSortStore): void { this.pendingStore = store; + ++this.pendingGeneration; + // Freeze the canonical head this edit is racing against at queue time so a + // live event applied during the debounce window (which advances the mutable + // watermark) cannot make the pre-publish comparison see equality and fall + // through to a publish that overwrites a remote that became head after this + // edit was queued. The baseline only advances via our own successful + // publishes, so a prior generation's own accepted write is folded in rather + // than mistaken for a competing remote. + this.publishBaseline = { ...this.lastRemoteHead }; + // Persist synchronously so an edit made <2s before quit/community-switch + // survives teardown and resumes on next mount (durable outbox). This + // window's own key is the only one written — a single unconditional + // setItem, never a shared-key read-modify-write; `queuedAt` stamps it so + // resume replays only the newest queued blob (whole-blob LWW). + writeChannelSortOutbox(this.pubkey, store, this.relayUrl); if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } + // A fresh edit supersedes any retry scheduled for the previous generation. + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = RETRY_BASE_MS; this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; - void this.doPublish(store); + this.startCycle(); }, DEBOUNCE_MS); } + /** + * Serialize publish cycles: at most one runs at a time. A debounce/retry timer + * that fires while a cycle is in flight defers — the in-flight cycle's + * completion re-drives if a pending edit still needs publishing. + */ + private startCycle(): void { + if (this.destroyed || this.pendingStore === null) return; + if (this.publishInFlight) return; + const store = this.pendingStore; + const gen = this.pendingGeneration; + this.publishInFlight = true; + void this.doPublish(store, gen).finally(() => { + this.publishInFlight = false; + if ( + !this.destroyed && + this.pendingStore !== null && + this.debounceTimer === null && + this.retryTimer === null + ) { + this.startCycle(); + } + }); + } + private async fetchOwnBlobBeforePublish( store: ChannelSortStore, - ): Promise { + ): Promise { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SORT], @@ -131,23 +336,35 @@ export class ChannelSortSyncManager { "#d": [D_TAG], limit: 1, }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; + if (events.length === 0 || events[0].pubkey !== this.pubkey) + return { kind: "publish", store }; const event = events[0]; - // Snapshot the watermark before advancing it: after recordRemoteHead - // runs, lastRemoteCreatedAt equals event.created_at, so the LWW - // comparison remote.createdAt > lastRemoteCreatedAt would always be - // false and silently suppress the merge. - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); - if (!remote) return store; - // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > headBeforeFetch) { - return remote.store; + // Record the head after decrypt attempt so the watermark/head-tuple + // advance even for an undecryptable payload. + this.recordRemoteHead(event.created_at, event.id); + if (!remote) return { kind: "publish", store }; + // Sort prefs use whole-blob LWW. Compare the fetched head against the + // baseline frozen when this edit was queued — NOT the live watermark. If + // the canonical head advanced since the edit began, the local edit lost + // and is adopted-away rather than republished over it. + if (remoteAdvancedSince(remote, this.publishBaseline)) { + // Unless the advancing head is a prior publish of OURS whose ACK was + // lost: it is our own accepted predecessor, not a competing remote. + // Fold it into the baseline and publish above it so the queued edit + // survives instead of adopting our own stale write away. + if (this.ambiguousAttemptIds.has(remote.eventId)) { + this.publishBaseline = canonicalMax(this.publishBaseline, { + createdAt: remote.createdAt, + eventId: remote.eventId, + }); + return { kind: "publish", store }; + } + return { kind: "adopt", remote }; } - return store; + return { kind: "publish", store }; } catch { - return store; + return { kind: "publish", store }; } } @@ -164,15 +381,40 @@ export class ChannelSortSyncManager { return true; } - private async doPublish(store: ChannelSortStore): Promise { + /** Schedule a bounded-backoff retry of the retained pending edit. */ + private scheduleRetry(gen: number): void { + if (this.destroyed || this.pendingStore === null) return; + // A newer edit has superseded this one; its own timer owns the retry. + if (gen !== this.pendingGeneration) return; + if (this.retryTimer !== null) return; + const delay = this.retryDelayMs; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, RETRY_MAX_MS); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.startCycle(); + }, delay); + } + + private async doPublish(store: ChannelSortStore, gen: number): Promise { + // A newer edit was queued after this publish was scheduled; it owns the + // pending state and will publish the latest store — abandon this stale run. + if (gen !== this.pendingGeneration) return; try { - const merged = await this.fetchOwnBlobBeforePublish(store); + const decision = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. + // was awaited (community switch during in-flight fetch). if (this.destroyed) return; + // A newer edit was queued while we awaited the pre-publish fetch. It owns + // convergence now; the serialized cycle re-drives for it once this run + // unwinds. + if (gen !== this.pendingGeneration) return; + if (decision.kind === "adopt") { + this.adoptRemote(decision.remote, gen); + return; + } + const merged = decision.store; if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + this.discardPending(gen); return; } const payload = { @@ -180,10 +422,10 @@ export class ChannelSortSyncManager { groups: merged.groups, }; const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); + // Clamp inside the relay's future-drift window so a skewed remote head + // can never make us stamp an unbounded future timestamp that wedges every + // subsequent publish; we adopt such a head on the next fetch instead. + const createdAt = clampPublishCreatedAt(this.lastRemoteCreatedAt); const event = await signRelayEvent({ kind: KIND_CHANNEL_SORT, content: ciphertext, @@ -193,20 +435,49 @@ export class ChannelSortSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - // Final guard immediately before the network call — sign/encrypt are - // synchronous-ish but cheap; the relay socket may have moved to a - // different community by the time we reach this point. - if (this.destroyed) return; + // Final guard immediately before the network call — a newer edit may have + // been queued during the encrypt/sign await, or the manager destroyed. + if (this.destroyed || gen !== this.pendingGeneration) return; + // Record this signed id as an in-flight attempt of unknown fate before we + // send it. If the ACK is lost below, a later cycle that fetches this id as + // the head recognises it as our own accepted write and folds it forward + // rather than adopting it away. + this.ambiguousAttemptIds.add(event.id); await relayClient.publishEvent( event, "Timed out publishing channel sort preferences.", "Failed to publish channel sort preferences.", ); - this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; + this.recordRemoteHead(event.created_at, event.id); + // This write is now the confirmed accepted head; it dominates every prior + // attempt (`created_at DESC, id ASC`), so no earlier ambiguous id can ever + // be the canonical head again. Clear the set to keep it bounded. + this.ambiguousAttemptIds.clear(); + // Fold our own accepted head into the pending edit's baseline — + // unconditional across generations so a newer edit's pre-publish check + // does not mistake OUR prior publish for a competing remote and adopt it + // away. Genuine remotes never fold in here. + this.publishBaseline = canonicalMax(this.publishBaseline, { + createdAt: event.created_at, + eventId: event.id, + }); + // Only claim this store as the published head if it is still the current + // edit; a newer edit queued mid-flight owns lastPublishedStore now. + if (gen === this.pendingGeneration) { + this.lastPublishedStore = merged; + this.retryDelayMs = RETRY_BASE_MS; + } + this.discardPending(gen); } catch (error) { + if (this.destroyed) return; + // Ambiguous outcome: the publish promise rejected (timeout / socket + // error), but the relay may already have accepted the write before the + // ACK was lost. Keep the pending edit and retry with backoff. The attempt + // id stays in ambiguousAttemptIds: if the relay did accept it, a later + // cycle that fetches this id as the head folds it forward as our own + // accepted predecessor instead of adopting it away. console.warn("[channelSortSync] publish failed:", error); + this.scheduleRetry(gen); } } @@ -224,7 +495,7 @@ export class ChannelSortSyncManager { if (event.pubkey !== this.pubkey) return; // Record the raw head before decrypt so an undecryptable live event // still advances the watermark and blocks future seed-publish. - this.recordRemoteHead(event.created_at); + this.recordRemoteHead(event.created_at, event.id); void decryptAndParse(event).then((result) => { if (result) { onUpdate(result); @@ -252,10 +523,10 @@ export class ChannelSortSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's sort prefs to relay B via the shared relayClient - // singleton. On return, bootstrap's found path whole-blob-replaces from - // remote, so any dropped pending edit is lost. + // Debounce-window changes are NOT lost: publishSortPrefs persisted them to + // the durable outbox synchronously, and the next mount resumes them. + // Flushing here is still avoided — it could publish relay A's sort prefs to + // relay B via the shared relayClient singleton. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index 1585a42d47e..bbdf9061d12 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -4,29 +4,50 @@ import test from "node:test"; import { boundStarStore, MAX_CHANNEL_STAR_ENTRIES, - parseStarPayload, mergeStores, + parseStarPayload, starredChannelIdsFromStore, } from "./channelStarsStorage.ts"; // ── parseStarPayload ────────────────────────────────────────────────────────── -test("parseStarPayload: valid payload with channels returns store", () => { +test("parseStarPayload: valid payload with channels returns store (rev preserved)", () => { const payload = { version: 1, channels: { - "chan-1": { starred: true, updatedAt: 1000 }, - "chan-2": { starred: false, updatedAt: 2000 }, + "chan-1": { starred: true, updatedAt: 1000, rev: 3 }, + "chan-2": { starred: false, updatedAt: 2000, rev: 0 }, }, }; - const result = parseStarPayload(payload); - assert.deepEqual(result, { + assert.deepEqual(parseStarPayload(payload), payload); +}); + +test("parseStarPayload: missing rev normalizes to 0 (old-build blob, entry kept)", () => { + const result = parseStarPayload({ + version: 1, + channels: { "chan-1": { starred: true, updatedAt: 1000 } }, + }); + assert.deepEqual(result.channels["chan-1"], { + starred: true, + updatedAt: 1000, + rev: 0, + }); +}); + +test("parseStarPayload: malformed rev (string / negative / non-integer / NaN) normalizes to 0", () => { + const result = parseStarPayload({ version: 1, channels: { - "chan-1": { starred: true, updatedAt: 1000 }, - "chan-2": { starred: false, updatedAt: 2000 }, + str: { starred: true, updatedAt: 1, rev: "5" }, + neg: { starred: true, updatedAt: 1, rev: -2 }, + frac: { starred: true, updatedAt: 1, rev: 1.5 }, + nan: { starred: true, updatedAt: 1, rev: NaN }, }, }); + for (const id of ["str", "neg", "frac", "nan"]) { + assert.equal(result.channels[id].rev, 0, `${id} rev normalized to 0`); + assert.equal(result.channels[id].starred, true, `${id} entry kept`); + } }); test("parseStarPayload: missing version returns null", () => { @@ -48,18 +69,15 @@ test("parseStarPayload: wrong version returns null", () => { ); }); -test("parseStarPayload: null input returns null", () => { +test("parseStarPayload: null / non-object input returns null", () => { assert.equal(parseStarPayload(null), null); -}); - -test("parseStarPayload: non-object input returns null", () => { assert.equal(parseStarPayload("string"), null); assert.equal(parseStarPayload(42), null); assert.equal(parseStarPayload(true), null); }); test("parseStarPayload: malformed channel entries missing starred/updatedAt are filtered out", () => { - const payload = { + const result = parseStarPayload({ version: 1, channels: { "no-starred": { updatedAt: 1000 }, @@ -69,267 +87,251 @@ test("parseStarPayload: malformed channel entries missing starred/updatedAt are "updated-at-wrong-type": { starred: true, updatedAt: "now" }, null: null, }, - }; - const result = parseStarPayload(payload); + }); assert.deepEqual(result, { version: 1, - channels: { - valid: { starred: false, updatedAt: 500 }, - }, + channels: { valid: { starred: false, updatedAt: 500, rev: 0 } }, }); }); test("parseStarPayload: NaN/Infinity/negative updatedAt entries are filtered out", () => { - const payload = { + const result = parseStarPayload({ version: 1, channels: { nan: { starred: true, updatedAt: NaN }, inf: { starred: true, updatedAt: Infinity }, "neg-inf": { starred: true, updatedAt: -Infinity }, neg: { starred: true, updatedAt: -1 }, - valid: { starred: true, updatedAt: 100 }, + valid: { starred: true, updatedAt: 100, rev: 2 }, }, - }; - const result = parseStarPayload(payload); + }); assert.deepEqual(result, { version: 1, - channels: { valid: { starred: true, updatedAt: 100 } }, + channels: { valid: { starred: true, updatedAt: 100, rev: 2 } }, }); }); -test("parseStarPayload: empty channels returns store with empty channels", () => { - const result = parseStarPayload({ version: 1, channels: {} }); - assert.deepEqual(result, { version: 1, channels: {} }); +test("parseStarPayload: empty channels / no channels key returns empty store", () => { + assert.deepEqual(parseStarPayload({ version: 1, channels: {} }), { + version: 1, + channels: {}, + }); + assert.deepEqual(parseStarPayload({ version: 1 }), { + version: 1, + channels: {}, + }); }); -test("parseStarPayload: version 1 with no channels key returns store with empty channels", () => { - const result = parseStarPayload({ version: 1 }); - assert.deepEqual(result, { version: 1, channels: {} }); -}); +// ── mergeStores: tuple order (updatedAt → rev → value) ──────────────────────── -// ── mergeStores ─────────────────────────────────────────────────────────────── +const E = (starred, updatedAt, rev) => ({ starred, updatedAt, rev }); +const S = (entry) => ({ version: 1, channels: { c: entry } }); -test("mergeStores: non-overlapping channels returns union of both", () => { - const local = { - version: 1, - channels: { "chan-a": { starred: true, updatedAt: 100 } }, - }; - const remote = { - version: 1, - channels: { "chan-b": { starred: false, updatedAt: 200 } }, - }; - const result = mergeStores(local, remote); +test("mergeStores: non-overlapping channels returns union", () => { + const result = mergeStores( + { version: 1, channels: { a: E(true, 100, 1) } }, + { version: 1, channels: { b: E(false, 200, 1) } }, + ); assert.deepEqual(result, { version: 1, - channels: { - "chan-a": { starred: true, updatedAt: 100 }, - "chan-b": { starred: false, updatedAt: 200 }, - }, + channels: { a: E(true, 100, 1), b: E(false, 200, 1) }, }); }); -test("mergeStores: overlapping channel with remote newer takes remote", () => { - const local = { - version: 1, - channels: { "chan-a": { starred: false, updatedAt: 100 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { starred: true, updatedAt: 200 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { - starred: true, - updatedAt: 200, - }); +test("mergeStores: strictly-later updatedAt wins regardless of rev (primary key)", () => { + // Later updatedAt with LOWER rev still wins — updatedAt is primary. This is + // the old-build interop case: an old build's rev-0 fresh edit beats a stale + // rev-bearing new-build entry. + const result = mergeStores(S(E(false, 200, 0)), S(E(true, 100, 7))); + assert.deepEqual(result.channels.c, E(false, 200, 0)); }); -test("mergeStores: overlapping channel with local newer takes local", () => { - const local = { - version: 1, - channels: { "chan-a": { starred: true, updatedAt: 300 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { starred: false, updatedAt: 100 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { - starred: true, - updatedAt: 300, - }); +test("mergeStores: equal updatedAt → higher rev wins (same-second tiebreak)", () => { + const result = mergeStores(S(E(false, 100, 5)), S(E(true, 100, 2))); + assert.deepEqual(result.channels.c, E(false, 100, 5)); }); -test("mergeStores: overlapping channel with same updatedAt local wins", () => { - const local = { - version: 1, - channels: { "chan-a": { starred: true, updatedAt: 500 } }, - }; - const remote = { - version: 1, - channels: { "chan-a": { starred: false, updatedAt: 500 } }, - }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { - starred: true, - updatedAt: 500, - }); +test("mergeStores: equal updatedAt AND equal rev → starred=true wins (leaf)", () => { + const result = mergeStores(S(E(false, 100, 3)), S(E(true, 100, 3))); + assert.deepEqual(result.channels.c, E(true, 100, 3)); }); test("mergeStores: unstar with higher updatedAt overrides star", () => { - const local = { - version: 1, - channels: { "chan-a": { starred: true, updatedAt: 100 } }, + const result = mergeStores(S(E(true, 100, 9)), S(E(false, 999, 1))); + assert.deepEqual(result.channels.c, E(false, 999, 1)); +}); + +test("mergeStores: empty local / empty remote / both empty", () => { + assert.deepEqual( + mergeStores({ version: 1, channels: {} }, S(E(true, 42, 1))).channels.c, + E(true, 42, 1), + ); + assert.deepEqual( + mergeStores(S(E(false, 10, 2)), { version: 1, channels: {} }).channels.c, + E(false, 10, 2), + ); + assert.deepEqual( + mergeStores({ version: 1, channels: {} }, { version: 1, channels: {} }), + { version: 1, channels: {} }, + ); +}); + +// ── mergeStores: algebra (commutativity, associativity, idempotence) ────────── + +function randEntry(rng) { + return { + starred: rng() > 0.5, + updatedAt: Math.floor(rng() * 5), + rev: Math.floor(rng() * 5), }; - const remote = { - version: 1, - channels: { "chan-a": { starred: false, updatedAt: 999 } }, +} +function randStore(rng, ids) { + const channels = {}; + for (const id of ids) if (rng() > 0.3) channels[id] = randEntry(rng); + return { version: 1, channels }; +} +// Deterministic LCG so failures reproduce. +function lcg(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels["chan-a"], { - starred: false, - updatedAt: 999, - }); +} + +test("mergeStores: commutative — merge(a,b) === merge(b,a)", () => { + const rng = lcg(12345); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + assert.deepEqual(mergeStores(a, b), mergeStores(b, a)); + } +}); + +test("mergeStores: associative — merge(merge(a,b),c) === merge(a,merge(b,c))", () => { + const rng = lcg(67890); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const c = randStore(rng, ids); + assert.deepEqual( + mergeStores(mergeStores(a, b), c), + mergeStores(a, mergeStores(b, c)), + ); + } }); -test("mergeStores: empty local returns remote entries", () => { - const local = { version: 1, channels: {} }; - const remote = { +test("mergeStores: idempotent — merge(a, merge(a,b)) === merge(a,b)", () => { + const rng = lcg(24680); + const ids = ["a", "b", "c", "d"]; + for (let i = 0; i < 200; i++) { + const a = randStore(rng, ids); + const b = randStore(rng, ids); + const ab = mergeStores(a, b); + assert.deepEqual(mergeStores(a, ab), ab); + assert.deepEqual(mergeStores(ab, ab), ab); + } +}); + +// ── v1-blob bidirectional compatibility ─────────────────────────────────────── + +test("v1 compat: a rev-carrying blob round-trips through a rev-less parser view", () => { + // Simulate an old build reading our blob: JSON-serialize our rev-carrying + // payload, parse it back — version stays 1 so it is NOT rejected, and the + // core fields survive (old build simply ignores rev). + const ours = { version: 1, - channels: { "chan-b": { starred: true, updatedAt: 42 } }, + channels: { c: { starred: true, updatedAt: 100, rev: 7 } }, }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels, { - "chan-b": { starred: true, updatedAt: 42 }, - }); + const roundTripped = parseStarPayload(JSON.parse(JSON.stringify(ours))); + assert.equal(roundTripped.version, 1, "version stays 1 — old build accepts"); + assert.equal(roundTripped.channels.c.starred, true); + assert.equal(roundTripped.channels.c.updatedAt, 100); }); -test("mergeStores: empty remote returns local entries", () => { - const local = { +test("v1 compat: old-build unstar (no rev, updatedAt+1) beats our stale star", () => { + // New build wrote {starred:true, rev:7, updatedAt:t}; old build (no rev) + // unstars producing {starred:false, updatedAt:t+1}. The unstar wins on the + // primary updatedAt key — old builds can still edit upgraded channels. + const ours = S(E(true, 100, 7)); + const oldBuildUnstar = parseStarPayload({ version: 1, - channels: { "chan-a": { starred: false, updatedAt: 10 } }, - }; - const remote = { version: 1, channels: {} }; - const result = mergeStores(local, remote); - assert.deepEqual(result.channels, { - "chan-a": { starred: false, updatedAt: 10 }, + channels: { c: { starred: false, updatedAt: 101 } }, + }); + assert.deepEqual(mergeStores(ours, oldBuildUnstar).channels.c, { + starred: false, + updatedAt: 101, + rev: 0, }); }); -test("mergeStores: both empty returns empty", () => { - const result = mergeStores( - { version: 1, channels: {} }, - { version: 1, channels: {} }, - ); - assert.deepEqual(result, { version: 1, channels: {} }); -}); +// ── boundStarStore ──────────────────────────────────────────────────────────── test("boundStarStore: retains newest entries regardless of starred value", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ - `active-${index}`, - { starred: true, updatedAt: index + 1 }, + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), ]), ); - channels["old-false"] = { starred: false, updatedAt: 0 }; - channels["new-false"] = { starred: false, updatedAt: 9999 }; - + channels["old-false"] = E(false, 0, 0); + channels["new-false"] = E(false, 9999, 0); const result = boundStarStore({ version: 1, channels }); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); assert.equal(result.channels["old-false"], undefined); - assert.deepEqual(result.channels["new-false"], { - starred: false, - updatedAt: 9999, - }); + assert.deepEqual(result.channels["new-false"], E(false, 9999, 0)); assert.equal(result.channels["active-0"], undefined); - assert.deepEqual(result.channels["active-1"], { - starred: true, - updatedAt: 2, - }); }); test("boundStarStore: uses channel ID as an updatedAt tie-breaker", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES + 1 }, (_, index) => [ - `channel-${String(MAX_CHANNEL_STAR_ENTRIES - index).padStart(3, "0")}`, - { starred: true, updatedAt: 1 }, + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES + 1 }, (_, i) => [ + `channel-${String(MAX_CHANNEL_STAR_ENTRIES - i).padStart(3, "0")}`, + E(true, 1, 0), ]), ); - const result = boundStarStore({ version: 1, channels }); - assert.equal(result.channels["channel-000"], undefined); - assert.deepEqual(result.channels["channel-500"], { - starred: true, - updatedAt: 1, - }); + assert.deepEqual(result.channels["channel-500"], E(true, 1, 0)); }); -test("boundStarStore: preserves a same-second star mutation by key", () => { +test("boundStarStore: preserves a same-second mutation by key", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ - `z-channel-${String(index).padStart(3, "0")}`, - { starred: true, updatedAt: 1 }, + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, i) => [ + `z-channel-${String(i).padStart(3, "0")}`, + E(true, 1, 0), ]), ); - channels["a-target"] = { starred: true, updatedAt: 1 }; - - const result = boundStarStore({ version: 1, channels }, "a-target"); - - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); - assert.deepEqual(result.channels["a-target"], { - starred: true, - updatedAt: 1, - }); - assert.equal(result.channels["z-channel-000"], undefined); -}); - -test("boundStarStore: preserves a same-second unstar mutation by key", () => { - const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ - `z-channel-${String(index).padStart(3, "0")}`, - { starred: true, updatedAt: 1 }, - ]), - ); - channels["a-target"] = { starred: false, updatedAt: 1 }; - + channels["a-target"] = E(false, 1, 1); const result = boundStarStore({ version: 1, channels }, "a-target"); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); - assert.deepEqual(result.channels["a-target"], { - starred: false, - updatedAt: 1, - }); + assert.deepEqual(result.channels["a-target"], E(false, 1, 1)); assert.equal(result.channels["z-channel-000"], undefined); }); test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () => { const channels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ - `active-${index}`, - { starred: true, updatedAt: index + 1 }, + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 1, 0), ]), ); - channels["unstarred"] = { starred: false, updatedAt: 9999 }; + channels.unstarred = E(false, 9999, 1); const bounded = boundStarStore({ version: 1, channels }); - const result = mergeStores(bounded, { version: 1, - channels: { unstarred: { starred: true, updatedAt: 9998 } }, - }); - - assert.deepEqual(result.channels.unstarred, { - starred: false, - updatedAt: 9999, + channels: { unstarred: E(true, 9998, 5) }, }); + assert.deepEqual(result.channels.unstarred, E(false, 9999, 1)); }); test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { const localChannels = Object.fromEntries( - Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ - `active-${index}`, - { starred: true, updatedAt: index + 10 }, + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, i) => [ + `active-${i}`, + E(true, i + 10, 0), ]), ); const result = mergeStores( @@ -337,59 +339,112 @@ test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimme { version: 1, channels: { - "evicted-id": { starred: true, updatedAt: 9999 }, - "active-0": { starred: false, updatedAt: 9998 }, + "evicted-id": E(true, 9999, 0), + "active-0": E(false, 9998, 0), }, }, ); - assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); - assert.deepEqual(result.channels["evicted-id"], { - starred: true, - updatedAt: 9999, - }); - assert.deepEqual(result.channels["active-0"], { - starred: false, - updatedAt: 9998, - }); + assert.deepEqual(result.channels["evicted-id"], E(true, 9999, 0)); + assert.deepEqual(result.channels["active-0"], E(false, 9998, 0)); assert.equal(result.channels["active-1"], undefined); - assert.deepEqual(result.channels["active-2"], { - starred: true, - updatedAt: 12, - }); }); -// ── starredChannelIdsFromStore ──────────────────────────────────────────────── +// ── Eviction / remount (finding 3) ──────────────────────────────────────────── + +// Easy branch: X evicted at an OLDER second → remount (high-water lost) → click +// X at the current second → merge a remote carrying X at a high rev but an old +// updatedAt. The click's newer updatedAt wins on the primary key; the lost rev +// high-water is irrelevant. Closed by construction for all cross-second cases. +test("finding 3 easy branch: a fresh click beats an evicted high-rev entry at an older updatedAt", () => { + // Remount state: the user clicks X fresh at updatedAt=now, empty high-water + // (evicted), so rev mints to 1. + const click = S(E(true, 1000, 1)); + // The previously observed remote X sits at an OLD updatedAt with a high rev. + const remote = S(E(false, 500, 100)); + const merged = mergeStores(click, remote); + assert.deepEqual( + merged.channels.c, + E(true, 1000, 1), + "fresh click wins on the primary updatedAt key", + ); +}); -test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => { - const store = { - version: 1, - channels: { - "chan-a": { starred: true, updatedAt: 100 }, - "chan-b": { starred: true, updatedAt: 200 }, - "chan-c": { starred: false, updatedAt: 300 }, - }, - }; - const result = starredChannelIdsFromStore(store); - assert.equal(result.has("chan-a"), true); - assert.equal(result.has("chan-b"), true); - assert.equal(result.has("chan-c"), false); - assert.equal(result.size, 2); +// Hard branch (Thufir's exact equal-second counterexample): >500 entries all at +// the CURRENT second → X evicted by the id tiebreak (not because it is old) → +// remount in the same second → click X at rev 1 (empty high-water) → merge the +// previously observed remote X at rev 100, EQUAL updatedAt. updatedAt ties, rev +// decides, 100 > 1 — the click LOSES. Documented deterministic residual, proven +// here as the hard branch (not disguised as safety). +test("finding 3 hard branch: equal-second evicted click (rev 1) loses to observed remote (rev 100)", () => { + const NOW = 777; + const TARGET = "aaa-target"; // lexicographically small → evicted by the id tiebreak + + // >500 entries all at the CURRENT second (NOW). With MAX+1 equal-updatedAt + // entries, boundStarStore sorts ascending by (updatedAt, id) and keeps the + // highest MAX, so the lowest id is evicted — TARGET, NOT because it is old. + const channels = { [TARGET]: E(true, NOW, 7) }; + for (let i = 0; i < MAX_CHANNEL_STAR_ENTRIES; i++) { + channels[`z-${String(i).padStart(3, "0")}`] = E(true, NOW, 0); + } + const bounded = boundStarStore({ version: 1, channels }); + assert.equal( + Object.keys(bounded.channels).length, + MAX_CHANNEL_STAR_ENTRIES, + "bound trims to the cap", + ); + assert.equal( + bounded.channels[TARGET], + undefined, + "TARGET evicted by the id tiebreak at equal updatedAt", + ); + + // Remount in the same second: TARGET's rev high-water is gone with the entry, + // so a fresh click mints rev 1 at updatedAt=NOW. + const click = { version: 1, channels: { [TARGET]: E(true, NOW, 1) } }; + // The previously observed remote for TARGET at the same second, rev 100 (it + // may precede the remount — not genuinely concurrent). + const remote = { version: 1, channels: { [TARGET]: E(false, NOW, 100) } }; + + // equal updatedAt → rev decides → 100 > 1: the click LOSES. Documented + // deterministic residual, proven here through real eviction+remount, not a + // pre-shrunk tuple. Deterministic in both merge orders — a lost click, never + // a divergence. + assert.deepEqual( + mergeStores(click, remote).channels[TARGET], + E(false, NOW, 100), + "equal updatedAt → higher rev wins deterministically (documented residual)", + ); + assert.deepEqual( + mergeStores(remote, click).channels[TARGET], + E(false, NOW, 100), + ); }); -test("starredChannelIdsFromStore: excludes IDs where starred=false", () => { - const store = { +// ── starredChannelIdsFromStore ──────────────────────────────────────────────── + +test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => { + const result = starredChannelIdsFromStore({ version: 1, channels: { - "chan-x": { starred: false, updatedAt: 1 }, - "chan-y": { starred: false, updatedAt: 2 }, + a: E(true, 100, 0), + b: E(true, 200, 0), + c: E(false, 300, 0), }, - }; - const result = starredChannelIdsFromStore(store); - assert.equal(result.size, 0); + }); + assert.deepEqual([...result].sort(), ["a", "b"]); }); -test("starredChannelIdsFromStore: empty channels returns empty set", () => { - const result = starredChannelIdsFromStore({ version: 1, channels: {} }); - assert.equal(result.size, 0); +test("starredChannelIdsFromStore: all-false / empty returns empty set", () => { + assert.equal( + starredChannelIdsFromStore({ + version: 1, + channels: { x: E(false, 1, 0) }, + }).size, + 0, + ); + assert.equal( + starredChannelIdsFromStore({ version: 1, channels: {} }).size, + 0, + ); }); diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.ts b/desktop/src/features/sidebar/lib/channelStarsStorage.ts index 43c845cb3bd..64fb7b4e8be 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.ts @@ -1,9 +1,22 @@ +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + clearOwnOutbox, + enumerateOutbox, + reclaimOutbox, + writeOwnOutbox, +} from "./sidebarSyncWatermark"; + const STORAGE_KEY_PREFIX = "buzz-channel-stars.v1"; export const MAX_CHANNEL_STAR_ENTRIES = 500; export type ChannelStarEntry = { starred: boolean; updatedAt: number; + // Per-channel Lamport revision. Breaks a same-second `updatedAt` tie that the + // integer clock cannot resolve. Absent in blobs from an older build ⇒ read as + // 0 (a valid, mergeable value), so the payload stays `version: 1` and older + // builds still parse our blobs. + rev: number; }; export type ChannelStarStore = { @@ -29,8 +42,8 @@ export function parseStarPayload(json: unknown): ChannelStarStore | null { obj.channels !== null && !Array.isArray(obj.channels) ? Object.fromEntries( - Object.entries(obj.channels as Record).filter( - (entry): entry is [string, ChannelStarEntry] => { + Object.entries(obj.channels as Record) + .filter((entry): entry is [string, Record] => { const v = entry[1]; return ( typeof v === "object" && @@ -42,8 +55,27 @@ export function parseStarPayload(json: unknown): ChannelStarStore | null { ) && ((v as Record).updatedAt as number) >= 0 ); - }, - ), + }) + // Normalize `rev`: accept a non-negative integer, otherwise 0. An + // entry is never dropped solely because `rev` is absent (older + // build) or malformed — absence is a valid mergeable value. + .map(([id, v]) => { + const rawRev = v.rev; + const rev = + typeof rawRev === "number" && + Number.isInteger(rawRev) && + rawRev >= 0 + ? rawRev + : 0; + return [ + id, + { + starred: v.starred as boolean, + updatedAt: v.updatedAt as number, + rev, + }, + ]; + }), ) : {}; return boundStarStore({ version: 1, channels }); @@ -93,40 +125,74 @@ export function boundStarStore( }; } +/** + * Persist the main store. Writes the passed store as-is (bounded) — no read of + * the shared key, so it is never a shared-key read-modify-write. Callers merge + * peer state into the window's OWN React state (via the storage-event handler + * and applyRemote) before calling here, so the write carries an owned, merged + * value. Returns the bounded store, or `null` on write failure. + * + * Cross-window convergence of the on-disk cache is eventual: a peer's storage + * event folds into this window's state, and the relay reconcile writes the + * merged head back. Durable no-loss of an unpublished click is held by the + * per-window outbox, not this cache. + */ export function writeChannelStarsStore( pubkey: string, store: ChannelStarStore, -): boolean { + preservedKey?: string, +): ChannelStarStore | null { try { - window.localStorage.setItem( - storageKey(pubkey), - JSON.stringify(boundStarStore(store)), - ); - return true; + const bounded = boundStarStore(store, preservedKey); + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(bounded)); + return bounded; } catch { - return false; + return null; } } +/** + * Merge two star stores by a per-channel total order: + * `updatedAt` DESC → `rev` DESC → `starred === true` wins. This order is + * commutative, associative, and idempotent (before bounding), so every + * observation path (bootstrap, live, reconnect, reconcile, pre-publish, + * cross-window storage) applies it with no ordering or ownership overlay and + * all replicas converge. + * + * `updatedAt` is primary so a strictly-later edit — from any build, whether it + * carries `rev` or (older build) reads `rev: 0` — wins outright. `rev` breaks + * only a same-second `updatedAt` tie: the ambiguous integer-second window the + * clock cannot resolve, where a click that minted `rev = maxSeen + 1` dominates + * any same-second state it observed. On a full tie (equal `updatedAt` AND equal + * `rev`) `true` wins as the deterministic leaf. + */ export function mergeStores( - local: ChannelStarStore, - remote: ChannelStarStore, + a: ChannelStarStore, + b: ChannelStarStore, + preservedKey?: string, ): ChannelStarStore { const allIds = new Set([ - ...Object.keys(local.channels), - ...Object.keys(remote.channels), + ...Object.keys(a.channels), + ...Object.keys(b.channels), ]); const merged: Record = {}; for (const id of allIds) { - const l = local.channels[id]; - const r = remote.channels[id]; - if (l && r) { - merged[id] = l.updatedAt >= r.updatedAt ? l : r; - } else { - merged[id] = (l ?? r) as ChannelStarEntry; - } + const l = a.channels[id]; + const r = b.channels[id]; + merged[id] = l && r ? pickStarEntry(l, r) : ((l ?? r) as ChannelStarEntry); } - return boundStarStore({ version: 1, channels: merged }); + return boundStarStore({ version: 1, channels: merged }, preservedKey); +} + +/** The winner of two entries under `updatedAt` → `rev` → `starred` order. */ +function pickStarEntry( + l: ChannelStarEntry, + r: ChannelStarEntry, +): ChannelStarEntry { + if (l.updatedAt !== r.updatedAt) return l.updatedAt > r.updatedAt ? l : r; + if (l.rev !== r.rev) return l.rev > r.rev ? l : r; + if (l.starred !== r.starred) return l.starred ? l : r; + return l; } export function starredChannelIdsFromStore( @@ -138,3 +204,112 @@ export function starredChannelIdsFromStore( .map(([id]) => id), ); } + +const OUTBOX_KEY_PREFIX = "buzz-channel-stars-outbox.v1"; + +// The single shared key written by builds before the outbox was keyed +// per-window. Enumerated as one more record so an edit persisted by a prior +// build still resumes, and reclaimed by the same relay-gated rule. +function legacyOutboxKey(pubkey: string, relayUrl: string): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** + * Persist this window's unpublished edit under its own outbox key. Written + * synchronously on every click as a single unconditional `setItem` (no shared- + * key read-modify-write); resumed by merging every window's record on next + * mount so a click made <2s before quit/community-switch is never dropped. + */ +export function writeChannelStarsOutbox( + pubkey: string, + store: ChannelStarStore, + relayUrl: string, +): void { + writeOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl, boundStarStore(store)); +} + +/** + * Merge every window's persisted unpublished edit into one store for resume, or + * null when none exists. Per-entry `mergeStores` is order-independent, so two + * windows' concurrent clicks on different channels both survive. + */ +export function readChannelStarsOutbox( + pubkey: string, + relayUrl: string, +): ChannelStarStore | null { + const records = enumerateOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseStarPayload, + ); + if (records.length === 0) return null; + return records.reduce( + (acc, r) => mergeStores(acc, r.store), + DEFAULT_STORE, + ); +} + +/** Clear this window's own outbox key (its edit published or is a no-op). */ +export function clearChannelStarsOutbox( + pubkey: string, + relayUrl: string, +): void { + clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl); +} + +/** + * True when the fetched relay `head` already reflects every entry in + * `candidate` — merging the candidate into the head leaves it unchanged. Used + * both to reclaim a subsumed foreign key and to skip a redundant boot-time + * replay publish of a fold the head already carries (e.g. only the + * never-deleted legacy key lingers). + */ +export function isStarsStoreSubsumedBy( + candidate: ChannelStarStore, + head: ChannelStarStore, +): boolean { + return starStoresEqual(mergeStores(head, candidate), head); +} + +/** + * Reclaim foreign outbox keys the fetched relay head already subsumes: a record + * is redundant when merging it into `head` yields `head` unchanged (the head + * carries an entry at least as new for every channel). Never touches this + * window's own key; a still-unpublished peer edit the head does not yet reflect + * is kept. Call only after a successful head fetch. + */ +export function reclaimSubsumedStarsOutbox( + pubkey: string, + relayUrl: string, + head: ChannelStarStore, +): void { + reclaimOutbox( + OUTBOX_KEY_PREFIX, + legacyOutboxKey(pubkey, relayUrl), + pubkey, + relayUrl, + parseStarPayload, + (record) => isStarsStoreSubsumedBy(record.store, head), + ); +} + +/** Deep per-channel equality of two star stores (order-independent). */ +function starStoresEqual(a: ChannelStarStore, b: ChannelStarStore): boolean { + const aKeys = Object.keys(a.channels); + const bKeys = Object.keys(b.channels); + if (aKeys.length !== bKeys.length) return false; + for (const id of aKeys) { + const l = a.channels[id]; + const r = b.channels[id]; + if ( + !r || + l.starred !== r.starred || + l.updatedAt !== r.updatedAt || + l.rev !== r.rev + ) + return false; + } + return true; +} diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs index b0235744672..010d619d146 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; +import { readChannelStarsOutbox } from "./channelStarsStorage.ts"; import { ChannelStarSyncManager } from "./channelStarsSync.ts"; import { - makeFakeWindow, installFakeWindow, + installTauriMock, + makeFakeWindow, } from "./sidebarSyncTestHelpers.mjs"; const RELAY = "wss://r.test"; @@ -14,12 +16,74 @@ const RELAY_KEY = encodeURIComponent(RELAY); function makeStore(channels = {}) { return { version: 1, channels }; } +const E = (starred, updatedAt, rev) => ({ starred, updatedAt, rev }); + +// Multi-slot timer fake keyed by delay, for overlapping-publish tests. Mirrors +// the sections suite convention (channelSectionsSync.test.mjs:407-432). +function makeMultiTimerWindow() { + const storage = new Map(); + const timers = new Map(); + let nextId = 1; + const win = { + localStorage: { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, + }, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + return { + win, + storage, + timers, + fireDelay: async (ms) => { + const entry = [...timers.entries()].find(([, v]) => v.ms === ms); + assert.ok(entry, `expected a timer scheduled at ${ms}ms`); + timers.delete(entry[0]); + entry[1].fn(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + }, + hasDelay: (ms) => [...timers.values()].some((t) => t.ms === ms), + }; +} + +// ─── observe() / high-water ingestion ───────────────────────────────────────── + +test("observe: high-water is per-channel max of rev and updatedAt, monotonic", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const m = new ChannelStarSyncManager("pk", RELAY); + m.observe(makeStore({ a: E(true, 100, 3), b: E(false, 50, 1) })); + assert.equal(m.maxRevSeen("a"), 3); + assert.equal(m.maxUpdatedAtSeen("a"), 100); + // A later observation raises each dimension independently; a lower one + // never regresses either. + m.observe(makeStore({ a: E(true, 90, 5) })); + assert.equal(m.maxRevSeen("a"), 5, "rev raised"); + assert.equal(m.maxUpdatedAtSeen("a"), 100, "updatedAt not regressed"); + m.observe(makeStore({ a: E(true, 200, 2) })); + assert.equal(m.maxUpdatedAtSeen("a"), 200, "updatedAt raised"); + assert.equal(m.maxRevSeen("a"), 5, "rev not regressed"); + // Unseen channel reports zero on both dimensions. + assert.equal(m.maxRevSeen("never"), 0); + assert.equal(m.maxUpdatedAtSeen("never"), 0); + } finally { + restore(); + } +}); // ─── destroy() must cancel pending publish, not flush ───────────────────────── -// Regression guard for the community-switch cross-relay publish vector: -// star a channel in relay A → destroy() called (relayUrl dep change) → -// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); @@ -31,7 +95,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => { const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-test", RELAY); - manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); manager.destroy(); assert.equal(publishCalls.length, 0, "no publish after destroy"); assert.equal(manager.getPendingStarStore(), null); @@ -60,7 +124,7 @@ test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolv const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-race", RELAY); - manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); fw._fireTimer(); manager.destroy(); releaseFetch(); @@ -87,9 +151,183 @@ test("destroy: is safe to call with no pending publish", () => { } }); +// ─── Generation CAS: A-in-flight → B-click → A-completes (both variants) ────── + +// Finding 2 (A succeeds): an older in-flight publish that completes after a +// newer edit is queued must NOT clear the newer edit's pending store/outbox, +// and B must reach the relay via the completion re-drive. Mutation: dropping the +// generation CAS in discardPending lets A's success null out B's pending+outbox. +test("A-in-flight → B-click → A-succeeds: B stays pending and B publishes", async () => { + let releaseFirst = null; + const publishedContents = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + if (releaseFirst === null && publishedContents.length === 0) { + return new Promise((res) => { + releaseFirst = res; + }); + } + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelStarSyncManager("pk-ab", RELAY); + const storeA = makeStore({ a: E(true, 100, 1) }); + const storeB = makeStore({ b: E(true, 101, 1) }); + + manager.publishStars(storeA); + await t.fireDelay(2000); // doPublish(A) awaits publishEvent + while (releaseFirst === null) await Promise.resolve(); + + // B arrives while A is in flight. + manager.publishStars(storeB); + assert.deepEqual( + Object.keys(manager.getPendingStarStore().channels), + ["b"], + "B is now pending", + ); + assert.ok(readChannelStarsOutbox("pk-ab", RELAY), "outbox holds B"); + + // A completes — must NOT clear B. + releaseFirst(); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.deepEqual( + Object.keys(manager.getPendingStarStore()?.channels ?? {}), + ["b"], + "older A completion leaves B pending", + ); + assert.ok( + readChannelStarsOutbox("pk-ab", RELAY), + "older A completion leaves B outbox", + ); + + // B's own debounce fires and B reaches the relay (published) with no kick. + const capturedBefore = tauri.capturedPlaintext(); + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok( + captured && captured !== capturedBefore && captured.includes('"b"'), + "B is published to the relay", + ); + assert.equal( + manager.getPendingStarStore(), + null, + "B cleared after publish", + ); + assert.equal( + readChannelStarsOutbox("pk-ab", RELAY), + null, + "B outbox cleared", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// Finding 2 (A fails): A's publish rejects after B is queued. B must remain +// pending and be published by the serialized re-drive / retry — no manual kick. +test("A-in-flight → B-click → A-fails: B remains pending and B publishes", async () => { + let rejectFirst = null; + let publishCount = 0; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + publishCount++; + if (publishCount === 1) { + return new Promise((_res, rej) => { + rejectFirst = () => rej(new Error("socket error")); + }); + } + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelStarSyncManager("pk-abfail", RELAY); + manager.publishStars(makeStore({ a: E(true, 100, 1) })); + await t.fireDelay(2000); + while (rejectFirst === null) await Promise.resolve(); + + manager.publishStars(makeStore({ b: E(true, 101, 1) })); + rejectFirst(); // A fails + for (let i = 0; i < 50; i++) await Promise.resolve(); + + assert.deepEqual( + Object.keys(manager.getPendingStarStore()?.channels ?? {}), + ["b"], + "B still pending after A's failure", + ); + assert.ok( + readChannelStarsOutbox("pk-abfail", RELAY), + "B outbox intact after A's failure", + ); + + // B's debounce fires and B publishes successfully. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + const captured = tauri.capturedPlaintext(); + assert.ok(captured?.includes('"b"'), "B published"); + assert.equal(manager.getPendingStarStore(), null, "B cleared"); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// ─── Bounded-backoff retry: failed publish on a healthy socket, no later edit ─ + +// Finding 2: a transient publish failure with the socket open and NO further +// click must self-heal via the bounded-backoff retry — the pending edit is kept +// and a retry timer is scheduled. Mutation: dropping scheduleRetry leaves the +// edit stranded (Will's "make another change to kick it" symptom). +test("failed publish schedules a bounded-backoff retry and keeps the pending edit", async () => { + let publishCount = 0; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => { + publishCount++; + if (publishCount === 1) return Promise.reject(new Error("timeout")); + return Promise.resolve(); + }); + const t = makeMultiTimerWindow(); + const restore = installFakeWindow(t.win); + const tauri = installTauriMock("{}"); + try { + const manager = new ChannelStarSyncManager("pk-retry", RELAY); + manager.publishStars(makeStore({ a: E(true, 100, 1) })); + await t.fireDelay(2000); // debounce → doPublish → publishEvent rejects + assert.ok( + manager.getPendingStarStore() !== null, + "pending edit retained after failure", + ); + assert.ok(t.hasDelay(2000), "a retry timer at RETRY_BASE_MS is scheduled"); + + // The retry fires and the second publish succeeds → pending cleared. + await t.fireDelay(2000); + for (let i = 0; i < 50; i++) await Promise.resolve(); + assert.equal(publishCount, 2, "retry re-published"); + assert.equal( + manager.getPendingStarStore(), + null, + "pending cleared on retry success", + ); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + // ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── -// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.reject(new Error("relay timeout")), @@ -99,9 +337,7 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-fail", RELAY); - const result = await manager.bootstrap( - makeStore({ ch1: { starred: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStarStore(), null); } finally { @@ -110,7 +346,6 @@ test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstr } }); -// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -122,16 +357,7 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-stale", RELAY); - assert.ok( - Number( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, - ) ?? "0", - ) > 0, - ); - const result = await manager.bootstrap( - makeStore({ ch1: { starred: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.equal(manager.getPendingStarStore(), null); } finally { @@ -140,7 +366,6 @@ test("revert-fix: absent fetch with prior watermark blocks seed-publish via boot } }); -// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); mock.method(relayClient, "publishEvent", () => Promise.resolve()); @@ -148,15 +373,7 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy const restore = installFakeWindow(fw); try { const manager = new ChannelStarSyncManager("pk-fresh", RELAY); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`, - ), - null, - ); - const result = await manager.bootstrap( - makeStore({ ch1: { starred: true, updatedAt: 1 } }), - ); + const result = await manager.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.ok(manager.getPendingStarStore() !== null); } finally { @@ -165,8 +382,6 @@ test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sy } }); -// 4. relay-A / relay-B watermark isolation -// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { const relayA = "wss://a.relay.test"; const relayB = "wss://b.relay.test"; @@ -180,16 +395,7 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B const restore = installFakeWindow(fw); try { const managerB = new ChannelStarSyncManager("pk-iso", relayB); - assert.equal( - fw.localStorage.getItem( - `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`, - ), - null, - "relay B watermark must be independent of relay A head", - ); - const result = await managerB.bootstrap( - makeStore({ ch1: { starred: true, updatedAt: 1 } }), - ); + const result = await managerB.bootstrap(makeStore({ ch1: E(true, 1, 0) })); assert.equal(result.action, "hold"); assert.ok( managerB.getPendingStarStore() !== null, @@ -200,3 +406,50 @@ test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B mock.reset(); } }); + +// ─── Timestamp clamp (Carl P2): a far-future remote head must not push our +// published createdAt past the relay's ±15min future-drift window. +// Mutation test: removing the clamp lets createdAt = lastRemote+1 (~now+3600), +// which exceeds now + MAX_PUBLISH_FUTURE_SECS. +test("timestamp clamp: published createdAt stays inside the relay future window", async () => { + const nowSecs = Math.floor(Date.now() / 1000); + const farFutureHead = nowSecs + 3_600; // 1h ahead — beyond the ±15min window + let call = 0; + mock.method(relayClient, "fetchEvents", () => { + call++; + // First call primes lastRemoteCreatedAt to farFutureHead (undecryptable so + // it does not merge into the store); pre-publish call returns created_at=0. + return Promise.resolve([ + { + pubkey: "pk-clamp", + content: "bad-cipher", + created_at: call === 1 ? farFutureHead : 0, + id: "evt-clamp", + }, + ]); + }); + let signedCreatedAt = null; + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(JSON.stringify({ version: 1, channels: {} })); + mock.method(relayClient, "publishEvent", (evt) => { + signedCreatedAt = evt.created_at; + return Promise.resolve(); + }); + try { + const manager = new ChannelStarSyncManager("pk-clamp", RELAY); + await manager.fetchRemoteStars(); // prime lastRemoteCreatedAt + manager.publishStars(makeStore({ ch1: E(true, 100, 1) })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + assert.ok(signedCreatedAt !== null, "publish must have been attempted"); + assert.ok( + signedCreatedAt <= Math.floor(Date.now() / 1000) + 840, + `createdAt must be clamped inside the future window — got ${signedCreatedAt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index a5abec03fba..d198d908212 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -7,12 +7,15 @@ import { import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_STARS } from "@/shared/constants/kinds"; import { + clearChannelStarsOutbox, mergeStores, parseStarPayload, + writeChannelStarsOutbox, type ChannelStarStore, } from "./channelStarsStorage"; import { advanceWatermark, + clampPublishCreatedAt, readWatermark, runBootstrap, type FetchResult, @@ -22,6 +25,12 @@ const D_TAG = "channel-stars"; const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; +// Bounded backoff for a retained pending edit whose publish failed transiently +// (timeout / socket error) on an otherwise-healthy socket, so it does not wait +// for a reconnect that may never fire. +const RETRY_BASE_MS = 2_000; +const RETRY_MAX_MS = 30_000; + export type RemoteStars = { store: ChannelStarStore; createdAt: number; @@ -43,10 +52,30 @@ export class ChannelStarSyncManager { private pubkey: string; private relayUrl: string; private debounceTimer: number | null = null; + private retryTimer: number | null = null; + private retryDelayMs = RETRY_BASE_MS; private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; + // Monotonic id for the current pending edit. Every publishStars() bumps it; + // every scheduled publish/retry captures the value it was queued for. A + // completion (success or no-op) may only clear pending state via + // compare-and-swap on this generation, so an older in-flight publish can + // never erase a newer edit that arrived while it was in flight. + private pendingGeneration = 0; + // Publish cycles are serialized: at most one runs at a time. A newer edit + // queued while a cycle is in flight defers; the in-flight cycle's completion + // re-drives it. Serialization guarantees there is never more than one + // fetch/publish sequence touching shared manager state. + private publishInFlight = false; private lastPublishedStore: ChannelStarStore | null = null; private destroyed = false; + // Per-channel high-water of every `rev` and `updatedAt` this manager has + // observed (bootstrap, live, reconnect, reconcile, pre-publish, cross-window + // storage, and initial persisted state). A click reads both so its minted + // `updatedAt = max(now, maxUpdatedAtSeen)` never regresses below observed + // state (the read-state logical-monotonic idiom), and `rev = maxRevSeen + 1` + // wins the resulting same-second tie. + private highWater = new Map(); constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; @@ -54,6 +83,29 @@ export class ChannelStarSyncManager { this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } + /** + * Ingest a store into the per-channel high-water. Called synchronously before + * any merge is applied to React state, so a click that follows reads a current + * watermark on both dimensions. Monotonic (`Math.max`) and idempotent. + */ + observe(store: ChannelStarStore): void { + for (const [id, entry] of Object.entries(store.channels)) { + const cur = this.highWater.get(id) ?? { rev: 0, updatedAt: 0 }; + this.highWater.set(id, { + rev: Math.max(cur.rev, entry.rev), + updatedAt: Math.max(cur.updatedAt, entry.updatedAt), + }); + } + } + + maxRevSeen(id: string): number { + return this.highWater.get(id)?.rev ?? 0; + } + + maxUpdatedAtSeen(id: string): number { + return this.highWater.get(id)?.updatedAt ?? 0; + } + async fetchRemoteStars(): Promise> { try { const events = await relayClient.fetchEvents({ @@ -71,6 +123,7 @@ export class ChannelStarSyncManager { if (!result) { return { status: "failed", createdAt: event.created_at }; } + this.observe(result.store); return { status: "found", data: result, @@ -94,6 +147,10 @@ export class ChannelStarSyncManager { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; } + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } } getPendingStarStore(): ChannelStarStore | null { @@ -102,15 +159,53 @@ export class ChannelStarSyncManager { publishStars(store: ChannelStarStore): void { this.pendingStore = store; + ++this.pendingGeneration; + // Persist synchronously so a click made <2s before quit/community-switch + // survives teardown and resumes on next mount (durable outbox). This + // window's own key is the only one written — a single unconditional + // setItem, never a shared-key read-modify-write. + writeChannelStarsOutbox(this.pubkey, store, this.relayUrl); if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } + // A fresh edit supersedes any retry scheduled for the previous generation. + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = RETRY_BASE_MS; this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; - void this.doPublish(store); + this.startCycle(); }, DEBOUNCE_MS); } + /** + * Serialize publish cycles: at most one runs at a time. A debounce/retry timer + * that fires while a cycle is in flight defers — the in-flight cycle's + * completion re-drives if a pending edit still needs publishing. A newer edit + * queued during a cycle cannot start its own concurrent cycle, so a stale + * generation can never publish after a newer edit exists. + */ + private startCycle(): void { + if (this.destroyed || this.pendingStore === null) return; + if (this.publishInFlight) return; + const store = this.pendingStore; + const gen = this.pendingGeneration; + this.publishInFlight = true; + void this.doPublish(store, gen).finally(() => { + this.publishInFlight = false; + if ( + !this.destroyed && + this.pendingStore !== null && + this.debounceTimer === null && + this.retryTimer === null + ) { + this.startCycle(); + } + }); + } + private async fetchOwnBlobBeforePublish( store: ChannelStarStore, ): Promise { @@ -127,6 +222,9 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); const remote = await decryptAndParse(event); if (!remote) return store; + this.observe(remote.store); + // Max-merge: the local edit's per-entry winners survive by construction + // and any newer remote entries fold in, so no adopt step is needed. return mergeStores(store, remote.store); } catch { return store; @@ -144,22 +242,55 @@ export class ChannelStarSyncManager { if ( !last || last.starred !== current.starred || - last.updatedAt !== current.updatedAt + last.updatedAt !== current.updatedAt || + last.rev !== current.rev ) return false; } return true; } - private async doPublish(store: ChannelStarStore): Promise { + /** + * Clear the in-memory pending edit and this window's own durable outbox key — + * but only if the completing publish still owns the current generation. A + * publish for an older edit that finishes after a newer edit was queued must + * leave the newer edit (and its retry state) untouched. + */ + private discardPending(gen: number): void { + if (gen !== this.pendingGeneration) return; + this.pendingStore = null; + clearChannelStarsOutbox(this.pubkey, this.relayUrl); + } + + /** Schedule a bounded-backoff retry of the retained pending edit. */ + private scheduleRetry(gen: number): void { + if (this.destroyed || this.pendingStore === null) return; + // A newer edit has superseded this one; its own timer owns the retry. + if (gen !== this.pendingGeneration) return; + if (this.retryTimer !== null) return; + const delay = this.retryDelayMs; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, RETRY_MAX_MS); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.startCycle(); + }, delay); + } + + private async doPublish(store: ChannelStarStore, gen: number): Promise { + // A newer edit was queued after this publish was scheduled; it owns the + // pending state and will publish the latest store — abandon this stale run. + if (gen !== this.pendingGeneration) return; try { const merged = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (community switch during in-flight fetch). If so, abort - // before touching the relay. + // was awaited (community switch during in-flight fetch). if (this.destroyed) return; + // A newer edit was queued while we awaited the pre-publish fetch. It owns + // convergence now; the serialized cycle re-drives for it once this run + // unwinds. + if (gen !== this.pendingGeneration) return; if (this.isIdenticalToLastPublished(merged)) { - this.pendingStore = null; + this.discardPending(gen); return; } const payload = { @@ -167,10 +298,10 @@ export class ChannelStarSyncManager { channels: merged.channels, }; const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); + // Clamp inside the relay's future-drift window so a skewed remote head + // can never make us stamp an unbounded future timestamp that wedges every + // subsequent publish; we adopt such a head on the next fetch instead. + const createdAt = clampPublishCreatedAt(this.lastRemoteCreatedAt); const event = await signRelayEvent({ kind: KIND_CHANNEL_STARS, content: ciphertext, @@ -180,17 +311,32 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); - if (this.destroyed) return; + // Final guard immediately before the network call: a newer edit may have + // been queued during the encrypt/sign await, or the manager destroyed. + if (this.destroyed || gen !== this.pendingGeneration) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", ); this.recordRemoteHead(event.created_at); - this.lastPublishedStore = merged; - this.pendingStore = null; + this.observe(merged); + // Only claim this store as the published head if it is still the current + // edit; a newer edit queued mid-flight owns lastPublishedStore now. + if (gen === this.pendingGeneration) { + this.lastPublishedStore = merged; + this.retryDelayMs = RETRY_BASE_MS; + } + this.discardPending(gen); } catch (error) { + if (this.destroyed) return; + // Transient publish failure (timeout / socket error). Keep the pending + // edit and retry with backoff rather than waiting for a reconnect that a + // healthy socket never fires. Max-merge makes a duplicate publish + // idempotent, so a lost-ACK write that the relay actually accepted is + // harmless to re-send. console.warn("[channelStarsSync] publish failed:", error); + this.scheduleRetry(gen); } } @@ -211,6 +357,7 @@ export class ChannelStarSyncManager { this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { + this.observe(result.store); onUpdate(result); } }); @@ -223,6 +370,9 @@ export class ChannelStarSyncManager { * delegates the seed/hold/apply-remote decision to `runBootstrap`. */ async bootstrap(localStore: ChannelStarStore) { + // Seed the high-water from the caller's persisted local store so a click + // before the remote fetch resolves already reflects retained entries. + this.observe(localStore); const fetchResult = await this.fetchRemoteStars(); return runBootstrap({ fetchResult, @@ -236,10 +386,10 @@ export class ChannelStarSyncManager { destroy(): void { // Cancel any pending publish and mark this manager as destroyed so any // in-flight doPublish() calls abort before reaching relayClient. - // Pending debounce-window changes are intentionally dropped: flushing - // could publish relay A's state to relay B via the shared relayClient - // singleton. Local entries survive because the apply/publish paths merge - // per-entry via mergeStores, so no local work is permanently lost. + // Debounce-window changes are NOT lost: publishStars persisted them to the + // durable outbox synchronously, and the next mount resumes them. Flushing + // here is still avoided — it could publish relay A's state to relay B via + // the shared relayClient singleton. this.destroyed = true; this.cancelPendingStarPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs new file mode 100644 index 00000000000..948d5744bd4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs @@ -0,0 +1,708 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Multi-window durable-outbox safety (Carl's CHANGES_REQUESTED, finding P1). +// +// All four sidebar-sync lanes persist an unpublished edit so it survives a +// quit/community-switch inside the 2s publish debounce. localStorage offers no +// atomic compare-and-delete or transactional read-modify-write, so neither a +// single shared key nor a per-window key a window OVERWRITES can be reclaimed +// by a peer safely: the value can change between the reclaim decision-read and +// the delete (the recheck race a byte-compare narrows but cannot close). +// +// The fix keys the outbox per window AND write-once: +// `::::`, where the nonce is stable per +// window (sessionStorage) and `seq` is a per-window monotonic counter. A window +// NEVER rewrites a key: a new edit writes a NEW key, then deletes its own older +// keys (write-before-delete, so a crash leaves ≥1 record, never zero). Because +// records are immutable, a booting peer that proves a foreign key reclaimable +// against durable relay evidence can delete it with no recheck — nothing can +// have changed at that key since the proof. Resume enumerates ALL windows' +// keys: merge lanes (stars/mutes) fold every record (order-independent); +// whole-blob lanes (sort/sections) replay the max-`queuedAt` record, ties broken +// by key. Reclamation runs AFTER replay so a same-second record the head appears +// to supersede is consumed into pending first. Whole-blob supersession is STRICT +// (`queuedAt` < head `created_at`) so a same-second record is never dropped, and +// the legacy v1 shared key is never deleted by v2 (it is mutable; only replayed). +// +// This matrix drives the shared helpers directly through a mock Storage seam — +// no relay, no timers — so each interleaving is deterministic. The seam mocks +// both localStorage (with a read-then-mutate hook to interpose a foreign write +// between the reclaim decision-read and the delete) and sessionStorage (for the +// per-window nonce). Manager- and hook-level behavior is covered by their own +// suites; this file isolates the cross-window storage contract. + +// A mock Storage. `onReadMutate(key, afterReads, fn)` runs `fn(map)` right after +// the Nth getItem of `key` returns its captured value — used to simulate a peer +// window rewriting a key in the reclaim recheck gap. +function makeStorage(initial = {}) { + const map = new Map(Object.entries(initial)); + const reads = new Map(); + const hooks = []; + return { + getItem(k) { + const n = (reads.get(k) ?? 0) + 1; + reads.set(k, n); + const val = map.has(k) ? map.get(k) : null; + for (const h of hooks) if (h.key === k && h.afterReads === n) h.fn(map); + return val; + }, + setItem(k, v) { + map.set(k, String(v)); + }, + removeItem(k) { + map.delete(k); + }, + clear() { + map.clear(); + }, + get length() { + return map.size; + }, + key(i) { + return [...map.keys()][i] ?? null; + }, + onReadMutate(key, afterReads, fn) { + hooks.push({ key, afterReads, fn }); + }, + has: (k) => map.has(k), + }; +} + +// Run `fn(localStorage)` with fresh mock local + session storage installed. +function withStorage(fn) { + const ls = makeStorage(); + const ss = makeStorage(); + const priorWindow = globalThis.window; + globalThis.window = { + ...(priorWindow ?? {}), + localStorage: ls, + sessionStorage: ss, + }; + try { + return fn(ls); + } finally { + if (priorWindow !== undefined) globalThis.window = priorWindow; + else delete globalThis.window; + } +} + +const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); +const { outboxWindowNonce } = await import("./sidebarSyncWatermark.ts"); +const stars = await import("./channelStarsStorage.ts"); +const mutes = await import("./channelMutesStorage.ts"); +const sort = await import("./channelSortPreference.ts"); +const sections = await import("./channelSectionsStorage.ts"); + +const PK = "pk"; +const RELAY = "wss://relay.example.com"; +const SCOPE = `${PK}:${encodeURIComponent(normalizeRelayUrl(RELAY))}`; + +const PREFIX = { + stars: "buzz-channel-stars-outbox.v1", + mutes: "buzz-channel-mutes-outbox.v1", + sort: "buzz-channel-sort-outbox.v1", + sections: "buzz-channel-sections-outbox.v1", +}; + +// A foreign window's key: same (pubkey, relay) scope, a different nonce than +// this process's own, plus a zero-padded `seq`. `legacyKey` is the +// pre-per-window shared key (no nonce/seq). +const SEQ_WIDTH = 12; +const pad = (seq) => String(seq).padStart(SEQ_WIDTH, "0"); +const foreignKey = (lane, nonce, seq) => + `${PREFIX[lane]}:${SCOPE}:${nonce}:${pad(seq)}`; +const legacyKey = (lane) => `${PREFIX[lane]}:${SCOPE}`; +const writeAt = (ls, key, store, queuedAt) => + ls.setItem(key, JSON.stringify({ store, queuedAt })); + +const starStore = (channels) => ({ version: 1, channels }); +const starEntry = (starred, updatedAt, rev) => ({ starred, updatedAt, rev }); +const muteStore = (channels) => ({ version: 1, channels }); +const muteEntry = (muted, updatedAt, rev) => ({ muted, updatedAt, rev }); +const sortStore = (groups) => ({ version: 1, groups }); +const sectionStore = (secs, assignments = {}) => ({ + version: 1, + sections: secs, + assignments, +}); + +// ── (i) Reclaim never deletes a foreign edit written in the decision→delete gap ─ +// +// Records are write-once: an owner's fresh edit lands on a NEW key, never a +// rewrite of the enumerated one. reclaimOutbox proves the enumerated (stale) +// key reclaimable and deletes it; a peer edit injected during enumeration lives +// under its own key and is evaluated on its own merits (kept when the head does +// not subsume/supersede it). We interpose that fresh write via the read-mutate +// seam to prove GC removes only the proven-stale key and never the fresh one. + +test("(i) stars: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => { + withStorage((ls) => { + const stale = foreignKey("stars", "peerB", 0); + // Foreign edit the fetched head has already absorbed → decision = reclaim. + writeAt(ls, stale, starStore({ a: starEntry(true, 100, 1) }), 100); + // During enumeration (read of the stale key), the peer commits a FRESH edit + // to a NEW key the head does not reflect — the write-once analogue of the + // owner rewriting in the gap. + const fresh = foreignKey("stars", "peerB", 1); + ls.onReadMutate(stale, 1, (map) => + map.set( + fresh, + JSON.stringify({ + store: starStore({ z: starEntry(true, 300, 9) }), + queuedAt: 300, + }), + ), + ); + // Head carries `a` at (100,1) — subsumes the stale value, not the fresh one. + stars.reclaimSubsumedStarsOutbox( + PK, + RELAY, + starStore({ a: starEntry(true, 100, 1) }), + ); + assert.ok(!ls.has(stale), "proven-stale key is reclaimed"); + assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives"); + assert.deepEqual( + stars.readChannelStarsOutbox(PK, RELAY).channels.z, + starEntry(true, 300, 9), + ); + }); +}); + +test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit written in the gap", () => { + withStorage((ls) => { + const stale = foreignKey("sort", "peerB", 0); + writeAt(ls, stale, sortStore({ dms: "alpha" }), 100); + const fresh = foreignKey("sort", "peerB", 1); + ls.onReadMutate(stale, 1, (map) => + map.set( + fresh, + JSON.stringify({ store: sortStore({ dms: "recent" }), queuedAt: 500 }), + ), + ); + // Head created_at 200 strictly supersedes the queuedAt=100 stale record, + // not the interposed queuedAt=500 fresh key. + sort.reclaimSupersededSortOutbox(PK, RELAY, 200); + assert.ok(!ls.has(stale), "proven-stale key is reclaimed"); + assert.ok(ls.has(fresh), "peer's fresh edit under a new key survives"); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.dms, + "recent", + ); + }); +}); + +// ── (ii) Two windows teardown/remount: every unpublished intent preserved ────── +// +// Windows A and B each persist an edit and quit; a fresh window remounts and +// enumerates both keys. Merge lanes keep BOTH; whole-blob lanes keep the newest +// (an older peer blob is LWW-superseded by definition, the documented residual). + +test("(ii) stars (merge): both windows' distinct-channel edits resume", () => { + withStorage((ls) => { + writeAt( + ls, + foreignKey("stars", "A", 0), + starStore({ a: starEntry(true, 100, 1) }), + 100, + ); + writeAt( + ls, + foreignKey("stars", "B", 0), + starStore({ b: starEntry(true, 200, 1) }), + 200, + ); + const resumed = stars.readChannelStarsOutbox(PK, RELAY); + assert.deepEqual(resumed.channels.a, starEntry(true, 100, 1)); + assert.deepEqual(resumed.channels.b, starEntry(true, 200, 1)); + }); +}); + +test("(ii) mutes (merge): both windows' distinct-channel edits resume", () => { + withStorage((ls) => { + writeAt( + ls, + foreignKey("mutes", "A", 0), + muteStore({ a: muteEntry(true, 100, 1) }), + 100, + ); + writeAt( + ls, + foreignKey("mutes", "B", 0), + muteStore({ b: muteEntry(true, 200, 1) }), + 200, + ); + const resumed = mutes.readChannelMutesOutbox(PK, RELAY); + assert.deepEqual(resumed.channels.a, muteEntry(true, 100, 1)); + assert.deepEqual(resumed.channels.b, muteEntry(true, 200, 1)); + }); +}); + +test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-superseded", () => { + withStorage((ls) => { + writeAt( + ls, + foreignKey("sort", "A", 0), + sortStore({ channels: "alpha" }), + 100, + ); + writeAt( + ls, + foreignKey("sort", "B", 0), + sortStore({ channels: "recent" }), + 200, + ); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.channels, + "recent", + ); + }); +}); + +test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW-superseded", () => { + withStorage((ls) => { + writeAt( + ls, + foreignKey("sections", "A", 0), + sectionStore([{ id: "s1", name: "One", order: 0 }]), + 100, + ); + writeAt( + ls, + foreignKey("sections", "B", 0), + sectionStore([{ id: "s2", name: "Two", order: 0 }]), + 200, + ); + assert.deepEqual( + sections.readChannelSectionsOutbox(PK, RELAY).store.sections, + [{ id: "s2", name: "Two", order: 0 }], + ); + }); +}); + +// ── (iii) Same-second whole-blob: head at t keeps a record queued at t ───────── +// +// One-second clock granularity means a record queued in the same second as the +// head cannot be proven to have lost LWW. Strict supersession (`queuedAt` < +// head `created_at`) keeps it; a strictly-earlier record is reclaimed. + +test("(iii) sort: same-second record kept, strictly-earlier reclaimed", () => { + withStorage((ls) => { + const sameSecond = foreignKey("sort", "same", 0); + const earlier = foreignKey("sort", "old", 0); + writeAt(ls, sameSecond, sortStore({ dms: "recent" }), 100); + writeAt(ls, earlier, sortStore({ dms: "alpha" }), 99); + sort.reclaimSupersededSortOutbox(PK, RELAY, 100); + assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept"); + assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed"); + }); +}); + +test("(iii) sections: same-second record kept, strictly-earlier reclaimed", () => { + withStorage((ls) => { + const sameSecond = foreignKey("sections", "same", 0); + const earlier = foreignKey("sections", "old", 0); + writeAt( + ls, + sameSecond, + sectionStore([{ id: "s2", name: "Two", order: 0 }]), + 100, + ); + writeAt( + ls, + earlier, + sectionStore([{ id: "s1", name: "One", order: 0 }]), + 99, + ); + sections.reclaimSupersededSectionsOutbox(PK, RELAY, 100); + assert.ok(ls.has(sameSecond), "same-second record (queuedAt == head) kept"); + assert.ok(!ls.has(earlier), "strictly-earlier record reclaimed"); + }); +}); + +// ── (iv) Legacy v1 shared key: replays, and is NEVER deleted by v2 ───────────── +// +// A pre-per-window build wrote one shared, MUTABLE key. v2 enumerates it as one +// more record (queuedAt 0 for a bare store) so it resumes, but never deletes it +// under any relay gating — a live old-build window may be rewriting it, and +// queuedAt 0 makes supersession meaningless (mixed dev/DMG fleet residual). + +test("(iv) stars: legacy shared key resumes and is never reclaimed", () => { + withStorage((ls) => { + // Legacy bare store (no envelope) from a pre-per-window build. + ls.setItem( + legacyKey("stars"), + JSON.stringify(starStore({ a: starEntry(true, 100, 1) })), + ); + const resumed = stars.readChannelStarsOutbox(PK, RELAY); + assert.deepEqual( + resumed.channels.a, + starEntry(true, 100, 1), + "legacy entry resumes", + ); + // Even a head that fully subsumes it must not delete the mutable v1 key. + stars.reclaimSubsumedStarsOutbox( + PK, + RELAY, + starStore({ a: starEntry(true, 100, 1) }), + ); + assert.ok( + ls.has(legacyKey("stars")), + "legacy v1 key is never deleted by v2", + ); + }); +}); + +test("(iv) sections: legacy shared key resumes and is never reclaimed", () => { + withStorage((ls) => { + // Legacy entry as a {store, queuedAt} envelope from an interim build. + writeAt( + ls, + legacyKey("sections"), + sectionStore([{ id: "s1", name: "One", order: 0 }]), + 100, + ); + assert.deepEqual( + sections.readChannelSectionsOutbox(PK, RELAY).store.sections, + [{ id: "s1", name: "One", order: 0 }], + ); + // A head strictly past the queued stamp still must not delete the v1 key. + sections.reclaimSupersededSectionsOutbox(PK, RELAY, 999); + assert.ok( + ls.has(legacyKey("sections")), + "legacy v1 key is never deleted by v2", + ); + }); +}); + +// ── (v) Owner crash between write-new and delete-old → both replay-coalesce ──── +// +// writeOwnOutbox writes the new key BEFORE deleting older own keys. A crash in +// that gap leaves two own records for the same window; replay coalesces them +// (merge fold / whole-blob max) with no loss and no duplicate publish. + +test("(v) stars: two own records from a write-new/delete-old crash merge-coalesce", () => { + withStorage((ls) => { + const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`; + // Simulate the crash residue: the pre-crash key (seq 0) plus the freshly + // written key (seq 1) both present. Merge must keep both channels. + writeAt( + ls, + `${base}:${pad(0)}`, + starStore({ a: starEntry(true, 100, 1) }), + 100, + ); + writeAt( + ls, + `${base}:${pad(1)}`, + starStore({ b: starEntry(true, 200, 1) }), + 200, + ); + const resumed = stars.readChannelStarsOutbox(PK, RELAY); + assert.deepEqual(resumed.channels.a, starEntry(true, 100, 1)); + assert.deepEqual(resumed.channels.b, starEntry(true, 200, 1)); + }); +}); + +test("(v) sort: two own records from a crash resume the newer seq (padded key order)", () => { + withStorage((ls) => { + const base = `${PREFIX.sort}:${SCOPE}:${outboxWindowNonce()}`; + // Same-second seqs crossing a digit boundary: unpadded, "9" > "10" + // lexically and would wrongly resume the OLDER edit. Zero-padding makes the + // higher seq win the whole-blob tiebreak. + writeAt(ls, `${base}:${pad(9)}`, sortStore({ dms: "alpha" }), 100); + writeAt(ls, `${base}:${pad(10)}`, sortStore({ dms: "recent" }), 100); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.dms, + "recent", + "newer seq resumes despite the digit-boundary crossing", + ); + }); +}); + +// ── (vi) Reload seeds seq above surviving own keys → no key reuse/overwrite ──── +// +// After a reload the sessionStorage nonce survives but the in-memory seq counter +// restarts. A fresh write must allocate a seq ABOVE the max surviving own key so +// it never overwrites (and thus mutates) an existing immutable record. + +test("(vi) reload: a write after surviving own keys allocates a strictly-higher key", () => { + withStorage((ls) => { + const base = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`; + // A surviving own key from before the (simulated) reload. + writeAt( + ls, + `${base}:${pad(5)}`, + starStore({ a: starEntry(true, 50, 1) }), + 50, + ); + // First write of the "new session" — seq counter cold, must seed above 5. + stars.writeChannelStarsOutbox( + PK, + starStore({ b: starEntry(true, 100, 1) }), + RELAY, + ); + // The pre-reload key must NOT have been overwritten; the new write lands on + // a strictly-higher key, and delete-old drops the seq-5 key. + const ownKeys = []; + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (k?.startsWith(`${base}:`)) ownKeys.push(k); + } + assert.equal(ownKeys.length, 1, "delete-old leaves exactly one own key"); + assert.ok( + ownKeys[0] > `${base}:${pad(5)}`, + "new key seq is strictly above the surviving key", + ); + // No data loss: the newest edit resumes. + assert.deepEqual( + stars.readChannelStarsOutbox(PK, RELAY).channels.b, + starEntry(true, 100, 1), + ); + }); +}); + +// ── (vii) Whole-blob replay tie → deterministic nonce (key) tiebreak ─────────── + +test("(vii) sort: equal-queuedAt records resolve by key so replay is deterministic", () => { + withStorage((ls) => { + writeAt( + ls, + foreignKey("sort", "aaa", 0), + sortStore({ forums: "alpha" }), + 100, + ); + writeAt( + ls, + foreignKey("sort", "zzz", 0), + sortStore({ forums: "recent" }), + 100, + ); + // Same queuedAt → the lexicographically-greater key wins (…:zzz:…). + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.forums, + "recent", + ); + }); +}); + +// ── (viii) Merge-lane replay is order-independent ────────────────────────────── + +test("(viii) stars: same-channel records fold to the max entry regardless of key order", () => { + withStorage((ls) => { + // Lower-rev record under a lexicographically-greater key (enumerated later) + // must still lose to the higher-rev record — merge is order-independent. + writeAt( + ls, + foreignKey("stars", "aaa", 0), + starStore({ c: starEntry(true, 100, 5) }), + 100, + ); + writeAt( + ls, + foreignKey("stars", "zzz", 0), + starStore({ c: starEntry(false, 100, 2) }), + 200, + ); + const merged = stars.readChannelStarsOutbox(PK, RELAY); + assert.deepEqual( + merged.channels.c, + starEntry(true, 100, 5), + "higher rev wins the tie", + ); + }); +}); + +// ── (ix) GC no-op when the head subsumes/supersedes nothing ──────────────────── +// +// The hook calls reclaim only inside the `apply-remote` branch, so a `failed` +// head fetch (or `absent`) never invokes it — that guard is structural in the +// hook. At the storage layer the matching invariant is that a head which +// subsumes/supersedes nothing removes nothing: a foreign edit newer than the +// head is live intent and is kept, so a stale/empty head can never over-collect. + +test("(ix) stars: a head that subsumes nothing reclaims nothing", () => { + withStorage((ls) => { + const key = foreignKey("stars", "B", 0); + writeAt(ls, key, starStore({ a: starEntry(true, 300, 2) }), 300); + // Empty head subsumes no channel → keep everything. + stars.reclaimSubsumedStarsOutbox(PK, RELAY, starStore({})); + assert.ok(ls.has(key), "unsubsumed foreign edit is kept"); + }); +}); + +test("(ix) sort: a head older than the queued edit supersedes nothing", () => { + withStorage((ls) => { + const key = foreignKey("sort", "B", 0); + writeAt(ls, key, sortStore({ dms: "recent" }), 300); + // headCreatedAt=0 (absent-equivalent) < queuedAt → keep. + sort.reclaimSupersededSortOutbox(PK, RELAY, 0); + assert.ok(ls.has(key), "edit queued after the head is kept"); + }); +}); + +// ── (x) Legacy whole-blob replay is one-shot and value-sensitive ─────────────── +// +// The legacy v1 key is never deleted (it is mutable), so without a consumption +// marker `resumeWholeBlobOutbox` would return it on EVERY boot and the hook would +// republish the stale blob above the current relay head forever (Thufir pass-2 +// resurrection finding). The per-value marker records the exact legacy raw a +// prior boot replayed: an unchanged legacy blob is skipped, a rewritten one (a +// live old build) is replayed again. The hook transfers the intent into its own +// v2 key (synchronous publish) BEFORE writing the marker, so a crash between the +// two replays the legacy blob once more rather than losing it. + +test("(x) sort: retained legacy blob replays once, then is skipped across later boots", () => { + withStorage((ls) => { + ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" }))); + + // Boot 1: the legacy blob resumes and reports itself for consumption. + const boot1 = sort.readChannelSortOutbox(PK, RELAY); + assert.equal(boot1.store.groups.dms, "recent", "legacy blob resumes"); + assert.equal( + boot1.legacyRawToConsume, + JSON.stringify(sortStore({ dms: "recent" })), + "reports the exact legacy raw to consume", + ); + // Hook: publish transfers intent to a v2 key, then marks consumed; model the + // published-and-cleared steady state (own key gone after a successful ACK). + sort.markChannelSortLegacyConsumed(PK, RELAY, boot1.legacyRawToConsume); + assert.ok(ls.has(legacyKey("sort")), "legacy key is still never deleted"); + + // Boot 2 and beyond: the unchanged legacy blob is excluded — no resurrection. + assert.equal( + sort.readChannelSortOutbox(PK, RELAY), + null, + "consumed legacy blob is not replayed again", + ); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY), + null, + "still skipped on a third boot", + ); + }); +}); + +test("(x) sections: a rewritten legacy blob (live old build) is replayed again", () => { + withStorage((ls) => { + writeAt( + ls, + legacyKey("sections"), + sectionStore([{ id: "s1", name: "One", order: 0 }]), + 0, + ); + const boot1 = sections.readChannelSectionsOutbox(PK, RELAY); + assert.deepEqual(boot1.store.sections, [ + { id: "s1", name: "One", order: 0 }, + ]); + sections.markChannelSectionsLegacyConsumed( + PK, + RELAY, + boot1.legacyRawToConsume, + ); + assert.equal( + sections.readChannelSectionsOutbox(PK, RELAY), + null, + "consumed blob skipped", + ); + + // A live old build rewrites the legacy key with NEW intent (different raw). + writeAt( + ls, + legacyKey("sections"), + sectionStore([{ id: "s2", name: "Two", order: 0 }]), + 0, + ); + const boot3 = sections.readChannelSectionsOutbox(PK, RELAY); + assert.deepEqual( + boot3.store.sections, + [{ id: "s2", name: "Two", order: 0 }], + "changed legacy value is replayed", + ); + assert.ok( + boot3.legacyRawToConsume !== null, + "new legacy raw reported for a fresh consumption marker", + ); + }); +}); + +test("(x) sort: crash after v2 transfer but before the marker resumes from the v2 key", () => { + withStorage((ls) => { + ls.setItem(legacyKey("sort"), JSON.stringify(sortStore({ dms: "recent" }))); + const boot1 = sort.readChannelSortOutbox(PK, RELAY); + // Hook order: publish (synchronous writeOwnOutbox → v2 key) THEN mark. Model + // a crash in that gap: the v2 key exists, the marker was never written. + sort.writeChannelSortOutbox(PK, boot1.store, RELAY); + // No markChannelSortLegacyConsumed — crash before it. + + // Boot 2: marker absent, so the legacy blob (queuedAt 0) is enumerated, but + // the v2 key (queuedAt > 0) wins the whole-blob max — intent is not lost, + // and the winner is not the legacy record so nothing is re-consumed. + const boot2 = sort.readChannelSortOutbox(PK, RELAY); + assert.equal(boot2.store.groups.dms, "recent", "intent survives in v2 key"); + assert.equal( + boot2.legacyRawToConsume, + null, + "winner is the v2 key, not the legacy record", + ); + }); +}); + +test("(x) stars (merge lane): a legacy blob the head subsumes needs no replay publish", () => { + withStorage((ls) => { + ls.setItem( + legacyKey("stars"), + JSON.stringify(starStore({ a: starEntry(true, 100, 1) })), + ); + const outbox = stars.readChannelStarsOutbox(PK, RELAY); + // The hook skips the boot replay publish when the found head subsumes the + // fold — so a lingering never-deleted legacy key doesn't re-drive an + // identical publish on every boot. + assert.ok( + stars.isStarsStoreSubsumedBy( + outbox, + starStore({ a: starEntry(true, 100, 1) }), + ), + "head-subsumed legacy fold is publish-free", + ); + // A head that does NOT yet reflect the legacy click still publishes. + assert.ok( + !stars.isStarsStoreSubsumedBy(outbox, starStore({})), + "an unsubsumed legacy click still needs a publish", + ); + }); +}); + +// ── Own-key round trip: write, read, clear (single-window baseline) ──────────── + +test("own key: write resumes, clear removes only this window's own keys", () => { + withStorage((ls) => { + stars.writeChannelStarsOutbox( + PK, + starStore({ a: starEntry(true, 100, 1) }), + RELAY, + ); + const ownBase = `${PREFIX.stars}:${SCOPE}:${outboxWindowNonce()}`; + const hasOwn = () => { + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (k?.startsWith(`${ownBase}:`)) return true; + } + return false; + }; + assert.ok(hasOwn(), "own edit is written under this window's nonce"); + // A foreign peer key is untouched by an own-key clear. + writeAt( + ls, + foreignKey("stars", "peer", 0), + starStore({ z: starEntry(true, 9, 1) }), + 9, + ); + stars.clearChannelStarsOutbox(PK, RELAY); + assert.ok(!hasOwn(), "own keys cleared"); + assert.ok( + ls.has(foreignKey("stars", "peer", 0)), + "foreign key untouched by own clear", + ); + }); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs index c94d76db705..79deee4f9aa 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -7,6 +7,10 @@ export function makeFakeWindow() { setItem: (k, v) => storage.set(k, v), removeItem: (k) => storage.delete(k), clear: () => storage.clear(), + get length() { + return storage.size; + }, + key: (i) => [...storage.keys()][i] ?? null, }; let timerCallback = null; let nextTimerId = 100; diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index 8f4cb321bd1..d81d2a73cf8 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -23,6 +23,472 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const PREFIX = "buzz-sync-watermark.v1"; +// The relay rejects events more than ±15 minutes (900s) from server time +// (`MAX_TIMESTAMP_DRIFT_SECS` in ingest.rs). Clamp every sidebar-sync publish's +// `created_at` well inside that window so a skewed remote head can never make a +// manager manufacture an unbounded future timestamp that wedges every +// subsequent publish. 840s leaves ~60s of transit margin while still letting a +// publish win LWW against any legitimately-timestamped head. +const MAX_PUBLISH_FUTURE_SECS = 840; + +/** + * Compute the `created_at` for a sidebar-sync publish. + * + * Stamps one second past the newest observed remote head so the write wins + * last-write-wins, but never further ahead than the relay's future-drift + * window. If a skewed remote head sits beyond the window the manager loses LWW + * and adopts it on the next fetch rather than walking past it and wedging. + * + * `nowSecs` defaults to the current wall clock in seconds; callers pass it + * explicitly only to keep a single clock reading across a publish. + */ +export function clampPublishCreatedAt( + lastRemoteCreatedAt: number, + nowSecs: number = Math.floor(Date.now() / 1_000), +): number { + return Math.min( + Math.max(nowSecs, lastRemoteCreatedAt + 1), + nowSecs + MAX_PUBLISH_FUTURE_SECS, + ); +} + +// ── Per-window durable outbox (write-once, append-only) ────────────────────── +// +// Each sidebar-sync lane persists an unpublished edit so it survives a +// quit/community-switch inside the 2s publish debounce. The durability boundary +// is localStorage, which offers no atomic compare-and-delete or transactional +// read-modify-write: a single key shared across windows can never be mutated +// safely, and even a per-window key a window OVERWRITES can change between a +// peer's reclaim decision-read and its delete (the recheck race a byte-compare +// narrows but cannot close). +// +// The outbox is therefore keyed per window AND write-once. A key is +// `::::`, where `nonce` is stable for one +// window's lifetime (sessionStorage — survives reload, gone on window close) and +// `seq` is a per-window monotonic counter. A window NEVER rewrites a key: a new +// edit writes a NEW key (next `seq`) as a single unconditional `setItem`, then +// deletes its own now-superseded key(s). Both are own-key operations and the +// write precedes the delete, so a crash between them leaves ≥1 record for +// replay to coalesce, never zero. +// +// Because records are immutable, foreign reclamation is safe by construction: a +// booting window reads an immutable foreign record, proves it reclaimable +// against durable relay evidence, and deletes it. Nothing can have changed at +// that key since the proof — the only competing interleave is the owner +// deleting it first, and `removeItem` on an absent key is a no-op. No byte +// recheck and no destructive-path residual remain. +// - merge lanes: delete a record the fetched relay head already subsumes. +// - whole-blob lanes: delete a record the head STRICTLY supersedes +// (`queuedAt` < head `created_at`); a same-second record +// is kept until a strictly-newer head lands. +// Reclamation runs only after a successful head fetch, never touches this +// window's own keys, and never touches the legacy v1 shared key — that key is +// mutable (a live old-build window may be rewriting it) and its `queuedAt=0` +// makes supersession meaningless, so no gating makes deleting it safe; v2 only +// ever replays it. The bounded cost is at most one lingering legacy key per lane +// per (pubkey, relay), and only when the last old-build session quit with an +// unpublished edit. + +const OUTBOX_WINDOW_NONCE_KEY = "buzz-sidebar-outbox-window.v1"; + +// Monotonic fallback counter so two mints in the same millisecond (or in an +// environment without `crypto.randomUUID`) still differ. +let nonceCounter = 0; +let cachedWindowNonce: string | null = null; + +function mintNonce(): string { + const uuid = globalThis.crypto?.randomUUID?.(); + if (uuid) return uuid; + return `${Date.now().toString(36)}-${(nonceCounter++).toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +/** + * The stable per-window nonce that scopes this window's outbox keys. Minted + * once and parked in sessionStorage so a reload re-owns the same keys while a + * new window gets its own; an unavailable sessionStorage (private mode, test + * harness without one) falls back to a process-lifetime in-memory nonce. + */ +export function outboxWindowNonce(): string { + if (cachedWindowNonce !== null) return cachedWindowNonce; + try { + const existing = window.sessionStorage.getItem(OUTBOX_WINDOW_NONCE_KEY); + if (existing) { + cachedWindowNonce = existing; + return existing; + } + const nonce = mintNonce(); + window.sessionStorage.setItem(OUTBOX_WINDOW_NONCE_KEY, nonce); + cachedWindowNonce = nonce; + return nonce; + } catch { + cachedWindowNonce = mintNonce(); + return cachedWindowNonce; + } +} + +function scopeSuffix(pubkey: string, relayUrl: string): string { + return `${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** + * The base every own key for this (lane, pubkey, relay, window) shares: + * `:::`. Own keys append `:`. + */ +function ownKeyBase(prefix: string, pubkey: string, relayUrl: string): string { + return `${prefix}:${scopeSuffix(pubkey, relayUrl)}:${outboxWindowNonce()}`; +} + +/** Prefix every window's key for this (lane, pubkey, relay) starts with. */ +function outboxScopePrefix( + prefix: string, + pubkey: string, + relayUrl: string, +): string { + return `${prefix}:${scopeSuffix(pubkey, relayUrl)}:`; +} + +// Per-window monotonic `seq`, keyed by own-key base. Lazily seeded above the max +// `seq` among this window's surviving own keys so a reload (nonce survives in +// sessionStorage, this in-memory counter restarts) can never reuse — and thus +// overwrite — a key. Overwriting an own key would silently break immutability +// and reopen the reclaim race. +const ownSeqCounter = new Map(); + +// `seq` is zero-padded to a fixed width so a key's lexicographic order matches +// its numeric order. That makes the whole-blob replay tiebreak (max `queuedAt`, +// then key string) pick the higher `seq` — the newer edit — when a crash +// between write-new and delete-old leaves two same-second own keys with +// adjacent seqs that cross a digit boundary (…:9 vs …:10). 12 digits bound a +// window's edit count far past any reachable value. +const SEQ_WIDTH = 12; + +/** This window's own keys for a base (`:`). */ +function ownKeys(base: string): string[] { + const p = `${base}:`; + return localStorageKeys().filter((k) => k.startsWith(p)); +} + +/** Allocate this window's next write-once own key for a base. */ +function nextOwnKey(base: string): string { + let last = ownSeqCounter.get(base); + if (last === undefined) { + last = -1; + for (const k of ownKeys(base)) { + const seq = Number(k.slice(base.length + 1)); + if (Number.isInteger(seq) && seq > last) last = seq; + } + } + const seq = last + 1; + ownSeqCounter.set(base, seq); + return `${base}:${String(seq).padStart(SEQ_WIDTH, "0")}`; +} + +/** A durable outbox record enumerated across all windows for a lane. */ +export type OutboxRecord = { + key: string; + store: T; + // The exact stored string this record parsed from. The whole-blob legacy + // consumption marker records it verbatim so an unchanged legacy blob is + // replayed once and then skipped, while a live old build rewriting the key + // (a different raw) is replayed again. + raw: string; + // Seconds since epoch when the edit was queued (0 for a legacy entry written + // before per-window keys, which therefore never wins a whole-blob tie). + queuedAt: number; + isOwn: boolean; +}; + +/** + * Parse a stored outbox value, tolerating the per-window envelope + * (`{ store, queuedAt }`), the previous token envelope (`{ store, token }` ⇒ + * `queuedAt` 0), and a bare store from an even older build (⇒ `queuedAt` 0). + */ +function parseEnvelope( + raw: string | null, + parseStore: (json: unknown) => T | null, +): { store: T; queuedAt: number } | null { + if (!raw) return null; + try { + const json = JSON.parse(raw); + if ( + json !== null && + typeof json === "object" && + !Array.isArray(json) && + "store" in (json as Record) + ) { + const env = json as { store: unknown; queuedAt?: unknown }; + const store = parseStore(env.store); + if (!store) return null; + const queuedAt = + typeof env.queuedAt === "number" && Number.isFinite(env.queuedAt) + ? env.queuedAt + : 0; + return { store, queuedAt }; + } + // Legacy bare-store shape from a pre-envelope build. + const store = parseStore(json); + if (!store) return null; + return { store, queuedAt: 0 }; + } catch { + return null; + } +} + +/** + * Persist this window's unpublished edit under a fresh write-once key. + * + * Allocates the next `seq` for this window and `setItem`s the record there (a + * single unconditional write — no read, no merge, no shared-key contention), + * THEN deletes this window's older own keys. Write-before-delete: a crash + * between the two leaves ≥1 record for replay to coalesce, never zero. Because + * a key is written exactly once and never rewritten, a peer's boot-time reclaim + * of a proven-stale foreign key can never race a rewrite. Best-effort: the + * in-memory pending edit still drives this session's publish even if the + * persisted copy could not be written. + */ +export function writeOwnOutbox( + prefix: string, + pubkey: string, + relayUrl: string, + store: unknown, + nowSecs: number = Math.floor(Date.now() / 1_000), +): void { + const base = ownKeyBase(prefix, pubkey, relayUrl); + try { + const key = nextOwnKey(base); + window.localStorage.setItem( + key, + JSON.stringify({ store, queuedAt: nowSecs }), + ); + // Drop this window's now-superseded own keys (all but the one just written). + for (const k of ownKeys(base)) { + if (k !== key) window.localStorage.removeItem(k); + } + } catch { + // Best-effort durability. + } +} + +/** Remove all of this window's own outbox keys (its edit published or a no-op). */ +export function clearOwnOutbox( + prefix: string, + pubkey: string, + relayUrl: string, +): void { + try { + for (const k of ownKeys(ownKeyBase(prefix, pubkey, relayUrl))) { + window.localStorage.removeItem(k); + } + } catch { + // Ignore — a stale own key is re-evaluated on the next publish/boot. + } +} + +function localStorageKeys(): string[] { + const ls = window.localStorage; + const keys: string[] = []; + for (let i = 0; i < ls.length; i++) { + const k = ls.key(i); + if (k !== null) keys.push(k); + } + return keys; +} + +/** + * Enumerate every window's durable outbox record for a lane, plus the single + * legacy shared key from a pre-per-window build (treated as one more record so + * an edit persisted by a prior build still resumes; never reclaimed — see + * `reclaimOutbox`). + */ +export function enumerateOutbox( + prefix: string, + legacyKey: string, + pubkey: string, + relayUrl: string, + parseStore: (json: unknown) => T | null, +): OutboxRecord[] { + const scopePrefix = outboxScopePrefix(prefix, pubkey, relayUrl); + const ownPrefix = `${ownKeyBase(prefix, pubkey, relayUrl)}:`; + const records: OutboxRecord[] = []; + try { + for (const key of localStorageKeys()) { + if (!key.startsWith(scopePrefix)) continue; + const raw = window.localStorage.getItem(key); + const parsed = parseEnvelope(raw, parseStore); + if (parsed && raw !== null) { + records.push({ + key, + store: parsed.store, + raw, + queuedAt: parsed.queuedAt, + isOwn: key.startsWith(ownPrefix), + }); + } + } + const legacyRaw = window.localStorage.getItem(legacyKey); + const legacy = parseEnvelope(legacyRaw, parseStore); + if (legacy && legacyRaw !== null) { + records.push({ + key: legacyKey, + store: legacy.store, + raw: legacyRaw, + queuedAt: legacy.queuedAt, + isOwn: false, + }); + } + } catch { + // Return whatever parsed before the failure. + } + return records; +} + +/** + * Reclaim redundant FOREIGN outbox keys whose edits durable relay evidence + * shows are safe to drop (`shouldReclaim`). Records are write-once, so a proven + * key cannot have changed since enumeration — the delete needs no recheck and + * cannot destroy a peer's fresh edit (a new edit lives under a new key). Never + * touches this window's own keys, and never touches the legacy v1 shared key: + * that key is mutable and `queuedAt=0`, so no gating makes deleting it safe; + * v2 only ever replays it. Call only after a successful head fetch. + */ +export function reclaimOutbox( + prefix: string, + legacyKey: string, + pubkey: string, + relayUrl: string, + parseStore: (json: unknown) => T | null, + shouldReclaim: (record: OutboxRecord) => boolean, +): void { + for (const record of enumerateOutbox( + prefix, + legacyKey, + pubkey, + relayUrl, + parseStore, + )) { + if (record.isOwn || record.key === legacyKey) continue; + if (!shouldReclaim(record)) continue; + try { + window.localStorage.removeItem(record.key); + } catch { + // Leave for the next boot's reclamation. + } + } +} + +/** + * Resolve the single whole-blob outbox record to replay on boot: max `queuedAt`, + * ties broken by the (nonce-bearing) key string so the choice is deterministic + * across windows. Whole-blob LWW means only the newest queued intent is + * replayed; an older blob a peer queued is superseded by definition and never + * resurrected. + * + * The legacy shared key is excluded when its exact raw string matches this + * lane's consumption marker — it was already replayed into a durable v2 key on + * a prior boot, so replaying it again would resurrect a stale blob above the + * current relay head on every boot (the legacy key is never deleted). A live + * old build that rewrites the legacy key stores a different raw, which no longer + * matches the marker and is replayed again (value-sensitive). + * + * Returns the winning store plus, when the winner is a not-yet-consumed legacy + * record, the raw string the caller must mark consumed via `markLegacyConsumed` + * AFTER it has durably transferred the intent into its own v2 key. Null when no + * replayable record exists. + */ +export function resumeWholeBlobOutbox( + prefix: string, + legacyKey: string, + pubkey: string, + relayUrl: string, + parseStore: (json: unknown) => T | null, +): { store: T; legacyRawToConsume: string | null } | null { + const consumed = readLegacyConsumed(prefix, pubkey, relayUrl); + let best: OutboxRecord | null = null; + for (const record of enumerateOutbox( + prefix, + legacyKey, + pubkey, + relayUrl, + parseStore, + )) { + if ( + record.key === legacyKey && + consumed !== null && + record.raw === consumed + ) { + continue; + } + if ( + best === null || + record.queuedAt > best.queuedAt || + (record.queuedAt === best.queuedAt && record.key > best.key) + ) { + best = record; + } + } + if (best === null) return null; + return { + store: best.store, + legacyRawToConsume: best.key === legacyKey ? best.raw : null, + }; +} + +/** + * Per-lane marker recording the exact legacy raw string a prior boot already + * replayed into a durable v2 key. Whole-blob lanes read it via + * `resumeWholeBlobOutbox` to resume an unchanged legacy blob exactly once + * rather than republishing it above the current relay head on every boot. + * + * A shared mutable key, but every write is a single unconditional `setItem` of + * the value just consumed — no read-modify-write, value-idempotent. The worst + * interleaving (two v2 windows racing a live old build's rewrite) costs one + * extra bounded replay of a value, never loss and never a per-boot loop. + * Bounded at one marker per whole-blob lane per (pubkey, relay). + */ +function legacyConsumedKey( + prefix: string, + pubkey: string, + relayUrl: string, +): string { + return `${prefix}-legacy-consumed:${scopeSuffix(pubkey, relayUrl)}`; +} + +function readLegacyConsumed( + prefix: string, + pubkey: string, + relayUrl: string, +): string | null { + try { + return window.localStorage.getItem( + legacyConsumedKey(prefix, pubkey, relayUrl), + ); + } catch { + return null; + } +} + +/** + * Record `raw` as this lane's consumed legacy blob. Call only AFTER the intent + * has been durably transferred into a v2 key (e.g. via a synchronous + * publish→`writeOwnOutbox`), so a crash before this write replays the legacy + * blob once more rather than losing it. + */ +export function markLegacyConsumed( + prefix: string, + pubkey: string, + relayUrl: string, + raw: string, +): void { + try { + window.localStorage.setItem( + legacyConsumedKey(prefix, pubkey, relayUrl), + raw, + ); + } catch { + // Best-effort — worst case the legacy blob is replayed once more next boot. + } +} + /** * Tri-state result returned by every `fetchRemote*()` method. * diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs index df41f403114..b54dd0c2749 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs @@ -18,35 +18,51 @@ before(() => { after(() => dom.window.close()); -test("same-second mute and unmute mutations survive at capacity", async () => { +// Shared harness: stub the relay so no network/live/reconnect fires unless a +// test installs its own live callback. Returns the captured live callback. +function stubRelay(relayClient, { live } = {}) { + const orig = { + fetchEvents: relayClient.fetchEvents, + subscribeLive: relayClient.subscribeLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + }; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + if (live) live.cb = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + return () => Object.assign(relayClient, orig); +} + +function mutePayload(channels) { + return JSON.stringify({ version: 1, channels }); +} + +test("same-second star and unstar mutations survive at capacity", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); const { MAX_CHANNEL_MUTE_ENTRIES, readChannelMutesStore, storageKey } = await import("./channelMutesStorage.ts"); const { useChannelMutes } = await import("./useChannelMutes.ts"); - const originalFetchEvents = relayClient.fetchEvents; - const originalSubscribeLive = relayClient.subscribeLive; - const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const restore = stubRelay(relayClient); const originalDateNow = Date.now; const updatedAt = 1_234_567; Date.now = () => updatedAt * 1_000; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; const relayUrl = "wss://relay.example"; const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ `z-channel-${String(index).padStart(3, "0")}`, - { muted: true, updatedAt }, + { muted: true, updatedAt, rev: 0 }, ]), ); try { - for (const [pubkey, action, expectedMuted] of [ - ["pk-mute", "muteChannel", true], - ["pk-unmute", "unmuteChannel", false], + for (const [pubkey, action, expectedStarred] of [ + ["pk-star", "muteChannel", true], + ["pk-unstar", "unmuteChannel", false], ]) { window.localStorage.setItem( storageKey(pubkey), @@ -55,25 +71,411 @@ test("same-second mute and unmute mutations survive at capacity", async () => { const { result, unmount } = renderHook(() => useChannelMutes(pubkey, relayUrl), ); - act(() => result.current[action]("a-target")); - const persisted = readChannelMutesStore(pubkey); assert.equal( Object.keys(persisted.channels).length, MAX_CHANNEL_MUTE_ENTRIES, ); - assert.deepEqual(persisted.channels["a-target"], { - muted: expectedMuted, - updatedAt, - }); + assert.equal(persisted.channels["a-target"].muted, expectedStarred); unmount(); } } finally { cleanup(); Date.now = originalDateNow; - relayClient.fetchEvents = originalFetchEvents; - relayClient.subscribeLive = originalSubscribeLive; - relayClient.subscribeToReconnects = originalSubscribeToReconnects; + restore(); + } +}); + +// Symmetric mint (Thufir MINOR 2): a click mints +// updatedAt = max(now, localEntry.updatedAt, maxUpdatedAtSeen) and +// rev = max(localEntry.rev, maxRevSeen) + 1 on BOTH dimensions. A remount reads +// the persisted local entry, so the very first click advances past it. +test("persisted-local first click mints seen+1 on both dimensions", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelMutesStore, storageKey } = await import( + "./channelMutesStorage.ts" + ); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + // Persisted entry is stamped in the FUTURE relative to wall clock, with a + // non-zero rev — the mint must not regress below either. + Date.now = () => 100 * 1_000; + const pubkey = "pk-persist"; + window.localStorage.setItem( + storageKey(pubkey), + mutePayload({ shared: { muted: true, updatedAt: 500, rev: 4 } }), + ); + try { + const { result, unmount } = renderHook(() => + useChannelMutes(pubkey, "wss://r"), + ); + act(() => result.current.unmuteChannel("shared")); + const persisted = readChannelMutesStore(pubkey); + assert.equal(persisted.channels.shared.muted, false, "unstar applied"); + assert.equal( + persisted.channels.shared.updatedAt, + 500, + "updatedAt held at persisted-local high-water (max(100,500,seen))", + ); + assert.equal( + persisted.channels.shared.rev, + 5, + "rev minted as persisted-local rev + 1", + ); + unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); + } +}); + +// Fast-clock veto fix (Thufir pass-2 finding 1): after observing a +// future-stamped remote (updatedAt = t+300, rev 7, unmuted), a slow device +// clicking star at wall-clock t must WIN — the logical-monotonic stamp lifts the +// click's updatedAt to t+300 and rev to 8, so it dominates the observed entry. +test("fast-clock veto fix: click after observing a future-stamped head wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelMutesStore } = await import("./channelMutesStorage.ts"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; // slow device: wall clock t = 100 + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + mutePayload({ shared: { muted: false, updatedAt: 400, rev: 7 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-fastclock"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + // Observe the future-stamped head (updatedAt 400 = t+300). + await act(async () => { + live.cb({ + id: "future-head", + pubkey, + created_at: 400, + content: "cipher", + kind: 30078, + tags: [["d", "channel-mutes"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + false, + "future head applied → unmuted", + ); + // Slow device clicks star at wall t=100. + await act(async () => hook.result.current.muteChannel("shared")); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + true, + "click must win despite the observed future timestamp", + ); + const persisted = readChannelMutesStore(pubkey); + assert.equal( + persisted.channels.shared.updatedAt, + 400, + "mint lifted to t+300", + ); + assert.equal(persisted.channels.shared.rev, 8, "rev = maxRevSeen+1"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Future-timestamp propagation (Thufir MINOR 1 / Paul MINOR 1): a poisoned +// far-future observation does not ratchet by itself — two opposite clicks keep +// the timestamp fixed at the observed future value while rev advances, and the +// latest click wins. No clamp; deterministic. +test("far-future observation: timestamp stays fixed, rev advances, latest click wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelMutesStore } = await import("./channelMutesStorage.ts"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const FUTURE = 100 + 31_536_000; // +1yr + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + mutePayload({ shared: { muted: true, updatedAt: FUTURE, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => { + live.cb({ + id: "far-future", + pubkey, + created_at: FUTURE, + content: "cipher", + kind: 30078, + tags: [["d", "channel-mutes"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.unmuteChannel("shared")); + let p = readChannelMutesStore(pubkey); + assert.equal(p.channels.shared.muted, false, "first click applied"); + assert.equal(p.channels.shared.updatedAt, FUTURE, "timestamp stays fixed"); + assert.equal(p.channels.shared.rev, 2, "rev advanced 1→2"); + await act(async () => hook.result.current.muteChannel("shared")); + p = readChannelMutesStore(pubkey); + assert.equal(p.channels.shared.muted, true, "latest click wins"); + assert.equal(p.channels.shared.updatedAt, FUTURE, "timestamp still fixed"); + assert.equal(p.channels.shared.rev, 3, "rev advanced 2→3"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Click-before-observation (design note gap test a): an empty-store click mints +// updatedAt=now, rev=1; a later bootstrap head carrying a HIGHER rev but an +// OLDER updatedAt for the opposite value must NOT reverse the click — updatedAt +// is primary. +test("empty-store click survives a later higher-rev head with an older updatedAt", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + // older updatedAt (500) but higher rev (99), opposite value + return Promise.resolve( + mutePayload({ shared: { muted: false, updatedAt: 500, rev: 99 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-empty"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.muteChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + live.cb({ + id: "older-higher-rev", + pubkey, + created_at: 500, + content: "cipher", + kind: 30078, + tags: [["d", "channel-mutes"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + true, + "click at newer updatedAt survives an older higher-rev head", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Unobserved-future residual (accepted mixed-fleet case, MINOR-2 contrast to the +// fast-clock fix): a click mints at wall-clock t with an empty high-water; a +// genuinely UNOBSERVED opposite-value head then arrives at t+300 and wins on the +// primary updatedAt key. The logical-monotonic stamp only defends against state +// the replica already observed — a future head it never saw before clicking is +// not covered, exactly as under today's shipped LWW. +test("unobserved future head wins over an empty-high-water click on primary updatedAt", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 (t) + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + // fetchEvents is stubbed empty, so bootstrap/pre-publish never decrypt; + // the only decrypt is the live head — the genuinely unobserved future + // entry at updatedAt 1300, delivered after the empty-high-water click. + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + mutePayload({ shared: { muted: false, updatedAt: 1300, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-unobserved-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.muteChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + // Genuinely unobserved head at t+300 (updatedAt 1300), opposite value. + live.cb({ + id: "unobserved-future", + pubkey, + created_at: 1300, + content: "cipher", + kind: 30078, + tags: [["d", "channel-mutes"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + false, + "unobserved future head wins on primary updatedAt (accepted residual)", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Cross-window storage: a peer window's write is observed into the high-water +// and max-merged, so a following click sees the peer's rev and no edit is lost. +test("cross-window storage event is observed and max-merged", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelMutesStore, storageKey } = await import( + "./channelMutesStorage.ts" + ); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const pubkey = "pk-xwin"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + // A peer window wrote a higher-rev entry for `shared` at updatedAt 900. + window.localStorage.setItem( + storageKey(pubkey), + mutePayload({ shared: { muted: true, updatedAt: 900, rev: 12 } }), + ); + await act(async () => { + window.dispatchEvent( + new dom.window.StorageEvent("storage", { key: storageKey(pubkey) }), + ); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.mutedChannelIds.has("shared"), + true, + "peer write merged into this window", + ); + // A following click sees the peer's high-water: updatedAt held at 900, + // rev minted to 13. + await act(async () => hook.result.current.unmuteChannel("shared")); + const p = readChannelMutesStore(pubkey); + assert.equal(p.channels.shared.muted, false, "click applied"); + assert.equal(p.channels.shared.updatedAt, 900, "held at peer high-water"); + assert.equal(p.channels.shared.rev, 13, "rev = peer rev + 1"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); + } +}); + +// Outbox resume: an edit persisted to the durable outbox before teardown is +// re-published on the next mount (bootstrap resume), so a click made <2s before +// quit/community-switch is never silently dropped. +test("bootstrap resumes a persisted outbox edit", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const pubkey = "pk-outbox"; + const relayUrl = "wss://r.outbox"; + const outboxKey = `buzz-channel-mutes-outbox.v1:${pubkey}:${encodeURIComponent(relayUrl)}`; + window.localStorage.setItem( + outboxKey, + mutePayload({ resumed: { muted: true, updatedAt: 90, rev: 2 } }), + ); + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelMutes(pubkey, relayUrl)); + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + // The resumed edit is queued for publish (pending), not silently dropped. + // We assert the pending publish debounce is scheduled by observing the + // outbox is still present (cleared only after publish completes). + assert.ok( + window.localStorage.getItem(outboxKey) !== null, + "outbox retained until the resumed publish completes", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); } }); diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 20b57453254..4c8c26a69f9 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -3,10 +3,14 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { boundMuteStore, + clearChannelMutesOutbox, DEFAULT_STORE, + isMutesStoreSubsumedBy, mergeStores, mutedChannelIdsFromStore, + readChannelMutesOutbox, readChannelMutesStore, + reclaimSubsumedMutesOutbox, storageKey, writeChannelMutesStore, type ChannelMuteEntry, @@ -15,6 +19,13 @@ import { import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; +// Reconciliation cadence. Steady interval re-fetches the head on a healthy +// socket so a silently-lost publish converges without waiting for a reconnect +// that may never fire; the retry window backs off while the fetch keeps failing. +const RECONCILE_STEADY_MS = 60_000; +const RECONCILE_RETRY_BASE_MS = 3_000; +const RECONCILE_RETRY_MAX_MS = 60_000; + export function useChannelMutes( pubkey: string | undefined, relayUrl?: string, @@ -31,19 +42,13 @@ export function useChannelMutes( }); const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); React.useEffect(() => { if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; return; } setStore(readChannelMutesStore(pubkey)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); @@ -51,6 +56,9 @@ export function useChannelMutes( }; }, [pubkey, relayUrl]); + // Cross-window sync: another window/tab wrote the shared store. Ingest it into + // the high-water and max-merge it into this window's state, so a click that + // follows sees the peer's revs/timestamps and no window's edit is clobbered. React.useEffect(() => { if (!pubkey) { return; @@ -60,7 +68,9 @@ export function useChannelMutes( if (e.key !== key) { return; } - setStore(readChannelMutesStore(pubkey)); + const incoming = readChannelMutesStore(pubkey); + managerRef.current?.observe(incoming); + setStore((prev) => mergeStores(prev, incoming)); }; window.addEventListener("storage", handler); return () => { @@ -68,22 +78,23 @@ export function useChannelMutes( }; }, [pubkey]); + // Every remote payload is observed by the manager before it reaches here + // (fetch/subscribe paths call observe() internally; the storage handler + // observes above), so this is a pure max-merge with no ordering or ownership + // overlay — "later" lives in the (updatedAt, rev) tuple. const applyRemote = React.useCallback( (remote: RemoteMutes): ((prev: ChannelMuteStore) => ChannelMuteStore) => { return (prev) => { if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingMutePublish(); - const merged = mergeStores(prev, remote.store); - if (!writeChannelMutesStore(pubkey, merged)) return prev; - return merged; + // Read-merge-write folds the head into whatever a peer window has + // persisted since; use the returned store so a concurrent click there + // is carried into this window's state rather than lost. + const persisted = writeChannelMutesStore( + pubkey, + mergeStores(prev, remote.store), + ); + if (!persisted) return prev; + return persisted; }; }, [pubkey], @@ -98,13 +109,90 @@ export function useChannelMutes( if (result.action === "apply-remote") { setStore(applyRemote(result.data)); } - // "hold": seed already performed by bootstrap (if first-sync), or blocked. + // Resume any edit persisted to the durable outbox before a prior + // quit/community-switch so a click made <2s before teardown still syncs. + // Replay runs BEFORE reclamation so a same-second record the head appears + // to supersede is consumed into pending here and can never be GC'd out. + const outbox = readChannelMutesOutbox(pubkey, relayUrl); + if (outbox) { + // Skip the publish only when the fetched head already subsumes the + // fold: a fresh manager's `lastPublishedStore` is null, so without this + // gate a lingering never-deleted legacy key (or any head-subsumed + // record) would re-drive an identical publish on every boot. A `hold` + // (no head) can't prove redundancy, so it always publishes. Merge LWW + // keeps this correctness-safe either way — the gate only removes noise. + const subsumed = + result.action === "apply-remote" && + isMutesStoreSubsumedBy(outbox, result.data.store); + if (!subsumed) { + managerRef.current?.publishMutes(outbox); + } + } else { + clearChannelMutesOutbox(pubkey, relayUrl); + } + if (result.action === "apply-remote") { + // Head fetch succeeded: reclaim any foreign window's write-once outbox + // key the head already subsumes (a peer that published then quit). + // Gated on the fetched head; records are immutable so no recheck is + // needed and a live peer's unpublished edit (under a different key) is + // never destroyed. A `hold` (absent/failed head) reclaims nothing. + reclaimSubsumedMutesOutbox(pubkey, relayUrl, result.data.store); + } }); return () => { cancelled = true; }; }, [pubkey, relayUrl, applyRemote]); + // Reconciliation loop: a single scheduler that both retries a failed bootstrap + // fetch with bounded backoff and periodically re-fetches the head, so a + // silently-lost publish converges within the steady cadence without waiting + // for a reconnect a healthy socket never fires. Also refreshes on visibility. + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + let timer: number | null = null; + let delayMs = RECONCILE_RETRY_BASE_MS; + + const schedule = (ms: number) => { + if (cancelled) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(tick, ms); + }; + + const tick = () => { + void managerRef.current?.fetchRemoteMutes().then((result) => { + if (cancelled) return; + if (result.status === "found") { + // max-merge folds the head into state without dropping a pending + // edit (that edit is in prev and owned by the manager's retry lane). + setStore(applyRemote(result.data)); + delayMs = RECONCILE_STEADY_MS; // relay answered → steady cadence + } else if (result.status === "absent") { + delayMs = RECONCILE_STEADY_MS; // answered (no blob) → steady cadence + } else { + delayMs = Math.min(delayMs * 2, RECONCILE_RETRY_MAX_MS); // failed → back off + } + schedule(delayMs); + }); + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + delayMs = RECONCILE_RETRY_BASE_MS; + tick(); + } + }; + document.addEventListener("visibilitychange", onVisible); + schedule(delayMs); + + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; @@ -159,11 +247,23 @@ export function useChannelMutes( const setMuteState = React.useCallback( (channelId: string, muted: boolean) => { if (!pubkey) return; - const entry: ChannelMuteEntry = { - muted, - updatedAt: Math.floor(Date.now() / 1000), - }; + const now = Math.floor(Date.now() / 1000); setStore((prev) => { + const manager = managerRef.current; + const localEntry = prev.channels[channelId]; + // Logical-monotonic mint: never regress below any (updatedAt, rev) this + // replica has observed for the channel (local entry OR manager + // high-water), so the click strictly dominates observed state in both + // merge keys — it can never lose to state it has already seen. + const updatedAt = Math.max( + now, + localEntry?.updatedAt ?? 0, + manager?.maxUpdatedAtSeen(channelId) ?? 0, + ); + const rev = + Math.max(localEntry?.rev ?? 0, manager?.maxRevSeen(channelId) ?? 0) + + 1; + const entry: ChannelMuteEntry = { muted, updatedAt, rev }; const next = boundMuteStore( { version: 1, @@ -171,9 +271,15 @@ export function useChannelMutes( }, channelId, ); - if (!writeChannelMutesStore(pubkey, next)) return prev; - managerRef.current?.publishMutes(next); - return next; + // Read-merge-write: fold this click into any concurrent peer-window + // click already persisted under the shared key, then thread the merged + // store into both React state and the publish so neither window's edit + // is dropped (Carl prong b). Preserve the clicked channel through the + // re-bound so a same-second mutation is not evicted at capacity. + const persisted = writeChannelMutesStore(pubkey, next, channelId); + if (!persisted) return prev; + manager?.publishMutes(persisted); + return persisted; }); }, [pubkey], diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index 401b59d9c1c..4b85c6e10f7 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -76,3 +76,202 @@ test("assignChannel refreshes an existing assignment before the next eviction", relayClient.subscribeToReconnects = originalSubscribeToReconnects; } }); + +// Fix 2 regression: a live remote arriving while a local edit is pending must +// NOT overwrite the optimistic edit or strand its durable outbox. The pending +// edit's own debounced publish owns convergence (publish-or-adopt). Reverting +// applyRemote's hasPendingEdit guard makes the live event clobber the UI and +// leave the outbox replay-eligible. +test("live remote while a local edit is pending defers to the pending edit", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSections } = await import("./useChannelSections.ts"); + const { readChannelSectionsOutbox } = await import( + "./channelSectionsStorage.ts" + ); + + const origFetch = relayClient.fetchEvents; + const origLive = relayClient.subscribeLive; + const origReconnect = relayClient.subscribeToReconnects; + const origPublish = relayClient.publishEvent; + const origTauri = window.__TAURI_INTERNALS__; + + let live = null; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + live = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.publishEvent = async () => {}; + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + JSON.stringify({ + version: 1, + sections: [{ id: "remote", name: "Remote", order: 0 }], + assignments: {}, + }), + ); + if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "signed", + pubkey: "pk-live-pending", + content: "ct", + created_at: 0, + kind: 30078, + tags: [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + + const pubkey = "pk-live-pending"; + const relayUrl = "wss://r.live"; + + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelSections(pubkey, relayUrl)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.ok(live, "live subscription installed"); + + // Make a local edit — it becomes the pending store and persists to outbox. + await act(async () => { + hook.result.current.createSection("Local"); + }); + assert.ok( + readChannelSectionsOutbox(pubkey, relayUrl), + "local edit persisted to outbox", + ); + const localSectionIds = hook.result.current.sections.map((s) => s.id); + + // A remote live event arrives while the edit is still pending. + await act(async () => { + live({ + id: "remote-event", + pubkey, + created_at: 500, + content: "cipher", + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.deepEqual( + hook.result.current.sections.map((s) => s.id), + localSectionIds, + "pending local edit must NOT be overwritten by the live remote", + ); + assert.ok( + readChannelSectionsOutbox(pubkey, relayUrl), + "outbox for the pending edit must survive the live remote", + ); + hook.unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = origFetch; + relayClient.subscribeLive = origLive; + relayClient.subscribeToReconnects = origReconnect; + relayClient.publishEvent = origPublish; + window.__TAURI_INTERNALS__ = origTauri; + } +}); + +// Fix 3 regression: equal-timestamp tie-break must match the relay's canonical +// winner (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id +// first, then the lower id at the same timestamp; the lower-id store must win. +// Reverting applyRemote's `>=` back to `<=` converges on the larger id instead. +test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const origFetch = relayClient.fetchEvents; + const origLive = relayClient.subscribeLive; + const origReconnect = relayClient.subscribeToReconnects; + const origTauri = window.__TAURI_INTERNALS__; + + let live = null; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + live = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + // Decrypt payload keyed off the event id embedded in the ciphertext so each + // delivered event yields a distinct store we can assert on. + window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + const id = args?.ciphertext ?? ""; + return Promise.resolve( + JSON.stringify({ + version: 1, + sections: [{ id, name: id, order: 0 }], + assignments: {}, + }), + ); + } + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + + const pubkey = "pk-tie"; + const relayUrl = "wss://r.tie"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelSections(pubkey, relayUrl)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.ok(live, "live subscription installed"); + + const deliver = async (id) => { + await act(async () => { + live({ + id, + pubkey, + created_at: 1000, + content: id, // decrypt echoes this into the section id + kind: 30078, + tags: [["d", "channel-sections"]], + sig: "s", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + // Larger id first (would win under the old <= comparator)... + await deliver("bbbb"); + // ...then the lower id at the same timestamp — the relay's canonical winner. + await deliver("aaaa"); + + assert.deepEqual( + hook.result.current.sections.map((s) => s.id), + ["aaaa"], + "lower event id must win the equal-timestamp tie-break", + ); + hook.unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = origFetch; + relayClient.subscribeLive = origLive; + relayClient.subscribeToReconnects = origReconnect; + window.__TAURI_INTERNALS__ = origTauri; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 5a544e82bca..6f71d5f0372 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -3,8 +3,12 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { boundChannelSectionsStore, + clearChannelSectionsOutbox, DEFAULT_STORE, + markChannelSectionsLegacyConsumed, + readChannelSectionsOutbox, readChannelSectionsStore, + reclaimSupersededSectionsOutbox, storageKey, writeChannelSectionsStore, } from "./channelSectionsStorage"; @@ -19,6 +23,13 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; +// Reconciliation cadence (fix 1). Steady interval re-fetches the head on a +// healthy socket so divergence self-heals without a reconnect; the retry +// window backs off from base to max while the fetch keeps failing. +const RECONCILE_STEADY_MS = 60_000; +const RECONCILE_RETRY_BASE_MS = 3_000; +const RECONCILE_RETRY_MAX_MS = 60_000; + export function useChannelSections( pubkey: string | undefined, relayUrl?: string, @@ -85,15 +96,26 @@ export function useChannelSections( ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { return (prev) => { if (!pubkey) return prev; + // A pending local edit owns convergence: its debounced publish + // re-checks the head and either wins (publish) or loses (adopt, which + // routes back through onRemoteAdopted with pending already cleared). + // Never let a passive remote arrival clobber the optimistic edit or + // strand its durable outbox — that is the one-convergence-mechanism + // invariant. The adopt path clears pending before calling us, so this + // guard is false there and the winning remote still writes through. + if (managerRef.current?.hasPendingEdit()) return prev; if (remote.createdAt < lastAppliedRemoteTs.current) return prev; + // Equal timestamps: the relay/database break ties by `id ASC` — the + // LOWEST event id is the canonical winner. Apply a strictly-lower id and + // ignore any id >= the last applied, so the UI converges on the same + // event the relay stored rather than the largest id seen. if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; lastAppliedRemoteTs.current = remote.createdAt; lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); if (!writeChannelSectionsStore(pubkey, remote.store, relayUrl)) return prev; return remote.store; @@ -102,6 +124,19 @@ export function useChannelSections( [pubkey, relayUrl], ); + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + const manager = managerRef.current; + if (!manager) return; + // When a local edit loses whole-blob LWW (pre-publish head is newer), the + // manager adopts the winning remote store. Write it through to React state + // + localStorage so the UI and relay never diverge; applyRemote also + // advances the applied-ts guard. + manager.setOnRemoteAdopted((remote) => { + setStore(applyRemote(remote)); + }); + }, [pubkey, relayUrl, applyRemote]); + React.useEffect(() => { if (!pubkey || !relayUrl) return; let cancelled = false; @@ -112,13 +147,98 @@ export function useChannelSections( setStore(applyRemote(result.data)); } // "hold": seed already performed by bootstrap (if first-sync), or - // blocked (failed fetch / prior watermark). Hook does nothing. + // blocked (failed fetch / prior watermark). The reconciliation effect + // below retries a failed fetch; here we only resume any edit that was + // persisted to the durable outbox before a prior quit/community-switch. + // Replay runs BEFORE reclamation so a same-second record the head appears + // to supersede is consumed into pending here and can never be GC'd out. + const outbox = readChannelSectionsOutbox(pubkey, relayUrl); + if (outbox) { + // publishSections synchronously copies the intent into this window's + // own v2 key, so marking the legacy blob consumed afterward can never + // lose it: a crash before the marker write replays the legacy blob once + // more, a crash after resumes it from the v2 key. The marker is what + // stops the never-deleted legacy key republishing above the head every + // boot (Thufir pass-2 resurrection finding). + managerRef.current?.publishSections(outbox.store); + if (outbox.legacyRawToConsume !== null) { + markChannelSectionsLegacyConsumed( + pubkey, + relayUrl, + outbox.legacyRawToConsume, + ); + } + } else { + clearChannelSectionsOutbox(pubkey, relayUrl); + } + if (result.action === "apply-remote") { + // Head fetch succeeded: reclaim any foreign window's write-once outbox + // key the head STRICTLY supersedes (`queuedAt` < head `created_at`). + // Gated on the fetched head; records are immutable so no recheck is + // needed and a live peer's edit queued at/after the head is kept. A + // `hold` (absent/failed) reclaims none. + reclaimSupersededSectionsOutbox( + pubkey, + relayUrl, + result.data.createdAt, + ); + } }); return () => { cancelled = true; }; }, [pubkey, relayUrl, applyRemote]); + // Reconciliation loop (fix 1): a single scheduler that both retries a failed + // bootstrap with bounded backoff and periodically re-fetches the head, so + // stale-at-open state converges without waiting for a reconnect event a + // healthy socket never fires. Also refreshes when the window becomes visible. + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + let timer: number | null = null; + let delayMs = RECONCILE_RETRY_BASE_MS; + + const schedule = (ms: number) => { + if (cancelled) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(tick, ms); + }; + + const tick = () => { + void managerRef.current?.fetchRemoteSections().then((result) => { + if (cancelled) return; + if (result.status === "found") { + // applyRemote defers to a pending local edit (whose own debounced + // publish converges via publish-or-adopt), so a periodic reconcile + // can never drop it — no re-queue needed. + setStore(applyRemote(result.data)); + delayMs = RECONCILE_STEADY_MS; // relay answered → steady cadence + } else if (result.status === "absent") { + delayMs = RECONCILE_STEADY_MS; // answered (no blob) → steady cadence + } else { + delayMs = Math.min(delayMs * 2, RECONCILE_RETRY_MAX_MS); // fetch failed → back off + } + schedule(delayMs); + }); + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + delayMs = RECONCILE_RETRY_BASE_MS; + tick(); + } + }; + document.addEventListener("visibilitychange", onVisible); + schedule(delayMs); + + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [pubkey, relayUrl, applyRemote]); + React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs new file mode 100644 index 00000000000..d813f383213 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +// Carl P1-sort regression: a live remote arriving while a local sort edit is +// still pending must NOT overwrite the optimistic edit or strand its durable +// outbox. Reverting applyRemote's `hasPendingEdit()` guard (or restoring the +// old `cancelPendingPublish()` on remote arrival) lets the remote clobber the +// pending edit and drop it. +test("live remote while a local edit is pending defers to the pending edit", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSortPreference } = await import( + "./useChannelSortPreference.ts" + ); + const { readChannelSortOutbox } = await import("./channelSortPreference.ts"); + + const origFetch = relayClient.fetchEvents; + const origLive = relayClient.subscribeLive; + const origReconnect = relayClient.subscribeToReconnects; + const origPublish = relayClient.publishEvent; + const origTauri = window.__TAURI_INTERNALS__; + + let live = null; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + live = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + relayClient.publishEvent = async () => {}; + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + JSON.stringify({ + version: 1, + groups: { "remote-group": "recent" }, + }), + ); + if (cmd === "nip44_encrypt_to_self") return Promise.resolve("ct"); + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "signed", + pubkey: "pk-sort-pending", + content: "ct", + created_at: 0, + kind: 30078, + tags: [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + + const pubkey = "pk-sort-pending"; + const relayUrl = "wss://r.live"; + + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelSortPreference(pubkey, relayUrl)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.ok(live, "live subscription installed"); + + // Make a local edit — it becomes the pending store and persists to outbox. + await act(async () => { + hook.result.current.setSortModeFor("channels", "recent"); + }); + assert.ok( + readChannelSortOutbox(pubkey, relayUrl), + "local edit persisted to outbox", + ); + assert.equal( + hook.result.current.sortModeFor("channels"), + "recent", + "optimistic local edit applied", + ); + + // A remote live event arrives while the edit is still pending. + await act(async () => { + live({ + id: "remote-event", + pubkey, + created_at: 500, + content: "cipher", + kind: 30078, + tags: [["d", "channel-sort"]], + sig: "s", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.equal( + hook.result.current.sortModeFor("channels"), + "recent", + "pending local edit must NOT be overwritten by the live remote", + ); + assert.equal( + hook.result.current.sortModeFor("remote-group"), + "alpha", + "remote store must not have been applied over the pending edit", + ); + assert.ok( + readChannelSortOutbox(pubkey, relayUrl), + "outbox for the pending edit must survive the live remote", + ); + hook.unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = origFetch; + relayClient.subscribeLive = origLive; + relayClient.subscribeToReconnects = origReconnect; + relayClient.publishEvent = origPublish; + window.__TAURI_INTERNALS__ = origTauri; + } +}); + +// Equal-timestamp tie-break must match the relay's canonical winner +// (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id first, +// then the lower id at the same timestamp; the lower id is the stored winner +// and its whole-blob store must replace the applied state. Reverting +// applyRemote's `>=` back to `<=` wrongly ignores the lower id (the relay winner). +test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelSortPreference } = await import( + "./useChannelSortPreference.ts" + ); + + const origFetch = relayClient.fetchEvents; + const origLive = relayClient.subscribeLive; + const origReconnect = relayClient.subscribeToReconnects; + const origTauri = window.__TAURI_INTERNALS__; + + let live = null; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + live = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + // Decrypt payload keyed off the event id embedded in the ciphertext so each + // delivered event yields a store setting a distinct group's mode to "recent". + window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + const id = args?.ciphertext ?? ""; + return Promise.resolve( + JSON.stringify({ version: 1, groups: { [id]: "recent" } }), + ); + } + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + + const pubkey = "pk-sort-tie"; + const relayUrl = "wss://r.tie"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelSortPreference(pubkey, relayUrl)); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.ok(live, "live subscription installed"); + + const deliver = async (id) => { + await act(async () => { + live({ + id, + pubkey, + created_at: 1000, + content: id, // decrypt echoes this into the group key + kind: 30078, + tags: [["d", "channel-sort"]], + sig: "s", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + // Larger id first (applied), then the lower id at the same timestamp — the + // relay's canonical winner, whose whole-blob store must replace the state. + await deliver("bbbb"); + await deliver("aaaa"); + + assert.equal( + hook.result.current.sortModeFor("aaaa"), + "recent", + "lower event id (relay canonical winner) must be applied, not rejected", + ); + assert.equal( + hook.result.current.sortModeFor("bbbb"), + "alpha", + "larger id's store must be superseded by the lower-id whole-blob winner", + ); + hook.unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = origFetch; + relayClient.subscribeLive = origLive; + relayClient.subscribeToReconnects = origReconnect; + window.__TAURI_INTERNALS__ = origTauri; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 85c07b1c398..84c93cd40b1 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -3,8 +3,12 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { boundChannelSortStore, + clearChannelSortOutbox, DEFAULT_STORE, + markChannelSortLegacyConsumed, + readChannelSortOutbox, readChannelSortStore, + reclaimSupersededSortOutbox, sortModeForGroup, storageKey, stripOrphanedSectionModes, @@ -16,6 +20,13 @@ import { import { ChannelSortSyncManager } from "./channelSortSync"; import type { RemoteSortPrefs } from "./channelSortSync"; +// Reconciliation cadence (mirrors sections). Steady interval re-fetches the head +// on a healthy socket so divergence self-heals without a reconnect; the retry +// window backs off from base to max while the fetch keeps failing. +const RECONCILE_STEADY_MS = 60_000; +const RECONCILE_RETRY_BASE_MS = 3_000; +const RECONCILE_RETRY_MAX_MS = 60_000; + /** * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so * they don't bleed across identities or communities (same scoping as channel @@ -85,15 +96,25 @@ export function useChannelSortPreference( ): ((prev: ChannelSortStore) => ChannelSortStore) => { return (prev) => { if (!pubkey) return prev; + // A pending local edit owns convergence: its debounced publish re-checks + // the head and either wins (publish) or loses (adopt, which routes back + // through onRemoteAdopted with pending already cleared). Never let a + // passive remote arrival clobber the optimistic edit or strand its + // durable outbox. The adopt path clears pending before calling us, so + // this guard is false there and the winning remote still writes through. + if (managerRef.current?.hasPendingEdit()) return prev; if (remote.createdAt < lastAppliedRemoteTs.current) return prev; + // Equal timestamps: the relay/database break ties by `id ASC` — the + // LOWEST event id is the canonical winner. Apply a strictly-lower id and + // ignore any id >= the last applied, so the UI converges on the same + // event the relay stored rather than the largest id seen. if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current + remote.eventId >= lastAppliedEventId.current ) return prev; lastAppliedRemoteTs.current = remote.createdAt; lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev; return remote.store; }; @@ -101,6 +122,19 @@ export function useChannelSortPreference( [pubkey, relayUrl], ); + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + const manager = managerRef.current; + if (!manager) return; + // When a local edit loses whole-blob LWW (pre-publish head is newer), the + // manager adopts the winning remote store. Write it through to React state + + // localStorage so the UI and relay never diverge; applyRemote also advances + // the applied-ts guard. + manager.setOnRemoteAdopted((remote) => { + setStore(applyRemote(remote)); + }); + }, [pubkey, relayUrl, applyRemote]); + React.useEffect(() => { if (!pubkey || !relayUrl) return; let cancelled = false; @@ -110,13 +144,94 @@ export function useChannelSortPreference( if (result.action === "apply-remote") { setStore(applyRemote(result.data)); } - // "hold": seed already performed by bootstrap (if first-sync), or blocked. + // "hold": seed already performed by bootstrap (if first-sync), or blocked + // (failed fetch / prior watermark). Resume any edit persisted to the + // durable outbox before a prior quit/community-switch. Replay runs BEFORE + // reclamation so a same-second record the head appears to supersede is + // consumed into pending here and can never be GC'd out. + const outbox = readChannelSortOutbox(pubkey, relayUrl); + if (outbox) { + // publishSortPrefs synchronously copies the intent into this window's + // own v2 key, so marking the legacy blob consumed afterward can never + // lose it: a crash before the marker write replays the legacy blob once + // more, a crash after resumes it from the v2 key. The marker is what + // stops the never-deleted legacy key republishing above the head every + // boot (Thufir pass-2 resurrection finding). + managerRef.current?.publishSortPrefs(outbox.store); + if (outbox.legacyRawToConsume !== null) { + markChannelSortLegacyConsumed( + pubkey, + relayUrl, + outbox.legacyRawToConsume, + ); + } + } else { + clearChannelSortOutbox(pubkey, relayUrl); + } + if (result.action === "apply-remote") { + // Head fetch succeeded: reclaim any foreign window's write-once outbox + // key the head STRICTLY supersedes (`queuedAt` < head `created_at`). + // Gated on the fetched head; records are immutable so no recheck is + // needed and a live peer's edit queued at/after the head is kept. A + // `hold` (absent/failed) reclaims none. + reclaimSupersededSortOutbox(pubkey, relayUrl, result.data.createdAt); + } }); return () => { cancelled = true; }; }, [pubkey, relayUrl, applyRemote]); + // Reconciliation loop (mirrors sections): a single scheduler that both retries + // a failed bootstrap with bounded backoff and periodically re-fetches the + // head, so stale-at-open state converges without waiting for a reconnect event + // a healthy socket never fires. Also refreshes when the window becomes visible. + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + let timer: number | null = null; + let delayMs = RECONCILE_RETRY_BASE_MS; + + const schedule = (ms: number) => { + if (cancelled) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(tick, ms); + }; + + const tick = () => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { + if (cancelled) return; + if (result.status === "found") { + // applyRemote defers to a pending local edit (whose own debounced + // publish converges via publish-or-adopt), so a periodic reconcile + // can never drop it — no re-queue needed. + setStore(applyRemote(result.data)); + delayMs = RECONCILE_STEADY_MS; // relay answered → steady cadence + } else if (result.status === "absent") { + delayMs = RECONCILE_STEADY_MS; // answered (no blob) → steady cadence + } else { + delayMs = Math.min(delayMs * 2, RECONCILE_RETRY_MAX_MS); // fetch failed → back off + } + schedule(delayMs); + }); + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + delayMs = RECONCILE_RETRY_BASE_MS; + tick(); + } + }; + document.addEventListener("visibilitychange", onVisible); + schedule(delayMs); + + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [pubkey, relayUrl, applyRemote]); + React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.test.mjs b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs index a8e1b4b4ae5..579434473c3 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs @@ -18,6 +18,27 @@ before(() => { after(() => dom.window.close()); +// Shared harness: stub the relay so no network/live/reconnect fires unless a +// test installs its own live callback. Returns the captured live callback. +function stubRelay(relayClient, { live } = {}) { + const orig = { + fetchEvents: relayClient.fetchEvents, + subscribeLive: relayClient.subscribeLive, + subscribeToReconnects: relayClient.subscribeToReconnects, + }; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async (_f, cb) => { + if (live) live.cb = cb; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => () => {}; + return () => Object.assign(relayClient, orig); +} + +function starPayload(channels) { + return JSON.stringify({ version: 1, channels }); +} + test("same-second star and unstar mutations survive at capacity", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); @@ -25,21 +46,16 @@ test("same-second star and unstar mutations survive at capacity", async () => { await import("./channelStarsStorage.ts"); const { useChannelStars } = await import("./useChannelStars.ts"); - const originalFetchEvents = relayClient.fetchEvents; - const originalSubscribeLive = relayClient.subscribeLive; - const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const restore = stubRelay(relayClient); const originalDateNow = Date.now; const updatedAt = 1_234_567; Date.now = () => updatedAt * 1_000; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; const relayUrl = "wss://relay.example"; const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ `z-channel-${String(index).padStart(3, "0")}`, - { starred: true, updatedAt }, + { starred: true, updatedAt, rev: 0 }, ]), ); @@ -55,25 +71,410 @@ test("same-second star and unstar mutations survive at capacity", async () => { const { result, unmount } = renderHook(() => useChannelStars(pubkey, relayUrl), ); - act(() => result.current[action]("a-target")); - const persisted = readChannelStarsStore(pubkey); assert.equal( Object.keys(persisted.channels).length, MAX_CHANNEL_STAR_ENTRIES, ); - assert.deepEqual(persisted.channels["a-target"], { - starred: expectedStarred, - updatedAt, - }); + assert.equal(persisted.channels["a-target"].starred, expectedStarred); unmount(); } } finally { cleanup(); Date.now = originalDateNow; - relayClient.fetchEvents = originalFetchEvents; - relayClient.subscribeLive = originalSubscribeLive; - relayClient.subscribeToReconnects = originalSubscribeToReconnects; + restore(); + } +}); + +// Symmetric mint (Thufir MINOR 2): a click mints +// updatedAt = max(now, localEntry.updatedAt, maxUpdatedAtSeen) and +// rev = max(localEntry.rev, maxRevSeen) + 1 on BOTH dimensions. A remount reads +// the persisted local entry, so the very first click advances past it. +test("persisted-local first click mints seen+1 on both dimensions", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelStarsStore, storageKey } = await import( + "./channelStarsStorage.ts" + ); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + // Persisted entry is stamped in the FUTURE relative to wall clock, with a + // non-zero rev — the mint must not regress below either. + Date.now = () => 100 * 1_000; + const pubkey = "pk-persist"; + window.localStorage.setItem( + storageKey(pubkey), + starPayload({ shared: { starred: true, updatedAt: 500, rev: 4 } }), + ); + try { + const { result, unmount } = renderHook(() => + useChannelStars(pubkey, "wss://r"), + ); + act(() => result.current.unstarChannel("shared")); + const persisted = readChannelStarsStore(pubkey); + assert.equal(persisted.channels.shared.starred, false, "unstar applied"); + assert.equal( + persisted.channels.shared.updatedAt, + 500, + "updatedAt held at persisted-local high-water (max(100,500,seen))", + ); + assert.equal( + persisted.channels.shared.rev, + 5, + "rev minted as persisted-local rev + 1", + ); + unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); + } +}); + +// Fast-clock veto fix (Thufir pass-2 finding 1): after observing a +// future-stamped remote (updatedAt = t+300, rev 7, unstarred), a slow device +// clicking star at wall-clock t must WIN — the logical-monotonic stamp lifts the +// click's updatedAt to t+300 and rev to 8, so it dominates the observed entry. +test("fast-clock veto fix: click after observing a future-stamped head wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelStarsStore } = await import("./channelStarsStorage.ts"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; // slow device: wall clock t = 100 + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + starPayload({ shared: { starred: false, updatedAt: 400, rev: 7 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-fastclock"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + // Observe the future-stamped head (updatedAt 400 = t+300). + await act(async () => { + live.cb({ + id: "future-head", + pubkey, + created_at: 400, + content: "cipher", + kind: 30078, + tags: [["d", "channel-stars"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + false, + "future head applied → unstarred", + ); + // Slow device clicks star at wall t=100. + await act(async () => hook.result.current.starChannel("shared")); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + true, + "click must win despite the observed future timestamp", + ); + const persisted = readChannelStarsStore(pubkey); + assert.equal( + persisted.channels.shared.updatedAt, + 400, + "mint lifted to t+300", + ); + assert.equal(persisted.channels.shared.rev, 8, "rev = maxRevSeen+1"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Future-timestamp propagation (Thufir MINOR 1 / Paul MINOR 1): a poisoned +// far-future observation does not ratchet by itself — two opposite clicks keep +// the timestamp fixed at the observed future value while rev advances, and the +// latest click wins. No clamp; deterministic. +test("far-future observation: timestamp stays fixed, rev advances, latest click wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelStarsStore } = await import("./channelStarsStorage.ts"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const FUTURE = 100 + 31_536_000; // +1yr + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + starPayload({ shared: { starred: true, updatedAt: FUTURE, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => { + live.cb({ + id: "far-future", + pubkey, + created_at: FUTURE, + content: "cipher", + kind: 30078, + tags: [["d", "channel-stars"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.unstarChannel("shared")); + let p = readChannelStarsStore(pubkey); + assert.equal(p.channels.shared.starred, false, "first click applied"); + assert.equal(p.channels.shared.updatedAt, FUTURE, "timestamp stays fixed"); + assert.equal(p.channels.shared.rev, 2, "rev advanced 1→2"); + await act(async () => hook.result.current.starChannel("shared")); + p = readChannelStarsStore(pubkey); + assert.equal(p.channels.shared.starred, true, "latest click wins"); + assert.equal(p.channels.shared.updatedAt, FUTURE, "timestamp still fixed"); + assert.equal(p.channels.shared.rev, 3, "rev advanced 2→3"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Click-before-observation (design note gap test a): an empty-store click mints +// updatedAt=now, rev=1; a later bootstrap head carrying a HIGHER rev but an +// OLDER updatedAt for the opposite value must NOT reverse the click — updatedAt +// is primary. +test("empty-store click survives a later higher-rev head with an older updatedAt", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "nip44_decrypt_from_self") + // older updatedAt (500) but higher rev (99), opposite value + return Promise.resolve( + starPayload({ shared: { starred: false, updatedAt: 500, rev: 99 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-empty"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.starChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + live.cb({ + id: "older-higher-rev", + pubkey, + created_at: 500, + content: "cipher", + kind: 30078, + tags: [["d", "channel-stars"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + true, + "click at newer updatedAt survives an older higher-rev head", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); + +// Unobserved-future residual (accepted mixed-fleet case, MINOR-2 contrast to the +// fast-clock fix): a click mints at wall-clock t with an empty high-water; a +// genuinely UNOBSERVED opposite-value head then arrives at t+300 and wins on the +// primary updatedAt key. The logical-monotonic stamp only defends against state +// the replica already observed — a future head it never saw before clicking is +// not covered, exactly as under today's shipped LWW. +test("unobserved future head wins over an empty-high-water click on primary updatedAt", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const live = {}; + const restore = stubRelay(relayClient, { live }); + const origTauri = window.__TAURI_INTERNALS__; + const origDateNow = Date.now; + Date.now = () => 1000 * 1_000; // click at updatedAt 1000 (t) + window.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + // fetchEvents is stubbed empty, so bootstrap/pre-publish never decrypt; + // the only decrypt is the live head — the genuinely unobserved future + // entry at updatedAt 1300, delivered after the empty-high-water click. + if (cmd === "nip44_decrypt_from_self") + return Promise.resolve( + starPayload({ shared: { starred: false, updatedAt: 1300, rev: 1 } }), + ); + return Promise.reject(new Error(`unmocked ${cmd}`)); + }, + }; + const pubkey = "pk-unobserved-future"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + await act(async () => hook.result.current.starChannel("shared")); // empty store → rev 1 @ 1000 + await act(async () => { + // Genuinely unobserved head at t+300 (updatedAt 1300), opposite value. + live.cb({ + id: "unobserved-future", + pubkey, + created_at: 1300, + content: "cipher", + kind: 30078, + tags: [["d", "channel-stars"]], + sig: "s", + }); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + false, + "unobserved future head wins on primary updatedAt (accepted residual)", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + window.__TAURI_INTERNALS__ = origTauri; + restore(); + } +}); +// Cross-window storage: a peer window's write is observed into the high-water +// and max-merged, so a following click sees the peer's rev and no edit is lost. +test("cross-window storage event is observed and max-merged", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { readChannelStarsStore, storageKey } = await import( + "./channelStarsStorage.ts" + ); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const pubkey = "pk-xwin"; + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, "wss://r")); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + // A peer window wrote a higher-rev entry for `shared` at updatedAt 900. + window.localStorage.setItem( + storageKey(pubkey), + starPayload({ shared: { starred: true, updatedAt: 900, rev: 12 } }), + ); + await act(async () => { + window.dispatchEvent( + new dom.window.StorageEvent("storage", { key: storageKey(pubkey) }), + ); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + assert.equal( + hook.result.current.starredChannelIds.has("shared"), + true, + "peer write merged into this window", + ); + // A following click sees the peer's high-water: updatedAt held at 900, + // rev minted to 13. + await act(async () => hook.result.current.unstarChannel("shared")); + const p = readChannelStarsStore(pubkey); + assert.equal(p.channels.shared.starred, false, "click applied"); + assert.equal(p.channels.shared.updatedAt, 900, "held at peer high-water"); + assert.equal(p.channels.shared.rev, 13, "rev = peer rev + 1"); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); + } +}); + +// Outbox resume: an edit persisted to the durable outbox before teardown is +// re-published on the next mount (bootstrap resume), so a click made <2s before +// quit/community-switch is never silently dropped. +test("bootstrap resumes a persisted outbox edit", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const restore = stubRelay(relayClient); + const origDateNow = Date.now; + Date.now = () => 100 * 1_000; + const pubkey = "pk-outbox"; + const relayUrl = "wss://r.outbox"; + const outboxKey = `buzz-channel-stars-outbox.v1:${pubkey}:${encodeURIComponent(relayUrl)}`; + window.localStorage.setItem( + outboxKey, + starPayload({ resumed: { starred: true, updatedAt: 90, rev: 2 } }), + ); + let hook = null; + try { + await act(async () => { + hook = renderHook(() => useChannelStars(pubkey, relayUrl)); + for (let i = 0; i < 40; i++) await Promise.resolve(); + }); + // The resumed edit is queued for publish (pending), not silently dropped. + // We assert the pending publish debounce is scheduled by observing the + // outbox is still present (cleared only after publish completes). + assert.ok( + window.localStorage.getItem(outboxKey) !== null, + "outbox retained until the resumed publish completes", + ); + hook.unmount(); + } finally { + cleanup(); + Date.now = origDateNow; + restore(); } }); diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 855c8de8581..7a53540287b 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -3,9 +3,13 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { boundStarStore, + clearChannelStarsOutbox, DEFAULT_STORE, + isStarsStoreSubsumedBy, mergeStores, + readChannelStarsOutbox, readChannelStarsStore, + reclaimSubsumedStarsOutbox, starredChannelIdsFromStore, storageKey, writeChannelStarsStore, @@ -15,6 +19,13 @@ import { import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; +// Reconciliation cadence. Steady interval re-fetches the head on a healthy +// socket so a silently-lost publish converges without waiting for a reconnect +// that may never fire; the retry window backs off while the fetch keeps failing. +const RECONCILE_STEADY_MS = 60_000; +const RECONCILE_RETRY_BASE_MS = 3_000; +const RECONCILE_RETRY_MAX_MS = 60_000; + export function useChannelStars( pubkey: string | undefined, relayUrl?: string, @@ -31,19 +42,13 @@ export function useChannelStars( }); const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); React.useEffect(() => { if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; return; } setStore(readChannelStarsStore(pubkey)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); @@ -51,6 +56,9 @@ export function useChannelStars( }; }, [pubkey, relayUrl]); + // Cross-window sync: another window/tab wrote the shared store. Ingest it into + // the high-water and max-merge it into this window's state, so a click that + // follows sees the peer's revs/timestamps and no window's edit is clobbered. React.useEffect(() => { if (!pubkey) { return; @@ -60,7 +68,9 @@ export function useChannelStars( if (e.key !== key) { return; } - setStore(readChannelStarsStore(pubkey)); + const incoming = readChannelStarsStore(pubkey); + managerRef.current?.observe(incoming); + setStore((prev) => mergeStores(prev, incoming)); }; window.addEventListener("storage", handler); return () => { @@ -68,22 +78,23 @@ export function useChannelStars( }; }, [pubkey]); + // Every remote payload is observed by the manager before it reaches here + // (fetch/subscribe paths call observe() internally; the storage handler + // observes above), so this is a pure max-merge with no ordering or ownership + // overlay — "later" lives in the (updatedAt, rev) tuple. const applyRemote = React.useCallback( (remote: RemoteStars): ((prev: ChannelStarStore) => ChannelStarStore) => { return (prev) => { if (!pubkey) return prev; - if (remote.createdAt < lastAppliedRemoteTs.current) return prev; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) - return prev; - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingStarPublish(); - const merged = mergeStores(prev, remote.store); - if (!writeChannelStarsStore(pubkey, merged)) return prev; - return merged; + // Read-merge-write folds the head into whatever a peer window has + // persisted since; use the returned store so a concurrent click there + // is carried into this window's state rather than lost. + const persisted = writeChannelStarsStore( + pubkey, + mergeStores(prev, remote.store), + ); + if (!persisted) return prev; + return persisted; }; }, [pubkey], @@ -98,13 +109,91 @@ export function useChannelStars( if (result.action === "apply-remote") { setStore(applyRemote(result.data)); } - // "hold": seed already performed by bootstrap (if first-sync), or blocked. + // Resume every window's edit persisted to the durable outbox before a + // prior quit/community-switch so a click made <2s before teardown still + // syncs. Merges all records (order-independent), then publishes. Replay + // runs BEFORE reclamation so a same-second record the head appears to + // supersede is consumed into pending here and can never be GC'd out. + const outbox = readChannelStarsOutbox(pubkey, relayUrl); + if (outbox) { + // Skip the publish only when the fetched head already subsumes the + // fold: a fresh manager's `lastPublishedStore` is null, so without this + // gate a lingering never-deleted legacy key (or any head-subsumed + // record) would re-drive an identical publish on every boot. A `hold` + // (no head) can't prove redundancy, so it always publishes. Merge LWW + // keeps this correctness-safe either way — the gate only removes noise. + const subsumed = + result.action === "apply-remote" && + isStarsStoreSubsumedBy(outbox, result.data.store); + if (!subsumed) { + managerRef.current?.publishStars(outbox); + } + } else { + clearChannelStarsOutbox(pubkey, relayUrl); + } + if (result.action === "apply-remote") { + // Head fetch succeeded: reclaim any foreign window's write-once outbox + // key the head already subsumes (a peer that published then quit). + // Gated on the fetched head; records are immutable so no recheck is + // needed and a live peer's unpublished edit (under a different key) is + // never destroyed. A `hold` (absent/failed head) reclaims nothing. + reclaimSubsumedStarsOutbox(pubkey, relayUrl, result.data.store); + } }); return () => { cancelled = true; }; }, [pubkey, relayUrl, applyRemote]); + // Reconciliation loop: a single scheduler that both retries a failed bootstrap + // fetch with bounded backoff and periodically re-fetches the head, so a + // silently-lost publish converges within the steady cadence without waiting + // for a reconnect a healthy socket never fires. Also refreshes on visibility. + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let cancelled = false; + let timer: number | null = null; + let delayMs = RECONCILE_RETRY_BASE_MS; + + const schedule = (ms: number) => { + if (cancelled) return; + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(tick, ms); + }; + + const tick = () => { + void managerRef.current?.fetchRemoteStars().then((result) => { + if (cancelled) return; + if (result.status === "found") { + // max-merge folds the head into state without dropping a pending + // edit (that edit is in prev and owned by the manager's retry lane). + setStore(applyRemote(result.data)); + delayMs = RECONCILE_STEADY_MS; // relay answered → steady cadence + } else if (result.status === "absent") { + delayMs = RECONCILE_STEADY_MS; // answered (no blob) → steady cadence + } else { + delayMs = Math.min(delayMs * 2, RECONCILE_RETRY_MAX_MS); // failed → back off + } + schedule(delayMs); + }); + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + delayMs = RECONCILE_RETRY_BASE_MS; + tick(); + } + }; + document.addEventListener("visibilitychange", onVisible); + schedule(delayMs); + + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; @@ -159,11 +248,23 @@ export function useChannelStars( const setStarState = React.useCallback( (channelId: string, starred: boolean) => { if (!pubkey) return; - const entry: ChannelStarEntry = { - starred, - updatedAt: Math.floor(Date.now() / 1000), - }; + const now = Math.floor(Date.now() / 1000); setStore((prev) => { + const manager = managerRef.current; + const localEntry = prev.channels[channelId]; + // Logical-monotonic mint: never regress below any (updatedAt, rev) this + // replica has observed for the channel (local entry OR manager + // high-water), so the click strictly dominates observed state in both + // merge keys — it can never lose to state it has already seen. + const updatedAt = Math.max( + now, + localEntry?.updatedAt ?? 0, + manager?.maxUpdatedAtSeen(channelId) ?? 0, + ); + const rev = + Math.max(localEntry?.rev ?? 0, manager?.maxRevSeen(channelId) ?? 0) + + 1; + const entry: ChannelStarEntry = { starred, updatedAt, rev }; const next = boundStarStore( { version: 1, @@ -171,9 +272,15 @@ export function useChannelStars( }, channelId, ); - if (!writeChannelStarsStore(pubkey, next)) return prev; - managerRef.current?.publishStars(next); - return next; + // Read-merge-write: fold this click into any concurrent peer-window + // click already persisted under the shared key, then thread the merged + // store into both React state and the publish so neither window's edit + // is dropped (Carl prong b). Preserve the clicked channel through the + // re-bound so a same-second mutation is not evicted at capacity. + const persisted = writeChannelStarsStore(pubkey, next, channelId); + if (!persisted) return prev; + manager?.publishStars(persisted); + return persisted; }); }, [pubkey],