feat: implement production-grade REQ state machine with per-relay tracking

Core Infrastructure:
- Add ReqRelayState and ReqOverallState types for granular state tracking
- Implement deriveOverallState() state machine with 8 query states
- Create useReqTimelineEnhanced hook combining RelayStateManager + event tracking
- Add comprehensive unit tests (27 tests, all passing)

State Machine Logic:
- DISCOVERING: NIP-65 relay selection in progress
- CONNECTING: Waiting for first relay connection
- LOADING: Initial events loading
- LIVE: Streaming with active relays (only when actually connected!)
- PARTIAL: Some relays ok, some failed/disconnected
- OFFLINE: All relays disconnected after being live
- CLOSED: Query completed, all relays closed
- FAILED: All relays failed to connect

UI Updates:
- Single-word status indicators with detailed tooltips
- Condensed relay status into NIP-65 section (no duplicate lists)
- Per-relay subscription state badges (RECEIVING, EOSE, ERROR, OFFLINE)
- Event counts per relay
- Connection + Auth status integrated into single dropdown

Fixes Critical Bug:
- Solves "LIVE with 0 relays" issue (Scenario 5 from analysis)
- Distinguishes real EOSE from relay disconnections
- Accurate status for all 7 edge cases documented in analysis

Technical Approach:
- Hybrid: RelayStateManager for connections + event._relay for tracking
- Works around applesauce-relay catchError bug without forking
- No duplicate subscriptions
- Production-quality error handling

Tests: 27/27 passing including edge case scenarios
This commit is contained in:
Claude
2025-12-22 16:18:15 +00:00
parent bebb4ed834
commit c60abe6df4
5 changed files with 1343 additions and 127 deletions

View File

