mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user