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