Rename and drop

The two annotations that gate destructive-looking changes — .renamedFrom() turns a DROP+ADD diff into a RENAME, dropped() turns a refused DROP into an allowed one. Lifecycle + when to remove the markers.

The planner refuses to silently rename or drop columns. Both ops can look identical structurally — column X gone, column Y present — but mean very different things. The annotations give you the vocabulary to declare intent.

.renamedFrom(oldName)

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

// After — without the marker:
export const users = table('users', { id: id(), givenName: text() })
// → planner classifies as DROP firstName + ADD givenName
// → ADD givenName lands as `needs-backfill` (blocked, no backfill declared)
// → boot refuses

// With the marker:
export const users = table('users', {
  id:        id(),
  givenName: text().renamedFrom('firstName'),
})
// → planner folds the diff into one RENAME COLUMN op, classified `safe`
// → boot applies it via `ALTER TABLE users RENAME COLUMN firstName TO givenName`

The marker says: "the column previously named firstName should be the column now declared as givenName". The planner verifies the live DB has a column called firstName matching the new column's shape (type, nullable, default). Mismatch → refuse with a helpful error.

When .renamedFrom() doesn't fold

If the live DB doesn't have a column called firstName, the marker is a no-op:

  • Maybe the rename was already applied → firstName is gone, givenName is there → diff is empty → no folding needed
  • Maybe you typo'd the old name → live has first_name not firstName → planner falls back to treating givenName as a new column (which IS needs-backfill → refuses)

The marker isn't validated against the live DB at schema-build time — it would have to introspect during type-checking, which is expensive. The runtime check fires at plan time.

The indexes come with it

Renaming a column is a metadata-only operation. Its indexes used to not be: index names are derived (<table>_<column>_idx), and no dialect renames an index when the column under it is renamed — so the planner saw users_firstName_idx on one side and users_givenName_idx on the other, and planned DROP INDEX + CREATE INDEX. On a large table that is a full B-tree rebuild: minutes of IO, and without CONCURRENTLY a write lock, behind a rename that was supposed to be instant.

The planner now folds that into a rename-index operation, which is a catalog-only statement everywhere it is emitted:

✓ rename-column users.firstName → users.givenName   # catalog-only
✓ rename-index  users_firstName_idx → users_givenName_idx   # catalog-only, no rebuild

You do not annotate anything for this — it follows from the column rename you already declared.

Four cases deliberately still plan as drop + create, because pairing an old index with a new one has no evidence to stand on in them:

  • sqlite — it has no rename statement at all. The plan you read matches what runs.
  • UNIQUE indexes — they are constraint objects, and the syntax to rename one diverges by dialect.
  • Expression / json-path indexes — the database normalises their key text, so there is no shape to compare; only the name, which is the thing that changed.
  • Two same-shaped indexes renamed at once — nothing says which became which. Rebuilding both is slower; renaming the wrong one is worse.

Renaming a TABLE

The same problem one level up, and with more at stake: a table rename and a drop+create look identical to the differ — old table gone, new table present — except that guessing wrong costs every row. So it needs a marker too, and it reads like its column counterpart:

// Before:
export const notes = table('notes', { id: id({ prefix: 'note' }), body: text() })

// After — without the marker:
export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
// → planner sees DROP TABLE notes + CREATE TABLE archive_notes
// → the DROP is `lossy` and blocked; nothing happens until you acknowledge it

// With the marker:
export const notes = table('archive_notes', { id: id({ prefix: 'note' }), body: text() })
  .renamedFrom('notes')
// → one `rename-table` op, classified `safe`
// → `ALTER TABLE notes RENAME TO archive_notes` — catalog-only, the rows stay put

Unlike an index rename, every dialect has this statement — sqlite included — so there is no dialect on which this falls back to a rebuild.

Its indexes come with it. The same derivation that bites a column rename bites harder here: notes_pkey and notes_<col>_idx are named after the table, and no dialect renames them when the table is renamed. The planner emits a rename-index for each so the catalog catches up:

