fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)

Fixes the bug where running a dev build with stale localStorage would
publish outdated channel sections, sort preferences, starred channels,
and muted channels to the relay, clobbering the DMG installation's live
state.

## Root cause

All four sidebar-preference sync managers (`channelSectionsSync`,
`channelSortSync`, `channelStarsSync`, `channelMutesSync`) collapsed
five distinct fetch outcomes — no event, timeout, error, auth-race empty
result, decrypt/parse failure — into a single `null`. Each hook's boot
effect treated `null` as "no remote exists" and seed-published whatever
was in localStorage, stamped at `max(now, lastRemoteCreatedAt+1)` with
`lastRemoteCreatedAt` reset to 0 on every boot. A dev build with stale
localStorage therefore re-signed old state as newer, and the DMG's live
subscription applied it.

## Two guards

**1. Tri-state fetch result** (`found | absent | failed`) — decrypt
failure on an existing event reports `failed` and records
`event.created_at`, so seed-publish is blocked even when the payload is
unreadable.

**2. Persisted head watermark** (`sidebarSyncWatermark.ts`) — keyed
`{blobType, pubkey, normalizedRelayUrl}`, written to localStorage on
every observed remote event (before decrypt on all paths: initial fetch,
live subscription, `fetchOwnBlobBeforePublish`), hydrated at
construction. Any session that has ever seen a remote blob skips
seed-publish on the next boot even when the fetch returns empty. Relay
URLs are normalised via `shared/lib/normalizeRelayUrl` (also used by
profile storage) so the same relay written two ways never produces two
keys.

**Bootstrap owns the seed.** Each manager exposes
`bootstrap(localStore)` that fetches, records the raw head, and
delegates the decision to the single `runBootstrap` policy: hold on
`failed` or `absent + prior watermark`, seed on genuine first-sync
(`absent + zero watermark + non-empty local`), `apply-remote` when a
blob was found. Hooks only act on `apply-remote`; they cannot publish
during bootstrap. First-time sync is unchanged: successful EOSE with no
event, zero watermark, and non-empty local state still seeds.

## LWW baseline preservation

`fetchOwnBlobBeforePublish` for sections/sort snapshots the watermark
before `recordRemoteHead` advances it, then compares the fetched event
against the snapshot — advancing first would make `remote.createdAt >
lastRemoteCreatedAt` always false and silently kill the whole-blob LWW
merge. Stars/mutes merge per-entry via `mergeStores`, so no snapshot is
needed there.

## Relay lifecycle

All four hooks require a defined `relayUrl` (plumbed from
`communitiesHook.activeCommunity?.relayUrl` in `AppShell.tsx`); while it
is undefined no manager is constructed and no boot/live/reconnect effect
binds. All effects depend on `[pubkey, relayUrl]`, so community switches
tear down and rebind. `destroy()` cancels pending publishes without
flushing — flushing would race community switching and could publish
relay A's state to relay B via the shared `relayClient` singleton.
Pending debounce-window edits are intentionally dropped: stars/mutes
entries survive via per-entry merge on the next publish; a dropped
sections/sort edit is lost because bootstrap whole-blob-replaces from
remote on return.

Known trade-off: a first boot with the relay unreachable holds (never
seeds) until the user's next explicit edit — preferred over risking a
stale seed-publish.

## Files

- `sidebarSyncWatermark.ts` — watermark persistence + `runBootstrap`
policy (tri-state `FetchResult`, `readWatermark`, `advanceWatermark`)
- `shared/lib/normalizeRelayUrl.ts` — relay-URL normalisation shared by
watermark keys and profile storage
- `channelSectionsSync.ts`, `channelSortSync.ts`, `channelStarsSync.ts`,
`channelMutesSync.ts` — tri-state fetch, pre-decrypt `recordRemoteHead`
on all paths, sections/sort watermark snapshot for LWW, `bootstrap()`,
cancel-without-flush `destroy()`
- `useChannelSections.ts`, `useChannelSortPreference.ts`,
`useChannelStars.ts`, `useChannelMutes.ts` — act on `bootstrap()`
results, gate on `relayUrl`, `[pubkey, relayUrl]` deps on all effects
- `AppShell.tsx` — passes `activeCommunity?.relayUrl` to
`useChannelMutes` and `useChannelStars`
- `sidebarSyncTestHelpers.mjs` — shared fake-window/localStorage/Tauri
mocks for the four manager suites
- Test suites — mutation-sensitive coverage: `failed→hold`,
`absent+watermark→hold`, first-sync seeds, undecryptable head recorded
on all paths, relay-A/B watermark isolation, watermark restart
round-trip, sections/sort LWW baseline

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-06 17:21:02 -04:00
committed by GitHub
parent c777d4fb9a
commit b08c8b126c
20 changed files with 1681 additions and 487 deletions

View File

