Skip to content

Commit 439df98

Browse files
Duncanwpfleger96
andcommitted
refactor(sidebar): fold channel stars/mutes onto Lamport-rev max-merge
Replace the LWW register plus ownership/dirty-set/canonical-supersession machinery with a per-entry Lamport `rev` and a single max-merge on every path, mirroring the read-state data model. Each entry carries an additive optional `rev` (missing implies 0; payload stays `version: 1` so older builds keep parsing our blobs). One `mergeStores` orders by updatedAt then rev then the starred/muted-true leaf, and ends in the 500-entry bound. Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0, maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id)) + 1`, so a click strictly dominates every state its replica has observed and cannot lose to a same-second remote. The sync managers hold a per-channel two-field high-water map fed by a single `observe()` on every ingest path. Stars sync keeps the generation-CAS + single-flight lane and bounded-backoff retry plus a durable outbox so an in-flight publish can never clear a newer pending edit; mutes sync mirrors it. Remote ingestion never touches the pending lane. Deleted: mergeApplyingRemote, mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds, lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch. Sections and sort are untouched. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 51af859 commit 439df98

12 files changed

Lines changed: 2232 additions & 1409 deletions

desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs

Lines changed: 256 additions & 212 deletions
Large diffs are not rendered by default.

desktop/src/features/sidebar/lib/channelMutesStorage.ts

Lines changed: 119 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
2+
13
const STORAGE_KEY_PREFIX = "buzz-channel-mutes.v1";
24
export const MAX_CHANNEL_MUTE_ENTRIES = 500;
35

46
export type ChannelMuteEntry = {
57
muted: boolean;
68
updatedAt: number;
9+
// Per-channel Lamport revision. Breaks a same-second `updatedAt` tie that the
10+
// integer clock cannot resolve. Absent in blobs from an older build ⇒ read as
11+
// 0 (a valid, mergeable value), so the payload stays `version: 1` and older
12+
// builds still parse our blobs.
13+
rev: number;
714
};
815

916
export type ChannelMuteStore = {
@@ -29,8 +36,8 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
2936
obj.channels !== null &&
3037
!Array.isArray(obj.channels)
3138
? Object.fromEntries(
32-
Object.entries(obj.channels as Record<string, unknown>).filter(
33-
(entry): entry is [string, ChannelMuteEntry] => {
39+
Object.entries(obj.channels as Record<string, unknown>)
40+
.filter((entry): entry is [string, Record<string, unknown>] => {
3441
const v = entry[1];
3542
return (
3643
typeof v === "object" &&
@@ -42,8 +49,27 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
4249
) &&
4350
((v as Record<string, unknown>).updatedAt as number) >= 0
4451
);
45-
},
46-
),
52+
})
53+
// Normalize `rev`: accept a non-negative integer, otherwise 0. An
54+
// entry is never dropped solely because `rev` is absent (older
55+
// build) or malformed — absence is a valid mergeable value.
56+
.map(([id, v]) => {
57+
const rawRev = v.rev;
58+
const rev =
59+
typeof rawRev === "number" &&
60+
Number.isInteger(rawRev) &&
61+
rawRev >= 0
62+
? rawRev
63+
: 0;
64+
return [
65+
id,
66+
{
67+
muted: v.muted as boolean,
68+
updatedAt: v.updatedAt as number,
69+
rev,
70+
},
71+
];
72+
}),
4773
)
4874
: {};
4975
return boundMuteStore({ version: 1, channels });
@@ -108,79 +134,47 @@ export function writeChannelMutesStore(
108134
}
109135
}
110136

111-
export function mergeStores(
112-
local: ChannelMuteStore,
113-
remote: ChannelMuteStore,
114-
): ChannelMuteStore {
115-
return mergeStoresWithTie(local, remote, false);
116-
}
117-
118137
/**
119-
* Merge a remote store that has already won the event-level canonical tie-break
120-
* (`created_at DESC, id ASC`) into the local store, resolving a per-entry
121-
* `updatedAt` tie in favour of the *remote* value. Once the comparator has
122-
* chosen this remote event as the stored winner, its per-entry values must
123-
* survive, or a stale value from a superseded larger-id event delivered first
124-
* would win the merge and silently undo the canonical winner. Strictly-newer
125-
* local per-entry edits (`l.updatedAt > r.updatedAt`) still win.
138+
* Merge two mute stores by a per-channel total order:
139+
* `updatedAt` DESC → `rev` DESC → `muted === true` wins. This order is
140+
* commutative, associative, and idempotent (before bounding), so every
141+
* observation path (bootstrap, live, reconnect, reconcile, pre-publish,
142+
* cross-window storage) applies it with no ordering or ownership overlay and
143+
* all replicas converge.
144+
*
145+
* `updatedAt` is primary so a strictly-later edit — from any build, whether it
146+
* carries `rev` or (older build) reads `rev: 0` — wins outright. `rev` breaks
147+
* only a same-second `updatedAt` tie: the ambiguous integer-second window the
148+
* clock cannot resolve, where a click that minted `rev = maxSeen + 1` dominates
149+
* any same-second state it observed. On a full tie (equal `updatedAt` AND equal
150+
* `rev`) `true` wins as the deterministic leaf.
126151
*/
127-
export function mergeApplyingRemote(
128-
local: ChannelMuteStore,
129-
remote: ChannelMuteStore,
130-
): ChannelMuteStore {
131-
return mergeStoresWithTie(local, remote, true);
132-
}
133-
134-
function mergeStoresWithTie(
135-
local: ChannelMuteStore,
136-
remote: ChannelMuteStore,
137-
preferRemoteOnTie: boolean,
152+
export function mergeStores(
153+
a: ChannelMuteStore,
154+
b: ChannelMuteStore,
138155
): ChannelMuteStore {
139156
const allIds = new Set([
140-
...Object.keys(local.channels),
141-
...Object.keys(remote.channels),
157+
...Object.keys(a.channels),
158+
...Object.keys(b.channels),
142159
]);
143160
const merged: Record<string, ChannelMuteEntry> = {};
144161
for (const id of allIds) {
145-
const l = local.channels[id];
146-
const r = remote.channels[id];
147-
if (l && r) {
148-
const localWins = preferRemoteOnTie
149-
? l.updatedAt > r.updatedAt
150-
: l.updatedAt >= r.updatedAt;
151-
merged[id] = localWins ? l : r;
152-
} else {
153-
merged[id] = (l ?? r) as ChannelMuteEntry;
154-
}
162+
const l = a.channels[id];
163+
const r = b.channels[id];
164+
merged[id] = l && r ? pickMuteEntry(l, r) : ((l ?? r) as ChannelMuteEntry);
155165
}
156166
return boundMuteStore({ version: 1, channels: merged });
157167
}
158168

159-
/**
160-
* Apply a canonical lower-id correction (`mergeApplyingRemote`: remote wins a
161-
* per-entry `updatedAt` tie) while preserving entries the user changed locally
162-
* since the superseded head was applied. The correction canonicalises remote
163-
* history, but a same-second local click carries integer-second `updatedAt`
164-
* equal to the remote's, so the plain remote-wins tie would silently clobber
165-
* it. For each `dirtyId` the local entry wins only the tie (`l.updatedAt >=
166-
* r.updatedAt`) — a genuinely newer remote value still wins, so a stale dirty
167-
* id can never override a later correction.
168-
*/
169-
export function mergeCanonicalSupersession(
170-
local: ChannelMuteStore,
171-
remote: ChannelMuteStore,
172-
dirtyIds: ReadonlySet<string>,
173-
): ChannelMuteStore {
174-
const applied = mergeApplyingRemote(local, remote);
175-
if (dirtyIds.size === 0) return applied;
176-
const channels = { ...applied.channels };
177-
for (const id of dirtyIds) {
178-
const l = local.channels[id];
179-
if (!l) continue;
180-
const r = remote.channels[id];
181-
if (!r || l.updatedAt >= r.updatedAt) channels[id] = l;
182-
}
183-
return boundMuteStore({ version: 1, channels });
169+
/** The winner of two entries under `updatedAt` → `rev` → `muted` order. */
170+
function pickMuteEntry(
171+
l: ChannelMuteEntry,
172+
r: ChannelMuteEntry,
173+
): ChannelMuteEntry {
174+
if (l.updatedAt !== r.updatedAt) return l.updatedAt > r.updatedAt ? l : r;
175+
if (l.rev !== r.rev) return l.rev > r.rev ? l : r;
176+
if (l.muted !== r.muted) return l.muted ? l : r;
177+
return l;
184178
}
185179

186180
export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
@@ -190,3 +184,62 @@ export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
190184
.map(([id]) => id),
191185
);
192186
}
187+
188+
const OUTBOX_KEY_PREFIX = "buzz-channel-mutes-outbox.v1";
189+
190+
// The outbox is a per-relay sync-lane structure (like the watermark), so it is
191+
// relay-scoped even though the main store stays pubkey-only: an edit made
192+
// against relay A must never resume-publish onto relay B after a community
193+
// switch.
194+
function outboxKey(pubkey: string, relayUrl: string): string {
195+
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
196+
}
197+
198+
/**
199+
* Persist an unpublished edit so it survives quit/community-switch within the
200+
* 2s publish debounce. Written synchronously on every click; cleared once the
201+
* edit is published or found identical to the last published store. Resumed on
202+
* next mount so a durable intent is never silently dropped at teardown.
203+
*/
204+
export function writeChannelMutesOutbox(
205+
pubkey: string,
206+
store: ChannelMuteStore,
207+
relayUrl: string,
208+
): void {
209+
try {
210+
window.localStorage.setItem(
211+
outboxKey(pubkey, relayUrl),
212+
JSON.stringify(boundMuteStore(store)),
213+
);
214+
} catch {
215+
// Best-effort durability; the in-memory pendingStore still drives this
216+
// session's publish even if the persisted copy could not be written.
217+
}
218+
}
219+
220+
/** Read a persisted unpublished edit, or null when none/unparseable. */
221+
export function readChannelMutesOutbox(
222+
pubkey: string,
223+
relayUrl: string,
224+
): ChannelMuteStore | null {
225+
try {
226+
const raw = window.localStorage.getItem(outboxKey(pubkey, relayUrl));
227+
if (!raw) return null;
228+
return parseMutePayload(JSON.parse(raw));
229+
} catch {
230+
return null;
231+
}
232+
}
233+
234+
/** Clear the persisted outbox (edit published or a no-op). */
235+
export function clearChannelMutesOutbox(
236+
pubkey: string,
237+
relayUrl: string,
238+
): void {
239+
try {
240+
window.localStorage.removeItem(outboxKey(pubkey, relayUrl));
241+
} catch {
242+
// Ignore — a stale outbox entry is re-evaluated (and re-cleared if
243+
// identical to the head) on the next publish attempt.
244+
}
245+
}

0 commit comments

Comments
 (0)