Skip to content

Lifecycle Hooks ​

Hooks let you run code around the migration lifecycle — logging, metrics, Slack notifications, cache busting, and so on. Define them in your config under hooks.

Code-only option

Hooks are functions, so they can only live in a .ts/.js config — not in migronaut.config.json.

The five hooks ​

ts
import type { MigrationHooks } from '@alexify/migronaut';

export default {
  uri: 'mongodb://localhost:27017',
  dbName: 'my_app',
  hooks: {
    /** Runs once before any migration in the batch starts */
    beforeAll: async (ctx) => {
      console.log('Starting migration batch…');
    },
    /** Runs once after the run ends — including when it failed */
    afterAll: async (ctx, summary) => {
      console.log(`Batch complete: ${summary.applied} applied (success: ${summary.success})`);
    },
    /** Runs before each individual migration */
    beforeEach: async (name, ctx, info) => {
      console.log(`→ ${name} (${info.index + 1}/${info.total})`);
    },
    /** Runs after each migration completes successfully */
    afterEach: async (name, duration, ctx, info) => {
      console.log(`✓ ${name} (${duration}ms, ${info.direction})`);
    },
    /** Runs when a migration throws — before the error propagates */
    onError: async (name, error, ctx) => {
      await notifySlack(`Migration ${name} failed: ${error.message}`);
    },
  } satisfies MigrationHooks,
};

Signatures ​

HookSignatureWhen it runs
beforeAll(ctx) => Promise<void>Once, before the first migration in the run
afterAll(ctx, summary) => Promise<void>Once, after the run ends — including when it failed
beforeEach(name, ctx, info) => Promise<void>Before every individual migration (not for skipped ones)
afterEach(name, duration, ctx, info) => Promise<void>After each migration succeeds
onError(name, error, ctx) => Promise<void>When a migration throws, before it propagates

ctx is the same MigrationContext passed to your migrations, so hooks have full database access. Its run and logger are the run's in beforeAll/afterAll (no migration, no attempt), and the migration's in beforeEach, afterEach and onError — a userland: true line from a hook emits migration:log like one from the migration. info is { direction, index, total }, so a hook can tell an apply from a revert and see its position in the run. summary is { success, applied, direction }.

A hook that throws fails the run with a HOOK_FAILED error — it is never swallowed, and never surfaces as an untyped Error.

Execution order ​

For a run of two migrations A then B:

beforeAll
  beforeEach(A) → A.up() → afterEach(A)
  beforeEach(B) → B.up() → afterEach(B)
afterAll

If A.up() throws:

beforeAll
  beforeEach(A) → A.up() ✖ → onError(A)   ← batch stops here, B never runs
afterAll({ success: false, applied: 0 })

afterAll does run when the batch stops on an error — cleanup and notification hooks matter most on exactly that path. Check summary.success to tell the two cases apart.

Events ​

Hooks are configured up front and run inside the migration's flow. For metrics and alerting, subscribe to events instead: several listeners may attach from outside the config, and a listener that throws — or an async one whose promise rejects — is contained rather than failing the run (or the process): the failure is logged at debug level.

js
const kit = new MigratorKit(config);

kit.on('migration:success', ({ migration, durationMs, runId }) => {
  metrics.timing('migration.duration', durationMs, { migration, runId });
});
kit.on('lock:lost', ({ reason }) => alert(`Migration lock lost: ${reason}`));

await kit.up();
EventPayload
run:start{ runId, command, direction? }
run:end{ runId, command, direction?, success, durationMs, applied?, reverted?, total?, error? }
migration:start{ runId, migration, direction, batch? }
migration:success{ runId, migration, direction, batch?, durationMs, attempts? } — attempts when the driver retried the transaction and the body ran again
migration:skipped{ runId, migration, direction, reason }
migration:error{ runId, migration, direction, batch?, durationMs?, attempts?, error }
migration:log{ kind, runId, migration?, direction, batch?, attempt?, jobId?, groupId?, requestedBy?, reason?, level, msg, data, at, seq, truncated? } — a ctx.logger call marked userland: true (Migration Logs); kind: 'background' from a background migration
lock:acquired{ runId, owner, ttlMs, acquireMs } — or { runId, owner, skipped: true } under --no-lock
lock:released{ runId, owner, early? } — early: true when a converge gave the lock up to wait for search index builds; the run goes on
lock:lost{ runId, owner, reason }
converge:start{ runId, trigger, collections } — a real converge run
converge:action{ runId, collection, target, name, action, status, durationMs?, reason?, error? } — status is 'started' before a step runs, then 'applied' or 'failed'
converge:wait{ runId, status, searchIndexes, lockReleased?, timeoutMs?, waitedMs?, notReady? } — a wait for search index builds (waitForSearchIndexes): status is 'started', 'progress' (every 30 s), then 'ready', 'failed', 'timeout', 'unreadable' or 'aborted'
converge:end{ runId, trigger, success, durationMs, changed, inSync, counts, result, error? }

command is 'up', 'down', 'redo', 'baseline', 'import' or 'converge'. The counts on run:end are present when the run produced a result list — on the failure path too, counted from what was applied before it failed. error is a message with URI credentials already redacted.

runId is the same value on every event of a run, on the lock document, and on each changelog record that run writes — so logs, metrics and the database can be correlated after the fact. It is a random UUID by default; the generateId option puts it in your own format.

Events are notifications: a listener can time a migration, but it cannot make a span the parent of what the migration does next. For traces — and for metrics without writing the listeners yourself — pass a tracer and a meter through the telemetry option instead.

Released under the MIT License.