@@ -173,9 +173,11 @@ export function AppShell() {
const identityQuery = useIdentityQuery();
const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
);
const { starredChannelIds, starChannel, unstarChannel } = useChannelStars(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
);
usePersonaSync(
identityQuery.data?.pubkey,

View File

@@ -11,16 +11,10 @@
* prevents one community's cached identity from bleeding into another.
*/
const STORAGE_KEY_PREFIX = "buzz-self-profile.v1";
export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
/**
* Normalizes a relay URL for use in storage keys.
* Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to
* the same key regardless of formatting differences.
*/
export function normalizeRelayUrl(relayUrl: string): string {
return relayUrl.trim().replace(/\/+$/, "").toLowerCase();
}
const STORAGE_KEY_PREFIX = "buzz-self-profile.v1";
/**
* Dispatched on window after a successful writeSelfProfileCache so that any

View File

@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { ChannelMuteSyncManager } from "./channelMutesSync.ts";
import {
makeFakeWindow,
installFakeWindow,
} from "./sidebarSyncTestHelpers.mjs";
const RELAY = "wss://r.test";
const RELAY_KEY = encodeURIComponent(RELAY);
function makeStore(channels = {}) {
return { version: 1, channels };
}
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
// Regression guard for the community-switch cross-relay publish vector:
// mute a channel in relay A → destroy() called (relayUrl dep change) →
// no publish should fire.
test("destroy: cancels pending publish without flushing to the relay", () => {
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-test", RELAY);
manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } }));
manager.destroy();
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingMuteStore(), null);
} finally {
restore();
mock.reset();
}
});
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
let releaseFetch = null;
const publishCalls = [];
mock.method(
relayClient,
"fetchEvents",
() =>
new Promise((res) => {
releaseFetch = () => res([]);
}),
);
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-race", RELAY);
manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } }));
fw._fireTimer();
manager.destroy();
releaseFetch();
await new Promise((r) => setTimeout(r, 0));
assert.equal(publishCalls.length, 0);
} finally {
restore();
mock.reset();
}
});
test("destroy: is safe to call with no pending publish", () => {
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────────
// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued)
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(
makeStore({ ch1: { muted: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingMuteStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued)
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(
makeStore({ ch1: { muted: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingMuteStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null)
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelMuteSyncManager("pk-fresh", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`,
),
null,
);
const result = await manager.bootstrap(
makeStore({ ch1: { muted: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.ok(manager.getPendingMuteStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. relay-A / relay-B watermark isolation
// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync.
test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => {
const relayA = "wss://a.relay.test";
const relayB = "wss://b.relay.test";
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`,
"1700000100",
);
const restore = installFakeWindow(fw);
try {
const managerB = new ChannelMuteSyncManager("pk-iso", relayB);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`,
),
null,
"relay B watermark must be independent of relay A head",
);
const result = await managerB.bootstrap(
makeStore({ ch1: { muted: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.ok(
managerB.getPendingMuteStore() !== null,
"first-sync seed on relay B must not be blocked by relay A watermark",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -11,8 +11,15 @@ import {
parseMutePayload,
type ChannelMuteStore,
} from "./channelMutesStorage";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
const D_TAG = "channel-mutes";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
export type RemoteMutes = {
@@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise<RemoteMutes | null> {
export class ChannelMuteSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt = 0;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelMuteStore | null = null;
private lastPublishedStore: ChannelMuteStore | null = null;
private destroyed = false;
constructor(pubkey: string) {
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteMutes(): Promise<RemoteMutes | null> {
async fetchRemoteMutes(): Promise<FetchResult<RemoteMutes>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_MUTES],
@@ -51,21 +62,33 @@ export class ChannelMuteSyncManager {
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return null;
if (events[0].pubkey !== this.pubkey) return null;
const result = await decryptAndParse(events[0]);
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
return result;
const event = events[0];
this.recordRemoteHead(event.created_at);
const result = await decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
return {
status: "found",
data: result,
createdAt: result.createdAt,
eventId: result.eventId,
};
} catch {
return null;
return { status: "failed" };
}
}
private recordRemoteHead(createdAt: number): void {
if (createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
}
cancelPendingMutePublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
@@ -99,12 +122,11 @@ export class ChannelMuteSyncManager {
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const remote = await decryptAndParse(events[0]);
const event = events[0];
// Record the raw head before decrypt on the pre-publish path too.
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
remote.createdAt,
);
return mergeStores(store, remote.store);
} catch {
return store;
@@ -132,6 +154,10 @@ export class ChannelMuteSyncManager {
private async doPublish(store: ChannelMuteStore): Promise<void> {
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
// Guard: manager may have been destroyed while fetchOwnBlobBeforePublish
// was awaited (community switch during in-flight fetch). If so, abort
// before touching the relay.
if (this.destroyed) return;
if (this.isIdenticalToLastPublished(merged)) {
this.pendingStore = null;
return;
@@ -154,15 +180,13 @@ export class ChannelMuteSyncManager {
["t", D_TAG], // relay discoverability; not used in our filters
],
});
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel mutes.",
"Failed to publish channel mutes.",
);
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
event.created_at,
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
@@ -182,12 +206,11 @@ export class ChannelMuteSyncManager {
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
// Record the raw head before decrypt so an undecryptable live event
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
onUpdate(result);
}
});
@@ -195,14 +218,30 @@ export class ChannelMuteSyncManager {
);
}
/**
* Fetches the remote blob on first mount, records the remote head, and
* delegates the seed/hold/apply-remote decision to `runBootstrap`.
*/
async bootstrap(localStore: ChannelMuteStore) {
const fetchResult = await this.fetchRemoteMutes();
return runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0,
publishFn: (s) => this.publishMutes(s),
});
}
destroy(): void {
if (this.debounceTimer !== null && this.pendingStore !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
void this.doPublish(this.pendingStore);
} else if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
// Cancel any pending publish and mark this manager as destroyed so any
// in-flight doPublish() calls abort before reaching relayClient.
// Pending debounce-window changes are intentionally dropped: flushing
// could publish relay A's state to relay B via the shared relayClient
// singleton. Local entries survive because the apply/publish paths merge
// per-entry via mergeStores, so no local work is permanently lost.
this.destroyed = true;
this.cancelPendingMutePublish();
this.pendingStore = null;
}
}

View File

@@ -1,4 +1,4 @@
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";

View File

@@ -3,6 +3,11 @@ import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { ChannelSectionSyncManager } from "./channelSectionsSync.ts";
import {
makeFakeWindow,
installFakeWindow,
installTauriMock,
} from "./sidebarSyncTestHelpers.mjs";
function makeStore(overrides = {}) {
return {
@@ -13,198 +18,265 @@ function makeStore(overrides = {}) {
};
}
function makeSectionsStore(sections = []) {
return { version: 1, sections, assignments: {} };
}
const RELAY = "wss://r.test";
const RELAY_KEY = encodeURIComponent(RELAY);
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
// Regression guard for the community-switch cross-relay publish vector:
// edit sections in relay A → destroy() is called (relayUrl dep change) →
// no publish should fire. The scoped localStorage write is durable; when the
// user returns to relay A the seed-publish path handles it.
// no publish should fire.
test("destroy: cancels pending publish without flushing to the relay", () => {
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
// Simulate the timer scheduler with a manual clock so we can advance it.
let timerCallback = null;
const originalSetTimeout = globalThis.window?.setTimeout;
const originalClearTimeout = globalThis.window?.clearTimeout;
// Inject a fake window.setTimeout/clearTimeout if needed.
const fakeTimers = [];
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
globalThis.window.setTimeout = (fn, _ms) => {
const id = nextId++;
fakeTimers.push({ id, fn });
timerCallback = fn;
return id;
};
globalThis.window.clearTimeout = (id) => {
const idx = fakeTimers.findIndex((t) => t.id === id);
if (idx !== -1) {
fakeTimers.splice(idx, 1);
timerCallback = null;
}
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-test");
const store = makeStore({
sections: [{ id: "s1", name: "Work", order: 0 }],
});
// Queue a publish — this sets the debounce timer.
manager.publishSections(store);
assert.ok(timerCallback !== null, "debounce timer should be set");
// Destroy before the debounce fires — simulates community switch.
const manager = new ChannelSectionSyncManager("pk-test", RELAY);
manager.publishSections(
makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }),
);
assert.ok(fw._hasTimer(), "debounce timer should be set");
manager.destroy();
// Timer must be cleared and no publish should fire now.
assert.ok(
timerCallback === null,
"debounce timer should be cleared on destroy",
);
// Advance time by invoking the callback that was cleared — it shouldn't exist.
// If clearTimeout didn't work, try firing whatever was captured before destroy.
// (There's nothing to fire after a correct destroy.)
assert.equal(
publishCalls.length,
0,
"no publish event should have been sent after destroy",
);
assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy");
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingStore(), null);
} finally {
// Restore timer functions.
if (originalSetTimeout !== undefined) {
globalThis.window.setTimeout = originalSetTimeout;
}
if (originalClearTimeout !== undefined) {
globalThis.window.clearTimeout = originalClearTimeout;
}
restore();
mock.reset();
}
});
// Regression guard for the timer-fired race: debounce fires → doPublish starts
// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep
// change) → publishEvent must never be called even though the timer already
// fired and cleared itself before destroy() ran.
// Regression guard for the timer-fired race: debounce fires → doPublish awaits
// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire.
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
// fetchEvents is held until we release it — simulates the latency window.
let releaseFetch = null;
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => {
return new Promise((resolve) => {
// resolve with empty so fetchOwnBlobBeforePublish returns the local store
releaseFetch = () => resolve([]);
});
});
mock.method(
relayClient,
"fetchEvents",
() =>
new Promise((res) => {
releaseFetch = () => res([]);
}),
);
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
let capturedCallback = null;
let nextId = 1;
const origSetTimeout = globalThis.window.setTimeout;
const origClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
capturedCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
capturedCallback = null;
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-race");
const store = makeStore({
sections: [{ id: "s1", name: "Work", order: 0 }],
});
// Queue the publish — captures the debounce callback.
manager.publishSections(store);
assert.ok(capturedCallback !== null, "debounce timer should be set");
// Fire the debounce manually — this starts doPublish() and nulls
// debounceTimer inside publishSections' callback, leaving the async
// doPublish running and awaiting fetchOwnBlobBeforePublish.
const timerFn = capturedCallback;
capturedCallback = null; // timer cleared itself inside the callback
timerFn();
// Now destroy() — debounceTimer is already null (timer fired), so only
// the destroyed flag can stop doPublish.
const manager = new ChannelSectionSyncManager("pk-race", RELAY);
manager.publishSections(
makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }),
);
fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish
manager.destroy();
// Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with
// the local store, then doPublish should check destroyed and abort.
releaseFetch();
// Drain microtasks so doPublish fully runs through to its abort point.
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((r) => setTimeout(r, 0));
assert.equal(
publishCalls.length,
0,
"publishEvent must not be called after destroy() even when timer already fired",
"publishEvent must not fire after destroy",
);
} finally {
globalThis.window.setTimeout = origSetTimeout;
globalThis.window.clearTimeout = origClearTimeout;
restore();
mock.reset();
}
});
test("destroy: is safe to call with no pending publish", () => {
const manager = new ChannelSectionSyncManager("pk-no-pending");
// Should not throw even with nothing queued.
assert.doesNotThrow(() => manager.destroy());
});
test("destroy: cancelPendingPublish clears pendingStore", () => {
let timerCallback = null;
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const orig = globalThis.window.setTimeout;
const origClear = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
timerCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
timerCallback = null;
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-pending-null");
const store = makeStore({
sections: [{ id: "s1", name: "Test", order: 0 }],
});
manager.publishSections(store);
assert.deepEqual(manager.getPendingStore(), store);
manager.destroy();
assert.equal(
manager.getPendingStore(),
null,
"pendingStore must be null after destroy",
);
assert.ok(timerCallback === null, "timer must be cleared after destroy");
const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
globalThis.window.setTimeout = orig;
globalThis.window.clearTimeout = origClear;
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ────────────────
// Wiring tests 1-3 drive the production bootstrap() path; policy tested once
// in sidebarSyncWatermark.test.mjs.
// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued)
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued)
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null)
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-fresh", RELAY);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.ok(manager.getPendingStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. LWW baseline: newer decryptable pre-publish event still wins after an
// undecryptable head was recorded.
// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison
// 200>200=false → local wins instead of remote → wrong content encrypted.
test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => {
const REMOTE_ID = "remote-section-from-relay";
let callCount = 0;
mock.method(relayClient, "fetchEvents", () => {
callCount++;
return Promise.resolve([
{
pubkey: "pk-lww",
content: callCount === 1 ? "bad-cipher" : "good-cipher",
created_at: callCount === 1 ? 100 : 200,
id: `evt-${callCount}`,
},
]);
});
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock(
JSON.stringify({
version: 1,
sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }],
assignments: {},
}),
);
try {
const manager = new ChannelSectionSyncManager("pk-lww", RELAY);
await manager.fetchRemoteSections();
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`,
) ?? "0",
) >= 100,
);
manager.publishSections(
makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]),
);
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
const pt = tauri.capturedPlaintext();
assert.ok(pt !== null, "nip44EncryptToSelf must have been called");
assert.ok(
JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID),
`remote sections must win LWW merge — got: ${pt}`,
);
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// 5. live-sub: undecryptable event on live path records head before decrypt
// Mutation test: removing recordRemoteHead before decrypt in the live callback
// leaves watermark at 0 after a live event.
test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => {
let liveCallback = null;
mock.method(relayClient, "subscribeLive", (_filter, onEvent) => {
liveCallback = onEvent;
return Promise.resolve(async () => {});
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-live", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`,
),
null,
"watermark starts absent",
);
await manager.subscribeToSections(() => {});
assert.ok(
liveCallback !== null,
"subscribeLive must have captured the callback",
);
liveCallback({
pubkey: "pk-live",
content: "!bad-cipher!",
created_at: 1700005555,
id: "live-evt-1",
});
await new Promise((r) => setTimeout(r, 0));
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`,
) ?? "0",
) >= 1700005555,
"live undecryptable event must advance the watermark before decrypt is attempted",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -11,8 +11,15 @@ import {
type ChannelSection,
type ChannelSectionStore,
} from "./channelSectionsStorage";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
const D_TAG = "channel-sections";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
export type RemoteSections = {
@@ -36,17 +43,22 @@ async function decryptAndParse(
export class ChannelSectionSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt = 0;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelSectionStore | null = null;
private lastPublishedStore: ChannelSectionStore | null = null;
private destroyed = false;
constructor(pubkey: string) {
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
// Hydrate from localStorage so we never seed-publish if a remote blob has
// been seen in a prior session.
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteSections(): Promise<RemoteSections | null> {
async fetchRemoteSections(): Promise<FetchResult<RemoteSections>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SECTIONS],
@@ -54,21 +66,37 @@ export class ChannelSectionSyncManager {
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return null;
if (events[0].pubkey !== this.pubkey) return null;
const result = await decryptAndParse(events[0]);
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
return result;
const event = events[0];
// An event exists — record its created_at regardless of whether we can
// decrypt it, so seed-publish is blocked even when the payload is
// unreadable (e.g. wrong key).
this.recordRemoteHead(event.created_at);
const result = await decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
return {
status: "found",
data: result,
createdAt: result.createdAt,
eventId: result.eventId,
};
} catch {
return null;
return { status: "failed" };
}
}
/** Update in-memory + persisted watermark. */
private recordRemoteHead(createdAt: number): void {
if (createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
}
cancelPendingPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
@@ -102,11 +130,17 @@ export class ChannelSectionSyncManager {
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const remote = await decryptAndParse(events[0]);
const event = events[0];
// Snapshot the watermark before advancing it: after recordRemoteHead
// runs, lastRemoteCreatedAt equals event.created_at, so the LWW
// comparison remote.createdAt > lastRemoteCreatedAt would always be
// false and silently suppress the merge.
const headBeforeFetch = this.lastRemoteCreatedAt;
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
// Sections use whole-blob LWW: take whichever is newer
if (remote.createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = remote.createdAt;
if (remote.createdAt > headBeforeFetch) {
return remote.store;
}
return store;
@@ -181,10 +215,7 @@ export class ChannelSectionSyncManager {
"Timed out publishing channel sections.",
"Failed to publish channel sections.",
);
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
event.created_at,
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
@@ -204,12 +235,11 @@ export class ChannelSectionSyncManager {
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
// Record the raw head before decrypt so an undecryptable live event
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
onUpdate(result);
}
});
@@ -217,14 +247,28 @@ export class ChannelSectionSyncManager {
);
}
/**
* Fetches the remote blob on first mount, records the remote head, and
* delegates the seed/hold/apply-remote decision to `runBootstrap`.
*/
async bootstrap(localStore: ChannelSectionStore) {
const fetchResult = await this.fetchRemoteSections();
return runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => s.sections.length > 0,
publishFn: (s) => this.publishSections(s),
});
}
destroy(): void {
// Cancel any pending publish and mark this manager as destroyed so any
// in-flight doPublish() calls abort before reaching relayClient. The
// scoped localStorage write is already durable; when the user returns to
// this relay the existing seed-publish guard will re-publish from local
// state. Flushing here would race against community switching and could
// publish relay A's sections to relay B via the shared relayClient
// singleton.
// in-flight doPublish() calls abort before reaching relayClient.
// Pending debounce-window changes are intentionally dropped: flushing
// could publish relay A's sections to relay B via the shared relayClient
// singleton. On return, bootstrap's found path whole-blob-replaces from
// remote, so any dropped pending edit is lost.
this.destroyed = true;
this.cancelPendingPublish();
this.pendingStore = null;

View File

@@ -1,4 +1,4 @@
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import type { Channel } from "@/shared/api/types";
const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1";

View File

@@ -3,174 +3,260 @@ import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { ChannelSortSyncManager } from "./channelSortSync.ts";
import {
makeFakeWindow,
installFakeWindow,
installTauriMock,
} from "./sidebarSyncTestHelpers.mjs";
function makeStore(groups = {}) {
return { version: 1, groups };
}
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
const RELAY = "wss://r.test";
const RELAY_KEY = encodeURIComponent(RELAY);
// Regression guard for the community-switch cross-relay publish vector:
// change a sort mode in relay A → destroy() is called (relayUrl dep change) →
// no publish should fire. The scoped localStorage write is durable; when the
// user returns to relay A the seed-publish path handles it.
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
test("destroy: cancels pending publish without flushing to the relay", () => {
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
let timerCallback = null;
const fakeTimers = [];
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const originalSetTimeout = globalThis.window.setTimeout;
const originalClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
const id = nextId++;
fakeTimers.push({ id, fn });
timerCallback = fn;
return id;
};
globalThis.window.clearTimeout = (id) => {
const idx = fakeTimers.findIndex((t) => t.id === id);
if (idx !== -1) {
fakeTimers.splice(idx, 1);
timerCallback = null;
}
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-test");
const store = makeStore({ channels: "recent" });
manager.publishSortPrefs(store);
assert.ok(timerCallback !== null, "debounce timer should be set");
const manager = new ChannelSortSyncManager("pk-test", RELAY);
manager.publishSortPrefs(makeStore({ channels: "recent" }));
assert.ok(fw._hasTimer(), "debounce timer should be set");
manager.destroy();
assert.ok(
timerCallback === null,
"debounce timer should be cleared on destroy",
);
assert.equal(
publishCalls.length,
0,
"no publish event should have been sent after destroy",
);
assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy");
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingStore(), null);
} finally {
if (originalSetTimeout !== undefined) {
globalThis.window.setTimeout = originalSetTimeout;
}
if (originalClearTimeout !== undefined) {
globalThis.window.clearTimeout = originalClearTimeout;
}
restore();
mock.reset();
}
});
// Regression guard for the timer-fired race: debounce fires → doPublish starts
// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep
// change) → publishEvent must never be called even though the timer already
// fired and cleared itself before destroy() ran.
// Regression guard for the timer-fired race: debounce fires → doPublish awaits
// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire.
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
let releaseFetch = null;
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => {
return new Promise((resolve) => {
releaseFetch = () => resolve([]);
});
});
mock.method(
relayClient,
"fetchEvents",
() =>
new Promise((res) => {
releaseFetch = () => res([]);
}),
);
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
let capturedCallback = null;
let nextId = 1;
const origSetTimeout = globalThis.window.setTimeout;
const origClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
capturedCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
capturedCallback = null;
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-race");
const store = makeStore({ dms: "recent" });
manager.publishSortPrefs(store);
assert.ok(capturedCallback !== null, "debounce timer should be set");
const timerFn = capturedCallback;
capturedCallback = null; // timer cleared itself inside the callback
timerFn();
const manager = new ChannelSortSyncManager("pk-race", RELAY);
manager.publishSortPrefs(makeStore({ dms: "recent" }));
fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish
manager.destroy();
releaseFetch();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((r) => setTimeout(r, 0));
assert.equal(
publishCalls.length,
0,
"publishEvent must not be called after destroy() even when timer already fired",
"publishEvent must not fire after destroy",
);
} finally {
globalThis.window.setTimeout = origSetTimeout;
globalThis.window.clearTimeout = origClearTimeout;
restore();
mock.reset();
}
});
test("destroy: is safe to call with no pending publish", () => {
const manager = new ChannelSortSyncManager("pk-no-pending");
assert.doesNotThrow(() => manager.destroy());
});
test("destroy: cancelPendingPublish clears pendingStore", () => {
let timerCallback = null;
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const orig = globalThis.window.setTimeout;
const origClear = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
timerCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
timerCallback = null;
};
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-pending-null");
const store = makeStore({ starred: "recent" });
manager.publishSortPrefs(store);
assert.deepEqual(manager.getPendingStore(), store);
manager.destroy();
assert.equal(
manager.getPendingStore(),
null,
"pendingStore must be null after destroy",
);
assert.ok(timerCallback === null, "timer must be cleared after destroy");
const manager = new ChannelSortSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
globalThis.window.setTimeout = orig;
globalThis.window.clearTimeout = origClear;
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ────────────────
// Wiring tests 1-3 drive the production bootstrap() path; policy tested once
// in sidebarSyncWatermark.test.mjs.
// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls
// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set.
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case)
// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed.
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved)
// Mutation: removing the absent+head-0 seed call leaves pendingStore null.
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-fresh", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`,
),
null,
);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.ok(manager.getPendingStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. LWW baseline: newer decryptable pre-publish event still wins after an
// undecryptable head was recorded.
// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison
// 200>200=false → local wins instead of remote → wrong content encrypted.
test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => {
const REMOTE_KEY = "remote-group-from-relay";
let callCount = 0;
mock.method(relayClient, "fetchEvents", () => {
callCount++;
return Promise.resolve([
{
pubkey: "pk-lww",
content: callCount === 1 ? "bad-cipher" : "good-cipher",
created_at: callCount === 1 ? 100 : 200,
id: `evt-${callCount}`,
},
]);
});
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock(
JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }),
);
try {
const manager = new ChannelSortSyncManager("pk-lww", RELAY);
await manager.fetchRemoteSortPrefs();
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`,
) ?? "0",
) >= 100,
);
manager.publishSortPrefs(makeStore({ "local-group": "recent" }));
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
const pt = tauri.capturedPlaintext();
assert.ok(pt !== null, "nip44EncryptToSelf must have been called");
assert.ok(
JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups,
`remote groups must win LWW merge — got: ${pt}`,
);
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// 5. live-sub: undecryptable event on live path records head before decrypt
// Mutation test: removing recordRemoteHead before decrypt in the live callback
// leaves watermark at 0 after a live event.
test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => {
let liveCallback = null;
mock.method(relayClient, "subscribeLive", (_filter, onEvent) => {
liveCallback = onEvent;
return Promise.resolve(async () => {});
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-live", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`,
),
null,
"watermark starts absent",
);
await manager.subscribeToSortPrefs(() => {});
assert.ok(
liveCallback !== null,
"subscribeLive must have captured the callback",
);
liveCallback({
pubkey: "pk-live",
content: "!bad-cipher!",
created_at: 1700005555,
id: "live-evt-1",
});
await new Promise((r) => setTimeout(r, 0));
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`,
) ?? "0",
) >= 1700005555,
"live undecryptable event must advance the watermark before decrypt is attempted",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -10,8 +10,15 @@ import {
parseChannelSortPayload,
type ChannelSortStore,
} from "./channelSortPreference";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
const D_TAG = "channel-sort";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
export type RemoteSortPrefs = {
@@ -44,17 +51,20 @@ async function decryptAndParse(
*/
export class ChannelSortSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt = 0;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelSortStore | null = null;
private lastPublishedStore: ChannelSortStore | null = null;
private destroyed = false;
constructor(pubkey: string) {
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteSortPrefs(): Promise<RemoteSortPrefs | null> {
async fetchRemoteSortPrefs(): Promise<FetchResult<RemoteSortPrefs>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SORT],
@@ -62,21 +72,33 @@ export class ChannelSortSyncManager {
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return null;
if (events[0].pubkey !== this.pubkey) return null;
const result = await decryptAndParse(events[0]);
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
return result;
const event = events[0];
this.recordRemoteHead(event.created_at);
const result = await decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
return {
status: "found",
data: result,
createdAt: result.createdAt,
eventId: result.eventId,
};
} catch {
return null;
return { status: "failed" };
}
}
private recordRemoteHead(createdAt: number): void {
if (createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
}
cancelPendingPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
@@ -110,11 +132,17 @@ export class ChannelSortSyncManager {
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const remote = await decryptAndParse(events[0]);
const event = events[0];
// Snapshot the watermark before advancing it: after recordRemoteHead
// runs, lastRemoteCreatedAt equals event.created_at, so the LWW
// comparison remote.createdAt > lastRemoteCreatedAt would always be
// false and silently suppress the merge.
const headBeforeFetch = this.lastRemoteCreatedAt;
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
// Sort prefs use whole-blob LWW: take whichever is newer
if (remote.createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = remote.createdAt;
if (remote.createdAt > headBeforeFetch) {
return remote.store;
}
return store;
@@ -174,10 +202,7 @@ export class ChannelSortSyncManager {
"Timed out publishing channel sort preferences.",
"Failed to publish channel sort preferences.",
);
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
event.created_at,
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
@@ -197,12 +222,11 @@ export class ChannelSortSyncManager {
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
// Record the raw head before decrypt so an undecryptable live event
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
onUpdate(result);
}
});
@@ -210,14 +234,28 @@ export class ChannelSortSyncManager {
);
}
/**
* Fetches the remote blob on first mount, records the remote head, and
* delegates the seed/hold/apply-remote decision to `runBootstrap`.
*/
async bootstrap(localStore: ChannelSortStore) {
const fetchResult = await this.fetchRemoteSortPrefs();
return runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0,
publishFn: (s) => this.publishSortPrefs(s),
});
}
destroy(): void {
// Cancel any pending publish and mark this manager as destroyed so any
// in-flight doPublish() calls abort before reaching relayClient. The
// scoped localStorage write is already durable; when the user returns to
// this relay the existing seed-publish guard will re-publish from local
// state. Flushing here would race against community switching and could
// publish relay A's sort prefs to relay B via the shared relayClient
// singleton.
// in-flight doPublish() calls abort before reaching relayClient.
// Pending debounce-window changes are intentionally dropped: flushing
// could publish relay A's sort prefs to relay B via the shared relayClient
// singleton. On return, bootstrap's found path whole-blob-replaces from
// remote, so any dropped pending edit is lost.
this.destroyed = true;
this.cancelPendingPublish();
this.pendingStore = null;

View File

@@ -0,0 +1,202 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { ChannelStarSyncManager } from "./channelStarsSync.ts";
import {
makeFakeWindow,
installFakeWindow,
} from "./sidebarSyncTestHelpers.mjs";
const RELAY = "wss://r.test";
const RELAY_KEY = encodeURIComponent(RELAY);
function makeStore(channels = {}) {
return { version: 1, channels };
}
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
// Regression guard for the community-switch cross-relay publish vector:
// star a channel in relay A → destroy() called (relayUrl dep change) →
// no publish should fire.
test("destroy: cancels pending publish without flushing to the relay", () => {
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-test", RELAY);
manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } }));
manager.destroy();
assert.equal(publishCalls.length, 0, "no publish after destroy");
assert.equal(manager.getPendingStarStore(), null);
} finally {
restore();
mock.reset();
}
});
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
let releaseFetch = null;
const publishCalls = [];
mock.method(
relayClient,
"fetchEvents",
() =>
new Promise((res) => {
releaseFetch = () => res([]);
}),
);
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-race", RELAY);
manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } }));
fw._fireTimer();
manager.destroy();
releaseFetch();
await new Promise((r) => setTimeout(r, 0));
assert.equal(
publishCalls.length,
0,
"publishEvent must not be called after destroy",
);
} finally {
restore();
mock.reset();
}
});
test("destroy: is safe to call with no pending publish", () => {
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ─────────────────
// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued)
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(
makeStore({ ch1: { starred: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStarStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued)
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(
makeStore({ ch1: { starred: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStarStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null)
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelStarSyncManager("pk-fresh", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`,
),
null,
);
const result = await manager.bootstrap(
makeStore({ ch1: { starred: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.ok(manager.getPendingStarStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. relay-A / relay-B watermark isolation
// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync.
test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => {
const relayA = "wss://a.relay.test";
const relayB = "wss://b.relay.test";
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`,
"1700000100",
);
const restore = installFakeWindow(fw);
try {
const managerB = new ChannelStarSyncManager("pk-iso", relayB);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`,
),
null,
"relay B watermark must be independent of relay A head",
);
const result = await managerB.bootstrap(
makeStore({ ch1: { starred: true, updatedAt: 1 } }),
);
assert.equal(result.action, "hold");
assert.ok(
managerB.getPendingStarStore() !== null,
"first-sync seed on relay B must not be blocked by relay A watermark",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -11,8 +11,15 @@ import {
parseStarPayload,
type ChannelStarStore,
} from "./channelStarsStorage";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
const D_TAG = "channel-stars";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
export type RemoteStars = {
@@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise<RemoteStars | null> {
export class ChannelStarSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt = 0;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelStarStore | null = null;
private lastPublishedStore: ChannelStarStore | null = null;
private destroyed = false;
constructor(pubkey: string) {
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteStars(): Promise<RemoteStars | null> {
async fetchRemoteStars(): Promise<FetchResult<RemoteStars>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_STARS],
@@ -51,21 +62,33 @@ export class ChannelStarSyncManager {
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return null;
if (events[0].pubkey !== this.pubkey) return null;
const result = await decryptAndParse(events[0]);
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
return result;
const event = events[0];
this.recordRemoteHead(event.created_at);
const result = await decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
return {
status: "found",
data: result,
createdAt: result.createdAt,
eventId: result.eventId,
};
} catch {
return null;
return { status: "failed" };
}
}
private recordRemoteHead(createdAt: number): void {
if (createdAt > this.lastRemoteCreatedAt) {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
}
cancelPendingStarPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
@@ -99,12 +122,11 @@ export class ChannelStarSyncManager {
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
const remote = await decryptAndParse(events[0]);
const event = events[0];
// Record the raw head before decrypt on the pre-publish path too.
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
remote.createdAt,
);
return mergeStores(store, remote.store);
} catch {
return store;
@@ -132,6 +154,10 @@ export class ChannelStarSyncManager {
private async doPublish(store: ChannelStarStore): Promise<void> {
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
// Guard: manager may have been destroyed while fetchOwnBlobBeforePublish
// was awaited (community switch during in-flight fetch). If so, abort
// before touching the relay.
if (this.destroyed) return;
if (this.isIdenticalToLastPublished(merged)) {
this.pendingStore = null;
return;
@@ -154,15 +180,13 @@ export class ChannelStarSyncManager {
["t", D_TAG], // relay discoverability; not used in our filters
],
});
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel stars.",
"Failed to publish channel stars.",
);
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
event.created_at,
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
@@ -182,12 +206,11 @@ export class ChannelStarSyncManager {
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey) return;
// Record the raw head before decrypt so an undecryptable live event
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
this.lastRemoteCreatedAt = Math.max(
this.lastRemoteCreatedAt,
result.createdAt,
);
onUpdate(result);
}
});
@@ -195,14 +218,30 @@ export class ChannelStarSyncManager {
);
}
/**
* Fetches the remote blob on first mount, records the remote head, and
* delegates the seed/hold/apply-remote decision to `runBootstrap`.
*/
async bootstrap(localStore: ChannelStarStore) {
const fetchResult = await this.fetchRemoteStars();
return runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0,
publishFn: (s) => this.publishStars(s),
});
}
destroy(): void {
if (this.debounceTimer !== null && this.pendingStore !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
void this.doPublish(this.pendingStore);
} else if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
// Cancel any pending publish and mark this manager as destroyed so any
// in-flight doPublish() calls abort before reaching relayClient.
// Pending debounce-window changes are intentionally dropped: flushing
// could publish relay A's state to relay B via the shared relayClient
// singleton. Local entries survive because the apply/publish paths merge
// per-entry via mergeStores, so no local work is permanently lost.
this.destroyed = true;
this.cancelPendingStarPublish();
this.pendingStore = null;
}
}

View File

@@ -0,0 +1,85 @@
// Shared helpers for sidebar sync manager tests.
export function makeFakeWindow() {
const storage = new Map();
const ls = {
getItem: (k) => storage.get(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
clear: () => storage.clear(),
};
let timerCallback = null;
let nextTimerId = 100;
return {
localStorage: ls,
setTimeout: (fn, _ms) => {
timerCallback = fn;
return nextTimerId++;
},
clearTimeout: (_id) => {
timerCallback = null;
},
_fireTimer: () => {
if (timerCallback) {
const fn = timerCallback;
timerCallback = null;
fn();
}
},
_hasTimer: () => timerCallback !== null,
};
}
export function installFakeWindow(fw) {
if (typeof globalThis.window === "undefined") globalThis.window = {};
const origLs = globalThis.window.localStorage;
const origSt = globalThis.window.setTimeout;
const origCt = globalThis.window.clearTimeout;
globalThis.window.localStorage = fw.localStorage;
globalThis.window.setTimeout = fw.setTimeout;
globalThis.window.clearTimeout = fw.clearTimeout;
return () => {
if (origLs !== undefined) globalThis.window.localStorage = origLs;
if (origSt !== undefined) globalThis.window.setTimeout = origSt;
if (origCt !== undefined) globalThis.window.clearTimeout = origCt;
};
}
export function installTauriMock(goodCipherPayload) {
const orig = globalThis.window?.__TAURI_INTERNALS__;
if (typeof globalThis.window === "undefined") globalThis.window = {};
let captured = null;
globalThis.window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
if (args?.ciphertext === "bad-cipher")
return Promise.reject(new Error("decrypt failed"));
return Promise.resolve(goodCipherPayload);
}
if (cmd === "nip44_encrypt_to_self") {
captured = args?.plaintext ?? null;
return Promise.resolve("ct");
}
if (cmd === "sign_event")
return Promise.resolve(
JSON.stringify({
id: "eid",
pubkey: "pk-lww",
content: "ct",
created_at: args?.createdAt ?? 0,
kind: args?.kind ?? 0,
tags: args?.tags ?? [],
sig: "s",
}),
);
return Promise.reject(new Error(`unmocked: ${cmd}`));
},
};
return {
restore: () => {
if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig;
else delete globalThis.window.__TAURI_INTERNALS__;
},
capturedPlaintext: () => captured,
};
}

