Files
buzz/desktop/src/features/sidebar/lib/channelSortPreference.ts
Duncan b539b74ca2 test(sidebar-sync): address Thufir pass-1 CRITICAL+IMPORTANT findings
CRITICAL 1 (C1): restored replay must carry original queuedAt age.
  - WholeBlobSyncManager.publish() now takes restoredQueuedAt param;
    pendingRestoredQueuedAt drives the adopt-guard in
    fetchOwnBlobBeforePublish (publish when remote.createdAt <=
    restoredQueuedAt; adopt when strictly newer). writeOutbox called
    with original queuedAt so on-disk stamp is never reminted.
  - channelSectionsSync/channelSortSync publishSections/publishSortPrefs
    accept and thread restoredQueuedAt through to publish().
  - channelSectionsStorage/channelSortPreference writeOutbox signatures
    updated to accept optional nowSecs override.
  - Hook-layer tests in runWholeBlobCarlSuite use the actual React hook
    (renderHook), driving the real bootstrap .then() callback, outbox
    read, queuedAt, and shouldReplay guard.

CRITICAL 2 (C2): successful-bootstrap replay needs a baseline.
  - bootstrap() snapshots the fetched head to this.bootstrapResultHead
    before returning. publish(_, true) uses canonicalMax(current,
    bootstrapResultHead) so a no-prior-pending restored replay gets the
    correct baseline from the immutable bootstrap snapshot rather than
    the mutable lastRemoteHead (which may carry a suppressed live peer
    head H102 that must remain a genuine advance).
  - New runWholeBlobC2Suite (manager layer) drives publish(_, true,
    undefined) so C1's queuedAt guard is disabled and only C2's
    bootstrapResultHead mechanism can prevent the adopt.

CRITICAL 3 (C3): confirmRetainedHead decrypt gap.
  - For a foreign winner, lastRemoteHead is advanced only AFTER
    decryptAndParse succeeds (mirrors fetchRemoteBlob fix). A click
    arriving during the confirm decrypt gap now sees lastRemoteHead as
    {0,""} (or the pre-confirm value) and correctly adopts the foreign
    winner rather than publishing over it.
  - New runWholeBlobC3Suite (manager layer) gates the first decrypt call
    and injects a click during the gap; asserts publishCalls === 1.

CRITICAL 4 (C4 / P3-reclaim): reclaimSubsumedOutbox drops preservedKey.
  - mergeLaneStorage.shared.ts reclaimSubsumedOutbox signature updated
    to accept optional preservedKey; calls isSubsumedBy(record.store,
    head, record.preservedKey) so a capacity-bounded proof can never
    evict the reserved channel and falsely certify retention.
  - New P3-reclaim tests (already in mergeLaneSyncCarl.shared.test.mjs)
    exercise 500-entry relay head + foreign outbox with clicked channel
    absent; mutation (drop preservedKey) → record deleted → test red.

IMPORTANT: test coverage.
  - P1/C1 hook tests now drive renderHook with the real outbox read,
    queuedAt threading, shouldReplay guard, and bootstrap .then()
    callback (not manager.publishReplay directly).
  - P3-ack fixture clock set to t=1s so clicked channel is the
    lexicographically evicted entry at 501-entry capacity, making the
    pendingPreservedKey-drop mutation genuinely causal (2 red).
  - All 6 named mutations verified red on exactly their tests, green
    on fixed source: C1 (2r), C2 (2r), C3 (2r), C4/P3-reclaim (2r),
    P3-ack (2r), P2a-1 (2r), P2b (2r). 57/57 pass on fixed source.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-09-02 21:15:55 -04:00

295 lines
9.4 KiB
TypeScript

