Skip to content

About

Production-grade MongoDB migration toolkit for Node.js with zero runtime dependencies — single-file runs, real rollbacks, dry-run, checksums, hooks, transactions & locking and more. A modern and robust migrate-mongo alternative.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

 
 

Latest commit

 

History

39 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@alexify/migronaut

migronaut

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.

npm CI node dependencies docs license

Precise, safe migrations for MongoDB. Run a single file, roll back anything, and preview every change before it touches your database.

Tip

🔄 Already using migrate-mongo? Switch in under a minute.

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 applied

Your 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


Reasons to choose it

  • Zero dependencies — no runtime dependencies at all; only the mongodb driver 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, or redo.
  • Preview before you run — migronaut dry-run up prints 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 import for a migrate-mongo history, migronaut baseline when 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 converge brings the database to match; no migration file per index change (details).
  • TypeScript, ESM & CommonJS — all run with no ts-node plumbing.
  • Zero config files required — drive everything from env vars if you prefer.
  • Pino-friendly logging — the logger option is pino-compatible; pass a pino instance directly and migronaut logs through it (with a component: 'migronaut' child binding).
  • Migration logs for your users (experimental) — every migration gets ctx.logger, bound to its run (run id, migration, attempt, queue job), and ctx.run; a line marked userland: true is also emitted as migration:log for 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/bullmq runs 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/versioning gives your repository layer updateWithRevision, typed per-version shapes, an upcaster and a Mongoose plugin (guide).
  • Background migrations (experimental) — long data rewrites (export const background = {…}) that up only 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).

How it compares to migrate-mongo

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.

How it compares to mongo-migrate-kit

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


Quick start

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 status

A 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 init and export MIGRONAUT_URI and MIGRONAUT_DB — that is enough to run.


Documentation

Full docs, guides, and the API reference live at migronaut.vercel.app.


Commands

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.


Advanced features

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 since

What it does

  • Reads the source collection (changelog by default; --from to override) and never modifies it — the mapped records are written to the migronaut changelog (your config's migrationsCollection, _migronaut_migrations by default; --to to write to a different collection).
  • Maps fileName → name, appliedAt → appliedAt, and resolves a checksum: it reuses migrate-mongo's fileHash when it matches the file on disk, otherwise recomputes a SHA-256 from disk (--trust-hash reuses 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 migronaut changelog already has records, imported batches continue after the existing maximum (use --force to 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 later migronaut down then 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 1 reverts just the single most-recently applied migration; a larger N can cross batch boundaries, so preview it first with migronaut 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 init is the one command without JSON output — its deliverable is the config file itself. init --format json writes migronaut.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

Run pending migrations on app start

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(...)

Multiple instances booting together

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
);

Serverless / cold start

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.

Fail a deploy/health check when the DB is behind (no writes)

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)`);
}

Full control

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: false and explicit uri/dbName to each — .env loading mutates the shared process.env (dotenv semantics), so different env files could otherwise leak MIGRONAUT_* 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 / listCollections and compares; nothing is recorded. Edit the declaration, converge again.
  • Safe by default. Missing indexes are created, a TTL or hidden change 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 with prune. 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-search waits until the builds are queryable. On a server without Search, converge refuses — or skips them with onSearchUnavailable: 'skip'.
  • Locked like a migration, and refused before the first write when the plan has a conflict.
  • After every deploy with convergeAfterUp: true — a bulk up then 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 CLI migronaut up at 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 reaches mq.kit.on('migration:log', …) with the job's id — keep it in a collection of your own (Migration Logs).

→ Migrations as a Queue · runnable example service


Configuration

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.


Migration file formats

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 .js file is an ES module, so module.exports = … throws "module is not defined in ES module scope." Use named exports (above), or name the file .cjs and add '.cjs' to fileExtensions in your config.


Benchmarks

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 bench

Apple 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.


License

MIT © Alexis Technologies

Originally forked from mongo-migrate-kit by Santosh Gupta; MIT, attribution retained in LICENSE. See what changed since the fork.

About

Production-grade MongoDB migration toolkit for Node.js with zero runtime dependencies — single-file runs, real rollbacks, dry-run, checksums, hooks, transactions & locking and more. A modern and robust migrate-mongo alternative.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages