fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)

Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

Related: [#7806](https://github.com/block/buzz/pull/7806)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-28 14:48:16 -04:00
committed by GitHub
parent f7ff4294fa
commit 52c4c08c9e
29 changed files with 2754 additions and 2285 deletions

View File

@@ -46,6 +46,11 @@ export class ChannelMuteSyncManager {
private lastRemoteCreatedAt: number;
private pendingStore: ChannelMuteStore | null = null;
private lastPublishedStore: ChannelMuteStore | null = null;
/** The bootstrap first-copy seed; retired once any relay head is observed. */
private seedStore: ChannelMuteStore | null = null;
/** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */
private remoteStore: ChannelMuteStore | null = null;
private remoteRev = 0;
private destroyed = false;
constructor(pubkey: string, relayUrl: string) {
@@ -71,6 +76,7 @@ export class ChannelMuteSyncManager {
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
this.remember(result.store);
return {
status: "found",
data: result,
@@ -87,6 +93,22 @@ export class ChannelMuteSyncManager {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
// A seed only fills a relay believed empty; any head supersedes it, even
// one whose payload a concurrent reader drops or cannot decrypt.
if (this.seedStore && this.pendingStore === this.seedStore) {
this.cancelPendingMutePublish();
this.pendingStore = null;
}
this.seedStore = null;
}
private remember(store: ChannelMuteStore): void {
const next = this.remoteStore
? mergeStores(this.remoteStore, store)
: store;
// Only a changed winner invalidates in-flight attempts.
if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++;
this.remoteStore = next;
}
cancelPendingMutePublish(): void {
@@ -127,19 +149,21 @@ export class ChannelMuteSyncManager {
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
return mergeStores(store, remote.store);
this.remember(remote.store);
return store;
} catch {
return store;
}
}
private isIdenticalToLastPublished(store: ChannelMuteStore): boolean {
if (!this.lastPublishedStore) return false;
const lastKeys = Object.keys(this.lastPublishedStore.channels);
/** Same winners (IDs, `muted`, `updatedAt`), ignoring key order. */
private sameEntries(prev: ChannelMuteStore | null, store: ChannelMuteStore) {
if (!prev) return false;
const lastKeys = Object.keys(prev.channels);
const currentKeys = Object.keys(store.channels);
if (lastKeys.length !== currentKeys.length) return false;
for (const key of currentKeys) {
const last = this.lastPublishedStore.channels[key];
const last = prev.channels[key];
const current = store.channels[key];
if (
!last ||
@@ -152,14 +176,29 @@ export class ChannelMuteSyncManager {
}
private async doPublish(store: ChannelMuteStore): Promise<void> {
// A seed retired while acquired (preflight, crypto or socket wait) aborts.
const seed = store === this.seedStore;
let rev = this.remoteRev; // a changed winner before dispatch voids it
const stale = () =>
this.destroyed ||
(seed && this.seedStore !== store) ||
rev !== this.remoteRev;
// Only the attempt that still owns `pendingStore` may clear it.
const release = () => {
if (this.pendingStore === store) this.pendingStore = null;
};
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
const pre = await this.fetchOwnBlobBeforePublish(store);
const merged = this.remoteStore
? mergeStores(pre, this.remoteStore)
: pre;
rev = this.remoteRev;
// 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;
if (stale()) return;
if (this.sameEntries(this.lastPublishedStore, merged)) {
release();
return;
}
const payload = {
@@ -180,17 +219,21 @@ export class ChannelMuteSyncManager {
["t", D_TAG], // relay discoverability; not used in our filters
],
});
if (this.destroyed) return;
if (stale()) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel mutes.",
"Failed to publish channel mutes.",
() => !stale(),
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
release();
} catch (error) {
console.warn("[channelMutesSync] publish failed:", error);
} finally {
const owned = !this.destroyed && this.pendingStore === store;
if (owned && rev !== this.remoteRev) this.publishMutes(store);
}
}
@@ -210,7 +253,8 @@ export class ChannelMuteSyncManager {
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
if (result && !this.destroyed) {
this.remember(result.store);
onUpdate(result);
}
});
@@ -229,7 +273,10 @@ export class ChannelMuteSyncManager {
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0,
publishFn: (s) => this.publishMutes(s),
publishFn: (s) => {
this.seedStore = s;
this.publishMutes(s);
},
});
}

View File

@@ -1,79 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { swapSectionOrder } from "./channelSectionsHelpers.ts";
function makeStore(sections, assignments = {}) {
return { version: 1, sections, assignments };
}
function makeSection(id, name, order) {
return { id, name, order };
}
test("move up succeeds: middle section swaps order with the one above", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 1),
makeSection("c", "C", 2),
]);
const result = swapSectionOrder(store, "b", "up");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId.b, 0);
assert.equal(byId.a, 1);
assert.equal(byId.c, 2);
});
test("move down succeeds: middle section swaps order with the one below", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 1),
makeSection("c", "C", 2),
]);
const result = swapSectionOrder(store, "b", "down");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId.b, 2);
assert.equal(byId.c, 1);
assert.equal(byId.a, 0);
});
test("move up at top boundary returns null", () => {
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
assert.equal(swapSectionOrder(store, "a", "up"), null);
});
test("move down at bottom boundary returns null", () => {
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
assert.equal(swapSectionOrder(store, "b", "down"), null);
});
test("non-existent section returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "z", "up"), null);
});
test("single section move up returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "a", "up"), null);
});
test("single section move down returns null", () => {
const store = makeStore([makeSection("a", "A", 0)]);
assert.equal(swapSectionOrder(store, "a", "down"), null);
});
test("non-contiguous orders: swap uses actual order values not indices", () => {
const store = makeStore([
makeSection("a", "A", 0),
makeSection("b", "B", 5),
makeSection("c", "C", 10),
]);
const result = swapSectionOrder(store, "b", "up");
assert.notEqual(result, null);
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
assert.equal(byId.b, 0);
assert.equal(byId.a, 5);
assert.equal(byId.c, 10);
});

View File

@@ -1,21 +0,0 @@
import type { ChannelSectionStore } from "./channelSectionsStorage";
export function swapSectionOrder(
prev: ChannelSectionStore,
sectionId: string,
direction: "up" | "down",
): ChannelSectionStore | null {
const target = prev.sections.find((s) => s.id === sectionId);
if (!target) return null;
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((s) => s.id === sectionId);
const neighborIdx = direction === "up" ? idx - 1 : idx + 1;
if (neighborIdx < 0 || neighborIdx >= sorted.length) return null;
const neighbor = sorted[neighborIdx];
const sections = prev.sections.map((s) => {
if (s.id === target.id) return { ...s, order: neighbor.order };
if (s.id === neighbor.id) return { ...s, order: target.order };
return s;
});
return { ...prev, sections };
}

View File

@@ -2,15 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
boundChannelSectionsStore,
DEFAULT_STORE,
MAX_CHANNEL_SECTION_ASSIGNMENTS,
MAX_CHANNEL_SECTIONS,
parseChannelSectionPayload,
readChannelSectionsStore,
storageKey,
stripOrphanedAssignments,
writeChannelSectionsStore,
} from "./channelSectionsStorage.ts";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
@@ -25,19 +18,6 @@ if (typeof globalThis.window === "undefined") {
};
}
function makeStore(overrides = {}) {
return {
version: 1,
sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }],
assignments: overrides.assignments ?? {},
...overrides,
};
}
function makeSection(overrides = {}) {
return { id: "s1", name: "Test", order: 0, ...overrides };
}
test("parseChannelSectionPayload: valid complete payload returns correct store", () => {
const payload = {
version: 1,
@@ -121,116 +101,6 @@ test("parseChannelSectionPayload: orphaned assignments are stripped", () => {
assert.deepEqual(result?.assignments, { chan1: "s1" });
});
test("stripOrphanedAssignments: store with no orphans returns same reference", () => {
const store = makeStore({
sections: [makeSection({ id: "s1" })],
assignments: { chan1: "s1" },
});
assert.equal(stripOrphanedAssignments(store), store);
});
test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => {
const store = makeStore({
sections: [makeSection({ id: "s1" })],
assignments: { chan1: "s1", chan2: "ghost" },
});
const result = stripOrphanedAssignments(store);
assert.notEqual(result, store);
assert.deepEqual(result.assignments, { chan1: "s1" });
});
test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => {
const store = makeStore({
sections: [
makeSection({ id: "s1" }),
makeSection({ id: "s2", name: "B", order: 1 }),
],
assignments: { chan1: "s1", chan2: "s2" },
});
assert.equal(stripOrphanedAssignments(store), store);
});
test("stripOrphanedAssignments: empty store returns same reference", () => {
const store = makeStore({ sections: [], assignments: {} });
assert.equal(stripOrphanedAssignments(store), store);
});
test("boundChannelSectionsStore caps sections and assignments", () => {
const sections = Array.from(
{ length: MAX_CHANNEL_SECTIONS + 1 },
(_, index) => makeSection({ id: `section-${index}`, order: index }),
);
const assignments = Object.fromEntries(
Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, index) => [
`channel-${index}`,
"section-100",
]),
);
const bounded = boundChannelSectionsStore(
makeStore({ sections, assignments }),
);
assert.equal(bounded.sections.length, MAX_CHANNEL_SECTIONS);
assert.equal(
bounded.sections.some((section) => section.id === "section-0"),
false,
);
assert.equal(
Object.keys(bounded.assignments).length,
MAX_CHANNEL_SECTION_ASSIGNMENTS,
);
assert.equal(bounded.assignments["channel-0"], undefined);
});
test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => {
const pubkey = "pk-roundtrip";
const store = makeStore({
sections: [makeSection({ id: "s1", name: "Work", order: 0 })],
assignments: { chan1: "s1" },
});
const written = writeChannelSectionsStore(pubkey, store);
assert.equal(written, true);
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, store);
});
test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => {
const result = readChannelSectionsStore("pk-does-not-exist-xyz");
assert.deepEqual(result, DEFAULT_STORE);
});
test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => {
const pubkey = "pk-corrupt";
window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{");
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, DEFAULT_STORE);
});
test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => {
const pubkey = "pk-wrong-version";
window.localStorage.setItem(
storageKey(pubkey),
JSON.stringify({ version: 2, sections: [], assignments: {} }),
);
const result = readChannelSectionsStore(pubkey);
assert.deepEqual(result, DEFAULT_STORE);
});
test("writeChannelSectionsStore: returns false when setItem throws", () => {
const pubkey = "pk-throws";
const original = window.localStorage.setItem;
window.localStorage.setItem = () => {
throw new Error("storage full");
};
try {
const result = writeChannelSectionsStore(pubkey, makeStore());
assert.equal(result, false);
} finally {
window.localStorage.setItem = original;
}
});
test("storageKey: returns expected format with pubkey", () => {
assert.equal(storageKey("abc123"), "buzz-channel-sections.v1:abc123");
});
@@ -263,100 +133,6 @@ test("storageKey: equivalent relay URLs (case + trailing slash) map to the same
assert.equal(k1, k2);
});
test("readChannelSectionsStore + writeChannelSectionsStore: scoped write/read roundtrip", () => {
const pubkey = "pk-relay-roundtrip";
const relay = "wss://relay.example.com";
const store = makeStore({
sections: [makeSection({ id: "s1", name: "Work", order: 0 })],
assignments: { chan1: "s1" },
});
assert.equal(writeChannelSectionsStore(pubkey, store, relay), true);
const result = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result, store);
});
test("readChannelSectionsStore: scoped key is isolated from other relay's data", () => {
const pubkey = "pk-isolation";
const relayA = "wss://relay-a.example.com";
const relayB = "wss://relay-b.example.com";
const storeA = makeStore({
sections: [makeSection({ id: "sa", name: "Relay A section", order: 0 })],
assignments: {},
});
writeChannelSectionsStore(pubkey, storeA, relayA);
// Relay B should see empty store, not relay A's data.
const resultB = readChannelSectionsStore(pubkey, relayB);
assert.deepEqual(resultB, DEFAULT_STORE);
});
test("readChannelSectionsStore: migrates legacy unscoped data on first scoped read", () => {
const pubkey = "pk-migrate";
const relay = "wss://relay-migrate.example.com";
const legacyStore = makeStore({
sections: [makeSection({ id: "sl", name: "Legacy Section", order: 0 })],
assignments: {},
});
// Write under legacy (pubkey-only) key.
writeChannelSectionsStore(pubkey, legacyStore);
// First scoped read should migrate and return the legacy data.
const result = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result, legacyStore);
// Legacy key must be deleted after migration (globally one-time guarantee).
const legacyKey = storageKey(pubkey);
assert.equal(window.localStorage.getItem(legacyKey), null);
// Subsequent scoped reads should hit the scoped key directly.
const result2 = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result2, legacyStore);
});
test("readChannelSectionsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => {
const pubkey = "pk-migrate-once";
const relayA = "wss://relay-migrate-once-a.example.com";
const relayB = "wss://relay-migrate-once-b.example.com";
const legacyStore = makeStore({
sections: [makeSection({ id: "sm", name: "Migrated Section", order: 0 })],
assignments: {},
});
// Write under legacy key then migrate via relay A.
writeChannelSectionsStore(pubkey, legacyStore);
readChannelSectionsStore(pubkey, relayA); // triggers migration, deletes legacy key
// Relay B must see DEFAULT_STORE — legacy data must not bleed in.
const resultB = readChannelSectionsStore(pubkey, relayB);
assert.deepEqual(resultB, DEFAULT_STORE);
// Relay B scoped key must not have been created.
const scopedBKey = storageKey(pubkey, relayB);
assert.equal(window.localStorage.getItem(scopedBKey), null);
});
test("readChannelSectionsStore: migration only copies non-empty legacy stores", () => {
const pubkey = "pk-migrate-empty";
const relay = "wss://relay-migrate-empty.example.com";
// Write an explicitly empty store under the legacy key.
writeChannelSectionsStore(pubkey, DEFAULT_STORE);
// Empty legacy store should NOT be migrated — we get DEFAULT_STORE.
const result = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result, DEFAULT_STORE);
});
test("readChannelSectionsStore: scoped key takes precedence over legacy key after migration", () => {
const pubkey = "pk-precedence";
const relay = "wss://relay-precedence.example.com";
const legacyStore = makeStore({
sections: [makeSection({ id: "sold", name: "Old Section", order: 0 })],
assignments: {},
});
const newStore = makeStore({
sections: [makeSection({ id: "snew", name: "New Section", order: 0 })],
assignments: {},
});
// Write legacy data then scoped data (simulates post-migration state).
writeChannelSectionsStore(pubkey, legacyStore);
writeChannelSectionsStore(pubkey, newStore, relay);
// Scoped read must return the scoped (newer) store, not the legacy one.
const result = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result, newStore);
});
test("parseChannelSectionPayload: preserves icon field when present", () => {
const payload = {
version: 1,

View File

@@ -17,20 +17,14 @@ export type ChannelSectionStore = {
assignments: Record<string, string>;
};
export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
version: 1,
sections: [],
assignments: {},
});
/**
* Returns the localStorage key for channel sections.
*
* When `relayUrl` is provided the key is scoped to that relay (normalized via
* the same `normalizeRelayUrl` used by all relay-scoped local stores) so
* sections from different communities/relays don't bleed across each other.
* When omitted the legacy pubkey-only key is returned (used only during
* one-time migration in `readChannelSectionsStore`).
* When omitted the legacy pubkey-only key is returned (migrated once on the
* first scoped read).
*/
export function storageKey(pubkey: string, relayUrl?: string): string {
if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`;
@@ -39,42 +33,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`;
}
export function boundChannelSectionsStore(
store: ChannelSectionStore,
): ChannelSectionStore {
const sections = store.sections
.slice()
.sort((left, right) => left.order - right.order)
.slice(-MAX_CHANNEL_SECTIONS);
const sectionIds = new Set(sections.map((section) => section.id));
const assignments = Object.fromEntries(
Object.entries(store.assignments)
.filter(([, sectionId]) => sectionIds.has(sectionId))
.slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS),
);
if (
sections.length === store.sections.length &&
Object.keys(assignments).length === Object.keys(store.assignments).length
) {
return store;
}
return { ...store, sections, assignments };
}
export function stripOrphanedAssignments(
store: ChannelSectionStore,
): ChannelSectionStore {
const sectionIds = new Set(store.sections.map((s) => s.id));
const cleaned = Object.fromEntries(
Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)),
);
const stripped =
Object.keys(cleaned).length === Object.keys(store.assignments).length
? store
: { ...store, assignments: cleaned };
return boundChannelSectionsStore(stripped);
}
/** Parses the legacy `sections`/`assignments` fields; orphaned assignments are dropped. */
export function parseChannelSectionPayload(
json: unknown,
): ChannelSectionStore | null {
@@ -105,92 +64,17 @@ export function parseChannelSectionPayload(
];
})
: [];
const sectionIds = new Set(sections.map((s) => s.id));
const assignments: Record<string, string> =
typeof obj.assignments === "object" &&
obj.assignments !== null &&
!Array.isArray(obj.assignments)
? Object.fromEntries(
Object.entries(obj.assignments as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
(entry): entry is [string, string] =>
typeof entry[1] === "string" && sectionIds.has(entry[1]),
),
)
: {};
return stripOrphanedAssignments({ version: 1, sections, assignments });
}
function parseRaw(raw: string | null): ChannelSectionStore | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
return null;
}
return parseChannelSectionPayload(parsed);
} catch {
return null;
}
}
/**
* Read the section store for `pubkey` scoped to `relayUrl`.
*
* On first access for a scoped key, migrates any existing data from the
* legacy pubkey-only key so users don't lose their sections on upgrade.
* After a successful migration write the legacy key is deleted, making
* this a globally one-time operation — subsequent empty scoped-key reads
* (i.e. a different relay) won't see legacy data and won't trigger
* cross-relay contamination via the seed-publish path.
*/
export function readChannelSectionsStore(
pubkey: string,
relayUrl?: string,
): ChannelSectionStore {
try {
const key = storageKey(pubkey, relayUrl);
const raw = window.localStorage.getItem(key);
// Scoped key already has data — use it directly.
if (raw !== null) {
return parseRaw(raw) ?? DEFAULT_STORE;
}
// No scoped data yet. If we were given a relay scope, attempt a
// one-time migration from the legacy pubkey-only key.
if (relayUrl) {
const legacyKey = storageKey(pubkey);
const legacyRaw = window.localStorage.getItem(legacyKey);
const migrated = parseRaw(legacyRaw);
if (migrated && migrated.sections.length > 0) {
// Persist under the scoped key and remove the legacy key so this
// migration cannot fire again for any other relay scope.
try {
window.localStorage.setItem(key, JSON.stringify(migrated));
window.localStorage.removeItem(legacyKey);
} catch {
// Ignore write failures — we still return the migrated value.
}
return migrated;
}
}
return DEFAULT_STORE;
} catch {
return DEFAULT_STORE;
}
}
export function writeChannelSectionsStore(
pubkey: string,
store: ChannelSectionStore,
relayUrl?: string,
): boolean {
try {
window.localStorage.setItem(
storageKey(pubkey, relayUrl),
JSON.stringify(boundChannelSectionsStore(store)),
);
return true;
} catch {
return false;
}
return { version: 1, sections, assignments };
}