@@ -18,7 +18,7 @@ import {
Send,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { useGrimoire } from "@/core/state";
import { useRelayState } from "@/hooks/useRelayState";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
@@ -69,6 +69,13 @@ import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import {
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
getRelayStateBadge,
} from "@/lib/req-state-machine";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
@@ -739,7 +746,7 @@ export default function ReqViewer({
// Streaming is the default behavior, closeOnEose inverts it
const stream = !closeOnEose;
const { events, loading, error, eoseReceived } = useReqTimeline(
const { events, loading, error, eoseReceived, relayStates: reqRelayStates, overallState } = useReqTimelineEnhanced(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
resolvedFilter,
finalRelays,
@@ -915,48 +922,23 @@ export default function ReqViewer({
{/* Compact Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Status Indicator */}
<div className="flex items-center gap-2">
<Radio
className={`size-3 ${
relaySelectionPhase !== "ready"
? "text-yellow-500 animate-pulse"
: loading && eoseReceived && stream
? "text-green-500 animate-pulse"
: loading && !eoseReceived
? "text-yellow-500 animate-pulse"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500 animate-pulse"
}`}
/>
<span
className={`${
relaySelectionPhase !== "ready"
? "text-yellow-500"
: loading && eoseReceived && stream
? "text-green-500"
: loading && !eoseReceived
? "text-yellow-500"
: eoseReceived
? "text-muted-foreground"
: "text-yellow-500"
} font-semibold`}
>
{relaySelectionPhase === "discovering"
? "DISCOVERING RELAYS"
: relaySelectionPhase === "selecting"
? "SELECTING RELAYS"
: loading && eoseReceived && stream
? "LIVE"
: loading && !eoseReceived && events.length === 0
? "CONNECTING"
: loading && !eoseReceived
? "LOADING"
: eoseReceived
? "CLOSED"
: "CONNECTING"}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
<Radio
className={`size-3 ${getStatusColor(overallState.status)} ${
shouldAnimate(overallState.status) ? "animate-pulse" : ""
}`}
/>
<span className={`${getStatusColor(overallState.status)} font-semibold`}>
{getStatusText(overallState)}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getStatusTooltip(overallState)}</p>
</TooltipContent>
</Tooltip>
{/* Right: Stats */}
<div className="flex items-center gap-3">
@@ -991,7 +973,7 @@ export default function ReqViewer({
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Wifi className="size-3" />
<span>
{connectedCount}/{finalRelays.length}
{overallState.connectedCount}/{overallState.totalRelays}
</span>
</button>
</DropdownMenuTrigger>
@@ -999,58 +981,9 @@ export default function ReqViewer({
align="end"
className="w-80 max-h-96 overflow-y-auto"
>
{/* Connection Status */}
<div className="py-1 border-b border-border">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Connection Status
</div>
{relayStatesForReq.map(({ url, state }) => {
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<DropdownMenuItem
key={url}
className="flex items-center justify-between gap-2"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 hover:bg-transparent"
iconClassname="size-3"
urlClassname="text-xs"
/>
<div
className="flex items-center gap-1.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</DropdownMenuItem>
);
})}
</div>
{/* Relay Selection */}
{!relays && reasoning && reasoning.length > 0 && (
{/* Relay Status (condensed: connection + subscription + NIP-65) */}
{!relays && reasoning && reasoning.length > 0 ? (
/* NIP-65 Relay Selection with status */
<div className="py-2">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Relay Selection
@@ -1071,38 +1004,182 @@ export default function ReqViewer({
)}
</div>
{/* Flat list of relays with icons and counts */}
{/* Relay list with connection, subscription, and NIP-65 info */}
<div className="px-3 py-1 space-y-1">
{reasoning.map((r, i) => (
<div
key={i}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={r.relay}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-2 flex-shrink-0 text-muted-foreground">
{r.readers.length > 0 && (
<div className="flex items-center gap-0.5">
<Mail className="w-3 h-3" />
<span>{r.readers.length}</span>
</div>
)}
{r.writers.length > 0 && (
<div className="flex items-center gap-0.5">
<Send className="w-3 h-3" />
<span>{r.writers.length}</span>
</div>
)}
{r.isFallback && (
<span className="text-[10px] text-muted-foreground/60">
fallback
</span>
)}
{reasoning.map((r, i) => {
const globalState = relayStates[r.relay];
const reqState = reqRelayStates.get(r.relay);
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
const badge = reqState ? getRelayStateBadge(reqState) : null;
return (
<div
key={i}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={r.relay}
showInboxOutbox={false}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-1.5 flex-shrink-0 text-muted-foreground">
{/* Event count */}
{reqState && reqState.eventCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-0.5">
<FileText className="size-3" />
<span className="text-[10px]">{reqState.eventCount}</span>
</div>
</TooltipTrigger>
<TooltipContent>
{reqState.eventCount} events received
</TooltipContent>
</Tooltip>
)}
{/* Subscription state badge */}
{badge && (
<span className={`text-[10px] ${badge.color}`}>
{badge.text}
</span>
)}
{/* NIP-65 inbox/outbox indicators */}
{r.readers.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-0.5">
<Mail className="w-3 h-3" />
<span className="text-[10px]">{r.readers.length}</span>
</div>
</TooltipTrigger>
<TooltipContent>
Inbox relay for {r.readers.length} author{r.readers.length !== 1 ? 's' : ''}
</TooltipContent>
</Tooltip>
)}
{r.writers.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-0.5">
<Send className="w-3 h-3" />
<span className="text-[10px]">{r.writers.length}</span>
</div>
</TooltipTrigger>
<TooltipContent>
Outbox relay for {r.writers.length} author{r.writers.length !== 1 ? 's' : ''}
</TooltipContent>
</Tooltip>
)}
{/* Fallback indicator */}
{r.isFallback && (
<span className="text-[10px] text-muted-foreground/60">
fallback
</span>
)}
{/* Auth icon */}
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
{/* Connection icon */}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
) : (
/* Explicit relays: show simple status list */
<div className="py-1">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
Relay Status
</div>
<div className="px-3 py-1 space-y-1">
{finalRelays.map((url) => {
const globalState = relayStates[url];
const reqState = reqRelayStates.get(url);
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
const badge = reqState ? getRelayStateBadge(reqState) : null;
return (
<div
key={url}
className="flex items-center gap-2 text-xs py-0.5"
>
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-1.5 flex-shrink-0 text-muted-foreground">
{/* Event count */}
{reqState && reqState.eventCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-0.5">
<FileText className="size-3" />
<span className="text-[10px]">{reqState.eventCount}</span>
</div>
</TooltipTrigger>
<TooltipContent>
{reqState.eventCount} events received
</TooltipContent>
</Tooltip>
)}
{/* Subscription state badge */}
{badge && (
<span className={`text-[10px] ${badge.color}`}>
{badge.text}
</span>
)}
{/* Auth icon */}
{authIcon && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
)}
{/* Connection icon */}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
)}

View File

@@ -0,0 +1,265 @@
import { useState, useEffect, useMemo, useRef } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore } from "applesauce-react/hooks";
import { isNostrEvent } from "@/lib/type-guards";
import { useStableValue, useStableArray } from "./useStable";
import { useRelayState } from "./useRelayState";
import type { ReqRelayState, ReqOverallState } from "@/types/req-state";
import { deriveOverallState } from "@/lib/req-state-machine";
interface UseReqTimelineEnhancedOptions {
limit?: number;
stream?: boolean;
}
interface UseReqTimelineEnhancedReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
// Enhanced state tracking
relayStates: Map<string, ReqRelayState>;
overallState: ReqOverallState;
}
/**
* Enhanced REQ timeline hook with per-relay state tracking
*
* This hook extends the original useReqTimeline with accurate per-relay
* state tracking and overall status derivation. It solves the "LIVE with 0 relays"
* bug by tracking connection state and event counts separately per relay.
*
* Architecture:
* - Uses pool.subscription() for event streaming (with deduplication)
* - Syncs connection state from RelayStateManager
* - Tracks events per relay via event._relay metadata
* - Derives overall state from individual relay states
*
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter(s)
* @param relays - Array of relay URLs
* @param options - Stream mode, limit, etc.
*/
export function useReqTimelineEnhanced(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineEnhancedOptions = { limit: 50 },
): UseReqTimelineEnhancedReturn {
const eventStore = useEventStore();
const { limit, stream = false } = options;
// Core state (compatible with original useReqTimeline)
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
const [eventsMap, setEventsMap] = useState<Map<string, NostrEvent>>(
new Map(),
);
// Enhanced: Per-relay state tracking
const [relayStates, setRelayStates] = useState<Map<string, ReqRelayState>>(
new Map(),
);
const queryStartedAt = useRef<number>(Date.now());
// Get global relay connection states from RelayStateManager
const { relays: globalRelayStates } = useRelayState();
// Sort events by created_at (newest first)
const events = useMemo(() => {
return Array.from(eventsMap.values()).sort(
(a, b) => b.created_at - a.created_at,
);
}, [eventsMap]);
// Stabilize inputs to prevent unnecessary re-renders
const stableFilters = useStableValue(filters);
const stableRelays = useStableArray(relays);
// Initialize relay states when relays change
useEffect(() => {
queryStartedAt.current = Date.now();
const initialStates = new Map<string, ReqRelayState>();
for (const url of relays) {
initialStates.set(url, {
url,
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
});
}
setRelayStates(initialStates);
}, [stableRelays]);
// Sync connection states from RelayStateManager
// This runs whenever globalRelayStates updates
useEffect(() => {
setRelayStates((prev) => {
const next = new Map(prev);
let changed = false;
for (const [url, state] of prev) {
const globalState = globalRelayStates[url];
if (
globalState &&
globalState.connectionState !== state.connectionState
) {
next.set(url, {
...state,
connectionState: globalState.connectionState as any,
connectedAt: globalState.lastConnected,
disconnectedAt: globalState.lastDisconnected,
});
changed = true;
}
}
return changed ? next : prev;
});
}, [globalRelayStates]);
// Subscribe to events
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
return;
}
console.log("REQ Enhanced: Starting query", {
relays,
filters,
limit,
stream,
});
setLoading(true);
setError(null);
setEoseReceived(false);
setEventsMap(new Map());
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore,
});
const subscription = observable.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string") {
console.log("REQ Enhanced: EOSE received");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
// Mark all connected relays as having received EOSE
// Note: We can't tell which specific relay sent EOSE due to
// applesauce-relay's catchError bug that converts errors to EOSE.
// We mark all connected relays as a best-effort approximation.
setRelayStates((prev) => {
const next = new Map(prev);
let changed = false;
for (const [url, state] of prev) {
if (
state.connectionState === "connected" &&
state.subscriptionState !== "eose"
) {
next.set(url, {
...state,
subscriptionState: "eose",
eoseAt: Date.now(),
});
changed = true;
}
}
return changed ? next : prev;
});
} else if (isNostrEvent(response)) {
// Event received - store and track per relay
const event = response as NostrEvent & { _relay?: string };
const relayUrl = event._relay;
// Store in EventStore and local map
eventStore.add(event);
setEventsMap((prev) => {
const next = new Map(prev);
next.set(event.id, event);
return next;
});
// Update relay state for this specific relay
if (relayUrl) {
setRelayStates((prev) => {
const state = prev.get(relayUrl);
if (!state) return prev;
const now = Date.now();
const next = new Map(prev);
next.set(relayUrl, {
...state,
subscriptionState: "receiving",
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? now,
lastEventAt: now,
});
return next;
});
}
} else {
console.warn("REQ Enhanced: Unexpected response type:", response);
}
},
(err: Error) => {
console.error("REQ Enhanced: Error", err);
setError(err);
setLoading(false);
},
() => {
// Observable completed
if (!stream) {
setLoading(false);
}
},
);
return () => {
subscription.unsubscribe();
};
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// Derive overall state from individual relay states
const overallState = useMemo(() => {
return deriveOverallState(
relayStates,
eoseReceived,
stream,
queryStartedAt.current,
);
}, [relayStates, eoseReceived, stream]);
return {
events: events || [],
loading,
error,
eoseReceived,
relayStates,
overallState,
};
}

View File

@@ -0,0 +1,539 @@
import { describe, it, expect } from "vitest";
import {
deriveOverallState,
getStatusText,
getStatusTooltip,
getStatusColor,
shouldAnimate,
getRelayStateBadge,
} from "./req-state-machine";
import type { ReqRelayState } from "@/types/req-state";
describe("deriveOverallState", () => {
const queryStartedAt = Date.now();
describe("discovering state", () => {
it("should return discovering when no relays", () => {
const state = deriveOverallState(new Map(), false, false, queryStartedAt);
expect(state.status).toBe("discovering");
expect(state.totalRelays).toBe(0);
});
});
describe("connecting state", () => {
it("should return connecting when relays pending with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "pending",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
expect(state.hasReceivedEvents).toBe(false);
expect(state.hasActiveRelays).toBe(false);
});
it("should return connecting when relays connecting with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connecting",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("connecting");
});
});
describe("failed state", () => {
it("should return failed when all relays error with no events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
expect(state.errorCount).toBe(2);
});
});
describe("loading state", () => {
it("should return loading when connected but no EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
firstEventAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(true);
expect(state.hasActiveRelays).toBe(true);
expect(state.receivingCount).toBe(1);
});
it("should return loading when waiting for events", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, false, false, queryStartedAt);
expect(state.status).toBe("loading");
expect(state.hasReceivedEvents).toBe(false);
expect(state.connectedCount).toBe(1);
});
});
describe("live state", () => {
it("should return live when EOSE + streaming + connected", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
eoseAt: Date.now(),
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.hasActiveRelays).toBe(true);
expect(state.eoseCount).toBe(1);
});
it("should return live with multiple connected relays", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("live");
expect(state.connectedCount).toBe(2);
});
});
describe("offline state", () => {
it("should return offline when all disconnected after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
expect(state.hasActiveRelays).toBe(false);
expect(state.hasReceivedEvents).toBe(true);
expect(state.disconnectedCount).toBe(2);
});
it("should return offline when all errored after EOSE in streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "error",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("offline");
});
});
describe("partial state", () => {
it("should return partial when some relays ok, some failed after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.connectedCount).toBe(1);
expect(state.errorCount).toBe(1);
});
it("should return partial when some disconnected after EOSE", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.disconnectedCount).toBe(1);
});
});
describe("closed state", () => {
it("should return closed when EOSE + not streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
it("should return closed when all relays disconnected after EOSE non-streaming", () => {
const relays = new Map<string, ReqRelayState>([
[
"wss://relay1.com",
{
url: "wss://relay1.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 10,
},
],
[
"wss://relay2.com",
{
url: "wss://relay2.com",
connectionState: "disconnected",
subscriptionState: "eose",
eventCount: 5,
},
],
]);
const state = deriveOverallState(relays, true, false, queryStartedAt);
expect(state.status).toBe("closed");
});
});
describe("edge cases from analysis", () => {
it("Scenario 1: All relays disconnect immediately", () => {
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 10; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, false, true, queryStartedAt);
expect(state.status).toBe("failed");
expect(state.allRelaysFailed).toBe(true);
});
it("Scenario 5: Streaming mode with gradual disconnections (THE BUG)", () => {
// Start with all relays connected and receiving
const relays = new Map<string, ReqRelayState>();
for (let i = 0; i < 30; i++) {
relays.set(`wss://relay${i}.com`, {
url: `wss://relay${i}.com`,
connectionState: "disconnected", // All disconnected
subscriptionState: "eose",
eventCount: 5, // Had events before
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
// Should be OFFLINE not LIVE
expect(state.status).toBe("offline");
expect(state.connectedCount).toBe(0);
expect(state.totalRelays).toBe(30);
expect(state.hasReceivedEvents).toBe(true);
});
it("Scenario 3: Mixed success/failure", () => {
const relays = new Map<string, ReqRelayState>();
// 10 succeed with EOSE
for (let i = 0; i < 10; i++) {
relays.set(`wss://success${i}.com`, {
url: `wss://success${i}.com`,
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
}
// 15 disconnect
for (let i = 0; i < 15; i++) {
relays.set(`wss://disconnect${i}.com`, {
url: `wss://disconnect${i}.com`,
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
}
// 5 error
for (let i = 0; i < 5; i++) {
relays.set(`wss://error${i}.com`, {
url: `wss://error${i}.com`,
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
}
const state = deriveOverallState(relays, true, true, queryStartedAt);
expect(state.status).toBe("partial");
expect(state.totalRelays).toBe(30);
expect(state.connectedCount).toBe(10);
expect(state.disconnectedCount).toBe(15);
expect(state.errorCount).toBe(5);
});
});
});
describe("getStatusText", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should return correct text for each status", () => {
expect(
getStatusText({ ...baseState, status: "discovering" }),
).toBe("DISCOVERING");
expect(
getStatusText({ ...baseState, status: "connecting" }),
).toBe("CONNECTING");
expect(getStatusText({ ...baseState, status: "loading" })).toBe("LOADING");
expect(getStatusText({ ...baseState, status: "live" })).toBe("LIVE");
expect(getStatusText({ ...baseState, status: "partial" })).toBe("PARTIAL");
expect(getStatusText({ ...baseState, status: "offline" })).toBe("OFFLINE");
expect(getStatusText({ ...baseState, status: "closed" })).toBe("CLOSED");
expect(getStatusText({ ...baseState, status: "failed" })).toBe("FAILED");
});
});
describe("getStatusTooltip", () => {
const baseState = {
totalRelays: 5,
connectedCount: 3,
receivingCount: 2,
eoseCount: 1,
errorCount: 0,
disconnectedCount: 0,
hasReceivedEvents: true,
hasActiveRelays: true,
allRelaysFailed: false,
queryStartedAt: Date.now(),
};
it("should provide detailed tooltips", () => {
const discovering = getStatusTooltip({
...baseState,
status: "discovering",
});
expect(discovering).toContain("NIP-65");
const loading = getStatusTooltip({ ...baseState, status: "loading" });
expect(loading).toContain("3/5");
const live = getStatusTooltip({ ...baseState, status: "live" });
expect(live).toContain("Streaming");
expect(live).toContain("3/5");
const offline = getStatusTooltip({ ...baseState, status: "offline" });
expect(offline).toContain("disconnected");
});
});
describe("getStatusColor", () => {
it("should return correct colors for each status", () => {
expect(getStatusColor("discovering")).toBe("text-yellow-500");
expect(getStatusColor("connecting")).toBe("text-yellow-500");
expect(getStatusColor("loading")).toBe("text-yellow-500");
expect(getStatusColor("live")).toBe("text-green-500");
expect(getStatusColor("partial")).toBe("text-yellow-500");
expect(getStatusColor("closed")).toBe("text-muted-foreground");
expect(getStatusColor("offline")).toBe("text-red-500");
expect(getStatusColor("failed")).toBe("text-red-500");
});
});
describe("shouldAnimate", () => {
it("should animate active states", () => {
expect(shouldAnimate("discovering")).toBe(true);
expect(shouldAnimate("connecting")).toBe(true);
expect(shouldAnimate("loading")).toBe(true);
expect(shouldAnimate("live")).toBe(true);
});
it("should not animate terminal states", () => {
expect(shouldAnimate("partial")).toBe(false);
expect(shouldAnimate("closed")).toBe(false);
expect(shouldAnimate("offline")).toBe(false);
expect(shouldAnimate("failed")).toBe(false);
});
});
describe("getRelayStateBadge", () => {
it("should return receiving badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "receiving",
eventCount: 5,
});
expect(badge?.text).toBe("RECEIVING");
expect(badge?.color).toBe("text-green-500");
});
it("should return eose badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "eose",
eventCount: 10,
});
expect(badge?.text).toBe("EOSE");
expect(badge?.color).toBe("text-blue-500");
});
it("should return error badge", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "error",
subscriptionState: "error",
eventCount: 0,
});
expect(badge?.text).toBe("ERROR");
expect(badge?.color).toBe("text-red-500");
});
it("should return offline badge for disconnected", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "disconnected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge?.text).toBe("OFFLINE");
expect(badge?.color).toBe("text-muted-foreground");
});
it("should return null for connected waiting state", () => {
const badge = getRelayStateBadge({
url: "wss://relay.com",
connectionState: "connected",
subscriptionState: "waiting",
eventCount: 0,
});
expect(badge).toBeNull();
});
});

