Skip to content
Chapter 47Lesson 6

Transactional delete

Harden the delete path of a CRUD surface: wrap the multi-step delete in one Drizzle transaction, and confirm it through a URL param.

Your delete already works: confirm the dialog, the row vanishes, you land back on the list. So why are we here?

An invoice doesn’t live alone. It owns invoice_lines rows today, and over the next units it will own an audit-log entry, a soft-delete flag, and a file in object storage. Once a delete touches more than one row, “does it delete?” becomes “does it delete all or nothing?” This lesson installs the shape that keeps a multi-step delete atomic as those steps pile on.

By the end, deleting an invoice removes the invoice and its line rows inside one Drizzle transaction, and the list page shows a server-rendered “Invoice INV-0042 deleted” banner that survives with JavaScript off, with a Sonner toast layered on when it’s on.

A forced error mid-delete — the invoice delete fails after the lines are already gone — leaves both exactly where they were. Nothing half-deleted, ever.

Refactor deleteInvoice to run its work inside one Drizzle transaction, and confirm the delete through a URL param the page can paint with or without JavaScript.

The senior point first, because it’s the whole reason this lesson exists: the foreign key on invoice_lines.invoiceId is already ON DELETE CASCADE, so Postgres deletes the children on its own and the transaction buys you nothing for the code as it stands. You add it for shape, not correctness — an explicit, reviewable block where a reviewer reads each step in order, and a slot the later audit-log write, soft-delete branch, and file cleanup all drop into without re-architecting the action.

That shape comes with a short rulebook, covered step by step when you write it. Every query runs on tx, never db. External calls and revalidatePath/redirect stay outside the callback, since a rollback can’t undo a sent email, a busted cache, or a navigation. The expected miss — the row isn’t there, or it belongs to another org — returns a plain value rather than throwing, since throwing is reserved for a genuine rollback. And success travels back through a ?deleted=INV-0042 query param the page renders as a server-side banner, so a no-JavaScript browser still gets a visible confirmation with the toast as a pure enhancement.

Confirming a delete removes the invoice and all its line rows together.
tested
A forced error after the line delete but before the invoice delete leaves both the invoice and its lines intact.
tested
Deleting a missing or other-org invoice returns a not-found result rather than throwing.
tested
After a successful delete the list page shows the deleted invoice’s number in a server-rendered banner, present without JavaScript.
tested
With JavaScript on, a Sonner toast carrying the same number appears on top of the banner.
untested
No external call or cache revalidation sits inside the transaction callback.
untested

Refactor deleteInvoice into the transactional shape and carry the deleted number back through the redirect. Only the action changes; the list page and the toast island are already in the starter.

Reference solution and walkthrough

Here is the full refactored deleteInvoice. The parse and context seams are unchanged from the previous lesson; the transaction is the new body.

src/lib/invoices/actions.ts
export const deleteInvoice = async (
_prevState: Result<null> | null,
formData: FormData,
): Promise<Result<null>> => {
const parsed = deleteInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId } = await getActiveContext();
const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);
};

The transaction block is where every decision in the brief lands, so walk it step by step.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

The existence read runs inside the transaction, on tx, and is tenant-scoped: and(eq(t.id, ...), eq(t.organizationId, organizationId)). Reading on tx is what makes the check-then-delete atomic. It also captures existing.number for the redirect.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

A missing row — bad id, or an id forged from another org — returns a discriminated { notFound: true as const } and ends the callback. You do not throw: this is an expected outcome, and the body below maps it to a Result.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

Delete the children first, on tx. The ON DELETE CASCADE foreign key would remove them anyway, but deleting them explicitly makes the block read as the multi-step operation it will become — and gives the rollback test something to roll back.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

Then delete the parent, on tx, with the same tenant-scoped where. Both deletes share one transaction, so if this one fails, Postgres undoes the first too. A stray db.delete here would open its own transaction and commit independently, breaking the all-or-nothing guarantee.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

On the happy path, return { notFound: false as const, deletedNumber: existing.number }. The discriminated union lets the body branch on result.notFound with the number narrowed in on the success side.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

result.notFound maps to err('not_found', 'Invoice not found.') — a clean failed Result, never a thrown error.

