Rollback

What can be reversed and what can't. Planner-applied plans are NOT auto-reversible — rollback is file-based only. The recovery paths for an applied schema change, and why forward-fix usually beats rollback.

There is no rollback for a planner-applied plan. The _voltro_migration_plans history is an append-only record of what ran; the applier does not store an inverse plan and voltro db has no rollback <plan-id> subcommand. Reversing a planner change means either re-declaring the prior schema and applying that as a NEW forward plan, or — for destroyed data — restoring from backup.

What CAN be reversed mechanically is a file-based migration, via its explicit down body.

What "rollback" means per change kind

Change kind Reverse path
Planner plan with only safe ops (ADD nullable, ADD index, widen type) Re-declare the old shape in TS → voltro db apply runs the inverse as a NEW forward plan
Planner plan with lossy ops (DROP column/table, narrow type) Not reversible — the data is gone. Restore from backup, or restore-snapshot if it was applied with VOLTRO_SOFT_DROP=1
migration() file (migrations/<ts>_<slug>.ts) voltro db rollback-file <id> runs its down body
defineMigration step file (*.migration.ts) voltro db rollback [--to <id>] runs its steps' undo in reverse

The rest of this page covers each path.

Reversing a planner change — forward-apply the old shape

A planner plan is a function of (declared schema, live schema). To undo one, make the declared schema describe the PRIOR state again and apply:

# 1. Revert the *.entity.ts edit (e.g. git revert the schema PR).
# 2. voltro db plan shows the inverse diff:
voltro db plan
#   ✓ ALTER TABLE users DROP COLUMN bio   # inverse of the ADD that shipped
# 3. Apply it as a new forward plan:
voltro db apply --note 'reverting bio column — PR #1234 backed out'

This works cleanly ONLY when every op in the inverse diff is itself safe. If the original plan added a NOT NULL column you now want gone, the inverse is a plain DROP (safe). But if the original plan DROPPED a column, the inverse is an ADD that the planner classifies needs-backfill — and the data that column held is already gone, so no backfill expression brings it back. That asymmetry is the whole reason lossy ops are gated behind dropped() / VOLTRO_DESTRUCTIVE_OK=1 in the first place.

The reverting apply lands a new _voltro_migration_plans row. The original plan stays in history; voltro db plans shows the apply and its reversal as two separate rows so the timeline is auditable.

What can't be recovered by forward-apply

Data from lossy ops

export const users = table('users', {
  id: id(),
  legacy: dropped(),   // applied → DROP COLUMN legacy
})

After apply, users.legacy and its data are gone. The applier does not snapshot column data before dropping (that would mean duplicating the table at apply time, which doesn't scale). Recovery options:

  • If the plan was applied with VOLTRO_SOFT_DROP=1, the column was RENAMED to a sidecar instead of dropped — voltro db restore-snapshot <plan-id> brings it back. See Soft-drop recovery.
  • Otherwise: restore from your DB's normal backup / PITR system.

This is why dropped() is an explicit annotation — it signals "I have a backup OR I really mean it".

Backfilled values

A needs-backfill plan computed values from the SQL expression / JS function. Forward-applying a DROP of that column discards the values. The expression is preserved in the plan's operations JSON, so re-applying the same forward plan reproduces the same values IF the source data is unchanged — but a structural reversal does not restore them.

Reversing a file-based migration

migration() files — rollback-file

voltro db rollback-file 20260415_120000_split_address_out

Runs the file's down body. The <id> is positional — the migration's id (its filename minus .ts). rollback-file refuses on NODE_ENV=production (schema rollback runs as an explicit deploy step there). If down throws, the rollback is failed and the schema stays half-reverted — the framework can't auto-recover from a broken inverse.

A non-destructive down only restores STRUCTURE, not data the up destroyed. Design the pair so up MOVES data it would otherwise drop:

up: async ({ sql }) => {
  await sql.unsafe(`CREATE TABLE obsolete_archive AS SELECT * FROM obsolete`)
  await sql.unsafe(`DROP TABLE obsolete`)
},
down: async ({ sql }) => {
  await sql.unsafe(`CREATE TABLE obsolete AS SELECT * FROM obsolete_archive`)
  await sql.unsafe(`DROP TABLE obsolete_archive`)
},

Higher disk cost during apply (two copies briefly), but the rollback is meaningful.

defineMigration step files — rollback

The separate step-based runner reverses with voltro db rollback (newest applied step) or voltro db rollback --to <id> (back through several). It re-runs each migration's undo effects in reverse step order against the _voltro_migrations table. This is a DIFFERENT runner from the migration() path above — see the overview for why both exist.

Rollback on prod

voltro db rollback-file / voltro db rollback refuse during a serving prod process — schema changes (forward or reverse) run as explicit deploy steps, never on boot. For a planner change you want backed out in prod, ship a schema PR that re-declares the old shape and apply it through the same prod pipeline as any other change.

When to design for reversal vs forward-fix

Scenario Recovery path
New code crashes on boot, needs reverting Image rollback + (if schema is incompatible) a forward-apply of the old shape
New code is fine but the new schema has a bug NEW migration that fixes the bug — don't reverse to a broken intermediate
A lossy apply destroyed data you needed Restore from backup / PITR, or restore-snapshot if soft-dropped
Critical bug in production, need to undo NOW Restore from backup — faster than re-planning when speed matters

For production incidents, treat backup + PITR as the first-line option, not schema reversal. Once data is gone, no forward plan brings it back — which is exactly why the framework's apply path is conservative and the prod refuse-to-boot is strict.