Elegant, fast, fully-typed, zero-dependency MongoDB migrations for Node.js.
Adopt an existing migrate-mongo changelog in one command — then get the controls it never had.
Precise, safe migrations for MongoDB. Run a single file, roll back anything, and preview every change before it touches your database.
Tip
migronaut adopts your existing changelog as-is — no re-running migrations, no data loss, no rewriting
files. Point it at the same database and bring your whole history over in one command:
migronaut import # one-time: adopt your migrate-mongo changelog (it's never modified)
migronaut up # applies only what's new — your past migrations are recognized as already appliedYour applied history is preserved and new migrations run normally. Your up/down/create/status
mental model carries over 1:1 — you just gain dry-runs, single-file control, real rollbacks, hooks,
and locking. No migrate-mongo history at all? migronaut baseline adopts an existing database
with no prior tool. → See how it works
- Zero dependencies — no runtime dependencies at all; only the
mongodbdriver as a peer (Mongoose, BullMQ and OpenTelemetry are optional integrations you inject — never installed for you). Instant installs, nothing extra in your lockfile, no supply-chain surface. - Run a single migration —
migronaut up <file>, not just "all pending". - Roll back anything — a batch (
--batch 3), the last N (--steps 2), one file, orredo. - Preview before you run —
migronaut dry-run upprints the exact plan without touching the database. - No race conditions — an atomic MongoDB lock stops two deploys running migrations at once.
- Tamper detection — SHA-256 checksums catch a migration edited after it was applied.
- Audit trail kept — a rollback updates the record, it never deletes it.
- Adopt any existing database —
migronaut importfor a migrate-mongo history,migronaut baselinewhen there was no migration tool at all. - Out-of-order detection — a migration merged late from a parallel branch is flagged
(warn by default,
onOutOfOrder: 'error'to refuse) instead of silently applying. - Lifecycle hooks —
beforeAll,afterAll,beforeEach,afterEach,onError. - Opt-in transactions — wrap a migration so it fully commits or fully aborts.
- Indexes, search indexes and validators as an end state — declare them (Atlas Search and
Vector Search indexes included), and
migronaut convergebrings the database to match; no migration file per index change (details). - TypeScript, ESM & CommonJS — all run with no
ts-nodeplumbing. - Zero config files required — drive everything from env vars if you prefer.
- Pino-friendly logging — the
loggeroption is pino-compatible; pass a pino instance directly and migronaut logs through it (with acomponent: 'migronaut'child binding). - Migration logs for your users (experimental) — every migration gets
ctx.logger, bound to its run (run id, migration, attempt, queue job), andctx.run; a line markeduserland: trueis also emitted asmigration:logfor your service to store and show — migronaut stores none of it (guide). - Your id format — run ids and queue group ids are random UUIDs by default; pass
generateId: ulid(or cuid2, nanoid, UUIDv7 — any() => string) and every id migronaut mints comes from your generator. - OpenTelemetry (optional) — pass a tracer and a meter from your own
@opentelemetry/api: a span per run and per migration, active while the migration runs, so an instrumented MongoDB driver nests its command spans under the migration that issued them — plus duration metrics. - Migrations as a queue (optional) —
@alexify/migronaut/bullmqruns each migration as its own BullMQ job, in order, so migronaut can be a migration service: trigger it over HTTP, on a schedule, or from a deploy hook that waits for the result. - Document versioning (experimental) — declare a collection's shape version (
__v) and optimistic-concurrency revision (__rev) once; converge enforces them, and@alexify/migronaut/versioninggives your repository layerupdateWithRevision, typed per-version shapes, an upcaster and a Mongoose plugin (guide). - Background migrations (experimental) — long data rewrites (
export const background = {…}) thatuponly registers and that run beside the migration line: partitions and parallel lanes across pods, checkpoints, pause/resume, transactional batches, shard-aware on sharded clusters, dry runs, and a drift watcher that upgrades old-shape writes after completion — from the CLI, inside your app, or on the queue (guide).
| Capability | migrate-mongo |
migronaut |
|---|---|---|
up / down / create / status |
✅ | ✅ |
| Run a single migration file | ❌ | ✅ |
| Roll back a specific batch (not just the last) | ❌ | ✅ |
| Dry-run preview | ❌ | ✅ |
redo (down + up) |
❌ | ✅ |
| Concurrency lock | opt-in, TTL-only | ✅ always on: heartbeat, owner token, abort-on-loss, lock/unlock CLI |
| Checksum / tamper detection | opt-in useFileHash (re-runs changed files) |
✅ enforced drift detection: per-row checksumOk, --strict, audit |
| Lifecycle hooks | ❌ | ✅ |
| First-class TypeScript (built-in) | ❌ | ✅ |
| History preserved on rollback (never deleted) | ❌ | ✅ |
Declared indexes & validators (converge) |
❌ | ✅ |
| Declared Atlas Search / Vector Search indexes | ❌ | ✅ |
Adopt an existing migrate-mongo changelog |
— | ✅ migronaut import |
Reflects migrate-mongo's documented CLI as of mid-2026 (v14: optional
lockCollectionName/lockTtl lock, optional useFileHash). It has since added transaction access
via a client argument; migronaut exposes the same plus a declarative per-file useTransaction flag.
migronaut is a fork of mongo-migrate-kit
(CLI mmk) by Santosh Gupta. The fork was not a rename — everything below landed after it:
| Capability | mongo-migrate-kit |
migronaut |
|---|---|---|
| Runtime dependencies | 6 | 0 |
| Ships as | bundled dist/ |
source, no build step |
--json on every command |
❌ | ✅ |
Typed exit code per error (EXIT_CODES) |
❌ | ✅ |
status --check deploy gate |
❌ | ✅ |
audit / lock commands |
❌ | ✅ |
up --to / down --to targeting |
❌ | ✅ |
Lifecycle events (EventEmitter) |
❌ | ✅ |
Reuse an already-connected MongoClient |
❌ | ✅ |
| Changelog written inside the migration's transaction | ❌ | ✅ |
Credentials masked in errors, logs and --json |
❌ | ✅ |
| Pino-compatible logger | ❌ | ✅ |
BullMQ queue adapter (/bullmq entry point) |
❌ | ✅ |
Declared indexes & validators (converge) |
❌ | ✅ |
| Declared Atlas Search / Vector Search indexes | ❌ | ✅ |
| Node floor | ≥ 18 | ≥ 22.18 |
Compared against mongo-migrate-kit 1.2.2 — the version this project forked from. The Node
floor is a trade-off, not a win: it is what buys .ts migrations with no loader and .env parsing
with no dependency.
→ Full comparison, and what stayed the same
npm install @alexify/migronaut
npm install mongodb # required peer dependency# 1 · create a configuration file (migronaut.config.js; pass --ts for TypeScript)
npx migronaut init
# 2 · create your first migration
npx migronaut create "add users email index"
# 3 · run everything pending
npx migronaut up
# 4 · see where you stand
npx migronaut statusA migration is just an up and a down:
import type { MigrationContext } from '@alexify/migronaut';
export const description = 'Add unique index on users.email';
export async function up({ db }: MigrationContext): Promise<void> {
await db.collection('users').createIndex({ email: 1 }, { unique: true });
}
export async function down({ db }: MigrationContext): Promise<void> {
await db.collection('users').dropIndex('email_1');
}Prefer no files at all? Skip
migronaut initand exportMIGRONAUT_URIandMIGRONAUT_DB— that is enough to run.
Full docs, guides, and the API reference live at migronaut.vercel.app.
- Why migronaut? — how it compares to
migrate-mongo - Core Concepts — migrations, batches, the changelog, locking
- Getting Started & Tutorial
- Configuration · Writing Migrations · Transactions · Hooks
- Programmatic API · Migrations as a Queue (BullMQ) · Migration Logs · OpenTelemetry · CI/CD · Troubleshooting
- Reference: CLI Cheatsheet · Error Codes
Every command accepts the global flags --uri, --db, --dir, --config, --env-file,
--no-env, --verbose, --quiet, --no-color, and --json (except init — see init --format).
| Command | What it does |
|---|---|
migronaut init |
Create a documented migronaut.config.* in the current directory |
migronaut import |
Adopt an existing migrate-mongo changelog (one-time, forward-only) |
migronaut baseline |
Mark existing migration files as applied without running them (adopt an existing DB) |
migronaut create <name> |
Generate a timestamped migration file |
migronaut up [file] |
Run all pending migrations, one named file, or up to --to <file> |
migronaut down [file] |
Roll back the last batch, a chosen batch, the last N steps, one file, or to --to <file> |
migronaut redo [file] |
Roll back then re-apply (the last migration, or one file) |
migronaut converge |
Bring declared collections — indexes, search indexes and validators — to their declared state |
migronaut background <action> [name] |
Background migrations: status, run, pause, resume, cancel, retry, repin, dry-run, unlock, verify, watch |
migronaut status |
Print the full migration status table (--check to fail CI on pending) |
migronaut list |
List migrations, filtered by status |
migronaut dry-run <up|down> [file] |
Preview a run without touching the database |
migronaut audit |
Read-only health check: config, connection, transactions, indexes, lock, drift |
migronaut lock |
Show who currently holds the migration lock |
migronaut unlock |
Force-release a stuck lock left behind by a crashed run |
Most data commands (up, down, redo, converge, background, status, list, dry-run,
import, baseline, create, audit, lock, unlock) accept --json for machine-readable output — see
CI & automation.
Options for every command
# init — generate a config file
migronaut init # migronaut.config.js (default)
migronaut init --js # migronaut.config.js (explicit default)
migronaut init --ts # migronaut.config.ts
migronaut init --format json # migronaut.config.json
migronaut init --secret-provider # async config that loads the URI from a secret manager (js/ts only)
migronaut init --force # overwrite an existing config file
migronaut init --uri mongodb://localhost:27017 --db my_app # prefill the generated config
# import — adopt an existing migrate-mongo changelog
migronaut import # read `changelog`, write the migronaut changelog
migronaut import --from <name> # read a differently-named source collection
migronaut import --to <name> # write to a specific collection (default: config migrationsCollection)
migronaut import --dry-run # preview the mapping, write nothing
migronaut import --trust-hash # reuse migrate-mongo's fileHash instead of recomputing
migronaut import --force # proceed even if the migronaut changelog already has records
migronaut import --no-lock # skip the concurrency lock (local dev only)
migronaut import --json # machine-readable output
# baseline — adopt an existing database (no prior migration tool)
migronaut baseline # mark ALL pending files as applied, without running them
migronaut baseline --to <file> # only files up to and including <file>
migronaut baseline --yes # skip the confirmation prompt (required with --json)
migronaut baseline --json # machine-readable output ({ "baselined": [...], ... })
# create — generate a migration file
migronaut create <name> # file type follows config `createExtension` (default .js)
migronaut create <name> --ts # force a .ts file
migronaut create <name> --js # force a .js file
migronaut create <name> --template <path> # use a custom template
migronaut create <name> --json # machine-readable output ({ "path": "..." })
# up — apply migrations
migronaut up # all pending (one shared batch for the run)
migronaut up <file> # one specific file
migronaut up --to <file> # pending files up to and including <file>, then stop
migronaut up --step # apply each file as its own batch (revert individually later)
migronaut up <file> --force # re-run an ALREADY-applied file (asks for confirmation)
migronaut up <file> --force --yes # confirm a re-run non-interactively (required with --json)
migronaut up --strict # abort on any checksum mismatch
migronaut up --no-lock # skip the concurrency lock (local dev only)
migronaut up --converge # converge the declared collections afterwards (bulk runs only)
migronaut up --no-converge # don't, even with convergeAfterUp on
migronaut up --json # machine-readable output (array of run results)
# down — roll back
migronaut down # the last batch (may be several files)
migronaut down <file> # one specific file
migronaut down --batch <n> # a specific batch number
migronaut down --steps <n> # the last N migrations, newest first, ignoring batches
migronaut down --to <file> # everything applied after <file>; <file> itself stays applied
migronaut down --no-lock # skip the concurrency lock (local dev only)
migronaut down --json # machine-readable output (array of run results)
# redo — down then up
migronaut redo # the most recently applied migration
migronaut redo <file> # a specific file
migronaut redo --no-lock # skip the lock (dev only)
migronaut redo --json # machine-readable output (array of run results)
# converge — declared indexes, search indexes and validators → the database
migronaut converge # plan, ask before any drop/rebuild, then apply
migronaut converge --dry-run # show the plan, change nothing
migronaut converge --check # exit 28 if anything would change (CI gate)
migronaut converge --prune # also drop indexes a definition does not declare
migronaut converge --wait-search # wait until every declared search index is queryable
migronaut converge --yes # no confirmation (required for drops/rebuilds with --json)
migronaut converge --no-lock # skip the concurrency lock (local dev only)
migronaut converge --json # machine-readable output (the converge result)
# background — background migrations (registered by up, rewritten in partitions)
migronaut background status # every background migration
migronaut background status --check # exit 32 if one failed, 31 if one is not completed
migronaut background run <name> # drive it from here (--concurrency N, --once, --all)
migronaut background pause <name> --wait # stop its lanes at the next batch
migronaut background dry-run <name> --validate # on a sample, in an always-aborted transaction
migronaut background verify # look for old-shape documents after completion
migronaut background watch # upgrade old-shape writes as they land (Ctrl-C stops)
# status — full status table
migronaut status # the full status table
migronaut status --check # exit 2 if any migration is pending (CI gate)
migronaut status --pending # only the pending rows
migronaut status --limit <n> # only the last N rows (not combinable with --check)
migronaut status --json # machine-readable output (array of status rows)
# list — filtered status
migronaut list # all migrations
migronaut list --pending # only pending
migronaut list --applied # only applied
migronaut list --json # machine-readable output (array of status rows)
# dry-run — preview, never writes
migronaut dry-run up [file]
migronaut dry-run down [file]
migronaut dry-run down --steps <n> # preview a step rollback (the last N migrations)
migronaut dry-run down --batch <n> # preview reverting a specific batch
migronaut dry-run up --to <file> # preview a staged rollout up to <file>
migronaut dry-run down --to <file> # preview reverting everything applied after <file>
migronaut dry-run up --json # machine-readable output (array of status rows)
# audit — read-only health check
migronaut audit # pass/warn/fail per check; exit 22 on any fail
migronaut audit --json # machine-readable report
# lock — inspect the current lock
migronaut lock # shows the holder (pid / host / user / since), or "no lock"
migronaut lock --json # machine-readable output
# unlock — clear a stuck lock after a crash
migronaut unlock # shows the holder, prompts y/N
migronaut unlock --yes # skip the prompt (short: -y)
migronaut unlock --json # machine-readable output ({ "released": ..., "holder": ... })Global flags (available on all commands): --uri <uri> (override MIGRONAUT_URI),
--db <name> (override MIGRONAUT_DB), --dir <path> (override MIGRONAUT_MIGRATIONS_DIR),
--config <path> (explicit config file, overrides auto-discovery), --env-file <path> /
--no-env (control .env loading), --verbose / --quiet (log level), --no-color,
-V, --version, -h, --help. Combined short flags are not supported — write -f -y, not -fy.
--json is a global flag (migronaut --json status and migronaut status --json both
work) and prints one JSON document to stdout — see CI & automation. The one
command without JSON output is migronaut init, whose deliverable is the config file itself:
use init --format <js|ts|json> to pick the file format.
Migrating from migrate-mongo — adopt an existing changelog with migronaut import
migronaut import reads your existing migrate-mongo changelog and records that history in the migronaut
changelog, so migronaut up knows what is already applied and runs only what is new. It is a one-time,
forward-only step.
# point migronaut at the same database, then:
migronaut import --dry-run # preview the mapping first (writes nothing)
migronaut import # adopt the history
migronaut up # apply only the migrations added sinceWhat it does
- Reads the source collection (
changelogby default;--fromto override) and never modifies it — the mapped records are written to themigronautchangelog (your config'smigrationsCollection,_migronaut_migrationsby default;--toto write to a different collection). - Maps
fileName → name,appliedAt → appliedAt, and resolves a checksum: it reusesmigrate-mongo'sfileHashwhen it matches the file on disk, otherwise recomputes a SHA-256 from disk (--trust-hashreuses the stored hash as-is). Records whose files are missing are still imported. - Assigns each migration a unique, sequential batch number in apply order. If the
migronautchangelog already has records, imported batches continue after the existing maximum (use--forceto import into a non-empty changelog). - Leaves migration files that exist on disk but are not in the source changelog pending — they
run on the next
migronaut up, exactly as expected for newly added migrations.
Options
| Flag | Default | What it does |
|---|---|---|
--from <collection> |
changelog |
Source collection to read (never modified). |
--to <collection> |
config migrationsCollection (_migronaut_migrations) |
Target collection to write the adopted history to. |
--dry-run |
off | Preview the mapping and print the table; writes nothing. |
--trust-hash |
off | Reuse migrate-mongo's stored fileHash as-is instead of recomputing the checksum from disk. |
--force |
off | Import into a changelog that already has records (imported batches continue after the existing max). |
--no-lock |
off | Skip the MongoDB concurrency lock (local dev only). |
Plus the global flags --uri, --db, --dir, and --config.
Forward-only — imported migrations cannot be rolled back
Adopted records are tagged origin: 'migrate-mongo'. migrate-mongo files use a positional
up(db, client) signature, which migronaut does not execute (it passes a single context object). To avoid
ever corrupting your data, migronaut down / migronaut redo refuse an imported migration up front, before
running or writing anything, and tell you why:
✖ Cannot roll back 1 migrate-mongo-imported migration(s): 20260101-add-index.js
If you need an old migration to be reversible under migronaut, re-author its file in the native format
(named exports, single context argument — see Migration file formats).
Transactions — wrap a migration in an all-or-nothing MongoDB transaction
Opt in per file with export const useTransaction = true (or globally via config). The runner opens a
session, passes it through the context, and commits on success or aborts on any error. Pass the
session to every operation so it joins the transaction:
export const useTransaction = true;
export async function up({ db, session }: MigrationContext): Promise<void> {
await db.collection('accounts').insertOne({ balance: 100 }, { session });
await db.collection('ledger').insertOne({ delta: 100 }, { session });
}Transactions require a replica set or sharded cluster — MongoDB's own requirement, not a library limit.
Lifecycle hooks — run code around the batch and each migration
Define hooks in your config file. Use them to seed data, emit metrics, or alert on failure:
hooks: {
beforeAll: async (ctx) => { /* once, before the batch */ },
afterAll: async (ctx) => { /* once, after the batch */ },
beforeEach: async (name, ctx) => { /* before each file */ },
afterEach: async (name, durationMs, ctx) => { /* after each file */ },
onError: async (name, error, ctx) => { /* a file threw — alert, then it re-throws */ },
}Loading secrets at runtime — AWS, Google, Vault, Azure, anything
A .ts/.js config may export a function (sync or async) instead of an object.
migronaut calls it once per command, so you can fetch the connection from a secret manager at run time.
The secret is never written to disk, and a rotated value is picked up automatically on the next run.
The library ships no cloud SDKs — you bring the one you already use, so any provider works:
// migronaut.config.js — AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
export default async () => {
const sm = new SecretsManagerClient({ region: 'us-east-1' });
const res = await sm.send(new GetSecretValueCommand({ SecretId: 'prod/mongo' }));
const { uri, dbName } = JSON.parse(res.SecretString ?? '{}');
return { uri, dbName }; // merged at the config-file tier — env vars / flags still override
};Run migronaut init --secret-provider to scaffold this form with an AWS example you can swap for any provider.
If the function throws, it surfaces as a ConfigInvalidError with the cause attached.
Batches & step rollback — group a deploy, or revert file-by-file
A batch is one migronaut up run. By default every migration applied in a single run shares one batch
number, so migronaut down rolls back that whole run as a unit — the same model used by Laravel and
Knex. That keeps a deploy atomic: one command applied it, one command reverts it.
When you want finer control, two flags mirror Laravel's migrate --step / migrate:rollback --step:
migronaut up --step— apply each file in the run as its own sequential batch instead of one shared batch. A latermigronaut downthen peels them off one at a time.migronaut down --steps <n>— revert the last N applied migrations, newest first, counted as individual files regardless of batch.migronaut down --steps 1reverts just the single most-recently applied migration; a larger N can cross batch boundaries, so preview it first withmigronaut dry-run down --steps <n>.
--steps is mutually exclusive with --batch and a filename. Migrations are always reverted
newest-first, so up followed by down --steps <same n> returns you to the starting state.
Concurrency lock & checksums — safe concurrent deploys, tamper detection
Lock. Each run acquires an atomic lock document in _migronaut_locks, so two deploys can never migrate
at once. A lock older than lockTTLSeconds is treated as stale and reclaimed; while a migration runs,
a heartbeat renews the lock at half the TTL so a long migration can't have its lock stolen mid-run.
The lock is always released in a finally block. --no-lock bypasses it for local development (and
warns loudly). If a process crashes hard and leaves a lock behind, clear it with migronaut unlock (it
shows you who held it and asks for confirmation).
Checksums. Every applied migration stores a SHA-256 of its file. On later runs migronaut compares the
two and surfaces drift in status. With strict: true (or --strict) a mismatch aborts the run;
otherwise it warns and skips. To intentionally re-run an edited, already-applied file, use
migronaut up <file> --force.
CI & automation — JSON output, deploy gates, scripting
Machine-readable output. Add --json to any data command (up, down, redo, status,
list, dry-run, import, create, audit, lock, unlock) to get a single JSON document on stdout — all
human logs and the spinner are redirected to stderr, so the stream is safe to pipe into jq or parse
in a script. On failure the command prints { "error": { "code": "...", "message": "..." } } to
stdout and exits 1.
# Apply pending migrations and capture the result in CI
migronaut up --json | jq '.[] | select(.status == "applied") | .file'
# Fail a deploy step if the database isn't fully migrated
migronaut status --check # exits 2 when anything is pending, 0 otherwise
# Inspect status as data
migronaut status --json | jq 'map(select(.status == "pending")) | length'A typical pipeline gate:
# .github/workflows/deploy.yml (excerpt)
- name: Fail if migrations are pending
run: npx migronaut status --check --uri "$MIGRONAUT_URI" --db "$MIGRONAUT_DB"Note:
migronaut initis the one command without JSON output — its deliverable is the config file itself.init --format jsonwritesmigronaut.config.json.
Audit trail — a complete, append-only history
Every record in _migronaut_migrations stores batch, status, appliedAt, revertedAt, duration,
checksum, environment, and executedBy. Rolling back updates a record's status to reverted
and stamps revertedAt — it is never deleted, so the full history stays intact for compliance.
Programmatic API — run migrations from your own code
runMigrations() is the blessed one-call entry point. It opens its own connection, applies every
pending migration, and always disconnects — even if a migration throws, so a failed boot never
leaks a MongoDB connection. A failing migration aborts startup instead of letting your app serve
traffic against a half-migrated database:
import { runMigrations } from '@alexify/migronaut';
// Call this before your server starts listening.
const { applied, upToDate } = await runMigrations({
uri: process.env.MIGRONAUT_URI!,
dbName: 'my_app',
migrationsDir: './migrations',
});
if (!upToDate) console.log(`Applied ${applied.length} migration(s)`);
// then: app.listen(...)When several instances start at once, only one wins the lock. Set onLockHeld: 'wait' so the others
block until the migrating peer finishes, then confirm there's nothing left to apply before returning:
await runMigrations(
{ uri: process.env.MIGRONAUT_URI!, dbName: 'my_app' },
{ onLockHeld: 'wait', lockWaitTimeoutMs: 90_000 }, // default 'throw', waits up to 90s
);The same call works in a Lambda/Cloud Function bootstrap. Keep onLockHeld: 'wait' so concurrent
cold starts don't fail, and rely on the auto-disconnect so each invocation cleans up after itself.
pendingMigrations() is a connection-managed, read-only readiness probe:
import { pendingMigrations } from '@alexify/migronaut';
const pending = await pendingMigrations({ uri, dbName: 'my_app' });
if (pending.length > 0) {
throw new Error(`Database is behind by ${pending.length} migration(s)`);
}For everything else, every CLI command is a method on MigratorKit (you manage the lifecycle):
import { MigratorKit } from '@alexify/migronaut';
const migrator = new MigratorKit({ uri, dbName: 'my_app', migrationsDir: './migrations' });
await migrator.connect();
const rows = await migrator.status(); // StatusRow[]
await migrator.disconnect();All errors extend MigronautError and carry a typed code (LOCK_ALREADY_HELD, CHECKSUM_MISMATCH,
NOT_APPLIED, …), so catch blocks stay type-safe.
[!NOTE] Running several kits against several databases in one process? Pass
envFile: falseand explicituri/dbNameto each —.envloading mutates the sharedprocess.env(dotenv semantics), so different env files could otherwise leakMIGRONAUT_*values between kits.
Declared collections — indexes, search indexes and validators as an end state, with no migration file per change
When what matters is the final shape of a collection's indexes, Atlas Search indexes and
validator — not the history of how it got there — declare it and let migronaut converge make the
difference:
// migronaut.config.js
export default {
uri: process.env.MIGRONAUT_URI,
dbName: 'my_app',
collections: [
{
name: 'users',
indexes: [
{ key: { email: 1 }, unique: true },
{ key: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
],
validator: { $jsonSchema: { bsonType: 'object', required: ['email'] } },
},
{
name: 'movies',
searchIndexes: [
{ definition: { mappings: { dynamic: true } } }, // Atlas Search, named "default"
{
name: 'plot_vectors',
type: 'vectorSearch',
definition: {
fields: [{ type: 'vector', path: 'embedding', numDimensions: 1536, similarity: 'cosine' }],
},
},
],
},
],
collectionsDir: './collections', // …and/or one file per collection
};migronaut converge --dry-run # what would change
migronaut converge # apply — asks before dropping or rebuilding an index
migronaut converge --check # exit 28 on drift: a CI gate- Stateless. Every run reads
listIndexes/listCollectionsand compares; nothing is recorded. Edit the declaration, converge again. - Safe by default. Missing indexes are created, a TTL or
hiddenchange is applied in place, a changed index is rebuilt (and put back if the rebuild fails). An index you did not declare is kept and reported — dropped only withprune. An identical index under another name is accepted as is, never silently rebuilt. - Search indexes, too — on Atlas, an Atlas CLI local deployment or MongoDB 8.3+ with
mongot. A changed definition is updated in place (on Atlas the old one serves until the new one is built), never dropped and rebuilt;--wait-searchwaits until the builds are queryable. On a server without Search, converge refuses — or skips them withonSearchUnavailable: 'skip'. - Locked like a migration, and refused before the first write when the plan has a conflict.
- After every deploy with
convergeAfterUp: true— a bulkupthen ends by converging, under the same lock — or as a queue job.
Experimental since 2.1 (search indexes: 2.2). → Declared Collections
Migrations as a queue — a migration service on BullMQ, one migration per job
@alexify/migronaut/bullmq enqueues each pending migration as its own BullMQ job and applies them
in order with a single-concurrency worker. BullMQ is injected — it is your dependency, never
migronaut's:
const { Queue, Worker, QueueEvents } = require('bullmq');
const { createMigrationQueue } = require('@alexify/migronaut/bullmq');
const mq = createMigrationQueue({
config: { uri: process.env.MIGRONAUT_URI, dbName: 'my_app' },
bullmq: { Queue, Worker, QueueEvents },
connection: { host: 'redis', port: 6379 },
});
await mq.startWorker(); // the process that applies migrations
const group = await mq.enqueueUp(); // one job per pending migration, one shared batch
const { results } = await group.wait(); // optional: block until they all finished
await mq.enqueueDown(); // roll the last batch back, newest first
await mq.schedule({ every: 300_000 }); // or keep the database migrated on a schedule
await mq.enqueueConverge(); // declared indexes and validators, as a job- Order comes from MongoDB, not from Redis. Every job is a normal single-file run under the
usual lock, and refuses while an earlier migration is still pending — so a failed migration
stops the line (
MIGRATION_BLOCKED), and a CLImigronaut upat the same moment is safe. - One attempt per job, on purpose — a queue retry would let later migrations overtake the
failed one. Duplicate enqueues are deduplicated; an already-applied migration completes as
skipped. - Bring your own Worker with
createMigrationProcessor()(NestJS, BullMQ Pro). - What a migration logs for your users (
logger.info(…, { userland: true })) lands in the job's log and reachesmq.kit.on('migration:log', …)with the job's id — keep it in a collection of your own (Migration Logs).
migronaut resolves settings in this order (highest wins):
CLI flags → environment variables → config file → built-in defaults
A config file is optional and auto-discovered in the working directory as migronaut.config.ts,
migronaut.config.js, or migronaut.config.json. Run migronaut init to generate one — it ships fully commented,
so every setting lives in one documented place. A generated migronaut.config.json points its
$schema at the hosted copy for editor completion; offline or air-gapped setups can point it at
the copy every install already ships: ./node_modules/@alexify/migronaut/migronaut.schema.json.
// migronaut.config.js — generated by `migronaut init`, every option explained
/** @type {import('@alexify/migronaut').MigronautConfig} */
export default {
// ── Connection (required) ───────────────────────────────────────────────
uri: 'mongodb://localhost:27017', // MongoDB connection string
dbName: 'my_app', // database to run migrations against
// ── Files ───────────────────────────────────────────────────────────────
migrationsDir: './migrations', // where migration files live
fileExtensions: ['.ts', '.js'], // which files count as migrations
createExtension: 'js', // default type for `migronaut create` ('js' | 'ts'); --js/--ts override
sequential: false, // true → 0001-style numbering instead of timestamps
// templatePath: './migration.template.ts', // custom template for `migronaut create`
// ── Bookkeeping collections ─────────────────────────────────────────────
migrationsCollection: '_migronaut_migrations', // the append-only audit trail
lockCollection: '_migronaut_locks', // the concurrency lock
convergeLogCollection: '_migronaut_converge', // what each converge changed
lockTTLSeconds: 60, // a lock older than this is reclaimable
// ── Safety ──────────────────────────────────────────────────────────────
strict: false, // true → abort on a checksum mismatch (instead of warn + skip)
useTransaction: false, // true → wrap every migration in a transaction (override per file)
// ── Declared collections (experimental) — see `migronaut converge` ──────
// collections: [{ name: 'users', indexes: [{ key: { email: 1 }, unique: true }] }],
// collectionsDir: './collections', // one definition file per collection
// convergeAfterUp: false, // true → every bulk `up` ends by converging
// onSearchUnavailable: 'fail', // 'skip' → converge without search indexes where Search is absent
// waitForSearchIndexes: false, // true → converge waits until search indexes are queryable
// ── Background migrations (experimental) — see `migronaut background` ───
// backgroundCollection: '_migronaut_background', // state (+ _partitions, _watch)
// backgroundInline: false, // true → run it to the end inside the registering `up`
// backgroundOnDrift: 'reopen', // 'report' → only report old-shape documents after completion
// backgroundDrift: 'poll', // 'stream' | 'both' → watch drift with change streams
// backgroundShardAware: 'auto', // 'off' → no shard-key partitions on sharded collections
// ── Code-only options (omit in migronaut.config.json) ─────────────────────────
// hooks: { beforeAll, afterAll, beforeEach, afterEach, onError },
// mongoose: myMongooseInstance, // pass if your migrations use Mongoose models
// logger: null, // null silences all output; a pino instance works directly
// generateId: ulid, // your id format for run ids — any sync `() => string`
// telemetry: { tracer, meter }, // OpenTelemetry, from your own @opentelemetry/api
};Custom id format — ULID, CUID, UUIDv7 or anything else instead of UUIDs
Every run gets a runId — stamped on its changelog records, events and log lines, and stored as
the lock's owner token — and every queue enqueue gets a groupId. Both are crypto.randomUUID()
by default. Pass generateId to mint them with the generator the rest of your system uses:
const { ulid } = require('ulid');
const { runMigrations } = require('@alexify/migronaut');
await runMigrations({
uri: process.env.MIGRONAUT_URI,
dbName: 'my_app',
generateId: ulid, // createId (cuid2), nanoid, uuidv7 … pass straight through
});It is called with no arguments and must synchronously return a non-empty string of at most 128
characters; anything else fails the run with CONFIG_INVALID before a migration starts. Ids are
for correlation only — the lock carries a token of its own, so a generator that repeats a value
can never let two runs hold the lock at once.
OpenTelemetry — a span per run and per migration, and their durations as metrics
Pass a tracer and/or a meter from your own @opentelemetry/api — migronaut never imports it:
const { metrics, trace } = require('@opentelemetry/api');
const { runMigrations } = require('@alexify/migronaut');
await runMigrations({
uri: process.env.MIGRONAUT_URI,
dbName: 'my_app',
telemetry: {
tracer: trace.getTracer('@alexify/migronaut'),
meter: metrics.getMeter('@alexify/migronaut'),
},
});migronaut.run one per run, once it holds the lock
└─ migronaut.migration one per migration — the ACTIVE span while it runs
├─ insert users ← your instrumented MongoDB driver nests here
└─ update _migronaut_migrations
That nesting is the point. An instrumented driver only records a command that has a parent span, and a migration run at application startup has none — so without this, the driver's spans for your migrations are simply missing. A lifecycle event can tell you a migration started; only a span opened inside the kit can be the parent of what it does next.
The meter gets migronaut.run.duration, migronaut.migration.duration and
migronaut.lock.acquire.duration (histograms, seconds), plus the counters migronaut.lock.refused
and migronaut.lock.lost. A failure sets the span's status to ERROR — with the message redacted
like every log line — and error.type to the typed error code. A tracer or meter that throws never
fails a run.
Through the BullMQ adapter, add
bullmq: { Queue, Worker, telemetry: new BullMQOtel({ tracerName }) } and one trace runs from the
request that enqueued to the MongoDB commands in the worker. Full guide:
OpenTelemetry.
Structured logging with pino — the logger option is pino-compatible
Anything with { debug, info, warn, error } methods works as logger — including a real pino
instance. When the logger has a pino-style child(), migronaut binds component: 'migronaut'
once, and a throwing logger can never break a migration run:
const pino = require('pino');
const { runMigrations } = require('@alexify/migronaut');
await runMigrations({
uri: process.env.MIGRONAUT_URI,
dbName: 'my_app',
logger: pino({ level: 'info' }),
});Environment variables — the zero-file way to configure everything
Every scalar config option has an environment variable, which is what makes a config file optional rather than merely discouraged:
| Env var | Config key | Default |
|---|---|---|
MIGRONAUT_URI |
uri |
— (required) |
MIGRONAUT_DB |
dbName |
— (required) |
MIGRONAUT_MIGRATIONS_DIR |
migrationsDir |
./migrations |
MIGRONAUT_COLLECTION |
migrationsCollection |
_migronaut_migrations |
MIGRONAUT_LOCK_COLLECTION |
lockCollection |
_migronaut_locks |
MIGRONAUT_CONVERGE_LOG_COLLECTION |
convergeLogCollection |
_migronaut_converge |
MIGRONAUT_LOCK_TTL |
lockTTLSeconds |
60 |
MIGRONAUT_STRICT |
strict |
false |
MIGRONAUT_USE_TRANSACTION |
useTransaction |
false |
MIGRONAUT_SEQUENTIAL |
sequential |
false |
MIGRONAUT_CREATE_EXTENSION |
createExtension |
js |
MIGRONAUT_ENVIRONMENT |
environment |
NODE_ENV, then production |
MIGRONAUT_TEMPLATE_PATH |
templatePath |
— (built-in template) |
MIGRONAUT_TIMEOUT_MS |
timeoutMs |
— (no timeout) |
MIGRONAUT_ON_LOCK_LOST |
onLockLost |
abort |
MIGRONAUT_ON_OUT_OF_ORDER |
onOutOfOrder |
warn |
MIGRONAUT_ENSURE_INDEXES |
ensureIndexes |
true |
MIGRONAUT_RELOAD_MIGRATIONS |
reloadMigrations |
false |
MIGRONAUT_COLLECTIONS_DIR |
collectionsDir |
— (none read) |
MIGRONAUT_CONVERGE_AFTER_UP |
convergeAfterUp |
false |
MIGRONAUT_ON_SEARCH_UNAVAILABLE |
onSearchUnavailable |
fail |
MIGRONAUT_WAIT_FOR_SEARCH_INDEXES |
waitForSearchIndexes |
false |
MIGRONAUT_SEARCH_INDEX_WAIT_TIMEOUT_MS |
searchIndexWaitTimeoutMs |
600000 |
MIGRONAUT_BACKGROUND_COLLECTION |
backgroundCollection |
_migronaut_background |
MIGRONAUT_BACKGROUND_INLINE |
backgroundInline |
false |
MIGRONAUT_BACKGROUND_ON_DRIFT |
backgroundOnDrift |
reopen |
MIGRONAUT_BACKGROUND_DRIFT |
backgroundDrift |
poll |
MIGRONAUT_BACKGROUND_SHARD_AWARE |
backgroundShardAware |
auto |
MIGRONAUT_ENV_FILE |
envFile |
.env |
fileExtensions, clientOptions, collections, client, mongoose, hooks, logger,
generateId and telemetry are config-file/API only — they aren't scalars, so no environment
variable can express them.
A value that doesn't parse is rejected, never coerced: MIGRONAUT_STRICT=on or
MIGRONAUT_LOCK_TTL=abc fails with an error naming the variable, rather than quietly turning a
safety setting off.
Three more variables shape the CLI rather than the config:
| Env var | Effect |
|---|---|
MIGRONAUT_NO_COLOR |
Disable ANSI color for migronaut only; outranks NO_COLOR/FORCE_COLOR |
MIGRONAUT_FORCE_COLOR |
Force color on (0 forces it off); the highest-priority color signal |
MIGRONAUT_USER |
Who to record in executedBy; overrides the OS user (useful in CI) |
The unprefixed NO_COLOR, FORCE_COLOR and TERM=dumb are still honored underneath the prefixed
pair — they're ecosystem-wide conventions, and a CLI is expected to obey them.
.env files are loaded automatically, parsed by Node's built-in util.parseEnv. Real environment
variables always win over .env values. Supported syntax: one KEY=VALUE per line, optional
export prefix, matching single/double/back quotes, full-line and inline # comments, and
multiline values inside double quotes. Not supported: ${VAR} interpolation. Files over 1 MB are
rejected, as is anything that isn't a regular file.
migronaut loads TypeScript and both JavaScript module systems with no extra setup:
// TypeScript / ESM — named exports (native on Node 22.18+, or under a loader like tsx)
export async function up({ db }) { /* ... */ }
export async function down({ db }) { /* ... */ }// CommonJS — default export
module.exports = {
async up({ db }) { /* ... */ },
async down({ db }) { /* ... */ },
};Optional per-file exports: description (shown in status) and useTransaction. Note that up/down
receive a single context object ({ db, client, mongoose?, session? }) — not migrate-mongo's
positional (db, client).
ESM vs CommonJS: Node decides a file's module system from its extension and the nearest
package.json"type". In a project with"type": "module", a.jsfile is an ES module, somodule.exports = …throws "module is not defined in ES module scope." Use namedexports (above), or name the file.cjsand add'.cjs'tofileExtensionsin your config.
Measured with the zero-dependency harness in bench/bench.js (in-process
scenarios: 1s timed run after 2k warmup iterations; DB-bound scenarios against an in-memory
MongoDB replica set: 500ms timed run after 100 warmup iterations). Reproduce with:
pnpm benchApple M3 Max, Node v24.14.1:
| Scenario | ops/sec |
|---|---|
computeChecksum — small migration file (~1 KB) |
~16,000 |
computeChecksum — large migration file (~100 KB) |
~8,000 |
loadMigrationFile — CommonJS default export |
~60,000 |
loadMigrationFile — ESM named exports |
~60,000 |
loadMigrationFile — TypeScript (native type-stripping) |
~60,000 |
Changelog.getAll — 1,000 records |
~20 |
Changelog.getAppliedNames — 1,000 records |
~30 |
Changelog.getLastBatch — 1,000 records |
~200 |
Changelog.markApplied — idempotent upsert |
~150 |
Changelog.markReverted — update existing applied record |
~150 |
MigrationLock.acquire + release — uncontended round trip |
~80 |
MigrationLock.renew — heartbeat update |
~150 |
Numbers vary by hardware and Node version — treat them as relative guidance, not absolutes. The harness exists primarily to catch performance regressions between releases.
MIT © Alexis Technologies
Originally forked from mongo-migrate-kit by Santosh Gupta; MIT, attribution
retained in LICENSE. See
what changed since the fork.