Skip to content

Spec — Column DEFAULT Preservation Across pgEnum Rename-Recreate (M5.A.1)

Status: Draft (2026-05-09). Implementation lives in PR for M5.A.1 per m5-plan.md.

Problem

The M3.B rename-recreate dance for pgEnum value removal emits four steps:

  1. ALTER TYPE foo RENAME TO foo__old;
  2. CREATE TYPE foo AS ENUM (…new value list…);
  3. ALTER TABLE T ALTER COLUMN C TYPE foo USING C::text::foo; (per affected column)
  4. 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 status

Adopters 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:

sql
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:

ts
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 REMOVE header at the top of up.sql stays 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.json content. 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 RemovedValueAsDefaultError at 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:

  1. Schema with status status NOT NULL DEFAULT 'active'.
  2. Seed two rows holding active + banned.
  3. Plant a migration that removes legacy (which no rows hold).
  4. Apply with confirmEnumDrop: true → succeeds.
  5. Assert: enum value list is [active, banned], the users.status column still has DEFAULT 'active' (cast via the new enum), no __old shadow type, the seeded rows still hold their original values.
  6. 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.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.