View File

@@ -0,0 +1,244 @@
import type {
ReqRelayState,
ReqOverallState,
ReqOverallStatus,
} from "@/types/req-state";
/**
* Derive overall query status from individual relay states
*
* This function implements the core state machine logic that determines
* the overall status of a REQ subscription based on the states of individual
* relays. It handles edge cases like all-relays-disconnected, partial failures,
* and distinguishes between CLOSED and OFFLINE states.
*
* @param relayStates - Map of relay URLs to their current states
* @param overallEoseReceived - Whether the group subscription emitted EOSE
* @param isStreaming - Whether this is a streaming subscription (stream=true)
* @param queryStartedAt - Timestamp when the query started
* @returns Aggregated state for the entire query
*/
export function deriveOverallState(
relayStates: Map<string, ReqRelayState>,
overallEoseReceived: boolean,
isStreaming: boolean,
queryStartedAt: number,
): ReqOverallState {
const states = Array.from(relayStates.values());
// Count relay states
const totalRelays = states.length;
const connectedCount = states.filter(
(s) => s.connectionState === "connected",
).length;
const receivingCount = states.filter(
(s) => s.subscriptionState === "receiving",
).length;
const eoseCount = states.filter(
(s) => s.subscriptionState === "eose",
).length;
const errorCount = states.filter(
(s) => s.connectionState === "error",
).length;
const disconnectedCount = states.filter(
(s) => s.connectionState === "disconnected",
).length;
// Calculate flags
const hasReceivedEvents = states.some((s) => s.eventCount > 0);
const hasActiveRelays = connectedCount > 0;
const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays;
const allDisconnected =
totalRelays > 0 && disconnectedCount + errorCount === totalRelays;
// Timing
const firstEventAt = states
.map((s) => s.firstEventAt)
.filter((t): t is number => t !== undefined)
.sort((a, b) => a - b)[0];
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
// Derive status based on relay states and flags
const status: ReqOverallStatus = (() => {
// No relays selected yet (NIP-65 discovery in progress)
if (totalRelays === 0) {
return "discovering";
}
// All relays failed to connect, no events received
if (allRelaysFailed && !hasReceivedEvents) {
return "failed";
}
// No relays connected and no events received yet
if (!hasActiveRelays && !hasReceivedEvents) {
return "connecting";
}
// Had events and EOSE, but all relays disconnected now
if (allDisconnected && hasReceivedEvents && overallEoseReceived) {
if (isStreaming) {
return "offline"; // Was live, now offline
} else {
return "closed"; // Completed and closed (expected)
}
}
// EOSE not received yet, still loading initial data
if (!overallEoseReceived) {
return "loading";
}
// EOSE received, but some relays have issues (check this before "live")
if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) {
if (hasActiveRelays) {
return "partial"; // Some working, some not
} else {
return "offline"; // All disconnected after EOSE
}
}
// EOSE received, streaming mode, all relays healthy and connected
if (overallEoseReceived && isStreaming && hasActiveRelays) {
return "live";
}
// EOSE received, not streaming, all done
if (overallEoseReceived && !isStreaming) {
return "closed";
}
// Default fallback (should rarely hit this)
return "loading";
})();
return {
status,
totalRelays,
connectedCount,
receivingCount,
eoseCount,
errorCount,
disconnectedCount,
hasReceivedEvents,
hasActiveRelays,
allRelaysFailed,
queryStartedAt,
firstEventAt,
allEoseAt,
};
}
/**
* Get user-friendly status text for display
*/
export function getStatusText(state: ReqOverallState): string {
switch (state.status) {
case "discovering":
return "DISCOVERING";
case "connecting":
return "CONNECTING";
case "loading":
return "LOADING";
case "live":
return "LIVE";
case "partial":
return "PARTIAL";
case "offline":
return "OFFLINE";
case "closed":
return "CLOSED";
case "failed":
return "FAILED";
}
}
/**
* Get detailed status description for tooltips
*/
export function getStatusTooltip(state: ReqOverallState): string {
const { status, connectedCount, totalRelays, hasReceivedEvents } = state;
switch (status) {
case "discovering":
return "Selecting optimal relays using NIP-65";
case "connecting":
return `Connecting to ${totalRelays} relay${totalRelays !== 1 ? "s" : ""}...`;
case "loading":
return hasReceivedEvents
? `Loading events from ${connectedCount}/${totalRelays} relays`
: `Waiting for events from ${connectedCount}/${totalRelays} relays`;
case "live":
return `Streaming live events from ${connectedCount}/${totalRelays} relays`;
case "partial":
return `${connectedCount}/${totalRelays} relays active, some failed or disconnected`;
case "offline":
return "All relays disconnected. Showing cached results.";
case "closed":
return "Query completed, all relays closed";
case "failed":
return `Failed to connect to any of ${totalRelays} relays`;
}
}
/**
* Get status indicator color class
*/
export function getStatusColor(status: ReqOverallStatus): string {
switch (status) {
case "discovering":
case "connecting":
case "loading":
return "text-yellow-500";
case "live":
return "text-green-500";
case "partial":
return "text-yellow-500";
case "closed":
return "text-muted-foreground";
case "offline":
case "failed":
return "text-red-500";
}
}
/**
* Should the status indicator pulse/animate?
*/
export function shouldAnimate(status: ReqOverallStatus): boolean {
return ["discovering", "connecting", "loading", "live"].includes(status);
}
/**
* Get relay subscription state badge text
*/
export function getRelayStateBadge(
relay: ReqRelayState,
): { text: string; color: string } | null {
const { subscriptionState, connectionState } = relay;
// Prioritize subscription state
if (subscriptionState === "receiving") {
return { text: "RECEIVING", color: "text-green-500" };
}
if (subscriptionState === "eose") {
return { text: "EOSE", color: "text-blue-500" };
}
if (subscriptionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
// Show connection state if not connected
if (connectionState === "connecting") {
return { text: "CONNECTING", color: "text-yellow-500" };
}
if (connectionState === "error") {
return { text: "ERROR", color: "text-red-500" };
}
if (connectionState === "disconnected") {
return { text: "OFFLINE", color: "text-muted-foreground" };
}
return null;
}

91
src/types/req-state.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Types for REQ subscription state tracking
*
* Provides per-relay and overall state for REQ subscriptions to enable
* accurate status indicators that distinguish between EOSE, disconnection,
* timeout, and error states.
*/
/**
* Connection state from RelayStateManager
*/
export type RelayConnectionState =
| "pending" // Not yet attempted
| "connecting" // Connection in progress
| "connected" // WebSocket connected
| "disconnected" // Disconnected (expected or unexpected)
| "error"; // Connection error
/**
* Subscription state specific to this REQ
*/
export type RelaySubscriptionState =
| "waiting" // Connected but no events yet
| "receiving" // Events being received
| "eose" // EOSE received (real or timeout)
| "error"; // Subscription error
/**
* Per-relay state for a single REQ subscription
*/
export interface ReqRelayState {
url: string;
// Connection state (from RelayStateManager)
connectionState: RelayConnectionState;
// Subscription state (tracked by enhanced hook)
subscriptionState: RelaySubscriptionState;
// Event tracking
eventCount: number;
firstEventAt?: number;
lastEventAt?: number;
// Timing
connectedAt?: number;
eoseAt?: number;
disconnectedAt?: number;
// Error handling
errorMessage?: string;
errorType?: "connection" | "protocol" | "timeout" | "auth";
}
/**
* Overall query state derived from individual relay states
*/
export type ReqOverallStatus =
| "discovering" // Selecting relays (NIP-65)
| "connecting" // Waiting for first relay to connect
| "loading" // Loading initial events
| "live" // Streaming after EOSE, relays connected
| "partial" // Some relays ok, some failed
| "closed" // All relays completed and closed
| "failed" // All relays failed
| "offline"; // All relays disconnected after being live
/**
* Aggregated state for the entire query
*/
export interface ReqOverallState {
status: ReqOverallStatus;
// Relay counts
totalRelays: number;
connectedCount: number;
receivingCount: number;
eoseCount: number;
errorCount: number;
disconnectedCount: number;
// Timing
queryStartedAt: number;
firstEventAt?: number;
allEoseAt?: number;
// Flags
hasReceivedEvents: boolean;
hasActiveRelays: boolean;
allRelaysFailed: boolean;
}