View File

@@ -1,282 +0,0 @@
import assert from "node:assert/strict";
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 {
version: 1,
sections: overrides.sections ?? [],
assignments: overrides.assignments ?? {},
...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.
test("destroy: cancels pending publish without flushing to the relay", () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
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();
assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy");
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 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",
() =>
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 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();
releaseFetch();
await new Promise((r) => setTimeout(r, 0));
assert.equal(
publishCalls.length,
0,
"publishEvent must not fire 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 ChannelSectionSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ────────────────
// Wiring tests 1-3 drive the production bootstrap() path; policy tested once
// in sidebarSyncWatermark.test.mjs.
// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued)
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued)
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null)
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-fresh", RELAY);
const result = await manager.bootstrap(
makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]),
);
assert.equal(result.action, "hold");
assert.ok(manager.getPendingStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. LWW baseline: newer decryptable pre-publish event still wins after an
// undecryptable head was recorded.
// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison
// 200>200=false → local wins instead of remote → wrong content encrypted.
test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => {
const REMOTE_ID = "remote-section-from-relay";
let callCount = 0;
mock.method(relayClient, "fetchEvents", () => {
callCount++;
return Promise.resolve([
{
pubkey: "pk-lww",
content: callCount === 1 ? "bad-cipher" : "good-cipher",
created_at: callCount === 1 ? 100 : 200,
id: `evt-${callCount}`,
},
]);
});
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock(
JSON.stringify({
version: 1,
sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }],
assignments: {},
}),
);
try {
const manager = new ChannelSectionSyncManager("pk-lww", RELAY);
await manager.fetchRemoteSections();
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`,
) ?? "0",
) >= 100,
);
manager.publishSections(
makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]),
);
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
const pt = tauri.capturedPlaintext();
assert.ok(pt !== null, "nip44EncryptToSelf must have been called");
assert.ok(
JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID),
`remote sections must win LWW merge — got: ${pt}`,
);
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// 5. live-sub: undecryptable event on live path records head before decrypt
// Mutation test: removing recordRemoteHead before decrypt in the live callback
// leaves watermark at 0 after a live event.
test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => {
let liveCallback = null;
mock.method(relayClient, "subscribeLive", (_filter, onEvent) => {
liveCallback = onEvent;
return Promise.resolve(async () => {});
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSectionSyncManager("pk-live", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`,
),
null,
"watermark starts absent",
);
await manager.subscribeToSections(() => {});
assert.ok(
liveCallback !== null,
"subscribeLive must have captured the callback",
);
liveCallback({
pubkey: "pk-live",
content: "!bad-cipher!",
created_at: 1700005555,
id: "live-evt-1",
});
await new Promise((r) => setTimeout(r, 0));
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`,
) ?? "0",
) >= 1700005555,
"live undecryptable event must advance the watermark before decrypt is attempted",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -1,276 +1,107 @@
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds";
import {
MAX_CHANNEL_SECTION_ASSIGNMENTS,
MAX_CHANNEL_SECTIONS,
parseChannelSectionPayload,
storageKey,
type ChannelSection,
type ChannelSectionStore,
} from "./channelSectionsStorage";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
import type { Lane } from "./sidebarLaneReconciler";
import { isReg, type Reg, type Tree } from "./sidebarLwwMap";
const D_TAG = "channel-sections";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
const isString = (value: unknown) => typeof value === "string";
export type RemoteSections = {
store: ChannelSectionStore;
createdAt: number;
eventId: string;
type SectionNode = { name?: Reg; icon?: Reg; order?: Reg; live?: Reg };
export type SectionsView = {
sections: ChannelSection[];
assignments: Record<string, string>;
};
async function decryptAndParse(
event: RelayEvent,
): Promise<RemoteSections | null> {
try {
const plaintext = await nip44DecryptFromSelf(event.content);
const store = parseChannelSectionPayload(JSON.parse(plaintext));
if (!store) return null;
return { store, createdAt: event.created_at, eventId: event.id };
} catch {
return null;
}
}
const val = (reg: unknown) => (isReg(reg) ? reg[2] : undefined);
export class ChannelSectionSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelSectionStore | null = null;
private lastPublishedStore: ChannelSectionStore | null = null;
private destroyed = false;
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<FetchResult<RemoteSections>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
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 { 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);
this.debounceTimer = null;
}
}
getPendingStore(): ChannelSectionStore | null {
return this.pendingStore;
}
publishSections(store: ChannelSectionStore): void {
this.pendingStore = store;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.doPublish(store);
}, DEBOUNCE_MS);
}
private async fetchOwnBlobBeforePublish(
store: ChannelSectionStore,
): Promise<ChannelSectionStore> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
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 > headBeforeFetch) {
return remote.store;
}
return store;
} catch {
return store;
}
}
private isIdenticalToLastPublished(store: ChannelSectionStore): boolean {
if (!this.lastPublishedStore) return false;
const lastSections = this.lastPublishedStore.sections;
const currentSections = store.sections;
if (lastSections.length !== currentSections.length) return false;
for (let i = 0; i < currentSections.length; i++) {
const last = lastSections[i] as ChannelSection | undefined;
const current = currentSections[i] as ChannelSection;
if (
!last ||
last.id !== current.id ||
last.name !== current.name ||
last.icon !== current.icon ||
last.order !== current.order
)
return false;
}
const lastAssignKeys = Object.keys(this.lastPublishedStore.assignments);
const currentAssignKeys = Object.keys(store.assignments);
if (lastAssignKeys.length !== currentAssignKeys.length) return false;
for (const key of currentAssignKeys) {
if (this.lastPublishedStore.assignments[key] !== store.assignments[key])
return false;
}
return true;
}
private async doPublish(store: ChannelSectionStore): 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;
}
const payload = {
version: 1,
sections: merged.sections,
assignments: merged.assignments,
};
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
const createdAt = Math.max(
Math.floor(Date.now() / 1_000),
this.lastRemoteCreatedAt + 1,
);
const event = await signRelayEvent({
kind: KIND_CHANNEL_SECTIONS,
content: ciphertext,
createdAt,
tags: [
["d", D_TAG],
["t", D_TAG], // relay discoverability; not used in our filters
],
});
// Final guard immediately before the network call — sign/encrypt are
// synchronous-ish but cheap; the relay socket may have moved to a
// different community by the time we reach this point.
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel sections.",
"Failed to publish channel sections.",
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
console.warn("[channelSectionsSync] publish failed:", error);
}
}
async subscribeToSections(
onUpdate: (remote: RemoteSections) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 0,
},
(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) {
onUpdate(result);
}
});
},
/** Live sections by `(order, id)`, with their canonical `order`. */
function rankedSections(tree: Tree) {
const nodes = (tree.s ?? {}) as Record<string, SectionNode>;
return Object.entries(nodes)
.filter(([, n]) => val(n.live) === true && isString(val(n.name)))
.map(([id, n]) => ({
id,
name: val(n.name) as string,
icon: val(n.icon),
order: Number(val(n.order) ?? 0),
}))
.sort(
(a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
);
}
/**
* 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.
// 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;
}
}
/** Highest canonical `order` among live sections, or -1 when there are none. */
export const maxLiveOrder = (tree: Tree) =>
Math.max(-1, ...rankedSections(tree).map((s) => s.order));
/** Live sections, densely renumbered. */
function liveSections(tree: Tree): ChannelSection[] {
return rankedSections(tree).map(({ icon, ...s }, order) => ({
...s,
...(isString(icon) && icon ? { icon: icon as string } : {}),
order,
}));
}
/** The sections and assignments older clients (and this UI) read. */
export function projectSections(tree: Tree): SectionsView {
const sections = liveSections(tree);
const live = new Set(sections.map((s) => s.id));
const assignments: Record<string, string> = {};
for (const [channelId, reg] of Object.entries(tree.a ?? {})) {
const target = val(reg);
if (isString(target) && live.has(target as string))
assignments[channelId] = target as string;
}
return { sections, assignments };
}
/** Channel sections: kind 30078, d-tag `channel-sections`, per-field registers. */
export const SECTIONS_LANE: Lane = {
dTag: "channel-sections",
storageKey,
legacyStorageKey: (pubkey) => storageKey(pubkey),
shape: {
s: {
"*": {
name: isString,
icon: (v) => v === null || isString(v),
order: Number.isSafeInteger,
live: (v) => typeof v === "boolean",
},
},
a: { "*": (v) => v === null || isString(v) },
},
fromLegacy(json, stamp) {
const store = parseChannelSectionPayload(json);
if (!store) return null;
const s: Tree = Object.fromEntries(
store.sections.map(({ id, name, icon, order }) => [
id,
{
name: stamp(name),
icon: stamp(icon ?? null),
order: stamp(Math.round(order)),
live: stamp(true),
},
]),
);
const a: Tree = Object.fromEntries(
Object.entries(store.assignments).map(([id, target]) => [
id,
stamp(target),
]),
);
return { s, a };
},
project: projectSections,
withinLimits: (p) =>
(p.sections as unknown[]).length <= MAX_CHANNEL_SECTIONS &&
Object.keys(p.assignments as object).length <=
MAX_CHANNEL_SECTION_ASSIGNMENTS,
};

View File

@@ -2,15 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
boundChannelSortStore,
DEFAULT_SORT_MODE,
DEFAULT_STORE,
MAX_CHANNEL_SORT_GROUPS,
parseChannelSortPayload,
sectionSortGroupKey,
sortChannelsForSidebar,
sortModeForGroup,
stripOrphanedSectionModes,
} from "./channelSortPreference.ts";
function makeChannel(id, name, lastMessageAt = null) {
@@ -81,116 +74,6 @@ test("parseChannelSortPayload: wrong version returns null", () => {
);
});
test("parseChannelSortPayload: non-object input returns null", () => {
assert.equal(parseChannelSortPayload(null), null);
assert.equal(parseChannelSortPayload("alpha"), null);
assert.equal(parseChannelSortPayload(42), null);
});
// ── sortModeForGroup / defaults ──────────────────────────────────────────────
test("default sort mode is alpha and default store has no overrides", () => {
assert.equal(DEFAULT_SORT_MODE, "alpha");
assert.deepEqual(DEFAULT_STORE.groups, {});
});
test("sortModeForGroup: unset group falls back to alpha", () => {
assert.equal(sortModeForGroup(DEFAULT_STORE, "channels"), "alpha");
assert.equal(sortModeForGroup(DEFAULT_STORE, "dms"), "alpha");
});
test("sortModeForGroup: set groups are independent", () => {
const store = {
version: 1,
groups: { channels: "recent", [sectionSortGroupKey("abc")]: "recent" },
};
assert.equal(sortModeForGroup(store, "channels"), "recent");
assert.equal(sortModeForGroup(store, sectionSortGroupKey("abc")), "recent");
assert.equal(sortModeForGroup(store, "forums"), "alpha");
assert.equal(sortModeForGroup(store, sectionSortGroupKey("xyz")), "alpha");
});
test("sectionSortGroupKey: namespaced by section id", () => {
assert.equal(sectionSortGroupKey("abc"), "section:abc");
});
// ── stripOrphanedSectionModes ────────────────────────────────────────────────
test("stripOrphanedSectionModes: drops modes for deleted sections", () => {
const store = {
version: 1,
groups: {
channels: "recent",
[sectionSortGroupKey("live")]: "recent",
[sectionSortGroupKey("deleted")]: "alpha",
},
};
assert.deepEqual(stripOrphanedSectionModes(store, ["live"]), {
version: 1,
groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" },
});
});
test("stripOrphanedSectionModes: fixed groups survive with no live sections", () => {
const store = {
version: 1,
groups: {
starred: "recent",
channels: "alpha",
forums: "recent",
dms: "recent",
[sectionSortGroupKey("gone")]: "recent",
},
};
assert.deepEqual(stripOrphanedSectionModes(store, []), {
version: 1,
groups: {
starred: "recent",
channels: "alpha",
forums: "recent",
dms: "recent",
},
});
});
test("stripOrphanedSectionModes: returns same reference when nothing is stale", () => {
const store = {
version: 1,
groups: { channels: "recent", [sectionSortGroupKey("live")]: "recent" },
};
assert.equal(stripOrphanedSectionModes(store, ["live", "other"]), store);
});
test("stripOrphanedSectionModes: does not mutate the input store", () => {
const store = {
version: 1,
groups: { [sectionSortGroupKey("gone")]: "recent" },
};
stripOrphanedSectionModes(store, []);
assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" });
});
test("boundChannelSortStore caps custom sections while preserving fixed groups", () => {
const groups = {
channels: "recent",
...Object.fromEntries(
Array.from({ length: MAX_CHANNEL_SORT_GROUPS }, (_, index) => [
sectionSortGroupKey(String(index)),
"alpha",
]),
),
};
const bounded = boundChannelSortStore({ version: 1, groups });
assert.equal(Object.keys(bounded.groups).length, MAX_CHANNEL_SORT_GROUPS);
assert.equal(bounded.groups.channels, "recent");
assert.equal(bounded.groups[sectionSortGroupKey("0")], undefined);
assert.equal(bounded.groups[sectionSortGroupKey("99")], "alpha");
});
// ── sortChannelsForSidebar ───────────────────────────────────────────────────
test("alpha: sorts case-insensitively with deterministic code-unit collation", () => {
const sorted = sortChannelsForSidebar(
[

View File

@@ -24,11 +24,6 @@ export type ChannelSortStore = {
export const DEFAULT_SORT_MODE: ChannelSortMode = "alpha";
export const DEFAULT_STORE: ChannelSortStore = Object.freeze({
version: 1,
groups: {},
});
export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey {
return `section:${sectionId}`;
}
@@ -47,43 +42,7 @@ export function storageKey(pubkey: string, relayUrl?: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`;
}
/**
* Drops per-section sort modes whose custom section no longer exists so
* deleted sections don't leave stale `section:<id>` keys in localStorage
* forever. Fixed group keys (starred/channels/forums/dms) are always kept.
* Returns the same store reference when nothing needs stripping.
*/
export function stripOrphanedSectionModes(
store: ChannelSortStore,
liveSectionIds: Iterable<string>,
): ChannelSortStore {
const liveKeys = new Set<string>(
[...liveSectionIds].map((id) => sectionSortGroupKey(id)),
);
const kept = Object.entries(store.groups).filter(
([key]) => !key.startsWith("section:") || liveKeys.has(key),
);
if (kept.length === Object.keys(store.groups).length) return store;
return { ...store, groups: Object.fromEntries(kept) };
}
export function boundChannelSortStore(
store: ChannelSortStore,
): ChannelSortStore {
const entries = Object.entries(store.groups);
if (entries.length <= MAX_CHANNEL_SORT_GROUPS) return store;
const isFixedGroup = (key: string) =>
key === "starred" ||
key === "channels" ||
key === "forums" ||
key === "dms";
const fixed = entries.filter(([key]) => isFixedGroup(key));
const custom = entries
.filter(([key]) => !isFixedGroup(key))
.slice(-(MAX_CHANNEL_SORT_GROUPS - fixed.length));
return { ...store, groups: Object.fromEntries([...fixed, ...custom]) };
}
/** Parses the legacy `groups` field; unknown keys are kept, unknown modes dropped. */
export function parseChannelSortPayload(
json: unknown,
): ChannelSortStore | null {
@@ -101,43 +60,7 @@ export function parseChannelSortPayload(
),
)
: {};
return boundChannelSortStore({ version: 1, groups });
}
export function readChannelSortStore(
pubkey: string,
relayUrl?: string,
): ChannelSortStore {
try {
const raw = window.localStorage.getItem(storageKey(pubkey, relayUrl));
if (!raw) return DEFAULT_STORE;
return parseChannelSortPayload(JSON.parse(raw)) ?? DEFAULT_STORE;
} catch {
return DEFAULT_STORE;
}
}
export function writeChannelSortStore(
pubkey: string,
store: ChannelSortStore,
relayUrl?: string,
): boolean {
try {
window.localStorage.setItem(
storageKey(pubkey, relayUrl),
JSON.stringify(boundChannelSortStore(store)),
);
return true;
} catch {
return false;
}
}
export function sortModeForGroup(
store: ChannelSortStore,
group: ChannelSortGroupKey,
): ChannelSortMode {
return store.groups[group] ?? DEFAULT_SORT_MODE;
return { version: 1, groups };
}
function channelRecencyMs(channel: Channel): number | null {

View File

@@ -1,262 +0,0 @@
import assert from "node:assert/strict";
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 };
}
const RELAY = "wss://r.test";
const RELAY_KEY = encodeURIComponent(RELAY);
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
test("destroy: cancels pending publish without flushing to the relay", () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
const publishCalls = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
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(!fw._hasTimer(), "debounce timer should be cleared on destroy");
assert.equal(publishCalls.length, 0);
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 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",
() =>
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 ChannelSortSyncManager("pk-race", RELAY);
manager.publishSortPrefs(makeStore({ dms: "recent" }));
fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish
manager.destroy();
releaseFetch();
await new Promise((r) => setTimeout(r, 0));
assert.equal(
publishCalls.length,
0,
"publishEvent must not fire 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 ChannelSortSyncManager("pk-no-pending", RELAY);
assert.doesNotThrow(() => manager.destroy());
} finally {
restore();
}
});
// ─── Boot seed-publish guard (the revert-fix regression suite) ────────────────
// Wiring tests 1-3 drive the production bootstrap() path; policy tested once
// in sidebarSyncWatermark.test.mjs.
// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls
// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set.
test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("relay timeout")),
);
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-fail", RELAY);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case)
// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed.
test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
fw.localStorage.setItem(
`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`,
"1700000000",
);
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-stale", RELAY);
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`,
) ?? "0",
) > 0,
);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.equal(manager.getPendingStore(), null);
} finally {
restore();
mock.reset();
}
});
// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved)
// Mutation: removing the absent+head-0 seed call leaves pendingStore null.
test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-fresh", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`,
),
null,
);
const result = await manager.bootstrap(makeStore({ channels: "recent" }));
assert.equal(result.action, "hold");
assert.ok(manager.getPendingStore() !== null);
} finally {
restore();
mock.reset();
}
});
// 4. LWW baseline: newer decryptable pre-publish event still wins after an
// undecryptable head was recorded.
// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison
// 200>200=false → local wins instead of remote → wrong content encrypted.
test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => {
const REMOTE_KEY = "remote-group-from-relay";
let callCount = 0;
mock.method(relayClient, "fetchEvents", () => {
callCount++;
return Promise.resolve([
{
pubkey: "pk-lww",
content: callCount === 1 ? "bad-cipher" : "good-cipher",
created_at: callCount === 1 ? 100 : 200,
id: `evt-${callCount}`,
},
]);
});
mock.method(relayClient, "publishEvent", () => Promise.resolve());
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
const tauri = installTauriMock(
JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }),
);
try {
const manager = new ChannelSortSyncManager("pk-lww", RELAY);
await manager.fetchRemoteSortPrefs();
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`,
) ?? "0",
) >= 100,
);
manager.publishSortPrefs(makeStore({ "local-group": "recent" }));
fw._fireTimer();
await new Promise((r) => setTimeout(r, 20));
const pt = tauri.capturedPlaintext();
assert.ok(pt !== null, "nip44EncryptToSelf must have been called");
assert.ok(
JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups,
`remote groups must win LWW merge — got: ${pt}`,
);
} finally {
tauri.restore();
restore();
mock.reset();
}
});
// 5. live-sub: undecryptable event on live path records head before decrypt
// Mutation test: removing recordRemoteHead before decrypt in the live callback
// leaves watermark at 0 after a live event.
test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => {
let liveCallback = null;
mock.method(relayClient, "subscribeLive", (_filter, onEvent) => {
liveCallback = onEvent;
return Promise.resolve(async () => {});
});
const fw = makeFakeWindow();
const restore = installFakeWindow(fw);
try {
const manager = new ChannelSortSyncManager("pk-live", RELAY);
assert.equal(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`,
),
null,
"watermark starts absent",
);
await manager.subscribeToSortPrefs(() => {});
assert.ok(
liveCallback !== null,
"subscribeLive must have captured the callback",
);
liveCallback({
pubkey: "pk-live",
content: "!bad-cipher!",
created_at: 1700005555,
id: "live-evt-1",
});
await new Promise((r) => setTimeout(r, 0));
assert.ok(
Number(
fw.localStorage.getItem(
`buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`,
) ?? "0",
) >= 1700005555,
"live undecryptable event must advance the watermark before decrypt is attempted",
);
} finally {
restore();
mock.reset();
}
});

