zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Schema and ORM

Custom MigrationsSupported

Generated migrations cover tables, columns, and installation of extensions required by extension-backed columns. Everything else — views, triggers, extension upgrades or removal, data backfills, index rebuilds — is a migration you write. The runner does not care which kind it is.

The shape#

A migration is a plain object:

import type { Migration } from '@zmdb/core/migrations';

export const migrations: Migration[] = [
  {
    version: 4,
    name: 'add_search_view',
    up: `CREATE VIEW "post_search" AS SELECT id, title FROM posts WHERE published_at IS NOT NULL`,
    down: `DROP VIEW "post_search"`,
  },
];

version orders them and is what gets recorded as applied. Use a monotonic integer or a timestamp; either works, but pick one convention and keep it, because a mix sorts unintuitively.

Mixing generated and hand-written#

Interleave them in the same array. A common pattern is generate the DDL, then hand-write the data step that has to run between two structural changes:

const ops = diff(prev, next);

export const migrations: Migration[] = [
  { version: 5, name: 'add_slug_nullable', up: emitUp(ops[0], 'postgres'), down: emitDown(ops[0], 'postgres') },
  {
    version: 6,
    name: 'backfill_slug',
    up: `UPDATE "posts" SET "slug" = lower(replace("title", ' ', '-')) WHERE "slug" IS NULL`,
    down: `UPDATE "posts" SET "slug" = NULL`,
  },
  { version: 7, name: 'slug_not_null', up: `ALTER TABLE "posts" ALTER COLUMN "slug" SET NOT NULL`, down: '...' },
];

That three-step split — add nullable, backfill, tighten — is how you add a NOT NULL column to a populated table without a full-table exclusive lock and without a default you do not want. Doing it in one step is the most common cause of a migration that works on an empty test database and locks production.

Multiple statements#

up is a string handed to MigrationConnection.exec. Whether it can carry several statements depends on your connection:

up: [
  `ALTER TABLE "posts" ADD COLUMN "slug" TEXT`,
  `CREATE UNIQUE INDEX "posts_slug" ON "posts" ("slug")`,
].join(';\n'),

node-postgres accepts multi-statement strings and runs them in one implicit transaction. mysql2 needs multipleStatements: true. If you would rather not depend on that, make each statement its own migration — more entries, no ambiguity.

Data migrations#

A backfill that cannot be expressed as one UPDATE is a script, not a migration. Migrations run inside a transaction and hold locks; a loop over a million rows should not. Do the structural change as a migration and the backfill as a separately-run, resumable job:

// scripts/backfill-slug.ts — run after migration 5, before 7
for (;;) {
  const batch = await repo.list({ where: { slug: { isNull: true } }, page: { limit: 1_000 } });
  if (batch.items.length === 0) break;
  for (const p of batch.items) await repo.update(p.id, { slug: slugify(p.title) });
}

Resumable, interruptible, and it does not hold a transaction open for an hour. See Streaming for the batching pattern.

Irreversible migrations#

Write the down:

{
  version: 9,
  name: 'drop_legacy_column',
  up: `ALTER TABLE "users" DROP COLUMN "legacy_flag"`,
  down: `ALTER TABLE "users" ADD COLUMN "legacy_flag" INTEGER`,   // structure back, data gone
}

The down restores the shape, not the data. Say so in a comment. A down that throws is also legitimate — better than one that pretends.

Dialect-specific migrations#

emitUp(op, dialect) takes the dialect, so a generated migration is per-dialect. If you support more than one, generate per dialect and select at runtime:

const dialect = process.env.DB_DIALECT as Dialect;
export const migrations = dialect === 'postgres' ? pgMigrations : sqliteMigrations;

Hand-written migrations usually have to fork too — even among the four root dialects, ALTER COLUMN has different grammar. Cockroach and SingleStore inherit the Postgres and MySQL forms respectively.

Testing them#

Run the whole chain up, then down, against a real database in CI:

it('migrations round-trip', async () => {
  await up(conn, migrations);
  await down(conn, migrations);
  await up(conn, migrations);
});

This catches the two common defects: a down that does not undo, and an up that is not re-runnable after a down.

---

See also: Migration Runner · Migrations · Views