Skip to content

Repository files navigation

mongo-migrate-kit

mongo-migrate-kit

Elegant, fast, TypeScript-first MongoDB migrations for Node.js.

A modern, drop-in replacement for migrate-mongo etc.

npm version Docs Node Known Vulnerabilities License: MIT

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


Reasons to choose it

  • Run a single migrationmmk 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 runmmk 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.
  • Lifecycle hooksbeforeAll, 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-node plumbing.
  • Zero config files required — drive everything from env vars if you prefer.

How it compares to migrate-mongo

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

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

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 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. → See how it works


Quick start

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 status

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


Documentation

Full docs, guides, and the API reference live at mongo-migrate-kit.vercel.app.


Commands

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


Advanced features

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 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 mmk changelog (your config's migrationsCollection, _mmk_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 mmk 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 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. mmk detects a standalone deployment up front and fails fast with TRANSACTIONS_UNSUPPORTED before 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 later mmk down then 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 1 reverts just the single most-recently applied migration; a larger N can cross batch boundaries, so preview it first with mmk 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 — with strict: 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, use mmk up <file> --force.
  • down — the rollback verifies the checksum first and refuses to run if the file drifted, regardless of strict (running edited rollback code against production is the riskiest case). The whole rollback aborts up front so nothing is left half-reverted. Use mmk down --force (it prompts for confirmation; --yes to skip) to roll back the drifted file anyway with its current down().
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 --json is the one exception — there --json means "write mmk.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

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

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.MONGO_URI!, dbName: 'my_app' },
  { onLockHeld: 'wait', lockWaitTimeoutMs: 30_000 }, // default 'throw'
);

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

Full control

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.


Configuration

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. mmk constructs the MongoClient with safe defaults (serverSelectionTimeoutMS/connectTimeoutMS of 10s and retryWrites) 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 with w: 'majority' (and the lock is read with readConcern: 'majority'), independent of your URI. Use mongoClientOptions to 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.


Migration file formats

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


License

MIT © guptasantosh327

About

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

Topics

Resources

Stars

17 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages