Zero downtime
A rolling deploy runs old and new code against one database at the same time. Sequencing a required column, a drop, a rename and a backfill so no request fails while that is true.
How do you add a required column without a maintenance window? Not in one deploy. Replicas swap over one at a time. The old ones keep serving until the last one is gone.
So every migration has to be safe for the code that is already running.
Which changes are already safe
| Change | Safe mid-rollout | Why |
|---|---|---|
| Add a nullable column | Yes | Old code never selects it |
| Add a column with a default | Yes | Old inserts get the default |
| Add a table | Yes | Nothing reads it yet |
| Add an index | Yes | Queries do not change |
Add a NOT NULL column | No | Old inserts omit it and fail |
| Drop a column | No | Old code still selects it |
| Rename a column | No | It is a drop and an add |
In QUESTPIE terms, f.text(120) gives you a nullable column. .required() adds
NOT NULL. .default(value) adds a column default. So the safe row and the
unsafe row are one method apart. That is why this one catches people.
A required column is three deploys
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({
title: f.text(255).required(),
slug: f.text(120), // new, and nullable on purpose
}))
.title(({ f }) => f.title);Ship that. Old replicas ignore the column.
Deploy 2 does no schema work. Ship code that writes slug on every create, and
run a job that fills the existing rows. Wait until it finishes.
slug: f.text(120).required(),Each step is its own questpie migrate:create and its own deploy. Three
migrations, no failed request.
Removing a column is the same in reverse
Stop reading it first. Deploy that. Drop it in a later deploy. Drop it while old replicas are still selecting it and their queries fail.
Renaming is never a rename
migrate:create compares two snapshots and nothing else. It has no way to know
that a dropped column and a new column are the same thing.
Do it by hand instead. Add the new column. Write both. Backfill. Switch reads. Drop the old one in a later deploy. That is four deploys for a rename. Good reason to name the field right the first time.
Backfills are jobs, not migrations
Every migration runs inside one transaction. A backfill over millions of rows holds that transaction open for minutes and blocks everything queued behind it.
Split them. The schema change is a migration. The backfill is a job that works in bounded batches and can be stopped and resumed.
A migration is not a place for side effects
The transaction only rolls back database work. An HTTP call, an email or a dispatch to an external queue has already happened by the time the migration fails. Put those in a job.
Down is not a recovery plan
questpie migrate:down reverses the DDL. It cannot restore rows that an up()
deleted. Roll back a migration that dropped a column and you get the column
back, empty.
Take a backup immediately before anything destructive. Treat restore, not rollback, as the recovery path.
Check it before you deploy
Two checks, both cheap. Run the generator once more on a clean tree:
questpie migrate:createFound 0 operations
⏭️ No schema changes detected, skipping migration generation (No operations)
⏭️ No schema changes detected, skipping migration generationThe generator says it, then the command says it again. That is the result you want. The committed migrations and the schema agree. A second migration appearing here means they do not. The deploy will not do what you think.
Then look at what the deploy is about to apply:
questpie migrate:statusYour new migration should be the only thing under Pending. Anything else there
is a migration some earlier deploy never applied. Find out why before you add to
the pile.
Commands
Every migrate command the CLI registers, the flags it takes, the ledger table it writes to, and what each one does when there is nothing to do.
The connection budget
PostgreSQL caps total connections and every process you run takes a slice. Who opens what, how to count it, and what a pooler in front changes.