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:
Tyler
2026-06-20 17:28:58 -06:00
26 changed files with 4300 additions and 37 deletions
+102 -7
View File
@@ -65,6 +65,69 @@ npm run build
npm test
```
## Live updates
The Claims, Remittances, and Activity pages stay current without
manual refresh. The backend publishes an internal event on every
store write, the page opens a streaming HTTP connection to the
matching `/api/<resource>/stream` endpoint, and new rows are
appended to the table the moment they hit the database.
### Wire format
Each stream endpoint emits newline-delimited JSON. The first batch
is the **snapshot** of currently-known rows; after that comes
**`snapshot_end`** with the count, then the **live** events.
```json
{"type":"item","data":{"id":"CLM-1", "...":"..."}}
{"type":"item","data":{"id":"CLM-2", "...":"..."}}
{"type":"snapshot_end","data":{"count":2}}
{"type":"item","data":{"id":"CLM-3", "...":"..."}} live
{"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} idle keep-alive
```
Lines are `{"type": ..., "data": ...}`; known types are `item`,
`snapshot_end`, `heartbeat`, and (rare) `item_dropped` /
`error`. Heartbeats keep the connection alive when nothing is
happening — clients flip to `stalled` after 30s of total silence
(heartbeat or otherwise) and surface a **↻ Reconnect** button.
### Endpoints
| Method | Path | Subscribes to | Default sort |
| ------ | -------------------------- | ------------------- | ------------------- |
| GET | `/api/claims/stream` | `claim_written` | `-submission_date` |
| GET | `/api/remittances/stream` | `remittance_written`| `-received_date` |
| GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) |
All three accept the same query params as their non-streaming
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
swap a one-shot fetch for a tail with no URL surgery. Responses
are `Content-Type: application/x-ndjson`.
### Status pill
The pill in each toolbar reflects the current connection state:
| Status | Badge variant | What it means |
| ------------- | ------------- | ------------------------------------------------------------------ |
| `live` | success | snapshot received, listening for new events |
| `connecting` | warning | opening the stream (initial mount) |
| `reconnecting`| warning | previous attempt failed, backing off before retry |
| `stalled` | destructive | no event (including heartbeat) for 30s — click **↻ Reconnect** |
| `error` | destructive | stream errored — click **↻ Reconnect** |
| `closed` | destructive | page unmounted |
The reconnect button is only shown on `stalled` and `error`. The
backoff schedule on error is `1s → 2s → 4s → 8s → 16s → 30s` capped.
### Knobs
| Env var | Default | Effect |
| ---------------------------- | ------- | ------------------------------------------------------- |
| `CYCLONE_TAIL_HEARTBEAT_S` | `15` | Idle heartbeat interval (seconds). Lower it for tests. |
## Persistence
Parsed batches, claims, remittances, matches, and activity events are
@@ -94,8 +157,9 @@ backup API).
.
├── backend/
│ ├── src/cyclone/
│ │ ├── api.py # FastAPI app, GET + parse routes
│ │ ├── store.py # InMemoryStore, mappers
│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
│ │ ├── store.py # InMemoryStore, mappers, publish-on-write
│ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
@@ -105,14 +169,20 @@ backup API).
│ ├── test_api_gets.py # 6 GET endpoints
│ ├── test_store.py # store + mappers + iterators
│ ├── test_api_streaming.py
│ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
│ ├── test_pubsub.py # EventBus + subscribe/unsubscribe
│ └── test_api_parse_persists.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ ── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ ── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ │ # + useTailStream, useMergedTail (live tail)
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│ │ # + tail-stream.ts (NDJSON parser)
│ ├── store/ # zustand sample-data + parsed-batches store
│ │ # + tail-store.ts (FIFO-capped live tail slices)
│ └── types/ # shared TS types
├── docs/
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
@@ -123,7 +193,7 @@ backup API).
## Roadmap
Sub-projects 2, 3, and the first half of 4 are **shipped**. Next up:
Sub-projects 2, 3, 4, and 5 are **shipped**. Next up:
- **Sub-project 3 (shipped) — More 837P/835 features.**
- **837P validation rules:** R034 enforces `REF*G1` on frequency-code
@@ -154,9 +224,23 @@ Sub-projects 2, 3, and the first half of 4 are **shipped**. Next up:
(404) states are all distinct. Powered by a new backend endpoint
`GET /api/claims/{claim_id}` that returns the full drawer payload
in a single round-trip.
- **Remaining SP4:** batch diff view, real-time streaming of NDJSON
GET responses, advanced filters (date range, multi-status, saved
filter sets).
- **Remaining SP4:** batch diff view, advanced filters (date range,
multi-status, saved filter sets).
- **Sub-project 5 (shipped) — Live updates.**
- The Claims, Remittances, and Activity pages stream new rows in
real time over Server-Sent Events encoded as newline-delimited
JSON. The backend publishes a `claim_written` /
`remittance_written` / `activity_recorded` event on every store
write; the frontend opens an HTTP stream and dispatches each
event into a per-resource Zustand store that the page reads on
top of its base query results.
- A small status pill in each toolbar surfaces the connection
state — `live` / `connecting` / `reconnecting` / `stalled` /
`error` / `closed` — and offers a manual **↻ Reconnect** button
on `stalled` or `error`. Stale connections (no event for 30s,
heartbeat included) flip to `stalled` automatically; the
back-off ladder on errors is 1s → 2s → 4s → 8s → 16s → 30s
capped. See the "Live updates" section below for details.
### SP3 endpoints
@@ -184,6 +268,17 @@ ACKs and lets you download the regenerated 999 text.
when the claim doesn't exist — distinct from a transient fetch
failure so the UI can render a dedicated not-found state.
### SP5 endpoints (live updates)
- `GET /api/claims/stream` — NDJSON stream of claim events. Emits a
snapshot (filtered by the same query params as `GET /api/claims`),
then `snapshot_end`, then live `claim_written` events as they
arrive, with a 15s idle heartbeat.
- `GET /api/remittances/stream` — same shape for remittances
(subscribes to `remittance_written`, default sort `-received_date`).
- `GET /api/activity/stream` — same shape for activity events
(subscribes to `activity_recorded`, default `limit=50`).
## License
No license file yet; this is internal-use software. Add a `LICENSE` file
+2
View File
@@ -20,6 +20,7 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-cov>=4.1",
"pytest-asyncio>=0.23,<1",
"httpx>=0.27,<1",
]
@@ -32,3 +33,4 @@ where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
asyncio_mode = "auto"
+2 -4
View File
@@ -27,10 +27,8 @@ def main() -> None:
]
if reload:
sys.argv.append("--reload")
# Initialize the DB (engine, migrations, schema) before the app
# boots so the first request hits a populated database.
from cyclone import db
db.init_db()
# DB + EventBus are initialized by the FastAPI lifespan handler
# (cyclone.api:lifespan); no explicit pre-init is needed here.
import uvicorn
uvicorn.main()
+224 -3
View File
@@ -15,10 +15,13 @@ plus GET/POST with any header.
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, Iterator
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
@@ -47,6 +50,7 @@ from cyclone.parsers.serialize_270 import serialize_270
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.pubsub import EventBus
from cyclone.store import (
AlreadyMatchedError,
BatchRecord,
@@ -58,6 +62,29 @@ from cyclone.store import (
log = logging.getLogger(__name__)
def _ndjson_line(event: dict) -> bytes:
"""Serialize one event dict as a single NDJSON line (UTF-8, trailing ``\\n``).
Used by the live-tail streaming endpoints to emit a uniform wire format
that the frontend ``tail-stream.ts`` parser can split on newlines.
Compact separators keep each line small and avoid ambiguity with embedded
whitespace.
"""
return (json.dumps(event, separators=(",", ":")) + "\n").encode("utf-8")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Initialize per-process resources (DB + EventBus) before the app
serves requests. No teardown needed for Cyclone's local-only posture.
"""
from cyclone import db
db.init_db()
app.state.event_bus = EventBus()
yield
# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
# Click-based CLI module.
PAYER_FACTORIES: dict[str, Any] = {
@@ -76,6 +103,7 @@ app = FastAPI(
title="Cyclone 837P / 835 Parser API",
version=__version__,
description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.",
lifespan=lifespan,
)
app.add_middleware(
@@ -264,7 +292,7 @@ async def parse_837(
parsed_at=utcnow(),
result=result,
)
store.add(rec)
store.add(rec, event_bus=request.app.state.event_bus)
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
@@ -472,7 +500,7 @@ async def parse_835_endpoint(
parsed_at=utcnow(),
result=result,
)
store.add(rec)
store.add(rec, event_bus=request.app.state.event_bus)
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
@@ -712,6 +740,123 @@ def list_claims(
}
# --------------------------------------------------------------------------- #
# Live-tail NDJSON streaming endpoints (Phase 3 — SP5)
# --------------------------------------------------------------------------- #
def _heartbeat_seconds() -> float:
"""Return the configured tail heartbeat interval.
Read from ``CYCLONE_TAIL_HEARTBEAT_S`` at call time so tests can
monkeypatch the env var without reloading the module. Defaults to
15s (the production cadence); tests override to a small value (e.g.
0.2s) to keep their runtime bounded.
"""
raw = os.environ.get("CYCLONE_TAIL_HEARTBEAT_S", "15")
try:
v = float(raw)
except ValueError:
return 15.0
return v if v > 0 else 15.0
async def _tail_events(
request: Request, bus: EventBus, kinds: list[str]
) -> AsyncIterator[bytes]:
"""Forward subscribed events as ``item`` lines with periodic heartbeats.
Polls the underlying ``asyncio.Queue`` directly (via
:meth:`EventBus.subscribe_raw`) instead of awaiting the bus's
async-iterator wrapper. ``asyncio.wait_for`` cancels the inner
future on timeout, which would otherwise terminate the bus
iterator at its ``await`` point and break subsequent
``__anext__`` calls with ``StopAsyncIteration``. Polling
``queue.get()`` is idempotent under cancellation, so heartbeats
don't poison the subscription.
A ``try/finally`` unsubscribes the queue from the bus when the
caller disconnects or the generator is garbage collected —
otherwise the bus would leak one queue per open stream.
"""
hb_s = _heartbeat_seconds()
queue, _sub = bus.subscribe_raw(kinds)
try:
while True:
if await request.is_disconnected():
return
get_task = asyncio.ensure_future(queue.get())
sleep_task = asyncio.ensure_future(asyncio.sleep(hb_s))
try:
done, pending = await asyncio.wait(
{get_task, sleep_task},
return_when=asyncio.FIRST_COMPLETED,
)
except BaseException:
get_task.cancel()
sleep_task.cancel()
raise
for t in pending:
t.cancel()
if get_task in done:
event = get_task.result()
yield _ndjson_line({"type": "item", "data": event})
else:
yield _ndjson_line({
"type": "heartbeat",
"data": {"ts": utcnow().isoformat().replace("+00:00", "Z")},
})
finally:
bus.unsubscribe(queue, kinds)
@app.get("/api/claims/stream")
async def claims_stream(
request: Request,
status: str | None = Query(None),
provider_npi: str | None = Query(None),
payer: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort: str | None = Query(None),
order: str = Query("desc"),
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream Claims as NDJSON: snapshot first, then live events.
Wire format:
* ``{"type":"item","data":<claim>}`` per snapshot row, then per
new ``claim_written`` event
* ``{"type":"snapshot_end","data":{"count":N}}`` after the snapshot
* ``{"type":"heartbeat","data":{"ts":<iso>}}`` every
``CYCLONE_TAIL_HEARTBEAT_S`` seconds when idle
Query params mirror :func:`list_claims` so a frontend can swap a
one-shot fetch for a tail with no URL surgery.
NOTE: registered before ``/api/claims/{claim_id}`` so the literal
``stream`` path segment doesn't get matched as a claim id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
# 1. Snapshot (eager — iter_claims returns a list already).
rows = store.iter_claims(
status=status, provider_npi=provider_npi, payer=payer,
date_from=date_from, date_to=date_to,
sort=sort or "-submission_date", order=order, limit=limit,
)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
# 2. Subscribe + heartbeats.
async for chunk in _tail_events(request, bus, ["claim_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/claims/{claim_id}")
def get_claim_detail_endpoint(claim_id: str) -> dict:
"""Return one claim with full drawer context (SP4).
@@ -865,6 +1010,45 @@ def list_remittances(
}
@app.get("/api/remittances/stream")
async def remittances_stream(
request: Request,
payer: str | None = Query(None),
claim_id: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort: str | None = Query(None),
order: str = Query("desc"),
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream Remittances as NDJSON: snapshot first, then live events.
Subscribes to ``remittance_written``. Default sort is
``-received_date`` (newest-first), matching the list endpoint's
most common sort.
NOTE: registered before ``/api/remittances/{remittance_id}`` so
the literal ``stream`` path segment doesn't get matched as a
remittance id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.iter_remittances(
payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
sort=sort or "-received_date", order=order, limit=limit,
)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in _tail_events(request, bus, ["remittance_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/remittances/{remittance_id}")
def get_remittance(remittance_id: str) -> dict:
"""Return one remittance with its labeled CAS ``adjustments`` array.
@@ -939,6 +1123,43 @@ def list_activity(
}
@app.get("/api/activity/stream")
async def activity_stream(
request: Request,
kind: str | None = Query(None),
since: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
) -> StreamingResponse:
"""Stream Activity events as NDJSON: snapshot first, then live events.
Subscribes to ``activity_recorded``. Default ``limit`` is 50
(smaller than the list endpoint's 200) because activity is
high-volume — callers usually want the most recent handful, not a
full replay.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
# Snapshot reuses the same in-memory filter as ``list_activity``
# so the two endpoints are interchangeable for the snapshot
# half.
events = store.recent_activity(limit=limit)
if kind is not None:
events = [e for e in events if e["kind"] == kind]
if since is not None:
events = [e for e in events if e["timestamp"] >= since]
for ev in events:
yield _ndjson_line({"type": "item", "data": ev})
yield _ndjson_line({
"type": "snapshot_end", "data": {"count": len(events)},
})
async for chunk in _tail_events(request, bus, ["activity_recorded"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
# --------------------------------------------------------------------------- #
# 999 ACKs (read views)
# --------------------------------------------------------------------------- #
+113
View File
@@ -0,0 +1,113 @@
"""In-process pub/sub for live-tail event streaming.
``EventBus`` is a tiny, async-first fan-out broker. Publishers call
``publish(kind, payload)`` and never block; if a subscriber's per-kind
queue is full, the oldest event is dropped so the slow consumer cannot
stall the producer. Subscribers receive events through an async
iterator that yields ``{**payload, "_kind": kind}``.
The bus is intentionally not thread-safe: it is designed to run on a
single asyncio event loop, which matches the FastAPI/uvicorn
deployment model used elsewhere in this project.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
class EventBus:
"""Per-kind fan-out bus with drop-oldest overflow per subscriber."""
def __init__(self, max_queue_size: int = 256) -> None:
self._max_queue_size = max_queue_size
self._subscribers: dict[str, list[asyncio.Queue[dict]]] = {}
async def publish(self, kind: str, payload: dict) -> None:
"""Fan ``payload`` out to every subscriber of ``kind``.
Never blocks. If a subscriber queue is full, the oldest queued
event is discarded to make room.
"""
event = {**payload, "_kind": kind}
for queue in list(self._subscribers.get(kind, ())):
self._enqueue_or_drop_oldest(queue, event)
def _enqueue_or_drop_oldest(
self, queue: asyncio.Queue[dict], event: dict
) -> None:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
# Drop the oldest event to make room, then enqueue.
queue.get_nowait()
queue.task_done()
queue.put_nowait(event)
def subscribe(self, kinds: list[str]) -> AsyncIterator[dict]:
"""Return an async iterator delivering events for any of ``kinds``."""
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=self._max_queue_size)
for kind in kinds:
self._subscribers.setdefault(kind, []).append(queue)
return self._iterator(queue)
def subscribe_raw(
self, kinds: list[str]
) -> tuple[asyncio.Queue[dict], AsyncIterator[dict]]:
"""Like :meth:`subscribe` but also returns the underlying queue.
Most consumers should use :meth:`subscribe`. The live-tail
endpoints use ``subscribe_raw`` so they can race
``queue.get()`` against a heartbeat sleep without poisoning
the subscription iterator: ``asyncio.wait_for`` cancels the
inner future on timeout, which terminates an async generator
at its ``await`` point and breaks all subsequent
``__anext__`` calls. Polling ``queue.get()`` directly avoids
that because ``Queue.get`` is idempotent under cancellation.
Callers MUST pair this with :meth:`unsubscribe` in a
``try/finally`` to release the queue when the consumer
disconnects — otherwise the bus leaks a queue per stream.
"""
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=self._max_queue_size)
for kind in kinds:
self._subscribers.setdefault(kind, []).append(queue)
return queue, self._iterator(queue)
def unsubscribe(
self, queue: asyncio.Queue[dict], kinds: list[str]
) -> None:
"""Remove ``queue`` from each kind's subscriber list.
Idempotent: missing entries are silently ignored so callers
can run the cleanup in a ``finally`` without worrying about
double-unsubscribe on disconnect-then-GeneratorExit.
"""
for kind in kinds:
subs = self._subscribers.get(kind)
if not subs:
continue
try:
subs.remove(queue)
except ValueError:
pass
if not subs:
self._subscribers.pop(kind, None)
async def _iterator(self, queue: asyncio.Queue[dict]) -> AsyncIterator[dict]:
while True:
yield await queue.get()
queue.task_done()
def get_event_bus() -> EventBus:
"""Return the process-wide EventBus attached to the FastAPI app state.
Late-imports ``cyclone.api`` to avoid a circular import at module
load time (the API module imports pubsub indirectly via lifespan).
Tests stub this via monkeypatch.
"""
from cyclone.api import app # late import to avoid circular
return app.state.event_bus
+97 -1
View File
@@ -809,7 +809,12 @@ class CycloneStore:
# -- write path -----------------------------------------------------
def add(self, record: BatchRecord) -> None:
def add(
self,
record: BatchRecord,
*,
event_bus: "EventBus | None" = None,
) -> None:
"""Persist a parsed batch (837P or 835) to the DB.
For 837P batches: inserts the Batch row, one Claim row per
@@ -830,10 +835,22 @@ class CycloneStore:
has a fresh ``uuid4`` id from the API). O(n) per row, but
acceptable for the small fixture sizes — production load is
one batch at a time via the API, not bulk inserts.
When ``event_bus`` is provided, publishes one ``claim_written``
or ``remittance_written`` event per newly-inserted row plus an
``activity_recorded`` event per activity row, after commit. The
publish calls are best-effort — failures are logged but do not
roll back the persisted batch.
"""
from cyclone.pubsub import EventBus
import logging
log = logging.getLogger(__name__)
# Track rows we actually inserted so we can publish events for them.
inserted_claim_ids: list[str] = []
inserted_remit_ids: list[str] = []
with db.SessionLocal()() as s:
batch_row = Batch(
id=record.id,
@@ -870,6 +887,7 @@ class CycloneStore:
"amount": float(claim.claim.total_charge or 0.0),
},
))
inserted_claim_ids.append(claim.claim_id)
elif isinstance(record, BatchRecord835):
result835: ParseResult835 = record.result
payer_name = result835.payer.name
@@ -903,6 +921,7 @@ class CycloneStore:
"amount": float(cp.total_paid or 0.0),
},
))
inserted_remit_ids.append(cp.payer_claim_control_number)
else:
raise TypeError(
f"Unsupported BatchRecord subclass: {type(record).__name__}"
@@ -922,6 +941,83 @@ class CycloneStore:
"reconcile.run failed for batch %s", record.id,
)
# Publish live-tail events synchronously. EventBus.publish is async
# but its body is purely synchronous ``put_nowait`` enqueues; we
# bypass the async wrapper and call the internal enqueue directly
# so callers (sync FastAPI endpoints, sync test harnesses) don't
# need to await.
if event_bus is not None and (inserted_claim_ids or inserted_remit_ids):
self._publish_events_sync(
event_bus, record, inserted_claim_ids, inserted_remit_ids,
)
def _publish_events_sync(
self,
event_bus: "EventBus",
record: BatchRecord,
claim_ids: list[str],
remit_ids: list[str],
) -> None:
"""Build UI-shaped payloads for newly-inserted rows and publish.
Runs after commit so subscribers can immediately re-fetch from
the API and see consistent data. Each ``claim_written`` /
``remittance_written`` payload is identical to what the matching
list endpoint would return for that row.
This is sync because EventBus's enqueue path is sync; we don't
need a coroutine for ``put_nowait``.
"""
import logging
log = logging.getLogger(__name__)
try:
with db.SessionLocal()() as s:
for cid in claim_ids:
row = s.get(Claim, cid)
if row is None:
continue
ui = to_ui_claim_from_orm(
row, batch_id=row.batch_id or record.id,
parsed_at=record.parsed_at,
)
self._sync_publish(event_bus, "claim_written", ui)
for rid in remit_ids:
row = s.get(Remittance, rid)
if row is None:
continue
ui = to_ui_remittance_from_orm(
row, batch_id=row.batch_id or record.id,
parsed_at=record.parsed_at,
)
self._sync_publish(event_bus, "remittance_written", ui)
# Activity events for this batch.
from sqlalchemy import select
activity_rows = s.execute(
select(ActivityEvent).where(ActivityEvent.batch_id == record.id)
).scalars().all()
for arow in activity_rows:
ui = {
"kind": arow.kind,
"ts": arow.ts.isoformat().replace("+00:00", "Z"),
"batchId": arow.batch_id,
"claimId": arow.claim_id,
"remittanceId": arow.remittance_id,
"payload": arow.payload_json,
}
self._sync_publish(event_bus, "activity_recorded", ui)
except Exception:
log.exception("add: event publish failed for batch %s", record.id)
@staticmethod
def _sync_publish(event_bus: "EventBus", kind: str, payload: dict) -> None:
"""Synchronous fan-out helper. Mirrors ``EventBus.publish`` but
bypasses the async wrapper so callers don't need an event loop.
"""
event = {**payload, "_kind": kind}
for queue in list(event_bus._subscribers.get(kind, ())):
event_bus._enqueue_or_drop_oldest(queue, event)
def _run_reconcile(self, batch_id: str) -> None:
"""T10 stub: invoke the reconcile orchestrator for a batch.
+15 -3
View File
@@ -19,10 +19,22 @@ import pytest
@pytest.fixture(autouse=True)
def _auto_init_db(tmp_path, monkeypatch):
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema."""
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
does not invoke the FastAPI lifespan handler unless used as a context
manager. The bus is reset between tests so subscribers don't leak.
"""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
from cyclone import db
from cyclone.api import app
from cyclone.pubsub import EventBus
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
app.state.event_bus = EventBus()
try:
yield
finally:
app.state.event_bus = None
db._reset_for_tests()
+607
View File
@@ -0,0 +1,607 @@
"""Live-tail NDJSON streaming endpoint tests (Phase 3).
Each ``/api/.../stream`` endpoint:
1. yields the current snapshot as ``{"type":"item","data":<row>}`` lines
2. terminates the snapshot with ``{"type":"snapshot_end","data":{"count":N}}``
3. subscribes to the relevant EventBus kind and forwards new events
4. emits a ``{"type":"heartbeat","data":{"ts":<iso>}}`` line every
``CYCLONE_TAIL_HEARTBEAT_S`` seconds (default 15) when idle
5. returns cleanly when the client disconnects
6. responds with ``application/x-ndjson``
Implementation note (deviation from plan)
-----------------------------------------
The plan suggested using ``httpx.AsyncClient(transport=ASGITransport(app))``
and ``client.stream()``. That doesn't work for true long-lived streams:
* ``httpx.ASGITransport``'s response is buffered (``ASGIResponseStream``
joins every body chunk at the end), so ``client.stream()`` blocks until
the app finishes — a live tail never finishes.
* ``starlette.requests.Request.is_disconnected()`` relies on a
``http.disconnect`` message in the ASGI receive channel, which
``ASGITransport`` only delivers *after* the response body completes
(the receive coroutine awaits ``response_complete``). So even if the
caller closes the client side, the endpoint never observes a
disconnect.
Instead, we call the endpoint coroutine directly, build a synthetic
``Request`` and ``StreamingResponse``, and iterate ``body_iterator``
ourselves. That gives us byte-by-byte streaming, a real disconnect
signal via ``body_iterator.aclose()`` (which throws ``CancelledError``
into the generator), and works for arbitrarily long-lived tails.
The public surface (snapshot + subscribe + heartbeat + disconnect
cleanup) is verified exactly as the plan requires; only the transport
differs.
"""
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from starlette.requests import Request
from cyclone.api import app, claims_stream, remittances_stream, activity_stream
from cyclone.parsers.models import (
BatchSummary,
BillingProvider,
ClaimHeader,
ClaimOutput,
Envelope,
Payer,
ParseResult,
Subscriber,
ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimAdjustment,
ClaimPayment,
Envelope as Envelope835,
FinancialInfo,
ParseResult835,
Payer835,
Payee835,
ReassociationTrace,
ServicePayment,
BatchSummary as BatchSummary835,
)
from cyclone.pubsub import EventBus
from cyclone.store import (
BatchRecord,
BatchRecord835,
CycloneStore,
)
@pytest.fixture(autouse=True)
def _short_heartbeat(monkeypatch):
"""Set a 0.2s heartbeat for all stream tests in this module.
The production default is 15s. Tests use a tight value so that
generators sitting idle after the test reads its lines exit
promptly — ``aclose()`` on the body iterator cancels the
generator and the heartbeat timeout is the de-facto idle
signal in these tests.
"""
monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")
# --------------------------------------------------------------------------- #
# Test helpers
# --------------------------------------------------------------------------- #
def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
return ClaimOutput(
claim_id=claim_id,
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id=claim_id, total_charge=Decimal(charge),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def _make_result_837(n: int = 3, batch_id: str = "b-uuid-1") -> ParseResult:
claims = [
_make_claim_837(f"CLM-{i}", charge=str(100 + i))
for i in range(1, n + 1)
]
return ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=claims,
summary=BatchSummary(
input_file="test.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=n, passed=n, failed=0,
),
)
def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1",
n: int = 3):
return BatchRecord(
id=batch_id, kind=kind, input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=result if result is not None else _make_result_837(n=n),
)
def _make_remit_with_cas(remit_id="CLP-1", status="1", charge="124.00",
paid="62.00", cas_amount="62.00", pcn=None):
cp = ClaimPayment(
payer_claim_control_number=pcn if pcn is not None else remit_id,
status_code=status,
status_label="Primary",
total_charge=charge, total_paid=paid,
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC", procedure_code="99213",
charge=charge, payment=paid,
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
amount=cas_amount)],
),
],
)
return cp
def _make_835_result(claims):
return ParseResult835(
envelope=Envelope835(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
financial_info=FinancialInfo(
handling_code="C", paid_amount=Decimal("0"),
credit_debit_flag="C", payment_method=None,
),
trace=ReassociationTrace(
trace_type_code="1", trace_number="0001",
originating_company_id="S",
),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=claims,
summary=BatchSummary835(
input_file="era.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims), passed=len(claims), failed=0,
),
)
def _make_request(path: str = "/api/claims/stream",
query_string: bytes = b"") -> Request:
"""Build a Starlette ``Request`` wired to ``app.state`` with a no-op
receive channel so the endpoint's ``is_disconnected()`` polling
works without raising ``RuntimeError: Receive channel has not
been made available``.
"""
# ``receive`` blocks until a message is delivered. We never deliver
# one, so the cancel scope inside ``is_disconnected()`` always
# returns ``False`` — exactly what we want for tests that close
# via ``body_iterator.aclose()`` rather than the disconnect path.
receive_started = asyncio.Event()
async def receive():
receive_started.set()
# Block forever; is_disconnected's cancel scope will cancel
# this await and we'll return None (treated as "no message").
await asyncio.Event().wait()
scope = {
"type": "http",
"method": "GET",
"headers": [],
"query_string": query_string,
"path": path,
"app": app,
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
}
return Request(scope, receive=receive)
def _resolve_query_defaults(endpoint) -> dict:
"""Extract the actual default values from a FastAPI endpoint signature.
``Query(None)`` etc. are marker objects — when called directly we
need to substitute their ``.default`` so the endpoint function
sees the same values it would receive over HTTP.
"""
import inspect
sig = inspect.signature(endpoint)
resolved: dict = {}
for name, param in sig.parameters.items():
default = param.default
if hasattr(default, "default"):
resolved[name] = default.default
else:
resolved[name] = default
return resolved
async def _call_endpoint(endpoint, path: str):
"""Invoke ``endpoint(request, **defaults)`` with resolved defaults."""
request = _make_request(path)
defaults = _resolve_query_defaults(endpoint)
# Drop ``request`` — it's the positional we pass explicitly.
defaults.pop("request", None)
return await endpoint(request, **defaults)
async def _read_lines(body_iter, n: int, timeout: float = 5.0) -> list[str]:
"""Read up to ``n`` lines from a StreamingResponse body iterator."""
buffer = b""
lines: list[str] = []
async with asyncio.timeout(timeout):
while len(lines) < n:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
break
buffer += chunk
while b"\n" in buffer and len(lines) < n:
raw, buffer = buffer.split(b"\n", 1)
if raw:
lines.append(raw.decode("utf-8"))
return lines
async def _read_one_line(body_iter, timeout: float = 5.0) -> str | None:
"""Read a single NDJSON line from the body iterator, or None on timeout."""
buffer = b""
async with asyncio.timeout(timeout):
while True:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
return None
buffer += chunk
if b"\n" in buffer:
raw, _ = buffer.split(b"\n", 1)
if raw:
return raw.decode("utf-8")
async def _drain_until_disconnect(body_iter, max_chunks: int = 20) -> None:
"""Close the body iterator and swallow the cancellation."""
try:
await body_iter.aclose()
except (asyncio.CancelledError, GeneratorExit):
pass
async def _read_until_type(
body_iter, target: str, *, timeout: float = 5.0, max_lines: int = 50
) -> dict | None:
"""Read lines from the body iterator until one has ``type == target``.
Used by tests that need to skip past heartbeats or multi-item
snapshot prefixes to assert on a specific event. Returns the first
matching parsed object, or ``None`` if ``max_lines`` is reached
without a match.
"""
buffer = b""
seen = 0
async with asyncio.timeout(timeout):
while seen < max_lines:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
return None
buffer += chunk
while b"\n" in buffer:
raw, buffer = buffer.split(b"\n", 1)
if not raw:
continue
seen += 1
obj = json.loads(raw.decode("utf-8"))
if obj.get("type") == target:
return obj
return None
# --------------------------------------------------------------------------- #
# Task 11 — claims stream
# --------------------------------------------------------------------------- #
async def test_claims_stream_yields_snapshot_then_snapshot_end():
"""3 claims pre-loaded → 3 item lines + 1 snapshot_end line."""
s = CycloneStore()
s.add(_make_batch_record(n=3))
response = await _call_endpoint(claims_stream, "/api/claims/stream")
assert response.media_type.startswith("application/x-ndjson")
lines = await _read_lines(response.body_iterator, n=4)
items = [json.loads(line) for line in lines]
assert len(items) == 4
# First three are items; the order isn't guaranteed without an
# explicit sort, so check shape not exact ordering.
for item in items[:3]:
assert item["type"] == "item"
assert "id" in item["data"]
# Fourth is the snapshot_end terminator.
assert items[3] == {"type": "snapshot_end", "data": {"count": 3}}
await _drain_until_disconnect(response.body_iterator)
async def test_claims_stream_emits_new_item_after_publish():
"""After snapshot_end, a bus.publish('claim_written', …) → new item line."""
s = CycloneStore()
s.add(_make_batch_record(n=1))
response = await _call_endpoint(claims_stream, "/api/claims/stream")
# Drain snapshot phase: 1 item + 1 snapshot_end.
lines = await _read_lines(response.body_iterator, n=2)
assert json.loads(lines[1]) == {
"type": "snapshot_end", "data": {"count": 1},
}
# Advance the generator one more step so ``subscribe_raw`` runs
# and registers the subscriber queue. The next yield will be a
# heartbeat (with the 0.2s env var set in some tests); we don't
# care about its content here, just that we've moved past the
# sync subscription code.
_ = await _read_one_line(response.body_iterator, timeout=2.0)
# Now publish a claim_written event and assert a new item line
# streams out.
payload = {
"id": "NEW-CLM-1",
"patientName": "Live Tail",
"providerNpi": "1234567890",
"payerName": "Live Payer",
"cptCode": "99213",
"billedAmount": 999.99,
"receivedAmount": 0.0,
"status": "submitted",
"state": "submitted",
"denialReason": None,
"submissionDate": "2026-06-19T12:00:00Z",
"batchId": "b-uuid-1",
"parsedAt": "2026-06-19T12:00:00Z",
}
await app.state.event_bus.publish("claim_written", payload)
new_line = await _read_one_line(response.body_iterator, timeout=1.0)
assert new_line is not None, "no item line arrived after publish"
obj = json.loads(new_line)
assert obj["type"] == "item"
assert obj["data"]["id"] == "NEW-CLM-1"
await _drain_until_disconnect(response.body_iterator)
async def test_claims_stream_heartbeat_after_idle(monkeypatch):
"""With heartbeat=0.2s, a heartbeat line appears within ~1s of idle."""
monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")
s = CycloneStore()
s.add(_make_batch_record(n=1))
response = await _call_endpoint(claims_stream, "/api/claims/stream")
# Drain snapshot (2 lines).
lines = await _read_lines(response.body_iterator, n=2)
assert json.loads(lines[1]) == {
"type": "snapshot_end", "data": {"count": 1},
}
# Read subsequent lines until we see a heartbeat or time out.
deadline = asyncio.get_event_loop().time() + 1.5
saw_heartbeat = False
while asyncio.get_event_loop().time() < deadline:
new_line = await _read_one_line(
response.body_iterator, timeout=0.5,
)
if new_line is None:
continue
obj = json.loads(new_line)
if obj["type"] == "heartbeat":
assert "ts" in obj["data"]
saw_heartbeat = True
break
assert saw_heartbeat, "no heartbeat line arrived within 1.5s"
await _drain_until_disconnect(response.body_iterator)
# --------------------------------------------------------------------------- #
# Task 12 — remittances stream
# --------------------------------------------------------------------------- #
async def test_remittances_stream_yields_snapshot_then_subscribes():
"""Pre-loaded 835 batch → snapshot (1 item + snapshot_end)."""
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-1")
rec = BatchRecord835(
id="b-835-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
response = await _call_endpoint(
remittances_stream, "/api/remittances/stream",
)
assert response.media_type.startswith("application/x-ndjson")
# Read the full snapshot (1 item + snapshot_end), skipping nothing.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
await _drain_until_disconnect(response.body_iterator)
async def test_remittances_stream_emits_after_upsert_remittance():
"""A bus.publish('remittance_written', …) → new item line on stream."""
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-0", pcn="PCN-0")
rec = BatchRecord835(
id="b-835-0", kind="835", input_filename="era0.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
response = await _call_endpoint(
remittances_stream, "/api/remittances/stream",
)
# Drain snapshot to the terminator.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
# Advance one more step so the subscription is registered before
# we publish. The next yielded line may be a heartbeat (with the
# 0.2s heartbeat fixture) or a real event — either is fine, we
# just need ``subscribe_raw`` to have run.
_ = await _read_one_line(response.body_iterator, timeout=2.0)
payload = {
"id": "PCN-NEW",
"claimId": "",
"payerName": "Tail Payer",
"paidAmount": 12.34,
"adjustmentAmount": 0.0,
"status": "received",
"denialReason": None,
"validationWarnings": [],
"receivedDate": "2026-06-19T12:00:00Z",
"batchId": "b-835-1",
"parsedAt": "2026-06-19T12:00:00Z",
"adjustments": [],
}
await app.state.event_bus.publish("remittance_written", payload)
# Read until the new item line appears (skip any heartbeats that
# race in before the published event).
new_obj = await _read_until_type(
response.body_iterator, "item", timeout=2.0,
)
assert new_obj is not None, "no item line arrived after publish"
assert new_obj["data"]["id"] == "PCN-NEW"
await _drain_until_disconnect(response.body_iterator)
# --------------------------------------------------------------------------- #
# Task 13 — activity stream
# --------------------------------------------------------------------------- #
async def test_activity_stream_yields_snapshot_then_subscribes():
"""An 837 batch seeds activity rows → snapshot has at least 1 item."""
s = CycloneStore()
s.add(_make_batch_record(n=2))
response = await _call_endpoint(
activity_stream, "/api/activity/stream",
)
assert response.media_type.startswith("application/x-ndjson")
# Read to snapshot_end — activity may have multiple items.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end is not None
assert snap_end["data"]["count"] >= 1
await _drain_until_disconnect(response.body_iterator)
async def test_activity_stream_emits_after_record_activity():
"""A bus.publish('activity_recorded', …) → new item line on stream."""
s = CycloneStore()
s.add(_make_batch_record(n=1))
response = await _call_endpoint(
activity_stream, "/api/activity/stream",
)
# Drain snapshot.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end is not None
# Advance one step so subscription is registered.
_ = await _read_one_line(response.body_iterator, timeout=2.0)
payload = {
"kind": "claim_submitted",
"ts": "2026-06-19T12:00:00Z",
"batchId": "b-uuid-1",
"claimId": "LIVE-CLM",
"remittanceId": None,
"payload": {"message": "live tail event"},
}
await app.state.event_bus.publish("activity_recorded", payload)
new_obj = await _read_until_type(
response.body_iterator, "item", timeout=2.0,
)
assert new_obj is not None, "no item line arrived after publish"
assert new_obj["data"]["kind"] == "claim_submitted"
assert new_obj["data"]["claimId"] == "LIVE-CLM"
await _drain_until_disconnect(response.body_iterator)
# --------------------------------------------------------------------------- #
# Task 14 — disconnect cancels subscription
# --------------------------------------------------------------------------- #
async def test_client_disconnect_cancels_subscription(monkeypatch):
"""After closing the body iterator, no claim_written subscribers remain.
Closes the body iterator (the test-side disconnect signal — see
the module docstring). The generator must propagate the
``CancelledError`` and tear down its bus subscription.
"""
monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.1")
s = CycloneStore()
s.add(_make_batch_record(n=1))
bus: EventBus = app.state.event_bus
response = await _call_endpoint(claims_stream, "/api/claims/stream")
# Read the full snapshot to the terminator.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end == {"type": "snapshot_end", "data": {"count": 1}}
# Advance one step into the subscription phase so subscribe_raw
# has run and registered the queue.
_ = await _read_one_line(response.body_iterator, timeout=2.0)
# Pre-condition: at least one subscriber registered while the
# stream was open.
assert len(bus._subscribers.get("claim_written", [])) >= 1
# Client disconnect: close the body iterator. The endpoint
# generator receives ``CancelledError`` via Starlette's
# cancellation path and returns; its `bus.subscribe_raw` queue is
# released. Wait briefly for cleanup.
await _drain_until_disconnect(response.body_iterator)
for _ in range(50):
if not bus._subscribers.get("claim_written"):
break
await asyncio.sleep(0.02)
assert bus._subscribers.get("claim_written", []) == [], (
f"subscribers leaked after disconnect: "
f"{bus._subscribers.get('claim_written')!r}"
)
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the in-process EventBus.
These exercise the drop-oldest overflow strategy, per-kind fan-out, and
the non-blocking publish contract. They are async because the bus
exposes an async iterator for subscribers.
"""
from __future__ import annotations
import asyncio
import time
import pytest
from cyclone.pubsub import EventBus, get_event_bus
@pytest.mark.asyncio
async def test_publish_no_subscribers_is_noop():
bus = EventBus()
# No subscribers at all — must not raise.
await bus.publish("x", {"a": 1})
@pytest.mark.asyncio
async def test_publish_to_n_subscribers_delivers_to_all():
bus = EventBus()
queues = [bus.subscribe(["claim"]) for _ in range(3)]
await bus.publish("claim", {"id": "C1"})
received = [await asyncio.wait_for(q.__anext__(), timeout=0.5) for q in queues]
for event in received:
assert event == {"id": "C1", "_kind": "claim"}
@pytest.mark.asyncio
async def test_subscriber_only_receives_matching_kinds():
bus = EventBus()
queue = bus.subscribe(["a"])
await bus.publish("b", {"n": 1})
await bus.publish("a", {"n": 2})
event = await asyncio.wait_for(queue.__anext__(), timeout=0.5)
assert event == {"n": 2, "_kind": "a"}
@pytest.mark.asyncio
async def test_slow_subscriber_does_not_block_publish():
bus = EventBus(max_queue_size=2)
queue = bus.subscribe(["e"])
async def emit():
for i in range(3):
await bus.publish("e", {"i": i})
start = time.monotonic()
await emit()
elapsed = time.monotonic() - start
# Publishing must not block even though the subscriber queue is full
# after the second event; the drop-oldest policy keeps publishes non-blocking.
assert elapsed < 0.05, f"publish blocked for {elapsed:.3f}s"
# The slow subscriber retains only the last two events.
seen = []
for _ in range(2):
seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5))
assert [e["i"] for e in seen] == [1, 2]
@pytest.mark.asyncio
async def test_queue_overflow_drops_oldest():
bus = EventBus(max_queue_size=2)
queue = bus.subscribe(["e"])
for i in range(3):
await bus.publish("e", {"i": i})
seen = []
for _ in range(2):
seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5))
assert [e["i"] for e in seen] == [1, 2]
@pytest.mark.asyncio
async def test_multiple_concurrent_subscribers_each_get_all_events():
bus = EventBus()
queues = [bus.subscribe(["claim"]) for _ in range(2)]
for i in range(5):
await bus.publish("claim", {"i": i})
for q in queues:
seen = []
for _ in range(5):
seen.append(await asyncio.wait_for(q.__anext__(), timeout=0.5))
assert [e["i"] for e in seen] == [0, 1, 2, 3, 4]
def test_get_event_bus_raises_when_app_state_uninitialized():
"""get_event_bus() late-imports cyclone.api; if the lifespan handler
hasn't set ``app.state.event_bus`` (or it's been cleared), the
AttributeError must surface so callers can fail loudly."""
from cyclone.api import app
# The autouse conftest fixture sets app.state.event_bus for every test
# so that endpoint code can call request.app.state.event_bus. We pop
# the key entirely here so __getattr__ raises AttributeError rather
# than returning None for an explicitly-None slot.
state_dict = app.state._state
saved = state_dict.pop("event_bus", None)
try:
with pytest.raises((AttributeError, RuntimeError)):
get_event_bus()
finally:
if saved is not None:
state_dict["event_bus"] = saved
def test_get_event_bus_returns_app_state_bus():
"""When the lifespan handler has initialised the bus, get_event_bus()
returns it. Validates the happy path under the same late-import."""
from cyclone.api import app
from cyclone.pubsub import EventBus
saved = getattr(app.state, "event_bus", None)
bus = EventBus()
app.state.event_bus = bus
try:
assert get_event_bus() is bus
finally:
app.state.event_bus = saved
def test_unsubscribe_removes_queue_from_each_kind():
bus = EventBus()
q1, _ = bus.subscribe_raw(["a", "b"])
q2, _ = bus.subscribe_raw(["a"])
bus.unsubscribe(q1, ["a", "b"])
assert q1 not in bus._subscribers.get("a", [])
assert q1 not in bus._subscribers.get("b", [])
assert q2 in bus._subscribers["a"]
def test_unsubscribe_is_idempotent():
bus = EventBus()
q, _ = bus.subscribe_raw(["a"])
bus.unsubscribe(q, ["a"])
# Second call must not raise.
bus.unsubscribe(q, ["a"])
bus.unsubscribe(q, ["b"]) # wrong kind — also fine
assert "a" not in bus._subscribers
+79
View File
@@ -90,6 +90,85 @@ def test_module_singleton():
assert isinstance(store, CycloneStore)
@pytest.mark.asyncio
async def test_add_837_publishes_claim_written_and_activity_recorded_events():
"""store.add() with event_bus= should publish one claim_written event
per inserted claim and one activity_recorded event per activity row."""
from cyclone.pubsub import EventBus
bus = EventBus()
claim_q = bus.subscribe(["claim_written"])
activity_q = bus.subscribe(["activity_recorded"])
s = CycloneStore()
rec = _make_batch_record()
s.add(rec, event_bus=bus)
# Two claims + two claim_submitted activity events expected.
import asyncio
claims = []
for _ in range(2):
claims.append(await asyncio.wait_for(claim_q.__anext__(), timeout=0.5))
activities = []
for _ in range(2):
activities.append(await asyncio.wait_for(activity_q.__anext__(), timeout=0.5))
# Each event has _kind and a payload with the expected shape.
for ev in claims:
assert ev["_kind"] == "claim_written"
assert "id" in ev
for ev in activities:
assert ev["_kind"] == "activity_recorded"
assert ev["kind"] == "claim_submitted"
assert "claimId" in ev
def test_add_835_publishes_remittance_written_and_activity_recorded_events():
"""835 batches publish remittance_written events (one per remit) and
activity_recorded events (one per activity row)."""
import asyncio
from cyclone.pubsub import EventBus
from cyclone.store import BatchRecord835
# Reuse the 835 result helper from test_store_reconcile.py.
from test_store_reconcile import _make_835_result, _make_remit_with_cas
bus = EventBus()
remit_q = bus.subscribe(["remittance_written"])
activity_q = bus.subscribe(["activity_recorded"])
cp = _make_remit_with_cas(remit_id="CLP-PUB", pcn="PCN-PUB")
rec = BatchRecord835(
id="b-835-1", kind="835", input_filename="test835.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
CycloneStore().add(rec, event_bus=bus)
loop = asyncio.new_event_loop()
try:
remits = []
for _ in range(1):
remits.append(loop.run_until_complete(
asyncio.wait_for(remit_q.__anext__(), timeout=0.5)
))
activities = []
for _ in range(1):
activities.append(loop.run_until_complete(
asyncio.wait_for(activity_q.__anext__(), timeout=0.5)
))
finally:
loop.close()
for ev in remits:
assert ev["_kind"] == "remittance_written"
assert ev["id"] == "PCN-PUB"
for ev in activities:
assert ev["_kind"] == "activity_recorded"
assert ev["kind"] == "remit_received"
assert ev["remittanceId"] == "PCN-PUB"
def test_add_837_persists_batch_and_claims():
s = CycloneStore()
rec = _make_batch_record()
@@ -0,0 +1,754 @@
# Sub-project 5 — Live Tail (NDJSON Pub/Sub): Implementation Plan
**Date:** 2026-06-20
**Status:** Approved
**Branch:** `live-tail`
**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md`
26 tasks across 5 phases. Follows the same task pattern as the SP3/SP4 plans (`- [ ] Step 1 → Step N → commit`). TDD throughout.
Adds real-time "tail -f" updates to `Claims`, `Remittances`, and `ActivityLog`: when a new claim/remittance/activity event lands in the DB, it appears on the relevant page without a manual refresh. Mechanism is **long-lived NDJSON GET endpoints** backed by an **in-process asyncio pub/sub bus** — no SSE, no polling, no new infra deps.
## Task 0: Add `pytest-asyncio` to dev deps
**Files:**
- Modify: `backend/pyproject.toml` (append `"pytest-asyncio>=0.23,<1"` to `dev` list)
- Modify: `backend/pyproject.toml` (under `[tool.pytest.ini_options]`, set `asyncio_mode = "auto"`)
```bash
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -q # smoke: full suite still green
```
- [ ] **Step 1: Add the dep + config**
- [ ] **Step 2: Install + run full suite, confirm green**
- [ ] **Step 3: Commit**
```bash
git add backend/pyproject.toml backend/uv.lock
git commit -m "build(deps): pytest-asyncio for SP5 stream tests"
```
## File inventory
**Backend (2 new, 3 modified):**
- `backend/src/cyclone/pubsub.py``EventBus` (NEW)
- `backend/src/cyclone/api.py` — +3 streaming endpoints; lifespan handler init `app.state.event_bus` (MODIFIED)
- `backend/src/cyclone/store.py``upsert_claim/upsert_remittance/record_activity` publish via injected bus (MODIFIED)
- `backend/src/cyclone/__main__.py` — drop the `db.init_db()` call (now lives in lifespan) (MODIFIED)
- `backend/tests/test_pubsub.py` — ~6 tests (NEW)
- `backend/tests/test_api_stream_live.py` — ~8 tests (NEW)
**Frontend (5 new, 4 modified):**
- `src/lib/tail-stream.ts` — NDJSON parser over `ReadableStream` (NEW)
- `src/store/tail-store.ts` — Zustand append-only, FIFO-capped slices (NEW)
- `src/hooks/useTailStream.ts` — connection lifecycle hook (NEW)
- `src/hooks/useMergedTail.ts` — base + tail merge with filter (NEW)
- `src/components/TailStatusPill.tsx` — status badge + reconnect button (NEW)
- `src/lib/tail-stream.test.ts` — ~5 tests (NEW)
- `src/store/tail-store.test.ts` — ~5 tests (NEW)
- `src/hooks/useTailStream.test.ts` — ~6 tests (NEW)
- `src/hooks/useMergedTail.test.ts` — ~4 tests (NEW)
- `src/pages/Claims.tsx` — swap `data.items``useMergedTail` + toolbar pill (MODIFIED)
- `src/pages/Remittances.tsx` — same (MODIFIED)
- `src/pages/ActivityLog.tsx` — same (MODIFIED)
- `src/pages/Claims.test.tsx` — +2 live-tail integration tests (MODIFIED)
Total: **9 new files, 6 modified, ~36 new tests.**
---
## Phase 1 — Backend: `EventBus`
### Task 1: `cyclone.pubsub.EventBus` skeleton + `publish`
**Files:**
- Create: `backend/src/cyclone/pubsub.py`
- Create: `backend/tests/test_pubsub.py`
API per spec §3.1:
```python
class EventBus:
def __init__(self, max_queue_size: int = 256) -> None: ...
async def publish(self, kind: str, payload: dict) -> None: ...
def subscribe(self, kinds: list[str]) -> "AsyncIterator[dict]": ...
```
Internals:
- `self._subscribers: dict[str, list[asyncio.Queue[dict]]]`
- `publish`: iterate `_subscribers[kind]`, call `queue.put_nowait(event)`. On `QueueFull`, drop oldest via `get_nowait() + task_done`, then enqueue. Never blocks.
- `subscribe`: returns an async generator; each yielded value is `{**payload, "_kind": kind}`.
- The subscriber queue size equals `max_queue_size` (default 256).
Tests (`backend/tests/test_pubsub.py`, ~6):
- `test_publish_no_subscribers_is_noop``publish("x", {"a": 1})` on a fresh bus doesn't raise.
- `test_publish_to_n_subscribers_delivers_to_all`
- `test_subscriber_only_receives_matching_kinds` — subscribe to `["a"]`, publish `b` + `a`, only `a` arrives.
- `test_slow_subscriber_does_not_block_publish` — one subscriber queue full, publish from another task completes within 10ms.
- `test_queue_overflow_drops_oldest` — fill a queue to capacity, publish another, the oldest is gone.
- `test_multiple_concurrent_subscribers_each_get_all_events`
- [ ] **Step 1: Add failing tests** in `backend/tests/test_pubsub.py` (`@pytest.mark.asyncio` on each)
- [ ] **Step 2: Run tests, confirm red**
- [ ] **Step 3: Implement `EventBus`** in `backend/src/cyclone/pubsub.py`
- [ ] **Step 4: Run tests, confirm green**
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/pubsub.py backend/tests/test_pubsub.py
git commit -m "feat(pubsub): EventBus with drop-oldest overflow, per-kind fan-out"
```
### Task 2: Module-level `get_event_bus()` accessor
**Files:**
- Modify: `backend/src/cyclone/pubsub.py`
- Modify: `backend/tests/test_pubsub.py`
Add (per spec §3.3):
```python
def get_event_bus() -> EventBus:
"""Module-level accessor; tests stub via monkeypatch."""
from cyclone.api import app # late import to avoid circular
return app.state.event_bus
```
Test:
- `test_get_event_bus_returns_attribute_error_when_app_not_initialized` — calling before lifespan raises; verifies the late-import contract.
- [ ] **Step 1: Add failing test**
- [ ] **Step 2: Implement the accessor**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/pubsub.py backend/tests/test_pubsub.py
git commit -m "feat(pubsub): get_event_bus() late-import accessor"
```
### Task 3: Phase 1 commit + full suite
```bash
.venv/bin/pytest -q
```
Expected: ~417 + 1 skip + ~7 new = ~424 pass. All green.
- [ ] **Step 1: Run full backend suite, confirm green**
- [ ] **Step 2: Tag phase complete (no commit unless changes)**
---
## Phase 2 — Backend: lifespan + store publishing
### Task 4: Add FastAPI lifespan handler that initializes `event_bus`
**Files:**
- Modify: `backend/src/cyclone/api.py`
Add at module top:
```python
from contextlib import asynccontextmanager
from cyclone.pubsub import EventBus
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize DB schema/migrations inside the lifespan so the bus has
# access to a fully-populated store before any request lands.
from cyclone import db
db.init_db()
app.state.event_bus = EventBus()
yield
# No teardown needed — single-process local-only.
app = FastAPI(..., lifespan=lifespan)
```
Important: keep the existing `@app.on_event("startup")` if present, OR remove it and rely solely on the lifespan. (Verify which is currently in use; if `@app.on_event` is absent, this is a pure add.)
- [ ] **Step 1: Add the lifespan handler + wire it to `app`**
- [ ] **Step 2: Smoke: import `cyclone.api` succeeds; `app.router.lifespan_context` is set**
- [ ] **Step 3: Commit**
```bash
git add backend/src/cyclone/api.py
git commit -m "feat(api): lifespan handler initializes EventBus + db"
```
### Task 5: Drop `db.init_db()` from `__main__.py`
**Files:**
- Modify: `backend/src/cyclone/__main__.py`
Remove the explicit `db.init_db()` call before `uvicorn.main()` — lifespan handles it now.
- [ ] **Step 1: Remove the line**
- [ ] **Step 2: Smoke: `python -m cyclone serve &; curl /api/health; kill %1`**
- [ ] **Step 3: Commit**
```bash
git add backend/src/cyclone/__main__.py
git commit -m "refactor(main): rely on lifespan for db init (SP5)"
```
### Task 6: `store.upsert_claim` publishes `claim_written`
**Files:**
- Modify: `backend/src/cyclone/store.py`
- Modify: `backend/tests/test_store.py`
Change signature to accept an optional `event_bus: EventBus | None = None`. After the `s.commit()` for an upsert:
```python
if event_bus is not None:
await event_bus.publish("claim_written", <ui-shaped claim dict>)
```
Test:
- `test_upsert_claim_publishes_event_with_ui_shape` — instantiate an `EventBus`, subscribe to `["claim_written"]`, call `upsert_claim(bus=bus, ...)`, drain the subscriber, assert event payload has same keys as `iter_claims` returns.
- [ ] **Step 1: Add failing test**
- [ ] **Step 2: Implement the publish call**
- [ ] **Step 3: Run test, confirm green**
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(store): upsert_claim publishes claim_written to EventBus"
```
### Task 7: `store.upsert_remittance` + `record_activity` publish
**Files:**
- Modify: `backend/src/cyclone/store.py`
- Modify: `backend/tests/test_store.py`
Same pattern as Task 6 for the other two write paths:
- `upsert_remittance``"remittance_written"` (payload = `to_ui_remittance` shape)
- `record_activity``"activity_recorded"` (payload = `to_activity_event` shape)
Tests:
- `test_upsert_remittance_publishes_remittance_written`
- `test_record_activity_publishes_activity_recorded`
- [ ] **Step 1: Add 2 failing tests**
- [ ] **Step 2: Implement both publish calls**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(store): upsert_remittance + record_activity publish events"
```
### Task 8: Wire `event_bus` into parse-837 / parse-835 endpoints
**Files:**
- Modify: `backend/src/cyclone/api.py`
Update the two parse endpoints to pass `event_bus=app.state.event_bus` into the store calls. Test impact: existing parse-837/parse-835 tests should continue to pass — the bus is wired but no subscribers are attached in those tests, so `publish` is a no-op.
- [ ] **Step 1: Wire the bus into both endpoints**
- [ ] **Step 2: Run full backend suite, confirm green**
- [ ] **Step 3: Commit**
```bash
git add backend/src/cyclone/api.py
git commit -m "feat(api): parse endpoints pass EventBus into store writes"
```
### Task 9: Phase 2 commit + full suite
```bash
.venv/bin/pytest -q
```
Expected: ~424 + ~3 new = ~427 pass. All green.
- [ ] **Step 1: Run full suite, confirm green**
- [ ] **Step 2: Tag phase complete**
---
## Phase 3 — Backend: streaming endpoints
### Task 10: `_stream_ndjson` helper
**Files:**
- Modify: `backend/src/cyclone/api.py`
Helper that wraps a generator of dict events into NDJSON lines:
```python
def _ndjson_line(event: dict) -> bytes:
return (json.dumps(event, separators=(",", ":")) + "\n").encode("utf-8")
```
- [ ] **Step 1: Add the helper**
- [ ] **Step 2: Commit**
```bash
git add backend/src/cyclone/api.py
git commit -m "refactor(api): _ndjson_line helper for streaming responses"
```
### Task 11: `GET /api/claims/stream`
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Create: `backend/tests/test_api_stream_live.py`
Endpoint signature mirrors `list_claims` query params (forwarded):
```python
@app.get("/api/claims/stream")
async def claims_stream(
request: Request,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
):
bus: EventBus = request.app.state.event_bus
async def gen():
# 1. Snapshot
rows = store.iter_claims(
status=status, provider_npi=provider_npi, payer=payer,
date_from=date_from, date_to=date_to,
sort=sort or "-submission_date", order=order, limit=limit,
)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
# 2. Subscribe
async for event in bus.subscribe(["claim_written"]):
if await request.is_disconnected():
return
yield _ndjson_line({"type": "item", "data": event})
return StreamingResponse(gen(), media_type="application/x-ndjson")
```
Tests (with `httpx.AsyncClient` + `pytest-asyncio`):
- `test_claims_stream_yields_snapshot_then_snapshot_end` — pre-populate 3 claims, hit stream, assert first 3 lines are `{"type":"item"}`, 4th is `{"type":"snapshot_end","data":{"count":3}}`.
- `test_claims_stream_emits_new_item_after_publish` — open stream in one task, call `bus.publish("claim_written", payload)` in another, assert the new item appears within 100ms.
- `test_claims_stream_heartbeat_after_15s_idle` — override heartbeat to 1s via env var or test fixture; assert heartbeat line arrives within 1.5s.
- [ ] **Step 1: Add 3 failing tests**
- [ ] **Step 2: Implement the endpoint (without heartbeat first)**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Add the 15s heartbeat via `asyncio.wait_for` loop** (overridable for tests)
- [ ] **Step 5: Run tests, confirm green**
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_api_stream_live.py
git commit -m "feat(api): GET /api/claims/stream — snapshot + subscribe + heartbeat"
```
### Task 12: `GET /api/remittances/stream`
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Modify: `backend/tests/test_api_stream_live.py`
Same shape; subscribes to `["remittance_written"]`. Default sort `-received_date`.
Tests:
- `test_remittances_stream_yields_snapshot_then_subscribes`
- `test_remittances_stream_emits_after_upsert_remittance`
- [ ] **Step 1: Add 2 failing tests**
- [ ] **Step 2: Implement the endpoint**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_api_stream_live.py
git commit -m "feat(api): GET /api/remittances/stream"
```
### Task 13: `GET /api/activity/stream`
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Modify: `backend/tests/test_api_stream_live.py`
Same shape; subscribes to `["activity_recorded"]`. Default sort `-timestamp`, default `limit=50` (smaller — activity is high-volume).
Tests:
- `test_activity_stream_yields_snapshot_then_subscribes`
- `test_activity_stream_emits_after_record_activity`
- [ ] **Step 1: Add 2 failing tests**
- [ ] **Step 2: Implement the endpoint**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_api_stream_live.py
git commit -m "feat(api): GET /api/activity/stream (limit=50)"
```
### Task 14: Client disconnect cancels subscription
**Files:**
- Modify: `backend/tests/test_api_stream_live.py`
Test:
- `test_client_disconnect_cancels_subscription` — open stream, read `snapshot_end`, close the response, then assert `bus._subscribers["claim_written"]` is empty (use a fresh bus per test).
- [ ] **Step 1: Add failing test**
- [ ] **Step 2: If not already implemented (the `request.is_disconnected()` check in Task 11 covers it), confirm**
- [ ] **Step 3: Run test, confirm green**
- [ ] **Step 4: Commit if any change**
### Task 15: Phase 3 commit + full suite
```bash
.venv/bin/pytest -q
```
Expected: ~427 + ~10 new = ~437 pass. All green.
- [ ] **Step 1: Run full suite, confirm green**
- [ ] **Step 2: Tag phase complete**
---
## Phase 4 — Frontend: NDJSON parser + tail store
### Task 16: `src/lib/tail-stream.ts` — NDJSON parser
**Files:**
- Create: `src/lib/tail-stream.ts`
- Create: `src/lib/tail-stream.test.ts`
API per spec §3.4:
```ts
export type TailEvent =
| { type: "item"; data: unknown }
| { type: "snapshot_end"; data: { count: number } }
| { type: "heartbeat"; data: { ts: string } }
| { type: "item_dropped"; data: { id: string } }
| { type: "error"; data: { message: string } };
export type TailResource = "claims" | "remittances" | "activity";
export async function* streamTail(
resource: TailResource,
opts?: { signal?: AbortSignal; baseUrl?: string },
): AsyncIterableIterator<TailEvent>;
```
Implementation: `fetch(${baseUrl}/api/${resource}/stream, { signal })` with `Accept: application/x-ndjson` header; pipe `response.body!` through `TextDecoderStream` then an NDJSON-line splitter that yields `JSON.parse`d typed events. Malformed lines are skipped with `console.warn`.
Tests (`src/lib/tail-stream.test.ts`, ~5):
- `test_parses_well_formed_ndjson_into_typed_events` — feed a `ReadableStream` of well-formed lines (use a `TransformStream` helper in tests).
- `test_yields_typed_error_on_error_line``{"type":"error","data":{"message":"x"}}` yields as `{ type: "error", data: { message: "x" } }`.
- `test_handles_heartbeat_silently` — heartbeat line yields correctly typed.
- `test_abort_signal_cancels_mid_stream` — pass an `AbortController`, abort after first event, generator throws/returns without further yield.
- `test_malformed_line_skipped_next_valid_still_emitted` — bad JSON in middle, surrounding good lines still parse.
- [ ] **Step 1: Add failing tests**
- [ ] **Step 2: Implement `streamTail`**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add src/lib/tail-stream.ts src/lib/tail-stream.test.ts
git commit -m "feat(frontend): NDJSON streamTail parser for /api/{resource}/stream"
```
### Task 17: `src/store/tail-store.ts` — Zustand append-only with FIFO cap
**Files:**
- Create: `src/store/tail-store.ts`
- Create: `src/store/tail-store.test.ts`
API per spec §3.4:
```ts
export const TAIL_CAP = 10_000;
interface TailStore {
claims: Record<string, ClaimListItem>;
remittances: Record<string, RemitListItem>;
activity: ActivityEvent[];
addClaim: (c: ClaimListItem) => void;
addRemittance: (r: RemitListItem) => void;
addActivity: (a: ActivityEvent) => void;
reset: (resource: TailResource) => void;
}
export const useTailStore = create<TailStore>((set) => ({ ... }));
```
Dedup: `addClaim(c)` is a no-op when `c.id` already present. FIFO cap: when `Object.keys(claims).length > TAIL_CAP`, drop the oldest by id (sort keys by insertion order via a small parallel `OrderMap`, or just evict the first 100 oldest when the cap is exceeded).
Tests (~5):
- `test_add_claim_adds_new_claim_keyed_by_id`
- `test_add_claim_with_duplicate_id_is_noop`
- `test_reset_claims_clears_only_claims_slice`
- `test_add_activity_appends` (no stable id)
- `test_fifo_cap_evicts_oldest_when_over_10000`
- [ ] **Step 1: Add failing tests**
- [ ] **Step 2: Implement the store**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add src/store/tail-store.ts src/store/tail-store.test.ts
git commit -m "feat(frontend): tail-store Zustand with FIFO cap 10k per slice"
```
### Task 18: Phase 4 commit + frontend check
```bash
npm run typecheck
npm test -- --run
```
Expected: 124 + ~10 new = ~134 frontend pass.
- [ ] **Step 1: Run typecheck + tests, confirm green**
- [ ] **Step 2: Commit if any change**
---
## Phase 5 — Frontend: hooks + page integration
### Task 19: `src/hooks/useTailStream.ts` — connection lifecycle
**Files:**
- Create: `src/hooks/useTailStream.ts`
- Create: `src/hooks/useTailStream.test.ts`
API per spec §3.4:
```ts
export type TailStatus = "connecting" | "live" | "reconnecting" | "closed" | "stalled" | "error";
export function useTailStream(resource: TailResource): {
status: TailStatus;
lastEventAt: Date | null;
error: Error | null;
forceReconnect: () => void;
};
```
Behavior (per spec):
- On mount: opens stream; status = `connecting`.
- On `snapshot_end`: status = `live`.
- On `item`: dispatches to `useTailStore`; updates `lastEventAt`.
- On error: status = `reconnecting`; backoff 1s → 2s → 4s → 8s → 16s → 30s cap.
- If no event (incl. heartbeat) for 30s: status = `stalled`.
- On unmount: abort; status = `closed`.
- `forceReconnect()`: cancels and re-opens immediately.
Tests (~6) — mock `streamTail` with a vitest module mock:
- `test_on_mount_status_connecting_then_live_after_snapshot_end`
- `test_on_stream_error_status_error`
- `test_on_abort_status_closed_no_reconnect`
- `test_on_event_dispatches_to_tail_store` — assert `useTailStore.getState().addClaim` is called.
- `test_reconnect_status_cycles_reconnecting_to_connecting_to_live`
- `test_reconnect_dedup_duplicate_items_appear_once_in_store`
- [ ] **Step 1: Add failing tests**
- [ ] **Step 2: Implement the hook** (use `useEffect` + `useRef` for backoff state; `AbortController` per attempt)
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add src/hooks/useTailStream.ts src/hooks/useTailStream.test.ts
git commit -m "feat(frontend): useTailStream lifecycle hook with backoff + stall detection"
```
### Task 20: `src/hooks/useMergedTail.ts` — merge base + tail with filter
**Files:**
- Create: `src/hooks/useMergedTail.ts`
- Create: `src/hooks/useMergedTail.test.ts`
API per spec §3.4:
```ts
export function useMergedTail<T extends { id: string }>(
resource: TailResource,
baseItems: T[],
filterFn?: (item: T) => boolean,
): T[];
```
Behavior:
- Reads the matching slice from `useTailStore`.
- Applies `filterFn` to tail items if provided.
- Dedups by `id` against `baseItems`.
- Returns `baseItems` followed by tail items in arrival order.
Tests (~4):
- `test_merges_base_and_tail_by_id_base_first`
- `test_filter_predicate_drops_tail_items_that_dont_match`
- `test_empty_tail_store_returns_base_unchanged`
- `test_new_tail_item_appears_at_top`
- [ ] **Step 1: Add failing tests**
- [ ] **Step 2: Implement the hook**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add src/hooks/useMergedTail.ts src/hooks/useMergedTail.test.ts
git commit -m "feat(frontend): useMergedTail merges base + tail with filter"
```
### Task 21: `src/components/TailStatusPill.tsx` — status badge + reconnect button
**Files:**
- Create: `src/components/TailStatusPill.tsx`
Small component (no test needed beyond a smoke render check inside Claims.test.tsx):
- Props: `{ status: TailStatus; lastEventAt: Date | null; onReconnect: () => void }`
- Renders the existing `<Badge>` with variant per status (live=success, connecting/reconnecting=warning, closed/stalled/error=error/destructive), plus "Last event: 12s ago" subtitle (computed via `useEffect` ticking every second), plus a "↻ Reconnect" button shown only when `status === "stalled" || status === "error"`.
- [ ] **Step 1: Implement the component**
- [ ] **Step 2: Commit**
```bash
git add src/components/TailStatusPill.tsx
git commit -m "feat(frontend): TailStatusPill with reconnect button"
```
### Task 22: `Claims.tsx` — swap to `useMergedTail` + toolbar pill
**Files:**
- Modify: `src/pages/Claims.tsx`
- Modify: `src/pages/Claims.test.tsx`
Wire-up:
- Replace `data?.items ?? []` with `useMergedTail("claims", data?.items ?? [], filterFn)`.
- Call `useTailStream("claims")` for status.
- Render `<TailStatusPill>` in the toolbar with status + lastEventAt + onReconnect = `forceReconnect`.
- Existing tests must still pass; add 2 new tests:
- `test_live_tail_arrival_triggers_row_in_table` — mock `streamTail` to emit an `item` for a new claim, assert row count + row text appears.
- `test_status_pill_shows_live_after_stream_connects` — mock `streamTail` to emit `snapshot_end`, assert pill text contains `live`.
- [ ] **Step 1: Add 2 failing tests**
- [ ] **Step 2: Wire up the page**
- [ ] **Step 3: Run tests, confirm green**
- [ ] **Step 4: Commit**
```bash
git add src/pages/Claims.tsx src/pages/Claims.test.tsx
git commit -m "feat(frontend): Claims page wires live tail + TailStatusPill"
```
### Task 23: `Remittances.tsx` + `ActivityLog.tsx` — same pattern
**Files:**
- Modify: `src/pages/Remittances.tsx`
- Modify: `src/pages/ActivityLog.tsx`
Mirror Task 22: swap to `useMergedTail("remittances", ...)` / `useMergedTail("activity", ...)`, render `<TailStatusPill>` in the toolbar. Use the same `filterFn` the page already passes to its list query.
- [ ] **Step 1: Wire both pages**
- [ ] **Step 2: Run typecheck + tests**
- [ ] **Step 3: Commit**
```bash
git add src/pages/Remittances.tsx src/pages/ActivityLog.tsx
git commit -m "feat(frontend): Remittances + ActivityLog wire live tail"
```
### Task 24: Phase 5 commit + full suite
```bash
npm run typecheck
npm test -- --run
.venv/bin/pytest -q
```
Expected: ~134 + ~10 new = ~144 frontend; ~437 backend. All green.
- [ ] **Step 1: Run all checks, confirm green**
- [ ] **Step 2: Commit if any change**
---
## Phase 6 — Docs + smoke + merge
### Task 25: End-to-end smoke
Per spec §8 smoke:
1. Start backend (`backend/.venv/bin/python -m cyclone serve &`).
2. Start frontend (`npm run dev &`).
3. Open `http://localhost:5173/claims` in browser.
4. In another tab (or via curl), POST `co_medicaid_837p.txt` to `/api/parse-837` (`curl -F file=@...`).
5. Verify the first `/claims` tab shows the new rows appearing without manual refresh.
6. Verify the status pill reads `live`.
7. Verify the backend logs show no errors.
8. Kill both servers.
- [ ] **Step 1: Run the smoke**
- [ ] **Step 2: Commit empty**
```bash
git commit --allow-empty -m "smoke: end-to-end SP5 (live tail) flow passes"
```
### Task 26: README + merge to main
**Files:**
- Modify: `README.md` — under "Roadmap" or new "Live updates" section, document the tail behavior + status pill + reconnect UX.
- [ ] **Step 1: Update README**
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: document live-tail behavior in README"
```
### Task 27: Cleanup + merge to main
1. `git status` — clean.
2. `git merge --ff-only live-tail`.
3. From `main`: `cd backend && .venv/bin/pytest -q` → must show ~437 pass + 1 skip; `cd .. && npm test -- --run` → ~144 pass.
4. `git worktree remove .worktrees/live-tail` (if applicable).
5. `git branch -d live-tail`.
6. Update SP4 plan checkboxes (if any drift) — not applicable here; this is the SP5 plan itself. Tick all `[ ]` to `[x]` in this file before committing.
- [ ] **Step 1: Verify clean**
- [ ] **Step 2: Discard stale uncommitted changes (if any)**
- [ ] **Step 3: Fast-forward merge to main**
- [ ] **Step 4: Remove worktree + delete branch**
- [ ] **Step 5: Tick all `[ ]` to `[x]` in this file; commit as `docs: tick SP5 plan checkboxes`**
---
## Test count targets
| Phase | New backend | New frontend | Running total |
|-------|-------------|--------------|---------------|
| Start (post-SP4) | 417 + 1 skip | 124 | 417 / 124 |
| 0 (deps) | 0 | 0 | 417 / 124 |
| P1 end | +7 | 0 | 424 / 124 |
| P2 end | +3 | 0 | 427 / 124 |
| P3 end | +10 | 0 | 437 / 124 |
| P4 end | 0 | +10 | 437 / 134 |
| P5 end | 0 | +10 (incl. 2 page tests) | 437 / 144 |
| **Final target** | **~437 + 1 skip** | **~144** | — |
## Risk reminders
- **`pytest-asyncio` config** — set `asyncio_mode = "auto"` so `@pytest.mark.asyncio` isn't required per test. Verify no other test file gets accidentally parallelized.
- **Lifespan replaces on_event** — if any existing startup hook is wired via `@app.on_event("startup")`, either fold it into the lifespan or leave both (idempotent). Check before deleting the existing handler in `__main__`.
- **Tail-store growth** — cap at 10k per slice (FIFO). Operators who never refresh the page won't OOM the tab.
- **Heartbeat timing in tests** — make the 15s heartbeat overridable via env var (`CYCLONE_HEARTBEAT_INTERVAL_S`) so tests can use 0.1s.
- **Stall threshold vs reconnect** — 30s stall threshold; the hook should NOT auto-reconnect on stall (user confirms). Distinguish from `reconnecting` (auto-backoff in progress).
- **NDJSON partial lines** — the splitter must buffer trailing partial chunks across stream chunks; verify with a test that streams a line across two `ReadableStream` pulls.
- **Filter drift on the client** — tail-store contains items that may not match the current page filter; `useMergedTail` filters them out. Verify with a test that flips the filter mid-stream.
- **ActivityLog already streams from parse-837** — the new tail stream is **additive**, not a replacement. The page already has its own `parse837` NDJSON consumer; the tail stream survives parse completion.
## Worktree note
Use `superpowers:using-git-worktrees` to create `.worktrees/live-tail` from `main` before Task 1. All commits land on the `live-tail` branch until the final ff-merge to `main`.
+184
View File
@@ -0,0 +1,184 @@
// @vitest-environment happy-dom
// TailStatusPill is a leaf component with no async state and no hooks
// beyond a single setInterval — happy-dom is the right env so we can
// inspect the rendered DOM.
(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 { TailStatusPill } from "./TailStatusPill";
import type { TailStatus } from "@/hooks/useTailStream";
/**
* Minimal render helper — the project doesn't ship
* `@testing-library/react`, so we render straight into a real
* `createRoot` and inspect the DOM via `container.innerHTML` and
* `container.querySelector`.
*
* Note: React 18's `createRoot` defers DOM mutation until the calling
* microtask flushes unless we wrap the render in `act`. The pill's only
* effect is a `setInterval`, so we use `act(() => { root.render(node) })`
* — sync act is fine because no state updates fire during render.
*/
function renderInto(node: React.ReactElement): {
container: HTMLDivElement;
root: Root;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(node);
});
return {
container,
root,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
const ALL_STATUSES: TailStatus[] = [
"connecting",
"live",
"reconnecting",
"closed",
"stalled",
"error",
];
describe("TailStatusPill", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T12:00:30Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("test_renders_badge_for_each_status", () => {
// Iterate every TailStatus and assert no crash, plus a sensible label.
// The plan's smoke spec calls for "renders all 5 statuses"; we test
// all 6 (closed is also a status even if rare in practice).
for (const status of ALL_STATUSES) {
const { container, root, unmount } = renderInto(
<TailStatusPill
status={status}
lastEventAt={null}
onReconnect={() => {}}
/>,
);
// The pill root should be present.
const pill = container.querySelector('[data-testid="tail-status-pill"]');
expect(pill).not.toBeNull();
// The Badge child should carry the human label — match by text
// content (the badge text is the only handoff the user reads).
const expectedLabels: Record<TailStatus, string> = {
connecting: "Connecting",
live: "Live",
reconnecting: "Reconnecting",
closed: "Closed",
stalled: "Stalled",
error: "Error",
};
expect(pill?.textContent).toContain(expectedLabels[status]);
unmount();
// Silence "unmount was not wrapped in act" warnings; renderInto's
// unmount is a sync root.unmount() and React tolerates this for
// leaf components with no async state.
void root;
}
});
it("test_shows_reconnect_button_only_for_stalled_and_error", () => {
const onReconnect = vi.fn();
for (const status of ALL_STATUSES) {
const { container, unmount } = renderInto(
<TailStatusPill
status={status}
lastEventAt={null}
onReconnect={onReconnect}
/>,
);
const button = container.querySelector(
'[data-testid="tail-status-pill-reconnect"]',
);
if (status === "stalled" || status === "error") {
expect(button).not.toBeNull();
expect(button?.textContent).toContain("Reconnect");
} else {
expect(button).toBeNull();
}
unmount();
}
});
it("test_subtitle_says_never_when_lastEventAt_is_null", () => {
const { container, unmount } = renderInto(
<TailStatusPill
status="live"
lastEventAt={null}
onReconnect={() => {}}
/>,
);
const subtitle = container.querySelector(
'[data-testid="tail-status-pill-subtitle"]',
);
expect(subtitle).not.toBeNull();
expect(subtitle?.textContent).toBe("Last event: never");
unmount();
});
it("test_subtitle_shows_age_in_seconds_when_lastEventAt_recent", () => {
// 12 seconds ago — fake-timer driven so the assertion is stable.
const eventAt = new Date("2026-06-20T12:00:18Z");
const { container, unmount } = renderInto(
<TailStatusPill
status="live"
lastEventAt={eventAt}
onReconnect={() => {}}
/>,
);
const subtitle = container.querySelector(
'[data-testid="tail-status-pill-subtitle"]',
);
expect(subtitle?.textContent).toBe("Last event: 12s ago");
unmount();
});
it("test_reconnect_button_invokes_onReconnect_when_clicked", () => {
const onReconnect = vi.fn();
const { container, unmount } = renderInto(
<TailStatusPill
status="stalled"
lastEventAt={null}
onReconnect={onReconnect}
/>,
);
const button = container.querySelector(
'[data-testid="tail-status-pill-reconnect"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
button?.click();
expect(onReconnect).toHaveBeenCalledTimes(1);
unmount();
});
});
+122
View File
@@ -0,0 +1,122 @@
// ---------------------------------------------------------------------------
// Live-tail status pill (sub-project 5, Phase 5 Task 21).
//
// Tiny presentational component used in the toolbar of `Claims`,
// `Remittances`, and `ActivityLog` to surface the connection status of
// `useTailStream`. Renders the existing `<Badge>` (variants from
// `src/components/ui/badge.tsx`) plus a "Last event: 12s ago" subtitle
// that re-ticks every second, plus a "↻ Reconnect" button that only
// appears when the connection has visibly failed (`stalled` or `error`).
//
// Variant mapping (per spec):
// live → success
// connecting → warning
// reconnecting → warning
// closed → destructive
// stalled → destructive
// error → destructive
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { TailStatus } from "@/hooks/useTailStream";
export interface TailStatusPillProps {
status: TailStatus;
lastEventAt: Date | null;
onReconnect: () => void;
}
const STATUS_LABEL: Record<TailStatus, string> = {
connecting: "Connecting…",
live: "Live",
reconnecting: "Reconnecting…",
closed: "Closed",
stalled: "Stalled",
error: "Error",
};
type BadgeVariant = "success" | "warning" | "destructive";
const STATUS_VARIANT: Record<TailStatus, BadgeVariant> = {
live: "success",
connecting: "warning",
reconnecting: "warning",
closed: "destructive",
stalled: "destructive",
error: "destructive",
};
/**
* Render a duration in seconds as a compact human label. We deliberately
* keep the units small — under a minute is the common case for a healthy
* tail (the stall detector fires at 30s, so anything beyond is a real
* outage the user will want to investigate).
*/
function formatAge(seconds: number): string {
if (seconds < 0) return "0s";
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
export function TailStatusPill({
status,
lastEventAt,
onReconnect,
}: TailStatusPillProps): React.JSX.Element {
// Re-tick every second so the "Last event: 12s ago" subtitle stays
// current without the parent having to re-render. We mount the
// interval only once per pill instance.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const ageSeconds = lastEventAt
? Math.max(0, Math.floor((now - lastEventAt.getTime()) / 1000))
: null;
const subtitle =
ageSeconds === null
? "Last event: never"
: `Last event: ${formatAge(ageSeconds)} ago`;
// Only show the manual reconnect affordance when the connection has
// visibly failed. `closed` is reserved for the post-unmount state and
// is intentionally not actionable — the component is about to vanish.
const showReconnect = status === "stalled" || status === "error";
return (
<div
className="inline-flex items-center gap-2"
data-testid="tail-status-pill"
>
<Badge variant={STATUS_VARIANT[status]} aria-label={`tail status: ${status}`}>
{STATUS_LABEL[status]}
</Badge>
<span
className="text-xs text-muted-foreground"
data-testid="tail-status-pill-subtitle"
>
{subtitle}
</span>
{showReconnect && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onReconnect}
data-testid="tail-status-pill-reconnect"
>
Reconnect
</Button>
)}
</div>
);
}
+231
View File
@@ -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();
});
});
+77
View File
@@ -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[])];
}
+403
View File
@@ -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();
});
});
+237
View File
@@ -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 };
}
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment node
// `streamTail` pipes a `fetch()` response body through a `TextDecoderStream`
// and a manual NDJSON-line splitter. We mock `fetch` with `vi.stubGlobal`
// and drive a fake `ReadableStream` from the test, so no DOM/happy-dom
// is needed — Node 22's globals (Response, ReadableStream, TextDecoderStream)
// are sufficient.
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamTail, type TailEvent } from "./tail-stream";
/**
* Build a controllable ReadableStream<Uint8Array> and a tiny driver. The
* stream's controller is captured at construction so the test can decide
* exactly when each NDJSON line is enqueued and when to close. The body
* is wrapped in a `Response` (matching what `fetch()` would return).
*/
function makeStream() {
const encoder = new TextEncoder();
let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controllerRef = c;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/x-ndjson" },
}),
push: (line: string) => {
controllerRef?.enqueue(encoder.encode(line + "\n"));
},
close: () => controllerRef?.close(),
};
}
/**
* Drain an async iterable into an array. Useful for asserting the full
* emission list without manually awaiting each `.next()`.
*/
async function collect<T>(iter: AsyncIterableIterator<T>): Promise<T[]> {
const out: T[] = [];
for await (const v of iter) out.push(v);
return out;
}
describe("streamTail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("test_parses_well_formed_ndjson_into_typed_events", async () => {
const driver = makeStream();
const fetchMock = vi.fn().mockResolvedValue(driver.response);
vi.stubGlobal("fetch", fetchMock);
const gen = streamTail("claims");
driver.push('{"type":"item","data":{"id":"CLM-1"}}');
driver.push('{"type":"item","data":{"id":"CLM-2"}}');
driver.push('{"type":"item","data":{"id":"CLM-3"}}');
driver.close();
const first = await gen.next();
const second = await gen.next();
const third = await gen.next();
const tail = await gen.next();
expect(first.done).toBe(false);
expect(second.done).toBe(false);
expect(third.done).toBe(false);
expect(tail.done).toBe(true);
expect([first.value, second.value, third.value]).toEqual<TailEvent[]>([
{ type: "item", data: { id: "CLM-1" } },
{ type: "item", data: { id: "CLM-2" } },
{ type: "item", data: { id: "CLM-3" } },
]);
// The fetch call itself must target /api/claims/stream with the
// NDJSON Accept header.
expect(fetchMock).toHaveBeenCalledTimes(1);
const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(calledUrl).toBe("/api/claims/stream");
expect(calledInit.headers).toMatchObject({ Accept: "application/x-ndjson" });
});
it("test_yields_typed_error_on_error_line", async () => {
const driver = makeStream();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
const gen = streamTail("remittances");
driver.push('{"type":"error","data":{"message":"x"}}');
driver.close();
const events = await collect(gen);
expect(events).toEqual<TailEvent[]>([
{ type: "error", data: { message: "x" } },
]);
});
it("test_handles_heartbeat_silently", async () => {
// "Silently" here means: still yields the correctly-typed heartbeat
// (no special filtering) — the consumer (useTailStream) decides what
// to do with it. The lib just has to forward it.
const driver = makeStream();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
const gen = streamTail("activity");
driver.push('{"type":"heartbeat","data":{"ts":"2026-06-20T12:00:00Z"}}');
driver.close();
const events = await collect(gen);
expect(events).toEqual<TailEvent[]>([
{ type: "heartbeat", data: { ts: "2026-06-20T12:00:00Z" } },
]);
});
it("test_abort_signal_cancels_mid_stream", async () => {
const driver = makeStream();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
const ac = new AbortController();
const gen = streamTail("claims", { signal: ac.signal });
// First event arrives normally.
driver.push('{"type":"item","data":{"id":"CLM-1"}}');
const first = await gen.next();
expect(first.done).toBe(false);
// Abort before any more events arrive, then close the upstream so
// any pending read completes. The generator must exit cleanly
// (no throw) and not yield further.
ac.abort();
driver.close();
const second = await gen.next();
expect(second.done).toBe(true);
});
it("test_malformed_line_skipped_next_valid_still_emitted", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const driver = makeStream();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(driver.response));
const gen = streamTail("claims");
driver.push('{"type":"item","data":{"id":"CLM-1"}}');
driver.push("this is not json");
driver.push('{"type":"item","data":{"id":"CLM-2"}}');
driver.close();
const events = await collect(gen);
expect(events).toEqual<TailEvent[]>([
{ type: "item", data: { id: "CLM-1" } },
{ type: "item", data: { id: "CLM-2" } },
]);
// The malformed line must produce a console.warn so operators can spot
// a buggy backend without crashing the whole tail.
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
+189
View File
@@ -0,0 +1,189 @@
// ---------------------------------------------------------------------------
// Live-tail NDJSON stream parser (sub-project 5, Phase 4 Task 16).
//
// Opens `GET /api/{resource}/stream` with `Accept: application/x-ndjson`,
// pipes the response body through a `TextDecoderStream` + a manual
// newline-splitter, and yields one typed `TailEvent` per line. The
// higher-level `useTailStream` hook (Phase 5) is what subscribes to this
// generator and dispatches into `tail-store`; this module is intentionally
// pure parsing — no React, no Zustand, no fetch options other than the
// ones documented in the spec.
// ---------------------------------------------------------------------------
/**
* One event yielded by `streamTail`. The data shapes mirror the backend's
* `GET /api/{resource}/stream` NDJSON contract (see `backend/api.py`).
*
* `item.data` is intentionally `unknown` — the consumer casts to the
* page-specific shape (Claim / Remittance / Activity). The other variants
* carry the data shape the spec mandates, so consumers get a friendly
* type for the non-item events.
*/
export type TailEvent =
| { type: "item"; data: unknown }
| { type: "snapshot_end"; data: { count: number } }
| { type: "heartbeat"; data: { ts: string } }
| { type: "item_dropped"; data: { id: string } }
| { type: "error"; data: { message: string } };
export type TailResource = "claims" | "remittances" | "activity";
export interface StreamTailOptions {
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
signal?: AbortSignal;
/** Override the base URL (defaults to "" — i.e. same-origin). Used in tests. */
baseUrl?: string;
}
const KNOWN_TYPES: ReadonlySet<TailEvent["type"]> = new Set([
"item",
"snapshot_end",
"heartbeat",
"item_dropped",
"error",
]);
/**
* Open a live-tail NDJSON stream and yield one typed event per line.
*
* The generator is single-use: call `gen.return()` (or break out of a
* `for await` loop) to close the underlying fetch. When `opts.signal`
* aborts, the generator exits cleanly without throwing.
*
* @example
* for await (const ev of streamTail("claims")) {
* if (ev.type === "item") console.log(ev.data);
* }
*/
export async function* streamTail(
resource: TailResource,
opts?: StreamTailOptions,
): AsyncIterableIterator<TailEvent> {
const base = opts?.baseUrl ?? "";
const url = `${base}/api/${resource}/stream`;
let res: Response;
try {
res = await fetch(url, {
headers: { Accept: "application/x-ndjson" },
signal: opts?.signal,
});
} catch (err) {
// Aborting the signal makes fetch reject with a DOMException; the spec
// says "if signal aborts, exit cleanly (don't throw)", so we just
// return — the consumer sees a completed iterator.
if (opts?.signal?.aborted) return;
throw err;
}
if (!res.ok) {
throw new Error(`stream open failed: ${res.status}`);
}
if (!res.body) {
throw new Error("stream has no body");
}
// TextDecoderStream handles the chunked UTF-8 boundary problem for us;
// after this, we have a stream of decoded strings. We then split each
// chunk on \n and JSON.parse each non-empty line.
const stringStream = res.body.pipeThrough(new TextDecoderStream());
const reader = stringStream.getReader();
let buffer = "";
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// Check the abort signal between reads so a quick abort doesn't
// require us to wait for a network read to complete.
if (opts?.signal?.aborted) return;
let chunk: string | undefined;
let done: boolean;
try {
const result = await reader.read();
chunk = result.value;
done = result.done;
} catch (err) {
if (opts?.signal?.aborted) return;
throw err;
}
if (done) break;
if (chunk) buffer += chunk;
let nl = buffer.indexOf("\n");
while (nl !== -1) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line.length > 0) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (err) {
// Malformed lines are the backend's bug, not ours. Log and
// continue so a single bad frame doesn't kill the stream.
console.warn(
"tail-stream: malformed NDJSON line, skipping",
{ line, err },
);
nl = buffer.indexOf("\n");
continue;
}
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
typeof (parsed as { type: unknown }).type === "string" &&
KNOWN_TYPES.has((parsed as { type: string }).type as TailEvent["type"])
) {
yield parsed as TailEvent;
} else {
// Unknown / wrong-shape line — log and skip. A future
// backend event type shouldn't crash the page.
console.warn(
"tail-stream: unknown event shape, skipping",
{ line },
);
}
}
nl = buffer.indexOf("\n");
}
}
// Flush any trailing partial line (no terminating newline).
const tail = buffer.replace(/\r$/, "");
if (tail.length > 0) {
try {
const parsed = JSON.parse(tail) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
typeof (parsed as { type: unknown }).type === "string" &&
KNOWN_TYPES.has((parsed as { type: string }).type as TailEvent["type"])
) {
yield parsed as TailEvent;
} else {
console.warn(
"tail-stream: unknown trailing event shape, skipping",
{ line: tail },
);
}
} catch (err) {
console.warn(
"tail-stream: malformed trailing NDJSON line, skipping",
{ line: tail, err },
);
}
}
} finally {
// Release the reader lock so the underlying connection can close
// when the test / consumer drops the iterator.
try {
reader.releaseLock();
} catch {
// already released — ignore
}
}
}
+23 -5
View File
@@ -1,6 +1,9 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useActivity } from "@/hooks/useActivity";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
import { Skeleton } from "@/components/ui/skeleton";
@@ -69,12 +72,17 @@ export function ActivityLog() {
});
const allItems = data?.items ?? [];
// SP5: live-tail wiring. `useMergedTail` appends tail-arriving events
// to the base query result, then we apply the same client-side
// multi-kind filter so live events respect the active filter set.
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("activity");
const merged = useMergedTail("activity", allItems);
const items = useMemo(() => {
// Server already returned a single-kind slice when `apiKind` is set;
// otherwise apply the (multi-)kind filter locally.
if (selectedKinds.length <= 1) return allItems;
return allItems.filter((a) => selectedKinds.includes(a.kind));
}, [allItems, selectedKinds]);
if (selectedKinds.length <= 1) return merged;
return merged.filter((a) => selectedKinds.includes(a.kind));
}, [merged, selectedKinds]);
const writeParams = useCallback(
(mutate: (next: URLSearchParams) => void) => {
@@ -152,6 +160,16 @@ export function ActivityLog() {
) : null}
<div className="surface rounded-xl p-6">
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
+111
View File
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Claims } from "./Claims";
import { api } from "@/lib/api";
import { useTailStore } from "@/store/tail-store";
import type { Claim, ClaimDetail } from "@/types";
// Module-level mock — vitest hoists vi.mock above imports. We mock the
@@ -38,6 +39,30 @@ vi.mock("@/lib/api", () => {
};
});
// ---------------------------------------------------------------------------
// Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests).
//
// We mock `useTailStream` directly (per the plan's "latter is simpler"
// guidance) so the page-level tests don't have to drive the real
// `streamTail` parser or the real AbortController-based reconnect loop.
// `useTailStream` is exercised exhaustively in its own test file
// (`useTailStream.test.ts`); the page only needs to observe that the
// returned status flows through to `<TailStatusPill>` and that items
// pushed into the tail store surface as rows via `useMergedTail`.
//
// The mock returns `status: "live"` by default — that matches what the
// real hook settles to after a `snapshot_end` event arrives, which is
// the state the page sees in production ~all of the time.
// ---------------------------------------------------------------------------
vi.mock("@/hooks/useTailStream", () => ({
useTailStream: vi.fn(() => ({
status: "live",
lastEventAt: null,
error: null,
forceReconnect: vi.fn(),
})),
}));
const SAMPLE_CLAIMS: Claim[] = [
{
id: "CLM-1",
@@ -190,6 +215,10 @@ describe("Claims page drawer wiring", () => {
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
SAMPLE_DETAIL
);
// Live-tail slice is a singleton zustand store — clear it between
// tests so the live-arrival test doesn't see rows from a previous
// case.
useTailStore.getState().reset("claims");
});
afterEach(() => {
@@ -420,4 +449,86 @@ describe("Claims page drawer wiring", () => {
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
).toBeNull();
});
// -------------------------------------------------------------------
// Live-tail integration (sub-project 5, Phase 5 Task 22 page tests).
//
// We mock `useTailStream` at the module level (see the top of this
// file) so the page sees a settled `status: "live"` without driving
// the real streamTail parser. To exercise the "tail arrival triggers a
// row" path we push a new claim directly into `useTailStore` — this
// is exactly what the real `useTailStream` hook does on `item` events,
// so we cover the same render path (zustand notify → useMergedTail
// re-derive → `<TableRow>` mount).
// -------------------------------------------------------------------
it("test_live_tail_arrival_triggers_row_in_table", async () => {
const { unmount } = renderClaims();
// Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first.
await settle(
() => document.body.textContent?.includes("CLM-1") ?? false,
);
// The new claim id is brand new (not in base, not in tail store yet).
const TAIL_ID = "CLM-TAIL-1";
expect(
document.body.textContent?.includes(TAIL_ID) ?? false,
).toBe(false);
// Simulate a live-arriving claim — this is what `useTailStream`
// does when it dispatches an `item` event. Wrap in `act` because
// zustand subscribers re-render synchronously.
await act(async () => {
useTailStore.getState().addClaim({
id: TAIL_ID,
patientName: "Live Arrival",
providerNpi: "1234567890",
payerName: "Colorado Medicaid",
cptCode: "99215",
billedAmount: 999,
receivedAmount: 0,
status: "submitted",
submissionDate: "2026-06-20",
});
});
// The new row must appear — proves the end-to-end path
// zustand.addClaim → useTailStore notify → useMergedTail re-derive
// → <TableBody> mounts the row with the new id.
await settle(
() => document.body.textContent?.includes(TAIL_ID) ?? false,
);
expect(document.body.textContent).toContain(TAIL_ID);
// The row's secondary cells should also be present so we know the
// row fully rendered, not just that the id appeared somewhere
// (e.g. in the search-input placeholder or similar).
expect(document.body.textContent).toContain("Live Arrival");
expect(document.body.textContent).toContain("99215");
unmount();
});
it("test_status_pill_shows_live_after_stream_connects", async () => {
const { unmount } = renderClaims();
// Wait for the base table to mount — once it's there, the toolbar
// (which contains the pill) has also mounted.
await settle(
() => document.body.textContent?.includes("CLM-1") ?? false,
);
// The mocked `useTailStream` returns `status: "live"`, so the
// <TailStatusPill> should render with the human label "Live"
// (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the
// pill's textContent so this stays robust against future styling
// changes (the test doesn't depend on Tailwind classes).
const pill = document.body.querySelector(
'[data-testid="tail-status-pill"]',
);
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain("Live");
unmount();
});
});
+36 -2
View File
@@ -27,9 +27,12 @@ import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips
import { Pagination } from "@/components/ui/pagination";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
import type { ClaimStatus } from "@/types";
import type { Claim, ClaimStatus } from "@/types";
import { cn } from "@/lib/utils";
const ALL = "all" as const;
@@ -74,7 +77,28 @@ export function Claims() {
};
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
const items = data?.items ?? [];
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 22).
//
// The tail stream emits every claim that lands in the DB — including
// ones that don't match the user's current `status` / `provider_npi`
// filter. Mirror the server-side filter the page already applies via
// `useClaims` so a newly-arrived item that wouldn't pass the same
// predicate is dropped before it can show up in the table. `useMergedTail`
// applies this to the tail slice only; base items are already filtered
// server-side.
const tailFilterFn = useMemo(
() => (c: Claim) => {
if (status !== ALL && c.status !== status) return false;
if (npi !== ALL && c.providerNpi !== npi) return false;
return true;
},
[status, npi],
);
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("claims");
const items = useMergedTail("claims", data?.items ?? [], tailFilterFn);
const totals = useMemo(
() => ({
@@ -190,6 +214,16 @@ export function Claims() {
))}
</SelectContent>
</Select>
{/* SP5: live-tail status pill. `ml-auto` pins it to the right
edge of the toolbar so it doesn't crowd the search input. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
<div className="mt-3">
+49
View File
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Remittances } from "./Remittances";
import { api } from "@/lib/api";
import { useTailStore } from "@/store/tail-store";
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
// stub the method this page touches via the `useRemittances` hook.
@@ -23,6 +24,18 @@ vi.mock("@/lib/api", () => ({
},
}));
// Mock the live-tail hook so the page renders the pill in the settled
// `live` state without driving the real streamTail parser. The hook's
// own test file (`useTailStream.test.ts`) covers the lifecycle in depth.
vi.mock("@/hooks/useTailStream", () => ({
useTailStream: vi.fn(() => ({
status: "live",
lastEventAt: null,
error: null,
forceReconnect: vi.fn(),
})),
}));
/**
* Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
@@ -178,6 +191,10 @@ const SAMPLE_REMITS = [
describe("Remittances", () => {
beforeEach(() => {
vi.clearAllMocks();
// Singleton tail-store: clear the remittances slice between tests
// so a tail-arrival case (if added later) doesn't see rows from a
// previous test.
useTailStore.getState().reset("remittances");
(
api.listRemittances as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
@@ -389,4 +406,36 @@ describe("Remittances", () => {
unmount();
});
// -------------------------------------------------------------------
// Live-tail status pill (sub-project 5, Phase 5 Task 23, optional
// page-level test). Cheap smoke check: the toolbar renders the pill
// with the `live` label whenever the mocked stream is settled. The
// hook's lifecycle is covered exhaustively in its own test file.
// -------------------------------------------------------------------
it("test_status_pill_shows_live_in_toolbar", async () => {
(
api.listRemittances as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
items: [],
total: 0,
returned: 0,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Remittances));
// Wait for the empty-state to mount — once the page body is rendered,
// the toolbar (and thus the pill) is in the DOM.
await waitForText("Remittances · awaiting first 835");
const pill = document.body.querySelector(
'[data-testid="tail-status-pill"]',
);
expect(pill).not.toBeNull();
expect(pill?.textContent).toContain("Live");
unmount();
});
});
+43 -12
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useState } from "react";
import { Fragment, useCallback, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
import {
Table,
@@ -17,9 +17,12 @@ import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { useRemittances } from "@/hooks/useRemittances";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, RemittanceStatus } from "@/types";
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
const PAGE_SIZE = 25;
@@ -71,7 +74,25 @@ export function Remittances() {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
const items = data?.items ?? [];
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The tail
// stream emits every remittance that lands — including ones that
// don't match the user's current `status` filter chip. Drop those
// tail items so a newly-arrived remit that wouldn't match the page's
// intent doesn't flash into the table. Base items are NOT filtered
// here (they reflect whatever the list query returned, matching the
// existing page behavior).
const tailFilterFn = useMemo(
() => (r: Remittance) => {
if (status && r.status !== status) return false;
return true;
},
[status],
);
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("remittances");
const items = useMergedTail("remittances", data?.items ?? [], tailFilterFn);
const total = items.reduce(
(acc, r) => ({
@@ -167,15 +188,25 @@ export function Remittances() {
</div>
<div className="surface rounded-xl p-4">
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
<div className="flex flex-wrap items-center gap-3">
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
+131
View File
@@ -0,0 +1,131 @@
// @vitest-environment node
// `useTailStore` is a pure zustand store with no DOM / React dependencies,
// so a node environment is fine. We import `getState()` and `setState`
// from the store directly (no React render) and assert on the resulting
// state shape.
import { beforeEach, describe, expect, it } from "vitest";
import type { Activity, Claim, Remittance } from "@/types";
import { TAIL_CAP, useTailStore } from "./tail-store";
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
return {
id,
patientName: `Patient ${id}`,
providerNpi: "1234567890",
payerName: "Medicaid",
cptCode: "99213",
billedAmount: 100,
receivedAmount: 0,
status: "submitted",
submissionDate: "2026-06-20T00:00:00Z",
...overrides,
};
}
function makeRemittance(id: string, overrides: Partial<Remittance> = {}): Remittance {
return {
id,
claimId: `CLM-${id}`,
payerName: "Medicaid",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-20",
checkNumber: id,
status: "received",
...overrides,
};
}
function makeActivity(id: string, overrides: Partial<Activity> = {}): Activity {
return {
id,
kind: "claim_submitted",
message: `Event ${id}`,
timestamp: "2026-06-20T00:00:00Z",
...overrides,
};
}
describe("useTailStore", () => {
// Each test starts from a known-empty state. The store is module-level
// (singleton), so we use `reset()` to clear between cases — that's also
// what production code calls on stream open.
beforeEach(() => {
useTailStore.getState().reset("claims");
useTailStore.getState().reset("remittances");
useTailStore.getState().reset("activity");
});
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
const { addClaim } = useTailStore.getState();
addClaim(makeClaim("CLM-1"));
addClaim(makeClaim("CLM-2"));
const { claims } = useTailStore.getState();
expect(Object.keys(claims)).toHaveLength(2);
expect(claims["CLM-1"]?.id).toBe("CLM-1");
expect(claims["CLM-2"]?.id).toBe("CLM-2");
});
it("test_add_claim_with_duplicate_id_is_noop", () => {
const { addClaim } = useTailStore.getState();
addClaim(makeClaim("CLM-1", { patientName: "Original" }));
addClaim(makeClaim("CLM-1", { patientName: "Updated" }));
const { claims } = useTailStore.getState();
expect(Object.keys(claims)).toHaveLength(1);
// First write wins; duplicates are silently dropped so the snapshot
// replay on reconnect doesn't trample the canonical row.
expect(claims["CLM-1"]?.patientName).toBe("Original");
});
it("test_reset_claims_clears_only_claims_slice", () => {
const { addClaim, addRemittance, addActivity, reset } = useTailStore.getState();
addClaim(makeClaim("CLM-1"));
addRemittance(makeRemittance("RMT-1"));
addActivity(makeActivity("ACT-1"));
reset("claims");
const s = useTailStore.getState();
expect(Object.keys(s.claims)).toHaveLength(0);
expect(Object.keys(s.remittances)).toHaveLength(1);
expect(s.activity).toHaveLength(1);
});
it("test_add_activity_appends", () => {
const { addActivity } = useTailStore.getState();
addActivity(makeActivity("ACT-1", { message: "first" }));
addActivity(makeActivity("ACT-2", { message: "second" }));
addActivity(makeActivity("ACT-3", { message: "third" }));
const { activity } = useTailStore.getState();
expect(activity).toHaveLength(3);
// Activity has no stable id; it lives in an array. Insertion order is
// the only ordering signal.
expect(activity.map((a) => a.message)).toEqual(["first", "second", "third"]);
});
it("test_fifo_cap_evicts_oldest_when_over_10000", () => {
// Insert 5 over the cap so the eviction policy must actually run.
// The plan calls for evicting the oldest 100 (or enough to be back at
// the cap) — we just assert: size == TAIL_CAP and the very-first
// items are gone.
//
// 10 005 individual `addClaim` calls is intentionally a stress test
// of the eviction path: each call rebuilds the dict + order array
// so the wall-clock cost is O(N^2) in the number of items ever
// inserted. Bump the per-test timeout from the default 5s to 60s
// so this case can complete in CI on slow runners.
const N = TAIL_CAP + 5;
const { addClaim } = useTailStore.getState();
for (let i = 0; i < N; i++) {
addClaim(makeClaim(`CLM-${i.toString().padStart(6, "0")}`));
}
const { claims } = useTailStore.getState();
expect(Object.keys(claims)).toHaveLength(TAIL_CAP);
// The five oldest (CLM-000000 .. CLM-000004) must be gone.
expect(claims["CLM-000000"]).toBeUndefined();
expect(claims["CLM-000004"]).toBeUndefined();
// The most recent (CLM-0010004) must be present.
const lastKey = `CLM-${(N - 1).toString().padStart(6, "0")}`;
expect(claims[lastKey]).toBeDefined();
}, 60_000);
});
+161
View File
@@ -0,0 +1,161 @@
// ---------------------------------------------------------------------------
// Live-tail append-only store (sub-project 5, Phase 4 Task 17).
//
// Three independent slices (`claims` / `remittances` / `activity`) that
// `useTailStream` (Phase 5) writes into as `item` events arrive. Reads
// happen via `useMergedTail` (also Phase 5), which merges each slice with
// the page's JSON fetch results and applies the page's filter.
//
// Design notes:
// - `claims` and `remittances` are key-by-id maps (id is stable), so
// duplicate snapshots are dedup'd by `addClaim`/`addRemittance` (first
// write wins — the snapshot replay on reconnect must not trample the
// canonical row).
// - `activity` is an append-only array (event ids aren't guaranteed to
// be unique across snapshots; the ActivityLog renders in arrival
// order).
// - Each slice is FIFO-capped at `TAIL_CAP` (10 000) so a runaway tail
// can't grow the heap unbounded. Oldest entries are evicted in the
// add function itself — `reset` is what production calls when a new
// stream opens.
// ---------------------------------------------------------------------------
import { create } from "zustand";
import type { Activity, Claim, Remittance } from "@/types";
import type { TailResource } from "@/lib/tail-stream";
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
export const TAIL_CAP = 10_000;
/**
* Per-slice eviction batch. When the cap is exceeded, drop the oldest
* `EVICT_BATCH` entries in a single `set()` call. Picking a batch > 1
* amortizes the cost of eviction when a high-volume stream pushes many
* items per frame — and 100 is small enough that a 10 005-item insert
* still lands within one `set()` call.
*
* The store does NOT require this batch to be exactly the overflow
* amount — it always drains down to the cap, so a +5 overflow evicts 5
* even when EVICT_BATCH is 100. The batch is just an upper bound on how
* many we delete in a single pass.
*/
const EVICT_BATCH = 100;
interface TailStore {
// --- Slices (keyed by id for the two that have stable ids) -----------
claims: Record<string, Claim>;
remittances: Record<string, Remittance>;
activity: Activity[];
// --- Insertion-order trackers (kept in sync with the dicts above) ----
// These are private to the store; consumers only read the dicts. We
// store them as part of the state so they reactively update with the
// same `set()` call (zustand shallow-merges, so the new array ref is
// what triggers a re-render in subscribers that select `claims`).
claimOrder: string[];
remitOrder: string[];
// --- Setters ---------------------------------------------------------
addClaim: (c: Claim) => void;
addRemittance: (r: RemitListItem) => void;
addActivity: (a: Activity) => void;
reset: (resource: TailResource) => void;
}
/**
* The spec's sketch uses the placeholder name `RemitListItem` for the
* remittance shape; locally we just use the existing `Remittance` type
* from `@/types` (same value, no need to add a duplicate).
*/
type RemitListItem = Remittance;
function evictOldest(
order: string[],
dict: Record<string, Claim | Remittance>,
batch: number,
): { order: string[]; dict: Record<string, Claim | Remittance> } {
if (order.length <= TAIL_CAP) return { order, dict };
// Drain down to the cap; never evict more than `batch` per call so a
// 5-item overflow evicts 5, but a 1 000-item overflow evicts 100 in
// this pass and the remaining 900 in subsequent add() calls.
const toDrop = Math.min(batch, order.length - TAIL_CAP);
const dropped = order.slice(0, toDrop);
const nextOrder = order.slice(toDrop);
// Object.assign is consistently faster than `{ ...dict }` in V8 for
// large dicts; we follow up with `delete` for each evicted id.
const nextDict: Record<string, Claim | Remittance> = Object.assign({}, dict);
for (const id of dropped) delete nextDict[id];
return { order: nextOrder, dict: nextDict };
}
export const useTailStore = create<TailStore>((set) => ({
claims: {},
remittances: {},
activity: [],
claimOrder: [],
remitOrder: [],
addClaim: (c) =>
set((s) => {
// Dedup: first write wins. The snapshot replay on reconnect
// produces the same id repeatedly; we want the first occurrence
// to stick so the canonical row isn't overwritten by an older
// version that happened to be in the snapshot.
if (s.claims[c.id]) return s;
// Object.assign is faster than `{ ...s.claims, [c.id]: c }` for
// large dicts; this hot path is called once per `item` event.
const nextClaims: Record<string, Claim> = Object.assign({}, s.claims, {
[c.id]: c,
});
const nextOrder = s.claimOrder.concat(c.id);
if (nextOrder.length > TAIL_CAP) {
const { order, dict } = evictOldest(nextOrder, nextClaims, EVICT_BATCH);
return { claims: dict as Record<string, Claim>, claimOrder: order };
}
return { claims: nextClaims, claimOrder: nextOrder };
}),
addRemittance: (r) =>
set((s) => {
if (s.remittances[r.id]) return s;
const nextRemits: Record<string, Remittance> = Object.assign(
{},
s.remittances,
{ [r.id]: r },
);
const nextOrder = s.remitOrder.concat(r.id);
if (nextOrder.length > TAIL_CAP) {
const { order, dict } = evictOldest(nextOrder, nextRemits, EVICT_BATCH);
return {
remittances: dict as Record<string, Remittance>,
remitOrder: order,
};
}
return { remittances: nextRemits, remitOrder: nextOrder };
}),
addActivity: (a) =>
set((s) => {
// Activity has no stable id, so it's a plain append. FIFO cap
// evicts the oldest with a single `slice` (the typical case is
// +1, +1, +1; an extreme burst falls back to multiple slice
// passes).
let next = [...s.activity, a];
if (next.length > TAIL_CAP) {
next = next.slice(next.length - TAIL_CAP);
}
return { activity: next };
}),
reset: (resource) =>
set(() => {
switch (resource) {
case "claims":
return { claims: {}, claimOrder: [] };
case "remittances":
return { remittances: {}, remitOrder: [] };
case "activity":
return { activity: [] };
}
}),
}));