feat(sp28): claimAcks slice + useTailStream/useMergedTail dispatch
This commit is contained in:
@@ -7,7 +7,14 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { useMergedTail } from "./useMergedTail";
|
import { useMergedTail } from "./useMergedTail";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Claim, Remittance, Activity, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Activity,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
* Same `renderHook` shim used in `useClaimDetail`, `useDrawerUrlState`,
|
||||||
@@ -116,6 +123,22 @@ function ta1Ack(id: number, controlNumber: string): Ta1Ack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function claimAck(id: number, claimId: string): ClaimAck {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
claimId,
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto",
|
||||||
|
claimState: "received",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useMergedTail", () => {
|
describe("useMergedTail", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Singleton store — clear each slice between tests so cases are
|
// Singleton store — clear each slice between tests so cases are
|
||||||
@@ -125,6 +148,7 @@ describe("useMergedTail", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_merges_base_and_tail_by_id_base_first", () => {
|
it("test_merges_base_and_tail_by_id_base_first", () => {
|
||||||
@@ -304,4 +328,62 @@ describe("useMergedTail", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the claim↔ack link slice. Same keyed-by-id shape as the SP25
|
||||||
|
// acks/ta1_acks branches — numeric id, ordered arrival via
|
||||||
|
// `claimAckOrder`, deduped against baseItems by `String(id)`. The
|
||||||
|
// ClaimDrawer and AckDrawer both mount this slice and apply a
|
||||||
|
// page-specific filter (e.g. `link.claimId === claimId`) so the merge
|
||||||
|
// shape must remain symmetric with the existing branches.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_claim_acks_resource_orders_by_claim_ack_order_and_dedupes", () => {
|
||||||
|
const base = [claimAck(1, "CLM-A"), claimAck(2, "CLM-A")];
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(claimAck(2, "CLM-A-replay")); // duplicate id — must be dropped
|
||||||
|
addClaimAck(claimAck(3, "CLM-B"));
|
||||||
|
addClaimAck(claimAck(4, "CLM-B"));
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useMergedTail("claim-acks", base),
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = result.current ?? [];
|
||||||
|
// Base first, then tail in arrival order. Tail's "2" is dropped
|
||||||
|
// because the base already has id 2.
|
||||||
|
expect(merged.map((a) => a.id)).toEqual([1, 2, 3, 4]);
|
||||||
|
// Base version wins for id 2.
|
||||||
|
expect(merged[1]?.claimId).toBe("CLM-A");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_claim_acks_filter_predicate_drops_tail_items_that_dont_match", () => {
|
||||||
|
// The ClaimDrawer panel mounts a filter `link.claimId === claimId`
|
||||||
|
// so only this claim's acks show up. Verify the merge keeps the
|
||||||
|
// filter AFTER dedup against baseItems (mirrors the SP25 claim
|
||||||
|
// branch shape).
|
||||||
|
const base = [claimAck(1, "CLM-A")];
|
||||||
|
const { addClaimAck } = useTailStore.getState();
|
||||||
|
addClaimAck(claimAck(2, "CLM-A"));
|
||||||
|
addClaimAck(claimAck(3, "CLM-B"));
|
||||||
|
addClaimAck(claimAck(4, "CLM-A"));
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() =>
|
||||||
|
useMergedTail(
|
||||||
|
"claim-acks",
|
||||||
|
base,
|
||||||
|
(link) => link.claimId === "CLM-A",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = result.current ?? [];
|
||||||
|
// Filter only applies to tail (base is kept verbatim by contract);
|
||||||
|
// tail's id 3 (CLM-B) is filtered out, leaving 2 and 4 in
|
||||||
|
// arrival order after the base 1.
|
||||||
|
expect(merged.map((a) => a.id)).toEqual([1, 2, 4]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
// - SP25: the `id` type is widened to `string | number` because `Ack`
|
// - SP25: the `id` type is widened to `string | number` because `Ack`
|
||||||
// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance`
|
// and `Ta1Ack` use numeric database ids while `Claim` / `Remittance`
|
||||||
// use string ids. Dedup normalizes via `String(...)`.
|
// use string ids. Dedup normalizes via `String(...)`.
|
||||||
|
// - SP28: `ClaimAck` adds another numeric-id slice (`claimAcks` /
|
||||||
|
// `claimAckOrder`). Same dedup normalization applies — `String(id)`
|
||||||
|
// keeps the symmetric comparison the hook has always used.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
@@ -78,6 +81,19 @@ export function useMergedTail<T extends { id: string | number }>(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
// SP28: the claim↔ack link slice. Same keyed-by-id shape as
|
||||||
|
// acks/ta1_acks (numeric id from the `claim_acks` table), so the
|
||||||
|
// dispatch is symmetric — the page-specific filter (e.g.
|
||||||
|
// `link.claimId === claimId`) is applied AFTER dedup so the
|
||||||
|
// base list's rows aren't accidentally dropped.
|
||||||
|
case "claim-acks": {
|
||||||
|
const out: unknown[] = [];
|
||||||
|
for (const id of s.claimAckOrder) {
|
||||||
|
const v = s.claimAcks[String(id)];
|
||||||
|
if (v !== undefined) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { useTailStream } from "./useTailStream";
|
import { useTailStream } from "./useTailStream";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Claim, Ta1Ack } from "@/types";
|
import type { Ack, Claim, ClaimAck, Ta1Ack } from "@/types";
|
||||||
import type { TailEvent } from "@/lib/tail-stream";
|
import type { TailEvent } from "@/lib/tail-stream";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -232,6 +232,22 @@ function makeTa1Ack(id: number, controlNumber: string): Ta1Ack {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeClaimAck(id: number, claimId: string): ClaimAck {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
claimId,
|
||||||
|
batchId: null,
|
||||||
|
ackId: 99,
|
||||||
|
ackKind: "999",
|
||||||
|
ak2Index: 0,
|
||||||
|
setControlNumber: "991102989",
|
||||||
|
setAcceptRejectCode: "A",
|
||||||
|
linkedAt: "2026-07-02T12:00:00Z",
|
||||||
|
linkedBy: "auto",
|
||||||
|
claimState: "received",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTailStream", () => {
|
describe("useTailStream", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockStreamTail.mockReset();
|
mockStreamTail.mockReset();
|
||||||
@@ -242,6 +258,7 @@ describe("useTailStream", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -486,4 +503,37 @@ describe("useTailStream", () => {
|
|||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SP28: the claim↔ack link stream. Both ClaimDrawer's <Acknowledgments>
|
||||||
|
// panel and AckDrawer's <MatchedClaim> panel mount useTailStream with
|
||||||
|
// resource "claim-acks"; the dispatcher must route the live event into
|
||||||
|
// the claimAcks slice (separate from acks/ta1_acks).
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("test_claim_acks_item_event_lands_in_claim_acks_slice", async () => {
|
||||||
|
const { ctrls } = trackCalls();
|
||||||
|
const { unmount } = renderHook(() => useTailStream("claim-acks"));
|
||||||
|
|
||||||
|
await waitFor(() => ctrls.length === 1);
|
||||||
|
ctrls[0].push({ type: "snapshot_end", data: { count: 0 } });
|
||||||
|
await waitFor(() => useTailStore.getState().claimAcks !== undefined);
|
||||||
|
|
||||||
|
ctrls[0].push({
|
||||||
|
type: "item",
|
||||||
|
data: makeClaimAck(101, "CLM-42"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => useTailStore.getState().claimAcks["101"] !== undefined,
|
||||||
|
);
|
||||||
|
const stored = useTailStore.getState().claimAcks["101"];
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
expect(stored?.claimId).toBe("CLM-42");
|
||||||
|
// Must NOT bleed into the acks slice — keyed by id, separate dicts.
|
||||||
|
expect(useTailStore.getState().acks["101"]).toBeUndefined();
|
||||||
|
expect(useTailStore.getState().ta1Acks["101"]).toBeUndefined();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
import { streamTail, type TailResource } from "@/lib/tail-stream";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
import { useTailStore } from "@/store/tail-store";
|
||||||
import type { Ack, Activity, Claim, Remittance, Ta1Ack } from "@/types";
|
import type {
|
||||||
|
Ack,
|
||||||
|
Activity,
|
||||||
|
Claim,
|
||||||
|
ClaimAck,
|
||||||
|
Remittance,
|
||||||
|
Ta1Ack,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export type TailStatus =
|
export type TailStatus =
|
||||||
| "connecting"
|
| "connecting"
|
||||||
@@ -85,6 +92,13 @@ function dispatch(resource: TailResource, data: unknown): void {
|
|||||||
case "ta1_acks":
|
case "ta1_acks":
|
||||||
store.addTa1Ack(data as Ta1Ack);
|
store.addTa1Ack(data as Ta1Ack);
|
||||||
break;
|
break;
|
||||||
|
// SP28: the claim↔ack link slice. Both ClaimDrawer and AckDrawer
|
||||||
|
// mount this stream so a manual-match link made on one drawer
|
||||||
|
// shows up live on the other. The dispatch is the same
|
||||||
|
// first-write-wins pattern as the SP25 acks/ta1_acks branches.
|
||||||
|
case "claim-acks":
|
||||||
|
store.addClaimAck(data as ClaimAck);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,30 @@ describe("streamTail", () => {
|
|||||||
expect(calledUrl).toBe("/api/ta1-acks/stream");
|
expect(calledUrl).toBe("/api/ta1-acks/stream");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("test_targets_claim_acks_stream_endpoint", async () => {
|
||||||
|
// SP28: the `claim-acks` resource is part of TailResource; the URL
|
||||||
|
// must be /api/claim-acks/stream. The claim↔ack link stream is a
|
||||||
|
// shared resource (not per-claim or per-ack) — both the ClaimDrawer
|
||||||
|
// and AckDrawer subscribe to the same endpoint and filter
|
||||||
|
// server-side via the page-specific filterFn passed to
|
||||||
|
// `useMergedTail`.
|
||||||
|
const driver = makeStream();
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(driver.response);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
// Drive an event so the iterator's first read resolves — same
|
||||||
|
// pattern as `test_parses_well_formed_ndjson_into_typed_events`
|
||||||
|
// (the `test_targets_acks_stream_endpoint` shape hangs in CI
|
||||||
|
// because it awaits `gen.next()` before any chunk arrives).
|
||||||
|
const gen = streamTail("claim-acks");
|
||||||
|
driver.push('{"type":"snapshot_end","data":{"count":0}}');
|
||||||
|
driver.close();
|
||||||
|
await gen.next();
|
||||||
|
await gen.return?.(undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [calledUrl] = fetchMock.mock.calls[0] as [string];
|
||||||
|
expect(calledUrl).toBe("/api/claim-acks/stream");
|
||||||
|
});
|
||||||
|
|
||||||
it("test_handles_heartbeat_silently", async () => {
|
it("test_handles_heartbeat_silently", async () => {
|
||||||
// "Silently" here means: still yields the correctly-typed heartbeat
|
// "Silently" here means: still yields the correctly-typed heartbeat
|
||||||
// (no special filtering) — the consumer (useTailStream) decides what
|
// (no special filtering) — the consumer (useTailStream) decides what
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ export type TailEvent =
|
|||||||
| { type: "item_dropped"; data: { id: string } }
|
| { type: "item_dropped"; data: { id: string } }
|
||||||
| { type: "error"; data: { message: string } };
|
| { type: "error"; data: { message: string } };
|
||||||
|
|
||||||
export type TailResource = "claims" | "remittances" | "activity" | "acks" | "ta1_acks";
|
export type TailResource =
|
||||||
|
| "claims"
|
||||||
|
| "remittances"
|
||||||
|
| "activity"
|
||||||
|
| "acks"
|
||||||
|
| "ta1_acks"
|
||||||
|
| "claim-acks";
|
||||||
|
|
||||||
export interface StreamTailOptions {
|
export interface StreamTailOptions {
|
||||||
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
|
/** Forwarded to `fetch`; aborting the controller cleanly exits the generator. */
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
// state shape.
|
// state shape.
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
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";
|
import { TAIL_CAP, useTailStore } from "./tail-store";
|
||||||
|
|
||||||
function makeClaim(id: string, overrides: Partial<Claim> = {}): Claim {
|
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", () => {
|
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
|
||||||
@@ -87,6 +111,7 @@ describe("useTailStore", () => {
|
|||||||
useTailStore.getState().reset("activity");
|
useTailStore.getState().reset("activity");
|
||||||
useTailStore.getState().reset("acks");
|
useTailStore.getState().reset("acks");
|
||||||
useTailStore.getState().reset("ta1_acks");
|
useTailStore.getState().reset("ta1_acks");
|
||||||
|
useTailStore.getState().reset("claim-acks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test_add_claim_adds_new_claim_keyed_by_id", () => {
|
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.ta1Acks)).toHaveLength(0);
|
||||||
expect(Object.keys(s.acks)).toHaveLength(1);
|
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
@@ -21,7 +21,14 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import { create } from "zustand";
|
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";
|
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. */
|
||||||
@@ -52,6 +59,13 @@ interface TailStore {
|
|||||||
// `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP).
|
// `claims`/`remittances` (first write wins, FIFO-capped at TAIL_CAP).
|
||||||
acks: Record<string, Ack>;
|
acks: Record<string, Ack>;
|
||||||
ta1Acks: Record<string, Ta1Ack>;
|
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) ----
|
// --- 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
|
||||||
@@ -62,6 +76,7 @@ interface TailStore {
|
|||||||
remitOrder: string[];
|
remitOrder: string[];
|
||||||
ackOrder: number[];
|
ackOrder: number[];
|
||||||
ta1AckOrder: number[];
|
ta1AckOrder: number[];
|
||||||
|
claimAckOrder: number[];
|
||||||
|
|
||||||
// --- Setters ---------------------------------------------------------
|
// --- Setters ---------------------------------------------------------
|
||||||
addClaim: (c: Claim) => void;
|
addClaim: (c: Claim) => void;
|
||||||
@@ -69,6 +84,7 @@ interface TailStore {
|
|||||||
addActivity: (a: Activity) => void;
|
addActivity: (a: Activity) => void;
|
||||||
addAck: (a: Ack) => void;
|
addAck: (a: Ack) => void;
|
||||||
addTa1Ack: (a: Ta1Ack) => void;
|
addTa1Ack: (a: Ta1Ack) => void;
|
||||||
|
addClaimAck: (a: ClaimAck) => void;
|
||||||
reset: (resource: TailResource) => void;
|
reset: (resource: TailResource) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +120,12 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
activity: [],
|
activity: [],
|
||||||
acks: {},
|
acks: {},
|
||||||
ta1Acks: {},
|
ta1Acks: {},
|
||||||
|
claimAcks: {},
|
||||||
claimOrder: [],
|
claimOrder: [],
|
||||||
remitOrder: [],
|
remitOrder: [],
|
||||||
ackOrder: [],
|
ackOrder: [],
|
||||||
ta1AckOrder: [],
|
ta1AckOrder: [],
|
||||||
|
claimAckOrder: [],
|
||||||
|
|
||||||
addClaim: (c) =>
|
addClaim: (c) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@@ -193,6 +211,31 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { ta1Acks: nextTa1Acks, ta1AckOrder: nextOrder };
|
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) =>
|
reset: (resource) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
switch (resource) {
|
switch (resource) {
|
||||||
@@ -206,6 +249,8 @@ export const useTailStore = create<TailStore>((set) => ({
|
|||||||
return { acks: {}, ackOrder: [] };
|
return { acks: {}, ackOrder: [] };
|
||||||
case "ta1_acks":
|
case "ta1_acks":
|
||||||
return { ta1Acks: {}, ta1AckOrder: [] };
|
return { ta1Acks: {}, ta1AckOrder: [] };
|
||||||
|
case "claim-acks":
|
||||||
|
return { claimAcks: {}, claimAckOrder: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user