fix(messaging): monotonic outbound status webhooks

Drain claims outbox rows as sent before the provider acks. Late ERROR
webhooks were regressing delivered rows to failed; late DELIVERED could
upgrade exhausted failures. Gate webhook updates by allowed source status.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 04:55:36 +00:00 committed by Ingo Wagner
parent a38cfb437f
commit bf65df8603
2 changed files with 90 additions and 12 deletions

View File

@ -151,14 +151,24 @@ function createDeps(overrides: {
where,
data,
}: {
where: { id?: string; providerHandle?: string; status?: string };
where: {
id?: string;
providerHandle?: string;
status?: string | { in: string[] };
};
data: Record<string, unknown>;
}) => {
let count = 0;
for (const row of rows) {
if (where.id && row.id !== where.id) continue;
if (where.providerHandle && row.providerHandle !== where.providerHandle) continue;
if (where.status && row.status !== where.status) continue;
if (where.status) {
const allowed =
typeof where.status === "string"
? [where.status]
: where.status.in;
if (!allowed.includes(String(row.status))) continue;
}
Object.assign(row, data);
count += 1;
}
@ -540,7 +550,7 @@ describe("deliverMessagingOutbound", () => {
});
describe("applyMessagingOutboundStatus", () => {
it("maps terminal statuses onto outbox rows by handle", async () => {
it("maps terminal statuses onto pending outbox rows by handle", async () => {
const deps = createDeps({});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
@ -549,7 +559,7 @@ describe("applyMessagingOutboundStatus", () => {
status: "ERROR",
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-1" },
where: { providerHandle: "h-1", status: { in: ["pending"] } },
data: { status: "failed" },
});
@ -560,7 +570,7 @@ describe("applyMessagingOutboundStatus", () => {
status: "DELIVERED",
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-2" },
where: { providerHandle: "h-2", status: { in: ["pending", "sent"] } },
data: { status: "sent" },
});
@ -572,6 +582,62 @@ describe("applyMessagingOutboundStatus", () => {
});
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledTimes(2);
});
it("does not regress a sent row when a late ERROR webhook arrives", async () => {
const deps = createDeps({
outboundRows: [
{
id: "out-delivered",
idempotencyKey: "dm:1",
kind: "dm",
identityId: "mi-1",
body: "already delivered",
status: "sent",
providerHandle: "h-late-error",
},
],
});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
provider: "sendblue",
handle: "h-late-error",
status: "ERROR",
});
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "sent" }));
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-late-error", status: { in: ["pending"] } },
data: { status: "failed" },
});
});
it("does not upgrade a failed row when a late DELIVERED webhook arrives", async () => {
const deps = createDeps({
outboundRows: [
{
id: "out-failed",
idempotencyKey: "dm:2",
kind: "dm",
identityId: "mi-1",
body: "gave up",
status: "failed",
providerHandle: "h-late-sent",
},
],
});
await applyMessagingOutboundStatus(deps.prisma, {
type: "status",
provider: "sendblue",
handle: "h-late-sent",
status: "DELIVERED",
});
expect(deps.rows[0]).toEqual(expect.objectContaining({ status: "failed" }));
expect(deps.prisma.messagingOutbound.updateMany).toHaveBeenCalledWith({
where: { providerHandle: "h-late-sent", status: { in: ["pending", "sent"] } },
data: { status: "sent" },
});
});
});
function createChannelDeps(

View File

@ -468,20 +468,32 @@ function messagingOutboundRetryDelayMs(attempts: number): number {
return Math.min(2 ** attempts * 1000, 60_000);
}
function outboundStatusFromWebhook(event: MessagingOutboundStatus): "failed" | "sent" | null {
if (event.status === "ERROR" || event.status === "DECLINED") return "failed";
if (event.status === "SENT" || event.status === "DELIVERED") return "sent";
return null;
}
/** Stored statuses that may transition to the webhook target. */
function allowedSourcesForWebhookTarget(target: "failed" | "sent"): Array<"pending" | "sent" | "failed"> {
// Drain claims rows `sent` before the provider acks; a late ERROR must not
// regress a delivered row. Terminal `failed` must not be upgraded by SENT.
if (target === "failed") return ["pending"];
return ["pending", "sent"];
}
/** Outbound status webhooks update outbox rows by provider handle. */
export async function applyMessagingOutboundStatus(
prisma: PrismaClient,
event: MessagingOutboundStatus,
): Promise<void> {
const status =
event.status === "ERROR" || event.status === "DECLINED"
? "failed"
: event.status === "SENT" || event.status === "DELIVERED"
? "sent"
: null;
const status = outboundStatusFromWebhook(event);
if (!status) return;
await prisma.messagingOutbound.updateMany({
where: { providerHandle: event.handle },
where: {
providerHandle: event.handle,
status: { in: allowedSourcesForWebhookTarget(status) },
},
data: { status },
});
}