Spec — Column DEFAULT Preservation Across pgEnum Rename-Recreate (M5.A.1)
Status: Draft (2026-05-09). Implementation lives in PR for
M5.A.1perm5-plan.md.
Problem
The M3.B rename-recreate dance for pgEnum value removal emits four steps:
ALTER TYPE foo RENAME TO foo__old;CREATE TYPE foo AS ENUM (…new value list…);ALTER TABLE T ALTER COLUMN C TYPE foo USING C::text::foo;(per affected column)DROP TYPE foo__old;
Step 3 fails opaquely when the column carries a DEFAULT pointing at the old enum:
ERROR: default for column "status" cannot be cast automatically to type statusAdopters today work around this by dropping the DEFAULT on the column manually before applying the migration, then re-setting it after. That's tedious, error-prone, and requires reading the migration's SQL to know which columns need the dance. The M4.E.1 integration test fixture omits DEFAULTs entirely to side-step the issue — real schemas don't have that luxury.
Goal
kick db generate emits a rename-recreate block that preserves column DEFAULTs across the type swap. Adopter schemas with column.notNull().default('active') keep working — no manual SQL editing, no per-migration cleanup.
Solution
Insert DROP DEFAULT and SET DEFAULT brackets around the existing type swap. The new sequence per affected column with a recorded default:
ALTER TYPE "status" RENAME TO "status__old";
CREATE TYPE "status" AS ENUM ('active', 'banned');
-- Per affected column WITH a default, prefix with DROP DEFAULT:
ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "users"
ALTER COLUMN "status" TYPE "status"
USING "status"::text::"status";
ALTER TABLE "users" ALTER COLUMN "status" SET DEFAULT 'active'::"status";
-- Per affected column WITHOUT a default, the existing type-swap statement is unchanged:
ALTER TABLE "audit_log"
ALTER COLUMN "action" TYPE "status"
USING "action"::text::"status";
DROP TYPE "status__old";Columns without a default skip both brackets — existing migrations stay byte-identical so the journal hash invariant doesn't trip.
Snapshot field
The diff engine already records every column's default field on the ColumnSnapshot (verified against packages/db/src/snapshot/types.ts). The emitter needs the literal default value (e.g. 'active') plus its source PG cast (e.g. 'active'::"status").
The RemoveEnumValue.affectedColumns array gains an optional default: string | null field per entry:
export interface RemoveEnumValue {
kind: 'removeEnumValue'
enum: string
removed: readonly string[]
values: readonly string[]
affectedColumns: readonly {
table: string
column: string
/**
* Literal SQL default expression as recorded on the prior snapshot,
* or `null` when the column has no default. The emitter wraps the
* type swap in `DROP DEFAULT` / `SET DEFAULT … ::"<enum>"` brackets
* only when this is non-null.
*/
default: string | null
}[]
}The diff engine populates this from prev.tables[T].columns[C].default — the prior-snapshot column carries the default that was active before the migration. Future-snapshot defaults are not consulted; the migration restores the prior state cast through the new type.
Edge cases
Default points at a removed value
If the column default WAS one of the removed values (e.g. column defaults to 'legacy' and we're removing legacy), the SET DEFAULT 'legacy'::"status" step itself fails — the literal isn't in the new enum. This is a hard error: the operator must update the schema's column default before generating the migration.
The generator detects this at diff time and refuses to emit, raising a new RemovedValueAsDefaultError (extends KickDbError, code removed_value_as_default). The error names the table, column, and conflicting default value so the operator knows what to change.
Default uses a function (e.g. now() style — won't apply to enums but for parity)
Function-call defaults (DEFAULT some_func()) are valid SQL but can't reference an enum cast. The snapshot's default field carries the raw expression; the emitter wraps it the same way: SET DEFAULT some_func(). PG accepts this if the function returns a text-castable value; otherwise the operator sees the standard PG error at apply time. M5.A.1 doesn't add special handling — the function-default case is rare for enum-typed columns.
Multiple columns with mixed defaults
The emitter walks affectedColumns in declaration order. Each column gets its own DROP/swap/SET triple or bare swap depending on whether default is non-null. No reordering — the ordering matches what the diff engine produces.
Down migration
The down migration (generated by invertChanges) reverses the rename-recreate. The inverted change carries the same default field, so the down emit also restores DEFAULTs through the inverse swap. No additional logic needed in the inverter.
What this does NOT change
- The
-- KICK ENUM REMOVEheader at the top ofup.sqlstays exactly as-is. The runner's gate (enforceEnumDropGate) keys off the header text, not the body. - The journal hash is computed over the entire
up.sql + down.sql + snapshot.jsoncontent. Any migration generated under M5.A.1 will have a different hash than one generated under M5 pre-A.1 — but only when at least one affected column has a default. Migrations against schemas with no DEFAULTs stay byte-identical so the hash invariant doesn't trip on existing applied migrations. - The composite-type detection gate (M4.C) runs before this emit and is unaffected — it refuses to emit at all when a composite references the enum, so we never reach the DEFAULT-bracket logic in that branch.
Test plan
Unit
packages/db/__tests__/unit/default-preservation.test.ts — pure emit + snapshot tests:
- Single column with a literal default: emit produces DROP/swap/SET triple.
- Single column without a default: emit produces bare swap, no DROP/SET.
- Two columns mixed: declaration order preserved, brackets only on the defaulted one.
- Default value matches a removed value: throws
RemovedValueAsDefaultErrorat diff/generate time. - Function-call default: emits
SET DEFAULT some_func()verbatim.
Integration
packages/db/__tests__/integration/enum-drop-with-default.test.ts — Testcontainers PG:
- Schema with
status status NOT NULL DEFAULT 'active'. - Seed two rows holding
active+banned. - Plant a migration that removes
legacy(which no rows hold). - Apply with
confirmEnumDrop: true→ succeeds. - Assert: enum value list is
[active, banned], theusers.statuscolumn still has DEFAULT'active'(cast via the new enum), no__oldshadow type, the seeded rows still hold their original values. - INSERT a new row without specifying
status; assert it picks up the default.
M4.E.1 fixture
packages/db-pg/__tests__/integration/enum-drop-value.test.ts keeps its no-DEFAULT shape — that test exercises the dead-row rollback path, which is orthogonal to DEFAULT handling. The new integration test above adds the DEFAULT scenario without touching the existing one.
Migration path for adopters
This ships as a patch on @forinda/kickjs-db. No new public surface, no schema changes required. Adopters who run kick db generate after upgrading get DEFAULT-preserving migrations automatically. Existing applied migrations stay valid (their hashes are computed over their existing SQL). A re-generated migration against the same schema diff produces the SAME SQL as before for any column without a default — so adopters who happen to re-run generate won't see their journal trip.