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 NULLADD INDEX(small tables; large tables get promoted toonline-required)DROP INDEXADD UNIQUE— adding.unique()to an existing column whose live values are already distinct (emitted as the<table>_<column>_keyconstraint). Applies on the next boot; if the column already holds duplicates the DB rejects it at migrate time (fail-fast) rather than letting anON CONFLICTupsert break at runtime. Pre-dedup a populated column with.unique({ dedup })— see the decision table.DROP UNIQUE(removing.unique()) is safe too.ADD CHECKon a new column- Widening a type (
varchar(50)→varchar(255),int→bigint) - 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 neededneeds-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 defaultCross-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):
ADD COLUMN <name> <type>(nullable)UPDATE <table> SET <name> = <backfill-expr>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 NULLWithout 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 surviveFor 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 UNIQUEconstraint that other code might depend onDROP INDEXthat 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 — intentionalWithout 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 fieldDROP 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: PostgresCREATE INDEX CONCURRENTLY, MySQL/MariaDBALGORITHM=INPLACE LOCK=NONE, MSSQLWITH (ONLINE = ON), SQLite no-opUPDATEbackfill: batched (default 1k rows/batch, configurable.backfill(sql, { batchSize: 5000, sleepMs: 50 }))- Type rewrites that need shadow-column-swap (
ALTER COLUMN TYPEon 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
addressestable with FK back) - Merging two tables
- Type changes that need a custom
USINGexpression (text → integerrequiresUSING 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 |