Files
buzz/desktop/src/features/channels/readState/useReadState.ts
Duncan f6a12809dc fix(readState): round-3 v4 conformance — typed outcomes, markAt, restart-safe rollback, auto-retry, non-reentrant drain
Six items from Thufir's pass-2 report:

1. Typed DrainOutcome union (applied-read | silent-inactive | applied-unread |
   genuine-refusal) with exhaustive switch in the hook layer, eliminating the
   stringly-typed seam that let already_inactive fall outside both branches.

2. already_inactive emits silent-inactive outcome so the hook's source-cleanup
   callback fires; committed forced-entry ownership is now removed exactly
   rather than left stale after a successful queued read.

3. Click-time markAt threaded through applyOverrideRead → markChannelRead →
   PendingIntent.readTarget; drain advances frontier to that exact target
   before C-bump so pre-ready reads don't lose position.

4. priorForcedEntry persisted in PendingIntent (and in the v2 blob); genuine-
   refusal DrainOutcome carries it; hook uses the persisted value when the
   in-memory snapshot map is empty (post-restart rollback is now byte-for-byte
   exact).

5. initialize() ending with an incomplete verdict schedules the first backoff
   timer (1 s, then 2 s, 4 s … 60 s via retryLoad's own loop) so pending
   intents eventually drain without needing an external reconnect signal.
   New witness: initialize_incompleteVerdict_schedulesAutomaticRetry.

6. Non-reentrant outcome commit: after onDrainOutcome fires, drain re-checks
   generation; if a newer intent was enqueued inside the callback, all in-
   memory effects (register, receipt, publishable, extraSlots) are rolled back
   and the loop continues without consuming the newer intent.
   drain_compareDeletePreservesNewerIntent strengthened with zero-stale-effects
   assertion.

Also: fix noExplicitAny in readStateStorage.ts (priorForcedEntry now typed as
ForcedUnreadEntry); ratchet trimmed to keep useUnreadChannels.ts at limit.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-08 11:53:40 -04:00

231 lines
7.2 KiB
TypeScript

import * as React from "react";
import {
ReadStateManager,
type ContextParentResolver,
type MarkResult,
type ReadStateProjection,
} from "@/features/channels/readState/readStateManager";
import type { OverrideLiveness } from "@/features/channels/readState/readStateFormat";
import type { RelayClient } from "@/shared/api/relayClientSession";
import { pendingOverrideIntentStore } from "@/features/channels/pendingOverrideIntents";
import type { DrainOutcome } from "@/features/channels/readState/readStateDrain";
import type { ForcedUnreadEntry } from "@/features/channels/forcedUnreadStore";
const noopGetTimestamp = () => null;
const noopMarkRead = () => {};
const noopDrainAdvances = (): ReadonlySet<string> => new Set<string>();
const noopSetResolver = () => {};
const noopGetOverrideLiveness = (): OverrideLiveness | null => null;
const noopGetProjection = (): ReadStateProjection | null => null;
const noopSetOnDrainOutcome = (): void => {};
/**
* Enqueue a mark-unread intent with no active manager (no relay client or no
* pubkey). The intent is presentation-only — it will be consumed by
* `computePreReadyUnread` so the channel appears unread during loading, and
* swept / replaced when a real manager hydrates from storage. Returns the
* tri-state `queued` result so `applyOverrideUnread` keeps the forced entry.
*/
function noopMarkChannelUnread(
channelId: string,
_priorForcedEntry?: ForcedUnreadEntry,
): MarkResult {
pendingOverrideIntentStore.enqueue(channelId, "unread");
return { status: "queued" };
}
/**
* Enqueue a mark-read intent with no active manager (no relay client or no
* pubkey). The intent is presentation-only — `computePreReadyUnread` will
* suppress the channel from the unread set while loading. Returns `queued`.
*/
function noopMarkChannelRead(
channelId: string,
sourceScope?: string,
_markAt?: number,
): MarkResult {
pendingOverrideIntentStore.enqueue(channelId, "read", sourceScope, undefined);
return { status: "queued" };
}
/**
* React hook that creates and manages a ReadStateManager instance.
* Returns no-op functions when pubkey or relayClient are not available.
*/
export function useReadState(
pubkey: string | undefined,
relayClient: RelayClient | undefined,
) {
const [readStateVersion, forceUpdate] = React.useReducer(
(x: number) => x + 1,
0,
);
const [initializedPubkey, setInitializedPubkey] = React.useState<
string | null
>(null);
const managerRef = React.useRef<ReadStateManager | null>(null);
// Create/destroy manager when pubkey becomes available/changes
React.useEffect(() => {
setInitializedPubkey(null);
if (!pubkey || !relayClient) return;
let isCancelled = false;
const manager = new ReadStateManager(pubkey, relayClient);
managerRef.current = manager;
const unsubscribe = manager.subscribe(() => {
forceUpdate();
});
void manager.initialize().finally(() => {
if (!isCancelled) {
setInitializedPubkey(pubkey);
}
});
return () => {
isCancelled = true;
unsubscribe();
manager.destroy();
managerRef.current = null;
};
}, [pubkey, relayClient]);
const getEffectiveTimestamp = React.useCallback(
(contextId: string): number | null => {
return managerRef.current?.getEffectiveTimestamp(contextId) ?? null;
},
[],
);
const getOwnTimestamp = React.useCallback(
(contextId: string): number | null => {
return managerRef.current?.getOwnTimestamp(contextId) ?? null;
},
[],
);
const markContextRead = React.useCallback(
(contextId: string, unixTimestamp: number): void => {
managerRef.current?.markContextRead(contextId, unixTimestamp);
},
[],
);
const seedContextRead = React.useCallback(
(contextId: string, unixTimestamp: number): void => {
managerRef.current?.seedContextRead(contextId, unixTimestamp);
},
[],
);
const drainSyncedAdvances = React.useCallback((): ReadonlySet<string> => {
return managerRef.current?.drainSyncedAdvances() ?? new Set<string>();
}, []);
const setContextParentResolver = React.useCallback(
(resolver: ContextParentResolver | null): void => {
managerRef.current?.setContextParentResolver(resolver);
},
[],
);
// Override APIs for the NIP-RS manual-unread layer (slice 3 seam).
const markChannelUnread = React.useCallback(
(channelId: string, priorForcedEntry?: ForcedUnreadEntry): MarkResult => {
return (
managerRef.current?.markChannelUnread(channelId, priorForcedEntry) ?? {
status: "refused",
reason: "load_incomplete",
}
);
},
[],
);
const markChannelRead = React.useCallback(
(channelId: string, sourceScope?: string, markAt?: number): MarkResult => {
return (
managerRef.current?.markChannelRead(channelId, sourceScope, markAt) ?? {
status: "refused",
reason: "load_incomplete",
}
);
},
[],
);
const getOverrideLiveness = React.useCallback(
(channelId: string): OverrideLiveness | null => {
return managerRef.current?.getOverrideLiveness(channelId) ?? null;
},
[],
);
const getProjection = React.useCallback((): ReadStateProjection | null => {
return managerRef.current?.getProjection() ?? null;
}, []);
// Wire the drain outcome callback into the current manager.
// Called by useUnreadChannels to route rollback/cleanup through the hook layer.
const setOnDrainOutcome = React.useCallback(
(callback: ((outcome: DrainOutcome) => void) | null): void => {
if (managerRef.current) {
managerRef.current.onDrainOutcome = callback;
}
},
[],
);
const isReady = Boolean(
pubkey && relayClient && initializedPubkey === pubkey,
);
// loadComplete is true only when the manager's full-state enumeration has
// terminated with a complete verdict. Distinct from isReady (initialized):
// a manager can be initialized with an incomplete load.
const isLoadComplete =
isReady && (managerRef.current?.getProjection().loadComplete ?? false);
if (!pubkey || !relayClient) {
return {
getEffectiveTimestamp: noopGetTimestamp,
isReady: false,
isLoadComplete: false,
markContextRead: noopMarkRead,
seedContextRead: noopMarkRead,
drainSyncedAdvances: noopDrainAdvances,
setContextParentResolver: noopSetResolver,
readStateVersion: 0,
getOwnTimestamp: noopGetTimestamp,
// Enqueue intents even without a manager so computePreReadyUnread can
// apply the pending-read suppression / pending-unread assertion rules.
// Intents enqueued here are presentation-only and will be swept when a
// real manager initializes and calls restoreFromStorage().
markChannelUnread: noopMarkChannelUnread,
markChannelRead: noopMarkChannelRead,
getOverrideLiveness: noopGetOverrideLiveness,
getProjection: noopGetProjection,
setOnDrainOutcome: noopSetOnDrainOutcome,
};
}
return {
getEffectiveTimestamp,
isReady,
isLoadComplete,
markContextRead,
seedContextRead,
drainSyncedAdvances,
setContextParentResolver,
readStateVersion,
getOwnTimestamp,
markChannelUnread,
markChannelRead,
getOverrideLiveness,
getProjection,
setOnDrainOutcome,
};
}