View File

@@ -0,0 +1,253 @@
import assert from "node:assert/strict";
import test from "node:test";
// We need a minimal localStorage stub since we're running in Node.
function withFreshStorage(fn) {
const store = new Map();
const ls = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
const orig = globalThis.window?.localStorage;
if (typeof globalThis.window === "undefined") globalThis.window = {};
globalThis.window.localStorage = ls;
try {
fn(ls);
} finally {
if (orig !== undefined) globalThis.window.localStorage = orig;
else delete globalThis.window.localStorage;
}
}
const { readWatermark, advanceWatermark, runBootstrap } = await import(
"./sidebarSyncWatermark.ts"
);
// Relay URLs are normalised (trimmed, lowercase, trailing slash stripped)
// so the same relay written two ways produces the same key.
const RELAY = "wss://relay.example.com";
const RELAY_ENCODED = encodeURIComponent("wss://relay.example.com");
// ── readWatermark ────────────────────────────────────────────────────────────
test("readWatermark: returns 0 when no key exists", () => {
withFreshStorage(() => {
assert.equal(readWatermark("pk", "sections", RELAY), 0);
});
});
test("readWatermark: returns 0 when stored value is 0", () => {
withFreshStorage((ls) => {
ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "0");
assert.equal(readWatermark("pk", "sections", RELAY), 0);
});
});
test("readWatermark: returns stored positive integer", () => {
withFreshStorage((ls) => {
ls.setItem(
`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`,
"1700000000",
);
assert.equal(readWatermark("pk", "sections", RELAY), 1700000000);
});
});
test("readWatermark: scopes by blobType", () => {
withFreshStorage((ls) => {
ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "100");
ls.setItem(`buzz-sync-watermark.v1:sort:pk:${RELAY_ENCODED}`, "200");
assert.equal(readWatermark("pk", "sections", RELAY), 100);
assert.equal(readWatermark("pk", "sort", RELAY), 200);
});
});
test("readWatermark: normalises relay URL (trailing slash, case)", () => {
withFreshStorage(() => {
// Write with one form, read with another — must produce the same value.
advanceWatermark("pk", "sections", "WSS://Relay.Example.Com/", 999);
assert.equal(
readWatermark("pk", "sections", "wss://relay.example.com"),
999,
);
assert.equal(
readWatermark("pk", "sections", "WSS://Relay.Example.Com/"),
999,
);
});
});
// ── advanceWatermark ─────────────────────────────────────────────────────────
test("advanceWatermark: writes when no prior value exists", () => {
withFreshStorage(() => {
advanceWatermark("pk", "sections", RELAY, 1700000000);
assert.equal(readWatermark("pk", "sections", RELAY), 1700000000);
});
});
test("advanceWatermark: advances when next > current", () => {
withFreshStorage(() => {
advanceWatermark("pk", "sections", RELAY, 100);
advanceWatermark("pk", "sections", RELAY, 200);
assert.equal(readWatermark("pk", "sections", RELAY), 200);
});
});
test("advanceWatermark: does not regress when next <= current (monotonic)", () => {
withFreshStorage(() => {
advanceWatermark("pk", "sections", RELAY, 500);
advanceWatermark("pk", "sections", RELAY, 400); // older — must not overwrite
advanceWatermark("pk", "sections", RELAY, 500); // equal — must not overwrite
assert.equal(readWatermark("pk", "sections", RELAY), 500);
});
});
test("advanceWatermark: round-trips across separate reads (simulated restart)", () => {
withFreshStorage(() => {
// Session A writes watermark.
advanceWatermark("pk", "sections", RELAY, 1700000042);
// Session B reads it back.
assert.equal(readWatermark("pk", "sections", RELAY), 1700000042);
});
});
// ── Relay-A / Relay-B isolation ──────────────────────────────────────────────
test("relay-A watermark does not suppress first-sync on relay-B", () => {
withFreshStorage(() => {
const relayA = "wss://a.relay.test";
const relayB = "wss://b.relay.test";
advanceWatermark("pk", "sections", relayA, 1700000100);
assert.equal(
readWatermark("pk", "sections", relayB),
0,
"relay B watermark must be independent of relay A",
);
});
});
test("relay-A watermark is preserved after relay-B session", () => {
withFreshStorage(() => {
const relayA = "wss://a.relay.test";
const relayB = "wss://b.relay.test";
advanceWatermark("pk", "sections", relayA, 1700000100);
advanceWatermark("pk", "sections", relayB, 1700000200);
assert.equal(
readWatermark("pk", "sections", relayA),
1700000100,
"relay A head must not be clobbered by relay B activity",
);
});
});
// ── runBootstrap policy — tested once; mutations to any branch fail here ─────
function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) {
let n = 0;
return {
args: {
fetchResult,
lastHead,
localStore: { items: localNonEmpty ? ["x"] : [] },
isLocalNonEmpty: (s) => s.items.length > 0,
publishFn: () => {
n++;
},
},
publishCount: () => n,
};
}
// Guard: fetch failed → hold, zero publishes.
// Mutation: removing the failed branch causes a seed on first-sync case.
test("runBootstrap: fetch failed returns hold and never calls publishFn", () => {
const { args, publishCount } = makeBootstrapArgs({
fetchResult: { status: "failed" },
lastHead: 0,
localNonEmpty: true,
});
const result = runBootstrap(args);
assert.equal(result.action, "hold");
assert.equal(
publishCount(),
0,
"publishFn must not be called on failed fetch",
);
});
// Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case).
// Mutation: setting lastHead to 0 causes a seed.
test("runBootstrap: fetch absent with prior head returns hold and never calls publishFn", () => {
const { args, publishCount } = makeBootstrapArgs({
fetchResult: { status: "absent" },
lastHead: 1700000000,
localNonEmpty: true,
});
const result = runBootstrap(args);
assert.equal(result.action, "hold");
assert.equal(
publishCount(),
0,
"publishFn must not be called when prior head exists",
);
});
// Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned.
// Mutation: removing the absent+head-0 seed call leaves publishCount at 0.
test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publishFn and returns hold", () => {
const { args, publishCount } = makeBootstrapArgs({
fetchResult: { status: "absent" },
lastHead: 0,
localNonEmpty: true,
});
const result = runBootstrap(args);
assert.equal(result.action, "hold");
assert.equal(
publishCount(),
1,
"publishFn must be called exactly once on first-sync",
);
});
// Guard: fetch absent + head 0 + empty local → no publish, hold returned.
test("runBootstrap: first-sync with empty local store does not call publishFn", () => {
const { args, publishCount } = makeBootstrapArgs({
fetchResult: { status: "absent" },
lastHead: 0,
localNonEmpty: false,
});
const result = runBootstrap(args);
assert.equal(result.action, "hold");
assert.equal(publishCount(), 0, "empty local store must not trigger seed");
});
// Guard: fetch found → apply-remote returned, no publish.
// Mutation: removing the found branch drops the remote data.
test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => {
const remoteData = {
store: { version: 1, items: [] },
createdAt: 100,
eventId: "e1",
};
const { args, publishCount } = makeBootstrapArgs({
fetchResult: {
status: "found",
data: remoteData,
createdAt: 100,
eventId: "e1",
},
lastHead: 0,
localNonEmpty: true,
});
const result = runBootstrap(args);
assert.equal(result.action, "apply-remote");
assert.deepEqual(result.data, remoteData);
assert.equal(
publishCount(),
0,
"publishFn must not be called when remote was found",
);
});