import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import type { Channel } from "@/shared/api/types";
import {
clearOwnOutbox,
markLegacyConsumed,
reclaimOutbox,
resumeWholeBlobOutbox,
writeOwnOutbox,
} from "./sidebarSyncWatermark";
const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1";
export const MAX_CHANNEL_SORT_GROUPS = 104;
export type ChannelSortMode = "alpha" | "recent";
/**
* Key identifying a sidebar grouping that carries its own sort preference.
* Fixed groups use their name; custom sections use `section:<sectionId>`.
*/
export type ChannelSortGroupKey =
| "starred"
| "channels"
| "forums"
| "dms"
| `section:${string}`;
export type ChannelSortStore = {
version: 1;
groups: Record<string, ChannelSortMode>;
};
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}`;
}
/**
* Returns the localStorage key for the sidebar channel sort preferences.
*
* When `relayUrl` is provided the key is scoped to that relay (normalized via
* the same `normalizeRelayUrl` used by all relay-scoped local stores) so
* preferences don't bleed across communities/relays.
*/
export function storageKey(pubkey: string, relayUrl?: string): string {
if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`;
const normalized = normalizeRelayUrl(relayUrl);
// Encode the normalized relay so it can't contain the `:` delimiter.
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]) };
}
export function parseChannelSortPayload(
json: unknown,
): ChannelSortStore | null {
if (typeof json !== "object" || json === null) return null;
const obj = json as Record<string, unknown>;
if (obj.version !== 1) return null;
const groups: Record<string, ChannelSortMode> =
typeof obj.groups === "object" &&
obj.groups !== null &&
!Array.isArray(obj.groups)
? Object.fromEntries(
Object.entries(obj.groups as Record<string, unknown>).filter(
(entry): entry is [string, ChannelSortMode] =>
entry[1] === "alpha" || entry[1] === "recent",
),
)
: {};
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;
}
}
const OUTBOX_KEY_PREFIX = "buzz-channel-sort-outbox.v1";
// The single shared key written by builds before the outbox was keyed
// per-window. Enumerated as one more record so an edit persisted by a prior
// build still resumes, and reclaimed by the same relay-gated rule.
function legacyOutboxKey(pubkey: string, relayUrl: string): string {
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
/**
* Persist this window's unpublished sort edit under its own outbox key. Written
* synchronously on every edit as a single unconditional `setItem` (no shared-
* key read-modify-write); resumed on next mount so an edit made <2s before
* quit/community-switch is never dropped. `queuedAt` stamps the write so resume
* replays only the newest queued blob (whole-blob LWW).
*/
export function writeChannelSortOutbox(
pubkey: string,
store: ChannelSortStore,
relayUrl: string,
nowSecs?: number,
): boolean {
return writeOwnOutbox(
OUTBOX_KEY_PREFIX,
pubkey,
relayUrl,
boundChannelSortStore(store),
nowSecs,
);
}
/**
* The whole-blob outbox record to resume on boot, or null when none exists.
* Whole-blob LWW: only the max-`queuedAt` record is replayed. Returns the
* winning store plus, when that winner is a not-yet-consumed legacy blob, the
* raw string the caller marks consumed (via `markChannelSortLegacyConsumed`)
* once it has durably re-queued the intent — the legacy key is never deleted,
* so this one-shot marker is what stops it republishing above the head forever.
*/
export function readChannelSortOutbox(
pubkey: string,
relayUrl: string,
): {
store: ChannelSortStore;
legacyRawToConsume: string | null;
queuedAt: number;
} | null {
return resumeWholeBlobOutbox(
OUTBOX_KEY_PREFIX,
legacyOutboxKey(pubkey, relayUrl),
pubkey,
relayUrl,
parseChannelSortPayload,
);
}
/**
* Mark a replayed legacy sort blob consumed so it is not resumed again on a
* later boot. Call only AFTER the intent is durably held in this window's own
* v2 key (its synchronous publish path), so a crash before this write replays
* the legacy blob once more rather than losing it.
*/
export function markChannelSortLegacyConsumed(
pubkey: string,
relayUrl: string,
raw: string,
): void {
markLegacyConsumed(OUTBOX_KEY_PREFIX, pubkey, relayUrl, raw);
}
/** Clear this window's own outbox key (its edit published or is a no-op). */
export function clearChannelSortOutbox(pubkey: string, relayUrl: string): void {
clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl);
}
/**
* Reclaim foreign outbox keys the relay head itself STRICTLY supersedes: a
* whole-blob record queued strictly before the durable head's `created_at`
* (`queuedAt` < `headCreatedAt`) lost LWW to a blob the relay already holds, so
* dropping it matches the relay's own resolution. A same-second record
* (`queuedAt` == `headCreatedAt`) is kept — one-second clock granularity cannot
* prove it lost, so it drains only when a strictly-newer head lands. A record
* queued after the head is live intent and is likewise kept. Records are
* write-once so the delete needs no recheck; never touches this window's own
* keys or the legacy shared key. Call only after a successful fetch.
*/
export function reclaimSupersededSortOutbox(
pubkey: string,
relayUrl: string,
headCreatedAt: number,
): void {
reclaimOutbox(
OUTBOX_KEY_PREFIX,
legacyOutboxKey(pubkey, relayUrl),
pubkey,
relayUrl,
parseChannelSortPayload,
(record) => record.queuedAt < headCreatedAt,
);
}
export function sortModeForGroup(
store: ChannelSortStore,
group: ChannelSortGroupKey,
): ChannelSortMode {
return store.groups[group] ?? DEFAULT_SORT_MODE;
}
function channelRecencyMs(channel: Channel): number | null {
if (!channel.lastMessageAt) return null;
const ms = Date.parse(channel.lastMessageAt);
return Number.isFinite(ms) ? ms : null;
}
function compareCodeUnits(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
export function compareChannelsByName(left: Channel, right: Channel): number {
return (
compareCodeUnits(left.name.toLowerCase(), right.name.toLowerCase()) ||
compareCodeUnits(left.id, right.id)
);
}
/**
* Sorts a single sidebar grouping's channels by the selected mode.
*
* `alpha` orders by name (id tie-breaker). `recent` orders by last message
* time, newest first; channels without any message activity sink to the
* bottom in alphabetical order so quiet channels stay stable and findable.
*/
export function sortChannelsForSidebar(
channels: Channel[],
mode: ChannelSortMode,
): Channel[] {
if (mode === "alpha") {
return [...channels].sort(compareChannelsByName);
}
return [...channels].sort((left, right) => {
const leftMs = channelRecencyMs(left);
const rightMs = channelRecencyMs(right);
if (leftMs !== null && rightMs !== null && leftMs !== rightMs) {
return rightMs - leftMs;
}
if (leftMs !== null && rightMs === null) return -1;
if (leftMs === null && rightMs !== null) return 1;
return compareChannelsByName(left, right);
});
}