zanith

Expand-contract migrations

Two helpers that turn a single risky DDL into a paired expand → contract plan. The expand phase adds the new shape and back-fills from the old; the contract phase drops the old once every running version is on the new shape.

Why expand-contract

Renaming a column or changing its type as a single migration breaks every application instance that hasn't redeployed yet — the old binary is reading full_name while the database now only has name. The expand-contract pattern eliminates that window: both columns exist for a stretch, applications update at their own pace, and the destructive cleanup happens in a second migration once every version has switched.

The risk model flags renames and type changes as high risk specifically because the naive form has this window — these helpers are the way out.

Renaming a column — expandContractRename

TS
import { expandContractRename } from 'zanith';
 
const [expand, contract] = expandContractRename({
table: 'users',
from: 'full_name',
to: 'name',
type: 'text',
destructivePolicy: 'archive', // 'soft_drop' | 'archive' | 'drop'
retentionDays: 30, // for soft_drop / archive
notNullAfter: true, // mark new column NOT NULL after backfill
});
 
// expand: addColumn name (nullable) → backfill name = full_name → alter NOT NULL
// contract: archiveColumn full_name (or softDrop / drop based on policy)

Options

OptionDefaultEffect
tablerequiredTable being modified.
fromrequiredExisting column name.
torequiredNew column name.
typerequiredSQL type for the new column.
destructivePolicy'archive'What the contract phase does to the old column. Archive moves rows to an artifact; soft-drop keeps the column with a tombstone; drop is irreversible.
retentionDaysengine defaultHow long the artifact survives before purgeOlderThan collects it.
notNullAfterfalseAfter backfill, alter the new column to NOT NULL.
idPrefixundefinedOptional YYYYMMDDHHMMSS prefix for the generated migration ids.

Changing a column's type — expandContractTypeChange

TS
import { expandContractTypeChange } from 'zanith';
 
const [expand, contract] = expandContractTypeChange({
table: 'orders',
column: 'total',
newType: 'numeric(12, 2)',
using: '"total"::numeric', // optional USING expression
validate: true, // CHECK constraint in expand, dropped in contract
});
OptionDefaultEffect
tablerequiredTable being modified.
columnrequiredColumn whose type is changing.
newTyperequiredTarget SQL type.
using::newTypePostgres USING clause for the cast.
validatefalseAdd a CHECK constraint in the expand phase to validate existing rows; drop it in contract.
idPrefixundefinedOptional id prefix.

The deployment shape

StepWhat runsWhy
1. Apply expandNew column added, backfill runs, both columns coexist.Old binaries keep working — they read the old column.
2. Deploy app codeApplication now writes both columns; reads from new.Both shapes valid — the database is forgiving.
3. WaitAll instances roll forward.Confirm via dashboards / logs / canary that no instance is still on the old code.
4. Apply contractOld column archived / soft-dropped / dropped.Only new code is running, so dropping the old column is safe.
5. (Optional) RecoverIf step 4 was archive / soft-drop, the column can still be restored.Nothing is permanent until purgeArtifact runs.

Risk model for which ops require expand-contract. Recovery for what archive / soft-drop look like in practice. Preflight for the checks that catch data shape problems before the expand phase runs.