Skip to content

Migrations ​

Why Migrations Exist ​

During development, you might use synchronize: true to let the ORM automatically create and alter tables based on your entity definitions. This is convenient. It is also dangerous.

Here is why. Imagine you have a users table with 50,000 rows. You rename a column from phone to mobile in your entity class. With synchronize: true, the ORM sees that phone no longer exists and mobile is new. It does the simplest thing: DROP the phone column and ADD a mobile column. Every phone number in your database is gone.

Migrations solve this problem by letting you write schema changes as explicit, versioned code. Instead of the ORM guessing what changed, you tell it exactly what to do:

sql
-- What synchronize: true would do (DANGEROUS):
ALTER TABLE "users" DROP COLUMN "phone";
ALTER TABLE "users" ADD COLUMN "mobile" VARCHAR(20);
-- All phone data is lost!

-- What a migration does (SAFE):
ALTER TABLE "users" RENAME COLUMN "phone" TO "mobile";
-- Data preserved. Column renamed.

Migrations give you three things that synchronize: true cannot:

  1. Safety -- You control exactly what SQL runs against your database
  2. History -- Every schema change is version-controlled, just like your code
  3. Rollback -- If something goes wrong, you can undo the change

Creating a Migration File ​

A migration is a class with two methods:

  • up() -- Apply the change (move forward)
  • down() -- Undo the change (move backward)

Think of it like an elevator: up() takes you to the next floor, down() brings you back.

typescript
// migrations/001_CreateUsersTable.ts
import { Migration, MigrationContext } from "@stingerloom/orm";

export class CreateUsersTable extends Migration {
  async up(context: MigrationContext) {
    await context.query(`
      CREATE TABLE IF NOT EXISTS "users" (
        "id" SERIAL PRIMARY KEY,
        "name" VARCHAR(100) NOT NULL,
        "email" VARCHAR(255) NOT NULL UNIQUE,
        "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);
  }

  async down(context: MigrationContext) {
    await context.query(`DROP TABLE IF EXISTS "users"`);
  }
}

The MigrationContext object gives you two things:

PropertyDescription
context.query(sql)Execute any SQL statement
context.driverAccess to the database driver (for DDL helpers, identifier escaping, etc.)

More Migration Examples ​

Adding a Column ​

You shipped the users table last week. Now the product team wants a phone number field. You do not modify the original migration -- you create a new one.

typescript
// migrations/002_AddPhoneToUsers.ts
export class AddPhoneToUsers extends Migration {
  async up(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20) NULL`
    );
  }

  async down(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" DROP COLUMN "phone"`
    );
  }
}

The generated SQL on up():

sql
ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20) NULL;

The generated SQL on down() (rollback):

sql
ALTER TABLE "users" DROP COLUMN "phone";

Adding an Index ​

Queries filtering by email are slow. You add an index:

typescript
// migrations/003_AddEmailIndex.ts
export class AddEmailIndex extends Migration {
  async up(context: MigrationContext) {
    await context.query(
      `CREATE INDEX "idx_users_email" ON "users" ("email")`
    );
  }

  async down(context: MigrationContext) {
    await context.query(
      `DROP INDEX "idx_users_email"`
    );
  }
}

The up() SQL:

sql
CREATE INDEX "idx_users_email" ON "users" ("email");

Seeding Initial Data ​

Migrations are not limited to schema changes. You can also insert seed data:

typescript
// migrations/004_SeedRoles.ts
export class SeedRoles extends Migration {
  async up(context: MigrationContext) {
    await context.query(`
      INSERT INTO "roles" ("name", "description") VALUES
      ('admin', 'Administrator'),
      ('user', 'Regular user'),
      ('guest', 'Guest')
    `);
  }

  async down(context: MigrationContext) {
    await context.query(
      `DELETE FROM "roles" WHERE "name" IN ('admin', 'user', 'guest')`
    );
  }
}

How Migration Tracking Works ​

When you run migrations for the first time, Stingerloom automatically creates a special table called __migrations. This table is the ORM's memory -- it records which migrations have already been applied.

For PostgreSQL / SQLite, the table looks like this:

