Elegant, fast, TypeScript-first MongoDB migrations for Node.js.
A modern, drop-in replacement for migrate-mongo etc.
Precise, safe migrations for MongoDB. Run a single file, roll back anything, and preview every change before it touches your database.
- Run a single migration —
mmk 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 —
mmk 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.
| Capability | migrate-mongo |
mongo-migrate-kit |
|---|---|---|
| Run a single migration file | ❌ | ✅ |
| Roll back a specific batch (not just the last) | ❌ | ✅ |
| Dry-run preview | ❌ | ✅ |
redo (down + up) |
❌ | ✅ |
| Checksum / tamper detection | ❌ | ✅ |
| Lifecycle hooks | ❌ | ✅ |
| First-class TypeScript (built-in) | ❌ | ✅ |
| History kept on rollback (never deleted) | ❌ | ✅ |
Adopt an existing migrate-mongo changelog |
— | ✅ mmk import |
Reflects migrate-mongo's documented CLI as of mid-2026. It has since added transaction access
via a client argument; mmk exposes the same plus a declarative per-file useTransaction flag.
Tip
mmk 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:
mmk import # one-time: adopt your migrate-mongo changelog (it's never modified)
mmk 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 mongo-migrate-kit
npm install mongodb # required peer dependency# 1 · create a configuration file mmk.config.*. (pass --ts if need ts file)
npx mmk init
# 2 · create your first migration
npx mmk create "add users email index"
# 3 · run everything pending
npx mmk up
# 4 · see where you stand
npx mmk statusA migration is just an up and a down:
import type { MigrationContext } from 'mongo-migrate-kit';
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
mmk initand exportMMK_URIandMMK_DB— that is enough to run.
Full docs, guides, and the API reference live at mongo-migrate-kit.vercel.app.
- Why mongo-migrate-kit? — 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, and --config.
| Command | What it does |
|---|---|
mmk init |
Create a documented mmk.config.* in the current directory |
mmk import |
Adopt an existing migrate-mongo changelog (one-time, forward-only) |
mmk create <name> |
Generate a timestamped migration file |
mmk up [file] |
Run all pending migrations, or one named file |
mmk down [file] |
Roll back the last batch, a chosen batch, the last N steps, or one file |
mmk redo [file] |
Roll back then re-apply (the last migration, or one file) |
mmk status |
Print the full migration status table (--check to fail CI on pending) |
mmk list |
List migrations, filtered by status |
mmk dry-run <up|down> [file] |
Preview a run without touching the database |
mmk unlock |
Force-release a stuck lock left behind by a crashed run |
Most data commands (up, down, redo, status, list, dry-run, import, create,
unlock) accept --json for machine-readable output — see CI & automation.
Options for every command
# init — generate a config file
mmk init # mmk.config.js (default)
mmk init --js # mmk.config.js (explicit default)
mmk init --ts # mmk.config.ts
mmk init --json # mmk.config.json (NOTE: here --json picks the file format)
mmk init --secret-provider # async config that loads the URI from a secret manager (js/ts only)
mmk init --force # overwrite an existing config file
mmk init --uri mongodb://localhost:27017 --db my_app # prefill the generated config
# import — adopt an existing migrate-mongo changelog
mmk import # read `changelog`, write the mmk changelog
mmk import --from <name> # read a differently-named source collection
mmk import --to <name> # write to a specific collection (default: config migrationsCollection)
mmk import --dry-run # preview the mapping, write nothing
mmk import --trust-hash # reuse migrate-mongo's fileHash instead of recomputing
mmk import --force # proceed even if the mmk changelog already has records
mmk import --no-lock # skip the concurrency lock (local dev only)
mmk import --json # machine-readable output
# create — generate a migration file
mmk create <name> # file type follows config `createExtension` (default .js)
mmk create <name> --ts # force a .ts file
mmk create <name> --js # force a .js file
mmk create <name> --template <path> # use a custom template
mmk create <name> --json # machine-readable output ({ "path": "..." })
# up — apply migrations
mmk up # all pending (one shared batch for the run)
mmk up <file> # one specific file
mmk up --step # apply each file as its own batch (revert individually later)
mmk up <file> --force # re-run an ALREADY-applied file (asks for confirmation)
mmk up <file> --force --yes # confirm a re-run non-interactively (required with --json)
mmk up --strict # abort on any checksum mismatch
mmk up --no-lock # skip the concurrency lock (local dev only)
mmk up --json # machine-readable output (array of run results)
# down — roll back
mmk down # the last batch (may be several files)
mmk down <file> # one specific file
mmk down --batch <n> # a specific batch number
mmk down --steps <n> # the last N migrations, newest first, ignoring batches
mmk down --force # roll back even if a file drifted (asks for confirmation)
mmk down --force --yes # confirm a forced rollback non-interactively (required with --json)
mmk down --no-lock # skip the concurrency lock (local dev only)
mmk down --json # machine-readable output (array of run results)
# redo — down then up
mmk redo # the most recently applied migration
mmk redo <file> # a specific file
mmk redo --json # machine-readable output (array of run results)
# status — full status table
mmk status # the full status table
mmk status --check # exit 1 if any migration is pending (CI gate)
mmk status --json # machine-readable output (array of status rows)
# list — filtered status
mmk list # all migrations
mmk list --pending # only pending
mmk list --applied # only applied
mmk list --json # machine-readable output (array of status rows)
# dry-run — preview, never writes
mmk dry-run up [file]
mmk dry-run down [file]
mmk dry-run down --steps <n> # preview a step rollback (the last N migrations)
mmk dry-run up --json # machine-readable output (array of status rows)
# unlock — clear a stuck lock after a crash
mmk unlock # shows the holder, prompts y/N
mmk unlock --yes # skip the prompt
mmk unlock --json # machine-readable output ({ "released": ..., "holder": ... })Global flags (available on all commands): --uri <uri> (override MMK_URI),
--db <name> (override MMK_DB), --dir <path> (override MMK_MIGRATIONS_DIR),
--config <path> (explicit config file, overrides auto-discovery).
--json is accepted by every data command above (up, down, redo, status, list,
dry-run, import, create, unlock) and prints one JSON document to stdout — see
CI & automation. On mmk init only, --json instead selects the config
file format (mmk.config.json).
Migrating from migrate-mongo — adopt an existing changelog with mmk import
mmk import reads your existing migrate-mongo changelog and records that history in the mmk
changelog, so mmk up knows what is already applied and runs only what is new. It is a one-time,
forward-only step.
# point mmk at the same database, then:
mmk import --dry-run # preview the mapping first (writes nothing)
mmk import # adopt the history
mmk 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 themmkchangelog (your config'smigrationsCollection,_mmk_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
mmkchangelog 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
mmk 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 (_mmk_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 mmk does not execute (it passes a single context object). To avoid
ever corrupting your data, mmk down / mmk 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 mmk, 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.
mmkdetects a standalone deployment up front and fails fast withTRANSACTIONS_UNSUPPORTEDbefore running the migration, instead of erroring half-way at commit.
The changelog record is written inside the transaction, with w: 'majority'. So a migration and
its "applied" record commit (or roll back) atomically — there is no window where the data is
committed but unrecorded, which would otherwise let the migration re-run on the next deploy. For
non-idempotent migrations against production, prefer useTransaction: true for exactly this reason.
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.
mmk 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:
// mmk.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 mmk 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 mmk up run. By default every migration applied in a single run shares one batch
number, so mmk 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:
mmk up --step— apply each file in the run as its own sequential batch instead of one shared batch. A latermmk downthen peels them off one at a time.mmk down --steps <n>— revert the last N applied migrations, newest first, counted as individual files regardless of batch.mmk down --steps 1reverts just the single most-recently applied migration; a larger N can cross batch boundaries, so preview it first withmmk 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 _mmk_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. The lock collection is read and written with
w: 'majority' / readConcern: 'majority' so the mutual-exclusion guarantee survives a primary
failover. --no-lock bypasses it for local development (and warns loudly). If a process crashes hard
and leaves a lock behind, clear it with mmk 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 mmk compares the
two and surfaces drift in status.
up— withstrict: true(or--strict) a mismatch on an already-applied file aborts the run; otherwise it warns and skips. To intentionally re-run an edited, already-applied file, usemmk up <file> --force.down— the rollback verifies the checksum first and refuses to run if the file drifted, regardless ofstrict(running edited rollback code against production is the riskiest case). The whole rollback aborts up front so nothing is left half-reverted. Usemmk down --force(it prompts for confirmation;--yesto skip) to roll back the drifted file anyway with its currentdown().
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, 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
mmk up --json | jq '.[] | select(.status == "applied") | .file'
# Fail a deploy step if the database isn't fully migrated
mmk status --check # exits 1 when anything is pending, 0 otherwise
# Inspect status as data
mmk 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 mmk status --check --uri "$MONGO_URI" --db "$MONGO_DB"Note:
mmk init --jsonis the one exception — there--jsonmeans "writemmk.config.json", not machine-readable output (kept for backwards compatibility).
Audit trail — a complete, append-only history
Every record in _mmk_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 'mongo-migrate-kit';
// Call this before your server starts listening.
const { applied, upToDate } = await runMigrations({
uri: process.env.MONGO_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.MONGO_URI!, dbName: 'my_app' },
{ onLockHeld: 'wait', lockWaitTimeoutMs: 30_000 }, // default 'throw'
);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 'mongo-migrate-kit';
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 'mongo-migrate-kit';
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 MmkError and carry a typed code (LOCK_ALREADY_HELD, CHECKSUM_MISMATCH,
NOT_APPLIED, …), so catch blocks stay type-safe.
mmk 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 mmk.config.ts,
mmk.config.js, or mmk.config.json. Run mmk init to generate one — it ships fully commented,
so every setting lives in one documented place:
// mmk.config.js — generated by `mmk init`, every option explained
/** @type {import('mongo-migrate-kit').MmkConfig} */
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 `mmk create` ('js' | 'ts'); --js/--ts override
sequential: false, // true → 0001-style numbering instead of timestamps
// templatePath: './migration.template.ts', // custom template for `mmk create`
// ── Bookkeeping collections ─────────────────────────────────────────────
migrationsCollection: '_mmk_migrations', // the append-only audit trail
lockCollection: '_mmk_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 mmk.config.json) ─────────────────────────
// hooks: { beforeAll, afterAll, beforeEach, afterEach, onError },
// mongoose: myMongooseInstance, // pass if your migrations use Mongoose models
// logger: null, // null silences all output (handy in CI/tests)
// mongoClientOptions: { // extra MongoClient options for connection hardening
// tls: true, // (TLS, timeouts, auth, read/write prefs).
// serverSelectionTimeoutMS: 10000, // mmk applies safe defaults; these override them.
// },
};Connection hardening.
mmkconstructs theMongoClientwith safe defaults (serverSelectionTimeoutMS/connectTimeoutMSof 10s andretryWrites) so a bad URI fails fast. It deliberately does not force a client-wide write concern — your migrations keep whatever durability your URI/cluster specifies. Durability is enforced only where it matters: the bookkeeping collections (_mmk_locks,_mmk_migrations) are always written withw: 'majority'(and the lock is read withreadConcern: 'majority'), independent of your URI. UsemongoClientOptionsto enable TLS, tune timeouts, or set auth/read-preference for your production cluster — it's merged last, so it overrides the defaults above.
Environment variables — the zero-file way to configure everything
| Env var | Config key | Default |
|---|---|---|
MMK_URI |
uri |
— (required) |
MMK_DB |
dbName |
— (required) |
MMK_MIGRATIONS_DIR |
migrationsDir |
./migrations |
MMK_COLLECTION |
migrationsCollection |
_mmk_migrations |
MMK_LOCK_COLLECTION |
lockCollection |
_mmk_locks |
MMK_LOCK_TTL |
lockTTLSeconds |
60 |
MMK_STRICT |
strict |
false |
MMK_USE_TRANSACTION |
useTransaction |
false |
MMK_SEQUENTIAL |
sequential |
false |
MMK_CREATE_EXTENSION |
createExtension |
js |
.env files are loaded automatically.
mmk 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.
MIT © guptasantosh327