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), int → bigint)
  • Dropping NOT NULL (NULL → optional is monotonic)

Changing a declaration from integer() to bigint() is a safe widening on PostgreSQL, MySQL, MariaDB and SQL Server: every existing 32-bit integer remains representable, so no .narrowedFrom() acknowledgement is needed. SQLite and Turso already store both declarations as 64-bit INTEGER, so this change needs no type-alter operation there. Review and apply it through the normal planner; safe does not promise that database DDL takes no lock or time.

The reverse change, bigint() to integer(), remains blocked as lossy where the physical types differ. An intentional conversion can use .narrowedFrom('bigint') (and a using expression when needed), which classifies it as needs-backfill; first verify that every value fits the target range. SQL Server refuses a declarative using transform and requires a reviewed file-based migration for that case. Other numeric conversions are not covered by the integer-widening rule. The public bigint() column still represents row values as decimal strings, independently of migration safety.

// 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.

A numeric id added to a table that is keyed some other way — id({ scheme: 'numeric' }) on a table whose primary key was (tenant, email) — is needs-default too, on PostgreSQL and MySQL / MariaDB. The column is added as the dialect's auto-increment primary key, which numbers every existing row in the same statement; the old primary key is dropped first. Declare a .unique([...]) on the old key columns if they must stay unique. SQLite cannot add a primary-key column and SQL Server's identity add is not supported here, so on those two the plan still refuses.

⊖ ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_pkey
⊕ ALTER TABLE subscriptions ADD COLUMN id BIGSERIAL PRIMARY KEY  # numeric id — the auto-increment numbers every existing row

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
  • Narrowing the store character set (convert-charset to utf8mb3 on MySQL / MariaDB) — a 4-byte character already stored is refused or truncated; the widening direction is safe
// 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