diff --git a/src/store/tail-store.test.ts b/src/store/tail-store.test.ts new file mode 100644 index 0000000..ca61566 --- /dev/null +++ b/src/store/tail-store.test.ts @@ -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 { + 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 { + 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 { + 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); +}); diff --git a/src/store/tail-store.ts b/src/store/tail-store.ts new file mode 100644 index 0000000..29de8f8 --- /dev/null +++ b/src/store/tail-store.ts @@ -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; + remittances: Record; + 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, + batch: number, +): { order: string[]; dict: Record } { + 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 = Object.assign({}, dict); + for (const id of dropped) delete nextDict[id]; + return { order: nextOrder, dict: nextDict }; +} + +export const useTailStore = create((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 = 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, claimOrder: order }; + } + return { claims: nextClaims, claimOrder: nextOrder }; + }), + + addRemittance: (r) => + set((s) => { + if (s.remittances[r.id]) return s; + const nextRemits: Record = 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, + 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: [] }; + } + }), +}));