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.

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.

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