Skip to content
Chapter 100Lesson 3

PR 1 (Expand): add the nullable subtotal and tax columns

Production is live. The app you wired to Vercel and Neon in the previous lesson is serving the invoices surface at a *.vercel.app URL, against the Neon main branch, behind branch-protected main and a green launch checklist. Now you begin the schema change the whole chapter exists for, and you begin it with the safest move there is.

Your goal in this lesson is to ship the expand step of the cadence: an additive-only migration that adds subtotal and tax as two new nullable columns, rehearsed on a Neon preview branch and merged to production through a green PR. When it lands, production runs the unchanged invoices app against a schema that now carries total plus two new columns nothing reads yet — and the inspector confirms it. The schema-state panel at /inspector shows subtotal and tax present and nullable; the split-coverage panel reads 0%, because every existing row still has them empty. No screenshot to chase here: the inspector is your finished-result proof, and those two panels are the ones to watch.

The expand step is the safe opening move of a destructive schema change. You widen the schema so the new and old shapes can coexist, without touching a line of application code and without rewriting a single existing row. That second clause is the whole point. A migration that has to rewrite rows takes a lock and runs for as long as the table is big; this one rewrites nothing, so it applies in milliseconds against a live database while traffic keeps flowing.

The reason it gets to be that cheap is nullability, and nullability is the entire safety argument of this PR. The running app does not yet read subtotal or tax — only the next PR teaches it to — so the columns can sit empty on every existing row. If you added them NOT NULL, Postgres would have to reject the migration outright: there is no value to put in the millions of rows already there, and a NOT NULL column with no default cannot be added to a table that has rows. The NOT NULL promotion is a real and necessary step, but it belongs at the tail of the next PR, after a backfill has populated every row. Here, the columns must be nullable, and that is not a compromise — it is what makes a column-add deployable against a live app with no incompatibility window.

One more decision hides in the column types. Copy total’s numeric(12, 2) precision and scale onto both new columns exactly. A mismatched precision is one of the quietest ways to corrupt money: a column that rounds to a different scale than the one feeding it will silently drop or pad cents, and you will not notice until an invoice total is a penny off and a customer emails about it. The reflex an experienced engineer reaches for is simply to copy the producer’s type — total is the producer of these values, so its precision is the precision.

Keep the scope of this PR ruthlessly narrow: the schema change, the generated migration, and the runbook entry, and nothing else. No edits to the actions, no edits to the queries, no new helper, no tests, no env changes. The temptation that gets people here is “while I’m in the file, let me also start writing the new columns so the next PR is smaller” — resist it completely. The instant this PR writes to the new columns, its rollback story stops being “revert the migration, nothing reads them” and becomes “revert the migration and reason about the data the deploy wrote in between.” Dual-write is the next PR’s job, and bundling it here is exactly what turns a trivially reversible change into one you have to think about. Keep total in place and leave the TODO(L4) and TODO(L5) markers untouched.

The single most valuable habit you build in this lesson is reading the preview build log line by line. When you push the branch, Vercel builds a preview against this PR’s own copy-on-write Neon branch, and the build command runs pnpm db:migrate before next build. The log is where you confirm the change is real: which migrations applied, whether any failed, whether the statement-breakpoint produced two separate ADD COLUMN statements. If vercel-build goes red, the log tells you whether it was the migration SQL that failed or the type-checked build — two completely different fixes. And if a preview branch ever ends up in a corrupt state, you do not repair it by hand: you close and reopen the PR, and Neon recreates the branch fresh from main. The preview is disposable on purpose. Treat it as the rehearsal stage it is.

The migration is additive only — it adds the two columns and contains no DROP, no NOT NULL add, and no RENAME.
tested
Both new columns are nullable and declared numeric(12, 2), matching total’s precision and scale.
tested
The migration applies cleanly against the live database without rewriting existing rows — confirmed by the preview build log and a sub-second completion on the seed data.
untested
On the preview deployment the existing app behaves identically — the list renders and create / edit / archive / restore all succeed — while the inspector shows subtotal and tax present, nullable, and unwritten (split-coverage 0%).
untested
The PR merges green across all CI checks and vercel-build, producing a production deployment whose commit SHA matches the merge commit.
untested
After the merge, production keeps working against the expanded schema — the list renders, mutations succeed reading and writing total, the inspector shows the two new nullable columns — and Sentry stays quiet across a two-minute observation window.
untested
A PR-1 entry in docs/runbooks/migration-subtotal-tax.md records what is true in production now and the cheap rollback available while nothing reads the new columns.
untested

Implement the expand PR against the brief above, then rehearse it on the preview before merging. The schema change is two lines; the discipline is in the workflow around it. Try it before opening the walkthrough.

Reference solution and walkthrough

The deliverable is small on purpose: two columns in the schema, one generated migration, one runbook section, and a green PR. We’ll go through it in the order you’d actually do the work — schema, generate, PR, merge, runbook.

Branch first — git switch -c expand/subtotal-tax — then open src/db/schema.ts. The combined-amount total column is sitting there with the three TODO markers beneath it:

// The combined-amount anti-pattern this cadence fixes: one numeric(12,2)
// column holding subtotal + tax mashed together.
total: numeric('total', { precision: 12, scale: 2 }).notNull(),
// TODO(L3) — add subtotal + tax nullable numeric(12,2)
// TODO(L4) — promote subtotal/tax to NOT NULL after backfill
// TODO(L5) — drop the total column