View File

@@ -0,0 +1,135 @@
/**
* Persisted remote-head watermark for sidebar-preference sync managers.
*
* Each manager (sections, sort, stars, mutes) persists the highest
* `created_at` it has ever observed from the relay under a key scoped to
* pubkey + relay + blob type. On the next boot the manager reads this value
* back: if it is > 0 a remote blob has existed before and seed-publishing
* must be skipped even when the fetch comes back empty (error, timeout, or
* auth-race).
*
* Keys live in localStorage alongside the payload blobs. They are tiny
* (one integer string per key) and scoped so they never bleed across
* identities, communities, or blob types.
*
* `relayUrl` is always required — a pubkey-only fallback is not safe because
* a head seen on relay A would suppress legitimate first-time seeding on
* relay B. The URL is normalised (trimmed, trailing slash stripped,
* lower-cased) before being embedded in the key so the same relay written
* two ways never produces two different keys.
*/
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
const PREFIX = "buzz-sync-watermark.v1";
/**
* Tri-state result returned by every `fetchRemote*()` method.
*
* - `found` — the relay returned an event that decrypted and parsed cleanly.
* - `absent` — the relay was successfully queried and returned zero events
* (genuine first-time use on this relay).
* - `failed` — the fetch threw (timeout, relay error, auth-race), or an event
* existed but could not be decrypted/parsed. In the `failed`
* case, `createdAt` may be set when the event itself was readable
* even though its payload was not — the manager records the head
* so seed-publish is still blocked.
*/
export type FetchResult<T> =
| { status: "found"; data: T; createdAt: number; eventId: string }
| { status: "absent" }
| { status: "failed"; createdAt?: number };
function watermarkKey(
pubkey: string,
blobType: string,
relayUrl: string,
): string {
return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
/** Read the persisted watermark (0 when absent or on read error). */
export function readWatermark(
pubkey: string,
blobType: string,
relayUrl: string,
): number {
try {
const raw = window.localStorage.getItem(
watermarkKey(pubkey, blobType, relayUrl),
);
if (raw === null) return 0;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 0;
} catch {
return 0;
}
}
/**
* Persist a new watermark if it is strictly greater than the current value.
* Absence or error never lowers the watermark (monotonic).
*/
export function advanceWatermark(
pubkey: string,
blobType: string,
relayUrl: string,
next: number,
): void {
try {
const current = readWatermark(pubkey, blobType, relayUrl);
if (next <= current) return;
window.localStorage.setItem(
watermarkKey(pubkey, blobType, relayUrl),
String(next),
);
} catch {
// Ignore write failures — the in-memory lastRemoteCreatedAt still guards
// seed-publish within this session; the watermark is belt-and-suspenders
// across sessions.
}
}
/** Result returned by `bootstrap()` — the hook acts on this without publishing. */
export type BootstrapResult<T> =
| { action: "apply-remote"; data: T }
| { action: "hold" };
/**
* Shared boot policy for all four sidebar-preference sync managers.
*
* Each manager calls this from its `bootstrap()` method, supplying its
* surface-specific fetch, publish, and local-store accessors. The full
* decision lives here once so that a mutation to any one surface cannot
* escape via a per-manager copy.
*
* Policy:
* - `found` → return `apply-remote`; hook applies data.
* - `failed` → hold; seed-publish blocked (error or unreadable event).
* - `absent` + `lastHead > 0` → hold; relay blob seen before, absence may be transient.
* - `absent` + `lastHead === 0` + non-empty local → call `publishFn(local)`; return `hold`.
* - `absent` + `lastHead === 0` + empty local → hold; nothing to seed.
*/
export function runBootstrap<TRemote, TLocal>({
fetchResult,
lastHead,
localStore,
isLocalNonEmpty,
publishFn,
}: {
fetchResult: FetchResult<TRemote>;
lastHead: number;
localStore: TLocal;
isLocalNonEmpty: (store: TLocal) => boolean;
publishFn: (store: TLocal) => void;
}): BootstrapResult<TRemote> {
if (fetchResult.status === "found") {
return { action: "apply-remote", data: fetchResult.data };
}
if (fetchResult.status === "absent" && lastHead === 0) {
if (isLocalNonEmpty(localStore)) {
publishFn(localStore);
}
}
return { action: "hold" };
}

View File

@@ -14,7 +14,10 @@ import {
import { ChannelMuteSyncManager } from "./channelMutesSync";
import type { RemoteMutes } from "./channelMutesSync";
export function useChannelMutes(pubkey: string | undefined): {
export function useChannelMutes(
pubkey: string | undefined,
relayUrl?: string,
): {
mutedChannelIds: Set<string>;
muteChannel: (channelId: string) => void;
unmuteChannel: (channelId: string) => void;
@@ -31,7 +34,7 @@ export function useChannelMutes(pubkey: string | undefined): {
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey) {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
@@ -40,12 +43,12 @@ export function useChannelMutes(pubkey: string | undefined): {
setStore(readChannelMutesStore(pubkey));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelMuteSyncManager(pubkey);
managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey]);
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!pubkey) {
@@ -86,24 +89,22 @@ export function useChannelMutes(pubkey: string | undefined): {
);
React.useEffect(() => {
if (!pubkey) return;
if (!pubkey || !relayUrl) return;
let cancelled = false;
void managerRef.current?.fetchRemoteMutes().then((remote) => {
const local = readChannelMutesStore(pubkey);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
} else {
const local = readChannelMutesStore(pubkey);
if (Object.keys(local.channels).length > 0) {
managerRef.current?.publishMutes(local);
}
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked.
});
return () => {
cancelled = true;
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it)
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
@@ -124,16 +125,17 @@ export function useChannelMutes(pubkey: string | undefined): {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteMutes().then((remote) => {
void managerRef.current?.fetchRemoteMutes().then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingMuteStore();
if (pending) {
@@ -145,7 +147,7 @@ export function useChannelMutes(pubkey: string | undefined): {
cancelled = true;
unsub();
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes)
const mutedChannelIds = React.useMemo(

View File

@@ -45,7 +45,7 @@ export function useChannelSections(
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey) {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
@@ -54,7 +54,7 @@ export function useChannelSections(
setStore(readChannelSectionsStore(pubkey, relayUrl));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelSectionSyncManager(pubkey);
managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
@@ -102,18 +102,16 @@ export function useChannelSections(
);
React.useEffect(() => {
if (!pubkey) return;
if (!pubkey || !relayUrl) return;
let cancelled = false;
void managerRef.current?.fetchRemoteSections().then((remote) => {
const local = readChannelSectionsStore(pubkey, relayUrl);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
} else {
const local = readChannelSectionsStore(pubkey, relayUrl);
if (local.sections.length > 0) {
managerRef.current?.publishSections(local);
}
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or
// blocked (failed fetch / prior watermark). Hook does nothing.
});
return () => {
cancelled = true;
@@ -146,10 +144,10 @@ export function useChannelSections(
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteSections().then((remote) => {
void managerRef.current?.fetchRemoteSections().then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStore();
if (pending) {

View File

@@ -49,7 +49,7 @@ export function useChannelSortPreference(
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey) {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
@@ -58,7 +58,7 @@ export function useChannelSortPreference(
setStore(readChannelSortStore(pubkey, relayUrl));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelSortSyncManager(pubkey);
managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
@@ -101,18 +101,15 @@ export function useChannelSortPreference(
);
React.useEffect(() => {
if (!pubkey) return;
if (!pubkey || !relayUrl) return;
let cancelled = false;
void managerRef.current?.fetchRemoteSortPrefs().then((remote) => {
const local = readChannelSortStore(pubkey, relayUrl);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
} else {
const local = readChannelSortStore(pubkey, relayUrl);
if (Object.keys(local.groups).length > 0) {
managerRef.current?.publishSortPrefs(local);
}
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked.
});
return () => {
cancelled = true;
@@ -145,10 +142,10 @@ export function useChannelSortPreference(
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteSortPrefs().then((remote) => {
void managerRef.current?.fetchRemoteSortPrefs().then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStore();
if (pending) {

View File

@@ -14,7 +14,10 @@ import {
import { ChannelStarSyncManager } from "./channelStarsSync";
import type { RemoteStars } from "./channelStarsSync";
export function useChannelStars(pubkey: string | undefined): {
export function useChannelStars(
pubkey: string | undefined,
relayUrl?: string,
): {
starredChannelIds: Set<string>;
starChannel: (channelId: string) => void;
unstarChannel: (channelId: string) => void;
@@ -31,7 +34,7 @@ export function useChannelStars(pubkey: string | undefined): {
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey) {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
@@ -40,12 +43,12 @@ export function useChannelStars(pubkey: string | undefined): {
setStore(readChannelStarsStore(pubkey));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelStarSyncManager(pubkey);
managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey]);
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!pubkey) {
@@ -86,24 +89,22 @@ export function useChannelStars(pubkey: string | undefined): {
);
React.useEffect(() => {
if (!pubkey) return;
if (!pubkey || !relayUrl) return;
let cancelled = false;
void managerRef.current?.fetchRemoteStars().then((remote) => {
const local = readChannelStarsStore(pubkey);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
} else {
const local = readChannelStarsStore(pubkey);
if (Object.keys(local.channels).length > 0) {
managerRef.current?.publishStars(local);
}
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked.
});
return () => {
cancelled = true;
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it)
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
@@ -124,16 +125,17 @@ export function useChannelStars(pubkey: string | undefined): {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteStars().then((remote) => {
void managerRef.current?.fetchRemoteStars().then((result) => {
if (cancelled) return;
if (remote) {
setStore(applyRemote(remote));
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStarStore();
if (pending) {
@@ -145,7 +147,7 @@ export function useChannelStars(pubkey: string | undefined): {
cancelled = true;
unsub();
};
}, [pubkey, applyRemote]);
}, [pubkey, relayUrl, applyRemote]);
// biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes)
const starredChannelIds = React.useMemo(

View File

@@ -0,0 +1,8 @@
/**
* Normalizes a relay URL for use in storage keys.
* Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to
* the same key regardless of formatting differences.
*/
export function normalizeRelayUrl(relayUrl: string): string {
return relayUrl.trim().replace(/\/+$/, "").toLowerCase();
}