✓ rename-table  notes → archive_notes
✓ rename-index  notes_pkey → archive_notes_pkey

Without that the plan would try to drop the primary-key index and re-add it as a plain UNIQUE, which postgres refuses outright.

Three cases where the planner will NOT fold the rename, each because folding it could destroy data rather than move it — and none of them is silent:

  • The old name is still declared by something. If your schema still has a notes table, it is yours and stays put; the new table is created empty. This is a legitimate outcome (it is what lets a framework plugin reclaim a name without taking yours), so the plan runs — and the create-table line says why the marker was not applied.
  • The new name already exists in the database.refuses to plan. Both tables exist and only you know which holds the real rows. The fix tells you to move them and drop one, or drop the empty one so the rename can run. Until then the old table is untouched.
  • Two tables both claim the same old name.refuses to plan. Nothing says which should receive the rows; remove the marker from all but one.

The last two refuse rather than degrade, because the quiet outcome — an empty plan reading "schema up to date" while the old table still holds every row — is the one that loses data by inaction.

Lifecycle. Same as .renamedFrom() on a column: a marker whose old table is not in the database is a silent no-op, so it stays in your source across a staged rollout and comes out once every environment has applied it.

One constraint worth knowing: a table whose name starts with _ cannot derive a typeid prefix, so it needs an explicit id({ prefix: '…' }). You will hear about it at declaration, not at runtime.

Lifecycle — when to remove the marker

Keep the marker until the rename has been applied in EVERY env you care about (dev, staging, prod). The framework tracks applied ops in _voltro_migration_plans:

dev    ← rename applied at 2026-04-15. Marker can come out.
staging ← rename applied at 2026-04-18. Marker can come out.
prod   ← rename NOT YET APPLIED.

Pull the marker too early and the next voltro db plan against prod sees:

✗ ALTER TABLE users DROP COLUMN firstName
✗ ALTER TABLE users ADD COLUMN givenName text
  ! fix: did you remove `.renamedFrom('firstName')` before staging migration applied?
         Re-add the marker OR run `voltro db apply` against staging first.

Practical rule: the marker stays in the codebase across the rollout. Once voltro db drift shows clean against the last env (usually prod), the rename is fully applied + the marker can come out in a follow-up PR.

dropped()

import { dropped } from '@voltro/database'

// Drop a column on a populated table — without the marker, refused:
export const users = table('users', { id: id() })   // `legacy` simply gone
// → planner sees `users.legacy` in live but not declared → classifies lossy → blocked

// With the marker:
export const users = table('users', {
  id:     id(),
  legacy: dropped(),   // ← explicit intent
})
// → planner classifies lossy + ALLOWED (intent declared)
// → applier emits ALTER TABLE users DROP COLUMN legacy

The marker fills the field-map slot the column used to occupy, telling the planner: "this column existed in the live DB AND is intentionally going away". The planner now classifies the drop as lossy-but-intended, which auto-applies.

dropped() is a column-shape no-op at runtime (the migration emitter emits a DROP COLUMN, then the column is gone). It's purely planner metadata.

Lifecycle — when to remove dropped()

After the drop has been applied in every env, remove the field-map entry entirely. The next plan sees nothing to do for that column (the live DB no longer has it, the declared schema no longer references it).

If you pull the dropped() marker before the drop has applied to all envs, the planner sees the column in live + the column ABSENT from declared → classifies as a fresh DROP COLUMN → blocked again with the same "add dropped() marker" fix. You'd just have to re-add it; no harm, no data loss.

Dropping a table

There's no dropped() equivalent for tables. The table simply being absent from the declared set IS the signal:

// Remove the entire users.entity.ts file or its export from database/index.ts
// → planner sees `users` in live but not in declared → lossy DROP TABLE → blocked

To allow it, set VOLTRO_DESTRUCTIVE_OK=1 on the apply:

VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring users table after migration to user_accounts'

VOLTRO_DESTRUCTIVE_OK=1 only relaxes the refusal when EVERY blocked op is lossy. If the plan also has a rename-without-marker or a NOT-NULL-without-backfill, those stay refused regardless.

For complex multi-table retirements (move data out, then drop), use a file-based migration — explicit ordering + a transaction wrapped around the data move.

Name the tables, not the whole run

VOLTRO_DESTRUCTIVE_OK=1 acknowledges every lossy op in the plan. That is rarely what you mean — a plan with one intended drop and three other lossy ops would have all four approved by a single 1. Give it a comma-separated list instead:

# Only these tables — every other lossy op in the plan stays refused.
VOLTRO_DESTRUCTIVE_OK=users,legacy_notes voltro db apply --note 'retiring the pre-migration tables'

An op the list does not name stays blocked, and a plan with anything still blocked is refused as a whole. Half a plan applied is how a schema ends up in a state neither the declaration nor the database describes.

There is deliberately no .dropped() marker for a table, unlike for a column. A dropped column leaves a slot worth documenting in the declaration; a dropped table leaves nothing, so the marker would be a dead entry you have to remember to delete.

.narrowedFrom() for type changes

A bare column type change is refuse-to-plan — the planner blocks it (the same way a drop-column without dropped() is blocked), because a raw ALTER COLUMN … TYPE may not be value-preserving and fails outright at the DB for non-implicit casts. Acknowledge the change with .narrowedFrom(<live type>, { using }): the planner downgrades it to needs-backfill and threads the cast into the applier's ALTER COLUMN … TYPE … USING <using> (and the online shadow-column copy).

// Before: status: text()
// After:
export const orders = table('orders', {
  id:     id(),
  status: text().oneOf(['pending', 'shipped', 'delivered']).narrowedFrom('text', {
    using: 'status::status_enum',
  }),
})
  • from is the type the LIVE DB currently has. It MUST equal the live column type — a stale from (the column already changed, or you named the wrong prior type) is ignored and the change stays blocked-lossy.
  • using is the raw cast expression spliced verbatim into ALTER COLUMN … TYPE … USING <using> (postgres) / the batched shadow-copy (shadow := <using>(old)) on the online path. It's developer-authored migration SQL — keep it portable or dialect-correct for your target.
  • Omit using when the conversion is implicit on the dialect (e.g. varchar → text): the planner still downgrades the change, and the applier emits a plain ALTER COLUMN … TYPE with no USING. For a non-implicit cast with no using, the DB rejects the apply — declare the cast.

orphanPolicy — adding an FK to a populated column

