From 5ce6b5e34ed08be059ffb07c54681efebacc5b9e Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 14 Jan 2026 21:08:54 +0100 Subject: [PATCH 1/2] feat: persist migration output in changelog collection Allow up() migrations to return JSON-serializable values that are stored in the changelog document under the 'output' field. This enables better auditing and debugging of migrations (e.g., tracking number of modified documents, created IDs, etc.). Closes #472 --- lib/actions/status.js | 10 ++-- lib/actions/up.js | 13 ++++- test/actions/status.test.js | 23 +++++++++ test/actions/up.test.js | 100 +++++++++++++++++++++++++++++++++++- 4 files changed, 140 insertions(+), 6 deletions(-) diff --git a/lib/actions/status.js b/lib/actions/status.js index 3da405a..daeb5ac 100644 --- a/lib/actions/status.js +++ b/lib/actions/status.js @@ -19,13 +19,17 @@ export default async (db) => { fileHash = await migrationsDir.loadFileHash(fileName); findTest = { fileName, fileHash }; } - const itemInLog = changelog.find(item => - item.fileName === findTest.fileName && + const itemInLog = changelog.find(item => + item.fileName === findTest.fileName && (!findTest.fileHash || item.fileHash === findTest.fileHash) ); const appliedAt = itemInLog ? itemInLog.appliedAt.toJSON() : "PENDING"; const migrationBlock = itemInLog ? itemInLog.migrationBlock : undefined; - return useFileHash ? { fileName, fileHash, appliedAt, migrationBlock } : { fileName, appliedAt, migrationBlock }; + const output = itemInLog?.output; + + return useFileHash + ? { fileName, fileHash, appliedAt, migrationBlock, output } + : { fileName, appliedAt, migrationBlock, output }; })); return statusTable; diff --git a/lib/actions/up.js b/lib/actions/up.js index 405e021..0f19a13 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -20,9 +20,10 @@ export default async (db, client) => { } const migrateItem = async item => { + let output; try { const migration = await migrationsDir.loadMigration(item.fileName); - await migration.up(db, client); + output = await migration.up(db, client); } catch (err) { const error = new Error( @@ -43,8 +44,16 @@ export default async (db, client) => { const { fileName, fileHash } = item; const appliedAt = new Date(); + const changelogDoc = useFileHash === true + ? { fileName, fileHash, appliedAt, migrationBlock } + : { fileName, appliedAt, migrationBlock }; + + if (output != null) { + changelogDoc.output = output; + } + try { - await changelogCollection.insertOne(useFileHash === true ? { fileName, fileHash, appliedAt, migrationBlock } : { fileName, appliedAt, migrationBlock }); + await changelogCollection.insertOne(changelogDoc); } catch (err) { throw new Error(`Could not update changelog: ${err.message}`); } diff --git a/test/actions/status.test.js b/test/actions/status.test.js index 91ab03a..d8c1f96 100644 --- a/test/actions/status.test.js +++ b/test/actions/status.test.js @@ -252,4 +252,27 @@ describe("status", () => { } ]); }); + + it("should include output in status when present in changelog", async () => { + const outputData = { count: 42 }; + changelogCollection.find.mockReturnValue({ + toArray: vi.fn().mockResolvedValue([ + { + fileName: "20160509113224-first_migration.js", + appliedAt: new Date("2016-06-03T20:10:12.123Z"), + output: outputData + } + ]) + }); + + const statusItems = await status(db); + + expect(statusItems[0].output).toEqual(outputData); + }); + + it("should have undefined output when not present in changelog", async () => { + const statusItems = await status(db); + + expect(statusItems[0].output).toBeUndefined(); + }); }); diff --git a/test/actions/up.test.js b/test/actions/up.test.js index 2ea0d8e..59199fa 100644 --- a/test/actions/up.test.js +++ b/test/actions/up.test.js @@ -254,7 +254,105 @@ describe("up", () => { changelogLockCollection.find.mockReturnValue({ toArray: vi.fn().mockResolvedValue([{ createdAt: new Date() }]) }); - + await expect(up(db)).rejects.toThrow("Could not migrate up, a lock is in place."); }); + + it("should capture and store migration output in changelog", async () => { + const outputData = { documentsModified: 5, collectionName: "users" }; + firstPendingMigration.up.mockResolvedValue(outputData); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2016-06-09T08:07:00.077Z")); + + await up(db); + + expect(changelogCollection.insertOne).toHaveBeenNthCalledWith(1, { + appliedAt: new Date("2016-06-09T08:07:00.077Z"), + fileName: "20160607173840-first_pending_migration.js", + migrationBlock: 1465459620077, + output: outputData + }); + + vi.useRealTimers(); + }); + + it("should not include output field when migration returns undefined", async () => { + firstPendingMigration.up.mockResolvedValue(undefined); + secondPendingMigration.up.mockResolvedValue(undefined); + + await up(db); + + const insertCall = changelogCollection.insertOne.mock.calls[0][0]; + expect(insertCall).not.toHaveProperty('output'); + }); + + it("should not include output field when migration returns null", async () => { + firstPendingMigration.up.mockResolvedValue(null); + + await up(db); + + const insertCall = changelogCollection.insertOne.mock.calls[0][0]; + expect(insertCall).not.toHaveProperty('output'); + }); + + it("should handle complex nested output objects", async () => { + const complexOutput = { + stats: { inserted: 10, updated: 5 }, + ids: ["abc123", "def456"], + metadata: { version: "1.0" } + }; + firstPendingMigration.up.mockResolvedValue(complexOutput); + + await up(db); + + expect(changelogCollection.insertOne).toHaveBeenNthCalledWith(1, + expect.objectContaining({ + output: complexOutput + }) + ); + }); + + it("should store output together with fileHash when both are enabled", async () => { + vi.spyOn(config, 'read').mockReturnValue({ + changelogCollectionName: "changelog", + lockCollectionName: "changelog_lock", + lockTtl: 0, + useFileHash: true, + }); + changelogLockCollection.find.mockReturnValue({ + toArray: vi.fn().mockResolvedValue([]) + }); + + const outputData = { count: 42 }; + firstPendingMigration.up.mockResolvedValue(outputData); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2016-06-09T08:07:00.077Z")); + + await up(db); + + expect(changelogCollection.insertOne).toHaveBeenNthCalledWith(1, { + appliedAt: new Date("2016-06-09T08:07:00.077Z"), + fileName: "20160607173840-first_pending_migration.js", + fileHash: undefined, + migrationBlock: 1465459620077, + output: outputData + }); + + vi.useRealTimers(); + }); + + it("should store falsy values like 0 and false as output", async () => { + firstPendingMigration.up.mockResolvedValue(0); + secondPendingMigration.up.mockResolvedValue(false); + + await up(db); + + const firstInsertCall = changelogCollection.insertOne.mock.calls[0][0]; + const secondInsertCall = changelogCollection.insertOne.mock.calls[1][0]; + + expect(firstInsertCall.output).toBe(0); + expect(secondInsertCall.output).toBe(false); + }); }); From e222d84fa0e802163fd3c5bae03818ed2130c8d5 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 14 Jan 2026 21:16:59 +0100 Subject: [PATCH 2/2] docs: add migration output persistence documentation - Add "Capturing migration output" section to README - Update status() API signature to include output field - Add changelog entry for the feature --- CHANGELOG.md | 6 ++++++ README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 181a51b..bea6c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [Unreleased] +- Add migration output persistence feature ([#472](https://github.com/seppevs/migrate-mongo/issues/472)) + - Migrations can now return values that are stored in the changelog + - Useful for auditing (e.g., tracking modified document counts) + - Output available via `status()` API and in changelog collection + ## [14.0.7] - 2025-12-03 - Reorganize test mocks structure - Move __mocks__ directory from project root to test directory diff --git a/README.md b/README.md index 64cf984..7745af3 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,46 @@ Now the status will also include the file hash in the output ``` +### Capturing migration output +Migrations can return values that will be stored in the changelog collection. This is useful for auditing and debugging purposes (e.g., tracking number of modified documents). + +````javascript +module.exports = { + async up(db) { + const result = await db.collection('albums').updateMany( + { artist: 'The Beatles' }, + { $set: { blacklisted: true } } + ); + return { matchedCount: result.matchedCount, modifiedCount: result.modifiedCount }; + }, + + async down(db) { + await db.collection('albums').updateMany( + { artist: 'The Beatles' }, + { $set: { blacklisted: false } } + ); + } +}; +```` + +The returned value will be stored in the `output` field of the changelog document: + +````json +{ + "fileName": "20160608155948-blacklist_the_beatles.js", + "appliedAt": "2016-06-08T20:13:30.415Z", + "output": { + "matchedCount": 42, + "modifiedCount": 42 + } +} +```` + +Notes: +- Only `up()` output is persisted (since `down()` deletes the changelog entry) +- Returning `undefined` or `null` will not add an `output` field +- The returned value must be JSON-serializable + ### Version To know which version of migrate-mongo you're running, just pass the `version` option: @@ -485,14 +525,17 @@ const migratedDown = await down(db, client); migratedDown.forEach(fileName => console.log('Migrated Down:', fileName)); ``` -### `status(MongoDb) → Promise>` +### `status(MongoDb) → Promise>` -Check which migrations are applied (or not. +Check which migrations are applied (or not). ```javascript const { db } = await database.connect(); const migrationStatus = await status(db); -migrationStatus.forEach(({ fileName, appliedAt }) => console.log(fileName, ':', appliedAt)); +migrationStatus.forEach(({ fileName, appliedAt, output }) => { + console.log(fileName, ':', appliedAt); + if (output) console.log(' Output:', output); +}); ``` ### `client.close() → Promise`