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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-26 22:11:44 -04:00
parent 323bf68107
commit 3712e6fb2c
12 changed files with 383 additions and 39 deletions

View File

@@ -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),
);
}

View File

@@ -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,

View File

@@ -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",
);

View File

@@ -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);

View File

@@ -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",
);

View File

@@ -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),
);
}

View File

@@ -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", () => {

View File

@@ -187,6 +187,11 @@ function nextOwnKey(base: string): string {
export type OutboxRecord<T> = {
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<T>(
records.push({
key,
store: parsed.store,
raw,
queuedAt: parsed.queuedAt,
isOwn: key.startsWith(ownPrefix),
});
@@ -325,6 +331,7 @@ export function enumerateOutbox<T>(
records.push({
key: legacyKey,
store: legacy.store,
raw: legacyRaw,
queuedAt: legacy.queuedAt,
isOwn: false,
});
@@ -370,20 +377,32 @@ export function reclaimOutbox<T>(
}
/**
* 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<T>(
export function resumeWholeBlobOutbox<T>(
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<T> | null = null;
for (const record of enumerateOutbox(
prefix,
@@ -392,6 +411,13 @@ export function latestOutbox<T>(
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<T>(
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.
}
}
/**

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}