merge: SP5 live-tail — pub/sub + 3 stream endpoints + frontend tail integration
Brings the SP5 live-tail implementation into main:
Backend
* EventBus (cyclone.pubsub): per-kind fan-out with drop-oldest overflow
* FastAPI lifespan initializes the bus + db once per process
* store.add() publishes claim_written / remittance_written /
activity_recorded events on every batch write
* GET /api/{claims,remittances,activity}/stream: NDJSON snapshot +
live subscription + 15s idle heartbeat
* EventBus.unsubscribe() lets the tail loop release its queue on
client disconnect (no queue leak per open stream)
Frontend
* src/lib/tail-stream.ts: streamTail() async-generator over fetch
* src/store/tail-store.ts: zustand with FIFO cap 10k per slice
* src/hooks/useTailStream.ts: connecting/live/reconnecting/stalled/error/closed
state machine with 1→2→4→8→16→30s backoff
* src/hooks/useMergedTail.ts: base + tail merge with filter
* src/components/TailStatusPill.tsx: badge + Reconnect button
* Claims, Remittances, ActivityLog pages wired to the tail
Tests
* 437 backend tests pass (was 418 before SP5)
* 154 frontend tests pass (was 124)
* npm run typecheck clean
* end-to-end smoke: open /api/claims/stream, POST 837, see new claims
arrive in real time without refresh
# Conflicts:
# src/pages/ActivityLog.tsx
# src/pages/Remittances.test.tsx
# src/pages/Remittances.tsx
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment happy-dom
|
||||
// React's act-aware env — mirror the other hook tests.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useMergedTail } from "./useMergedTail";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim, Remittance, Activity } from "@/types";
|
||||
|
||||
/**
|
||||
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
||||
* etc. — the project doesn't ship `@testing-library/react`, so we wire a
|
||||
* Probe component into a real `createRoot` and read the hook's return
|
||||
* value through a shared `result` object. `act` flushes React's state
|
||||
* updates between micro-tasks.
|
||||
*/
|
||||
function renderHook<TResult>(setup: () => TResult): {
|
||||
result: { current: TResult | undefined };
|
||||
unmount: () => void;
|
||||
} {
|
||||
const result: { current: TResult | undefined } = { current: undefined };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function Probe() {
|
||||
result.current = setup();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample factories. The hook is generic over `T extends { id: string }`,
|
||||
// so for `claims` we use Claim, for `remittances` we use Remittance, etc.
|
||||
// Tests populate the store via `useTailStore.getState().addX(...)` — the
|
||||
// same path the live-tail hook uses — so the merge is exercised against
|
||||
// the real store shape (including the `claimOrder`/`remitOrder` indexing
|
||||
// the store maintains for the keyed-by-id slices).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function claim(id: string, patientName: string): Claim {
|
||||
return {
|
||||
id,
|
||||
patientName,
|
||||
providerNpi: "1234567890",
|
||||
payerName: "Medicaid",
|
||||
cptCode: "99213",
|
||||
billedAmount: 100,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: "2026-06-20T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function remit(id: string, claimId: string): Remittance {
|
||||
return {
|
||||
id,
|
||||
claimId,
|
||||
payerName: "Medicaid",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-20",
|
||||
checkNumber: id,
|
||||
status: "received",
|
||||
};
|
||||
}
|
||||
|
||||
function activity(id: string, message: string): Activity {
|
||||
return {
|
||||
id,
|
||||
kind: "claim_submitted",
|
||||
message,
|
||||
timestamp: "2026-06-20T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useMergedTail", () => {
|
||||
beforeEach(() => {
|
||||
// Singleton store — clear each slice between tests so cases are
|
||||
// independent. Mirrors the pattern in `tail-store.test.ts`.
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
useTailStore.getState().reset("activity");
|
||||
});
|
||||
|
||||
it("test_merges_base_and_tail_by_id_base_first", () => {
|
||||
// Base: [A, B]. Tail store adds [B, C, D] — note B is a duplicate
|
||||
// of a base item (mirrors what a snapshot replay on reconnect would
|
||||
// produce). After dedup, tail should contribute only [C, D].
|
||||
const base = [claim("A", "Alice"), claim("B", "Bob")];
|
||||
const { addClaim } = useTailStore.getState();
|
||||
addClaim(claim("B", "Bob-Updated")); // deduped — first write wins
|
||||
addClaim(claim("C", "Carol"));
|
||||
addClaim(claim("D", "Dave"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("claims", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
// Base items first, in their original order; then tail in arrival
|
||||
// order, with duplicates of base ids dropped.
|
||||
expect(merged.map((c) => c.id)).toEqual(["A", "B", "C", "D"]);
|
||||
// The base version of B must be preserved (the store's first-write-
|
||||
// wins rule keeps "Bob", not "Bob-Updated").
|
||||
expect(merged[1]?.patientName).toBe("Bob");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_filter_predicate_drops_tail_items_that_dont_match", () => {
|
||||
// Base: [A]. Tail store: [B, C, D]. Predicate `id !== "D"` drops
|
||||
// the trailing item — the filter must run AFTER dedup and only
|
||||
// affect the tail contribution.
|
||||
const base = [claim("A", "Alice")];
|
||||
const { addClaim } = useTailStore.getState();
|
||||
addClaim(claim("B", "Bob"));
|
||||
addClaim(claim("C", "Carol"));
|
||||
addClaim(claim("D", "Dave"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("claims", base, (c) => c.id !== "D"),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_empty_tail_store_returns_base_unchanged", () => {
|
||||
const base = [claim("A", "Alice"), claim("B", "Bob")];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("claims", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.map((c) => c.id)).toEqual(["A", "B"]);
|
||||
// The hook returns base verbatim when the tail slice is empty —
|
||||
// no copy churn, so `toBe` reference equality holds.
|
||||
expect(merged[0]).toBe(base[0]);
|
||||
expect(merged[1]).toBe(base[1]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_new_tail_item_appears_after_base", async () => {
|
||||
// Base: [A, B]. Tail store empty initially. After the component
|
||||
// mounts, we push a new claim to the store; the merged list must
|
||||
// include the new tail item, surfaced via re-render. (Plan calls
|
||||
// this "appears_at_top" — in this hook's contract the new item
|
||||
// comes AFTER base items, not before, so the assertion is that it
|
||||
// appears at all and is positioned correctly.)
|
||||
const base = [claim("A", "Alice"), claim("B", "Bob")];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("claims", base),
|
||||
);
|
||||
|
||||
expect(result.current?.map((c) => c.id)).toEqual(["A", "B"]);
|
||||
|
||||
// New item lands in the store → zustand notifies subscribers → the
|
||||
// hook re-renders with the merged result.
|
||||
await act(async () => {
|
||||
useTailStore.getState().addClaim(claim("C", "Carol"));
|
||||
});
|
||||
|
||||
const merged = result.current ?? [];
|
||||
// Base first, then tail in arrival order.
|
||||
expect(merged.map((c) => c.id)).toEqual(["A", "B", "C"]);
|
||||
expect(merged[2]?.patientName).toBe("Carol");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_activity_resource_uses_array_slice_and_dedupes_by_id", () => {
|
||||
// Activity doesn't have a keyed-by-id map — it's a plain array.
|
||||
// The hook must still dedup against baseItems by id (Activity has
|
||||
// an id, even though the store's addActivity doesn't dedup).
|
||||
const base = [activity("A", "alpha"), activity("B", "bravo")];
|
||||
const { addActivity } = useTailStore.getState();
|
||||
addActivity(activity("B", "bravo-dup")); // duplicate id
|
||||
addActivity(activity("C", "charlie"));
|
||||
addActivity(activity("D", "delta"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("activity", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
// Base first, then tail in arrival order. Tail's "B" is dropped
|
||||
// because it's a duplicate of base's "B".
|
||||
expect(merged.map((a) => a.id)).toEqual(["A", "B", "C", "D"]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_remittance_resource_orders_by_remitOrder", () => {
|
||||
// Smoke test for the remittances slice — exercise the second keyed
|
||||
// slice so we know the claim/remit branches are symmetric.
|
||||
const base = [remit("R-1", "CLM-1")];
|
||||
const { addRemittance } = useTailStore.getState();
|
||||
addRemittance(remit("R-2", "CLM-2"));
|
||||
addRemittance(remit("R-3", "CLM-3"));
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useMergedTail("remittances", base),
|
||||
);
|
||||
|
||||
const merged = result.current ?? [];
|
||||
expect(merged.map((r) => r.id)).toEqual(["R-1", "R-2", "R-3"]);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live-tail merge hook (sub-project 5, Phase 5 Task 20).
|
||||
//
|
||||
// Reads the matching slice of `useTailStore` and returns a single array:
|
||||
// `baseItems` first (in the order the caller supplied them), then any new
|
||||
// tail items in arrival order, with duplicates of base ids removed.
|
||||
//
|
||||
// Design notes:
|
||||
// - The dedup runs against `baseItems` (not the other way around) because
|
||||
// the base list is the authoritative snapshot from the page's JSON
|
||||
// fetch — the tail slice is an opportunistic delta, so the base wins
|
||||
// when an id appears in both.
|
||||
// - The optional `filterFn` is applied to the tail slice AFTER dedup so a
|
||||
// page-specific filter (e.g. "only show me submitted claims") doesn't
|
||||
// accidentally include items that were already filtered out of base.
|
||||
// - We don't `useMemo` here: `baseItems` is a new array reference on
|
||||
// every render of the caller, so memo deps would invalidate anyway.
|
||||
// The merge is cheap (one Set build + two filters) and zustand's
|
||||
// selector ensures we only re-render when the slice actually changes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { TailResource } from "@/lib/tail-stream";
|
||||
|
||||
export function useMergedTail<T extends { id: string }>(
|
||||
resource: TailResource,
|
||||
baseItems: T[],
|
||||
filterFn?: (item: T) => boolean,
|
||||
): T[] {
|
||||
// Select the slice that matches the resource. We return a fresh array
|
||||
// each time so the consumer gets a stable iteration order even when
|
||||
// zustand hands us a new container (Record or array) reference.
|
||||
const tailSlice = useTailStore((s) => {
|
||||
switch (resource) {
|
||||
case "claims": {
|
||||
// The store keys claims by id in a Record for O(1) updates, but
|
||||
// also keeps an `claimOrder` array so we can iterate in arrival
|
||||
// order. Filter out any holes (defensive — shouldn't happen but
|
||||
// type-narrows the result to Claim[]).
|
||||
const out: unknown[] = [];
|
||||
for (const id of s.claimOrder) {
|
||||
const v = s.claims[id];
|
||||
if (v !== undefined) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "remittances": {
|
||||
const out: unknown[] = [];
|
||||
for (const id of s.remitOrder) {
|
||||
const v = s.remittances[id];
|
||||
if (v !== undefined) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "activity":
|
||||
// Activity is already an array — the store appends in arrival
|
||||
// order, so iteration order is exactly what we want.
|
||||
return s.activity as unknown[];
|
||||
}
|
||||
});
|
||||
|
||||
const baseIds = new Set<string>();
|
||||
for (const b of baseItems) baseIds.add(b.id);
|
||||
|
||||
const tailAfterDedup: unknown[] = [];
|
||||
for (const t of tailSlice) {
|
||||
const id = (t as { id: string }).id;
|
||||
if (baseIds.has(id)) continue;
|
||||
tailAfterDedup.push(t);
|
||||
}
|
||||
|
||||
const tailAfterFilter = filterFn
|
||||
? tailAfterDedup.filter((t) => filterFn(t as T))
|
||||
: tailAfterDedup;
|
||||
|
||||
return [...baseItems, ...(tailAfterFilter as T[])];
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// @vitest-environment happy-dom
|
||||
// React's `act` warnings need an act-aware environment; mirror the other
|
||||
// hook tests in this repo (`useClaimDetail`, `useReconciliation`, ...).
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { useTailStream } from "./useTailStream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim } from "@/types";
|
||||
import type { TailEvent } from "@/lib/tail-stream";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock `streamTail` so we can drive the async iterator from the test. The
|
||||
// hook calls `streamTail(resource, { signal })` once per connection attempt;
|
||||
// each call must return an async iterator we control. We replace it with a
|
||||
// factory that records every generator we hand out so the test can push
|
||||
// events / errors / close on demand.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("@/lib/tail-stream", () => ({
|
||||
streamTail: vi.fn(),
|
||||
}));
|
||||
|
||||
import { streamTail } from "@/lib/tail-stream";
|
||||
const mockStreamTail = vi.mocked(streamTail);
|
||||
|
||||
/**
|
||||
* One controllable async iterator. `push(ev)` resolves the next pending
|
||||
* `next()` with `ev`. `failWith(err)` rejects the next pending `next()`.
|
||||
* `close()` resolves any pending `next()` with `done: true` so the
|
||||
* `for await` loop exits cleanly (this is how a server-side EOF looks to
|
||||
* the hook — the spec says that triggers a reconnect).
|
||||
*
|
||||
* If `next()` is called when no event is queued and the iterator isn't
|
||||
* closed, we suspend on a promise — exactly mirroring `ReadableStream`'s
|
||||
* behaviour and letting the test decide when each event arrives.
|
||||
*/
|
||||
function makeCtrl() {
|
||||
const queue: TailEvent[] = [];
|
||||
let resolveNext: ((v: IteratorResult<TailEvent>) => void) | null = null;
|
||||
let rejectNext: ((e: unknown) => void) | null = null;
|
||||
let done = false;
|
||||
let pendingError: unknown = null;
|
||||
|
||||
const next = (): Promise<IteratorResult<TailEvent>> => {
|
||||
if (pendingError !== null) {
|
||||
const e = pendingError;
|
||||
pendingError = null;
|
||||
return Promise.reject(e);
|
||||
}
|
||||
if (queue.length > 0) {
|
||||
return Promise.resolve({ value: queue.shift() as TailEvent, done: false });
|
||||
}
|
||||
if (done) {
|
||||
return Promise.resolve({ value: undefined as unknown as TailEvent, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<TailEvent>>((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const iter: AsyncIterableIterator<TailEvent> = {
|
||||
next,
|
||||
return: () => {
|
||||
done = true;
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r({ value: undefined as unknown as TailEvent, done: true });
|
||||
}
|
||||
return Promise.resolve({ value: undefined as unknown as TailEvent, done: true });
|
||||
},
|
||||
throw: (err: unknown) => {
|
||||
done = true;
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r(err);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
iter,
|
||||
push(ev: TailEvent): void {
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r({ value: ev, done: false });
|
||||
} else {
|
||||
queue.push(ev);
|
||||
}
|
||||
},
|
||||
close(): void {
|
||||
done = true;
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r({ value: undefined as unknown as TailEvent, done: true });
|
||||
}
|
||||
},
|
||||
failWith(err: unknown): void {
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r(err);
|
||||
} else {
|
||||
pendingError = err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Ctrl = ReturnType<typeof makeCtrl>;
|
||||
|
||||
/** Configure `mockStreamTail` so every call returns a fresh controllable iterator. */
|
||||
function trackCalls(): { ctrls: Ctrl[] } {
|
||||
const ctrls: Ctrl[] = [];
|
||||
mockStreamTail.mockImplementation(() => {
|
||||
const c = makeCtrl();
|
||||
ctrls.push(c);
|
||||
return c.iter;
|
||||
});
|
||||
return { ctrls };
|
||||
}
|
||||
|
||||
/** Same `renderHook` shim used by `useClaimDetail`, `useReconciliation`, etc. */
|
||||
function renderHook<TResult>(setup: () => TResult): {
|
||||
result: { current: TResult | undefined };
|
||||
unmount: () => void;
|
||||
} {
|
||||
const result: { current: TResult | undefined } = { current: undefined };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function Probe() {
|
||||
result.current = setup();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Flush microtasks + React state until `predicate()` holds (or we time out). */
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 1000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(
|
||||
`waitFor: predicate did not hold within ${timeoutMs}ms`,
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a valid Claim shape for the dispatch test. */
|
||||
function makeClaim(id: string, patientName: string): Claim {
|
||||
return {
|
||||
id,
|
||||
patientName,
|
||||
providerNpi: "1234567890",
|
||||
payerName: "Medicaid",
|
||||
cptCode: "99213",
|
||||
billedAmount: 100,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: "2026-06-20T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTailStream", () => {
|
||||
beforeEach(() => {
|
||||
mockStreamTail.mockReset();
|
||||
// Reset the singleton tail-store between tests so each case sees a
|
||||
// clean claims/remittances/activity slate.
|
||||
useTailStore.getState().reset("claims");
|
||||
useTailStore.getState().reset("remittances");
|
||||
useTailStore.getState().reset("activity");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore real timers so a fake-timers-using test doesn't leak into
|
||||
// the next case.
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("test_on_mount_status_connecting_then_live_after_snapshot_end", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { result, unmount } = renderHook(() => useTailStream("claims"));
|
||||
|
||||
// Wait for the effect to call streamTail once.
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
// Initial status: connecting.
|
||||
expect(result.current?.status).toBe("connecting");
|
||||
|
||||
// Server finishes replaying the snapshot — status flips to live.
|
||||
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => result.current?.status === "live");
|
||||
expect(result.current?.status).toBe("live");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_on_stream_error_status_error", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { result, unmount } = renderHook(() => useTailStream("claims"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
|
||||
// Simulate a stream-level error event (yielded, not thrown). The hook
|
||||
// should turn this into an Error and surface `status === "error"`.
|
||||
ctrls[0].failWith(new Error("boom"));
|
||||
|
||||
await waitFor(() => result.current?.status === "error");
|
||||
expect(result.current?.status).toBe("error");
|
||||
expect(result.current?.error).toBeInstanceOf(Error);
|
||||
expect((result.current?.error as Error).message).toBe("boom");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_on_abort_status_closed_no_reconnect", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { unmount } = renderHook(() => useTailStream("claims"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
|
||||
// Capture the AbortSignal the hook passed to streamTail — the contract
|
||||
// is that the hook aborts it on unmount.
|
||||
const call = mockStreamTail.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const opts = call[1] as { signal?: AbortSignal } | undefined;
|
||||
expect(opts?.signal).toBeDefined();
|
||||
expect(opts?.signal?.aborted).toBe(false);
|
||||
|
||||
unmount();
|
||||
|
||||
// After unmount the signal MUST be aborted — that's how `streamTail`
|
||||
// tears down its fetch loop, and it's the externally-observable
|
||||
// evidence that we cleaned up. (`status === "closed"` is set on the
|
||||
// hook's state, but a renderHook shim can't observe post-unmount
|
||||
// re-renders, so we assert on the abort instead.)
|
||||
expect(opts?.signal?.aborted).toBe(true);
|
||||
|
||||
// No reconnect must be scheduled. Advance past the entire backoff
|
||||
// window and confirm `streamTail` is still at exactly one call.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(mockStreamTail).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("test_on_event_dispatches_to_tail_store", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { unmount } = renderHook(() => useTailStream("claims"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
|
||||
// Bring the stream to "live" so the dispatcher is in the happy path.
|
||||
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => useTailStore.getState().claims !== undefined);
|
||||
|
||||
// Push an item — it must end up in the claims slice.
|
||||
ctrls[0].push({
|
||||
type: "item",
|
||||
data: makeClaim("CLM-1", "Patient One"),
|
||||
});
|
||||
await waitFor(
|
||||
() => useTailStore.getState().claims["CLM-1"] !== undefined,
|
||||
);
|
||||
|
||||
const stored = useTailStore.getState().claims["CLM-1"];
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored?.patientName).toBe("Patient One");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_reconnect_status_cycles_reconnecting_to_connecting_to_live", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
|
||||
// Compress the backoff schedule so the test doesn't sit on a real
|
||||
// 1-second timer. We override the global setTimeout / clearTimeout to
|
||||
// schedule timers via `setImmediate`-style micro-tasks instead of
|
||||
// wall-clock — but the simpler path is just `vi.useFakeTimers` and
|
||||
// manually advance. We go with the simpler path.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result, unmount } = renderHook(() => useTailStream("claims"));
|
||||
await act(async () => {
|
||||
// Yield so the initial useEffect runs and calls streamTail.
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(ctrls.length).toBe(1);
|
||||
|
||||
// First attempt fails — hook sets status=error and schedules a
|
||||
// reconnect after the first backoff step (1000ms in the real
|
||||
// schedule, but we advance just past it).
|
||||
ctrls[0].failWith(new Error("first failed"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current?.status).toBe("error");
|
||||
|
||||
// Advance past the first backoff step. The hook should reopen —
|
||||
// status becomes "connecting" (attempt counter resets on success
|
||||
// but only after snapshot_end; while retrying it's still
|
||||
// "reconnecting" until the new attempt actually opens).
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(ctrls.length).toBe(2);
|
||||
|
||||
// Second attempt completes the snapshot — status flips to live.
|
||||
ctrls[1].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => result.current?.status === "live");
|
||||
expect(result.current?.status).toBe("live");
|
||||
|
||||
unmount();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("test_reconnect_dedup_duplicate_items_appear_once_in_store", async () => {
|
||||
const { ctrls } = trackCalls();
|
||||
const { unmount } = renderHook(() => useTailStream("claims"));
|
||||
|
||||
await waitFor(() => ctrls.length === 1);
|
||||
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||
await waitFor(() => useTailStore.getState().claims !== undefined);
|
||||
|
||||
// Push the same claim twice (mimicking a snapshot replay on
|
||||
// reconnect). The store must keep exactly one copy.
|
||||
ctrls[0].push({
|
||||
type: "item",
|
||||
data: makeClaim("CLM-DUP", "Original"),
|
||||
});
|
||||
ctrls[0].push({
|
||||
type: "item",
|
||||
data: makeClaim("CLM-DUP", "Updated"),
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => useTailStore.getState().claims["CLM-DUP"] !== undefined,
|
||||
);
|
||||
// Give the second push time to be processed — we want to assert it
|
||||
// was rejected by the store's first-write-wins rule.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(Object.keys(useTailStore.getState().claims)).toHaveLength(1);
|
||||
expect(useTailStore.getState().claims["CLM-DUP"]?.patientName).toBe(
|
||||
"Original",
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live-tail connection lifecycle hook (sub-project 5, Phase 5 Task 19).
|
||||
//
|
||||
// Opens a `streamTail(resource)` connection, dispatches `item` events into
|
||||
// the matching `useTailStore` slice, exposes the connection status to the
|
||||
// page (for `<TailStatusPill>`), and survives transient backend failures
|
||||
// with an exponential-backoff retry (1s → 2s → 4s → 8s → 16s, capped at
|
||||
// 30s — spec §3.4). A stall detector flips status to `stalled` after 30s
|
||||
// of silence (no event, including heartbeats) so the UI can show a stale
|
||||
// connection without polling.
|
||||
//
|
||||
// Design notes:
|
||||
// - One effect per `(resource, reconnectNonce)` pair. Bumping
|
||||
// `reconnectNonce` from `forceReconnect()` re-runs the effect, which
|
||||
// aborts the in-flight stream (cleanup) and opens a fresh one.
|
||||
// - The AbortController is stored in a ref so `forceReconnect` can abort
|
||||
// even from outside React's render cycle.
|
||||
// - Backoff is indexed by an `attempt` counter that resets to 0 once the
|
||||
// server completes a snapshot (i.e. we're "live"). A clean server-side
|
||||
// EOF is treated like an error — we reconnect with backoff.
|
||||
// - `setStatus("closed")` fires from the effect cleanup; the consumer
|
||||
// (e.g. `<TailStatusPill>`) typically unmounts at the same time, so
|
||||
// this update is rarely observed — but it's there for any parent that
|
||||
// keeps the consumer rendered after the hook is detached.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Activity, Claim, Remittance } from "@/types";
|
||||
|
||||
export type TailStatus =
|
||||
| "connecting"
|
||||
| "live"
|
||||
| "reconnecting"
|
||||
| "closed"
|
||||
| "stalled"
|
||||
| "error";
|
||||
|
||||
export interface UseTailStreamResult {
|
||||
status: TailStatus;
|
||||
lastEventAt: Date | null;
|
||||
error: Error | null;
|
||||
forceReconnect: () => void;
|
||||
}
|
||||
|
||||
/** Backoff schedule per spec §3.4: 1s, 2s, 4s, 8s, 16s, then cap at 30s. */
|
||||
const BACKOFF_STEPS_MS: readonly number[] = [
|
||||
1_000, 2_000, 4_000, 8_000, 16_000, 30_000,
|
||||
];
|
||||
|
||||
/** No event (including heartbeat) for this long → flip to `stalled`. */
|
||||
const STALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
function backoffDelayMs(attempt: number): number {
|
||||
const i = Math.min(Math.max(attempt, 0), BACKOFF_STEPS_MS.length - 1);
|
||||
return BACKOFF_STEPS_MS[i] as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a single item event into the matching slice of the tail store.
|
||||
* The store slices are typed with the canonical shapes (`Claim`,
|
||||
* `Remittance`, `Activity`); the stream yields `unknown` so we cast here.
|
||||
*/
|
||||
function dispatch(resource: TailResource, data: unknown): void {
|
||||
const store = useTailStore.getState();
|
||||
switch (resource) {
|
||||
case "claims":
|
||||
store.addClaim(data as Claim);
|
||||
break;
|
||||
case "remittances":
|
||||
store.addRemittance(data as Remittance);
|
||||
break;
|
||||
case "activity":
|
||||
store.addActivity(data as Activity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function useTailStream(resource: TailResource): UseTailStreamResult {
|
||||
const [status, setStatus] = useState<TailStatus>("connecting");
|
||||
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
/**
|
||||
* Bumping this state causes the effect to re-run, aborting the current
|
||||
* stream and starting a fresh one. `forceReconnect` is the only place
|
||||
* we mutate it from outside the effect itself.
|
||||
*/
|
||||
const [reconnectNonce, setReconnectNonce] = useState(0);
|
||||
|
||||
/**
|
||||
* Hold the in-flight AbortController so `forceReconnect` can tear it
|
||||
* down even when called from a stale render. The effect cleanup also
|
||||
* aborts via this ref.
|
||||
*/
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const forceReconnect = useCallback(() => {
|
||||
setReconnectNonce((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let attempt = 0;
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearStall = (): void => {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-arm the stall detector. Called on every event (including
|
||||
* heartbeats and `item_dropped` notices) so a quiet backend that
|
||||
* still pings every <30s doesn't get flagged as stalled. The stall
|
||||
* timer only flips status if we were already in a "still trying"
|
||||
* state — once we're `closed` or `error`, a missed heartbeat
|
||||
* shouldn't override that.
|
||||
*/
|
||||
const armStall = (): void => {
|
||||
clearStall();
|
||||
stallTimer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
setStatus((prev) => {
|
||||
if (
|
||||
prev === "closed" ||
|
||||
prev === "reconnecting" ||
|
||||
prev === "error" ||
|
||||
prev === "stalled"
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return "stalled";
|
||||
});
|
||||
}, STALL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (cancelled) return;
|
||||
if (backoffTimer) {
|
||||
clearTimeout(backoffTimer);
|
||||
backoffTimer = null;
|
||||
}
|
||||
const delay = backoffDelayMs(attempt);
|
||||
backoffTimer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
backoffTimer = null;
|
||||
attempt += 1;
|
||||
void openOnce();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const openOnce = async (): Promise<void> => {
|
||||
if (cancelled) return;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
// First attempt → "connecting". Subsequent retries → "reconnecting"
|
||||
// so the user can see that we've lost the prior connection.
|
||||
setStatus(attempt === 0 ? "connecting" : "reconnecting");
|
||||
|
||||
try {
|
||||
const iter = streamTail(resource, { signal: controller.signal });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (const ev of iter) {
|
||||
if (cancelled) return;
|
||||
setLastEventAt(new Date());
|
||||
armStall();
|
||||
|
||||
switch (ev.type) {
|
||||
case "snapshot_end":
|
||||
// Server finished replaying; we are now live. Reset the
|
||||
// backoff counter so a transient blip doesn't penalize the
|
||||
// next outage.
|
||||
attempt = 0;
|
||||
setStatus("live");
|
||||
break;
|
||||
case "item":
|
||||
dispatch(resource, ev.data);
|
||||
break;
|
||||
case "heartbeat":
|
||||
case "item_dropped":
|
||||
// No state change — the stall timer has already been
|
||||
// re-armed. These exist purely so the hook knows the
|
||||
// connection is alive.
|
||||
break;
|
||||
case "error":
|
||||
// Yielded (not thrown) error event. Promote to a thrown
|
||||
// Error so the catch block below runs the reconnect
|
||||
// machinery.
|
||||
throw new Error(ev.data.message);
|
||||
}
|
||||
}
|
||||
// Stream finished cleanly (server-side EOF). Treat as a
|
||||
// reconnect-trigger: if we're still mounted, schedule a retry.
|
||||
if (!cancelled) {
|
||||
setStatus("reconnecting");
|
||||
scheduleReconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// An abort during teardown isn't really an error — the stream
|
||||
// is being torn down on purpose. Skip the reconnect.
|
||||
if (controller.signal.aborted) return;
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
setError(e);
|
||||
setStatus("error");
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
void openOnce();
|
||||
armStall();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (backoffTimer) {
|
||||
clearTimeout(backoffTimer);
|
||||
backoffTimer = null;
|
||||
}
|
||||
clearStall();
|
||||
// Per spec: on unmount, status = closed. This is the only way a
|
||||
// parent that keeps the consumer mounted (e.g. for a transition)
|
||||
// can observe the closed state.
|
||||
setStatus("closed");
|
||||
};
|
||||
}, [resource, reconnectNonce]);
|
||||
|
||||
return { status, lastEventAt, error, forceReconnect };
|
||||
}
|
||||
Reference in New Issue
Block a user