feat(catalog): persist idempotent bulk operations and retryable deliveries
CI / check (push) Successful in 3m7s
CI / deploy (push) Successful in 1m37s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-13 18:05:03 +02:00
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,
+60 -26
View File
@@ -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(() => {