sql
CREATE TABLE IF NOT EXISTS "__migrations" (
  "id" SERIAL PRIMARY KEY,
  "name" VARCHAR(255) NOT NULL UNIQUE,
  "executed_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

For MySQL:

sql
CREATE TABLE IF NOT EXISTS `__migrations` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `name` VARCHAR(255) NOT NULL UNIQUE,
  `executed_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Every time a migration runs successfully, a row is inserted:

sql
INSERT INTO "__migrations" ("name") VALUES ('CreateUsersTable');

When you run migrations again, the runner queries this table first:

sql
SELECT "name" FROM "__migrations" ORDER BY "id" ASC;
-- Returns: ['CreateUsersTable', 'AddPhoneToUsers']

It then compares this list against your registered migrations and only runs the ones that are not in the table yet. This is how migrations are idempotent -- running migrate:run twice does not apply the same migration twice.

When you rollback, the corresponding row is deleted:

sql
DELETE FROM "__migrations" WHERE "name" = 'AddPhoneToUsers';

Running Migrations ​

There are two ways to run migrations: the built-in CLI (simplest) and the programmatic API (for custom setups).

Stingerloom ships with a CLI executable that reads your config file and runs migrations directly from the terminal.

bash
# Run all pending migrations
npx stingerloom migrate:run

# Roll back the last migration
npx stingerloom migrate:rollback

# Show executed and pending migrations
npx stingerloom migrate:status

# Auto-generate a migration from schema diff (see below)
npx stingerloom migrate:generate

Config File ​

The CLI auto-detects a config file in the project root. It searches for these filenames in order:

  1. stingerloom.config.ts
  2. stingerloom.config.js
  3. stingerloom.config.mjs / stingerloom.config.cjs
  4. ormconfig.ts
  5. ormconfig.js
  6. ormconfig.mjs / ormconfig.cjs
typescript
// stingerloom.config.ts
import { User } from "./src/entities/user.entity";
import { Post } from "./src/entities/post.entity";
import { CreateUsersTable } from "./migrations/001_CreateUsersTable";
import { AddPhoneToUsers } from "./migrations/002_AddPhoneToUsers";

export default {
  connection: {
    type: "postgres",
    host: "localhost",
    port: 5432,
    username: "postgres",
    password: "password",
    database: "mydb",
    entities: [User, Post],
  },
  migrations: [
    new CreateUsersTable(),
    new AddPhoneToUsers(),
  ],
};

You can override the config path and set options with CLI flags:

bash
npx stingerloom migrate:run --config ./config/prod.config.ts
npx stingerloom migrate:generate --output ./src/migrations --name AddEmailIndex
FlagDescription
--config <path>Path to config file (default: auto-detect)
--output <dir>Output directory for generated migrations (default: ./migrations)
--name <suffix>Migration name suffix for generated file
--helpShow help message

The CLI supports TypeScript config files natively via ts-node or tsx. If neither is installed, use .js config files.

Concurrent Safety with Advisory Locks ​

When multiple servers start at the same time (common in Kubernetes deployments), they might try to run migrations simultaneously. This could cause duplicate table creation errors or worse.

Stingerloom prevents this with advisory locks. Before running any migration, the runner acquires a database-level lock:

Server A: acquireAdvisoryLock("stingerloom_migration_lock") -> acquired!
Server B: acquireAdvisoryLock("stingerloom_migration_lock") -> waiting...
Server A: runs migrations, releases lock
Server B: acquireAdvisoryLock("stingerloom_migration_lock") -> acquired!
Server B: checks __migrations table, finds nothing pending, exits

If the lock cannot be acquired within the timeout (default: 10 seconds), the runner throws an AdvisoryLockError.

Using MigrationCli (Programmatic) ​

For more control, create a custom migration script. This is useful when your config comes from environment variables or you need custom logic before/after migrations.

typescript
// src/migrate.ts
import { MigrationCli } from "@stingerloom/orm";
import { CreateUsersTable } from "./migrations/001_CreateUsersTable";
import { AddPhoneToUsers } from "./migrations/002_AddPhoneToUsers";
import { AddEmailIndex } from "./migrations/003_AddEmailIndex";

const migrations = [
  new CreateUsersTable(),
  new AddPhoneToUsers(),
  new AddEmailIndex(),
];

const cli = new MigrationCli(migrations, {
  type: "postgres",
  host: process.env.DB_HOST ?? "localhost",
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USER ?? "postgres",
  password: process.env.DB_PASS ?? "password",
  database: process.env.DB_NAME ?? "mydb",
  entities: [],
});

async function main() {
  await cli.connect();

  const command = process.argv[2]; // "migrate:run" | "migrate:rollback" | "migrate:status"
  try {
    const result = await cli.execute(command as any);
    console.log(result);
  } finally {
    await cli.close();
  }
}

main().catch(console.error);

Register scripts in package.json for convenience:

json
{
  "scripts": {
    "migrate:run": "ts-node ./src/migrate.ts migrate:run",
    "migrate:rollback": "ts-node ./src/migrate.ts migrate:rollback",
    "migrate:status": "ts-node ./src/migrate.ts migrate:status",
    "migrate:generate": "ts-node ./src/migrate.ts migrate:generate"
  }
}

Checking Migration Results ​

Each migration returns a result object that tells you whether it succeeded or failed:

typescript
const results = await cli.migrateRun();

for (const result of results) {
  if (result.success) {
    console.log(`[OK] ${result.name}`);
  } else {
    console.error(`[FAIL] ${result.name}: ${result.error}`);
  }
}

If a migration fails, the runner stops immediately -- it does not attempt to run subsequent migrations, because they likely depend on the one that failed.


File Naming Convention ​

Migrations are executed in the order they are registered in the array. Use sequence numbers in filenames to make the order obvious:

migrations/
├── 001_CreateUsersTable.ts
├── 002_CreatePostsTable.ts
├── 003_AddPhoneToUsers.ts
├── 004_AddEmailIndex.ts
└── 005_SeedRoles.ts

Schema Diff -- Automatic Migration Generation ​

Writing migration files by hand is fine for simple changes, but tedious for complex ones. Schema Diff automates this by comparing your entity definitions against the actual database schema and generating the necessary migration code.

How Schema Diff Works, Step by Step ​

Here is what happens inside SchemaDiff.diff():

Step 1: Read your entity definitions. The diff engine uses reflect-metadata to extract every @Entity class and its @Column definitions -- table names, column names, types, lengths, and nullability.

Step 2: Query the real database. It runs an information_schema query to discover what tables and columns actually exist:

sql
-- PostgreSQL
SELECT column_name, data_type, is_nullable, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users';

-- MySQL
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users';

Step 3: Compare. For each entity, it checks:

  • Does the table exist in the database? If not, it goes into addTables.
  • For each column in the entity, does it exist in the database? If not, it goes into addColumns.
  • For each column in the database, does it exist in the entity? If not, it goes into dropColumns.
  • If both exist, do the types and lengths match? If not, it goes into alterColumns.

Step 4: Detect renames. Before finalizing, the engine looks for column renames it can prove -- an explicit renamedFrom, or an add/drop pair whose names read as the same column. Anything less certain is reported as a rename candidate (explained below).

Step 5: Generate migration code. The SchemaDiffMigrationGenerator takes the diff result and produces a migration class with the appropriate up() and down() methods.

Using the CLI ​

The simplest way to generate a migration:

bash
npx stingerloom migrate:generate

This will:

  1. Connect to the database using your config.
  2. Run SchemaDiff.diff() against all registered entities.
  3. If differences are found, generate a timestamped migration file.
  4. If the schema is already in sync, print "No schema changes" and exit.

Using the Programmatic API ​

typescript
import { SchemaDiff, SchemaDiffMigrationGenerator } from "@stingerloom/orm";

// Step 1: Compare entity definitions with the live database
const schemaDiff = new SchemaDiff();
const diff = await schemaDiff.diff(
  [User, Post, Comment],   // your entity classes
  queryRunner,              // something with a .query() method
  "postgres",               // dialect: "postgres" | "mysql" | "sqlite"
);

console.log(diff.addTables);      // ["comment"]
console.log(diff.dropTables);     // []
console.log(diff.addColumns);     // [{ tableName: "users", columnName: "phone", ... }]
console.log(diff.renamedColumns); // [{ tableName: "users", oldColumnName: "phone", newColumnName: "mobile", ... }]

// Step 2: Generate migration code from the diff
if (diff.addTables.length === 0 &&
    diff.dropTables.length === 0 &&
    diff.addColumns.length === 0 &&
    diff.dropColumns.length === 0 &&
    diff.alterColumns.length === 0 &&
    (diff.renamedColumns?.length ?? 0) === 0) {
  console.log("No schema changes");
  return;
}

const generator = new SchemaDiffMigrationGenerator();
const content = generator.generate(diff, "postgres");
await generator.save(content, "./migrations");

SQLite column changes

SQLite cannot ALTER a column's type or nullability. When the diff contains such a change and the dialect is "sqlite", both generate() and dryRun() throw ORM_UNSUPPORTED_OPERATION listing the affected columns instead of emitting a migration that silently skips them. Write a manual migration that recreates the table: create a new table with the desired schema, copy the data over, drop the old table, then rename the new one.

Example: Adding a Column ​

You add a phone column to the User entity:

typescript
@Entity()
class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "varchar", length: 100 })
  name!: string;

  @Column({ type: "varchar", length: 20 })  // NEW
  phone!: string;
}

Running migrate:generate detects that phone exists in the entity but not in the database, and produces:

typescript
class SchemaDiff_1708000000000 extends Migration {
  async up(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20) NULL`
    );
  }
  async down(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" DROP COLUMN "phone"`
    );
  }
}