Replace the // TODO(L3) marker with the two new columns, leaving total and the other two markers exactly where they are:

subtotal: numeric('subtotal', { precision: 12, scale: 2 }),
tax: numeric('tax', { precision: 12, scale: 2 }),

The load-bearing detail is what’s missing: neither column has .notNull(). That omission is the entire safety argument from the brief made concrete — these columns are nullable, so the migration that adds them rewrites nothing and the existing rows stay valid with the new columns empty. The numeric('subtotal', { precision: 12, scale: 2 }) matches total’s declaration exactly, scale for scale, so no cent ever rounds away in the gap between the producer and the new pair.

Let Drizzle Kit translate the schema diff into SQL:

Terminal window
pnpm db:generate

It writes drizzle/0005_expand_subtotal_tax.sql:

ALTER TABLE "invoices" ADD COLUMN "subtotal" numeric(12, 2);--> statement-breakpoint
ALTER TABLE "invoices" ADD COLUMN "tax" numeric(12, 2);

Two ADD COLUMN statements, separated by Drizzle’s --> statement-breakpoint marker — the comment that tells the migration runner to send each statement to Postgres on its own.

Commit the schema change and the migration together, push the branch, and open a PR titled expand: add subtotal and tax columns (nullable). The push is the deploy: Vercel builds a preview against this PR’s own Neon branch, branched copy-on-write off main, and the build command runs pnpm db:migrate && next build — so 0005 applies to the preview branch before the app boots. This is the preview-per-PR workflow from a Neon branch per preview; here you finally use it to rehearse a real schema change.

Open the build log and read it. You’re looking for 0005_expand_subtotal_tax applied with a success line and sub-second timing — the proof that the migration ran and rewrote nothing. Then open the preview URL (Vercel Authentication will prompt you to sign in) and check two things against the inspector: the schema-state panel now lists subtotal and tax as nullable, and the split-coverage panel reads 0% because every seeded row still has them empty. Exercise the app while you’re there — list, create, edit, archive, restore — and watch it behave exactly as it did before. It can’t behave any other way: no code reads the new columns yet.

Your self-review is one sentence: the diff is two column additions plus the generated migration, and nothing else. If anything in actions.ts, queries.ts, or a component changed, you’ve leaked the next PR’s work into this one — back it out.

Merge once CI and vercel-build are green. The merge to main triggers the production build: pnpm install, then pnpm db:migrate applying 0005 against the Neon main branch, then next build. The new function fleet rolls out over a few minutes, and the production deployment’s commit SHA matches the merge commit — the inspector’s build-source panel is where you confirm which commit is live.

Then do the thing the cadence is built to let you do: watch production keep working. Hit /invoices, run a mutation or two, confirm they still read and write total exactly as before, and check the inspector shows the two new nullable columns. Give Sentry a two-minute window to stay quiet. This is the load-bearing observation of the whole chapter made real — a destructive schema change is underway, and production never noticed.

The last deliverable is the PR-1 section of docs/runbooks/migration-subtotal-tax.md, which ships as a stub with empty headers. Record what is now true and the rollback you have while it’s true:

  • The 0005_expand_subtotal_tax migration added subtotal and tax as nullable numeric(12, 2) columns alongside total.
  • No application code reads or writes the new columns yet, and no existing row was rewritten.
  • Rollback while nothing reads the columns is cheap: revert the migration (or simply drop the two columns). Because they are unread and unwritten, dropping them loses no data and breaks nothing — a property that disappears the moment the next PR starts writing to them.

That last bullet is the entry’s real value. It captures the window you’re in — the one where this change is trivially reversible — and names the exact moment it closes. A runbook is for the version of you reading it under pressure later, and “is this safe to undo right now?” is the first question that future reader asks.

The expand-migrate-contract cadence itself, and why forward-only migrations never open an incompatibility window, are owned by Expand, migrate, contract — lean on that rather than re-deriving it here.

The lesson tests are source-shape probes: they read the migration Drizzle Kit generated and confirm the one observable that proves the safety argument — that 0005 is an additive, nullable, correctly-typed pair of ADD COLUMNs. They don’t reach the live database or the preview; those outcomes you confirm by hand below. Run them:

Terminal window
pnpm test:lesson 3

A clean pass means the expand migration checks are green:

tests/lessons/Lesson 3.test.ts (6 tests)
Test Files 1 passed (1)
Tests 6 passed (6)

That covers the additive-only check (no DROP, no NOT NULL, no RENAME) and the nullable-numeric(12, 2) check. Everything else in this lesson is a deploy-and-observe outcome the tests can’t see. Work down this list by hand as you rehearse and merge:

The preview build log shows 0005_expand_subtotal_tax applied with a success line and sub-second timing.
untested
On the preview, the inspector’s schema-state probe shows subtotal and tax as nullable, and information_schema.columns reports is_nullable = YES for both.
untested
On the preview, create / edit / archive / restore all succeed and the split-coverage panel shows subtotal and tax null for every row (0%).
untested
After the merge, production /invoices renders, mutations succeed reading and writing total, the inspector shows the two new nullable columns, and Sentry reports zero new errors after a two-minute wait.
untested
docs/runbooks/migration-subtotal-tax.md carries the PR-1 state and the cheap-rollback note.
untested