Operation classes

The seven classification buckets the planner sorts every diff into — what triggers each, what the planner does by default, and the exact DSL annotation that turns a blocked op into an allowed one.

Every concrete DDL operation the planner emits gets stamped with one of seven OperationClass values. The class drives the default policy + the refuse-to-plan message you'll see when something needs human input. This page enumerates them with a fixture-style example per class.

safe

The op is reversible AND has no effect on existing data. Auto-applied on every dev boot + by voltro db apply in any env.

Triggers:

  • CREATE TABLE (no live data possible)
  • ADD COLUMN <nullable> — new column starts NULL
  • ADD INDEX (small tables; large tables get promoted to online-required)
  • DROP INDEX
  • ADD UNIQUE — adding .unique() to an existing column whose live values are already distinct (emitted as the <table>_<column>_key constraint). Applies on the next boot; if the column already holds duplicates the DB rejects it at migrate time (fail-fast) rather than letting an ON CONFLICT upsert break at runtime. Pre-dedup a populated column with .unique({ dedup }) — see the decision table. DROP UNIQUE (removing .unique()) is safe too.
  • ADD CHECK on a new column
  • Widening a type (varchar(50)varchar(255), intbigint)
  • Dropping NOT NULL (NULL → optional is monotonic)
// Before:
export const users = table('users', { id: id(), email: text() })

// After — ADD bio (nullable) → safe.
export const users = table('users', {
  id:    id(),
  email: text(),
  bio:   text().nullable(),
})

Plan output:

✓ ALTER TABLE users ADD COLUMN bio text  # nullable column add — no backfill needed

needs-default

ADD NOT NULL column where the schema declares .default(value). The planner emits ADD COLUMN … NOT NULL DEFAULT <value> in one statement. Postgres 11+ records the default in the catalog without rewriting the table — instant on a 100M-row table.

export const users = table('users', {
  id:   id(),
  plan: text().default('free'),
})

Plan output:

⊕ ALTER TABLE users ADD COLUMN plan text NOT NULL DEFAULT 'free'  # literal default

Cross-dialect note: MySQL strict mode + a TEXT column with a DEFAULT clause throws at DDL time. Use varchar(N) (.maxLength(N) on text()) for text columns that need a default on MySQL. The framework's multi-dialect strategy page covers the other landmines.

needs-backfill

ADD NOT NULL column on a populated table where the schema declares .backfill(). The planner emits a three-step plan inside one transaction (or one forward-roll group on mysql/mariadb):

  1. ADD COLUMN <name> <type> (nullable)
  2. UPDATE <table> SET <name> = <backfill-expr>
  3. ALTER COLUMN <name> SET NOT NULL
export const users = table('users', {
  id:    id(),
  email: text().backfill(sql`'unknown-' || id || '@local'`),
})

Plan output:

⊕ ALTER TABLE users ADD COLUMN email text          # 3-step: ADD nullable
⊕ UPDATE users SET email = 'unknown-' || id || '@local'  # → run backfill
⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL # → SET NOT NULL

Without the .backfill() annotation: blocked. The fix hint surfaces in both voltro db plan and the boot refuse message:

✗ ALTER TABLE users ADD COLUMN email text  # NOT NULL column on a table whose row count is unknown
  ! fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive

For the SQL-vs-JS backfill trade-off + per-batch tuning see Backfill.

needs-rename-annotation

Column X disappeared from the declared schema AND column Y appeared with similar shape. The planner won't silently turn that into DROP X + ADD Y (data loss). It refuses-to-plan unless the new column carries .renamedFrom('X') — the explicit signal that intent is RENAME, not DROP+ADD.

// Before:
export const users = table('users', { id: id(), firstName: text() })

// After — without the marker, planner refuses:
export const users = table('users', { id: id(), givenName: text() })

// With the marker — planner folds the diff into one RENAME op:
export const users = table('users', {
  id:        id(),
  givenName: text().renamedFrom('firstName'),
})

The annotation stays in the code until the rename has been applied in every env you care about (dev, staging, prod). _voltro_migration_plans records the applied rename so the planner won't re-emit; removing the marker earlier yields a clear refuse-to-plan ("did you remove .renamedFrom('firstName') before staging migration applied? Re-add the marker oder apply against staging first"). Rename and drop covers the full lifecycle.

lossy

The op destroys data. Refuse-to-plan unless the developer declared intent explicitly OR VOLTRO_DESTRUCTIVE_OK=1 was set.

Triggers:

  • DROP COLUMN (live data is gone after apply)
  • DROP TABLE
  • Narrowing a type (varchar(255)varchar(50) with strings longer than 50)
  • DROP UNIQUE constraint that other code might depend on
  • DROP INDEX that an FK depends on
// Drop a column INTENTIONALLY — declare `dropped()`:
export const users = table('users', {
  id:           id(),
  legacy:       dropped(),    // ← explicit. Planner classifies lossy, allows apply.
})
⊕ ALTER TABLE users DROP COLUMN legacy  # column dropped via `dropped()` marker — intentional

Without the marker:

✗ ALTER TABLE users DROP COLUMN legacy  # column missing from declared schema
  ! fix: if intentional, add `legacy: dropped()` to the schema. If a typo, restore the field

DROP TABLE has no equivalent annotation — the table simply being missing from the declared set is the signal. Set VOLTRO_DESTRUCTIVE_OK=1 to allow it (loud warning), or use a file-based migration for cross-table data moves the diff can't infer.

VOLTRO_DESTRUCTIVE_OK=1 only relaxes the refusal when EVERY blocked op is lossy. Rename-without-marker and NOT-NULL-without-backfill stay firm regardless — those are sloppy declarations, not intentional destruction.

online-required

The op operates on a table whose row count exceeds the planner's online threshold (default 50k; tunable via online-after per project). Auto-rewritten to a non-blocking variant:

  • ADD INDEX: Postgres CREATE INDEX CONCURRENTLY, MySQL/MariaDB ALGORITHM=INPLACE LOCK=NONE, MSSQL WITH (ONLINE = ON), SQLite no-op
  • UPDATE backfill: batched (default 1k rows/batch, configurable .backfill(sql, { batchSize: 5000, sleepMs: 50 }))
  • Type rewrites that need shadow-column-swap (ALTER COLUMN TYPE on large tables)

Online migrations walks through every variant with sizing + tuning guidance.

multi-step

Operations the planner can't infer from a structural diff alone:

  • Splitting a table (e.g. extract address fields to a separate addresses table with FK back)
  • Merging two tables
  • Type changes that need a custom USING expression (text → integer requires USING col::integer)
  • Data moves that span multiple tables atomically

The planner refuses-to-plan these + points at the file-based migrations escape hatch. You write the migration body explicitly (up/down SQL or Effect program) and the framework picks it up in timestamp order before the next auto-diff pass.

Decision table at a glance

Want to … Add this to the schema
Add a required column with a constant default .default(value)
Add a required column on a populated table .backfill(sql\expr`)`
Rename a column without losing data .renamedFrom('oldName') on the new column
Drop a column intentionally legacy: dropped() at the field-map slot
Drop a table intentionally (remove from declared set) + VOLTRO_DESTRUCTIVE_OK=1 for one apply
Add a unique constraint on a populated column with dupes .unique({ dedup: 'fail' / 'suffix-counter' })
Add an FK on a populated column with orphans reference(() => target, { orphanPolicy: 'fail' / 'null' / 'delete' })
Change a column type with a custom cast .narrowedFrom('<live type>', { using }) on the column — the planner downgrades the blocked-lossy type change to needs-backfill and threads the USING cast (see rename and drop)
Move data across tables atomically file-based migration