PR 3 (Contract): drop the old column, promote the new pair
Two PRs in, production is running on a schema that carries three money columns: the old total, plus the subtotal and tax pair that PR 2 backfilled to 100% coverage and promoted to NOT NULL. Every write fills all three; every read still falls through coalesce to total for any row the backfill might have missed. That belt-and-suspenders state was the whole point — it is what let you ship the consequential change in two safe, fully-reversible steps. This lesson you cash it in. You drop total, strip the transitional scaffolding from the app, and land production on the target shape: two money columns, no legacy.
When it lands, nothing about the surface looks different — the table and the edit form already render the pair, since PR 2 did that work. What changes is underneath. The list and detail reads pull subtotal and tax straight from their now-NOT NULL columns with no fall-through; the create and edit paths persist only the pair; and the total column is gone from the database. On the inspector, the schema-state probe shows subtotal NOT NULL and tax NOT NULL with no total row, split-coverage holds at 100%, the data-integrity diff reads n/a — total dropped because there is no longer a total to diff against, and Sentry stays quiet through the deploy. There is no screenshot to show here — the proof is in those panels, not in the UI.
Your mission
Section titled “Your mission”Your job is to finish the cadence: remove the legacy total column, strip every reference to it from the app, and settle production on the subtotal + tax shape. This is the cadence’s payoff, and it is also its one irreversible move — once the column is dropped, the data it held is gone. The striking thing is that it is by far the smallest of the three PRs. PR 2 did the heavy lifting: it promoted the pair to NOT NULL and taught every write and read to handle both shapes, so by the time you arrive here the schema change is a single DROP COLUMN total and the app work is mechanical deletion. Keeping the consequential PR to that one statement is deliberate — the most dangerous change in the chapter should also be the easiest to review, because a reviewer can hold the entire diff in their head.
Two safety nets prove that no reader survives the drop, and they cover different ground. The first is the type checker, and it works for free: Drizzle’s typed query builder no longer exposes invoices.total once you remove it from the schema, so any surviving typed reference becomes a build error the moment you run tsc. The second is a single scoped grep — for invoices.total and invoiceTotal, not the bare English word total, which appears in a hundred innocent places. You need the grep because raw SQL strings and any code outside the typed builder slip past the type system entirely. The combined amount, wherever a surface still wants to show it, is computed at the app layer as subtotal + tax through the combinedAmount helper from PR 2 — never read from a column, because there is no column.
There is a senior risk here worth naming even though it does not bite in this project: any reader of the table that is not this app — a nightly report script, an analytics pipeline reading the raw column, a downstream service — breaks the instant total disappears, and the type checker knows nothing about them. A real production contract PR is gated on a sweep of every reader of the table, not just the application. This project has no external readers, so the type check plus the scoped grep are sufficient; in a real system they would be the floor, not the ceiling. One more thing to hold onto for next time: this is the single PR in the cadence whose schema move an alias rollback cannot undo. Re-pointing the production alias to the previous deployment rolls back your code instantly, but the dropped column stays dropped — the rollback rehearsal lesson makes exactly that point concrete against this deployment. Out of scope here: that rollback rehearsal, and any new feature work. Drop the column, delete the scaffolding, ship it.
DROP COLUMN total and contains nothing destructive beyond it.subtotal and tax; the transitional combined-amount write and the legacy-amount fallback are both gone.subtotal and tax directly, with no coalesce fall-through to total.combinedAmount rather than reading it from a column.invoices.total / invoiceTotal returns nothing.subtotal NOT NULL, tax NOT NULL, and no total; the list and every mutation work against the new shape; a SELECT total errors with column-does-not-exist.vercel-build, producing a production deployment whose commit SHA matches the merge commit.Coding time
Section titled “Coding time”Implement against the brief and the lesson’s tests, rehearse the result on a preview branch, then open and merge the PR. Read the reference solution below only after you have attempted it. The tests for this lesson can’t import the mutation and read modules — actions.ts, queries.ts, and schema.ts all pull in server-only plus the env boundary and a live Postgres client, which throw the instant a Node test touches them — so they read your source and prove it carries the settled shape, with one exception: the pure combinedAmount helper has no server dependencies, so the gate exercises it for real. A source-shape gate confirms the shape; only the preview rehearsal proves the column is actually gone and the app still serves.
Reference solution and walkthrough
The work lives on a branch named contract/drop-total, and it is four files of deletion plus a generated migration. Start at the schema, because everything downstream is a consequence of the column leaving it.
src/db/schema.ts — remove the total column and its TODO(L5) marker. The pair is already .notNull() from PR 2, so the money block settles to two lines:
subtotal: numeric('subtotal', { precision: 12, scale: 2 }).notNull(), tax: numeric('tax', { precision: 12, scale: 2 }).notNull(),That deletion is the entire schema change, and it is worth pausing on why it is safe to ship to live production in one statement. DROP COLUMN in Postgres is metadata-only: it marks the column dead in the catalog and returns almost instantly, even on a table with millions of rows — the on-disk space is reclaimed later by a background VACUUM, not during the migration. So there is no table scan, no long lock, and no --> statement-breakpoint to split anything across. That is the opposite of PR 2’s SET NOT NULL, which had to scan every row to verify no nulls remained — the trade-off the expand-migrate-contract lesson walks through. Drop is cheap and fast; what makes it the move you ship last is that it cannot be reversed — the bytes the column held are gone the instant it lands.
drizzle/0007_contract_total.sql — generated by pnpm db:generate after you edit the schema. The whole migration is one statement:
ALTER TABLE "invoices" DROP COLUMN "total";No breakpoint, no second statement. If db:generate produces anything more than this, you changed more than the one column — back out the extra change and regenerate.
src/lib/invoices/actions.ts — this is the file where “what comes out” is least obvious, because the transitional dual-write threaded total through three places. Three deletions retire it: the total field leaves both Zod schemas, the total: combinedAmount(...) line leaves both the .values({...}) insert and the .set({...}) update, and the legacy-amount fallback goes with it. The settled createInvoice write carries only the pair:
const createInvoiceSchema = z.strictObject({ number: z.string().min(1), customerName: z.string().min(1), status: z.enum(STATUS_VALUES).default('draft'), subtotal: z.string().min(1), tax: z.string().min(1), currency: z.string().min(1).default('USD'),});
export const createInvoice = authedAction( 'member', createInvoiceSchema, async (input, ctx): Promise<Result<InvoiceRow>> => withTenant(ctx.orgId, async (tx) => { const [row] = await tx .insert(invoices) .values({ organizationId: ctx.orgId, number: input.number, customerName: input.customerName, status: input.status, subtotal: input.subtotal, tax: input.tax, currency: input.currency, }) .returning();The schema now accepts only the pair. The total: z.string().min(1) field that PR 2’s dual-write carried is deleted — a posted total is simply ignored by z.strictObject, but better, the form no longer sends one.
const createInvoiceSchema = z.strictObject({ number: z.string().min(1), customerName: z.string().min(1), status: z.enum(STATUS_VALUES).default('draft'), subtotal: z.string().min(1), tax: z.string().min(1), currency: z.string().min(1).default('USD'),});
export const createInvoice = authedAction( 'member', createInvoiceSchema, async (input, ctx): Promise<Result<InvoiceRow>> => withTenant(ctx.orgId, async (tx) => { const [row] = await tx .insert(invoices) .values({ organizationId: ctx.orgId, number: input.number, customerName: input.customerName, status: input.status, subtotal: input.subtotal, tax: input.tax, currency: input.currency, }) .returning();The insert writes the two columns and nothing else. The transitional total: combinedAmount({ subtotal: input.subtotal, tax: input.tax }) line is gone — there is no column to write it to — and so is the combinedAmount import in this file. The same three deletions apply verbatim to updateInvoice’s schema and its .set({...}) update.
The updateInvoice write changes identically — its schema drops total, its .set({...}) drops the total line, and the legacy fallback goes. Note that the lifecycle actions — archiveInvoice, restoreInvoice, softDeleteInvoice — touch no money column at all, so the cadence leaves them untouched. And because nothing writes total anymore, combinedAmount has no place in this file; its import comes out too, which the lesson test checks for explicitly.
src/lib/invoices/queries.ts — this is the clearest illustration of the dual-read coming out. PR 2 taught the reads to surface the pair through coalesce(subtotal, total) and coalesce(tax, 0), so a not-yet-backfilled row still produced sensible numbers, and it kept a total field on InvoiceRow for any caller that still read it. The contract step removes both: the reads select the columns directly, and the sort — which can no longer order on a dropped total column — orders on the derived expression instead.
const subtotalExpr = sql<string>`coalesce(${invoices.subtotal}, ${invoices.total})`;const taxExpr = sql<string>`coalesce(${invoices.tax}, 0)`;const amountExpr = sql`(${subtotalExpr} + ${taxExpr})`;// InvoiceRow carried `total: string` alongside the pair, and the select read:// subtotal: subtotalExpr,// tax: taxExpr,// total: invoices.total,The coalesce fall-through and the total field — what PR 2 shipped so a not-yet-backfilled row still read sensibly.
// InvoiceRow exposes subtotal: string and tax: string, no total field.const amountExpr = sql`(${invoices.subtotal} + ${invoices.tax})`;// and the select reads the columns directly: subtotal: invoices.subtotal, tax: invoices.tax,subtotal and tax read straight from their NOT NULL columns; the sort orders on (subtotal + tax) since there is no total column left.
Both listInvoices and getInvoiceDetail make the same change — select invoices.subtotal and invoices.tax directly, drop the total field from the returned shape. The -total and total sort cases keep their names (the URL parameter is part of the carried-in surface and does not change), but they now order on amountExpr, which is the live computation (subtotal + tax) rather than a stored value.
src/app/(protected)/invoices/[id]/edit/edit-form.tsx — all that remains here is clearing the TODO(L5) marker. The split subtotal and tax inputs landed in PR 2, and the combinedAmount(...) reads in table.tsx and conflict-banner.tsx landed with them, so there is no combined-amount affordance left to retire — the marker was a reminder to confirm that, nothing more. The split-input form work belongs to the migrate PR; don’t re-do it here.
A few decisions worth saying out loud:
- Why the migration is the drop only. Bundling anything else — an index change, a rename, a second drop — into the contract PR would defeat the reason it is small. The cadence’s one irreversible step should carry exactly the irreversible change and nothing a reviewer has to reason about separately. The lesson test enforces this: it counts drop statements in the generated SQL and fails on more than one.
- Why the scoped grep, given the type checker. Drizzle’s typed builder catches every reference that goes through it, which is most of the app. But the inspector reads the schema with raw
db.execute(sql\…`)probes that name columns by SQL literal —SELECT column_name FROM information_schema.columns WHERE table_name = ‘invoices’— and a SQL string is opaque to the type system. That is by design: it is what lets the inspector's_data.tscompile unchanged against both the old and the new schema. The grep forinvoices.totalandinvoiceTotal(the typed-reference spellings) covers the gap the type system can't see, and it comes back empty because the inspector never namestotal` as a typed property.
To finish, rehearse and ship:
- Run
pnpm verify(Biome +tsc+next build) andpnpm testlocally until both are green. Thetscstep is your first net — if any typedinvoices.totalsurvived, the build fails here. - Run the scoped grep —
rg 'invoices\.total|invoiceTotal' src scripts— and confirm it returns nothing. - Open the PR titled
contract: drop total, finalize subtotal + tax. Its preview deploys on a fresh Neon branch; the build runspnpm db:migrateto apply0007against that branch before booting, so the preview URL exercises the new code against the dropped-column shape. Walk the rehearsal checklist on it. - Merge once CI and
vercel-buildare green. Production rebuilds,0007applies against the Neonmainbranch, and the column is gone.
The Postgres reference confirming DROP COLUMN is metadata-only — quick, no rewrite, space reclaimed later by VACUUM.
How db:generate diffs your schema into a SQL file and db:migrate applies it — the step that produces 0007.
Copy-on-write branches per preview deploy, so 0007 runs against a throwaway branch before it touches production.
Moment of truth
Section titled “Moment of truth”Run the lesson’s gate:
pnpm test:lesson 5The gate reads your source and confirms the cadence landed: that the generated 0007 migration is a single DROP COLUMN total with nothing destructive beyond it, that schema.ts carries the subtotal + tax pair (both NOT NULL) and no total, that the create and update schemas accept and persist only the pair with the transitional total write and the combinedAmount import both gone, that the reads expose the pair directly with no coalesce and no invoices.total left to sort on, and that combinedAmount still adds the pair in exact integer cents. A clean pass looks like this:
✓ Lesson 5 — req 1: the contract migration is a single DROP COLUMN total (2) ✓ Lesson 5 — req 2: schema and mutations settle on subtotal + tax only (4) ✓ Lesson 5 — req 3: reads return subtotal + tax directly, no coalesce fall-through (3) ✓ Lesson 5 — req 4: the combined amount is computed, never read from a column (1)
Test Files 1 passed (1) Tests 10 passed (10)What the gate can’t reach is everything that happens off your local machine — the preview, the deploy, and the live production check. Tick these off by hand as you go:
invoices.total / invoiceTotal across src and scripts returns nothing.subtotal NOT NULL, tax NOT NULL, and no total row; running SELECT total FROM invoices in Drizzle Studio errors with column "total" does not exist./invoices renders, mutations succeed, and the inspector shows the target shape — split-coverage 100% and a data-integrity diff reading “n/a — total dropped” — with Sentry logging zero new errors.docs/runbooks/migration-subtotal-tax.md carries the closing PR 3 entry: the 0007 DROP COLUMN, the legacy-reference cleanup, and the type-checker + scoped-grep nets that proved no reader survived.