Enums (dbEnum)

Postgres-native ENUM types via dbEnum() — cheap ADD VALUE migrations, full type-narrowing. Native on postgres and the mysql family, CHECK-backed on mssql and sqlite.

dbEnum('name', [values]) declares a postgres-native ENUM type that's reusable across columns and tables. The literal value union narrows the row type at compile time; cheap ALTER TYPE ... ADD VALUE migrations replace CHECK-rewrite pain.

Quick start

import { dbEnum, id, table } from '@voltro/database'

export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped'] as const)

export const orders = table('orders', {
  id:     id(),
  status: orderStatus.column().default('pending'),
})

// row.status is typed `'pending' | 'paid' | 'shipped'`, not plain string

Why this exists

Voltro has two ways to enforce a closed value set on a text column:

  1. text().oneOf(['a', 'b']) — emits a CHECK constraint. Cross-dialect, works on every backend, but ADD VALUE is a CHECK rewrite (expensive on a 10M-row table).
  2. dbEnum('name', [...]) — postgres-native ENUM type. ALTER TYPE ... ADD VALUE 'new_value' is O(1).

Use dbEnum when:

  • You're on postgres AND
  • The value set is reasonably stable but might grow occasionally AND
  • You want the value set self-documenting in the schema

Use oneOf when:

  • You want ONE mechanism everywhere. dbEnum is portable — it is planned and applied on every dialect — but what it emits differs by dialect (see Cross-dialect below), so oneOf is the simpler thing to reason about if you never need the cheap ADD VALUE.
  • The value set is highly stable (the cheap ADD VALUE doesn't help).

Reuse across tables

export const status = dbEnum('item_status', ['draft', 'published', 'archived'] as const)

export const articles = table('articles', { id: id(), status: status.column() })
export const videos   = table('videos',   { id: id(), state:  status.column() })

The CREATE TYPE DDL emits exactly once — the framework dedups by enum name across all schema tables. Defining the same name with different value sets throws at DDL time.

Defaults

status: orderStatus.column().default('pending')

Default must be one of the declared values — TypeScript narrows the .default(...) parameter to the literal union.

ADD VALUE migrations

// Day 1
export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped'] as const)

// Day 30 — add a value
export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped', 'returned'] as const)

voltro db plan detects the new value and emits an add-enum-value op:

ALTER TYPE "order_status" ADD VALUE 'returned';

On postgres this is O(1) — no table rewrite. The other dialects are planned too, by two different mechanisms: the mysql family redeclares the column (MODIFY COLUMN … ENUM(…) with the full set), mssql and sqlite recreate the CHECK. See Cross-dialect below.

The value set is part of the schema fingerprint, so a production voltro serve refuses to boot until the change has been applied, exactly as it does for a new column. That is the point: the label is a promise your code makes to the database, and a boot that ignored it would fail at the first write instead — invalid input value for enum.

Removing a value

A removed value is deliberately not an operation. Postgres has no DROP VALUE; removing a label means recreating the type and rewriting every column that uses it, which the planner will not do behind your back. Nothing breaks while the label lingers — your code never writes it, and old rows still read.

What the planner will not do silently is pretend the two sides match. voltro db plan reports the table under its fingerprint divergence:

0 operations
declared and live differ without a planned change: orders

That line is the whole feature. An empty plan under two different fingerprints is the one state no command can resolve, so the difference is named rather than hidden. If you want the label really gone, that is a file-based migration recreating the type — and worth asking first whether an unused label is costing you anything.

Renaming values

A value rename looks identical to a drop+add in a naive value-set diff — and a drop+add would orphan every row already storing the old label. So a rename needs explicit INTENT, exactly like a column rename needs .renamedFrom('oldName'). Declare it with declareEnumRename, placed next to the dbEnum(...) whose value array now carries the new label:

import { dbEnum, declareEnumRename } from '@voltro/database'

// The enum now spells the NEW value.
export const orderStatus = dbEnum('order_status', ['awaiting', 'paid', 'shipped'] as const)

// Record that 'pending' was renamed to 'awaiting'.
declareEnumRename('order_status', { from: 'pending', to: 'awaiting' })

The migrator then emits a guarded, data-preserving rename — it fires only when the OLD label still exists and the NEW one doesn't, so re-runs are no-ops:

DO $
DECLARE type_oid oid;
BEGIN
  SELECT oid INTO type_oid FROM pg_type WHERE typname = 'order_status';
  IF type_oid IS NOT NULL
     AND EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = type_oid AND enumlabel = 'pending')
     AND NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = type_oid AND enumlabel = 'awaiting') THEN
    IF current_setting('server_version_num')::int >= 140000 THEN
      EXECUTE format('ALTER TYPE %I RENAME VALUE %L TO %L', 'order_status', 'pending', 'awaiting');
    ELSE
      UPDATE pg_enum SET enumlabel = 'awaiting' WHERE enumtypid = type_oid AND enumlabel = 'pending';
    END IF;
  END IF;
END $;
  • Postgres 14+ uses the native ALTER TYPE … RENAME VALUE 'old' TO 'new' — cheap, in-place, no table rewrite.
  • Postgres 13 and below falls back to a data-preserving pg_enum.enumlabel catalog update — every row keeps its value, the label is simply re-spelled.
  • Every other dialect carries the label set inside the column itself — a native ENUM(…) on the mysql family, a CHECK on mssql and sqlite — so the new list lands when the column or constraint is rewritten. No dedicated rename DDL is needed there.

Keep the declareEnumRename(...) declaration until the rename has applied in every environment you care about, then remove it (the new value is by then the only label the enum knows).

Cross-dialect

Dialect Emits
postgres CREATE TYPE "name" AS ENUM (...) + <col> name (native)
mysql / mariadb <col> ENUM('a','b','c') (native enum column type)
mssql / sqlite <col> NVARCHAR(255) / TEXT + CHECK (col IN ('a','b','c')) (fallback)

Three mechanisms, not two — the mysql family has a native enum column type of its own and does NOT go through text + CHECK. That matters because adding a label takes a different statement on each:

Dialect Adding a label
postgres ALTER TYPE … ADD VALUE — O(1), no table rewrite
mysql / mariadb ALTER TABLE … MODIFY COLUMN … ENUM(…) with the full set
mssql / sqlite the CHECK is recreated (a table rebuild on sqlite)

All three are planned and applied for you; the row exists so you can predict the cost on your own dialect before a deploy window.

When NOT to use

  • You're on mysql or sqlite primarily — the postgres-only benefit doesn't apply. Use text().oneOf([...]) for consistency.
  • You need value-level metadata (descriptions, sort orders) — enums are just strings. Build a lookup table.
  • The value set changes weekly — every change is still a schema migration. For high-churn lists, store the values in a table.

See also

  • Columns — text().oneOf([...]) for the cross-dialect CHECK form
  • Migrations — the planner's classification for enum changes