View File

@@ -1,263 +1,37 @@
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_CHANNEL_SORT } from "@/shared/constants/kinds";
import {
MAX_CHANNEL_SORT_GROUPS,
parseChannelSortPayload,
type ChannelSortStore,
storageKey,
type ChannelSortMode,
} from "./channelSortPreference";
import {
advanceWatermark,
readWatermark,
runBootstrap,
type FetchResult,
} from "./sidebarSyncWatermark";
import type { Lane } from "./sidebarLaneReconciler";
import { isReg, type Tree } from "./sidebarLwwMap";
const D_TAG = "channel-sort";
const BLOB_TYPE = D_TAG;
const DEBOUNCE_MS = 2_000;
/** Non-null sort modes per group key; `null` registers are explicit resets. */
export function projectSort(tree: Tree): {
groups: Record<string, ChannelSortMode>;
} {
const groups: Record<string, ChannelSortMode> = {};
for (const [key, reg] of Object.entries(tree.g ?? {})) {
if (isReg(reg) && reg[2] !== null) groups[key] = reg[2] as ChannelSortMode;
}
return { groups };
}
export type RemoteSortPrefs = {
store: ChannelSortStore;
createdAt: number;
eventId: string;
};
async function decryptAndParse(
event: RelayEvent,
): Promise<RemoteSortPrefs | null> {
try {
const plaintext = await nip44DecryptFromSelf(event.content);
const store = parseChannelSortPayload(JSON.parse(plaintext));
/** Sidebar sort modes: kind 30078, d-tag `channel-sort`, one register per group key. */
export const SORT_LANE: Lane = {
dTag: "channel-sort",
storageKey,
shape: { g: { "*": (v) => v === null || v === "alpha" || v === "recent" } },
fromLegacy(json, stamp) {
const store = parseChannelSortPayload(json);
if (!store) return null;
return { store, createdAt: event.created_at, eventId: event.id };
} catch {
return null;
}
}
/**
* Syncs the per-group sidebar sort preferences across clients via encrypted
* NIP-78 app data (kind 30078, d-tag `channel-sort`), following the same
* pattern as channel sections: NIP-44 encrypted-to-self content, debounced
* writes, and whole-blob last-write-wins. The sort map is a compact,
* low-frequency preference blob, so whole-blob LWW (like sections) is
* sufficient — per-key merge (like stars/mutes) would be unnecessary
* complexity here.
*/
export class ChannelSortSyncManager {
private pubkey: string;
private relayUrl: string;
private debounceTimer: number | null = null;
private lastRemoteCreatedAt: number;
private pendingStore: ChannelSortStore | null = null;
private lastPublishedStore: ChannelSortStore | null = null;
private destroyed = false;
constructor(pubkey: string, relayUrl: string) {
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
}
async fetchRemoteSortPrefs(): Promise<FetchResult<RemoteSortPrefs>> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SORT],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
return { status: "absent" };
}
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 { 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);
this.debounceTimer = null;
}
}
getPendingStore(): ChannelSortStore | null {
return this.pendingStore;
}
publishSortPrefs(store: ChannelSortStore): void {
this.pendingStore = store;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
void this.doPublish(store);
}, DEBOUNCE_MS);
}
private async fetchOwnBlobBeforePublish(
store: ChannelSortStore,
): Promise<ChannelSortStore> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_CHANNEL_SORT],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
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 > headBeforeFetch) {
return remote.store;
}
return store;
} catch {
return store;
}
}
private isIdenticalToLastPublished(store: ChannelSortStore): boolean {
if (!this.lastPublishedStore) return false;
const lastGroups = this.lastPublishedStore.groups;
const currentGroups = store.groups;
const lastKeys = Object.keys(lastGroups);
const currentKeys = Object.keys(currentGroups);
if (lastKeys.length !== currentKeys.length) return false;
for (const key of currentKeys) {
if (lastGroups[key] !== currentGroups[key]) return false;
}
return true;
}
private async doPublish(store: ChannelSortStore): 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;
}
const payload = {
version: 1,
groups: merged.groups,
};
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
const createdAt = Math.max(
Math.floor(Date.now() / 1_000),
this.lastRemoteCreatedAt + 1,
);
const event = await signRelayEvent({
kind: KIND_CHANNEL_SORT,
content: ciphertext,
createdAt,
tags: [
["d", D_TAG],
["t", D_TAG], // relay discoverability; not used in our filters
],
});
// Final guard immediately before the network call — sign/encrypt are
// synchronous-ish but cheap; the relay socket may have moved to a
// different community by the time we reach this point.
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel sort preferences.",
"Failed to publish channel sort preferences.",
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
} catch (error) {
console.warn("[channelSortSync] publish failed:", error);
}
}
async subscribeToSortPrefs(
onUpdate: (remote: RemoteSortPrefs) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_CHANNEL_SORT],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 0,
},
(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) {
onUpdate(result);
}
});
},
const g: Tree = Object.fromEntries(
Object.entries(store.groups).map(([key, mode]) => [key, stamp(mode)]),
);
}
/**
* 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.
// 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;
}
}
return { g };
},
project: projectSort,
withinLimits: (p) =>
Object.keys(p.groups as object).length <= MAX_CHANNEL_SORT_GROUPS,
};

View File

@@ -46,6 +46,11 @@ export class ChannelStarSyncManager {
private lastRemoteCreatedAt: number;
private pendingStore: ChannelStarStore | null = null;
private lastPublishedStore: ChannelStarStore | null = null;
/** The bootstrap first-copy seed; retired once any relay head is observed. */
private seedStore: ChannelStarStore | null = null;
/** Decoded heads, LWW-merged within the 500-entry bound, for each attempt. */
private remoteStore: ChannelStarStore | null = null;
private remoteRev = 0;
private destroyed = false;
constructor(pubkey: string, relayUrl: string) {
@@ -71,6 +76,7 @@ export class ChannelStarSyncManager {
if (!result) {
return { status: "failed", createdAt: event.created_at };
}
this.remember(result.store);
return {
status: "found",
data: result,
@@ -87,6 +93,22 @@ export class ChannelStarSyncManager {
this.lastRemoteCreatedAt = createdAt;
}
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
// A seed only fills a relay believed empty; any head supersedes it, even
// one whose payload a concurrent reader drops or cannot decrypt.
if (this.seedStore && this.pendingStore === this.seedStore) {
this.cancelPendingStarPublish();
this.pendingStore = null;
}
this.seedStore = null;
}
private remember(store: ChannelStarStore): void {
const next = this.remoteStore
? mergeStores(this.remoteStore, store)
: store;
// Only a changed winner invalidates in-flight attempts.
if (!this.sameEntries(this.remoteStore, next)) this.remoteRev++;
this.remoteStore = next;
}
cancelPendingStarPublish(): void {
@@ -127,19 +149,21 @@ export class ChannelStarSyncManager {
this.recordRemoteHead(event.created_at);
const remote = await decryptAndParse(event);
if (!remote) return store;
return mergeStores(store, remote.store);
this.remember(remote.store);
return store;
} catch {
return store;
}
}
private isIdenticalToLastPublished(store: ChannelStarStore): boolean {
if (!this.lastPublishedStore) return false;
const lastKeys = Object.keys(this.lastPublishedStore.channels);
/** Same winners (IDs, `starred`, `updatedAt`), ignoring key order. */
private sameEntries(prev: ChannelStarStore | null, store: ChannelStarStore) {
if (!prev) return false;
const lastKeys = Object.keys(prev.channels);
const currentKeys = Object.keys(store.channels);
if (lastKeys.length !== currentKeys.length) return false;
for (const key of currentKeys) {
const last = this.lastPublishedStore.channels[key];
const last = prev.channels[key];
const current = store.channels[key];
if (
!last ||
@@ -152,14 +176,29 @@ export class ChannelStarSyncManager {
}
private async doPublish(store: ChannelStarStore): Promise<void> {
// A seed retired while acquired (preflight, crypto or socket wait) aborts.
const seed = store === this.seedStore;
let rev = this.remoteRev; // a changed winner before dispatch voids it
const stale = () =>
this.destroyed ||
(seed && this.seedStore !== store) ||
rev !== this.remoteRev;
// Only the attempt that still owns `pendingStore` may clear it.
const release = () => {
if (this.pendingStore === store) this.pendingStore = null;
};
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
const pre = await this.fetchOwnBlobBeforePublish(store);
const merged = this.remoteStore
? mergeStores(pre, this.remoteStore)
: pre;
rev = this.remoteRev;
// 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;
if (stale()) return;
if (this.sameEntries(this.lastPublishedStore, merged)) {
release();
return;
}
const payload = {
@@ -180,17 +219,21 @@ export class ChannelStarSyncManager {
["t", D_TAG], // relay discoverability; not used in our filters
],
});
if (this.destroyed) return;
if (stale()) return;
await relayClient.publishEvent(
event,
"Timed out publishing channel stars.",
"Failed to publish channel stars.",
() => !stale(),
);
this.recordRemoteHead(event.created_at);
this.lastPublishedStore = merged;
this.pendingStore = null;
release();
} catch (error) {
console.warn("[channelStarsSync] publish failed:", error);
} finally {
const owned = !this.destroyed && this.pendingStore === store;
if (owned && rev !== this.remoteRev) this.publishStars(store);
}
}
@@ -210,7 +253,8 @@ export class ChannelStarSyncManager {
// still advances the watermark and blocks future seed-publish.
this.recordRemoteHead(event.created_at);
void decryptAndParse(event).then((result) => {
if (result) {
if (result && !this.destroyed) {
this.remember(result.store);
onUpdate(result);
}
});
@@ -229,7 +273,10 @@ export class ChannelStarSyncManager {
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0,
publishFn: (s) => this.publishStars(s),
publishFn: (s) => {
this.seedStore = s;
this.publishStars(s);
},
});
}

View File

@@ -0,0 +1,572 @@
import assert from "node:assert/strict";
import { after, before, beforeEach, mock, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html>", { url: "http://localhost" });
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
window: dom.window,
IS_REACT_ACT_ENVIRONMENT: true,
});
});
after(() => dom.window.close());
const PK = "f".repeat(64);
const RELAY = "wss://r.test";
let fx;
let seq = 0;
const ev = (dTag, json, createdAt) => ({
id: String(++seq).padStart(64, "0"),
pubkey: PK,
kind: 30078,
created_at: createdAt,
content: JSON.stringify(json),
tags: [["d", dTag]],
sig: "s",
});
async function harness() {
const rtl = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
mock.method(relayClient, "fetchEvents", async (filter) => {
if (fx.onFetch) return fx.onFetch();
const head = fx.heads[filter["#d"][0]];
if (fx.gate) {
fx.gated = (fx.gated ?? 0) + 1; // a fetch entered the gated preflight
await fx.gate;
}
return head ? [head] : [];
});
mock.method(relayClient, "subscribeLive", async (_f, onEvent) => {
fx.live = onEvent;
return async () => {};
});
mock.method(relayClient, "subscribeToReconnects", (fn) => {
fx.reconnect = fn;
return () => {};
});
mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => {
fx.attempts.push(event); // the manager reached the send boundary
if (fx.holdPublish) await fx.holdPublish;
if (isCurrent && !isCurrent()) throw new Error("canceled");
fx.published.push(event);
const d = event.tags[0][1]; // relay rule: newer wins, ties to lower id
const [a, b] = [event, fx.heads[d]];
if (
!b ||
a.created_at > b.created_at ||
(a.created_at === b.created_at && a.id < b.id)
)
fx.heads[d] = event;
});
mock.method(console, "warn", () => {});
window.__TAURI_INTERNALS__ = {
invoke: async (cmd, args) => {
if (cmd === "nip44_encrypt_to_self")
return Promise.resolve(fx.holdCrypto).then(() => args.plaintext);
if (cmd === "nip44_decrypt_from_self") return args.ciphertext;
if (cmd === "sign_event")
return JSON.stringify({
...ev(args.tags[0][1], JSON.parse(args.content), args.createdAt),
content: args.content,
});
throw new Error(`unmocked ${cmd}`);
},
};
// Advance fake time in steps, letting async work between timers settle.
const advance = async (ms) => {
for (let t = 0; t < ms; t += 1_000) {
await rtl.act(async () => {
mock.timers.tick(1_000);
for (let i = 0; i < 10; i++) await new Promise(setImmediate);
});
}
};
return { ...rtl, advance };
}
beforeEach(() => {
window.localStorage.clear();
mock.restoreAll();
mock.timers.reset();
mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 });
fx = { heads: {}, published: [], attempts: [], reconnect: null };
});
for (const [name, modPath, hookName, dTag, idsKey] of [
[
"stars",
"./useChannelStars.ts",
"useChannelStars",
"channel-stars",
"starredChannelIds",
],
[
"mutes",
"./useChannelMutes.ts",
"useChannelMutes",
"channel-mutes",
"mutedChannelIds",
],
]) {
test(`${name}: the real hook recovers a missed head on the 5 s and steady 60 s ticks`, async () => {
const { renderHook, advance, cleanup } = await harness();
const hook = (await import(modPath))[hookName];
const entry = (ids) =>
Object.fromEntries(
ids.map((id) => [
id,
{ [name === "stars" ? "starred" : "muted"]: true, updatedAt: 1 },
]),
);
const { result } = renderHook(() => hook(PK, RELAY));
await advance(1_000);
assert.equal(result.current[idsKey].size, 0);
fx.heads[dTag] = ev(dTag, { version: 1, channels: entry(["c1"]) }, 100);
await advance(5_000);
assert.deepEqual([...result.current[idsKey]], ["c1"]);
await advance(10_000 + 30_000); // back-off climbs to its steady 60 s
fx.heads[dTag] = ev(
dTag,
{ version: 1, channels: entry(["c1", "c2"]) },
200,
);
await advance(60_000);
assert.deepEqual([...result.current[idsKey]].sort(), ["c1", "c2"]);
cleanup();
});
}
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => ([resolve, reject] = [res, rej]));
return { promise, resolve, reject };
};
for (const [name, modPath, hookName, dTag, idsKey, verb, field] of [
[
"stars",
"./useChannelStars.ts",
"useChannelStars",
"channel-stars",
"starredChannelIds",
"starChannel",
"starred",
],
[
"mutes",
"./useChannelMutes.ts",
"useChannelMutes",
"channel-mutes",
"mutedChannelIds",
"muteChannel",
"muted",
],
]) {
const payload = (ids, updatedAt = 1) => ({
version: 1,
channels: Object.fromEntries(
ids.map((id) => [id, { [field]: true, updatedAt }]),
),
});
const headIds = () =>
Object.entries(JSON.parse(fx.heads[dTag].content).channels)
.filter(([, v]) => v[field])
.map(([id]) => id)
.sort();
const visible = () =>
document.dispatchEvent(new window.Event("visibilitychange"));
const setup = async () => {
Object.defineProperty(document, "visibilityState", {
value: "visible",
configurable: true,
});
const h = await harness();
const hook = (await import(modPath))[hookName];
fx.storageKey = (
await import(`./channel${name === "stars" ? "Stars" : "Mutes"}Storage.ts`)
).storageKey;
return { ...h, hook };
};
// B (or the union) must hold in the UI and the persisted cache alike.
const shows = (result, ids, msg) => {
const cached = JSON.parse(window.localStorage.getItem(fx.storageKey(PK)));
assert.deepEqual([...result.current[idsKey]].sort(), ids, msg);
const on = Object.entries(cached.channels).filter(([, v]) => v[field]);
assert.deepEqual(on.map(([id]) => id).sort(), ids, msg);
};
// A later authoritative head still applies: recovery kept polling.
const resumes = async ({ act, advance }, result, ids) => {
fx.heads[dTag] = ev(dTag, payload([...ids, "c7"], 9), 2e9);
await act(async () => visible());
await advance(1_000);
shows(result, [...ids, "c7"].sort(), "polling resumed");
};
test(`${name}: an older ACK leaves a newer edit pending through recovery`, async () => {
const h = await setup();
const { renderHook, act, advance, cleanup, hook } = h;
const { result } = renderHook(() => hook(PK, RELAY));
await advance(1_000);
act(() => result.current[verb]("c1")); // edit A
const ack = deferred();
fx.holdPublish = ack.promise;
await advance(2_000); // A is at the socket, its ACK held
act(() => result.current[verb]("c2")); // edit B, deadline +2 s
fx.holdPublish = null;
await act(async () => ack.resolve());
assert.deepEqual(headIds(), ["c1"], "A's ACK exit ran before recovery");
await act(async () => visible()); // recovery before B's deadline
shows(result, ["c1", "c2"], "B survives the recovery");
await advance(3_000);
assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay");
shows(result, ["c1", "c2"], "B settled");
await resumes(h, result, ["c1", "c2"]);
cleanup();
});
test(`${name}: an identical-payload exit leaves a newer edit pending through recovery`, async () => {
const h = await setup();
const { renderHook, act, advance, cleanup, hook } = h;
const { result } = renderHook(() => hook(PK, RELAY));
await advance(1_000);
act(() => result.current[verb]("c1")); // A
const ack = deferred();
fx.holdPublish = ack.promise;
await advance(2_000);
await act(async () => fx.reconnect()); // re-queues pending A
fx.holdPublish = null;
await act(async () => ack.resolve());
const gate = deferred();
fx.gate = gate.promise;
await advance(2_000); // A's repeat waits in preflight
assert.equal(fx.gated, 1, "A's repeat entered its gated preflight");
act(() => result.current[verb]("c2")); // B
fx.gate = null;
await act(async () => gate.resolve()); // repeat of A exits as identical
assert.equal(fx.attempts.length, 1, "A's repeat sent nothing");
await act(async () => visible());
shows(result, ["c1", "c2"], "B survives the recovery");
await advance(3_000);
assert.deepEqual(headIds(), ["c1", "c2"], "B reached the relay");
shows(result, ["c1", "c2"], "B settled");
await resumes(h, result, ["c1", "c2"]);
cleanup();
});
const stages = ["absent", "failed", "found", "crypto", "socket"];
const edits = ["edit", "edit-absent", "edit-failed", "edit2"];
const mid = ["edit-late", "edit-crypto", "edit-stale"]; // decoded mid-attempt
edits.push(...mid, "edit-conflict");
for (const stage of [...stages, ...edits, "ack"]) {
const what =
{
edit: "a genuine edit survives the seed's retirement",
"edit-absent": "an edit whose preflight is absent keeps R",
"edit-failed": "an edit whose preflight fails keeps R",
edit2: "a second edit whose preflight fails keeps R",
"edit-late": "R decoded during a failing preflight is kept",
"edit-crypto": "R decoded during encryption requeues the edit",
"edit-stale": "an observation with no changed winner cancels nothing",
"edit-conflict": "R's future-dated false beats the edit's true",
ack: "the seed's own ACK leaves a genuine edit pending",
}[stage] ?? `an acquired seed yields to R (retired at ${stage})`;
test(`${name}: startup recovery; ${what}`, async () => {
const { renderHook, act, advance, cleanup, hook } = await setup();
window.localStorage.setItem(
fx.storageKey(PK),
JSON.stringify(payload(["s1"])),
);
const off = { [field]: false, updatedAt: 3e12 }; // future-dated winner
const channels = payload(["r1"], 2e12).channels;
if (stage === "edit-conflict") channels.c9 = off;
const R = ev(dTag, { version: 1, channels }, 1e9 + 30); // future-dated
if (stage !== "ack") fx.heads[dTag] = R;
const calls = [];
fx.onFetch = () => {
const d = deferred();
calls.push(d);
return d.promise;
};
const { result } = renderHook(() => hook(PK, RELAY));
await act(async () => calls[0].resolve([])); // bootstrap: absent, seeds S
const held = deferred();
if (mid.includes(stage)) {
const [late, early] = [stage === "edit-late", stage === "edit-stale"];
act(() => result.current[verb]("c9"));
if (early) await act(async () => calls[1].resolve([R]));
if (!late) fx.holdCrypto = held.promise;
await advance(2_000); // the edit's preflight waits
if (!late) await act(async () => calls[2].resolve([]));
const old = ev(dTag, payload(["r1"]), 1e9 + 10);
if (early)
await act(async () => fx.live(old)); // stale copy
else await act(async () => calls[1].resolve([R])); // decodes R
if (late) calls[2].reject(new Error("x"));
await act(async () => held.resolve());
fx.holdCrypto = null;
const sends = early ? 1 : 0; // only R's arrival voids the snapshot
if (!late) assert.equal(fx.attempts.length, sends, "fence");
} else if (edits.includes(stage)) {
const fail = (c) =>
stage === "edit-absent" ? c.resolve([]) : c.reject(new Error("x"));
act(() => result.current[verb]("c9"));
await act(async () => calls[1].resolve([R])); // recovery observes R
await advance(2_000);
const ok = stage === "edit" || stage === "edit2";
await act(async () => (ok ? calls[2].resolve([R]) : fail(calls[2])));
if (stage === "edit2") {
act(() => result.current[verb]("c8")); // second edit
await advance(2_000);
await act(async () => fail(calls.at(-1)));
}
} else if (stage === "ack") {
fx.holdPublish = held.promise;
await advance(2_000); // S acquired
await act(async () => calls[1].resolve([]));
await act(async () => calls[2].resolve([])); // S at the socket
act(() => result.current[verb]("c9")); // genuine edit B
fx.onFetch = fx.holdPublish = null;
await act(async () => held.resolve()); // S's own ACK
} else if (stage === "crypto" || stage === "socket") {
fx[stage === "crypto" ? "holdCrypto" : "holdPublish"] = held.promise;
await advance(2_000); // seed acquired
await act(async () => calls[2].resolve([])); // preflight: absent
await act(async () => calls[1].resolve([R])); // retire mid-stage
await act(async () => held.resolve());
const sends = stage === "crypto" ? 0 : 1; // observable send boundary
assert.equal(fx.attempts.length, sends, `retired during ${stage}`);
} else {
await advance(2_000); // seed acquired; its preflight waits
await act(async () => calls[1].resolve([R])); // recovery observes R
if (stage === "failed") calls[2].reject(new Error("offline"));
else calls[2].resolve(stage === "found" ? [R] : []);
}
await advance(3_000);
// The later scheduled read gets whatever head the relay kept.
fx.onFetch = null;
for (const c of calls) c.resolve([fx.heads[dTag]]);
await advance(60_000);
const kept = stage.startsWith("edit")
? [...({ edit2: ["c8", "c9"], "edit-conflict": [] }[stage] ?? ["c9"])]
.concat("r1", "s1")
.sort()
: { ack: ["c9", "s1"] }[stage];
shows(result, kept ?? ["r1", "s1"], "UI and cache");
const sent = fx.published.map((e) => JSON.parse(e.content).channels);
if (edits.includes(stage))
assert.ok(
sent.every((c) => c.r1),
"no send lacks r1 (presence only)",
);
if (!kept) {
assert.equal(fx.published.length, 0, "the seed never published");
assert.equal(fx.heads[dTag], R, "R stays the relay head");
}
assert.deepEqual(headIds(), kept ?? ["r1"]);
const head = JSON.parse(fx.heads[dTag].content).channels;
const cache = window.localStorage.getItem(fx.storageKey(PK));
const both = [head, JSON.parse(cache).channels]; // relay and cache
for (const ch of edits.includes(stage) ? both : []) {
assert.deepEqual(ch.r1, { [field]: true, updatedAt: 2e12 });
if (stage === "edit-conflict") assert.deepEqual(ch.c9, off);
}
cleanup(); // a fresh reader consumes only the retained head
window.localStorage.clear();
const fresh = renderHook(() => hook(PK, RELAY));
await advance(1_000);
assert.deepEqual(
[...fresh.result.current[idsKey]].sort(),
kept ?? ["r1"],
);
cleanup();
});
}
}
test("sections hook: 60 s tick, reconnect, cross-tab, pending edit and in-flight unmount", async () => {
const { renderHook, act, advance, cleanup } = await harness();
const { useChannelSections } = await import("./useChannelSections.ts");
const { storageKey } = await import("./channelSectionsStorage.ts");
const legacy = (names) => ({
version: 1,
sections: names.map((n, order) => ({ id: n, name: n, order })),
assignments: {},
});
fx.heads["channel-sections"] = ev("channel-sections", legacy(["a"]), 100);
const { result, unmount } = renderHook(() => useChannelSections(PK, RELAY));
await advance(1_000);
const names = () => result.current.sections.map((s) => s.name);
assert.deepEqual(names(), ["a"]);
// Missed live event after the back-off reached 60 s (ticks at 5/15/45 s):
// only the steady tick at 105 s picks it up.
await advance(45_000);
fx.heads["channel-sections"] = ev(
"channel-sections",
legacy(["a", "b"]),
200,
);
await advance(58_000);
assert.deepEqual(names(), ["a"], "not yet: no tick before 105 s");
await advance(2_000);
assert.deepEqual(names(), ["a", "b"]);
// Reconnect re-reads.
fx.heads["channel-sections"] = ev(
"channel-sections",
legacy(["a", "b", "c"]),
300,
);
await act(async () => fx.reconnect());
await advance(1_000);
assert.deepEqual(names(), ["a", "b", "c"]);
// Another tab's write merges (never replaces).
const other = legacy(["d"]);
await act(async () =>
window.dispatchEvent(
new window.StorageEvent("storage", {
key: storageKey(PK, RELAY),
newValue: JSON.stringify(other),
}),
),
);
assert.deepEqual(names().sort(), ["a", "b", "c", "d"]);
// A read (here, reconnect) during the edit debounce merges but cannot
// publish early or shorten the debounce.
const before = fx.published.length;
act(() => void result.current.createSection("e"));
fx.heads["channel-sections"] = ev(
"channel-sections",
legacy(["a", "b", "c", "f"]),
400,
);
await act(async () => fx.reconnect());
await advance(1_000);
assert.equal(fx.published.length, before, "held by the debounce");
assert.ok(names().includes("f") && names().includes("e"));
// Unmount while the publish is in flight: nothing reaches the socket.
let release;
fx.holdPublish = new Promise((r) => (release = r));
await advance(2_000);
unmount();
release();
await advance(1_000);
assert.equal(fx.published.length, before);
cleanup();
});
test("sections hook: create appends past gaps; reorder completes against the live list", async () => {
const { renderHook, act, advance, cleanup } = await harness();
const { useChannelSections } = await import("./useChannelSections.ts");
const { storageKey } = await import("./channelSectionsStorage.ts");
const { result } = renderHook(() => useChannelSections(PK, RELAY));
await advance(1_000);
const names = () => result.current.sections.map((s) => s.name);
const ids = {};
for (const n of ["a", "b", "c"])
act(() => {
ids[n] = result.current.createSection(n).id;
});
act(() => result.current.deleteSection(ids.b)); // canonical ranks 0 and 2
act(() => void result.current.createSection("d"));
assert.deepEqual(names(), ["a", "c", "d"], "new section sorts last");
// A section merged in from another tab after the drag snapshot was taken.
const snapshot = result.current.sections.map((s) => s.id);
await act(async () =>
window.dispatchEvent(
new window.StorageEvent("storage", {
key: storageKey(PK, RELAY),
newValue: JSON.stringify({
version: 1,
sections: [{ id: "x", name: "x", order: 9 }],
assignments: {},
}),
}),
),
);
act(() =>
result.current.reorderSections([...snapshot].reverse().concat("gone")),
);
assert.deepEqual(names(), ["d", "c", "a", "x"]);
const orders = JSON.parse(window.localStorage.getItem(storageKey(PK, RELAY)))
.meta.s;
assert.ok(orders.x.order, "the missing live section got an order register");
cleanup();
});
const UPGRADE_LANES = [
{
name: "sections",
dTag: "channel-sections",
load: async () => [
(await import("./useChannelSections.ts")).useChannelSections,
(await import("./channelSectionsStorage.ts")).storageKey,
],
legacy: (name) => ({
version: 1,
sections: [{ id: "x", name, order: 0 }],
assignments: {},
}),
ui: (r) => r.sections[0]?.name,
fromDoc: (doc) => doc.sections[0]?.name,
edit: (r, v) => r.renameSection("x", v),
values: ["Eng", "Platform", "Mine"],
},
{
name: "sort",
dTag: "channel-sort",
load: async () => [
(await import("./useChannelSortPreference.ts")).useChannelSortPreference,
(await import("./channelSortPreference.ts")).storageKey,
],
legacy: (mode) => ({ version: 1, groups: { channels: mode } }),
ui: (r) => r.sortModeFor("channels"),
fromDoc: (doc) => doc.groups.channels,
edit: (r, v) => {
r.setSortModeFor("channels", "alpha"); // authored, then back
r.setSortModeFor("channels", v);
},
values: ["recent", "alpha", "recent"],
},
];
for (const L of UPGRADE_LANES) {
for (const edited of [false, true]) {
const what = edited
? "a genuine local edit still beats the relay"
: "a cache-only import yields to the relay head";
test(`${L.name} first upgrade: ${what}`, async () => {
const { renderHook, act, advance, cleanup } = await harness();
const [hook, key] = await L.load();
const [cached, remote, mine] = L.values;
window.localStorage.setItem(
key(PK, RELAY),
JSON.stringify(L.legacy(cached)),
);
fx.heads[L.dTag] = ev(L.dTag, L.legacy(remote), 100);
let open;
fx.gate = new Promise((r) => (open = r));
const { result } = renderHook(() => hook(PK, RELAY));
if (edited) act(() => L.edit(result.current, mine));
fx.gate = null;
open();
await advance(5_000);
const want = edited ? mine : remote;
assert.equal(L.ui(result.current), want, "UI");
const cache = JSON.parse(window.localStorage.getItem(key(PK, RELAY)));
assert.equal(L.fromDoc(cache), want, "cache");
const head = JSON.parse(fx.heads[L.dTag].content);
assert.equal(L.fromDoc(head), want, "relay head");
assert.ok(head.meta, "a fresh reader gets the registers");
cleanup();
});
}
}

