257 lines
9.5 KiB
TypeScript
257 lines
9.5 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// 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<string, Claim>;
|
|
remittances: Record<string, Remittance>;
|
|
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<string, Ack>;
|
|
ta1Acks: Record<string, Ta1Ack>;
|
|
// 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 <Acknowledgments /> panel and the
|
|
// AckDrawer's <MatchedClaim /> panel both subscribe to this slice via
|
|
// `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`.
|
|
claimAcks: Record<string, ClaimAck>;
|
|
|
|
// --- 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<K extends string | number, V>(
|
|
order: K[],
|
|
dict: Record<string, V>,
|
|
batch: number,
|
|
): { order: K[]; dict: Record<string, V> } {
|
|
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, V> = Object.assign({}, dict);
|
|
for (const id of dropped) delete nextDict[String(id)];
|
|
return { order: nextOrder, dict: nextDict };
|
|
}
|
|
|
|
export const useTailStore = create<TailStore>((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<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 };
|
|
}),
|
|
|
|
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<string, Ack> = 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<string, Ta1Ack> = 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<string, ClaimAck> = 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: [] };
|
|
}
|
|
}),
|
|
}));
|