Column Rename Detection ​

When you rename a column, the naive approach sees a "drop" and an "add" -- the old name disappeared and a new name appeared. Schema Diff tries to recognize the rename instead, so the data stays. But a rename and a column replacement produce exactly the same pair, and guessing wrong is worse than not guessing: the dropped column's rows end up under the new name. So the engine only renames when it is sure.

Here is how it decides, per table:

  1. Gather the columns that would be dropped (in the DB, not in the entity) and the ones that would be added (in the entity, not in the DB).
  2. Pair up anything the entity declared with @Column({ renamedFrom }). An explicit hint always wins.
  3. For the rest, keep only pairs with a compatible type (and the same length/precision when the DB reports one).
  4. Rename such a pair when the two names read as the same column and neither side has another equally plausible partner.
  5. Report everything else as a rename candidate and apply it as the declared drop + add.

Two names read as the same column when they are equal apart from case and separators (user_name -> userName), when one contains the other (name -> fullName, legacyNote -> note), or when they are within a small edit distance (recieved_at -> received_at). Unrelated names -- legacyNote -> bio, createdAt -> updatedAt -- are not renamed.

For a name change the engine recognizes:

typescript
// Before
@Column({ type: "varchar", length: 20 })
phone!: string;

// After
@Column({ type: "varchar", length: 20 })
phoneNumber!: string;