View File

@@ -0,0 +1,387 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { PublishCanceledError } from "@/shared/api/relayEventPublisher";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds";
import {
decodeDoc,
encodeDoc,
LaneStore,
type LaneCodec,
} from "./sidebarLaneStore";
import { canonical, isEmptyTree, type Tree } from "./sidebarLwwMap";
import { advanceWatermark, readWatermark } from "./sidebarSyncWatermark";
import { useStaleReaderRecovery } from "./useStaleReaderRecovery";
/** Relay event size ceiling shared by every sidebar blob. */
const MAX_CIPHERTEXT_BYTES = 65_535;
const DEBOUNCE_MS = 2_000;
const BACKOFF_MS = [5_000, 10_000, 30_000, 60_000, 60_000];
export type Lane = LaneCodec & {
dTag: string;
/** False when the projected payload exceeds today's count caps. */
withinLimits: (projection: Record<string, unknown>) => boolean;
};
type Status = "unknown" | "empty" | "decoding" | "decoded" | "unreadable";
type Head = {
id: string;
createdAt: number;
status: Status;
digest: string | null;
};
/** `a` beats `b`: later `created_at`, then lower id (the relay's replaceable winner). */
const beats = (a: RelayEvent, b: Head) =>
a.created_at > b.createdAt || (a.created_at === b.createdAt && a.id < b.id);
/**
* Converges one lane's local tree with its relay head. Publishes only when the
* decoded head's canonical bytes differ from the local tree's, never over an
* undecoded or unreadable head, and never over an absence after a head has
* been seen in this scope (the watermark). Every publish attempt waits for
* `notBefore` (edit debounce or failure backoff); reads and merges do not.
*/
export class LaneReconciler {
head: Head = { id: "", createdAt: 0, status: "unknown", digest: null };
private lastHead: number;
private attempt: object | null = null;
private recheck = false;
private failures = 0;
private notBefore = 0;
private observations = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private destroyed = false;
private readonly lane: Lane;
private readonly store: LaneStore;
private readonly pubkey: string;
private readonly relayUrl: string;
constructor(lane: Lane, store: LaneStore, pubkey: string, relayUrl: string) {
this.lane = lane;
this.store = store;
this.pubkey = pubkey;
this.relayUrl = relayUrl;
this.lastHead = readWatermark(pubkey, lane.dTag, relayUrl);
}
filter(limit: number) {
return {
kinds: [KIND_CHANNEL_SECTIONS],
authors: [this.pubkey],
"#d": [this.lane.dTag],
limit,
};
}
/** Records a raw head; one that beats the current head holds publishing until decoded. */
private observe(event: RelayEvent): void {
this.observations++;
this.lastHead = Math.max(this.lastHead, event.created_at);
advanceWatermark(
this.pubkey,
this.lane.dTag,
this.relayUrl,
event.created_at,
);
if (!beats(event, this.head)) return;
this.head = {
id: event.id,
createdAt: event.created_at,
status: "decoding",
digest: null,
};
}
/** Decodes and merges `event`; its status lands only while it is still the head. */
async ingest(event: RelayEvent): Promise<void> {
if (event.pubkey !== this.pubkey) return;
this.observe(event);
const mine = this.observations;
let json: unknown = null;
try {
json = JSON.parse(await nip44DecryptFromSelf(event.content));
} catch {
json = null;
}
if (this.destroyed) return;
const doc = decodeDoc(this.lane, json, event.created_at * 1_000);
if (doc) this.store.merge(doc.tree, doc.legacy);
// Only the newest observation (event or absence) may set eligibility; a
// stale decode still merges its content above.
if (this.head.id === event.id && this.observations === mine) {
this.head = doc
? { ...this.head, status: "decoded", digest: canonical(json) }
: { ...this.head, status: "unreadable", digest: null };
}
this.reconcile();
}
/** One relay read. Throws on transport failure. */
async read(): Promise<void> {
const seen = this.observations;
const [event] = await relayClient.fetchEvents(this.filter(1));
if (this.destroyed) return;
if (event && event.pubkey === this.pubkey) return this.ingest(event);
// Absence counts only if nothing was observed while it was in flight. It
// permits a first copy for a genuinely new scope; after a head has been
// seen it demotes the head and holds until a readable head returns.
if (seen === this.observations) {
this.observations++;
const status = this.lastHead === 0 ? "empty" : "unknown";
this.head = { ...this.head, status, digest: null };
}
this.reconcile();
}
reconcile(): void {
if (this.destroyed) return;
const wait = this.notBefore - Date.now();
if (wait > 0) {
this.wake(wait); // hold; re-read at the deadline
return;
}
if (this.attempt) {
this.recheck = true;
return;
}
const { status, digest } = this.head;
if (status !== "empty" && status !== "decoded") return; // hold; retried on cadence
const tree = this.store.get();
const settled =
status === "empty"
? isEmptyTree(tree)
: digest === canonical(encodeDoc(this.lane, tree));
if (settled) {
this.failures = 0;
return;
}
void this.runAttempt();
}
/** Re-reads after `delay`. Never shortens a publish hold (`defer`). */
wake(delay = 0): void {
if (this.destroyed) return;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
this.read().catch(() => this.backoff());
}, delay);
}
/** Holds publishing for at least `delay`; local edits coalesce behind it. */
defer(delay = DEBOUNCE_MS): void {
this.notBefore = Math.max(this.notBefore, Date.now() + delay);
this.wake(delay);
}
private backoff(): void {
const delay = BACKOFF_MS[
Math.min(this.failures, BACKOFF_MS.length - 1)
] as number;
this.failures++;
this.defer(delay);
}
private async runAttempt(): Promise<void> {
const token = {};
this.attempt = token;
let outcome: "settled" | "acked" | "held" | "failed" = "failed";
const held = () => Date.now() < this.notBefore;
let head = this.head;
let tree: Tree = this.store.get();
try {
await this.read(); // preflight: merge the current head first
head = this.head;
tree = this.store.get();
if (held()) {
outcome = "held"; // a deadline arrived during preflight
return;
}
const doc = encodeDoc(this.lane, tree);
const empty = head.status === "empty" && isEmptyTree(tree);
if (head.status !== "empty" && head.status !== "decoded") {
outcome = "settled"; // hold: the recovery cadence re-reads
return;
}
if (
empty ||
head.digest === canonical(doc) ||
!this.lane.withinLimits(doc)
) {
outcome = "settled"; // equal, or over limit: kept locally only
return;
}
const dropped = () =>
this.destroyed ||
held() ||
this.store.get() !== tree ||
this.head.id !== head.id ||
this.head.status !== head.status;
// A deliberate deadline cancel keeps the deadline; stale drops back off.
// The cause is captured when the drop happens, not when it is consumed.
let heldDrop = false;
const drop = () => {
if (!dropped()) return false;
heldDrop = held();
if (heldDrop) outcome = "held";
return true;
};
const content = await nip44EncryptToSelf(JSON.stringify(doc));
if (new TextEncoder().encode(content).length > MAX_CIPHERTEXT_BYTES) {
outcome = "settled";
return;
}
if (drop()) return;
const event = await signRelayEvent({
kind: KIND_CHANNEL_SECTIONS,
content,
createdAt: Math.max(Math.floor(Date.now() / 1_000), head.createdAt + 1),
tags: [
["d", this.lane.dTag],
["t", this.lane.dTag], // relay discoverability; not used in our filters
],
});
if (drop()) return;
try {
await relayClient.publishEvent(
event,
`Timed out publishing ${this.lane.dTag}.`,
`Failed to publish ${this.lane.dTag}.`,
() => !drop(), // checked again right before each socket send
);
} catch (error) {
if (error instanceof PublishCanceledError && heldDrop) return;
if (!String((error as Error)?.message).startsWith("duplicate:"))
throw error;
}
outcome = "acked";
this.lastHead = Math.max(this.lastHead, event.created_at);
advanceWatermark(
this.pubkey,
this.lane.dTag,
this.relayUrl,
event.created_at,
);
} catch (error) {
console.warn(`[${this.lane.dTag}] sync attempt failed:`, error);
} finally {
if (this.attempt === token) this.attempt = null;
const recheck = this.recheck;
this.recheck = false;
// An ACK proves nothing about the head; verify with a read. Real
// failures and stale drops retry behind the backoff, never immediately.
if (outcome === "acked") this.wake();
else if (outcome === "held")
this.wake(this.notBefore - Date.now()); // deadline kept; no retry now
else if (outcome === "failed") this.backoff();
else if (recheck && (this.head !== head || this.store.get() !== tree)) {
this.reconcile();
}
}
}
destroy(): void {
this.destroyed = true;
if (this.timer !== null) clearTimeout(this.timer);
}
}
/**
* Binds a lane to React for one pubkey + relay scope: the store's tree as
* state, and a reconciler fed by bootstrap, live events, reconnects, local
* edits, and the stale-reader recovery cadence.
*/
export function useLaneSync(
lane: Lane,
pubkey: string | undefined,
relayUrl: string | undefined,
): { tree: Tree; edit: (fn: (tree: Tree) => Tree) => void } {
const store = React.useMemo(
() => (pubkey && relayUrl ? new LaneStore(lane, pubkey, relayUrl) : null),
[lane, pubkey, relayUrl],
);
const [reconciler, setReconciler] = React.useState<LaneReconciler | null>(
null,
);
const reconcilerRef = React.useRef<LaneReconciler | null>(null);
reconcilerRef.current = reconciler;
const tree = React.useSyncExternalStore(
React.useCallback(
(fn: () => void) => store?.subscribe(fn) ?? (() => {}),
[store],
),
() => store?.get() ?? EMPTY,
);
React.useEffect(() => {
if (!store || !pubkey || !relayUrl) return;
const rec = new LaneReconciler(lane, store, pubkey, relayUrl);
setReconciler(rec);
let disposed = false;
let unsubLive: (() => Promise<void>) | null = null;
const detachTabs = store.attachCrossTab();
const unsubReconnect = relayClient.subscribeToReconnects(() => rec.wake());
void relayClient
.subscribeLive(rec.filter(0), (event) => {
if (!disposed) void rec.ingest(event);
})
.then((dispose) => {
if (disposed) void dispose();
else unsubLive = dispose;
})
.catch(() => {});
return () => {
disposed = true;
rec.destroy();
detachTabs();
unsubReconnect();
if (unsubLive) void unsubLive();
setReconciler(null);
};
}, [lane, store, pubkey, relayUrl]);
// D2: the shared recovery cadence drives reads even while an edit is
// pending; the reconciler consumes every result, so nothing is applied here.
const recoveryRead = React.useCallback(async () => {
store?.persist();
await reconciler?.read().catch(() => {});
return undefined;
}, [store, reconciler]);
useStaleReaderRecovery<never, never>({
enabled: reconciler !== null,
fetch: recoveryRead,
hasPending: NEVER_PENDING,
getRevision: ZERO,
makeUpdater: IDENTITY,
setStore: NOOP,
});
// A local edit persists immediately and publishes after the debounce; an
// edit made before the reconciler exists is picked up by its first read.
const edit = React.useCallback(
(fn: (tree: Tree) => Tree) => {
if (!store) return;
const before = store.get();
store.transact(fn);
if (store.get() !== before) reconcilerRef.current?.defer();
},
[store],
);
return { tree, edit };
}
const EMPTY: Tree = Object.freeze({}) as Tree;
const NEVER_PENDING = () => false;
const ZERO = () => 0;
const IDENTITY = () => (prev: never) => prev;
const NOOP = () => {};

