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.
Your mission
Section titled “Your mission”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.
Coding time
Section titled “Coding time”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.
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>.
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):
const params = await searchParams; const deleted = typeof params.deleted === 'string' ? params.deleted : undefined; {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.
'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.
The db.transaction(async (tx) => …) API you refactor into, including returning a value from the callback.
findFirst with a where callback — the tenant-scoped existence read you run on tx inside the block.
Why redirect throws and must stay outside the transaction callback, plus the 303 it serves from a Server Action.
What the post-commit cache invalidation does, and why it belongs after the transaction, not inside it.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 6The 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:
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.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.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.