migrate-mongo

Versioned Migrations with migrate-mongo

MongoDB 1,815 has no ALTER TABLE, so a schema change is a script, and what you need is a record of which scripts have run. migrate-mongo 1,030 (github.com/seppevs/migrate-mongo (https://github.com/seppevs/migrate-mongo 1,030 ), MIT, version 14.0.7) keeps that record in a changelog collection in the same database, locks changelog_lock against a second deploy, and gives every script an up and a down.

npx migrate-mongo init writes migrate-mongo-config.js; read the URL from process.env there, and in an ESM project set moduleSystem: 'esm' or no migration file will load. create add-book-slug then writes migrations/20260922044142-add-book-slug.js, timestamped in UTC so files sort in write order. The script receives the raw driver Db, not your models, so that it keeps working when the schema file changes.

migrations/20260922044142-add-book-slug.jsJavaScript
const slugify = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
export const up = async (db) => {
  const books = db.collection('books');
  const todo = await books.find({ slug: { $exists: false } }).project({ title: 1 }).toArray();
  for (let i = 0; i < todo.length; i += 1000)
    await books.bulkWrite(todo.slice(i, i + 1000).map((b) => ({
      updateOne: { filter: { _id: b._id }, update: { $set: { slug: slugify(b.title) } } }
    })), { ordered: false });
  await books.createIndex({ slug: 1 }, { name: 'slug_1' });
};
export const down = async (db) => {
  await db.collection('books').dropIndex('slug_1');
  await db.collection('books').updateMany({}, { $unset: { slug: '' } });
};
Output
$ npx migrate-mongo up
MIGRATED UP: 20260922044142-add-book-slug.js
$ npx migrate-mongo down
MIGRATED DOWN: 20260922044142-add-book-slug.js

Between the two, status prints a row per file with its Applied At timestamp and a migration block number grouping everything one up applied, so down --block rolls a whole deploy back. After down, all 5,005 books have lost slug, the index is gone and the changelog is empty. The $exists filter is what makes up rerunnable.

When a migration fails halfway

Nothing wraps up in a transaction. This one sets a field on every book, drops the index, then builds a unique index over slugs that are not unique:

Output of 119
ERROR: Could not migrate up 20260922044252-unique-book-slug.js: Index build failed: ...
:: caused by :: E11000 duplicate key error collection: bookshelf_dev.books index: slug_1
dup key: { slug: "1984" }

The process exits 1 and the changelog stays empty for that file — but the database is in neither shape: 5,005 books carry the new field and slug_1 no longer exists. Keep every step safe to repeat, split the data change and the index build into separate migrations, and test up twice in a row before shipping it.