View File

@@ -0,0 +1,158 @@
import {
canonical,
isReg,
LEGACY_DEV,
mergeTrees,
validTree,
type Reg,
type Shape,
type Tree,
} from "./sidebarLwwMap";
/** How one sidebar preference maps between its register tree and its JSON. */
export type LaneCodec = {
storageKey: (pubkey: string, relayUrl: string) => string;
/** Pre-relay-scoping key migrated once on first read, then removed. */
legacyStorageKey?: (pubkey: string) => string;
/** Shape of `meta` (excluding `meta.v`). */
shape: Shape;
/** Registers for a meta-less payload, every value stamped by `stamp`. */
fromLegacy: (json: unknown, stamp: <T>(value: T) => Reg<T>) => Tree | null;
/** The legacy fields older clients read, projected from the registers. */
project: (tree: Tree) => Record<string, unknown>;
};
export type DecodedDoc = { tree: Tree; legacy: boolean };
/**
* Decodes a `version: 1` payload. A meta-less payload (older writer) is
* imported at version `legacyV`; the caller merges it only into missing
* leaves. Returns null when unreadable, including an unknown `meta.v`.
*/
export function decodeDoc(
codec: LaneCodec,
json: unknown,
legacyV: number,
): DecodedDoc | null {
if (typeof json !== "object" || json === null) return null;
const { version, meta } = json as { version?: unknown; meta?: unknown };
if (version !== 1) return null;
if (meta === undefined) {
// Validated like `meta`, so legacy keys obey the same own-key rules.
const tree = validTree(
codec.fromLegacy(json, (value) => [legacyV, LEGACY_DEV, value]),
codec.shape,
);
return tree && !isReg(tree) ? { tree, legacy: true } : null;
}
if ((meta as { v?: unknown } | null)?.v !== 1) return null;
const { v: _v, ...fields } = meta as Record<string, unknown>;
const tree = validTree(fields, codec.shape);
return tree && !isReg(tree) ? { tree, legacy: false } : null;
}
export function encodeDoc(codec: LaneCodec, tree: Tree) {
return { version: 1, ...codec.project(tree), meta: { v: 1, ...tree } };
}
/**
* The one authoritative register tree for a lane in a pubkey + relay scope.
* Every read and edit goes through `transact`; the full tree persists at rest
* (limits gate only publishing).
*/
export class LaneStore {
private tree: Tree = {};
private dirty = false;
/** Pre-relay-scoping key to remove once the scoped copy is durable. */
private legacyKey: string | undefined;
private listeners = new Set<() => void>();
private readonly key: string;
private readonly codec: LaneCodec;
constructor(codec: LaneCodec, pubkey: string, relayUrl: string) {
this.codec = codec;
this.key = codec.storageKey(pubkey, relayUrl);
const legacyKey = codec.legacyStorageKey?.(pubkey);
const raw = this.readRaw(this.key) ?? this.readRaw(legacyKey);
// Meta-less local caches import at a stable stamp any remote beats.
const doc = raw === undefined ? null : decodeDoc(codec, raw, 1);
if (!doc) return;
this.tree = doc.tree;
this.dirty = doc.legacy;
this.legacyKey = legacyKey;
this.persist();
}
private readRaw(key: string | undefined): unknown {
try {
const raw = key ? window.localStorage.getItem(key) : null;
return raw === null ? undefined : JSON.parse(raw);
} catch {
return undefined;
}
}
get = (): Tree => this.tree;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
/**
* Applies `fn`. A tree with unchanged canonical bytes has no side effects
* beyond retrying an earlier failed write.
*/
transact(fn: (tree: Tree) => Tree): void {
const next = fn(this.tree);
if (next === this.tree || canonical(next) === canonical(this.tree)) {
this.persist();
return;
}
this.tree = next;
this.dirty = true;
this.persist();
for (const listener of this.listeners) listener();
}
merge(remote: Tree, onlyMissing = false): void {
this.transact((tree) => mergeTrees(tree, remote, onlyMissing));
}
/**
* Writes the tree if an earlier write failed or is outstanding, then retires
* the legacy key once the scoped copy is durable. Both retry on later calls.
*/
persist(): boolean {
try {
if (this.dirty) {
const raw = canonical(encodeDoc(this.codec, this.tree));
window.localStorage.setItem(this.key, raw);
this.dirty = false;
}
if (this.legacyKey && this.readRaw(this.key) !== undefined) {
window.localStorage.removeItem(this.legacyKey);
this.legacyKey = undefined;
}
} catch {
// Stays pending; retried on the next transaction or recovery tick.
}
return !this.dirty;
}
/** Merges (never replaces) another tab's copy of this scope. */
attachCrossTab(): () => void {
const handler = (event: StorageEvent) => {
if (event.key !== this.key || event.newValue === null) return;
try {
const doc = decodeDoc(this.codec, JSON.parse(event.newValue), 1);
if (doc) this.merge(doc.tree, doc.legacy);
} catch {
// Ignore a malformed foreign write.
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}
}

View File

@@ -0,0 +1,643 @@
import assert from "node:assert/strict";
import test, { beforeEach, mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { SECTIONS_LANE, projectSections } from "./channelSectionsSync.ts";
import { SORT_LANE, projectSort } from "./channelSortSync.ts";
import {
PublishCanceledError,
publishSessionEvent,
} from "@/shared/api/relayEventPublisher";
import { LaneReconciler } from "./sidebarLaneReconciler.ts";
import { LaneStore } from "./sidebarLaneStore.ts";
import { canonical, setRegs } from "./sidebarLwwMap.ts";
const PK = "f".repeat(64);
const RELAY = "wss://r.test";
const storage = new Map();
globalThis.window ??= {};
Object.assign(globalThis.window, {
localStorage: {
getItem: (k) => storage.get(k) ?? null,
setItem: (k, v) => {
if (fx.storageFails) throw new Error("quota");
fx.writes++;
storage.set(k, v);
},
removeItem: (k) => storage.delete(k),
},
addEventListener: () => {},
removeEventListener: () => {},
});
/** Fake relay (one replaceable head), identity crypto, scripted failures. */
let fx;
let seq = 0;
beforeEach(() => {
storage.clear();
mock.restoreAll();
fx = {
head: null,
published: [],
fetchFails: false,
publish: "ok",
storageFails: false,
writes: 0,
badDecrypt: new Set(),
};
globalThis.window.__TAURI_INTERNALS__ = {
invoke: async (cmd, args) => {
if (cmd === "nip44_encrypt_to_self") return args.plaintext;
if (cmd === "nip44_decrypt_from_self") {
if (fx.badDecrypt.has(args.ciphertext)) throw new Error("bad");
if (fx.holdDecrypt && --fx.holdAfter < 0) await fx.holdDecrypt;
return args.ciphertext;
}
if (cmd === "sign_event")
return JSON.stringify(ev(args.content, args.createdAt));
throw new Error(`unmocked ${cmd}`);
},
};
mock.method(relayClient, "fetchEvents", async () => {
const result = fx.head ? [fx.head] : []; // snapshot at request time
if (fx.gate) await fx.gate;
if (fx.fetchFails) throw new Error("offline");
return result;
});
mock.method(console, "warn", () => {});
mock.method(relayClient, "publishEvent", async (event, _t, _e, isCurrent) => {
fx.beforeSend?.();
if (!isCurrent()) throw (await fx.consume) ?? new PublishCanceledError();
fx.published.push(event);
if (fx.publish === "cancelText") {
fx.publish = "ok";
fx.afterSend();
throw new Error(new PublishCanceledError().message); // a relay's text
}
if (fx.publish === "timeout") throw new Error("Timed out");
if (fx.publish === "reject") throw new Error("blocked: nope");
if (fx.publish === "duplicate") throw new Error("duplicate: have it");
if (fx.publish !== "lost") fx.head = event;
});
});
function ev(content, createdAt) {
seq++;
return {
id: String(seq).padStart(64, "0"),
pubkey: PK,
kind: 30078,
created_at: createdAt,
content,
tags: [],
sig: "s",
};
}
function device(lane) {
const store = new LaneStore(lane, PK, RELAY);
const rec = new LaneReconciler(lane, store, PK, RELAY);
rec.wake = () => {}; // timers are driven explicitly via read()
return { store, rec };
}
const settle = () => new Promise((r) => setImmediate(r));
async function sync(d) {
await d.rec.read();
for (let i = 0; i < 5; i++) await settle();
}
const LANES = [
{
name: "sections",
lane: SECTIONS_LANE,
edit: (tree, key, value) =>
setRegs(tree, [
[["s", key, "name"], value],
[["s", key, "order"], 0],
[["s", key, "live"], true],
]),
remove: (tree, key) => setRegs(tree, [[["s", key, "live"], false]]),
view: (tree) =>
Object.fromEntries(
projectSections(tree).sections.map((s) => [s.id, s.name]),
),
legacy: (items) => ({
version: 1,
sections: Object.entries(items).map(([id, name], order) => ({
id,
name,
order,
})),
assignments: {},
}),
big: (tree) =>
setRegs(
tree,
Array.from({ length: 101 }, (_, i) => [
["s", `s${i}`, "live"],
true,
]).flatMap((w, i) => [
w,
[["s", `s${i}`, "name"], "n"],
[["s", `s${i}`, "order"], i],
]),
),
},
{
name: "sort",
lane: SORT_LANE,
edit: (tree, key, value) =>
setRegs(tree, [
[
["g", `section:${key}`],
value === "B" || value === "Y" ? "recent" : "alpha",
],
]),
remove: (tree, key) => setRegs(tree, [[["g", `section:${key}`], null]]),
view: (tree) =>
Object.fromEntries(
Object.entries(projectSort(tree).groups).map(([k, m]) => [
k.slice(8),
m === "recent" ? "B" : "A",
]),
),
legacy: (items) => ({
version: 1,
groups: Object.fromEntries(
Object.entries(items).map(([k, v]) => [
`section:${k}`,
v === "B" ? "recent" : "alpha",
]),
),
}),
big: (tree) =>
setRegs(
tree,
Array.from({ length: 105 }, (_, i) => [
["g", `section:${i}`],
"recent",
]),
),
},
];
for (const L of LANES) {
test(`${L.name}: new scope with local data publishes its first copy`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
await sync(d);
assert.equal(fx.published.length, 1);
const doc = JSON.parse(fx.published[0].content);
assert.equal(doc.version, 1);
assert.equal(doc.meta.v, 1);
await sync(d);
assert.equal(fx.published.length, 1, "equal digest: no republish");
});
test(`${L.name}: absence after a seen head holds (never publishes over it)`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
await sync(d);
fx.head = null; // relay transiently returns nothing
const d2 = device(L.lane); // restart: watermark > 0
d2.store.transact((t) => L.edit(t, "k2", "B"));
await sync(d2);
assert.equal(fx.published.length, 1);
assert.equal(d2.rec.head.status, "unknown");
});
test(`${L.name}: stale device merges instead of overwriting newer items`, async () => {
const a = device(L.lane);
a.store.transact((t) => L.edit(t, "k1", "A"));
await sync(a);
storage.clear(); // B: separate install with its own local cache
const b = device(L.lane);
b.store.transact((t) => L.edit(t, "k2", "B"));
await sync(b);
assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), {
k1: "A",
k2: "B",
});
await sync(a);
assert.deepEqual(L.view(a.store.get()), { k1: "A", k2: "B" });
});
test(`${L.name}: a delete on one device survives a stale device's publish`, async () => {
const a = device(L.lane);
a.store.transact((t) => L.edit(L.edit(t, "k1", "A"), "k2", "B"));
await sync(a);
const snapshot = new Map(storage);
a.store.transact((t) => L.remove(t, "k1"));
await sync(a);
storage.clear();
for (const [k, v] of snapshot) if (!k.includes("clock")) storage.set(k, v);
const stale = device(L.lane); // cache from before the delete
stale.store.transact((t) => L.edit(t, "k3", "A"));
await sync(stale);
assert.deepEqual(L.view(JSON.parse(fx.head.content).meta), {
k2: "B",
k3: "A",
});
});
test(`${L.name}: old writer without meta adds items but cannot delete`, async () => {
const a = device(L.lane);
a.store.transact((t) => L.edit(t, "k1", "A"));
await sync(a);
fx.head = ev(JSON.stringify(L.legacy({ k9: "B" })), fx.head.created_at + 1);
await sync(a);
assert.deepEqual(L.view(a.store.get()), { k1: "A", k9: "B" });
assert.equal(
JSON.parse(fx.head.content).meta.v,
1,
"next upgraded publish restores meta",
);
});
test(`${L.name}: meta-less local cache imports once at a stable stamp`, async () => {
storage.set(
L.lane.storageKey(PK, RELAY),
JSON.stringify(L.legacy({ k1: "A" })),
);
const d = device(L.lane);
const first = canonical(d.store.get());
assert.deepEqual(L.view(d.store.get()), { k1: "A" });
assert.equal(canonical(device(L.lane).store.get()), first);
});
test(`${L.name}: publish exits (timeout, reject, duplicate) release the attempt`, async (t) => {
t.mock.timers.enable({ apis: ["Date"], now: 1e12 });
for (const outcome of ["timeout", "reject", "duplicate"]) {
storage.clear();
fx.head = null;
fx.publish = outcome;
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
const before = fx.published.length;
await sync(d);
assert.equal(fx.published.length, before + 1, outcome);
fx.publish = "ok";
if (outcome === "duplicate") fx.head = fx.published.at(-1);
t.mock.timers.tick(60_000); // past the failure backoff
await sync(d);
assert.equal(
fx.published.length,
before + (outcome === "duplicate" ? 1 : 2),
`${outcome} retry`,
);
}
});
test(`${L.name}: preflight failure inside an attempt backs off, then recovers`, async (t) => {
t.mock.timers.enable({ apis: ["Date"], now: 1e12 });
const d = device(L.lane);
d.store.transact((tr) => L.edit(tr, "k2", "B"));
fx.fetchFails = true; // the attempt's own preflight read fails
await d.rec.ingest(ev(JSON.stringify(L.legacy({ k1: "A" })), 100));
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 0);
fx.fetchFails = false;
fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 100);
await sync(d);
assert.equal(fx.published.length, 0, "held by the backoff");
t.mock.timers.tick(5_000);
await sync(d);
assert.equal(fx.published.length, 1);
});
test(`${L.name}: a stale preflight decode cannot undo a newer absence`, async () => {
const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100);
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k2", "B"));
let release;
fx.holdDecrypt = new Promise((r) => (release = r));
fx.holdAfter = 1; // the attempt's preflight decode of H0 waits
fx.head = h0;
void d.rec.ingest(h0);
for (let i = 0; i < 5; i++) await settle();
fx.head = null;
await d.rec.read(); // recovery read during the acquired attempt
release();
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 0);
assert.equal(d.rec.head.status, "unknown");
fx.holdDecrypt = null;
fx.head = h0;
await sync(d);
assert.equal(fx.published.length, 1, "a later readable head re-enables");
});
for (const fails of [false, true]) {
const what = fails ? "a failure to its full backoff" : "the send";
test(`${L.name}: an edit during preflight holds ${what}`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 });
const h0 = ev(JSON.stringify(L.legacy({ k1: "A" })), 100);
const store = new LaneStore(L.lane, PK, RELAY);
const rec = new LaneReconciler(L.lane, store, PK, RELAY);
store.transact((tr) => L.edit(tr, "k2", "B"));
let open;
fx.gate = new Promise((r) => (open = r));
fx.head = h0;
void rec.ingest(h0); // attempt acquired; preflight fetch waits
for (let i = 0; i < 5; i++) await settle();
store.transact((tr) => L.edit(tr, "k3", "A"));
rec.defer(); // edit deadline: +2 s
fx.fetchFails = fails; // the acquired preflight then really fails
fx.gate = null;
open();
for (let i = 0; i < 5; i++) await settle();
fx.fetchFails = false;
assert.equal(fx.published.length, 0);
t.mock.timers.tick(2_000); // real wake() re-arms; no manual read
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, fails ? 0 : 1, "edit deadline");
t.mock.timers.tick(3_000);
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1, "after the 5 s failure backoff");
rec.destroy();
});
}
test(`${L.name}: a relay rejection with the cancel text during a hold backs off`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 });
const store = new LaneStore(L.lane, PK, RELAY);
const rec = new LaneReconciler(L.lane, store, PK, RELAY);
store.transact((tr) => L.edit(tr, "k1", "A"));
fx.publish = "cancelText";
fx.afterSend = () => rec.defer(); // an edit hold starts mid-send
await rec.read();
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1);
t.mock.timers.tick(2_000);
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1, "not re-armed as a hold");
t.mock.timers.tick(3_000);
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 2, "retried after the 5 s backoff");
rec.destroy();
});
test(`${L.name}: a hold cancel consumed after its deadline stays a hold`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 });
const store = new LaneStore(L.lane, PK, RELAY);
const rec = new LaneReconciler(L.lane, store, PK, RELAY);
store.transact((tr) => L.edit(tr, "k1", "A"));
let consume;
fx.consume = new Promise((r) => (consume = r));
fx.beforeSend = () => {
fx.beforeSend = null;
rec.defer();
};
await rec.read(); // the send is canceled by the hold
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.beforeSend, null, "canceled at the send boundary");
t.mock.timers.tick(2_000); // the hold expires before the cancel is consumed
fx.consume = null;
consume();
for (let i = 0; i < 5; i++) await settle();
t.mock.timers.tick(1); // the kept, expired deadline re-arms at once
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1, "no failure backoff");
rec.destroy();
});
test(`${L.name}: unreadable head holds quietly; content never published over it`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
fx.head = ev("not json", 10);
await sync(d);
assert.equal(d.rec.head.status, "unreadable");
assert.equal(fx.published.length, 0);
});
test(`${L.name}: stale decode merges content but does not settle status`, async () => {
const d = device(L.lane);
const older = ev(JSON.stringify(L.legacy({ k1: "A" })), 10);
const newer = ev("garbage", 11);
fx.badDecrypt.add("garbage");
const p = d.rec.ingest(older);
await d.rec.ingest(newer); // newer head observed before older decode lands
await p;
assert.equal(d.rec.head.id, newer.id);
assert.equal(d.rec.head.status, "unreadable");
assert.deepEqual(L.view(d.store.get()), { k1: "A" });
});
test(`${L.name}: over limit stays local and is not published`, async () => {
const d = device(L.lane);
d.store.transact(L.big);
await sync(d);
assert.equal(fx.published.length, 0);
assert.ok(
storage.get(L.lane.storageKey(PK, RELAY)).length > 1000,
"kept durable locally",
);
});
test(`${L.name}: destroyed reconciler never publishes`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
d.rec.destroy();
await sync(d);
assert.equal(fx.published.length, 0);
});
}
test("sections: projection is live-only, dense integer order, live assignments", () => {
const A = "a".repeat(16);
const tree = {
s: {
x: { name: [1, A, "X"], order: [1, A, 7], live: [1, A, true] },
y: { name: [1, A, "Y"], order: [1, A, 3], live: [1, A, true] },
z: { name: [1, A, "Z"], order: [1, A, 0], live: [1, A, false] },
},
a: { c1: [1, A, "x"], c2: [1, A, "z"], c3: [1, A, null] },
};
assert.deepEqual(projectSections(tree), {
sections: [
{ id: "y", name: "Y", order: 0 },
{ id: "x", name: "X", order: 1 },
],
assignments: { c1: "x" },
});
});
test("sections: rename concurrent with delete stays deleted", async () => {
const d = device(SECTIONS_LANE);
d.store.transact((t) => LANES[0].edit(t, "k1", "A"));
const base = d.store.get();
const renamed = setRegs(base, [[["s", "k1", "name"], "B"]]);
const deleted = setRegs(base, [[["s", "k1", "live"], false]]);
d.store.merge(renamed);
d.store.merge(deleted);
assert.deepEqual(projectSections(d.store.get()).sections, []);
});
// ─── pass-1 regressions ────────────────────────────────────────────────────
for (const L of LANES) {
test(`${L.name}: absence on a running reconciler holds until a head returns`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
await sync(d);
const h1 = fx.head;
fx.head = null; // transient empty read after H0 was decoded
d.store.transact((t) => L.edit(t, "k2", "B"));
await sync(d);
assert.equal(fx.published.length, 1);
assert.equal(d.rec.head.status, "unknown");
fx.head = h1;
await sync(d);
assert.equal(fx.published.length, 2, "readable head re-enables publish");
});
test(`${L.name}: empty verification after an ACK does not republish`, async () => {
fx.publish = "lost"; // ACKed, but reads keep returning nothing
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
await sync(d);
await sync(d);
await sync(d);
assert.equal(fx.published.length, 1);
});
test(`${L.name}: a stale absent read cannot demote a newer observation`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
let open;
fx.gate = new Promise((r) => (open = r));
const pending = d.rec.read(); // absent, still in flight
fx.gate = null;
fx.head = ev(JSON.stringify(L.legacy({ k1: "A" })), 50);
await d.rec.ingest(fx.head);
open();
await pending;
assert.equal(d.rec.head.status, "decoded");
await sync(d); // drain the attempt this head enabled
});
test(`${L.name}: an imported empty container settles without publishing`, async () => {
storage.set(L.lane.storageKey(PK, RELAY), JSON.stringify(L.legacy({})));
const d = device(L.lane);
await sync(d);
assert.equal(fx.published.length, 0);
});
test(`${L.name}: backoff holds recovery, live and reconnect attempts`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 1e12 });
const store = new LaneStore(L.lane, PK, RELAY);
const rec = new LaneReconciler(L.lane, store, PK, RELAY);
fx.publish = "reject";
store.transact((tr) => L.edit(tr, "k1", "A"));
await rec.read();
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1);
fx.publish = "ok";
await rec.read(); // recovery tick at t+0
rec.wake(); // reconnect must not shorten the 5 s backoff
t.mock.timers.tick(4_000);
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 1, "held inside backoff");
t.mock.timers.tick(1_000);
for (let i = 0; i < 5; i++) await settle();
assert.equal(fx.published.length, 2, "published after the deadline");
rec.destroy();
});
test(`${L.name}: a change during the publish wait cancels the send`, async () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
fx.beforeSend = () => d.store.transact((t) => L.edit(t, "k2", "B"));
await sync(d);
assert.equal(fx.published.length, 0);
fx.beforeSend = null;
d.rec.destroy();
});
test(`${L.name}: legacy pubkey key migrates once and stays relay-scoped`, () => {
const legacyKey = L.lane.legacyStorageKey?.(PK);
if (!legacyKey) return;
storage.set(legacyKey, JSON.stringify(L.legacy({ k1: "A" })));
fx.storageFails = true;
const failed = new LaneStore(L.lane, PK, RELAY);
assert.deepEqual(L.view(failed.get()), { k1: "A" });
assert.ok(storage.has(legacyKey), "kept until the scoped write lands");
fx.storageFails = false;
failed.persist(); // same store, e.g. the next recovery tick
assert.ok(!storage.has(legacyKey));
const other = new LaneStore(L.lane, PK, "wss://other.test");
assert.deepEqual(L.view(other.get()), {}, "second relay starts clean");
storage.set(legacyKey, JSON.stringify(L.legacy({ k9: "B" })));
assert.deepEqual(L.view(new LaneStore(L.lane, PK, RELAY).get()), {
k1: "A",
});
});
test(`${L.name}: two tabs converge and go quiet`, () => {
const handlers = [];
window.addEventListener = (_type, h) => handlers.push(h);
const tabs = [0, 1].map(() => new LaneStore(L.lane, PK, RELAY));
for (const tab of tabs) tab.attachCrossTab();
window.addEventListener = () => {};
const key = L.lane.storageKey(PK, RELAY);
const deliver = (to) =>
handlers[to]({ key, newValue: storage.get(key) ?? null });
tabs[0].transact((t) => L.edit(t, "k1", "A"));
const lost = storage.get(key);
tabs[1].transact((t) => L.edit(t, "k2", "B")); // overwrites tab 0's write
handlers[1]({ key, newValue: lost });
deliver(0);
const writes = fx.writes;
let notified = 0;
for (const tab of tabs) tab.subscribe(() => notified++);
deliver(0);
deliver(1);
assert.equal(fx.writes, writes, "no further writes");
assert.equal(notified, 0, "no further notifications");
assert.deepEqual(L.view(tabs[0].get()), { k1: "A", k2: "B" });
assert.equal(canonical(tabs[0].get()), canonical(tabs[1].get()));
});
test(`${L.name}: a byte-identical clone does not write or notify`, () => {
const d = device(L.lane);
d.store.transact((t) => L.edit(t, "k1", "A"));
let notified = 0;
d.store.subscribe(() => notified++);
d.store.transact((t) => ({ ...t }));
assert.equal(notified, 0);
});
}
test("publisher: isCurrent gates the first send and the reconnect retry", async () => {
const sends = [];
let current = true;
const session = {
generation: () => 1,
ownership: () => 1,
pendingEvents: new Map(),
send: async (payload) => {
sends.push(payload);
throw new Error("socket closed");
},
reconnect: async () => {
current = false; // lane changed while reconnecting
return 1;
},
normalizeError: (e) => e,
recoverSocketFailure: (e) => e,
};
Object.assign(globalThis.window, { setTimeout, clearTimeout });
const event = { id: "e1" };
await assert.rejects(
publishSessionEvent(session, event, "t", "s", () => current),
PublishCanceledError,
);
assert.equal(sends.length, 1, "retry never sent");
assert.equal(session.pendingEvents.size, 0);
await assert.rejects(
publishSessionEvent(session, event, "t", "s", () => false),
PublishCanceledError,
);
assert.equal(sends.length, 1, "first send never sent");
});

