feat(sp25): /api/acks/stream + /api/ta1-acks/stream NDJSON endpoints

Adds two streaming endpoints that match the live-tail wire format
established by /api/claims/stream, /api/remittances/stream, and
/api/activity/stream:

  * /api/acks/stream      — subscribes to ack_received
  * /api/ta1-acks/stream  — subscribes to ta1_ack_received

Both yield a snapshot of existing rows (newest first, capped by the
`limit` query param), then a `snapshot_end` line, then forward
live events from the bus. They are registered before the
/{ack_id} path-param endpoints so the literal `stream` segment
isn't matched as an id.

Tests use the same direct-coroutine pattern as test_api_stream_live.py
because httpx.ASGITransport buffers the response body and never
delivers a disconnect message — iterating body_iterator directly
with body_iterator.aclose() simulates a client disconnect.
This commit is contained in:
Nora
2026-07-02 09:00:54 -06:00
parent 686c11f480
commit 603ec8842a
3 changed files with 380 additions and 6 deletions
+43 -3
View File
@@ -1,4 +1,4 @@
"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
"""``/api/acks`` — list, detail, and live-tail stream for 999 ACKs.
These are the persisted acknowledgment rows produced by
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
@@ -7,18 +7,29 @@ list payload to its ``Ack`` interface in ``src/types/index.ts``.
The detail endpoint returns the full ``raw_json`` payload plus the
regenerated ``raw_999_text`` so the UI can show "view source" without a
second round-trip.
SP25: ``/api/acks/stream`` joins the live-tail triplet — the Acks
page mounts ``useTailStream("acks")`` and ``useMergedTail("acks", …)``
to see new 999 acks the moment they land (whether from the SFTP
poller or a manual upload).
"""
from __future__ import annotations
import logging
from typing import Any
from typing import Any, AsyncIterator
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.api_helpers import (
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.parsers.models_999 import ParseResult999
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_ack
router = APIRouter()
@@ -71,6 +82,35 @@ def list_acks_endpoint(
}
@router.get("/api/acks/stream")
async def acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream 999 ACKs as NDJSON: snapshot first, then live events.
SP25: this endpoint joins the live-tail triplet — subscribes to
``ack_received`` and emits one ``item`` per snapshot row plus a
single ``snapshot_end`` line, then forwards live events from
the bus. Matches the wire format used by ``/api/claims/stream``,
``/api/remittances/stream``, and ``/api/activity/stream``.
NOTE: registered BEFORE ``/api/acks/{ack_id}`` so the literal
``stream`` path segment doesn't get matched as an ack id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/acks/{ack_id}")
def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail.
+34 -3
View File
@@ -1,4 +1,4 @@
"""``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes.
"""``/api/ta1-acks`` — list, detail, and live-tail stream for TA1 envelopes.
TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a
single segment, no functional group, no transaction set. Cyclone
@@ -7,14 +7,21 @@ row can sit alongside the 999 / 277CA ack rows without special-casing.
The detail endpoint also reconstructs the TA1 segment string
(``TA1*...~``) so the operator can copy it into a downstream tool.
SP25: ``/api/ta1-acks/stream`` joins the live-tail triplet — the
Acks page mounts ``useTailStream("ta1_acks")`` so the TA1 envelope
ack section sees new rows the moment they land.
"""
from __future__ import annotations
from typing import Any
from typing import Any, AsyncIterator
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone import db
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_ta1_ack
router = APIRouter()
@@ -52,6 +59,30 @@ def list_ta1_acks_endpoint(
}
@router.get("/api/ta1-acks/stream")
async def ta1_acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream TA1 envelope acks as NDJSON.
Subscribes to ``ta1_ack_received`` and emits the same wire format
as the other live-tail endpoints. Registered BEFORE
``/api/ta1-acks/{ack_id}`` so ``stream`` isn't matched as an id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_ta1_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ta1_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ta1_ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/ta1-acks/{ack_id}")
def get_ta1_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted TA1 ACK row with its parsed detail."""