mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
572
desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs
Normal file
572
desktop/src/features/sidebar/lib/sidebarLaneHooks.test.mjs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
387
desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts
Normal file
387
desktop/src/features/sidebar/lib/sidebarLaneReconciler.ts
Normal 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 = () => {};
|
||||
158
desktop/src/features/sidebar/lib/sidebarLaneStore.ts
Normal file
158
desktop/src/features/sidebar/lib/sidebarLaneStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
643
desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs
Normal file
643
desktop/src/features/sidebar/lib/sidebarLaneSync.test.mjs
Normal 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");
|
||||
});
|
||||
147
desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs
Normal file
147
desktop/src/features/sidebar/lib/sidebarLwwMap.test.mjs
Normal 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));
|
||||
});
|
||||
203
desktop/src/features/sidebar/lib/sidebarLwwMap.ts
Normal file
203
desktop/src/features/sidebar/lib/sidebarLwwMap.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
158
desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts
Normal file
158
desktop/src/features/sidebar/lib/useStaleReaderRecovery.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -286,7 +286,6 @@ export function AppSidebar({
|
||||
const { sortModeFor, setSortModeFor } = useChannelSortPreference(
|
||||
currentPubkey,
|
||||
activeCommunity?.relayUrl,
|
||||
sectionIds,
|
||||
);
|
||||
|
||||
const [createSectionState, setCreateSectionState] = React.useState<{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user