View File

@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canonical,
compareRegs,
LEGACY_DEV,
mergeTrees,
setRegs,
validTree,
} from "./sidebarLwwMap.ts";
const storage = new Map();
globalThis.window ??= {};
globalThis.window.localStorage = {
getItem: (k) => storage.get(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
};
const A = "a".repeat(16);
const B = "b".repeat(16);
test("compareRegs: version, then device, then tombstone, then value bytes", () => {
assert.ok(compareRegs([2, A, "x"], [1, B, "y"]) > 0);
assert.ok(compareRegs([1, B, "x"], [1, A, "y"]) > 0);
assert.ok(compareRegs([1, A, null], [1, A, "x"]) > 0);
assert.ok(compareRegs([1, A, false], [1, A, true]) > 0);
assert.ok(compareRegs([1, A, "b"], [1, A, "a"]) > 0);
assert.equal(compareRegs([1, A, "a"], [1, A, "a"]), 0);
});
test("mergeTrees: commutative, associative, idempotent", () => {
const x = { s: { k: { name: [1, A, "x"] } }, a: { c1: [5, A, "k"] } };
const y = { s: { k: { name: [2, B, "y"], live: [1, B, true] } } };
const z = { a: { c1: [5, B, null], c2: [3, A, "k"] } };
const xy = mergeTrees(x, y);
assert.equal(canonical(xy), canonical(mergeTrees(y, x)));
assert.equal(
canonical(mergeTrees(mergeTrees(x, y), z)),
canonical(mergeTrees(x, mergeTrees(y, z))),
);
assert.equal(mergeTrees(xy, xy), xy);
assert.deepEqual(xy.s.k.name, [2, B, "y"]);
});
test("mergeTrees: returns the same reference when nothing changes", () => {
const a = { g: { dms: [9, A, "recent"] } };
assert.equal(mergeTrees(a, { g: { dms: [1, B, "alpha"] } }), a);
});
test("mergeTrees onlyMissing: fills absent leaves and never overrides", () => {
const local = { s: { k: { name: [1, A, "mine"] } } };
const legacy = {
s: {
k: { name: [9e12, LEGACY_DEV, "old"] },
n: { name: [9e12, LEGACY_DEV, "new"] },
},
};
const out = mergeTrees(local, legacy, true);
assert.equal(out.s.k.name[2], "mine");
assert.equal(out.s.n.name[2], "new");
});
test("setRegs: identical edit mints no version and returns the same tree", () => {
const t = setRegs({}, [[["g", "dms"], "recent"]]);
assert.equal(setRegs(t, [[["g", "dms"], "recent"]]), t);
});
test("setRegs: new version exceeds every version seen and the install's last", () => {
const future = Date.now() + 1e9;
const t = setRegs({ g: { dms: [future, B, "alpha"] } }, [
[["g", "dms"], "recent"],
]);
assert.equal(t.g.dms[0], future + 1);
const t2 = setRegs({}, [[["g", "x"], "alpha"]]);
assert.ok(t2.g.x[0] > future, "lastIssued persists across documents");
assert.equal(t2.g.x[1], t.g.dms[1], "device id persists per install");
assert.match(t2.g.x[1], /^[0-9a-f]{16}$/);
});
test("setRegs all: writes every leaf, including unchanged ones", () => {
const t = { s: { a: { order: [1, A, 0] }, b: { order: [1, A, 1] } } };
const out = setRegs(
t,
[
[["s", "a", "order"], 0],
[["s", "b", "order"], 0],
],
true,
);
assert.ok(out.s.a.order[0] > 1);
assert.equal(out.s.a.order[0], out.s.b.order[0]);
});
test("validTree: skips invalid records, keeps valid siblings", () => {
const shape = {
s: {
"*": { name: (v) => typeof v === "string", order: Number.isSafeInteger },
},
};
const out = validTree(
{
s: {
ok: { name: [1, A, "n"], order: [1, A, 2] },
badName: { name: [1, A, 7], order: [1, A, 0] },
badDev: { name: [1, "zz", "n"] },
badV: { name: [1.5, A, "n"] },
__proto__: { name: [1, A, "p"] },
},
extra: { x: [1, A, 1] },
},
shape,
);
assert.deepEqual(out, {
s: {
ok: { name: [1, A, "n"], order: [1, A, 2] },
badName: { order: [1, A, 0] },
badDev: {},
badV: {},
},
});
});
test("canonical: key order independent and stable", () => {
assert.equal(
canonical({ b: 1, a: { d: [1], c: null } }),
canonical({ a: { c: null, d: [1] }, b: 1 }),
);
assert.equal(canonical({ a: undefined, b: 1 }), '{"b":1}');
});
test("inherited names are ordinary keys in either arrival order", () => {
const A = "a".repeat(16);
const one = { g: { constructor: [2, A, "recent"] } };
const two = { g: { toString: [3, A, "alpha"] } };
const ab = mergeTrees(mergeTrees({}, one), two);
const ba = mergeTrees(mergeTrees({}, two), one);
assert.equal(canonical(ab), canonical(ba));
assert.deepEqual(Object.keys(ab.g).sort(), ["constructor", "toString"]);
const set = setRegs({ g: {} }, [[["g", "constructor"], "alpha"]]);
assert.equal(set.g.constructor[2], "alpha");
const valid = validTree(JSON.parse(canonical(ab)), {
g: { "*": () => true },
});
assert.equal(canonical(valid), canonical(ab));
});

View File

@@ -0,0 +1,203 @@
/**
* Per-field last-writer-wins registers for synced sidebar preferences.
*
* A document is a tree whose leaves are registers `[v, dev, value]`: `v` is a
* millisecond version, `dev` a random per-install id. Merging keeps, for every
* leaf, the register that sorts highest, so any two devices that have seen the
* same registers hold byte-identical documents regardless of arrival order.
*/
export type Reg<T = unknown> = [v: number, dev: string, value: T];
export type Tree = { [key: string]: Tree | Reg | undefined };
/** Device id stamped on registers imported from meta-less (legacy) data. */
export const LEGACY_DEV = "0".repeat(16);
const CLOCK_KEY = "buzz-sidebar-clock.v1";
const DEV_RE = /^[0-9a-f]{16}$/;
const encoder = new TextEncoder();
function compareBytes(left: string, right: string): number {
const a = encoder.encode(left);
const b = encoder.encode(right);
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i]) return (a[i] as number) - (b[i] as number);
}
return a.length - b.length;
}
const isTombstone = (value: unknown) => value === null || value === false;
/** Orders by `v`, then `dev` bytewise, then tombstone over value, then value JSON bytewise. */
export function compareRegs(a: Reg, b: Reg): number {
if (a[0] !== b[0]) return a[0] - b[0];
if (a[1] !== b[1]) return compareBytes(a[1], b[1]);
const tombA = isTombstone(a[2]);
if (tombA !== isTombstone(b[2])) return tombA ? 1 : -1;
return compareBytes(JSON.stringify(a[2]), JSON.stringify(b[2]));
}
export const isReg = (node: unknown): node is Reg => Array.isArray(node);
/** Own-property read: dynamic keys such as `constructor` never hit the prototype. */
export const own = (node: Tree, key: string): Tree | Reg | undefined =>
Object.hasOwn(node, key) ? node[key] : undefined;
/** True when no register (tombstones included) exists anywhere in `node`. */
export const isEmptyTree = (node: Tree): boolean =>
Object.values(node).every(
(child) => child === undefined || (!isReg(child) && isEmptyTree(child)),
);
/** A register imported from a meta-less local cache (stamp 1); any remote beats it. */
const isCachePlaceholder = (reg: Reg) => reg[0] === 1 && reg[1] === LEGACY_DEV;
/**
* Merges `b` into `a`. With `onlyMissing`, `b` only fills leaves `a` lacks or
* holds as a cache placeholder (legacy import: omission never means deletion,
* and authored registers win). Returns `a` itself when nothing changes.
*/
export function mergeTrees<T extends Tree>(a: T, b: T, onlyMissing = false): T {
let out: Tree | null = null;
for (const [key, bv] of Object.entries(b)) {
const av = own(a, key);
let next = av;
if (av === undefined) next = bv;
else if (bv === undefined) continue;
else if (isReg(av) && isReg(bv)) {
if ((!onlyMissing || isCachePlaceholder(av)) && compareRegs(bv, av) > 0)
next = bv;
} else if (!isReg(av) && !isReg(bv)) {
next = mergeTrees(av, bv, onlyMissing);
}
if (next !== av) {
out ??= { ...a };
out[key] = next;
}
}
return (out ?? a) as T;
}
function maxVersion(node: Tree): number {
let max = 0;
for (const child of Object.values(node)) {
if (child === undefined) continue;
max = Math.max(max, isReg(child) ? child[0] : maxVersion(child));
}
return max;
}
function randomDev(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8));
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
/** `v = max(now, lastIssued + 1, maxSeen + 1)`; `dev` and `lastIssued` persist per install. */
function nextStamp(maxSeen: number): [number, string] {
let dev = randomDev();
let last = 0;
try {
const saved = JSON.parse(window.localStorage.getItem(CLOCK_KEY) ?? "null");
if (DEV_RE.test(saved?.dev) && Number.isSafeInteger(saved?.last)) {
dev = saved.dev;
last = saved.last;
}
} catch {
// Fall back to a fresh clock; ordering still holds via `maxSeen`.
}
const v = Math.max(Date.now(), last + 1, maxSeen + 1);
try {
window.localStorage.setItem(CLOCK_KEY, JSON.stringify({ dev, last: v }));
} catch {
// Non-fatal: the next stamp still exceeds every version in the document.
}
return [v, dev];
}
function getAt(tree: Tree, path: string[]): Tree | Reg | undefined {
let node: Tree | Reg | undefined = tree;
for (const key of path) {
if (node === undefined || isReg(node)) return undefined;
node = own(node, key);
}
return node;
}
function setAt(tree: Tree, [key, ...rest]: string[], reg: Reg): Tree {
const child = own(tree, key as string);
const next =
rest.length === 0
? reg
: setAt(child && !isReg(child) ? child : {}, rest, reg);
return { ...tree, [key as string]: next };
}
/**
* Writes each `[path, value]` as a fresh register sharing one stamp. An edit
* where every target already holds its value mints no version and returns
* `tree` itself. Otherwise only changed leaves are written, or with `all`
* every leaf (a whole-list operation such as a reorder).
*/
export function setRegs<T extends Tree>(
tree: T,
writes: Array<[path: string[], value: unknown]>,
all = false,
): T {
const changed = writes.filter(([path, value]) => {
const current = getAt(tree, path);
return !(
isReg(current) && JSON.stringify(current[2]) === JSON.stringify(value)
);
});
if (changed.length === 0) return tree;
const [v, dev] = nextStamp(maxVersion(tree));
let out: Tree = tree;
for (const [path, value] of all ? writes : changed)
out = setAt(out, path, [v, dev, value]);
return out as T;
}
/** A leaf validator, or a node shape: fixed fields, or `{"*": child}` for maps. */
export type Shape = ((value: unknown) => boolean) | { [key: string]: Shape };
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/**
* Keeps only well-formed nodes of `shape`. An invalid register or record is
* dropped while its valid siblings survive.
*/
export function validTree(node: unknown, shape: Shape): Tree | Reg | undefined {
if (typeof shape === "function") {
return isReg(node) &&
node.length === 3 &&
Number.isSafeInteger(node[0]) &&
node[0] >= 0 &&
typeof node[1] === "string" &&
DEV_RE.test(node[1]) &&
shape(node[2])
? node
: undefined;
}
if (!isObject(node)) return undefined;
const out: Tree = {};
for (const [key, child] of Object.entries(node)) {
if (key === "__proto__") continue;
const sub = Object.hasOwn(shape, key) ? shape[key] : shape["*"];
const valid = sub === undefined ? undefined : validTree(child, sub);
if (valid !== undefined) out[key] = valid;
}
return out;
}
/** Stable key-sorted JSON; the digest and no-op comparison for documents. */
export function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (isObject(value)) {
const keys = Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort(compareBytes);
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}

