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:
@@ -5,7 +5,7 @@
|
|||||||
// state shape.
|
// state shape.
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Activity, Claim, Remittance } from "@/types";
|
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
||||||
import { TAIL_CAP, useTailStore } from "./tail-store";
|
import { TAIL_CAP, useTailStore } from "./tail-store";
|
||||||
|
|
||||||
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
||||||
@@ -47,6 +47,36 @@ function makeActivity(id: string, overrides: Partial<Activity> = {}): Activity {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeAck(id: number, overrides: Partial<Ack> = {}): Ack {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sourceBatchId: `BATCH-${id}`,
|
||||||
|
acceptedCount: 1,
|
||||||
|
rejectedCount: 0,
|
||||||
|
receivedCount: 1,
|
||||||
|
ackCode: "A",
|
||||||
|
parsedAt: "2026-07-02T12:00:00Z",
|
||||||
|
patientControlNumber: `PCN-${id}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTa1Ack(id: number, overrides: Partial<Ta1Ack> = {}): Ta1Ack {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
controlNumber: `0000000${id}`,
|
||||||
|
ackCode: "A",
|
||||||
|
noteCode: "000",
|
||||||
|
interchangeDate: "2026-07-02",
|
||||||
|
interchangeTime: "1200",
|
||||||
|
senderId: "SENDER",
|
||||||
|
receiverId: "RECEIVER",
|
||||||
|
sourceBatchId: `TA1-BATCH-${id}`,
|
||||||
|
parsedAt: "2026-07-02T12:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTailStore", () => {
|
describe("useTailStore", () => {
|
||||||
// Each test starts from a known-empty state. The store is module-level
|
// 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
|
// (singleton), so we use `reset()` to clear between cases — that's also
|
||||||
@@ -55,6 +85,8 @@ describe("useTailStore", () => {
|
|||||||
useTailStore.getState().reset("claims");
|
useTailStore.getState().reset("claims");
|
||||||
useTailStore.getState().reset("remittances");
|
useTailStore.getState().reset("remittances");
|
||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
|
useTailStore.getState().reset("acks");
|
||||||
|
useTailStore.getState().reset("ta1_acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
||||||
@@ -128,4 +160,71 @@ describe("useTailStore", () => {
|
|||||||
const lastKey = `CLM-${(N - 1).toString().padStart(6, "0")}`;
|
const lastKey = `CLM-${(N - 1).toString().padStart(6, "0")}`;
|
||||||
expect(claims[lastKey]).toBeDefined();
|
expect(claims[lastKey]).toBeDefined();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP25: acks + ta1_acks slices mirror the claims/remittances key-by-id
|
||||||
|
// pattern (both have a stable `id` from the database row).
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_add_ack_adds_new_ack_keyed_by_id", () => {
|
||||||
|
const { addAck } = useTailStore.getState();
|
||||||
|
addAck(makeAck(1));
|
||||||
|
addAck(makeAck(2));
|
||||||
|
const { acks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(acks)).toHaveLength(2);
|
||||||
|
expect(acks["1"]?.sourceBatchId).toBe("BATCH-1");
|
||||||
|
expect(acks["2"]?.sourceBatchId).toBe("BATCH-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_add_ack_with_duplicate_id_is_noop", () => {
|
||||||
|
const { addAck } = useTailStore.getState();
|
||||||
|
addAck(makeAck(1, { sourceBatchId: "BATCH-original" }));
|
||||||
|
addAck(makeAck(1, { sourceBatchId: "BATCH-replay" }));
|
||||||
|
const { acks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(acks)).toHaveLength(1);
|
||||||
|
// First write wins; snapshot replay must not trample the canonical row.
|
||||||
|
expect(acks["1"]?.sourceBatchId).toBe("BATCH-original");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_reset_acks_clears_only_acks_slice", () => {
|
||||||
|
const { addAck, addClaim, reset } = useTailStore.getState();
|
||||||
|
addAck(makeAck(1));
|
||||||
|
addClaim(makeClaim("CLM-1"));
|
||||||
|
|
||||||
|
reset("acks");
|
||||||
|
const s = useTailStore.getState();
|
||||||
|
expect(Object.keys(s.acks)).toHaveLength(0);
|
||||||
|
// The other slices are untouched.
|
||||||
|
expect(Object.keys(s.claims)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_add_ta1_ack_adds_new_ta1_ack_keyed_by_id", () => {
|
||||||
|
const { addTa1Ack } = useTailStore.getState();
|
||||||
|
addTa1Ack(makeTa1Ack(1));
|
||||||
|
addTa1Ack(makeTa1Ack(2));
|
||||||
|
const { ta1Acks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(ta1Acks)).toHaveLength(2);
|
||||||
|
expect(ta1Acks["1"]?.controlNumber).toBe("00000001");
|
||||||
|
expect(ta1Acks["2"]?.controlNumber).toBe("00000002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_add_ta1_ack_with_duplicate_id_is_noop", () => {
|
||||||
|
const { addTa1Ack } = useTailStore.getState();
|
||||||
|
addTa1Ack(makeTa1Ack(7, { senderId: "ORIGINAL" }));
|
||||||
|
addTa1Ack(makeTa1Ack(7, { senderId: "REPLAY" }));
|
||||||
|
const { ta1Acks } = useTailStore.getState();
|
||||||
|
expect(Object.keys(ta1Acks)).toHaveLength(1);
|
||||||
|
expect(ta1Acks["7"]?.senderId).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_reset_ta1_acks_clears_only_ta1_acks_slice", () => {
|
||||||
|
const { addTa1Ack, addAck, reset } = useTailStore.getState();
|
||||||
|
addTa1Ack(makeTa1Ack(1));
|
||||||
|
addAck(makeAck(1));
|
||||||
|
|
||||||
|
reset("ta1_acks");
|
||||||
|
const s = useTailStore.getState();
|
||||||
|
expect(Object.keys(s.ta1Acks)).toHaveLength(0);
|
||||||
|
expect(Object.keys(s.acks)).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+57
-7
@@ -21,7 +21,7 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { create } from "zustand";
|
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";
|
import type { TailResource } from "@/lib/tail-stream";
|
||||||
|
|
||||||
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
|
/** Maximum number of items retained per slice before FIFO eviction kicks in. */
|
||||||
@@ -46,6 +46,12 @@ interface TailStore {
|
|||||||
claims: Record<string, Claim>;
|
claims: Record<string, Claim>;
|
||||||
remittances: Record<string, Remittance>;
|
remittances: Record<string, Remittance>;
|
||||||
activity: Activity[];
|
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) ----
|
// --- Insertion-order trackers (kept in sync with the dicts above) ----
|
||||||
// These are private to the store; consumers only read the dicts. We
|
// 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`).
|
// what triggers a re-render in subscribers that select `claims`).
|
||||||
claimOrder: string[];
|
claimOrder: string[];
|
||||||
remitOrder: string[];
|
remitOrder: string[];
|
||||||
|
ackOrder: number[];
|
||||||
|
ta1AckOrder: number[];
|
||||||
|
|
||||||
// --- Setters ---------------------------------------------------------
|
// --- Setters ---------------------------------------------------------
|
||||||
addClaim: (c: Claim) => void;
|
addClaim: (c: Claim) => void;
|
||||||
addRemittance: (r: RemitListItem) => void;
|
addRemittance: (r: RemitListItem) => void;
|
||||||
addActivity: (a: Activity) => void;
|
addActivity: (a: Activity) => void;
|
||||||
|
addAck: (a: Ack) => void;
|
||||||
|
addTa1Ack: (a: Ta1Ack) => void;
|
||||||
reset: (resource: TailResource) => void;
|
reset: (resource: TailResource) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +79,11 @@ interface TailStore {
|
|||||||
*/
|
*/
|
||||||
type RemitListItem = Remittance;
|
type RemitListItem = Remittance;
|
||||||
|
|
||||||
function evictOldest(
|
function evictOldest<T>(
|
||||||
order: string[],
|
order: Array<string | number>,
|
||||||
dict: Record<string, Claim | Remittance>,
|
dict: Record<string, T>,
|
||||||
batch: number,
|
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 };
|
if (order.length <= TAIL_CAP) return { order, dict };
|
||||||
// Drain down to the cap; never evict more than `batch` per call so a
|
// 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
|
// 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);
|
const nextOrder = order.slice(toDrop);
|
||||||
// Object.assign is consistently faster than `{ ...dict }` in V8 for
|
// Object.assign is consistently faster than `{ ...dict }` in V8 for
|
||||||
// large dicts; we follow up with `delete` for each evicted id.
|
// large dicts; we follow up with `delete` for each evicted id.
|
||||||
const nextDict: Record<string, Claim | Remittance> = Object.assign({}, dict);
|
const nextDict: Record<string, T> = Object.assign({}, dict);
|
||||||
for (const id of dropped) delete nextDict[id];
|
for (const id of dropped) delete nextDict[String(id)];
|
||||||
return { order: nextOrder, dict: nextDict };
|
return { order: nextOrder, dict: nextDict };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +102,12 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
claims: {},
|
claims: {},
|
||||||
remittances: {},
|
remittances: {},
|
||||||
activity: [],
|
activity: [],
|
||||||
|
acks: {},
|
||||||
|
ta1Acks: {},
|
||||||
claimOrder: [],
|
claimOrder: [],
|
||||||
remitOrder: [],
|
remitOrder: [],
|
||||||
|
ackOrder: [],
|
||||||
|
ta1AckOrder: [],
|
||||||
|
|
||||||
addClaim: (c) =>
|
addClaim: (c) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@@ -147,6 +161,38 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { activity: next };
|
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) =>
|
reset: (resource) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
@@ -156,6 +202,10 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { remittances: {}, remitOrder: [] };
|
return { remittances: {}, remitOrder: [] };
|
||||||
case "activity":
|
case "activity":
|
||||||
return { activity: [] };
|
return { activity: [] };
|
||||||
|
case "acks":
|
||||||
|
return { acks: {}, ackOrder: [] };
|
||||||
|
case "ta1_acks":
|
||||||
|
return { ta1Acks: {}, ta1AckOrder: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user