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
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
| Hook | Signature | When 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)
afterAllIf 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.
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();| Event | Payload |
|---|---|
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.