Promoting an existing text() column to reference() (common when a column already holds the target's id as a plain string — e.g. data migrated from another system) is NOT a type change: a reference is TEXT-storage on every dialect, so the planner collapses the type diff to a no-op. The only real change is the FK CONSTRAINT, which db apply adds with ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY ….

By default the FK-add just applies — classified needs-backfill, exactly like tightening a column to NOT NULL: the existing rows must already satisfy it. If a row is an orphan (its value points at a target that doesn't exist) the ADD CONSTRAINT fails at the DB, the whole apply rolls back atomically, and the failing statement is surfaced. orphanPolicy (on the reference) tells the applier to clear orphans FIRST so it can't fail:

// Was `authorId: text()`. The column already has data, possibly with orphans.
authorId: reference(() => users, { orphanPolicy: 'null' }).nullable(),
ownerId:  reference(() => orgs,  { orphanPolicy: 'delete' }),
  • 'fail' (default) — bare ADD CONSTRAINT, applies (needs-backfill, not blocked). The DB rejects it only if an existing row is an orphan — then declare 'null' / 'delete' and re-apply. (The planner is pure — it can't read row counts, so it can't distinguish a clean / empty table from one with orphans; blocking by default would refuse every clean case too.)
  • 'null' — the applier runs UPDATE child SET col = NULL WHERE col references a missing target BEFORE ADD CONSTRAINT. Requires the column be .nullable() (else the NULL-out would violate NOT NULL — the planner blocks it with that exact hint).
  • 'delete' — the applier runs DELETE FROM child WHERE col references a missing target first (removes the orphan ROWS — destructive).

'null' / 'delete' apply via plain db apply — the policy IS the acknowledgement, exactly like .narrowedFrom(...) for a type change — and show in the plan as lossy with a reason naming the orphan handling. orphanPolicy is planner metadata only (no runtime/query effect), and once applied a re-plan is a no-op (introspection reports the FK; the policy is stripped from the comparison). On sqlite an FK change rebuilds the table, so the orphan pre-step is skipped — clean the orphans yourself there.

Markers don't pile up

Each marker maps to ONE applied op. The next migration after a rename + drop has clean code:

// Before the rollout:
export const users = table('users', {
  id:        id(),
  givenName: text().renamedFrom('firstName'),
  legacy:    dropped(),
})

// After the rollout finished + applied in every env:
export const users = table('users', {
  id:        id(),
  givenName: text(),
  // legacy: dropped() removed entirely — the field-map slot disappears too.
})

The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.

voltro evolve — the schema-evolution copilot

Adding the .renamedFrom() marker by hand is easy for one column. The hard part of changing an EXISTING schema is the rest: which handlers read or write that column, whether a rename is safe or needs a backfill, and getting the annotation onto the entity AND every call site without missing one. voltro evolve does that reconnaissance and proposes a reviewable plan.

Given a change, it reads the observed graph (app.graph.observed.generated.json, recorded by voltro dev — see voltro check) plus the declared manifest, enumerates the real blast radius, and prints three things: a proposed codemod, a branch-verified backfill plan, and a voltro check verify step.

voltro evolve rename-column notes.title --to heading            # dry-run: plan + codemod preview
voltro evolve rename-column notes.title --to heading --write    # apply the codemod
voltro evolve retype-column orders.total --to numeric           # reshape → manual codemod + steps
voltro evolve split-column users.name --into firstName,lastName
voltro evolve rename-table note --to notes
voltro evolve drop-column notes.legacy
voltro evolve rename-column notes.title --to heading --json     # for CI / an agent loop

It is dry-run by default (mirrors voltro generate); --write applies the codemod through the same runCodemods toolkit as voltro update. --json emits the whole plan for CI or an agent.

The blast radius is observed, not guessed

change: rename-column notes.title → heading

blast radius (observed + declared):
  query(notes.list) — observed read
  mutation(notes.update) — observed update
  ⚠ 1 declared but NEVER exercised — column use UNKNOWN: notes.archive

codemod:
  entity: rename 'notes.title' → 'heading' and add .renamedFrom('title') (catalog rename — data preserved)
  annotate 2 handler site(s) that reference the old field

backfill (dry-runs on branch notes-pr-0):
  [safe] catalog RENAME — no data movement

verify: voltro check

A handler that a voltro dev session or a test actually ran is reported with what it did (observed read / observed update). A handler that is declared to touch the table but was never exercised is flagged UNKNOWN and listed separately — it is never folded into "safe", because no run proves what it does with the column. That honesty is the point: the tool tells you exactly where it cannot vouch for the change.

What the codemod does per kind

  • rename-column gets a real transform: it renames the field in the *.entity.ts AND chains .renamedFrom('old') (so the differ plans a catalog RENAME, not the lossy drop+create described above), then annotates the handler sites the blast radius found.
  • retype-column / split-column / drop-column / rename-table are reshaping changes with no single mechanical rewrite, so they get a manual codemod: a generated, numbered checklist of the edits + the annotation to add, printed for you to apply.

voltro evolve produces the plan; it does not apply the schema change. voltro check is the gate on the result, and voltro db apply lands it — after --write, review the annotated handlers, then run those two.