const result = await db.transaction(async (tx) => {
const existing = await tx.query.invoices.findFirst({
where: (t, { and, eq }) =>
and(eq(t.id, parsed.data.id), eq(t.organizationId, organizationId)),
});
if (!existing) {
return { notFound: true as const };
}
await tx
.delete(invoiceLines)
.where(eq(invoiceLines.invoiceId, parsed.data.id));
await tx
.delete(invoices)
.where(
and(
eq(invoices.id, parsed.data.id),
eq(invoices.organizationId, organizationId),
),
);
return { notFound: false as const, deletedNumber: existing.number };
});
if (result.notFound) {
return err('not_found', 'Invoice not found.');
}
revalidatePath('/invoices');
redirect(`/invoices?deleted=${result.deletedNumber}`);

revalidatePath and redirect sit outside the callback, on the committed-success path only. A rolled-back delete leaves through a throw, so neither runs and a failed delete never invalidates the cache or navigates. The redirect carries the captured number as ?deleted=<number>.

1 / 1

The list page and the toast island are already in the starter — you don’t write them, but they’re shown here so you can see where your ?deleted=<number> redirect lands. This slice of app/invoices/page.tsx reads the param and renders the two layers (the searchParams read is the pattern from URL state with searchParams and route params):

src/app/invoices/page.tsx (provided)
const params = await searchParams;
const deleted =
typeof params.deleted === 'string' ? params.deleted : undefined;
src/app/invoices/page.tsx (provided)
{deleted ? (
<>
{/* SSR success banner — survives no-JS (it's text from searchParams). */}
<p
role="status"
data-testid="deleted-banner"
className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
>
Invoice {deleted} deleted
</p>
{/* JS-enhanced toast island. */}
<DeletedToast number={deleted} />
</>
) : null}

The banner is role="status" text rendered on the server from searchParams, so it reaches a no-JavaScript browser in the HTML — that is the half of the confirmation that survives without scripting. The <DeletedToast> island is the enhancement on top, also provided: a Client Component whose useEffect fires one Sonner toast.success keyed by the number, returning nothing.

src/app/invoices/_components/deleted-toast.tsx (provided)
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
// The JS-enhanced success toast. Success data flows through the URL (?deleted),
// so the SSR `deleted-banner` survives no-JS; this island is the enhancement on
// top — it fires the Sonner toast once when the param is present.
export const DeletedToast = ({ number }: { number: string }) => {
useEffect(() => {
toast.success(`Invoice ${number} deleted`, { id: `deleted-${number}` });
}, [number]);
return null;
};

The <Toaster> that renders these toasts is mounted once in app/layout.tsx, so the island only fires the event. The id: `deleted-${number}` dedupes it: re-rendering with the same number won’t stack a second toast.

That covers the two checklist items the tests can’t reach. Req 6 — no external call or revalidation inside the callback — you confirm by eye: every tx. call lives in the block, and both revalidatePath and redirect sit after it. When later units add an audit-log write, a soft-delete branch, or file cleanup, the writes join the block on tx and external dispatch lands after the commit — which is why you build the heavier shape now. Req 5 — the Sonner toast — is fired by the provided island; verify it by hand with JavaScript on.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 6

The suite drives your deleteInvoice against a real Postgres and asserts the observable result, never your file or function names. It covers four cases. Deleting a seeded invoice-with-lines removes both rows together. Forcing the invoice delete to fail after the line delete has run — via a real BEFORE DELETE trigger on invoices — leaves both rows intact, the database-level rollback that proves the two deletes are one atomic block rather than two independent commits. Deleting a non-existent id, or another org’s invoice, returns a not_found Result with no throw and leaves the foreign row untouched. Rendering the provided list page with ?deleted=INV-0042 puts the banner text in the server markup. A green run looks like this:

✓ tests/lessons/Lesson 6.test.ts (6 tests) 1234ms
Test Files 1 passed (1)
Tests 6 passed (6)

The tests cover the rollback, the not-found result, and the banner text. Confirm the rest by hand and tick each off:

Delete an invoice and confirm in pnpm db:studio that the invoice row and its invoice_lines rows are gone, and that the list shows the “Invoice INV-0042 deleted” banner plus a Sonner toast.
untested
Temporarily add throw new Error('debug rollback') between the two deletes and attempt a delete — the request fails and Studio still shows both the invoice and its lines. Remove the throw afterward.
untested
Disable JavaScript and delete via the inline fallback form — the server-rendered banner still renders from the URL param, while the JavaScript-only toast does not.
untested

That closes the chapter: a full CRUD surface on the invoicing data layer, with the delete carrying the atomic transaction shape it will grow into.