feat(sp25): extend useTailStore with acks + ta1_acks slices

The Acks page needs the same live-tail triplet as Claims/Remittances/
Activity. Both Ack and Ta1Ack have stable numeric ids from the
database row, so they get the keyed-by-id treatment (first write
wins, FIFO-capped at TAIL_CAP) — same shape as the claims and
remittances slices, not the append-only activity array.

Adds addAck, addTa1Ack, ackOrder, ta1AckOrder, and the matching
reset cases for the new 'acks' / 'ta1_acks' TailResource kinds.

evictOldest is now generic over T so the same helper covers the
two new keyed-by-id slices without duplication.
This commit is contained in:
Nora
2026-07-02 09:07:26 -06:00
parent f1bee546f6
commit 1648c81425
2 changed files with 157 additions and 8 deletions
+57 -7
View File
@@ -21,7 +21,7 @@
// ---------------------------------------------------------------------------
import { create } from "zustand";
import type { Activity, Claim, Remittance } from "@/types";
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
import type { TailResource } from "@/lib/tail-stream";
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
@@ -46,6 +46,12 @@ interface TailStore {
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>;
// --- Insertion-order trackers (kept in sync with the dicts above) ----
// These are private to the store; consumers only read the dicts. We
@@ -54,11 +60,15 @@ interface TailStore {
// what triggers a re-render in subscribers that select `claims`).
claimOrder: string[];
remitOrder: string[];
ackOrder: number[];
ta1AckOrder: number[];
// --- Setters ---------------------------------------------------------
addClaim: (c: Claim) => void;
addRemittance: (r: RemitListItem) => void;
addActivity: (a: Activity) => void;
addAck: (a: Ack) => void;
addTa1Ack: (a: Ta1Ack) => void;
reset: (resource: TailResource) => void;
}
@@ -69,11 +79,11 @@ interface TailStore {
*/
type RemitListItem = Remittance;
function evictOldest(
order: string[],
dict: Record<string, Claim | Remittance>,
function evictOldest<T>(
order: Array<string | number>,
dict: Record<string, T>,
batch: number,
): { order: string[]; dict: Record<string, Claim | Remittance> } {
): { order: Array<string | number>; dict: Record<string, T> } {
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
@@ -83,8 +93,8 @@ function evictOldest(
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];
const nextDict: Record<string, T> = Object.assign({}, dict);
for (const id of dropped) delete nextDict[String(id)];
return { order: nextOrder, dict: nextDict };
}
@@ -92,8 +102,12 @@ export const useTailStore = create<TailStore>((set) => ({
claims: {},
remittances: {},
activity: [],
acks: {},
ta1Acks: {},
claimOrder: [],
remitOrder: [],
ackOrder: [],
ta1AckOrder: [],
addClaim: (c) =>
set((s) => {
@@ -147,6 +161,38 @@ export const useTailStore = create<TailStore>((set) => ({
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 as number[] };
}
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 as number[] };
}
return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder };
}),
reset: (resource) =>
set(() => {
switch (resource) {
@@ -156,6 +202,10 @@ export const useTailStore = create<TailStore>((set) => ({
return { remittances: {}, remitOrder: [] };
case "activity":
return { activity: [] };
case "acks":
return { acks: {}, ackOrder: [] };
case "ta1_acks":
return { ta1Acks: {}, ta1AckOrder: [] };
}
}),
}));