The diff engine sees:

  • Dropped: phone (type: VARCHAR(20))
  • Added: phoneNumber (type: VARCHAR(20))
  • Same table, compatible type, the names read as one column -- this is a rename.

The generated migration uses RENAME COLUMN instead of DROP + ADD:

typescript
class SchemaDiff_1708000000000 extends Migration {
  async up(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" RENAME COLUMN "phone" TO "phoneNumber"`
    );
  }
  async down(context: MigrationContext) {
    await context.query(
      `ALTER TABLE "users" RENAME COLUMN "phoneNumber" TO "phone"`
    );
  }
}

Renaming to an unrelated name: renamedFrom ​

When the new name says nothing about the old one, tell the ORM:

typescript
@Column({ type: "varchar", length: 100, renamedFrom: "legacyNote" })
bio!: string;

renamedFrom names the DB column the values come from. With it, synchronize and migrate:generate emit a RENAME COLUMN; without it they add bio empty and drop legacyNote, which is what the entity literally declares. The option is inert once no column of that name exists, so you can drop it after the rename has shipped everywhere. The same option exists on defineEntity (t.varchar(100).renamedFrom("legacyNote")) and on EntitySchema columns (renamedFrom: "legacyNote").

What a refused rename looks like ​

synchronize logs one warning per pair before applying the drop + add:

[sync] profile.bio is being added while the dropped column "legacyNote" has the
same type but an unrelated name. Treating it as a new column: nothing is copied
from legacyNote. If it is a rename, declare
@Column({ renamedFrom: "legacyNote" }) (or write a migration) before this sync runs.

migrate:generate writes the ADD/DROP it decided on, followed by the rename as a commented-out alternative:

typescript
// POSSIBLE RENAME (name differs from the dropped legacyNote) -- uncomment INSTEAD of the ADD/DROP pair above if this is a rename:
// await query(`ALTER TABLE "profile" RENAME COLUMN "legacyNote" TO "bio"`)

The refused pairs are on the diff result too, as renameCandidates, each with the added column, the dropped columns that fit it (best match first), and a reason of "ambiguous" (several fit) or "dissimilar-names" (one fits by type only).

A rename is subject to synchronize.failOnDestructiveChange: with that flag on, synchronize refuses to rename and throws ORM_SCHEMA_SYNC_DESTRUCTIVE_CHANGE rather than move data at boot.

Schema Diff detects additions, deletions, and renames of tables and columns. Column type changes (e.g., changing VARCHAR to TEXT) are detected as alterColumns in the diff result, but write those as manual migrations for safety.

ENUM Value Synchronization (PostgreSQL) ​

Most schema changes -- adding a column, renaming a table -- happen at the table level. But PostgreSQL has an unusual feature: ENUM types are separate database objects that live outside any table. When you define a column with @Column({ type: "enum", enumValues: ["admin", "user"] }), PostgreSQL creates a named type (like users_role_enum) and the column references that type.

Here's the problem. Suppose you add a new role:

typescript
@Column({
  type: "enum",
  enumValues: ["admin", "user", "moderator"],  // "moderator" is new
})
role!: string;

A regular ALTER TABLE can't detect this change. The column type is still users_role_enum -- it hasn't changed. What changed is the enum type definition itself, which is a different database object. Without auto-sync, you'd have to manually write:

sql
ALTER TYPE "users_role_enum" ADD VALUE IF NOT EXISTS 'moderator';

SchemaDiff now handles this automatically for PostgreSQL.

Hint synchronize applies the same two operations at boot without going through a migration file -- it creates missing enum types and appends missing values. See Configuration.

How it works. During the diff step, after comparing tables and columns, SchemaDiff runs an additional check for PostgreSQL enum types:

  1. For each @Column({ type: "enum" }) in your entities, it reads the current enum values from pg_enum and pg_type in the database.
  2. It compares the values in your entity definition against the values in the database.
  3. New values go into addValues. Removed values go into removeValues.

The result is stored in the enumChanges array of the diff:

typescript
interface EnumChange {
  enumName: string;        // e.g. "users_role_enum"
  addValues: string[];     // values to add
  removeValues: string[];  // values that were removed
  isNew: boolean;          // true if the entire enum type needs to be created
}

Generated migration -- adding a value:

When you add "moderator" to your enum, the migration generator produces:

typescript
class AutoMigration_1708000000000 extends Migration {
  async up({ query }: MigrationContext): Promise<void> {
    await query(`ALTER TYPE "users_role_enum" ADD VALUE IF NOT EXISTS 'moderator'`);
  }

  async down({ query }: MigrationContext): Promise<void> {
    // WARNING: Cannot reverse ALTER TYPE ADD VALUE for "users_role_enum".
    // Recreate the type manually if needed.
  }
}

The IF NOT EXISTS clause makes this safe to run multiple times -- if the value already exists, PostgreSQL silently skips it.

Generated migration -- removing a value:

PostgreSQL has a fundamental limitation: you cannot remove a value from an existing enum type. The only way is to drop and recreate the entire type, which requires updating every column that references it. The migration generator acknowledges this with a warning comment instead of generating unsafe DDL:

sql
-- WARNING: Cannot remove enum values from "users_role_enum": guest.
-- Recreate the type manually if needed.

This is intentionally cautious. Dropping and recreating an enum type is a multi-step operation that can fail if any row contains the removed value. It's safer as a manual migration where you control the process.

What about MySQL?

MySQL keeps the values in the column's own type (role ENUM('admin','user','moderator')), and the diff compares only the type's name -- ENUM against enum -- not the list inside it. A changed value list is therefore not detected on MySQL / MariaDB, by migrate:generate or by synchronize. Write the MODIFY COLUMN by hand:

sql
ALTER TABLE `users` MODIFY COLUMN `role` ENUM('admin','user','moderator') NOT NULL;

What the Schema Diff Does Not Compare ​

