Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -485,14 +525,17 @@ const migratedDown = await down(db, client);
migratedDown.forEach(fileName => console.log('Migrated Down:', fileName));
```

### `status(MongoDb) → Promise<Array<{ fileName, appliedAt }>>`
### `status(MongoDb) → Promise<Array<{ fileName, appliedAt, output? }>>`

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`
Expand Down
10 changes: 7 additions & 3 deletions lib/actions/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions lib/actions/up.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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}`);
}
Expand Down
23 changes: 23 additions & 0 deletions test/actions/status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
100 changes: 99 additions & 1 deletion test/actions/up.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});