Capstone: Evolving a Live Schema
Prisma Intermediate/Advanced
Chapter 10 ยท Capstone: Evolving a Live Schema Without Downtime
The blog API from Prisma Fundamentals is live. Posts are either published or not, stored in a
published Boolean. The editors now want a third state, archived: hidden from the
front page but still reachable by link. The job is to replace published with a
status field — DRAFT, PUBLISHED or ARCHIVED —
while the site keeps running, several copies of the API are serving traffic, and no reader sees an error.
This capstone follows Chapter 8's expand-and-contract plan, and uses something from every chapter along the way.
| Release | Schema | Code | Chapters used |
|---|---|---|---|
| 1. Expand | Add optional status, plus an index | Unchanged | 7, 8, 9 |
| 2. Dual write | — | Writes set both fields, in one place | 1, 5, 6 |
| 3. Backfill | Data migration fills old rows | — | 3, 8 |
| 4. Switch reads | status becomes required | Reads use status; new stats | 2, 4, 5 |
| 5. Stop old writes | — | Only status written | 6 |
| 6. Contract | Drop published | — | 8, 9 |
Release 1: Expand
Old code ignores the new column; nothing it does can fail. It ships through the pipeline from Chapters 8 and 9:
CI checks the migration exists, the release step runs migrate deploy once, then the new version
starts. On a large table, edit the index line to CREATE INDEX CONCURRENTLY before committing
(Chapter 8).
Release 2: Dual Write
Every write must now keep the two fields in agreement. Scattering that across routes would be easy to get wrong, so the rule lives in one place: model methods from Chapter 6.
Routes that create or publish posts now call statusFields(...) or setStatus(...). The
optimistic-concurrency check from Chapter 1 stops two editors publishing and archiving the same post at once.
published, and an archived post has published: false, so old copies
of the app treat it as a draft — hidden. That's acceptable here, because archived posts should leave the
front page anyway. Always check what the old code will make of each new state: if it would have
exposed something private, you'd hold back the new state until Release 4.
Release 3: Backfill
Posts written before Release 2 still have status = NULL. A data migration (Chapter 8) fills them in,
using SQL (Chapter 3):
The WHERE "status" IS NULL makes it safe to re-run and leaves alone any post already written by
Release 2. Before moving on, verify the result — this is the check that makes the next step safe:
Release 4: Switch Reads
With every row filled in, status can become required:
The default matters: old copies still running during this deploy create posts without setting
status, and the default keeps those inserts working. Reads now switch over:
Update the seed scripts (Chapter 4) to set status too, including a few archived posts, so
development data exercises the new state.
Release 5: Stop Old Writes
Once no running copy reads published, statusFields stops writing it — one line,
because the rule lived in one place:
Before shipping, search the whole codebase — including raw SQL strings, seed scripts, tests and admin
scripts — for published. Raw SQL is invisible to TypeScript, so the compiler won't point it out.
Release 6: Contract
Finally, after at least one full release with nothing reading or writing the old column, remove it from the schema:
Looking Back
| Chapter | Role in the capstone |
|---|---|
| 1. Transactions | Version check in setStatus; consistent verification reads |
| 2. Aggregation | Posts per status with groupBy |
| 3. Raw SQL | The backfill and the mismatch check |
| 4. Seeding | Sample data with archived posts |
| 5. Testing | Integration test proving both fields agree |
| 6. Extensions | setStatus and the single source of the write rule |
| 7. Performance | The status, createdAt index for the front page |
| 8. Schema evolution | The whole expand-and-contract sequence |
| 9. Deploying | One migration step per release, before the new code starts |
Hands-On Exercises
Midway through (after Release 2), a bug is found and Release 2 must be rolled back to Release 1's code. What happens to posts archived in the meantime, and what must you check before redeploying Release 2?
๐ View solutionThe posts table has 20 million rows. Replace Release 3's single UPDATE with a batched backfill script that's safe to stop and restart, and report progress.
Apply the same approach to a new change: split User.name into firstName and lastName. Write the release plan and the backfill SQL, and point out where this is harder than the status change.
Chapter 10 Quick Reference
- Expand → dual write → backfill → switch reads → stop old writes → contract, each a separate release
- Keep the dual-write rule in one function or extension, so stopping it is a one-line change
- Ask what the old code will do with each new state before introducing it
- Backfills are re-runnable and verified with counts before the next step
- Give a newly required column a default so old code's inserts keep working
- Search raw SQL and scripts, not just TypeScript, before removing a field
- Dropping the column is the only irreversible step: back up and wait