View File

@@ -14,6 +14,7 @@ import {
} from "./channelMutesStorage";
import { ChannelMuteSyncManager } from "./channelMutesSync";
import type { RemoteMutes } from "./channelMutesSync";
import { useStaleReaderRecovery } from "./useStaleReaderRecovery";
export function useChannelMutes(
pubkey: string | undefined,
@@ -33,6 +34,9 @@ export function useChannelMutes(
const managerRef = React.useRef<ChannelMuteSyncManager | null>(null);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
// Local-mutation revision: incremented on every user edit so an in-flight
// retry fetch that started before the edit is discarded at apply time.
const localRevision = React.useRef(0);
React.useEffect(() => {
if (!pubkey || !relayUrl) {
@@ -75,14 +79,16 @@ export function useChannelMutes(
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
remote.eventId >= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingMutePublish();
const merged = mergeStores(prev, remote.store);
if (!writeChannelMutesStore(pubkey, merged)) return prev;
// Advance the applied head only after the cache write succeeds so a
// failed write leaves the same head retryable on the next tick.
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
return merged;
};
},
@@ -150,6 +156,31 @@ export function useChannelMutes(
};
}, [pubkey, relayUrl, applyRemote]);
// Retry effect: see useStaleReaderRecovery for full behavior contract.
const retryFetch = React.useCallback(
() => managerRef.current?.fetchRemoteMutes(),
[],
);
const retryHasPending = React.useCallback(
() => managerRef.current?.getPendingMuteStore() != null,
[],
);
const retryGetRevision = React.useCallback(() => localRevision.current, []);
const retryMakeUpdater = React.useCallback(
(data: RemoteMutes) => applyRemote(data),
[applyRemote],
);
useStaleReaderRecovery({
enabled: !!pubkey && !!relayUrl,
fetch: retryFetch,
hasPending: retryHasPending,
getRevision: retryGetRevision,
makeUpdater: retryMakeUpdater,
setStore,
pubkey,
relayUrl,
});
// 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(
() => mutedChannelIdsFromStore(store),
@@ -172,6 +203,7 @@ export function useChannelMutes(
channelId,
);
if (!writeChannelMutesStore(pubkey, next)) return prev;
localRevision.current += 1;
managerRef.current?.publishMutes(next);
return next;
});

View File

@@ -1,78 +0,0 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});
after(() => dom.window.close());
test("assignChannel refreshes an existing assignment before the next eviction", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import(
"./channelSectionsStorage.ts"
);
const { useChannelSections } = await import("./useChannelSections.ts");
const originalFetchEvents = relayClient.fetchEvents;
const originalSubscribeLive = relayClient.subscribeLive;
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async () => async () => {};
relayClient.subscribeToReconnects = () => () => {};
const pubkey = "pk-at-capacity";
const relayUrl = "wss://relay.example";
const assignments = Object.fromEntries(
Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [
`chan-${String(index).padStart(4, "0")}`,
"section-1",
]),
);
window.localStorage.setItem(
storageKey(pubkey, relayUrl),
JSON.stringify({
version: 1,
sections: [
{ id: "section-1", name: "One", order: 0 },
{ id: "section-2", name: "Two", order: 1 },
],
assignments,
}),
);
try {
const { result, unmount } = renderHook(() =>
useChannelSections(pubkey, relayUrl),
);
act(() => result.current.assignChannel("chan-0000", "section-2"));
act(() => result.current.assignChannel("chan-new", "section-1"));
assert.equal(result.current.assignments["chan-0000"], "section-2");
assert.equal(result.current.assignments["chan-new"], "section-1");
assert.equal(result.current.assignments["chan-0001"], undefined);
assert.equal(
Object.keys(result.current.assignments).length,
MAX_CHANNEL_SECTION_ASSIGNMENTS,
);
unmount();
} finally {
cleanup();
relayClient.fetchEvents = originalFetchEvents;
relayClient.subscribeLive = originalSubscribeLive;
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});

View File

