feat(sp28): claimAcks slice + useTailStream/useMergedTail dispatch

This commit is contained in:
Nora
2026-07-02 11:31:17 -06:00
parent a97f1d1350
commit c54b2c1867
8 changed files with 326 additions and 6 deletions
+84 -1
View File
@@ -5,7 +5,14 @@
// state shape.
import { beforeEach, describe, expect, it } from "vitest";
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
import type {
Ack,
Activity,
Claim,
ClaimAck,
Remittance,
Ta1Ack,
} from "@/types";
import { TAIL_CAP, useTailStore } from "./tail-store";
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
@@ -77,6 +84,23 @@ function makeTa1Ack(id: number, overrides: Partial<Ta1Ack> = {}): Ta1Ack {
};
}
function makeClaimAck(id: number, overrides: Partial<ClaimAck> = {}): ClaimAck {
return {
id,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999",
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
linkedBy: "auto",
claimState: "received",
...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
@@ -87,6 +111,7 @@ describe("useTailStore", () => {
useTailStore.getState().reset("activity");
useTailStore.getState().reset("acks");
useTailStore.getState().reset("ta1_acks");
useTailStore.getState().reset("claim-acks");
});
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
@@ -227,4 +252,62 @@ describe("useTailStore", () => {
expect(Object.keys(s.ta1Acks)).toHaveLength(0);
expect(Object.keys(s.acks)).toHaveLength(1);
});
// -------------------------------------------------------------------------
// SP28: the claim↔ack link slice mirrors the SP25 acks/ta1_acks slice
// (numeric id from the `claim_acks` table, first write wins, FIFO-capped
// at TAIL_CAP). The slice drives both the ClaimDrawer's
// <Acknowledgments /> panel and the AckDrawer's <MatchedClaim /> panel
// via `useTailStream("claim-acks")` + `useMergedTail("claim-acks", …)`.
// -------------------------------------------------------------------------
it("test_add_claim_ack_adds_new_claim_ack_keyed_by_id", () => {
const { addClaimAck } = useTailStore.getState();
addClaimAck(makeClaimAck(1));
addClaimAck(makeClaimAck(2));
const { claimAcks } = useTailStore.getState();
expect(Object.keys(claimAcks)).toHaveLength(2);
expect(claimAcks["1"]?.claimId).toBe("CLM-1");
expect(claimAcks["2"]?.claimId).toBe("CLM-1");
});
it("test_add_claim_ack_with_duplicate_id_is_noop", () => {
const { addClaimAck } = useTailStore.getState();
addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "A" }));
addClaimAck(makeClaimAck(1, { setAcceptRejectCode: "R" }));
const { claimAcks } = useTailStore.getState();
expect(Object.keys(claimAcks)).toHaveLength(1);
// First write wins; snapshot replay must not trample the canonical row.
expect(claimAcks["1"]?.setAcceptRejectCode).toBe("A");
});
it("test_reset_claim_acks_clears_only_claim_acks_slice", () => {
const { addClaimAck, addAck, reset } = useTailStore.getState();
addClaimAck(makeClaimAck(1));
addAck(makeAck(1));
reset("claim-acks");
const s = useTailStore.getState();
expect(Object.keys(s.claimAcks)).toHaveLength(0);
// The other slices are untouched.
expect(Object.keys(s.acks)).toHaveLength(1);
});
it("test_claim_acks_slice_evicts_oldest_when_over_10000", () => {
// Cap-eviction smoke test — same shape as the claims FIFO test
// above. 5 over the cap to exercise the eviction path without
// pinning down the wall-clock cost of a full 10 005-insert run.
const N = TAIL_CAP + 5;
const { addClaimAck } = useTailStore.getState();
for (let i = 0; i < N; i++) {
addClaimAck(makeClaimAck(i));
}
const { claimAcks } = useTailStore.getState();
expect(Object.keys(claimAcks)).toHaveLength(TAIL_CAP);
// The five oldest ids must be gone.
expect(claimAcks["0"]).toBeUndefined();
expect(claimAcks["4"]).toBeUndefined();
// The most recent id must be present.
expect(claimAcks[String(N - 1)]).toBeDefined();
}, 60_000);
});
+46 -1
View File
@@ -21,7 +21,14 @@
// ---------------------------------------------------------------------------
import { create } from "zustand";
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
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. */
@@ -52,6 +59,13 @@ interface TailStore {
// `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
@@ -62,6 +76,7 @@ interface TailStore {
remitOrder: string[];
ackOrder: number[];
ta1AckOrder: number[];
claimAckOrder: number[];
// --- Setters ---------------------------------------------------------
addClaim: (c: Claim) => void;
@@ -69,6 +84,7 @@ interface TailStore {
addActivity: (a: Activity) => void;
addAck: (a: Ack) => void;
addTa1Ack: (a: Ta1Ack) => void;
addClaimAck: (a: ClaimAck) => void;
reset: (resource: TailResource) => void;
}
@@ -104,10 +120,12 @@ export const useTailStore = create<TailStore>((set) => ({
activity: [],
acks: {},
ta1Acks: {},
claimAcks: {},
claimOrder: [],
remitOrder: [],
ackOrder: [],
ta1AckOrder: [],
claimAckOrder: [],
addClaim: (c) =>
set((s) => {
@@ -193,6 +211,31 @@ export const useTailStore = create<TailStore>((set) => ({
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) {
@@ -206,6 +249,8 @@ export const useTailStore = create<TailStore>((set) => ({
return { acks: {}, ackOrder: [] };
case "ta1_acks":
return { ta1Acks: {}, ta1AckOrder: [] };
case "claim-acks":
return { claimAcks: {}, claimAckOrder: [] };
}
}),
}));