mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
198
desktop/src/features/sidebar/lib/channelMutesSync.test.mjs
Normal file
198
desktop/src/features/sidebar/lib/channelMutesSync.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
202
desktop/src/features/sidebar/lib/channelStarsSync.test.mjs
Normal file
202
desktop/src/features/sidebar/lib/channelStarsSync.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
85
desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs
Normal file
85
desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
253
desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs
Normal file
253
desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
135
desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts
Normal file
135
desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts
Normal 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" };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
8
desktop/src/shared/lib/normalizeRelayUrl.ts
Normal file
8
desktop/src/shared/lib/normalizeRelayUrl.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user