@@ -1,24 +1,38 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
boundChannelSectionsStore,
DEFAULT_STORE,
readChannelSectionsStore,
storageKey,
writeChannelSectionsStore,
} from "./channelSectionsStorage";
import { ChannelSectionSyncManager } from "./channelSectionsSync";
import type { RemoteSections } from "./channelSectionsSync";
import { swapSectionOrder } from "./channelSectionsHelpers";
maxLiveOrder,
projectSections,
SECTIONS_LANE,
} from "./channelSectionsSync";
import { useLaneSync } from "./sidebarLaneReconciler";
import { own, setRegs, type Tree } from "./sidebarLwwMap";
export type { ChannelSection } from "./channelSectionsStorage";
import type {
ChannelSection,
ChannelSectionStore,
} from "./channelSectionsStorage";
import type { ChannelSection } from "./channelSectionsStorage";
/**
* One whole-list order operation against the current live list: dead IDs are
* dropped, live IDs missing from `requested` keep their relative order at the
* end, and every live section gets a new `order` register.
*/
function writeOrder(tree: Tree, requested: string[]) {
const live = projectSections(tree).sections.map((s) => s.id);
const wanted = new Set(requested.filter((id) => live.includes(id)));
const ids = [...wanted, ...live.filter((id) => !wanted.has(id))];
return setRegs(
tree,
ids.map((id, index) => [["s", id, "order"], index]),
true,
);
}
/**
* Channel sections for one pubkey + relay scope, merged per field across
* devices (see `sidebarLaneReconciler`). Deletes are `live = false`
* tombstones; unassign is an explicit `null` register.
*/
export function useChannelSections(
pubkey: string | undefined,
relayUrl?: string,
@@ -34,313 +48,93 @@ export function useChannelSections(
assignChannel: (channelId: string, sectionId: string) => void;
unassignChannel: (channelId: string) => void;
} {
const [store, setStore] = React.useState<ChannelSectionStore>(() => {
if (!pubkey) {
return DEFAULT_STORE;
}
return readChannelSectionsStore(pubkey, relayUrl);
});
const managerRef = React.useRef<ChannelSectionSyncManager | null>(null);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
return;
}
setStore(readChannelSectionsStore(pubkey, relayUrl));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!pubkey) {
return;
}
const key = storageKey(pubkey, relayUrl);
const handler = (e: StorageEvent) => {
if (e.key !== key) {
return;
}
setStore(readChannelSectionsStore(pubkey, relayUrl));
};
window.addEventListener("storage", handler);
return () => {
window.removeEventListener("storage", handler);
};
}, [pubkey, relayUrl]);
const applyRemote = React.useCallback(
(
remote: RemoteSections,
): ((prev: ChannelSectionStore) => ChannelSectionStore) => {
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingPublish();
if (!writeChannelSectionsStore(pubkey, remote.store, relayUrl))
return prev;
return remote.store;
};
},
[pubkey, relayUrl],
);
React.useEffect(() => {
if (!pubkey || !relayUrl) return;
let cancelled = false;
const local = readChannelSectionsStore(pubkey, relayUrl);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
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;
};
}, [pubkey, relayUrl, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
let cancelled = false;
void managerRef.current
?.subscribeToSections((remote) => {
if (cancelled) return;
setStore(applyRemote(remote));
})
.then((dispose) => {
if (cancelled) {
void dispose();
} else {
unsub = dispose;
}
});
return () => {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteSections().then((result) => {
if (cancelled) return;
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStore();
if (pending) {
managerRef.current?.publishSections(pending);
}
});
});
return () => {
cancelled = true;
unsub();
};
}, [pubkey, applyRemote]);
const sections = React.useMemo<ChannelSection[]>(
() => store.sections.slice().sort((a, b) => a.order - b.order),
[store.sections],
const { tree, edit } = useLaneSync(SECTIONS_LANE, pubkey, relayUrl);
const { sections, assignments } = React.useMemo(
() => projectSections(tree),
[tree],
);
const createSection = React.useCallback(
(name: string, icon?: string): ChannelSection | null => {
if (!pubkey) return null;
const prev = readChannelSectionsStore(pubkey, relayUrl);
const maxOrder =
prev.sections.length > 0
? Math.max(...prev.sections.map((s) => s.order))
: -1;
const section: ChannelSection = {
id: crypto.randomUUID(),
name,
...(icon ? { icon } : {}),
order: maxOrder + 1,
};
setStore((current) => {
const next = boundChannelSectionsStore({
...current,
sections: [...current.sections, section],
});
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current;
managerRef.current?.publishSections(next);
return next;
if (!pubkey || !relayUrl) return null;
const id = crypto.randomUUID();
let order = 0;
edit((prev) => {
order = projectSections(prev).sections.length;
return setRegs(prev, [
[["s", id, "name"], name],
[["s", id, "icon"], icon || null],
[["s", id, "order"], maxLiveOrder(prev) + 1],
[["s", id, "live"], true],
]);
});
return section;
return { id, name, ...(icon ? { icon } : {}), order };
},
[pubkey, relayUrl],
[pubkey, relayUrl, edit],
);
const renameSection = React.useCallback(
(sectionId: string, newName: string, icon?: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const next: ChannelSectionStore = {
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId
? {
id: s.id,
name: newName,
...(icon ? { icon } : {}),
order: s.order,
}
: s,
),
};
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
(sectionId: string, newName: string, icon?: string) =>
edit((prev) =>
setRegs(prev, [
[["s", sectionId, "name"], newName],
[["s", sectionId, "icon"], icon || null],
]),
),
[edit],
);
const deleteSection = React.useCallback(
(sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
for (const channelId of Object.keys(assignments)) {
if (assignments[channelId] === sectionId) {
delete assignments[channelId];
}
}
const next: ChannelSectionStore = {
...prev,
sections: prev.sections.filter((s) => s.id !== sectionId),
assignments,
};
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
);
const moveSectionUp = React.useCallback(
(sectionId: string) => {
if (!pubkey) return;
setStore((prev) => {
const next = swapSectionOrder(prev, sectionId, "up");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
return prev;
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
);
const moveSectionDown = React.useCallback(
(sectionId: string) => {
if (!pubkey) return;
setStore((prev) => {
const next = swapSectionOrder(prev, sectionId, "down");
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
return prev;
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
(sectionId: string) =>
edit((prev) => setRegs(prev, [[["s", sectionId, "live"], false]])),
[edit],
);
const reorderSections = React.useCallback(
(orderedIds: string[]) => {
if (!pubkey) return;
setStore((prev) => {
const sections = prev.sections.map((s) => {
const newOrder = orderedIds.indexOf(s.id);
return newOrder === -1 ? s : { ...s, order: newOrder };
});
const next: ChannelSectionStore = { ...prev, sections };
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
(orderedIds: string[]) => edit((prev) => writeOrder(prev, orderedIds)),
[edit],
);
const moveSection = React.useCallback(
(sectionId: string, step: -1 | 1) =>
edit((prev) => {
const ids = projectSections(prev).sections.map((s) => s.id);
const from = ids.indexOf(sectionId);
const to = from + step;
if (from < 0 || to < 0 || to >= ids.length) return prev;
[ids[from], ids[to]] = [ids[to] as string, sectionId];
return writeOrder(prev, ids);
}),
[edit],
);
const moveSectionUp = React.useCallback(
(sectionId: string) => moveSection(sectionId, -1),
[moveSection],
);
const moveSectionDown = React.useCallback(
(sectionId: string) => moveSection(sectionId, 1),
[moveSection],
);
const assignChannel = React.useCallback(
(channelId: string, sectionId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
assignments[channelId] = sectionId;
const next = boundChannelSectionsStore({
...prev,
assignments,
});
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
(channelId: string, sectionId: string) =>
edit((prev) => setRegs(prev, [[["a", channelId], sectionId]])),
[edit],
);
const unassignChannel = React.useCallback(
(channelId: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const assignments = { ...prev.assignments };
delete assignments[channelId];
const next: ChannelSectionStore = { ...prev, assignments };
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
return prev;
}
managerRef.current?.publishSections(next);
return next;
});
},
[pubkey, relayUrl],
(channelId: string) =>
edit((prev) =>
prev.a && own(prev.a as Tree, channelId)
? setRegs(prev, [[["a", channelId], null]])
: prev,
),
[edit],
);
return {
sections,
assignments: store.assignments,
assignments,
createSection,
renameSection,
deleteSection,

View File

@@ -1,191 +1,44 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import {
boundChannelSortStore,
DEFAULT_STORE,
readChannelSortStore,
sortModeForGroup,
storageKey,
stripOrphanedSectionModes,
writeChannelSortStore,
DEFAULT_SORT_MODE,
type ChannelSortGroupKey,
type ChannelSortMode,
type ChannelSortStore,
} from "./channelSortPreference";
import { ChannelSortSyncManager } from "./channelSortSync";
import type { RemoteSortPrefs } from "./channelSortSync";
import { SORT_LANE } from "./channelSortSync";
import { useLaneSync } from "./sidebarLaneReconciler";
import { isReg, own, setRegs } from "./sidebarLwwMap";
/**
* Persistent per-group sidebar sort preferences, scoped by pubkey + relay so
* they don't bleed across identities or communities (same scoping as channel
* sections). Each sidebar grouping (starred, channels, forums, dms, and each
* custom section) carries its own saved Recent/A–Z mode; unset groups default
* to A–Z. Mirrors changes made in other windows via the storage event.
*
* Preferences sync across clients via encrypted NIP-78 app data (kind 30078,
* d-tag `channel-sort`), following the channel-sections pattern: localStorage
* stays the instant/offline cache, the relay blob is the cross-client source
* of truth, and conflicts resolve with whole-blob last-write-wins.
*
* When `liveSectionIds` is provided, writes also prune `section:<id>` entries
* whose custom section no longer exists, so deleted sections don't leave
* stale keys in localStorage.
* Persistent per-group sidebar sort preferences, scoped by pubkey + relay.
* Each grouping (starred, channels, forums, dms, and each custom section)
* carries its own Recent/A–Z mode; unset groups default to A–Z. Each group
* is its own register, so devices merge per group (see
* `sidebarLaneReconciler`) rather than overwriting the whole blob.
*/
export function useChannelSortPreference(
pubkey: string | undefined,
relayUrl?: string,
liveSectionIds?: string[],
): {
sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode;
setSortModeFor: (group: ChannelSortGroupKey, mode: ChannelSortMode) => void;
} {
const [store, setStore] = React.useState<ChannelSortStore>(() => {
if (!pubkey) return DEFAULT_STORE;
return readChannelSortStore(pubkey, relayUrl);
});
const managerRef = React.useRef<ChannelSortSyncManager | null>(null);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
React.useEffect(() => {
if (!pubkey || !relayUrl) {
setStore(DEFAULT_STORE);
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
return;
}
setStore(readChannelSortStore(pubkey, relayUrl));
lastAppliedRemoteTs.current = 0;
lastAppliedEventId.current = "";
managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl);
return () => {
managerRef.current?.destroy();
managerRef.current = null;
};
}, [pubkey, relayUrl]);
React.useEffect(() => {
if (!pubkey) return;
const key = storageKey(pubkey, relayUrl);
const handler = (e: StorageEvent) => {
if (e.key !== key) return;
setStore(readChannelSortStore(pubkey, relayUrl));
};
window.addEventListener("storage", handler);
return () => {
window.removeEventListener("storage", handler);
};
}, [pubkey, relayUrl]);
const applyRemote = React.useCallback(
(
remote: RemoteSortPrefs,
): ((prev: ChannelSortStore) => ChannelSortStore) => {
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingPublish();
if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev;
return remote.store;
};
},
[pubkey, relayUrl],
);
React.useEffect(() => {
if (!pubkey || !relayUrl) return;
let cancelled = false;
const local = readChannelSortStore(pubkey, relayUrl);
void managerRef.current?.bootstrap(local).then((result) => {
if (cancelled) return;
if (result.action === "apply-remote") {
setStore(applyRemote(result.data));
}
// "hold": seed already performed by bootstrap (if first-sync), or blocked.
});
return () => {
cancelled = true;
};
}, [pubkey, relayUrl, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let unsub: (() => Promise<void>) | null = null;
let cancelled = false;
void managerRef.current
?.subscribeToSortPrefs((remote) => {
if (cancelled) return;
setStore(applyRemote(remote));
})
.then((dispose) => {
if (cancelled) {
void dispose();
} else {
unsub = dispose;
}
});
return () => {
cancelled = true;
if (unsub) void unsub();
};
}, [pubkey, applyRemote]);
React.useEffect(() => {
if (!pubkey) return;
let cancelled = false;
const unsub = relayClient.subscribeToReconnects(() => {
void managerRef.current?.fetchRemoteSortPrefs().then((result) => {
if (cancelled) return;
if (result.status === "found") {
setStore(applyRemote(result.data));
}
const pending = managerRef.current?.getPendingStore();
if (pending) {
managerRef.current?.publishSortPrefs(pending);
}
});
});
return () => {
cancelled = true;
unsub();
};
}, [pubkey, applyRemote]);
const { tree, edit } = useLaneSync(SORT_LANE, pubkey, relayUrl);
const sortModeFor = React.useCallback(
(group: ChannelSortGroupKey) => sortModeForGroup(store, group),
[store],
(group: ChannelSortGroupKey) => {
const reg = tree.g && !isReg(tree.g) ? own(tree.g, group) : undefined;
return isReg(reg) && reg[2] !== null
? (reg[2] as ChannelSortMode)
: DEFAULT_SORT_MODE;
},
[tree],
);
const setSortModeFor = React.useCallback(
(group: ChannelSortGroupKey, mode: ChannelSortMode) => {
if (!pubkey) return;
setStore((prev) => {
const withUpdate: ChannelSortStore = {
...prev,
groups: { ...prev.groups, [group]: mode },
};
// Prune sort modes left behind by deleted custom sections on write so
// the stored map can't grow unboundedly with stale `section:` keys.
const next = boundChannelSortStore(
liveSectionIds
? stripOrphanedSectionModes(withUpdate, liveSectionIds)
: withUpdate,
);
if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev;
managerRef.current?.publishSortPrefs(next);
return next;
});
},
[pubkey, relayUrl, liveSectionIds],
(group: ChannelSortGroupKey, mode: ChannelSortMode) =>
edit((prev) => setRegs(prev, [[["g", group], mode]])),
[edit],
);
return { sortModeFor, setSortModeFor };

View File

@@ -14,6 +14,7 @@ import {
} from "./channelStarsStorage";
import { ChannelStarSyncManager } from "./channelStarsSync";
import type { RemoteStars } from "./channelStarsSync";
import { useStaleReaderRecovery } from "./useStaleReaderRecovery";
export function useChannelStars(
pubkey: string | undefined,
@@ -33,6 +34,9 @@ export function useChannelStars(
const managerRef = React.useRef<ChannelStarSyncManager | null>(null);
const lastAppliedRemoteTs = React.useRef(0);
const lastAppliedEventId = React.useRef("");
// Local-mutation revision: incremented on every user edit so an in-flight
// retry fetch that started before the edit is discarded at apply time.
const localRevision = React.useRef(0);
React.useEffect(() => {
if (!pubkey || !relayUrl) {
@@ -75,14 +79,16 @@ export function useChannelStars(
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
remote.eventId >= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
managerRef.current?.cancelPendingStarPublish();
const merged = mergeStores(prev, remote.store);
if (!writeChannelStarsStore(pubkey, merged)) return prev;
// Advance the applied head only after the cache write succeeds so a
// failed write leaves the same head retryable on the next tick.
lastAppliedRemoteTs.current = remote.createdAt;
lastAppliedEventId.current = remote.eventId;
return merged;
};
},
@@ -150,6 +156,31 @@ export function useChannelStars(
};
}, [pubkey, relayUrl, applyRemote]);
// Retry effect: see useStaleReaderRecovery for full behavior contract.
const retryFetch = React.useCallback(
() => managerRef.current?.fetchRemoteStars(),
[],
);
const retryHasPending = React.useCallback(
() => managerRef.current?.getPendingStarStore() != null,
[],
);
const retryGetRevision = React.useCallback(() => localRevision.current, []);
const retryMakeUpdater = React.useCallback(
(data: RemoteStars) => applyRemote(data),
[applyRemote],
);
useStaleReaderRecovery({
enabled: !!pubkey && !!relayUrl,
fetch: retryFetch,
hasPending: retryHasPending,
getRevision: retryGetRevision,
makeUpdater: retryMakeUpdater,
setStore,
pubkey,
relayUrl,
});
// 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(
() => starredChannelIdsFromStore(store),
@@ -172,6 +203,7 @@ export function useChannelStars(
channelId,
);
if (!writeChannelStarsStore(pubkey, next)) return prev;
localRevision.current += 1;
managerRef.current?.publishStars(next);
return next;
});

View File

@@ -0,0 +1,158 @@
import React from "react";
import type { FetchResult } from "./sidebarSyncWatermark.ts";
/**
* Shared bounded-backoff retry effect for all four sidebar sync hooks.
*
* Polls the relay at 5 → 10 → 30 → 60 s (steady at 60 s) so a stale view
* recovers without an edit-kick or reconnect. Fires immediately on
* `visibilitychange` to "visible" so returning from background converges
* quickly. Single-flight: skips ticks while a fetch is in progress.
*
* **Pending-first guard (apply-time, not response-time):**
* `hasPending()` and the effect lifetime are re-evaluated inside the React
* state updater, after `fetch()` resolves but before `applyRemote` runs.
* This closes the window where a local edit updater queued before the remote
* response could be executed after it: React may execute queued updaters in
* the same batch, so checking only at response time is insufficient.
*
* **Mutation revision guard:**
* A revision counter is snapshotted at the start of each `fetch()` call and
* rechecked inside the updater. If a local mutation landed while the request
* was in flight the response is discarded — no old relay read can roll back a
* successfully published local edit.
*
* Both guards apply inside `makeUpdater(data)`, the lane-supplied function
* that wraps `applyRemote` and `setStore`. The checks happen before
* `applyRemote`'s returned updater runs, so `cancelPending*Publish` inside
* `applyRemote` is never reached on a fenced path.
*
* Residual: a pending edit whose publish permanently fails and is never
* resolved by a reconnect will defer recovery indefinitely. Main today has
* zero recovery in that state, so this is not a regression.
*
* @param enabled Must be true for the effect to activate
* (e.g. `!!pubkey && !!relayUrl`).
* @param fetch Calls the manager's `fetchRemote*()` for this lane.
* @param hasPending Returns true when a local unpublished edit is in
* flight. Called inside the state updater.
* @param getRevision Returns the current local-mutation revision counter.
* Called at fetch start (snapshot) and inside the
* updater (recheck).
* @param makeUpdater Given the fetched data, returns a React state updater
* that applies `applyRemote(data)` to the previous
* store. Called only when both guards pass.
* @param setStore The hook's React state setter; called with the fenced
* updater produced by `makeUpdater`.
* @param pubkey Identity key — forces the recovery effect to restart
* when the active pubkey changes. Pass whenever the
* `makeUpdater` closure does not directly capture pubkey
* (e.g. if `applyRemote` only depends on pubkey but the
* outer `makeUpdater` wraps a stable ref).
* @param relayUrl Relay key — forces the recovery effect to restart when
* the relay changes. Required for stars/mutes whose
* `applyRemote` does not capture relayUrl; without it a
* still-mounted hook switching relay A→B would keep
* applying A's stale fetch after the switch.
*/
export function useStaleReaderRecovery<T, S>({
enabled,
fetch,
hasPending,
getRevision,
makeUpdater,
setStore,
pubkey,
relayUrl,
}: {
enabled: boolean;
fetch: () => Promise<FetchResult<T> | undefined> | undefined;
hasPending: () => boolean;
getRevision: () => number;
makeUpdater: (data: T) => (prev: S) => S;
setStore: React.Dispatch<React.SetStateAction<S>>;
/** Passed to bind the effect lifetime to identity; see `@param pubkey`. */
pubkey?: string | undefined;
/** Passed to bind the effect lifetime to relay; see `@param relayUrl`. */
relayUrl?: string | undefined;
}): void {
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and relayUrl are intentional — they bind the recovery effect lifetime to identity and relay so a still-mounted hook switching relay A→B cancels the old effect (stars/mutes applyRemote does not capture relayUrl in its deps)
React.useEffect(() => {
if (!enabled) return;
let cancelled = false;
let inFlight = false;
const BACKOFF_STEPS = [5_000, 10_000, 30_000, 60_000];
let stepIndex = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
if (cancelled || inFlight) return;
inFlight = true;
// Snapshot the local-mutation revision before the async fetch so we can
// detect a mutation that landed while the request was in flight.
const revisionAtFetch = getRevision();
try {
// A read started while an edit is pending can return the pre-edit
// blob after the edit publishes and pending clears; skip it.
const result = hasPending() ? undefined : await fetch();
if (!cancelled && result?.status === "found") {
const remoteUpdater = makeUpdater(result.data);
// Apply inside a state updater so hasPending() and the cancelled /
// revision guards are evaluated at execution time, not at response
// time. This closes the React-batch window where a local edit
// updater could be queued before this remote updater but execute
// after it.
setStore((prev) => {
if (cancelled) return prev;
if (hasPending()) return prev;
if (getRevision() !== revisionAtFetch) return prev;
return remoteUpdater(prev);
});
}
} finally {
inFlight = false;
}
if (!cancelled) {
const delay = BACKOFF_STEPS[
Math.min(stepIndex, BACKOFF_STEPS.length - 1)
] as number;
stepIndex = Math.min(stepIndex + 1, BACKOFF_STEPS.length - 1);
timer = setTimeout(() => {
void tick();
}, delay);
}
};
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
stepIndex = 0;
void tick();
};
document.addEventListener("visibilitychange", onVisible);
// Kick off the first tick immediately so bootstrap failures are recovered
// without waiting a full backoff interval.
void tick();
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", onVisible);
if (timer !== null) clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
enabled,
fetch,
hasPending,
getRevision,
makeUpdater,
setStore,
pubkey,
relayUrl,
]);
}

View File

@@ -286,7 +286,6 @@ export function AppSidebar({
const { sortModeFor, setSortModeFor } = useChannelSortPreference(
currentPubkey,
activeCommunity?.relayUrl,
sectionIds,
);
const [createSectionState, setCreateSectionState] = React.useState<{

View File

@@ -42,6 +42,7 @@ Date.now = () => fakeNow;
const { RelayClient } = await import("./relayClientSession.ts");
const { invokeTauri } = await import("./tauri.ts");
const { PublishCanceledError } = await import("./relayEventPublisher.ts");
const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
"./relayRateLimitGate.ts"
);
@@ -351,3 +352,25 @@ test("HTTP failure does not clear an existing WS backoff", async () => {
await deliver(client, ["OK", event.id, true, ""]);
assert.equal(await published, event);
});
test("isCurrent forwarded by publishEvent cancels a rate-limited send", async () => {
reset();
activateRateLimit(4);
const client = connectedClient();
const event = { id: "7".repeat(64), kind: 30078 };
let current = true;
const published = client.publishEvent(
event,
"timed out",
"send failed",
() => current,
);
await Promise.resolve();
assert.equal(sendAttempts.length, 0, "waits behind the gate");
current = false;
resetRateLimitGate();
await assert.rejects(published, PublishCanceledError);
assert.equal(sendAttempts.length, 0, "nothing reached the native send");
assert.equal(client.pendingEvents.size, 0);
assert.equal(pendingTimers.size, 0, "no publication timeout armed");
});

View File

@@ -842,6 +842,7 @@ export class RelayClient {
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
isCurrent?: () => boolean,
) {
return publishSessionEvent(
{
@@ -859,6 +860,7 @@ export class RelayClient {
event,
timeoutMessage,
sendErrorMessage,
isCurrent,
);
}

View File

@@ -13,18 +13,32 @@ type PublishSession = {
recoverSocketFailure: (error: unknown, fallback: string) => Error;
};
/** Publish once, with one reconnect retry, without crossing session ownership. */
/** Typed so no relay rejection text can be mistaken for a caller's cancel. */
export class PublishCanceledError extends Error {
constructor() {
super("Relay publish canceled by its caller.");
this.name = "PublishCanceledError";
}
}
/**
* Publish once, with one reconnect retry, without crossing session ownership.
* `isCurrent`, when given, is checked immediately before every socket send; a
* false result rejects with `PublishCanceledError` and sends nothing.
*/
export async function publishSessionEvent(
session: PublishSession,
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
isCurrent?: () => boolean,
): Promise<RelayEvent> {
const publishOwnership = session.ownership();
await waitForRateLimit();
if (publishOwnership !== session.ownership()) {
throw new Error("Relay disconnected for community switch.");
}
if (isCurrent?.() === false) throw new PublishCanceledError();
const publishGeneration = session.generation();
return new Promise<RelayEvent>((resolve, reject) => {
@@ -65,6 +79,12 @@ export async function publishSessionEvent(
"Relay publish was superseded by a session change.",
);
}
if (isCurrent?.() === false) {
window.clearTimeout(timeout);
session.pendingEvents.delete(event.id);
reject(new PublishCanceledError());
return;
}
await session.send(["EVENT", event], retryGeneration);
} catch (retryError) {
if (session.pendingEvents.get(event.id) !== pendingEvent) return;

View File

@@ -178,7 +178,10 @@ test.describe("per-group channel sort", () => {
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { channels: "recent" } });
expect(stored).toMatchObject({
version: 1,
groups: { channels: "recent" },
});
// Survives reload.
await page.reload();
@@ -246,6 +249,6 @@ test.describe("per-group channel sort", () => {
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
}, SORT_STORAGE_KEY);
expect(stored).toEqual({ version: 1, groups: { dms: "recent" } });
expect(stored).toMatchObject({ version: 1, groups: { dms: "recent" } });
});
});