On a table that already exists, the diff compares columns: their presence, type, length, precision and nullability, plus renames, generated columns and PostgreSQL enum values. synchronize also creates the indexes and foreign keys an entity adds. Everything else in the table below stays as it is in the database. Both were measured on PostgreSQL, MariaDB and SQLite:

Change to an existing tablesynchronizemigrate:generate
Column added or dropped, or its type, length or nullability changedApplied (as the mode allows)Generated
Column renamed (renamedFrom, or a matching name)AppliedGenerated
@ComputedColumn addedAppliedGenerated
PostgreSQL enum value addedAppliedGenerated
@Index, @UniqueIndex, class-level @Index([...]) or @FullTextIndex addedCreatedNot generated
Relation added (@ManyToOne, owning @OneToOne)Column and constraint created -- SQLite: column only, it cannot add a constraint to an existing tableColumn only
Relation removedColumn dropped on PostgreSQL; on MySQL / MariaDB and SQLite the drop fails with a warning, because the constraint still uses the columnDROP COLUMN written commented out
Index or unique index removed from the entityLeft in placeNot generated
Index redefined under the same nameLeft as it wasNot generated
Unique index columns changed, name generatedNew index created; the old one stays and is still enforcedNot generated
Column default added, changed or removedNot comparedNot compared
Relation onDelete / onUpdate changedNot comparedNot compared
@ComputedColumn expression changedNot comparedNot compared
MySQL / MariaDB ENUM values changedNot comparedNot compared
Entity removedTable keptTable kept

Because a restart after such an edit would otherwise look like a successful sync, both say so. When a table already existed, synchronize logs one line per boot listing the kinds your entities can run into -- a default is only listed when some column declares one, and so on:

INFO [SchemaRegistrar] [sync] synchronize does not apply these to existing tables: changed column defaults, removed or redefined indexes. Write a migration for them (see docs/migrations.md#what-the-schema-diff-does-not-compare).

migrate:generate logs the same after it runs, with "new indexes and foreign key constraints" at the head of the list. Write those changes as a migration by hand; Adding an Index shows the shape.


Migration Hooks ​

The MigrationRunner supports lifecycle hooks for monitoring, logging, and error handling during migration runs.

Available hooks ​

typescript
interface MigrationHooks {
  beforeAll?(context: MigrationContext): Promise<void> | void;
  afterAll?(context: MigrationContext, results: MigrationResult[]): Promise<void> | void;
  beforeEach?(migration: Migration, context: MigrationContext): Promise<void> | void;
  afterEach?(migration: Migration, context: MigrationContext, durationMs: number): Promise<void> | void;
  onError?(migration: Migration, error: Error, context: MigrationContext): Promise<void> | void;
}
HookFires WhenParameters
beforeAllBefore the first migration runsMigrationContext
afterAllAfter all migrations completeMigrationContext, results array
beforeEachBefore each individual migrationMigration, MigrationContext
afterEachAfter each successful migrationMigration, MigrationContext, duration in ms
onErrorWhen a migration failsMigration, error, MigrationContext

Example: Slack notification on failure ​

typescript
import { MigrationRunner } from "@stingerloom/orm";

const runner = new MigrationRunner(driver, migrations, {
  hooks: {
    beforeAll(ctx) {
      console.log("Starting migrations...");
    },
    afterEach(migration, ctx, durationMs) {
      console.log(`Completed ${migration.constructor.name} in ${durationMs}ms`);
    },
    onError(migration, error, ctx) {
      notifySlack(`Migration failed: ${migration.constructor.name} -- ${error.message}`);
    },
    afterAll(ctx, results) {
      console.log(`All done. ${results.length} migrations applied.`);
    },
  },
});

await runner.runAll();

All hooks can be async (return a Promise) or synchronous.


MigrationRunner API ​

MethodDescription
run(migrations?)Execute pending migrations in order
rollback(n?)Roll back the last n migrations (default: 1)
status()Returns { executed: string[], pending: string[] }
runAll()Execute all pending migrations
runUp(migration)Apply a single migration
runDown(migration)Revert a single migration
revertLast()Revert the last migration
getPendingMigrations()Return the list of pending migrations
getExecutedMigrations()Return the list of executed migrations

Next Steps ​

Released under the MIT License.