From 3712e6fb2c71f03587a476c36081b1ac831a60c7 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 26 Aug 2026 22:11:44 -0400 Subject: [PATCH] fix(sidebar): make retained legacy whole-blob replay one-shot per value The legacy v1 shared outbox key is never deleted (it is mutable and a concurrently-live old build may still rewrite it), so the whole-blob resume path re-read it on every boot and republished the stale blob above the current relay head forever. A found relay head never stopped it: a fresh manager has no lastPublishedStore and queueing the replay freezes publishBaseline to the just-fetched head. Distinguish compatibility replay from permanently pending intent with a durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox excludes the legacy record when its exact raw matches the stored marker; a live old build rewriting the key stores a different raw and is replayed again. The hook transfers the intent into its own v2 key (synchronous publish) BEFORE writing the marker, so a crash in that gap replays the blob once more rather than losing it. Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate so a lingering legacy key does not re-drive an identical boot-time publish. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/lib/channelMutesStorage.ts | 16 +- .../sidebar/lib/channelSectionsStorage.ts | 30 +++- .../sidebar/lib/channelSectionsSync.test.mjs | 2 +- .../sidebar/lib/channelSortPreference.ts | 30 +++- .../sidebar/lib/channelSortSync.test.mjs | 6 +- .../sidebar/lib/channelStarsStorage.ts | 16 +- .../sidebar/lib/multiWindowOutbox.test.mjs | 160 ++++++++++++++++-- .../sidebar/lib/sidebarSyncWatermark.ts | 102 ++++++++++- .../features/sidebar/lib/useChannelMutes.ts | 14 +- .../sidebar/lib/useChannelSections.ts | 16 +- .../sidebar/lib/useChannelSortPreference.ts | 16 +- .../features/sidebar/lib/useChannelStars.ts | 14 +- 12 files changed, 383 insertions(+), 39 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts index 24287ae970..0653e3f187 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.ts +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts @@ -257,6 +257,20 @@ export function clearChannelMutesOutbox( 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 @@ -275,7 +289,7 @@ export function reclaimSubsumedMutesOutbox( pubkey, relayUrl, parseMutePayload, - (record) => muteStoresEqual(mergeStores(head, record.store), head), + (record) => isMutesStoreSubsumedBy(record.store, head), ); } diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 633c56d775..03e0c9ac7c 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,8 +1,9 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import { clearOwnOutbox, - latestOutbox, + markLegacyConsumed, reclaimOutbox, + resumeWholeBlobOutbox, writeOwnOutbox, } from "./sidebarSyncWatermark"; @@ -231,15 +232,18 @@ export function writeChannelSectionsOutbox( } /** - * The newest window's persisted unpublished edit for resume, or null when none - * exists. Whole-blob LWW: only the max-`queuedAt` record is replayed, so an - * older blob a peer queued is superseded by definition, never resurrected. + * 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, -): ChannelSectionStore | null { - return latestOutbox( +): { store: ChannelSectionStore; legacyRawToConsume: string | null } | null { + return resumeWholeBlobOutbox( OUTBOX_KEY_PREFIX, legacyOutboxKey(pubkey, relayUrl), pubkey, @@ -248,6 +252,20 @@ export function readChannelSectionsOutbox( ); } +/** + * 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, diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index afa321e037..a967a64707 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -448,7 +448,7 @@ test("overlapping publishes: older completion does not erase a newer queued edit "B is now the pending edit", ); assert.equal( - readChannelSectionsOutbox("pk-overlap", RELAY).sections[0].id, + readChannelSectionsOutbox("pk-overlap", RELAY).store.sections[0].id, "b", "outbox holds B", ); diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index db0a49be68..e22653fb81 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -2,8 +2,9 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; import { clearOwnOutbox, - latestOutbox, + markLegacyConsumed, reclaimOutbox, + resumeWholeBlobOutbox, writeOwnOutbox, } from "./sidebarSyncWatermark"; @@ -169,15 +170,18 @@ export function writeChannelSortOutbox( } /** - * The newest window's persisted unpublished sort edit for resume, or null when - * none exists. Whole-blob LWW: only the max-`queuedAt` record is replayed, so - * an older blob a peer queued is superseded by definition, never resurrected. + * 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, -): ChannelSortStore | null { - return latestOutbox( +): { store: ChannelSortStore; legacyRawToConsume: string | null } | null { + return resumeWholeBlobOutbox( OUTBOX_KEY_PREFIX, legacyOutboxKey(pubkey, relayUrl), pubkey, @@ -186,6 +190,20 @@ export function readChannelSortOutbox( ); } +/** + * 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); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index ab0bb10f8b..2d28c146cd 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -317,7 +317,7 @@ test("durable outbox: edit destroyed inside the debounce resumes and publishes o // 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); + m2.publishSortPrefs(persisted.store); fw._fireTimer(); await new Promise((r) => setTimeout(r, 20)); assert.equal(publishCalls.length, 1, "resumed edit must publish"); @@ -434,7 +434,9 @@ test("overlapping publishes: older completion does not erase a newer queued edit "B is now the pending edit", ); assert.deepEqual( - Object.keys(readChannelSortOutbox("pk-overlap", RELAY)?.groups ?? {}), + Object.keys( + readChannelSortOutbox("pk-overlap", RELAY)?.store.groups ?? {}, + ), ["dms"], "outbox holds B", ); diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.ts b/desktop/src/features/sidebar/lib/channelStarsStorage.ts index 47a3191efc..64fb7b4e8b 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.ts @@ -259,6 +259,20 @@ export function clearChannelStarsOutbox( 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 @@ -277,7 +291,7 @@ export function reclaimSubsumedStarsOutbox( pubkey, relayUrl, parseStarPayload, - (record) => starStoresEqual(mergeStores(head, record.store), head), + (record) => isStarsStoreSubsumedBy(record.store, head), ); } diff --git a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs index 25c5dddc20..948d5744bd 100644 --- a/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs +++ b/desktop/src/features/sidebar/lib/multiWindowOutbox.test.mjs @@ -185,7 +185,10 @@ test("(i) sort: reclaim deletes the proven-stale key and keeps a fresh edit writ 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).groups.dms, "recent"); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.dms, + "recent", + ); }); }); @@ -250,7 +253,7 @@ test("(ii) sort (whole-blob): the newest queued window resumes; older is LWW-sup 200, ); assert.equal( - sort.readChannelSortOutbox(PK, RELAY).groups.channels, + sort.readChannelSortOutbox(PK, RELAY).store.groups.channels, "recent", ); }); @@ -270,9 +273,10 @@ test("(ii) sections (whole-blob): the newest queued window resumes; older is LWW sectionStore([{ id: "s2", name: "Two", order: 0 }]), 200, ); - assert.deepEqual(sections.readChannelSectionsOutbox(PK, RELAY).sections, [ - { id: "s2", name: "Two", order: 0 }, - ]); + assert.deepEqual( + sections.readChannelSectionsOutbox(PK, RELAY).store.sections, + [{ id: "s2", name: "Two", order: 0 }], + ); }); }); @@ -358,9 +362,10 @@ test("(iv) sections: legacy shared key resumes and is never reclaimed", () => { sectionStore([{ id: "s1", name: "One", order: 0 }]), 100, ); - assert.deepEqual(sections.readChannelSectionsOutbox(PK, RELAY).sections, [ - { id: "s1", name: "One", order: 0 }, - ]); + 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( @@ -408,7 +413,7 @@ test("(v) sort: two own records from a crash resume the newer seq (padded key or writeAt(ls, `${base}:${pad(9)}`, sortStore({ dms: "alpha" }), 100); writeAt(ls, `${base}:${pad(10)}`, sortStore({ dms: "recent" }), 100); assert.equal( - sort.readChannelSortOutbox(PK, RELAY).groups.dms, + sort.readChannelSortOutbox(PK, RELAY).store.groups.dms, "recent", "newer seq resumes despite the digit-boundary crossing", ); @@ -474,7 +479,10 @@ test("(vii) sort: equal-queuedAt records resolve by key so replay is determinist 100, ); // Same queuedAt → the lexicographically-greater key wins (…:zzz:…). - assert.equal(sort.readChannelSortOutbox(PK, RELAY).groups.forums, "recent"); + assert.equal( + sort.readChannelSortOutbox(PK, RELAY).store.groups.forums, + "recent", + ); }); }); @@ -533,6 +541,138 @@ test("(ix) sort: a head older than the queued edit supersedes nothing", () => { }); }); +// ── (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", () => { diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index 8a7d602e5e..d81d2a73cf 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -187,6 +187,11 @@ function nextOwnKey(base: string): string { 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; @@ -314,6 +319,7 @@ export function enumerateOutbox( records.push({ key, store: parsed.store, + raw, queuedAt: parsed.queuedAt, isOwn: key.startsWith(ownPrefix), }); @@ -325,6 +331,7 @@ export function enumerateOutbox( records.push({ key: legacyKey, store: legacy.store, + raw: legacyRaw, queuedAt: legacy.queuedAt, isOwn: false, }); @@ -370,20 +377,32 @@ export function reclaimOutbox( } /** - * The single winning record among all windows' whole-blob outbox entries for - * resume: max `queuedAt`, ties broken by the (nonce-bearing) key string so the - * choice is deterministic across windows. Null when no record exists. + * 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. * - * 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 latestOutbox( +export function resumeWholeBlobOutbox( prefix: string, legacyKey: string, pubkey: string, relayUrl: string, parseStore: (json: unknown) => T | null, -): 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, @@ -392,6 +411,13 @@ export function latestOutbox( relayUrl, parseStore, )) { + if ( + record.key === legacyKey && + consumed !== null && + record.raw === consumed + ) { + continue; + } if ( best === null || record.queuedAt > best.queuedAt || @@ -400,7 +426,67 @@ export function latestOutbox( best = record; } } - return best?.store ?? null; + 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. + } } /** diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 10506531a3..4c8c26a69f 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -5,6 +5,7 @@ import { boundMuteStore, clearChannelMutesOutbox, DEFAULT_STORE, + isMutesStoreSubsumedBy, mergeStores, mutedChannelIdsFromStore, readChannelMutesOutbox, @@ -114,7 +115,18 @@ export function useChannelMutes( // to supersede is consumed into pending here and can never be GC'd out. const outbox = readChannelMutesOutbox(pubkey, relayUrl); if (outbox) { - managerRef.current?.publishMutes(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); } diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index edefdff4dc..6f71d5f037 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -5,6 +5,7 @@ import { boundChannelSectionsStore, clearChannelSectionsOutbox, DEFAULT_STORE, + markChannelSectionsLegacyConsumed, readChannelSectionsOutbox, readChannelSectionsStore, reclaimSupersededSectionsOutbox, @@ -153,7 +154,20 @@ export function useChannelSections( // to supersede is consumed into pending here and can never be GC'd out. const outbox = readChannelSectionsOutbox(pubkey, relayUrl); if (outbox) { - managerRef.current?.publishSections(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); } diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 67b52bc57d..84c93cd40b 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -5,6 +5,7 @@ import { boundChannelSortStore, clearChannelSortOutbox, DEFAULT_STORE, + markChannelSortLegacyConsumed, readChannelSortOutbox, readChannelSortStore, reclaimSupersededSortOutbox, @@ -150,7 +151,20 @@ export function useChannelSortPreference( // consumed into pending here and can never be GC'd out. const outbox = readChannelSortOutbox(pubkey, relayUrl); if (outbox) { - managerRef.current?.publishSortPrefs(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); } diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index f480c266b4..7a53540287 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -5,6 +5,7 @@ import { boundStarStore, clearChannelStarsOutbox, DEFAULT_STORE, + isStarsStoreSubsumedBy, mergeStores, readChannelStarsOutbox, readChannelStarsStore, @@ -115,7 +116,18 @@ export function useChannelStars( // supersede is consumed into pending here and can never be GC'd out. const outbox = readChannelStarsOutbox(pubkey, relayUrl); if (outbox) { - managerRef.current?.publishStars(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); }