// --------------------------------------------------------------------------- // 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 { Ack, Activity, Claim, ClaimAck, Remittance, Ta1Ack, } 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[]; // SP25: the Acks page needs the same live-tail triplet as // Claims/Remittances/Activity. `acks` and `ta1_acks` both have stable // numeric ids from the database row, so they're keyed-by-id like // `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP). acks: Record; ta1Acks: Record; // SP28: the claim↔ack link slice. Mirrors the SP25 acks/ta1_acks // pattern — `claimAcks` rows have a stable numeric `id` from the // `claim_acks` table, so we key by id (first write wins, FIFO-capped // at TAIL_CAP). The ClaimDrawer's panel and the // AckDrawer's panel both subscribe to this slice via // `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`. claimAcks: Record; // --- 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[]; ackOrder: number[]; ta1AckOrder: number[]; claimAckOrder: number[]; // --- Setters --------------------------------------------------------- addClaim: (c: Claim) => void; addRemittance: (r: RemitListItem) => void; addActivity: (a: Activity) => void; addAck: (a: Ack) => void; addTa1Ack: (a: Ta1Ack) => void; addClaimAck: (a: ClaimAck) => 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: K[], dict: Record, batch: number, ): { order: K[]; 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[String(id)]; return { order: nextOrder, dict: nextDict }; } export const useTailStore = create((set) => ({ claims: {}, remittances: {}, activity: [], acks: {}, ta1Acks: {}, claimAcks: {}, claimOrder: [], remitOrder: [], ackOrder: [], ta1AckOrder: [], claimAckOrder: [], 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 }; }), addAck: (a) => set((s) => { // First write wins; snapshot replay must not trample the // canonical row. `id` is a number from the database row. if (s.acks[String(a.id)]) return s; const nextAcks: Record = Object.assign({}, s.acks, { [String(a.id)]: a, }); const nextOrder = s.ackOrder.concat(a.id); if (nextOrder.length > TAIL_CAP) { const { order, dict } = evictOldest(nextOrder, nextAcks, EVICT_BATCH); return { acks: dict, ackOrder: order }; } return { acks: nextAcks, ackOrder: nextOrder }; }), addTa1Ack: (a) => set((s) => { if (s.ta1Acks[String(a.id)]) return s; const nextTa1Acks: Record = Object.assign( {}, s.ta1Acks, { [String(a.id)]: a }, ); const nextOrder = s.ta1AckOrder.concat(a.id); if (nextOrder.length > TAIL_CAP) { const { order, dict } = evictOldest(nextOrder, nextTa1Acks, EVICT_BATCH); return { ta1Acks: dict, ta1AckOrder: order }; } return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder }; }), // SP28: same first-write-wins / FIFO-capped shape as addAck / // addTa1Ack. The numeric `id` is the primary key from the // `claim_acks` table — distinct from (claim_id, ack_id, ak2_index) // which is the natural dedup key but isn't what the live-tail // payload uses (every claim_ack_written event carries the row id). addClaimAck: (a) => set((s) => { if (s.claimAcks[String(a.id)]) return s; const nextClaimAcks: Record = Object.assign( {}, s.claimAcks, { [String(a.id)]: a }, ); const nextOrder = s.claimAckOrder.concat(a.id); if (nextOrder.length > TAIL_CAP) { const { order, dict } = evictOldest( nextOrder, nextClaimAcks, EVICT_BATCH, ); return { claimAcks: dict, claimAckOrder: order }; } return { claimAcks: nextClaimAcks, claimAckOrder: nextOrder }; }), reset: (resource) => set(() => { switch (resource) { case "claims": return { claims: {}, claimOrder: [] }; case "remittances": return { remittances: {}, remitOrder: [] }; case "activity": return { activity: [] }; case "acks": return { acks: {}, ackOrder: [] }; case "ta1_acks": return { ta1Acks: {}, ta1AckOrder: [] }; case "claim-acks": return { claimAcks: {}, claimAckOrder: [] }; } }), }));