Migrations

Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses.

Voltro's migrator does not generate or apply migration files. Instead, on every voltro dev boot — and any time you run voltro db plan — it:

  1. Introspects the live database via information_schema.* (or the dialect-specific equivalent), building a SchemaSnapshot.
  2. Diffs that against your declared schema (every *.entity.ts / *.schema.ts file in the project plus the framework's bookkeeping tables).
  3. Classifies each pending DDL op into one of seven OperationClasses — safe, needs-default, needs-backfill, needs-rename-annotation, lossy, online-required, multi-step.
  4. Refuses to apply anything that can't be made safe automatically. The diff output tells you exactly which DSL annotation to add (.backfill(), .renamedFrom(), dropped(), …).
  5. Applies the rest under an advisory lock + records the result in _voltro_migration_plans with a fingerprint of the post-apply schema.

There are no generated SQL files to commit, no migrations/ directory to rebase, no checksum manifest to repair. The source of truth is your schema TypeScript; the DB is the slave.

The four-phase model

┌───────────────┐    ┌─────────────────┐    ┌──────────────┐    ┌──────────────┐
│ declare       │ →  │ plan (diff +    │ →  │ review       │ →  │ apply        │
│ schema in TS  │    │ classify)       │    │ (CLI / UI)   │    │ (dev: auto,  │
│               │    │                 │    │              │    │ prod: CLI)   │
└───────────────┘    └─────────────────┘    └──────────────┘    └──────────────┘
  • dev: phases 2–4 are automatic on boot. A blocked plan refuses the boot with a structured error pointing at the fix. When the declared schema is unchanged since the last apply, dev short-circuits on a fingerprint check (one indexed query) and skips the full introspect entirely — so reboots against a large schema stay fast. Force a full re-introspect (drift recovery) with VOLTRO_MIGRATE_FORCE=1.
  • prod: phase 2 (planning) is automatic, but phase 4 (apply) NEVER happens during a serving process. The fingerprint of the declared schema must already match _voltro_migration_plans.fingerprint from a prior explicit voltro db apply — mismatch → refuse to boot.

This is the constraint repeated across the docs: schema changes mid-rolling-deploy without review are not allowed.

A first session

# 1. Edit apps/api/database/users.entity.ts — add a required column.
export const users = table('users', {
  id:    id(),
  email: text(),                    // NEW: required, no default
})

# 2. voltro dev. The planner classifies "ADD COLUMN email NOT NULL"
#    on a populated table as `needs-backfill`. No backfill declared
#    → boot refuses with:
#
# auto-migrate: REFUSED — 1 blocked operation(s):
#   - add-column [users]: NOT NULL column on a table whose row count is unknown
#     fix: declare `email: <type>().backfill(sql\`...\`)` OR `.default(value)`
#          so existing rows survive the migration
voltro dev .

# 3. Add the backfill annotation in the schema:
export const users = table('users', {
  id:    id(),
  email: text().backfill(sql`'unknown-' || id || '@local'`),
})

# 4. Boot again. The planner classifies the same op as `needs-backfill`
#    with a declared backfill — applier runs ADD nullable → UPDATE via
#    the SQL expression → SET NOT NULL inside one transaction.
voltro dev .

Same column. Same migration. The first attempt refuses loudly; the second succeeds silently. The DSL annotation IS the migration plan.

The seven operation classes

Every pending op is stamped with one of seven OperationClass values — safe, needs-default, needs-backfill, needs-rename-annotation, lossy, online-required, multi-step — which drives whether it auto-applies or refuses-to-plan pending a DSL annotation.

The full trigger + default-policy table, with a worked example and the exact fix for each blocked case, lives on the dedicated Operation classes page.

The CLI surface

# Planner-based (declarative diff)
voltro db plan                    # diff + color-coded classes + fix hints
voltro db plan --against <url>    # diff vs a remote env's inspect endpoint (see cross-env-sync)
voltro db apply                   # execute (dev only — refuses on NODE_ENV=production)
voltro db apply --note '...'      # apply with a freeform note recorded in history
voltro db plans [--limit 20]      # history from _voltro_migration_plans, newest first
voltro db drift                   # live-vs-applied fingerprint check — exit 4 on drift
voltro db squash --before <date>  # consolidate history into one snapshot
voltro db restore-snapshot <id>   # restore VOLTRO_SOFT_DROP=1 columns from a plan

# File-based escape hatch — migration() up/down files under migrations/
voltro db files                   # apply pending migration() files
voltro db rollback-file <id>      # run a migration() file's down body

# defineMigration step runner (separate system, _voltro_migrations table)
voltro db migrate                 # apply pending *.migration.ts steps
voltro db rollback [--to <id>]    # undo applied *.migration.ts steps
voltro db status                  # list applied / pending *.migration.ts

plan + apply are the planner-based commands. There are TWO distinct file-based runners, intentionally not unified: the migration() runner (files / rollback-file, records into _voltro_migration_plans with source: 'file') is the escape hatch the planner points you at for table-splits and cross-table data moves; the defineMigration runner (migrate / rollback / status, its own _voltro_migrations table) runs hand-authored *.migration.ts step files. See File-based migrations for the migration() path — the one most apps reach for.

Note: voltro db plan is flagless beyond --against / --token — there is no --json or --sql, and voltro db apply takes only --note. The prod flow is a plain voltro db apply run as an explicit deploy step (prod pipeline), not a pre-serialised plan file.

Where to go next

Topic Page
Every operation class with concrete examples + each fix Operation classes
SQL vs JS backfill, performance, batch tuning Backfill
.renamedFrom() + dropped() lifecycle + when to remove the markers Rename and drop
MySQL implicit commit, SQLite table rewrite, per-dialect atomicity matrix Multi-dialect strategy
CONCURRENTLY / batched backfill / shadow-column for large tables Online migrations
File-based escape hatch for table-split / merge / data moves File-based migrations
The plan-review-apply pipeline for production Prod pipeline
Local devtools dashboard walkthrough Devtools UI
Cloud dashboard walkthrough + multi-tenant boundaries Cloud UI
What can be reversed (and why planner plans have no auto-rollback) Rollback
Drift detection + recovery Drift
Consolidating an aged history into one snapshot Squashing
VOLTRO_SOFT_DROP=1 + restore-snapshot — the only path that recovers dropped-column data Soft-drop recovery
voltro db plan --against <env-url> for pre-deploy preview Cross-environment sync
The most common "why is my boot refusing?" cases Troubleshooting