feat(frontend): tail-store Zustand with FIFO cap 10k per slice

This commit is contained in:
Tyler
2026-06-20 16:05:21 -06:00
parent 11a4eaa480
commit 64fb11a9cf
2 changed files with 292 additions and 0 deletions
+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: [] };
}
}),
}));