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.
- Zero dependencies — no runtime dependencies at all; only the
mongodbdriver as a peer. 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.
- Lifecycle hooks —
beforeAll,afterAll,beforeEach,afterEach,onError. - Opt-in transactions — wrap a migration so it fully commits or fully aborts.
- 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).
| 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) |
❌ | ✅ |
| SHA-256 checksum / tamper detection | ❌ | ✅ |
| Lifecycle hooks | ❌ | ✅ |
| First-class TypeScript (built-in) | ❌ | ✅ |
| History preserved on rollback (never deleted) | ❌ | ✅ |
Adopt an existing migrate-mongo changelog |
— | ✅ migronaut import |
Reflects migrate-mongo's documented CLI as of mid-2026. 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 | ❌ | ✅ |
| 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
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. → See how it works
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 · 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 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 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, status, list, dry-run, import, 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
# 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 --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)
# 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.
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:
// 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
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)
// ── 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
};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_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_ENSURE_INDEXES |
ensureIndexes |
true |
MIGRONAUT_RELOAD_MIGRATIONS |
reloadMigrations |
false |
MIGRONAUT_ENV_FILE |
envFile |
.env |
fileExtensions, clientOptions, client, mongoose, hooks and logger 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.