feat(catalog): persist idempotent bulk operations and retryable deliveries
This commit is contained in:
1 parent
76e46d295c
commit
13665e0c3c
53 files changed
+1316
-76
No files matched your search
@@ -57,10 +57,8 @@ it("gates edits before database and sends one update after commit", async () =>
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
state.send.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
expect(state.refresh).toHaveBeenCalled();
|
||||
});
|
||||
it("stops denied permission before export or data access", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
@@ -76,7 +74,7 @@ it("does not notify hotel when a transaction fails", async () => {
|
||||
it("does not report a committed edit as failed on audit failure", async () => {
|
||||
state.audit.mockRejectedValueOnce(Error("log failed"));
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads destination choices with view permission only", async () => {
|
||||
@@ -95,9 +93,9 @@ it("undo enforces edit access and exports only successful restores", async () =>
|
||||
state.undo.mockResolvedValueOnce({ changedCount: 2 });
|
||||
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.undo).toHaveBeenCalledWith([10, 11], 1);
|
||||
expect(state.undo).toHaveBeenCalledWith([10, 11], 1, undefined);
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
it("undo denial stops before export and a conflict does not notify", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
@@ -107,3 +105,10 @@ it("undo denial stops before export and a conflict does not notify", async () =>
|
||||
expect((await undoBulkOffers([10])).ok).toBe(false);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a stable operation key and does not synchronously notify after commit", async () => {
|
||||
const key = "123e4567-e89b-42d3-a456-426614174000";
|
||||
expect((await applyBulkOffers(input, "x", key)).ok).toBe(true);
|
||||
expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -115,6 +115,17 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/operations/server", async () => {
|
||||
const { db } = await import("@/lib/db");
|
||||
return {
|
||||
runOperation: (
|
||||
_input: unknown,
|
||||
work: (tx: unknown, id: string) => Promise<unknown>,
|
||||
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||
enqueueEffect: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
applyHistory,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readHistory,
|
||||
recordHistory,
|
||||
} from "@/features/history/server";
|
||||
import { enqueueEffect, runOperation } from "@/features/operations/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
@@ -115,11 +116,12 @@ export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
userId?: number,
|
||||
requestKey: string = randomUUID(),
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new CatalogInputError("A valid preview is required");
|
||||
return db.transaction(async (tx) => {
|
||||
const work = async (tx: Tx, operationId?: string) => {
|
||||
// Read references, then lock pages before offers to match category structural commands.
|
||||
const initial = await readOffers(tx, input.ids);
|
||||
const pages = await readPages(tx, initial, input, true);
|
||||
@@ -167,8 +169,27 @@ export async function applyBulkOffersCommand(
|
||||
historyIds.push(historyId);
|
||||
}
|
||||
}
|
||||
if (operationId && result.changedCount > 0) {
|
||||
await enqueueEffect(tx, operationId, "catalog.refresh");
|
||||
await enqueueEffect(tx, operationId, "catalog.export.request");
|
||||
}
|
||||
return { changedCount: result.changedCount, historyIds };
|
||||
});
|
||||
};
|
||||
return userId
|
||||
? runOperation(
|
||||
{
|
||||
actorId: userId,
|
||||
kind: "catalog.bulk.apply",
|
||||
key: requestKey,
|
||||
input: {
|
||||
...input,
|
||||
ids: [...input.ids].sort((a, b) => a - b),
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
work,
|
||||
)
|
||||
: db.transaction((tx) => work(tx));
|
||||
}
|
||||
|
||||
export async function listBulkOfferDestinationsCommand() {
|
||||
@@ -188,6 +209,7 @@ export async function listBulkOfferDestinationsCommand() {
|
||||
export async function undoBulkOffersCommand(
|
||||
historyIds: number[],
|
||||
userId: number,
|
||||
requestKey: string = randomUUID(),
|
||||
) {
|
||||
if (
|
||||
!Array.isArray(historyIds) ||
|
||||
@@ -197,27 +219,39 @@ export async function undoBulkOffersCommand(
|
||||
historyIds.some((id) => !Number.isSafeInteger(id) || id < 1)
|
||||
)
|
||||
throw new CatalogInputError("Invalid undo selection");
|
||||
return db.transaction(async (tx) => {
|
||||
const entries = [];
|
||||
for (const id of historyIds) {
|
||||
const entry = await readHistory(tx, id);
|
||||
if (entry.kind !== "catalog_offer")
|
||||
throw new CatalogInputError("Invalid undo selection");
|
||||
entries.push(entry);
|
||||
}
|
||||
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
|
||||
if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length)
|
||||
throw new CatalogInputError("Duplicate undo target");
|
||||
await lockOfferHistoryPages(tx, entries);
|
||||
try {
|
||||
for (const entry of entries) await applyHistory(tx, entry, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "conflict")
|
||||
throw new CatalogConflict(
|
||||
"Offers changed after this update. Undo was not applied.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { changedCount: entries.length };
|
||||
});
|
||||
return runOperation(
|
||||
{
|
||||
actorId: userId,
|
||||
kind: "catalog.bulk.undo",
|
||||
key: requestKey,
|
||||
input: { historyIds: [...historyIds].sort((a, b) => a - b) },
|
||||
},
|
||||
async (tx, operationId) => {
|
||||
const entries = [];
|
||||
for (const id of historyIds) {
|
||||
const entry = await readHistory(tx, id);
|
||||
if (entry.kind !== "catalog_offer")
|
||||
throw new CatalogInputError("Invalid undo selection");
|
||||
entries.push(entry);
|
||||
}
|
||||
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
|
||||
if (
|
||||
new Set(entries.map((entry) => entry.targetId)).size !== entries.length
|
||||
)
|
||||
throw new CatalogInputError("Duplicate undo target");
|
||||
await lockOfferHistoryPages(tx, entries);
|
||||
try {
|
||||
for (const entry of entries) await applyHistory(tx, entry, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "conflict")
|
||||
throw new CatalogConflict(
|
||||
"Offers changed after this update. Undo was not applied.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await enqueueEffect(tx, operationId, "catalog.refresh");
|
||||
await enqueueEffect(tx, operationId, "catalog.export.request");
|
||||
return { changedCount: entries.length };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,17 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/features/operations/server", async () => {
|
||||
const { db } = await import("@/lib/db");
|
||||
return {
|
||||
runOperation: (
|
||||
_input: unknown,
|
||||
work: (tx: unknown, id: string) => Promise<unknown>,
|
||||
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||
enqueueEffect: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { undoBulkOffersCommand } from "./bulk-offers";
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in new issue
Block a user