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.
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: '' } });
};$ 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:
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.