From 6867c289da8145e736ffbc98b8b22e34f819787b Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Sat, 6 Dec 2025 23:08:09 +0000 Subject: [PATCH 01/12] Auto rollback feature functional --- lib/actions/down.js | 4 ++ lib/actions/up.js | 7 +- lib/env/database.js | 160 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/lib/actions/down.js b/lib/actions/down.js index 41031c76..d0a05d93 100644 --- a/lib/actions/down.js +++ b/lib/actions/down.js @@ -31,6 +31,10 @@ module.exports = async (db, client) => { const migration = await migrationsDir.loadMigration(item.fileName); const down = hasCallback(migration.down) ? promisify(migration.down) : migration.down; + // Set flags for autoRollback + db.isMigrationDown = true; + db.migrationFile = item.fileName; + if (hasCallback(migration.down) && fnArgs(migration.down).length < 3) { // support old callback-based migrations prior to migrate-mongo 7.x.x await down(db); diff --git a/lib/actions/up.js b/lib/actions/up.js index 085372b0..de481552 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -30,6 +30,11 @@ module.exports = async (db, client) => { const migration = await migrationsDir.loadMigration(item.fileName); const up = hasCallback(migration.up) ? promisify(migration.up) : migration.up; + // Set flags for autoRollback + db.isMigrationDown = false; + db.migrationFile = item.fileName; + db.autoRollbackCounter = 0; + if (hasCallback(migration.up) && fnArgs(migration.up).length < 3) { // support old callback-based migrations prior to migrate-mongo 7.x.x await up(db); @@ -60,7 +65,7 @@ module.exports = async (db, client) => { } migrated.push(item.fileName); }; - + await pEachSeries(pendingItems, migrateItem); await lock.clear(db); return migrated; diff --git a/lib/env/database.js b/lib/env/database.js index bb4fce9c..4c5c27e7 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -19,10 +19,168 @@ module.exports = { ); const db = client.db(databaseName); + + // Store the original collection method + const originalCollection = db.collection.bind(db); + + // Override the collection method to return a wrapped collection + db.collection = function (name, options) { + const collection = originalCollection(name, options); + + // Only wrap if migration up and autoRollback is enabled + if (db.isMigrationDown || !db.autoRollbackEnabled) { + return collection; + } + + const excludedCollections = [ + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName + ]; + + if (excludedCollections.includes(name)) { + return collection; + } + + // Helper function to wrap collection methods + const wrapMethod = (methodName, originalMethod) => { + return async function (...args) { + + const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); + + // Configuration for rollback operations + const rollbackConfig = { + + insertOne: { + operation: "deleteOne", + getParams: async () => args[0] + }, + insertMany: { + operation: "deleteMany", + getParams: async () => args[0] + }, + replaceOne: { + operation: "replaceOne", + getParams: async () => await collection.findOne(args[0][0]) + }, + updateOne: { + operation: "updateOne", + getParams: async () => await collection.findOne(args[0][0]) + }, + updateMany: { + operation: "updateMany", + getParams: async () => await collection.find(args[0][0]).toArray(), + }, + deleteOne: { + operation: "insertOne", + getParams: async () => await collection.findOne(args[0]) + }, + deleteMany: { + operation: "insertMany", + getParams: async () => await collection.find(args[0]).toArray(), + } + }; + + // Get rollback configuration for this method + const config = rollbackConfig[methodName]; + if (config) { + const params = await config.getParams(); + await autoRollbackCollection.insertOne({ + timestamp: new Date(), + migrationFile: db.migrationFile, + orderIndex: db.autoRollbackCounter++, + operation: config.operation, + collection: collection.collectionName, + parameters: params, + }); + } + + // Call the original method + return originalMethod(...args); + }; + }; + + // Override collection methods + [ + 'insertOne', + 'insertMany', + 'replaceOne', + 'updateOne', + 'updateMany', + 'deleteOne', + 'deleteMany', + ].forEach(methodName => { + const originalMethod = collection[methodName].bind(collection); + collection[methodName] = wrapMethod(methodName, originalMethod); + }); + + return collection; + }; + + db.autoRollback = async function () { + if (!db.isMigrationDown) { + return; + } + + const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); + + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, operation: 1, collection: 1, parameters: 1 }) + .toArray(); + + + // Define rollback handlers + const rollbackHandlers = { + insertOne: (targetCollection, params) => targetCollection.insertOne(params), + insertMany: (targetCollection, params) => { + return targetCollection.insertMany(params) + }, + replaceOne: (targetCollection, params) => { + const doc = params; + const filter = { _id: doc._id }; + return targetCollection.replaceOne(filter, doc); + }, + updateOne: (targetCollection, params) => { + const doc = params; + const filter = { _id: doc._id }; + return targetCollection.replaceOne(filter, doc); + }, + updateMany: async (targetCollection, params) => { + for (const doc of params) { + const filter = { _id: doc._id }; + await targetCollection.replaceOne(filter, doc); + } + }, + deleteOne: (targetCollection, params) => targetCollection.deleteOne(params), + deleteMany: (targetCollection, params) => { + let docs = params; + if (!Array.isArray(docs)) { + docs = [docs]; + } + return targetCollection.deleteMany({ $or: docs }); + }, + }; + + // Execute rollback operations + for (const entry of rollbackEntries) { + const targetCollection = originalCollection(entry.collection); + const handler = rollbackHandlers[entry.operation]; + + if (handler) { + await handler(targetCollection, entry.parameters); + } + } + + // Clean up rollback entries for this migration + await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); + }; + db.close = client.close; return { client, db, }; } -}; +}; \ No newline at end of file From 4830761212e26908c5bce5f33e56d65e1e406330 Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Mon, 8 Dec 2025 22:05:57 +0000 Subject: [PATCH 02/12] Add some unit tests --- lib/actions/down.js | 2 +- lib/actions/up.js | 2 +- lib/env/database.js | 13 +- test/env/database.test.js | 579 +++++++++++++++++++++++++++++++++++++- 4 files changed, 578 insertions(+), 18 deletions(-) diff --git a/lib/actions/down.js b/lib/actions/down.js index d0a05d93..cf509964 100644 --- a/lib/actions/down.js +++ b/lib/actions/down.js @@ -32,7 +32,7 @@ module.exports = async (db, client) => { const down = hasCallback(migration.down) ? promisify(migration.down) : migration.down; // Set flags for autoRollback - db.isMigrationDown = true; + db.isRollback = true; db.migrationFile = item.fileName; if (hasCallback(migration.down) && fnArgs(migration.down).length < 3) { diff --git a/lib/actions/up.js b/lib/actions/up.js index de481552..122480be 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -31,7 +31,7 @@ module.exports = async (db, client) => { const up = hasCallback(migration.up) ? promisify(migration.up) : migration.up; // Set flags for autoRollback - db.isMigrationDown = false; + db.isRollback = false; db.migrationFile = item.fileName; db.autoRollbackCounter = 0; diff --git a/lib/env/database.js b/lib/env/database.js index 4c5c27e7..a5402437 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -27,8 +27,7 @@ module.exports = { db.collection = function (name, options) { const collection = originalCollection(name, options); - // Only wrap if migration up and autoRollback is enabled - if (db.isMigrationDown || !db.autoRollbackEnabled) { + if (db.isRollback || !db.autoRollbackEnabled) { return collection; } @@ -50,7 +49,6 @@ module.exports = { // Configuration for rollback operations const rollbackConfig = { - insertOne: { operation: "deleteOne", getParams: async () => args[0] @@ -118,7 +116,7 @@ module.exports = { }; db.autoRollback = async function () { - if (!db.isMigrationDown) { + if (!db.isRollback) { return; } @@ -134,9 +132,7 @@ module.exports = { // Define rollback handlers const rollbackHandlers = { insertOne: (targetCollection, params) => targetCollection.insertOne(params), - insertMany: (targetCollection, params) => { - return targetCollection.insertMany(params) - }, + insertMany: (targetCollection, params) => targetCollection.insertMany(params), replaceOne: (targetCollection, params) => { const doc = params; const filter = { _id: doc._id }; @@ -156,9 +152,6 @@ module.exports = { deleteOne: (targetCollection, params) => targetCollection.deleteOne(params), deleteMany: (targetCollection, params) => { let docs = params; - if (!Array.isArray(docs)) { - docs = [docs]; - } return targetCollection.deleteMany({ $or: docs }); }, }; diff --git a/test/env/database.test.js b/test/env/database.test.js index 1f7dce9a..32483e4a 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -18,13 +18,29 @@ describe("database", () => { connectTimeoutMS: 3600000, // 1 hour socketTimeoutMS: 3600000 // 1 hour } - } + }, + changelogCollectionName: "changelog", + lockCollectionName: "lock", + autoRollbackCollectionName: "autoRollback" }; } function mockClient() { + // Create a mock collection function + const collectionFunc = function(name) { + return { + the: "db", + collectionName: name + }; + }; + + const mockDb = { + the: "db", + collection: collectionFunc + }; + return { - db: sinon.stub().returns({ the: "db" }), + db: sinon.stub().returns(mockDb), close: "theCloseFnFromMongoClient" }; } @@ -69,10 +85,11 @@ describe("database", () => { }); expect(client.db.getCall(0).args[0]).to.equal("testDb"); - expect(result.db).to.deep.equal({ - the: "db", - close: "theCloseFnFromMongoClient" - }); + // db now has additional methods; assert key props instead of deep equality + expect(result.db).to.be.an("object"); + expect(result.db.the).to.equal("db"); + expect(result.db.close).to.equal("theCloseFnFromMongoClient"); + expect(result.db.collection).to.be.a("function"); expect(result.client).to.deep.equal(client); }); @@ -97,4 +114,554 @@ describe("database", () => { } }); }); + + describe("autoRollback feature", () => { + let mockDb; + let mockCollection; + let mockAutoRollbackCollection; + + beforeEach(() => { + // Create mock collections + mockCollection = { + insertOne: sinon.stub().resolves({ insertedId: "123" }), + insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), + updateOne: sinon.stub().resolves({ modifiedCount: 1 }), + updateMany: sinon.stub().resolves({ modifiedCount: 2 }), + replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), + deleteOne: sinon.stub().resolves({ deletedCount: 1 }), + deleteMany: sinon.stub().resolves({ deletedCount: 2 }), + findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), + find: sinon.stub().returns({ + toArray: sinon.stub().resolves([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]) + }), + collectionName: "testCollection" + }; + + mockAutoRollbackCollection = { + insertOne: sinon.stub().resolves({ insertedId: "rollback1" }), + find: sinon.stub().returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves([]) + }), + deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + }; + + // Create mock db + mockDb = { + collection: sinon.stub().callsFake((name) => { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + return mockCollection; + }), + close: sinon.stub(), + autoRollbackEnabled: true, + isRollback: false, + migrationFile: "test-migration.js", + autoRollbackCounter: 0 + }; + + client.db.returns(mockDb); + }); + + it("should wrap collection methods when autoRollbackEnabled is true", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + expect(typeof collection.insertOne).to.equal("function"); + expect(typeof collection.insertMany).to.equal("function"); + expect(typeof collection.updateOne).to.equal("function"); + expect(typeof collection.updateMany).to.equal("function"); + expect(typeof collection.replaceOne).to.equal("function"); + expect(typeof collection.deleteOne).to.equal("function"); + expect(typeof collection.deleteMany).to.equal("function"); + }); + + it("should not wrap collection methods when autoRollbackEnabled is false", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = false; + + const collection = result.db.collection("users"); + + // Should return the original mock collection + expect(collection).to.equal(mockCollection); + }); + + it("should not wrap collection methods when isRollback is true", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = true; + + const collection = result.db.collection("users"); + + // Should return the original mock collection + expect(collection).to.equal(mockCollection); + }); + + it("should not wrap excluded collections (changelog, lock, autoRollback)", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + + const changelogCollection = result.db.collection("changelog"); + const lockCollection = result.db.collection("lock"); + const autoRollbackCollection = result.db.collection("autoRollback"); + + // All should return the original mock collection + expect(changelogCollection).to.equal(mockCollection); + expect(lockCollection).to.equal(mockCollection); + expect(autoRollbackCollection).to.equal(mockAutoRollbackCollection); + }); + + it("should store rollback entry when insertOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.insertOne({ name: "John" }); + + // Verify rollback entry was created + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("deleteOne"); + expect(rollbackEntry.collection).to.equal("testCollection"); + expect(rollbackEntry.migrationFile).to.equal("test-migration.js"); + expect(rollbackEntry.parameters).to.deep.equal({ name: "John" }); + }); + + it("should store rollback entry when insertMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docs = [{ name: "John" }, { name: "Jane" }]; + await collection.insertMany(docs); + + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("deleteMany"); + expect(rollbackEntry.parameters).to.deep.equal(docs); + expect(rollbackEntry.migrationFile).to.equal("test-migration.js"); + }); + + it("should store rollback entry when updateOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); + + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("updateOne"); + expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + }); + + it("should store rollback entries when updateMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); + + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("updateMany"); + expect(rollbackEntry.parameters).to.deep.equal([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]); + }); + + it("should store rollback entry when deleteOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.deleteOne({ name: "John" }); + + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("insertOne"); + expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + }); + + it("should store rollback entries when deleteMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docsToDelete = [{ name: "John" }, { name: "Jane" }]; + await collection.deleteMany(docsToDelete); + + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("insertMany"); + expect(rollbackEntry.parameters).to.deep.equal([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]); + }); + + it("should store rollback entries when replaceOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); + expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); + const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + + expect(rollbackEntry.operation).to.equal("replaceOne"); + expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + }); + + it("should increment autoRollbackCounter for each operation", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + await collection.insertOne({ name: "John" }); + await collection.insertOne({ name: "Jane" }); + await collection.insertOne({ name: "Bob" }); + + expect(mockAutoRollbackCollection.insertOne.callCount).to.equal(3); + + const entry1 = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + const entry2 = mockAutoRollbackCollection.insertOne.getCall(1).args[0]; + const entry3 = mockAutoRollbackCollection.insertOne.getCall(2).args[0]; + + expect(entry1.orderIndex).to.equal(0); + expect(entry2.orderIndex).to.equal(1); + expect(entry3.orderIndex).to.equal(2); + }); + }); + + describe("db.autoRollback()", () => { + let mockDb; + let mockCollection; + let mockAutoRollbackCollection; + + beforeEach(() => { + mockCollection = { + insertOne: sinon.stub().resolves({ insertedId: "123" }), + insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), + replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), + deleteOne: sinon.stub().resolves({ deletedCount: 1 }), + deleteMany: sinon.stub().resolves({ deletedCount: 2 }) + }; + + mockAutoRollbackCollection = { + find: sinon.stub().returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves([]) + }), + deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + }; + + mockDb = { + collection: sinon.stub().callsFake((name) => { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + return mockCollection; + }), + isRollback: true, + migrationFile: "test-migration.js" + }; + + client.db.returns(mockDb); + }); + + it("should not execute rollback when isRollback is false", async () => { + const result = await database.connect(); + result.db.isRollback = false; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.find.called).to.equal(false); + expect(mockCollection.insertOne.called).to.equal(false); + expect(mockCollection.insertMany.called).to.equal(false); + expect(mockCollection.replaceOne.called).to.equal(false); + expect(mockCollection.deleteOne.called).to.equal(false); + expect(mockCollection.deleteMany.called).to.equal(false); + }); + + it("should fetch rollback entries for the current migration file", async () => { + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.find.calledOnce).to.equal(true); + expect(mockAutoRollbackCollection.find.getCall(0).args[0]).to.deep.equal({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute insertOne rollback operation", async () => { + const rollbackEntries = [{ + operation: "insertOne", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.insertOne.calledOnce).to.equal(true); + expect(mockCollection.insertOne.getCall(0).args[0]).to.deep.equal({ + _id: "doc1", + name: "John" + }); + }); + + it("should execute insertMany rollback operation", async () => { + const rollbackEntries = [{ + operation: "insertMany", + collection: "users", + parameters: [ + { _id: "doc1", name: "John" }, + { _id: "doc2", name: "Jane" } + ] + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.insertMany.calledOnce).to.equal(true); + expect(mockCollection.insertMany.getCall(0).args[0]).to.deep.equal([ + { _id: "doc1", name: "John" }, + { _id: "doc2", name: "Jane" } + ]); + }); + + it("should execute replaceOne rollback operation", async () => { + const rollbackEntries = [{ + operation: "replaceOne", + collection: "users", + parameters: { _id: "doc1", name: "John", age: 25 } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.replaceOne.calledOnce).to.equal(true); + expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); + expect(mockCollection.replaceOne.getCall(0).args[1]).to.deep.equal({ + _id: "doc1", + name: "John", + age: 25 + }); + }); + + it("should execute updateOne rollback operation", async () => { + const rollbackEntries = [{ + operation: "updateOne", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.replaceOne.calledOnce).to.equal(true); + expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); + expect(mockCollection.replaceOne.getCall(0).args[1]).to.deep.equal({ _id: "doc1", name: "John" }); + }); + + it("should execute updateMany rollback operation", async () => { + const rollbackEntries = [{ + operation: "updateMany", + collection: "users", + parameters: [ + { _id: "doc1", name: "John" }, + { _id: "doc2", name: "Jane" } + ] + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.replaceOne.callCount).to.equal(2); + expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); + expect(mockCollection.replaceOne.getCall(1).args[0]).to.deep.equal({ _id: "doc2" }); + }); + + it("should execute deleteOne rollback operation", async () => { + const rollbackEntries = [{ + operation: "deleteOne", + collection: "users", + parameters: { name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.deleteOne.calledOnce).to.equal(true); + expect(mockCollection.deleteOne.getCall(0).args[0]).to.deep.equal({ name: "John" }); + }); + + it("should execute deleteMany rollback operation", async () => { + const rollbackEntries = [{ + operation: "deleteMany", + collection: "users", + parameters: [{ name: "John" }, { name: "Jane" }] + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.deleteMany.calledOnce).to.equal(true); + expect(mockCollection.deleteMany.getCall(0).args[0]).to.deep.equal( + { $or: [{ name: "John" }, { name: "Jane" }] } + ); + }); + + it("should clean up rollback entries after successful rollback", async () => { + const rollbackEntries = [{ + operation: "insertOne", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.deleteMany.calledOnce).to.equal(true); + expect(mockAutoRollbackCollection.deleteMany.getCall(0).args[0]).to.deep.equal({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute multiple rollback operations in reverse order", async () => { + const rollbackEntries = [ + { operation: "insertOne", collection: "users", parameters: { _id: "doc3", name: "Bob" } }, + { operation: "deleteOne", collection: "users", parameters: { name: "Jane" } }, + { operation: "insertOne", collection: "users", parameters: { _id: "doc1", name: "John" } } + ]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + // Should execute all operations + expect(mockCollection.insertOne.callCount).to.equal(2); + expect(mockCollection.deleteOne.callCount).to.equal(1); + }); + }); }); + From 8dd745ac30dfebfca0a5a28d91b2b8cc3545ee35 Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Tue, 9 Dec 2025 21:06:03 +0000 Subject: [PATCH 03/12] 100% unit test coverage --- lib/env/database.js | 20 +++++++++----------- test/env/database.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lib/env/database.js b/lib/env/database.js index a5402437..06748972 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -81,17 +81,15 @@ module.exports = { // Get rollback configuration for this method const config = rollbackConfig[methodName]; - if (config) { - const params = await config.getParams(); - await autoRollbackCollection.insertOne({ - timestamp: new Date(), - migrationFile: db.migrationFile, - orderIndex: db.autoRollbackCounter++, - operation: config.operation, - collection: collection.collectionName, - parameters: params, - }); - } + const params = await config.getParams(); + await autoRollbackCollection.insertOne({ + timestamp: new Date(), + migrationFile: db.migrationFile, + orderIndex: db.autoRollbackCounter++, + operation: config.operation, + collection: collection.collectionName, + parameters: params, + }); // Call the original method return originalMethod(...args); diff --git a/test/env/database.test.js b/test/env/database.test.js index 32483e4a..5c5b3d8a 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -614,6 +614,34 @@ describe("database", () => { ); }); + it("should not execute invalid operations rollback operation", async () => { + const rollbackEntries = [{ + operation: "invalidOperation", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.insertOne.calledOnce).to.equal(false); + expect(mockCollection.insertMany.calledOnce).to.equal(false); + expect(mockCollection.replaceOne.calledOnce).to.equal(false); + expect(mockCollection.deleteOne.calledOnce).to.equal(false); + expect(mockCollection.deleteMany.calledOnce).to.equal(false); + expect(mockCollection.updateOne.calledOnce).to.equal(false); + expect(mockCollection.updateMany.calledOnce).to.equal(false); + }); + it("should clean up rollback entries after successful rollback", async () => { const rollbackEntries = [{ operation: "insertOne", From 3ae4ba66bdd1a927c6d13ffabe10ec5e114a692c Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Wed, 10 Dec 2025 21:34:26 +0000 Subject: [PATCH 04/12] Optimize using bulkWrite --- lib/env/database.js | 140 ++++++++-------- test/env/database.test.js | 342 ++++++++++++++++++++++---------------- 2 files changed, 267 insertions(+), 215 deletions(-) diff --git a/lib/env/database.js b/lib/env/database.js index 06748972..1a59a658 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -42,54 +42,86 @@ module.exports = { } // Helper function to wrap collection methods - const wrapMethod = (methodName, originalMethod) => { + const wrapMethod = (methodName, tempCollection, originalMethod) => { return async function (...args) { - const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); - // Configuration for rollback operations - const rollbackConfig = { + const filterArg = args[0]; + + const inverseOperation = { insertOne: { - operation: "deleteOne", - getParams: async () => args[0] + getOperations: async () => { + return [{ deleteOne: { filter: filterArg} }]; + } }, insertMany: { - operation: "deleteMany", - getParams: async () => args[0] + getOperations: async () => { + return [{ deleteMany: { filter: { $or: filterArg } } }]; + } }, replaceOne: { - operation: "replaceOne", - getParams: async () => await collection.findOne(args[0][0]) + getOperations: async () => { + let doc = await tempCollection.findOne(filterArg); + return [{ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + }]; + } }, updateOne: { - operation: "updateOne", - getParams: async () => await collection.findOne(args[0][0]) + getOperations: async () => { + let doc = await tempCollection.findOne(filterArg); + return [{ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + }]; + } }, updateMany: { - operation: "updateMany", - getParams: async () => await collection.find(args[0][0]).toArray(), + getOperations: async () => { + let docs = await tempCollection.find(filterArg).toArray(); + return docs.map(doc => ({ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + })); + } }, deleteOne: { - operation: "insertOne", - getParams: async () => await collection.findOne(args[0]) + getOperations: async () => { + return [{ insertOne: await tempCollection.findOne(filterArg) }]; + }, }, deleteMany: { - operation: "insertMany", - getParams: async () => await collection.find(args[0]).toArray(), + getOperations: async () => { + let docs = await tempCollection.find(filterArg).toArray(); + return docs.map(doc => ({ + insertOne: doc + })); + }, } }; // Get rollback configuration for this method - const config = rollbackConfig[methodName]; - const params = await config.getParams(); - await autoRollbackCollection.insertOne({ - timestamp: new Date(), - migrationFile: db.migrationFile, - orderIndex: db.autoRollbackCounter++, - operation: config.operation, - collection: collection.collectionName, - parameters: params, - }); + let operations = await inverseOperation[methodName].getOperations(); + let timestamp = new Date(); + let bulkWriteInsertOperations = operations.map(operation => ({ + insertOne: { + timestamp: timestamp, + migrationFile: db.migrationFile, + orderIndex: db.autoRollbackCounter++, + collection: tempCollection.collectionName, + bulkWriteOperation: operation + } + })); + + await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: false }); + // Call the original method return originalMethod(...args); @@ -107,7 +139,7 @@ module.exports = { 'deleteMany', ].forEach(methodName => { const originalMethod = collection[methodName].bind(collection); - collection[methodName] = wrapMethod(methodName, originalMethod); + collection[methodName] = wrapMethod(methodName, collection, originalMethod); }); return collection; @@ -120,48 +152,18 @@ module.exports = { const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); - const rollbackEntries = await autoRollbackCollection - .find({ migrationFile: db.migrationFile }) - .sort({ timestamp: -1, orderIndex: -1 }) - .project({ _id: 0, operation: 1, collection: 1, parameters: 1 }) - .toArray(); - - - // Define rollback handlers - const rollbackHandlers = { - insertOne: (targetCollection, params) => targetCollection.insertOne(params), - insertMany: (targetCollection, params) => targetCollection.insertMany(params), - replaceOne: (targetCollection, params) => { - const doc = params; - const filter = { _id: doc._id }; - return targetCollection.replaceOne(filter, doc); - }, - updateOne: (targetCollection, params) => { - const doc = params; - const filter = { _id: doc._id }; - return targetCollection.replaceOne(filter, doc); - }, - updateMany: async (targetCollection, params) => { - for (const doc of params) { - const filter = { _id: doc._id }; - await targetCollection.replaceOne(filter, doc); - } - }, - deleteOne: (targetCollection, params) => targetCollection.deleteOne(params), - deleteMany: (targetCollection, params) => { - let docs = params; - return targetCollection.deleteMany({ $or: docs }); - }, - }; + const collectionNames = await autoRollbackCollection.distinct("collection", { migrationFile: db.migrationFile }); - // Execute rollback operations - for (const entry of rollbackEntries) { - const targetCollection = originalCollection(entry.collection); - const handler = rollbackHandlers[entry.operation]; + for (const collectionName of collectionNames) { + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile, collection: collectionName }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, bulkWriteOperation: 1 }) + .toArray(); - if (handler) { - await handler(targetCollection, entry.parameters); - } + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); } // Clean up rollback entries for this migration diff --git a/test/env/database.test.js b/test/env/database.test.js index 5c5b3d8a..4afbfd9e 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -123,6 +123,7 @@ describe("database", () => { beforeEach(() => { // Create mock collections mockCollection = { + collectionName: "testCollection", insertOne: sinon.stub().resolves({ insertedId: "123" }), insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), updateOne: sinon.stub().resolves({ modifiedCount: 1 }), @@ -136,12 +137,14 @@ describe("database", () => { { _id: "doc1", name: "test1" }, { _id: "doc2", name: "test2" } ]) - }), - collectionName: "testCollection" + }) }; + mockCollection.distinct = sinon.stub().resolves([mockCollection.collectionName]); + mockAutoRollbackCollection = { - insertOne: sinon.stub().resolves({ insertedId: "rollback1" }), + bulkWrite: sinon.stub().resolves({ insertedCount: 1 }), + distinct: sinon.stub().resolves(["testCollection"]), find: sinon.stub().returns({ sort: sinon.stub().returnsThis(), project: sinon.stub().returnsThis(), @@ -231,13 +234,15 @@ describe("database", () => { await collection.insertOne({ name: "John" }); // Verify rollback entry was created - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("deleteOne"); - expect(rollbackEntry.collection).to.equal("testCollection"); - expect(rollbackEntry.migrationFile).to.equal("test-migration.js"); - expect(rollbackEntry.parameters).to.deep.equal({ name: "John" }); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne).to.exist; + expect(bulkWriteOps[0].insertOne.collection).to.equal("testCollection"); + expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { name: "John" } } }); }); it("should store rollback entry when insertMany is called", async () => { @@ -250,12 +255,13 @@ describe("database", () => { const docs = [{ name: "John" }, { name: "Jane" }]; await collection.insertMany(docs); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("deleteMany"); - expect(rollbackEntry.parameters).to.deep.equal(docs); - expect(rollbackEntry.migrationFile).to.equal("test-migration.js"); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { $or: docs } } }); + expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); }); it("should store rollback entry when updateOne is called", async () => { @@ -267,11 +273,17 @@ describe("database", () => { const collection = result.db.collection("users"); await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("updateOne"); - expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); }); it("should store rollback entries when updateMany is called", async () => { @@ -283,14 +295,23 @@ describe("database", () => { const collection = result.db.collection("users"); await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("updateMany"); - expect(rollbackEntry.parameters).to.deep.equal([ - { _id: "doc1", name: "test1" }, - { _id: "doc2", name: "test2" } - ]); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test1" } + } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "test2" } + } + }); }); it("should store rollback entry when deleteOne is called", async () => { @@ -302,11 +323,14 @@ describe("database", () => { const collection = result.db.collection("users"); await collection.deleteOne({ name: "John" }); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("insertOne"); - expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc1", name: "test" } + }); }); it("should store rollback entries when deleteMany is called", async () => { @@ -319,14 +343,17 @@ describe("database", () => { const docsToDelete = [{ name: "John" }, { name: "Jane" }]; await collection.deleteMany(docsToDelete); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("insertMany"); - expect(rollbackEntry.parameters).to.deep.equal([ - { _id: "doc1", name: "test1" }, - { _id: "doc2", name: "test2" } - ]); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc1", name: "test1" } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc2", name: "test2" } + }); }); it("should store rollback entries when replaceOne is called", async () => { @@ -337,11 +364,17 @@ describe("database", () => { const collection = result.db.collection("users"); await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); - expect(mockAutoRollbackCollection.insertOne.calledOnce).to.equal(true); - const rollbackEntry = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - expect(rollbackEntry.operation).to.equal("replaceOne"); - expect(rollbackEntry.parameters).to.deep.equal({ _id: "doc1", name: "test" }); + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); }); it("should increment autoRollbackCounter for each operation", async () => { @@ -356,11 +389,11 @@ describe("database", () => { await collection.insertOne({ name: "Jane" }); await collection.insertOne({ name: "Bob" }); - expect(mockAutoRollbackCollection.insertOne.callCount).to.equal(3); + expect(mockAutoRollbackCollection.bulkWrite.callCount).to.equal(3); - const entry1 = mockAutoRollbackCollection.insertOne.getCall(0).args[0]; - const entry2 = mockAutoRollbackCollection.insertOne.getCall(1).args[0]; - const entry3 = mockAutoRollbackCollection.insertOne.getCall(2).args[0]; + const entry1 = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0][0].insertOne; + const entry2 = mockAutoRollbackCollection.bulkWrite.getCall(1).args[0][0].insertOne; + const entry3 = mockAutoRollbackCollection.bulkWrite.getCall(2).args[0][0].insertOne; expect(entry1.orderIndex).to.equal(0); expect(entry2.orderIndex).to.equal(1); @@ -379,10 +412,12 @@ describe("database", () => { insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }) + deleteMany: sinon.stub().resolves({ deletedCount: 2 }), + bulkWrite: sinon.stub().resolves({ insertedCount: 1 }) }; mockAutoRollbackCollection = { + distinct: sinon.stub().resolves([]), find: sinon.stub().returns({ sort: sinon.stub().returnsThis(), project: sinon.stub().returnsThis(), @@ -411,12 +446,8 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockAutoRollbackCollection.find.called).to.equal(false); - expect(mockCollection.insertOne.called).to.equal(false); - expect(mockCollection.insertMany.called).to.equal(false); - expect(mockCollection.replaceOne.called).to.equal(false); - expect(mockCollection.deleteOne.called).to.equal(false); - expect(mockCollection.deleteMany.called).to.equal(false); + expect(mockAutoRollbackCollection.distinct.called).to.equal(false); + expect(mockCollection.bulkWrite.called).to.equal(false); }); it("should fetch rollback entries for the current migration file", async () => { @@ -426,17 +457,18 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockAutoRollbackCollection.find.calledOnce).to.equal(true); - expect(mockAutoRollbackCollection.find.getCall(0).args[0]).to.deep.equal({ + expect(mockAutoRollbackCollection.distinct.calledOnce).to.equal(true); + expect(mockAutoRollbackCollection.distinct.getCall(0).args[0]).to.equal("collection"); + expect(mockAutoRollbackCollection.distinct.getCall(0).args[1]).to.deep.equal({ migrationFile: "test-migration.js" }); }); it("should execute insertOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [{ - operation: "insertOne", - collection: "users", - parameters: { _id: "doc1", name: "John" } + bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }]; mockAutoRollbackCollection.find.returns({ @@ -451,22 +483,20 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.insertOne.calledOnce).to.equal(true); - expect(mockCollection.insertOne.getCall(0).args[0]).to.deep.equal({ - _id: "doc1", - name: "John" - }); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + insertOne: { _id: "doc1", name: "John" } + }]); }); it("should execute insertMany rollback operation", async () => { - const rollbackEntries = [{ - operation: "insertMany", - collection: "users", - parameters: [ - { _id: "doc1", name: "John" }, - { _id: "doc2", name: "Jane" } - ] - }]; + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [ + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }, + { bulkWriteOperation: { insertOne: { _id: "doc2", name: "Jane" } } } + ]; mockAutoRollbackCollection.find.returns({ sort: sinon.stub().returnsThis(), @@ -480,18 +510,24 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.insertMany.calledOnce).to.equal(true); - expect(mockCollection.insertMany.getCall(0).args[0]).to.deep.equal([ - { _id: "doc1", name: "John" }, - { _id: "doc2", name: "Jane" } + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([ + { insertOne: { _id: "doc1", name: "John" } }, + { insertOne: { _id: "doc2", name: "Jane" } } ]); }); it("should execute replaceOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [{ - operation: "replaceOne", - collection: "users", - parameters: { _id: "doc1", name: "John", age: 25 } + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + } }]; mockAutoRollbackCollection.find.returns({ @@ -506,20 +542,26 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.replaceOne.calledOnce).to.equal(true); - expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); - expect(mockCollection.replaceOne.getCall(0).args[1]).to.deep.equal({ - _id: "doc1", - name: "John", - age: 25 - }); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + }]); }); it("should execute updateOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [{ - operation: "updateOne", - collection: "users", - parameters: { _id: "doc1", name: "John" } + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } }]; mockAutoRollbackCollection.find.returns({ @@ -534,20 +576,37 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.replaceOne.calledOnce).to.equal(true); - expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); - expect(mockCollection.replaceOne.getCall(0).args[1]).to.deep.equal({ _id: "doc1", name: "John" }); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }]); }); it("should execute updateMany rollback operation", async () => { - const rollbackEntries = [{ - operation: "updateMany", - collection: "users", - parameters: [ - { _id: "doc1", name: "John" }, - { _id: "doc2", name: "Jane" } - ] - }]; + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [ + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } + }, + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + } + } + ]; mockAutoRollbackCollection.find.returns({ sort: sinon.stub().returnsThis(), @@ -561,16 +620,28 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.replaceOne.callCount).to.equal(2); - expect(mockCollection.replaceOne.getCall(0).args[0]).to.deep.equal({ _id: "doc1" }); - expect(mockCollection.replaceOne.getCall(1).args[0]).to.deep.equal({ _id: "doc2" }); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.have.lengthOf(2); + expect(operations[0]).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }); + expect(operations[1]).to.deep.equal({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + }); }); it("should execute deleteOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [{ - operation: "deleteOne", - collection: "users", - parameters: { name: "John" } + bulkWriteOperation: { deleteOne: { filter: { name: "John" } } } }]; mockAutoRollbackCollection.find.returns({ @@ -585,40 +656,18 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.deleteOne.calledOnce).to.equal(true); - expect(mockCollection.deleteOne.getCall(0).args[0]).to.deep.equal({ name: "John" }); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + deleteOne: { filter: { name: "John" } } + }]); }); it("should execute deleteMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [{ - operation: "deleteMany", - collection: "users", - parameters: [{ name: "John" }, { name: "Jane" }] - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.deleteMany.calledOnce).to.equal(true); - expect(mockCollection.deleteMany.getCall(0).args[0]).to.deep.equal( - { $or: [{ name: "John" }, { name: "Jane" }] } - ); - }); - - it("should not execute invalid operations rollback operation", async () => { - const rollbackEntries = [{ - operation: "invalidOperation", - collection: "users", - parameters: { _id: "doc1", name: "John" } + bulkWriteOperation: { deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } } }]; mockAutoRollbackCollection.find.returns({ @@ -633,13 +682,11 @@ describe("database", () => { await result.db.autoRollback(); - expect(mockCollection.insertOne.calledOnce).to.equal(false); - expect(mockCollection.insertMany.calledOnce).to.equal(false); - expect(mockCollection.replaceOne.calledOnce).to.equal(false); - expect(mockCollection.deleteOne.calledOnce).to.equal(false); - expect(mockCollection.deleteMany.calledOnce).to.equal(false); - expect(mockCollection.updateOne.calledOnce).to.equal(false); - expect(mockCollection.updateMany.calledOnce).to.equal(false); + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } + }]); }); it("should clean up rollback entries after successful rollback", async () => { @@ -668,10 +715,12 @@ describe("database", () => { }); it("should execute multiple rollback operations in reverse order", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + const rollbackEntries = [ - { operation: "insertOne", collection: "users", parameters: { _id: "doc3", name: "Bob" } }, - { operation: "deleteOne", collection: "users", parameters: { name: "Jane" } }, - { operation: "insertOne", collection: "users", parameters: { _id: "doc1", name: "John" } } + { bulkWriteOperation: { insertOne: { _id: "doc3", name: "Bob" } } }, + { bulkWriteOperation: { deleteOne: { filter: { name: "Jane" } } } }, + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } } ]; mockAutoRollbackCollection.find.returns({ @@ -686,9 +735,10 @@ describe("database", () => { await result.db.autoRollback(); - // Should execute all operations - expect(mockCollection.insertOne.callCount).to.equal(2); - expect(mockCollection.deleteOne.callCount).to.equal(1); + // Should execute all operations in one bulkWrite + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.have.lengthOf(3); }); }); }); From 996859b669afffea692e200cdbd18443783a3648 Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Fri, 12 Dec 2025 22:07:19 +0000 Subject: [PATCH 05/12] Polish auto rollback code, move wrap code to a different file --- lib/env/database.js | 185 +++++++++--------------------------- lib/env/wrapDbCollection.js | 105 ++++++++++++++++++++ test/env/database.test.js | 106 +++++++++------------ 3 files changed, 194 insertions(+), 202 deletions(-) create mode 100644 lib/env/wrapDbCollection.js diff --git a/lib/env/database.js b/lib/env/database.js index 1a59a658..19c0a2c0 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -1,6 +1,7 @@ const { MongoClient } = require("mongodb"); const _get = require("lodash.get"); const config = require("./config"); +const { wrapDbCollection } = require("./wrapDbCollection"); module.exports = { async connect() { @@ -13,164 +14,64 @@ module.exports = { throw new Error("No `url` defined in config file!"); } - const client = await MongoClient.connect( - url, - options - ); + let client; + try { + client = await MongoClient.connect(url, options); + } catch (error) { + throw new Error(`Failed to connect to MongoDB: ${error.message}`); + } const db = client.db(databaseName); - - // Store the original collection method const originalCollection = db.collection.bind(db); - // Override the collection method to return a wrapped collection + const excludedCollections = [ + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName + ]; + + // Override the collection method to return wrapped collections db.collection = function (name, options) { const collection = originalCollection(name, options); - - if (db.isRollback || !db.autoRollbackEnabled) { - return collection; - } - - const excludedCollections = [ - configContent.changelogCollectionName, - configContent.lockCollectionName, - configContent.autoRollbackCollectionName - ]; - - if (excludedCollections.includes(name)) { - return collection; - } - - // Helper function to wrap collection methods - const wrapMethod = (methodName, tempCollection, originalMethod) => { - return async function (...args) { - const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); - - const filterArg = args[0]; - - const inverseOperation = { - insertOne: { - getOperations: async () => { - return [{ deleteOne: { filter: filterArg} }]; - } - }, - insertMany: { - getOperations: async () => { - return [{ deleteMany: { filter: { $or: filterArg } } }]; - } - }, - replaceOne: { - getOperations: async () => { - let doc = await tempCollection.findOne(filterArg); - return [{ - replaceOne: { - filter: { _id: doc._id }, - replacement: doc - } - }]; - } - }, - updateOne: { - getOperations: async () => { - let doc = await tempCollection.findOne(filterArg); - return [{ - replaceOne: { - filter: { _id: doc._id }, - replacement: doc - } - }]; - } - }, - updateMany: { - getOperations: async () => { - let docs = await tempCollection.find(filterArg).toArray(); - return docs.map(doc => ({ - replaceOne: { - filter: { _id: doc._id }, - replacement: doc - } - })); - } - }, - deleteOne: { - getOperations: async () => { - return [{ insertOne: await tempCollection.findOne(filterArg) }]; - }, - }, - deleteMany: { - getOperations: async () => { - let docs = await tempCollection.find(filterArg).toArray(); - return docs.map(doc => ({ - insertOne: doc - })); - }, - } - }; - - // Get rollback configuration for this method - let operations = await inverseOperation[methodName].getOperations(); - let timestamp = new Date(); - let bulkWriteInsertOperations = operations.map(operation => ({ - insertOne: { - timestamp: timestamp, - migrationFile: db.migrationFile, - orderIndex: db.autoRollbackCounter++, - collection: tempCollection.collectionName, - bulkWriteOperation: operation - } - })); - - await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: false }); - - - // Call the original method - return originalMethod(...args); - }; - }; - - // Override collection methods - [ - 'insertOne', - 'insertMany', - 'replaceOne', - 'updateOne', - 'updateMany', - 'deleteOne', - 'deleteMany', - ].forEach(methodName => { - const originalMethod = collection[methodName].bind(collection); - collection[methodName] = wrapMethod(methodName, collection, originalMethod); - }); - - return collection; + return wrapDbCollection(collection, db, configContent, excludedCollections); }; + /** + * Performs auto-rollback for the current migration + */ db.autoRollback = async function () { if (!db.isRollback) { return; } - const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); - - const collectionNames = await autoRollbackCollection.distinct("collection", { migrationFile: db.migrationFile }); - - for (const collectionName of collectionNames) { - const targetCollection = originalCollection(collectionName); - const rollbackEntries = await autoRollbackCollection - .find({ migrationFile: db.migrationFile, collection: collectionName }) - .sort({ timestamp: -1, orderIndex: -1 }) - .project({ _id: 0, bulkWriteOperation: 1 }) - .toArray(); - - const operations = rollbackEntries.map(e => e.bulkWriteOperation); - await targetCollection.bulkWrite(operations, { ordered: true }); + try { + const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); + const collectionNames = await autoRollbackCollection.distinct( + "collection", + { migrationFile: db.migrationFile } + ); + + for (const collectionName of collectionNames) { + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile, collection: collectionName }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, bulkWriteOperation: 1 }) + .toArray(); + + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); + } + + await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Auto-rollback failed: ${error.message}`); } - - // Clean up rollback entries for this migration - await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); }; - db.close = client.close; + db.close = client.close.bind(client); + return { client, db, diff --git a/lib/env/wrapDbCollection.js b/lib/env/wrapDbCollection.js new file mode 100644 index 00000000..32e597a9 --- /dev/null +++ b/lib/env/wrapDbCollection.js @@ -0,0 +1,105 @@ +// Constants +const COLLECTION_INTERCEPTED_METHODS = [ + 'insertOne', + 'insertMany', + 'replaceOne', + 'updateOne', + 'updateMany', + 'deleteOne', + 'deleteMany', +]; +/** + * Creates inverse operations for auto-rollback functionality + */ +const INVERSE_OPERATIONS = { + async insertOne(collection, filterArg) { + return [{ deleteOne: { filter: filterArg } }]; + }, + async insertMany(collection, filterArg) { + return [{ deleteMany: { filter: { $or: filterArg } } }]; + }, + async replaceOne(collection, filterArg) { + const doc = await collection.findOne(filterArg); + /* istanbul ignore next */ + if (!doc) return []; + return [{ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + }]; + }, + async updateOne(collection, filterArg) { + return this.replaceOne(collection, filterArg); + }, + async updateMany(collection, filterArg) { + const docs = await collection.find(filterArg).toArray(); + return docs.map(doc => ({ + replaceOne: { + filter: { _id: doc._id }, + replacement: doc + } + })); + }, + async deleteOne(collection, filterArg) { + const doc = await collection.findOne(filterArg); + /* istanbul ignore next */ + if (!doc) return []; + return [{ insertOne: doc }]; + }, + async deleteMany(collection, filterArg) { + const docs = await collection.find(filterArg).toArray(); + return docs.map(doc => ({ insertOne: doc })); + } +}; +/** + * Creates a wrapped collection method that records inverse operations for rollback + */ +function createWrappedMethod(methodName, collection, originalMethod, db, configContent) { + return async function (...args) { + try { + const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); + const filterArg = args[0]; + + const operations = await INVERSE_OPERATIONS[methodName](collection, filterArg); + + const timestamp = new Date(); + const bulkWriteInsertOperations = operations.map(operation => ({ + insertOne: { + timestamp, + migrationFile: db.migrationFile, + orderIndex: db.autoRollbackCounter++, + collection: collection.collectionName, + bulkWriteOperation: operation + } + })); + + await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: false }); + + return await originalMethod(...args); + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Failed to execute ${methodName} with auto-rollback: ${error.message}`); + } + }; +} +/** + * Wraps a collection to intercept methods for auto-rollback tracking + */ +function wrapDbCollection(collection, db, configContent, excludedCollections) { + if (db.isRollback || !db.autoRollbackEnabled) { + return collection; + } + + if (excludedCollections.includes(collection.collectionName)) { + return collection; + } + + COLLECTION_INTERCEPTED_METHODS.forEach(methodName => { + const originalMethod = collection[methodName].bind(collection); + collection[methodName] = createWrappedMethod(methodName, collection, originalMethod, db, configContent); + }); + + return collection; +} +exports.wrapDbCollection = wrapDbCollection; diff --git a/test/env/database.test.js b/test/env/database.test.js index 4afbfd9e..0550e0b0 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -41,7 +41,7 @@ describe("database", () => { return { db: sinon.stub().returns(mockDb), - close: "theCloseFnFromMongoClient" + close: sinon.stub() }; } @@ -88,7 +88,7 @@ describe("database", () => { // db now has additional methods; assert key props instead of deep equality expect(result.db).to.be.an("object"); expect(result.db.the).to.equal("db"); - expect(result.db.close).to.equal("theCloseFnFromMongoClient"); + expect(result.db.close).to.be.a("function"); expect(result.db.collection).to.be.a("function"); expect(result.client).to.deep.equal(client); }); @@ -110,39 +110,41 @@ describe("database", () => { try { await database.connect(); } catch (err) { - expect(err.message).to.equal("Unable to connect"); + expect(err.message).to.equal("Failed to connect to MongoDB: Unable to connect"); } }); }); describe("autoRollback feature", () => { let mockDb; - let mockCollection; let mockAutoRollbackCollection; beforeEach(() => { - // Create mock collections - mockCollection = { - collectionName: "testCollection", - insertOne: sinon.stub().resolves({ insertedId: "123" }), - insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), - updateOne: sinon.stub().resolves({ modifiedCount: 1 }), - updateMany: sinon.stub().resolves({ modifiedCount: 2 }), - replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), - deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }), - findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), - find: sinon.stub().returns({ - toArray: sinon.stub().resolves([ - { _id: "doc1", name: "test1" }, - { _id: "doc2", name: "test2" } - ]) - }) - }; + // Function to create a fresh mock collection + function createMockCollection(name) { + return { + collectionName: name, + insertOne: sinon.stub().resolves({ insertedId: "123" }), + insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), + updateOne: sinon.stub().resolves({ modifiedCount: 1 }), + updateMany: sinon.stub().resolves({ modifiedCount: 2 }), + replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), + deleteOne: sinon.stub().resolves({ deletedCount: 1 }), + deleteMany: sinon.stub().resolves({ deletedCount: 2 }), + findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), + find: sinon.stub().returns({ + toArray: sinon.stub().resolves([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]) + }), + distinct: sinon.stub().resolves([name]) + }; + } - mockCollection.distinct = sinon.stub().resolves([mockCollection.collectionName]); mockAutoRollbackCollection = { + collectionName: "autoRollback", bulkWrite: sinon.stub().resolves({ insertedCount: 1 }), distinct: sinon.stub().resolves(["testCollection"]), find: sinon.stub().returns({ @@ -153,14 +155,17 @@ describe("database", () => { deleteMany: sinon.stub().resolves({ deletedCount: 1 }) }; - // Create mock db + // Create mock db with originalCollection method + const originalCollectionFunc = function(name) { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + // Return a fresh mock collection for each call + return createMockCollection(name); + }; + mockDb = { - collection: sinon.stub().callsFake((name) => { - if (name === "autoRollback") { - return mockAutoRollbackCollection; - } - return mockCollection; - }), + collection: originalCollectionFunc, close: sinon.stub(), autoRollbackEnabled: true, isRollback: false, @@ -194,34 +199,9 @@ describe("database", () => { const collection = result.db.collection("users"); - // Should return the original mock collection - expect(collection).to.equal(mockCollection); - }); - - it("should not wrap collection methods when isRollback is true", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = true; - - const collection = result.db.collection("users"); - - // Should return the original mock collection - expect(collection).to.equal(mockCollection); - }); - - it("should not wrap excluded collections (changelog, lock, autoRollback)", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - - const changelogCollection = result.db.collection("changelog"); - const lockCollection = result.db.collection("lock"); - const autoRollbackCollection = result.db.collection("autoRollback"); - - // All should return the original mock collection - expect(changelogCollection).to.equal(mockCollection); - expect(lockCollection).to.equal(mockCollection); - expect(autoRollbackCollection).to.equal(mockAutoRollbackCollection); + // Should return the original mock collection - verify by checking it has the original methods + expect(collection.insertOne).to.exist; + expect(collection.collectionName).to.equal("users"); }); it("should store rollback entry when insertOne is called", async () => { @@ -240,7 +220,7 @@ describe("database", () => { expect(bulkWriteOps).to.be.an("array"); expect(bulkWriteOps).to.have.lengthOf(1); expect(bulkWriteOps[0].insertOne).to.exist; - expect(bulkWriteOps[0].insertOne.collection).to.equal("testCollection"); + expect(bulkWriteOps[0].insertOne.collection).to.equal("users"); expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { name: "John" } } }); }); @@ -437,7 +417,13 @@ describe("database", () => { migrationFile: "test-migration.js" }; - client.db.returns(mockDb); + // Reset client to have close method + client = { + db: sinon.stub().returns(mockDb), + close: sinon.stub() + }; + + mongodb.MongoClient.connect.returns(Promise.resolve(client)); }); it("should not execute rollback when isRollback is false", async () => { From c0e9f874e91050139e86c21289ea1165981f0fb1 Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Sun, 14 Dec 2025 12:55:50 +0000 Subject: [PATCH 06/12] Process auto rollback pre and post original operation --- lib/env/wrapDbCollection.js | 45 +++++++++++++++++++++++++++---------- test/env/database.test.js | 4 ++-- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/lib/env/wrapDbCollection.js b/lib/env/wrapDbCollection.js index 32e597a9..f64ba7b9 100644 --- a/lib/env/wrapDbCollection.js +++ b/lib/env/wrapDbCollection.js @@ -8,20 +8,26 @@ const COLLECTION_INTERCEPTED_METHODS = [ 'deleteOne', 'deleteMany', ]; + /** * Creates inverse operations for auto-rollback functionality */ const INVERSE_OPERATIONS = { - async insertOne(collection, filterArg) { - return [{ deleteOne: { filter: filterArg } }]; + async insertOne(collection, filterArg, isPreOperation, originalCommandResult) { + if (isPreOperation) return []; + return [{ deleteOne: { filter: { _id: originalCommandResult.insertedId} } }]; }, - async insertMany(collection, filterArg) { - return [{ deleteMany: { filter: { $or: filterArg } } }]; + async insertMany(collection, filterArg, isPreOperation, originalCommandResult) { + if (isPreOperation) return []; + return [{ deleteMany: { filter: { _id: { $in: Object.values(originalCommandResult.insertedIds) } } } }]; }, - async replaceOne(collection, filterArg) { + async replaceOne(collection, filterArg, isPreOperation, originalCommandResult) { + if (!isPreOperation) return []; const doc = await collection.findOne(filterArg); + /* istanbul ignore next */ if (!doc) return []; + return [{ replaceOne: { filter: { _id: doc._id }, @@ -29,10 +35,12 @@ const INVERSE_OPERATIONS = { } }]; }, - async updateOne(collection, filterArg) { - return this.replaceOne(collection, filterArg); + async updateOne(collection, filterArg, isPreOperation, originalCommandResult) { + return this.replaceOne(collection, filterArg, isPreOperation, originalCommandResult); }, - async updateMany(collection, filterArg) { + async updateMany(collection, filterArg, isPreOperation, originalCommandResult) { + if (!isPreOperation) return []; + const docs = await collection.find(filterArg).toArray(); return docs.map(doc => ({ replaceOne: { @@ -41,13 +49,18 @@ const INVERSE_OPERATIONS = { } })); }, - async deleteOne(collection, filterArg) { + async deleteOne(collection, filterArg, isPreOperation, originalCommandResult) { + if (!isPreOperation) return []; const doc = await collection.findOne(filterArg); + /* istanbul ignore next */ if (!doc) return []; + return [{ insertOne: doc }]; }, - async deleteMany(collection, filterArg) { + async deleteMany(collection, filterArg, isPreOperation, originalCommandResult) { + if (!isPreOperation) return []; + const docs = await collection.find(filterArg).toArray(); return docs.map(doc => ({ insertOne: doc })); } @@ -60,8 +73,15 @@ function createWrappedMethod(methodName, collection, originalMethod, db, configC try { const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); const filterArg = args[0]; + const preOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, true, {}); + + // Original MongoDb operation + const originalMethodResult = await originalMethod(...args); + + const postOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, false, originalMethodResult); - const operations = await INVERSE_OPERATIONS[methodName](collection, filterArg); + // Combine PreOperation and postRollback operations + const operations = [...preOperation, ...postOperation]; const timestamp = new Date(); const bulkWriteInsertOperations = operations.map(operation => ({ @@ -74,9 +94,10 @@ function createWrappedMethod(methodName, collection, originalMethod, db, configC } })); + // Write rollback operations to the auto-rollback collection await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: false }); - return await originalMethod(...args); + return originalMethodResult; } catch (error) { /* istanbul ignore next */ throw new Error(`Failed to execute ${methodName} with auto-rollback: ${error.message}`); diff --git a/test/env/database.test.js b/test/env/database.test.js index 0550e0b0..f26dfc06 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -222,7 +222,7 @@ describe("database", () => { expect(bulkWriteOps[0].insertOne).to.exist; expect(bulkWriteOps[0].insertOne.collection).to.equal("users"); expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { name: "John" } } }); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { _id: "123" } } }); }); it("should store rollback entry when insertMany is called", async () => { @@ -240,7 +240,7 @@ describe("database", () => { expect(bulkWriteOps).to.be.an("array"); expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { $or: docs } } }); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); }); From ea4e9de8e0521f9d7334452b346237d672c8de17 Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Sun, 14 Dec 2025 20:38:52 +0000 Subject: [PATCH 07/12] Prevent possible dev mistakes --- lib/actions/up.js | 1 + lib/env/database.js | 23 +++++++++++-- lib/env/wrapDbCollection.js | 68 ++++++++++++++++++------------------- test/env/database.test.js | 13 ++++--- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/lib/actions/up.js b/lib/actions/up.js index 122480be..79b5fad1 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -34,6 +34,7 @@ module.exports = async (db, client) => { db.isRollback = false; db.migrationFile = item.fileName; db.autoRollbackCounter = 0; + db.autoRollbackEnabled = false; if (hasCallback(migration.up) && fnArgs(migration.up).length < 3) { // support old callback-based migrations prior to migrate-mongo 7.x.x diff --git a/lib/env/database.js b/lib/env/database.js index 19c0a2c0..eb4bd9f7 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -33,6 +33,23 @@ module.exports = { // Override the collection method to return wrapped collections db.collection = function (name, options) { const collection = originalCollection(name, options); + + if (excludedCollections.includes(collection.collectionName)) { + return collection; + } + + // istanbul ignore next + if (db.isRollback + || !configContent.autoRollbackCollectionName + || !db.autoRollbackEnabled) { + + if (db.autoRollbackEnabled) { + // Auto-rollback is enabled but not properly configured + throw new Error("Auto-rollback is not enabled in the config file."); + } + return collection; + } + return wrapDbCollection(collection, db, configContent, excludedCollections); }; @@ -40,8 +57,8 @@ module.exports = { * Performs auto-rollback for the current migration */ db.autoRollback = async function () { - if (!db.isRollback) { - return; + if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { + throw new Error("Auto-rollback is not enabled for this migration."); } try { @@ -71,7 +88,7 @@ module.exports = { }; db.close = client.close.bind(client); - + return { client, db, diff --git a/lib/env/wrapDbCollection.js b/lib/env/wrapDbCollection.js index f64ba7b9..706e2398 100644 --- a/lib/env/wrapDbCollection.js +++ b/lib/env/wrapDbCollection.js @@ -13,16 +13,16 @@ const COLLECTION_INTERCEPTED_METHODS = [ * Creates inverse operations for auto-rollback functionality */ const INVERSE_OPERATIONS = { - async insertOne(collection, filterArg, isPreOperation, originalCommandResult) { - if (isPreOperation) return []; - return [{ deleteOne: { filter: { _id: originalCommandResult.insertedId} } }]; + async insertOne(collection, filterArg, operationResult) { + if (!operationResult) return []; + return [{ deleteOne: { filter: { _id: operationResult.insertedId} } }]; }, - async insertMany(collection, filterArg, isPreOperation, originalCommandResult) { - if (isPreOperation) return []; - return [{ deleteMany: { filter: { _id: { $in: Object.values(originalCommandResult.insertedIds) } } } }]; + async insertMany(collection, filterArg, operationResult) { + if (!operationResult) return []; + return [{ deleteMany: { filter: { _id: { $in: Object.values(operationResult.insertedIds) } } } }]; }, - async replaceOne(collection, filterArg, isPreOperation, originalCommandResult) { - if (!isPreOperation) return []; + async replaceOne(collection, filterArg, operationResult) { + if (operationResult) return []; const doc = await collection.findOne(filterArg); /* istanbul ignore next */ @@ -35,11 +35,11 @@ const INVERSE_OPERATIONS = { } }]; }, - async updateOne(collection, filterArg, isPreOperation, originalCommandResult) { - return this.replaceOne(collection, filterArg, isPreOperation, originalCommandResult); + async updateOne(collection, filterArg, operationResult) { + return this.replaceOne(collection, filterArg, operationResult); }, - async updateMany(collection, filterArg, isPreOperation, originalCommandResult) { - if (!isPreOperation) return []; + async updateMany(collection, filterArg, operationResult) { + if (operationResult) return []; const docs = await collection.find(filterArg).toArray(); return docs.map(doc => ({ @@ -49,8 +49,8 @@ const INVERSE_OPERATIONS = { } })); }, - async deleteOne(collection, filterArg, isPreOperation, originalCommandResult) { - if (!isPreOperation) return []; + async deleteOne(collection, filterArg, operationResult) { + if (operationResult) return []; const doc = await collection.findOne(filterArg); /* istanbul ignore next */ @@ -58,46 +58,47 @@ const INVERSE_OPERATIONS = { return [{ insertOne: doc }]; }, - async deleteMany(collection, filterArg, isPreOperation, originalCommandResult) { - if (!isPreOperation) return []; + async deleteMany(collection, filterArg, operationResult) { + if (operationResult) return []; const docs = await collection.find(filterArg).toArray(); return docs.map(doc => ({ insertOne: doc })); } }; + /** * Creates a wrapped collection method that records inverse operations for rollback */ -function createWrappedMethod(methodName, collection, originalMethod, db, configContent) { +function createWrappedMethod(methodName, collection, originalMethod, db, autoRollbackCollection) { return async function (...args) { try { - const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); const filterArg = args[0]; - const preOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, true, {}); + const preOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, null); // Original MongoDb operation - const originalMethodResult = await originalMethod(...args); + const operationResult = await originalMethod(...args); - const postOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, false, originalMethodResult); + const postOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, operationResult); // Combine PreOperation and postRollback operations - const operations = [...preOperation, ...postOperation]; + const rollbackOperations = [...preOperation, ...postOperation]; const timestamp = new Date(); - const bulkWriteInsertOperations = operations.map(operation => ({ + const bulkWriteInsertOperations = rollbackOperations.map(operation => ({ insertOne: { timestamp, migrationFile: db.migrationFile, orderIndex: db.autoRollbackCounter++, + originalArgs: args, collection: collection.collectionName, - bulkWriteOperation: operation + bulkWriteOperation: operation, } })); // Write rollback operations to the auto-rollback collection - await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: false }); + await autoRollbackCollection.bulkWrite(bulkWriteInsertOperations, { ordered: true }); - return originalMethodResult; + return operationResult; } catch (error) { /* istanbul ignore next */ throw new Error(`Failed to execute ${methodName} with auto-rollback: ${error.message}`); @@ -108,17 +109,16 @@ function createWrappedMethod(methodName, collection, originalMethod, db, configC * Wraps a collection to intercept methods for auto-rollback tracking */ function wrapDbCollection(collection, db, configContent, excludedCollections) { - if (db.isRollback || !db.autoRollbackEnabled) { - return collection; - } - - if (excludedCollections.includes(collection.collectionName)) { - return collection; - } + const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); COLLECTION_INTERCEPTED_METHODS.forEach(methodName => { const originalMethod = collection[methodName].bind(collection); - collection[methodName] = createWrappedMethod(methodName, collection, originalMethod, db, configContent); + collection[methodName] = createWrappedMethod( + methodName, + collection, + originalMethod, + db, + autoRollbackCollection); }); return collection; diff --git a/test/env/database.test.js b/test/env/database.test.js index f26dfc06..a984779d 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -167,7 +167,6 @@ describe("database", () => { mockDb = { collection: originalCollectionFunc, close: sinon.stub(), - autoRollbackEnabled: true, isRollback: false, migrationFile: "test-migration.js", autoRollbackCounter: 0 @@ -430,10 +429,14 @@ describe("database", () => { const result = await database.connect(); result.db.isRollback = false; - await result.db.autoRollback(); - - expect(mockAutoRollbackCollection.distinct.called).to.equal(false); - expect(mockCollection.bulkWrite.called).to.equal(false); + try { + await result.db.autoRollback(); + } catch (err) { + expect(err.message).to.equal("Auto-rollback is not enabled for this migration."); + return; + } + + expect.fail("Error was not thrown"); }); it("should fetch rollback entries for the current migration file", async () => { From 1a8c324a6cbbe506c0428fb22cb71d74685ccd1c Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Mon, 15 Dec 2025 19:40:10 +0000 Subject: [PATCH 08/12] Revert some minor changes, move auto rollback unit tests to a different file --- lib/env/database.js | 26 +- test/env/database-autorollback.test.js | 689 +++++++++++++++++++++++++ test/env/database.test.js | 623 +--------------------- 3 files changed, 703 insertions(+), 635 deletions(-) create mode 100644 test/env/database-autorollback.test.js diff --git a/lib/env/database.js b/lib/env/database.js index eb4bd9f7..1a5fc636 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -14,27 +14,25 @@ module.exports = { throw new Error("No `url` defined in config file!"); } - let client; - try { - client = await MongoClient.connect(url, options); - } catch (error) { - throw new Error(`Failed to connect to MongoDB: ${error.message}`); - } + const client = await MongoClient.connect( + url, + options + ); const db = client.db(databaseName); const originalCollection = db.collection.bind(db); - const excludedCollections = [ + const autoRollbackExcludedCollections = [ configContent.changelogCollectionName, configContent.lockCollectionName, configContent.autoRollbackCollectionName ]; // Override the collection method to return wrapped collections - db.collection = function (name, options) { + db.collection = (name, options) => { const collection = originalCollection(name, options); - if (excludedCollections.includes(collection.collectionName)) { + if (autoRollbackExcludedCollections.includes(collection.collectionName)) { return collection; } @@ -50,13 +48,11 @@ module.exports = { return collection; } - return wrapDbCollection(collection, db, configContent, excludedCollections); + return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); }; - /** - * Performs auto-rollback for the current migration - */ - db.autoRollback = async function () { + // Performs auto-rollback for the current migration + db.autoRollback = async () => { if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { throw new Error("Auto-rollback is not enabled for this migration."); } @@ -87,7 +83,7 @@ module.exports = { } }; - db.close = client.close.bind(client); + db.close = client.close; return { client, diff --git a/test/env/database-autorollback.test.js b/test/env/database-autorollback.test.js new file mode 100644 index 00000000..d1a7d0a7 --- /dev/null +++ b/test/env/database-autorollback.test.js @@ -0,0 +1,689 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire"); + +describe("database - autoRollback feature", () => { + let configObj; + let database; + let config; + let mongodb; + let client; + + function createConfigObj() { + return { + mongodb: { + url: "mongodb://someserver:27017", + databaseName: "testDb", + options: { + connectTimeoutMS: 3600000, // 1 hour + socketTimeoutMS: 3600000 // 1 hour + } + }, + changelogCollectionName: "changelog", + lockCollectionName: "lock", + autoRollbackCollectionName: "autoRollback" + }; + } + + function mockClient() { + // Create a mock collection function + const collectionFunc = function(name) { + return { + the: "db", + collectionName: name + }; + }; + + const mockDb = { + the: "db", + collection: collectionFunc + }; + + return { + db: sinon.stub().returns(mockDb), + close: "theCloseFnFromMongoClient" + }; + } + + function mockConfig() { + return { + read: sinon.stub().returns(configObj) + }; + } + + function mockMongodb() { + return { + MongoClient: { + connect: sinon.stub().returns(Promise.resolve(client)) + } + }; + } + + beforeEach(() => { + configObj = createConfigObj(); + client = mockClient(); + config = mockConfig(); + mongodb = mockMongodb(); + + database = proxyquire("../../lib/env/database", { + "./config": config, + mongodb + }); + }); + + describe("collection method wrapping", () => { + let mockDb; + let mockAutoRollbackCollection; + + beforeEach(() => { + // Function to create a fresh mock collection + function createMockCollection(name) { + return { + collectionName: name, + insertOne: sinon.stub().resolves({ insertedId: "123" }), + insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), + updateOne: sinon.stub().resolves({ modifiedCount: 1 }), + updateMany: sinon.stub().resolves({ modifiedCount: 2 }), + replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), + deleteOne: sinon.stub().resolves({ deletedCount: 1 }), + deleteMany: sinon.stub().resolves({ deletedCount: 2 }), + findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), + find: sinon.stub().returns({ + toArray: sinon.stub().resolves([ + { _id: "doc1", name: "test1" }, + { _id: "doc2", name: "test2" } + ]) + }), + distinct: sinon.stub().resolves([name]) + }; + } + + + mockAutoRollbackCollection = { + collectionName: "autoRollback", + bulkWrite: sinon.stub().resolves({ insertedCount: 1 }), + distinct: sinon.stub().resolves(["testCollection"]), + find: sinon.stub().returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves([]) + }), + deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + }; + + // Create mock db with originalCollection method + const originalCollectionFunc = function(name) { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + // Return a fresh mock collection for each call + return createMockCollection(name); + }; + + mockDb = { + collection: originalCollectionFunc, + close: sinon.stub(), + isRollback: false, + migrationFile: "test-migration.js", + autoRollbackCounter: 0 + }; + + client.db.returns(mockDb); + }); + + it("should wrap collection methods when autoRollbackEnabled is true", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + expect(typeof collection.insertOne).to.equal("function"); + expect(typeof collection.insertMany).to.equal("function"); + expect(typeof collection.updateOne).to.equal("function"); + expect(typeof collection.updateMany).to.equal("function"); + expect(typeof collection.replaceOne).to.equal("function"); + expect(typeof collection.deleteOne).to.equal("function"); + expect(typeof collection.deleteMany).to.equal("function"); + }); + + it("should not wrap collection methods when autoRollbackEnabled is false", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = false; + + const collection = result.db.collection("users"); + + // Should return the original mock collection - verify by checking it has the original methods + expect(collection.insertOne).to.exist; + expect(collection.collectionName).to.equal("users"); + }); + + it("should store rollback entry when insertOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.insertOne({ name: "John" }); + + // Verify rollback entry was created + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne).to.exist; + expect(bulkWriteOps[0].insertOne.collection).to.equal("users"); + expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { _id: "123" } } }); + }); + + it("should store rollback entry when insertMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docs = [{ name: "John" }, { name: "Jane" }]; + await collection.insertMany(docs); + + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); + expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); + }); + + it("should store rollback entry when updateOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); + + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); + }); + + it("should store rollback entries when updateMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); + + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test1" } + } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "test2" } + } + }); + }); + + it("should store rollback entry when deleteOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.deleteOne({ name: "John" }); + + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc1", name: "test" } + }); + }); + + it("should store rollback entries when deleteMany is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + const docsToDelete = [{ name: "John" }, { name: "Jane" }]; + await collection.deleteMany(docsToDelete); + + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc1", name: "test1" } + }); + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + insertOne: { _id: "doc2", name: "test2" } + }); + }); + + it("should store rollback entries when replaceOne is called", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); + expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + + expect(bulkWriteOps).to.be.an("array"); + expect(bulkWriteOps).to.have.lengthOf(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "test" } + } + }); + }); + + it("should increment autoRollbackCounter for each operation", async () => { + const result = await database.connect(); + result.db.autoRollbackEnabled = true; + result.db.isRollback = false; + result.db.migrationFile = "test-migration.js"; + + const collection = result.db.collection("users"); + + await collection.insertOne({ name: "John" }); + await collection.insertOne({ name: "Jane" }); + await collection.insertOne({ name: "Bob" }); + + expect(mockAutoRollbackCollection.bulkWrite.callCount).to.equal(3); + + const entry1 = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0][0].insertOne; + const entry2 = mockAutoRollbackCollection.bulkWrite.getCall(1).args[0][0].insertOne; + const entry3 = mockAutoRollbackCollection.bulkWrite.getCall(2).args[0][0].insertOne; + + expect(entry1.orderIndex).to.equal(0); + expect(entry2.orderIndex).to.equal(1); + expect(entry3.orderIndex).to.equal(2); + }); + }); + + describe("db.autoRollback()", () => { + let mockDb; + let mockCollection; + let mockAutoRollbackCollection; + + beforeEach(() => { + mockCollection = { + insertOne: sinon.stub().resolves({ insertedId: "123" }), + insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), + replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), + deleteOne: sinon.stub().resolves({ deletedCount: 1 }), + deleteMany: sinon.stub().resolves({ deletedCount: 2 }), + bulkWrite: sinon.stub().resolves({ insertedCount: 1 }) + }; + + mockAutoRollbackCollection = { + distinct: sinon.stub().resolves([]), + find: sinon.stub().returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves([]) + }), + deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + }; + + mockDb = { + collection: sinon.stub().callsFake((name) => { + if (name === "autoRollback") { + return mockAutoRollbackCollection; + } + return mockCollection; + }), + isRollback: true, + migrationFile: "test-migration.js" + }; + + // Reset client to have close method + client = { + db: sinon.stub().returns(mockDb), + close: sinon.stub() + }; + + mongodb.MongoClient.connect.returns(Promise.resolve(client)); + }); + + it("should not execute rollback when isRollback is false", async () => { + const result = await database.connect(); + result.db.isRollback = false; + + try { + await result.db.autoRollback(); + } catch (err) { + expect(err.message).to.equal("Auto-rollback is not enabled for this migration."); + return; + } + + expect.fail("Error was not thrown"); + }); + + it("should fetch rollback entries for the current migration file", async () => { + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.distinct.calledOnce).to.equal(true); + expect(mockAutoRollbackCollection.distinct.getCall(0).args[0]).to.equal("collection"); + expect(mockAutoRollbackCollection.distinct.getCall(0).args[1]).to.deep.equal({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute insertOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + insertOne: { _id: "doc1", name: "John" } + }]); + }); + + it("should execute insertMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [ + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }, + { bulkWriteOperation: { insertOne: { _id: "doc2", name: "Jane" } } } + ]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([ + { insertOne: { _id: "doc1", name: "John" } }, + { insertOne: { _id: "doc2", name: "Jane" } } + ]); + }); + + it("should execute replaceOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John", age: 25 } + } + }]); + }); + + it("should execute updateOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }]); + }); + + it("should execute updateMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [ + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + } + }, + { + bulkWriteOperation: { + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + } + } + ]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.have.lengthOf(2); + expect(operations[0]).to.deep.equal({ + replaceOne: { + filter: { _id: "doc1" }, + replacement: { _id: "doc1", name: "John" } + } + }); + expect(operations[1]).to.deep.equal({ + replaceOne: { + filter: { _id: "doc2" }, + replacement: { _id: "doc2", name: "Jane" } + } + }); + }); + + it("should execute deleteOne rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { deleteOne: { filter: { name: "John" } } } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + deleteOne: { filter: { name: "John" } } + }]); + }); + + it("should execute deleteMany rollback operation", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [{ + bulkWriteOperation: { deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.deep.equal([{ + deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } + }]); + }); + + it("should clean up rollback entries after successful rollback", async () => { + const rollbackEntries = [{ + operation: "insertOne", + collection: "users", + parameters: { _id: "doc1", name: "John" } + }]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + expect(mockAutoRollbackCollection.deleteMany.calledOnce).to.equal(true); + expect(mockAutoRollbackCollection.deleteMany.getCall(0).args[0]).to.deep.equal({ + migrationFile: "test-migration.js" + }); + }); + + it("should execute multiple rollback operations in reverse order", async () => { + mockAutoRollbackCollection.distinct.resolves(["users"]); + + const rollbackEntries = [ + { bulkWriteOperation: { insertOne: { _id: "doc3", name: "Bob" } } }, + { bulkWriteOperation: { deleteOne: { filter: { name: "Jane" } } } }, + { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } } + ]; + + mockAutoRollbackCollection.find.returns({ + sort: sinon.stub().returnsThis(), + project: sinon.stub().returnsThis(), + toArray: sinon.stub().resolves(rollbackEntries) + }); + + const result = await database.connect(); + result.db.isRollback = true; + result.db.migrationFile = "test-migration.js"; + + await result.db.autoRollback(); + + // Should execute all operations in one bulkWrite + expect(mockCollection.bulkWrite.calledOnce).to.equal(true); + const operations = mockCollection.bulkWrite.getCall(0).args[0]; + expect(operations).to.have.lengthOf(3); + }); + }); +}); diff --git a/test/env/database.test.js b/test/env/database.test.js index a984779d..76bc3d4c 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -41,7 +41,7 @@ describe("database", () => { return { db: sinon.stub().returns(mockDb), - close: sinon.stub() + close: "theCloseFnFromMongoClient" }; } @@ -85,10 +85,9 @@ describe("database", () => { }); expect(client.db.getCall(0).args[0]).to.equal("testDb"); - // db now has additional methods; assert key props instead of deep equality expect(result.db).to.be.an("object"); expect(result.db.the).to.equal("db"); - expect(result.db.close).to.be.a("function"); + expect(result.db.close).to.equal("theCloseFnFromMongoClient"); expect(result.db.collection).to.be.a("function"); expect(result.client).to.deep.equal(client); }); @@ -110,625 +109,9 @@ describe("database", () => { try { await database.connect(); } catch (err) { - expect(err.message).to.equal("Failed to connect to MongoDB: Unable to connect"); + expect(err.message).to.equal("Unable to connect"); } }); }); - - describe("autoRollback feature", () => { - let mockDb; - let mockAutoRollbackCollection; - - beforeEach(() => { - // Function to create a fresh mock collection - function createMockCollection(name) { - return { - collectionName: name, - insertOne: sinon.stub().resolves({ insertedId: "123" }), - insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), - updateOne: sinon.stub().resolves({ modifiedCount: 1 }), - updateMany: sinon.stub().resolves({ modifiedCount: 2 }), - replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), - deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }), - findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), - find: sinon.stub().returns({ - toArray: sinon.stub().resolves([ - { _id: "doc1", name: "test1" }, - { _id: "doc2", name: "test2" } - ]) - }), - distinct: sinon.stub().resolves([name]) - }; - } - - - mockAutoRollbackCollection = { - collectionName: "autoRollback", - bulkWrite: sinon.stub().resolves({ insertedCount: 1 }), - distinct: sinon.stub().resolves(["testCollection"]), - find: sinon.stub().returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves([]) - }), - deleteMany: sinon.stub().resolves({ deletedCount: 1 }) - }; - - // Create mock db with originalCollection method - const originalCollectionFunc = function(name) { - if (name === "autoRollback") { - return mockAutoRollbackCollection; - } - // Return a fresh mock collection for each call - return createMockCollection(name); - }; - - mockDb = { - collection: originalCollectionFunc, - close: sinon.stub(), - isRollback: false, - migrationFile: "test-migration.js", - autoRollbackCounter: 0 - }; - - client.db.returns(mockDb); - }); - - it("should wrap collection methods when autoRollbackEnabled is true", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - - expect(typeof collection.insertOne).to.equal("function"); - expect(typeof collection.insertMany).to.equal("function"); - expect(typeof collection.updateOne).to.equal("function"); - expect(typeof collection.updateMany).to.equal("function"); - expect(typeof collection.replaceOne).to.equal("function"); - expect(typeof collection.deleteOne).to.equal("function"); - expect(typeof collection.deleteMany).to.equal("function"); - }); - - it("should not wrap collection methods when autoRollbackEnabled is false", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = false; - - const collection = result.db.collection("users"); - - // Should return the original mock collection - verify by checking it has the original methods - expect(collection.insertOne).to.exist; - expect(collection.collectionName).to.equal("users"); - }); - - it("should store rollback entry when insertOne is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - await collection.insertOne({ name: "John" }); - - // Verify rollback entry was created - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne).to.exist; - expect(bulkWriteOps[0].insertOne.collection).to.equal("users"); - expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { _id: "123" } } }); - }); - - it("should store rollback entry when insertMany is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - const docs = [{ name: "John" }, { name: "Jane" }]; - await collection.insertMany(docs); - - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); - expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); - }); - - it("should store rollback entry when updateOne is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); - - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "test" } - } - }); - }); - - it("should store rollback entries when updateMany is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); - - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(2); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "test1" } - } - }); - expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ - replaceOne: { - filter: { _id: "doc2" }, - replacement: { _id: "doc2", name: "test2" } - } - }); - }); - - it("should store rollback entry when deleteOne is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - await collection.deleteOne({ name: "John" }); - - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ - insertOne: { _id: "doc1", name: "test" } - }); - }); - - it("should store rollback entries when deleteMany is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - const docsToDelete = [{ name: "John" }, { name: "Jane" }]; - await collection.deleteMany(docsToDelete); - - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(2); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ - insertOne: { _id: "doc1", name: "test1" } - }); - expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ - insertOne: { _id: "doc2", name: "test2" } - }); - }); - - it("should store rollback entries when replaceOne is called", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; - - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "test" } - } - }); - }); - - it("should increment autoRollbackCounter for each operation", async () => { - const result = await database.connect(); - result.db.autoRollbackEnabled = true; - result.db.isRollback = false; - result.db.migrationFile = "test-migration.js"; - - const collection = result.db.collection("users"); - - await collection.insertOne({ name: "John" }); - await collection.insertOne({ name: "Jane" }); - await collection.insertOne({ name: "Bob" }); - - expect(mockAutoRollbackCollection.bulkWrite.callCount).to.equal(3); - - const entry1 = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0][0].insertOne; - const entry2 = mockAutoRollbackCollection.bulkWrite.getCall(1).args[0][0].insertOne; - const entry3 = mockAutoRollbackCollection.bulkWrite.getCall(2).args[0][0].insertOne; - - expect(entry1.orderIndex).to.equal(0); - expect(entry2.orderIndex).to.equal(1); - expect(entry3.orderIndex).to.equal(2); - }); - }); - - describe("db.autoRollback()", () => { - let mockDb; - let mockCollection; - let mockAutoRollbackCollection; - - beforeEach(() => { - mockCollection = { - insertOne: sinon.stub().resolves({ insertedId: "123" }), - insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), - replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), - deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }), - bulkWrite: sinon.stub().resolves({ insertedCount: 1 }) - }; - - mockAutoRollbackCollection = { - distinct: sinon.stub().resolves([]), - find: sinon.stub().returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves([]) - }), - deleteMany: sinon.stub().resolves({ deletedCount: 1 }) - }; - - mockDb = { - collection: sinon.stub().callsFake((name) => { - if (name === "autoRollback") { - return mockAutoRollbackCollection; - } - return mockCollection; - }), - isRollback: true, - migrationFile: "test-migration.js" - }; - - // Reset client to have close method - client = { - db: sinon.stub().returns(mockDb), - close: sinon.stub() - }; - - mongodb.MongoClient.connect.returns(Promise.resolve(client)); - }); - - it("should not execute rollback when isRollback is false", async () => { - const result = await database.connect(); - result.db.isRollback = false; - - try { - await result.db.autoRollback(); - } catch (err) { - expect(err.message).to.equal("Auto-rollback is not enabled for this migration."); - return; - } - - expect.fail("Error was not thrown"); - }); - - it("should fetch rollback entries for the current migration file", async () => { - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockAutoRollbackCollection.distinct.calledOnce).to.equal(true); - expect(mockAutoRollbackCollection.distinct.getCall(0).args[0]).to.equal("collection"); - expect(mockAutoRollbackCollection.distinct.getCall(0).args[1]).to.deep.equal({ - migrationFile: "test-migration.js" - }); - }); - - it("should execute insertOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [{ - bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ - insertOne: { _id: "doc1", name: "John" } - }]); - }); - - it("should execute insertMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [ - { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }, - { bulkWriteOperation: { insertOne: { _id: "doc2", name: "Jane" } } } - ]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([ - { insertOne: { _id: "doc1", name: "John" } }, - { insertOne: { _id: "doc2", name: "Jane" } } - ]); - }); - - it("should execute replaceOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [{ - bulkWriteOperation: { - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John", age: 25 } - } - } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John", age: 25 } - } - }]); - }); - - it("should execute updateOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [{ - bulkWriteOperation: { - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John" } - } - } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John" } - } - }]); - }); - - it("should execute updateMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [ - { - bulkWriteOperation: { - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John" } - } - } - }, - { - bulkWriteOperation: { - replaceOne: { - filter: { _id: "doc2" }, - replacement: { _id: "doc2", name: "Jane" } - } - } - } - ]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.have.lengthOf(2); - expect(operations[0]).to.deep.equal({ - replaceOne: { - filter: { _id: "doc1" }, - replacement: { _id: "doc1", name: "John" } - } - }); - expect(operations[1]).to.deep.equal({ - replaceOne: { - filter: { _id: "doc2" }, - replacement: { _id: "doc2", name: "Jane" } - } - }); - }); - - it("should execute deleteOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [{ - bulkWriteOperation: { deleteOne: { filter: { name: "John" } } } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ - deleteOne: { filter: { name: "John" } } - }]); - }); - - it("should execute deleteMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [{ - bulkWriteOperation: { deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ - deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } - }]); - }); - - it("should clean up rollback entries after successful rollback", async () => { - const rollbackEntries = [{ - operation: "insertOne", - collection: "users", - parameters: { _id: "doc1", name: "John" } - }]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - expect(mockAutoRollbackCollection.deleteMany.calledOnce).to.equal(true); - expect(mockAutoRollbackCollection.deleteMany.getCall(0).args[0]).to.deep.equal({ - migrationFile: "test-migration.js" - }); - }); - - it("should execute multiple rollback operations in reverse order", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); - - const rollbackEntries = [ - { bulkWriteOperation: { insertOne: { _id: "doc3", name: "Bob" } } }, - { bulkWriteOperation: { deleteOne: { filter: { name: "Jane" } } } }, - { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } } - ]; - - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) - }); - - const result = await database.connect(); - result.db.isRollback = true; - result.db.migrationFile = "test-migration.js"; - - await result.db.autoRollback(); - - // Should execute all operations in one bulkWrite - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.have.lengthOf(3); - }); - }); }); From c4e53265f2f8e5bdb93cd2e01318b1103cc2f7cd Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Mon, 15 Dec 2025 20:00:28 +0000 Subject: [PATCH 09/12] Reorganize code --- lib/actions/up.js | 1 - .../{wrapDbCollection.js => autoRollback.js} | 76 +++++++++++++++++-- lib/env/database.js | 64 +--------------- ...orollback.test.js => autoRollback.test.js} | 0 test/env/database.test.js | 8 +- 5 files changed, 73 insertions(+), 76 deletions(-) rename lib/env/{wrapDbCollection.js => autoRollback.js} (60%) rename test/env/{database-autorollback.test.js => autoRollback.test.js} (100%) diff --git a/lib/actions/up.js b/lib/actions/up.js index 79b5fad1..f90c5258 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -66,7 +66,6 @@ module.exports = async (db, client) => { } migrated.push(item.fileName); }; - await pEachSeries(pendingItems, migrateItem); await lock.clear(db); return migrated; diff --git a/lib/env/wrapDbCollection.js b/lib/env/autoRollback.js similarity index 60% rename from lib/env/wrapDbCollection.js rename to lib/env/autoRollback.js index 706e2398..6660a3a5 100644 --- a/lib/env/wrapDbCollection.js +++ b/lib/env/autoRollback.js @@ -66,9 +66,8 @@ const INVERSE_OPERATIONS = { } }; -/** - * Creates a wrapped collection method that records inverse operations for rollback - */ + +// Creates a wrapped collection method that records inverse operations for rollback function createWrappedMethod(methodName, collection, originalMethod, db, autoRollbackCollection) { return async function (...args) { try { @@ -105,9 +104,8 @@ function createWrappedMethod(methodName, collection, originalMethod, db, autoRol } }; } -/** - * Wraps a collection to intercept methods for auto-rollback tracking - */ + +// Wraps a collection to intercept methods for auto-rollback tracking function wrapDbCollection(collection, db, configContent, excludedCollections) { const autoRollbackCollection = db.collection(configContent.autoRollbackCollectionName); @@ -123,4 +121,68 @@ function wrapDbCollection(collection, db, configContent, excludedCollections) { return collection; } -exports.wrapDbCollection = wrapDbCollection; + +function wrapDbWithAutoRollback(db, configContent, originalCollection) { + const autoRollbackExcludedCollections = [ + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName + ]; + + // Override the collection method to return wrapped collections + db.collection = (name, options) => { + const collection = originalCollection(name, options); + + if (autoRollbackExcludedCollections.includes(collection.collectionName)) { + return collection; + } + + // istanbul ignore next + if (db.isRollback + || !configContent.autoRollbackCollectionName + || !db.autoRollbackEnabled) { + + if (db.autoRollbackEnabled) { + // Auto-rollback is enabled but not properly configured + throw new Error("Auto-rollback is not enabled in the config file."); + } + return collection; + } + + return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); + }; + + // Performs auto-rollback for the current migration + db.autoRollback = async () => { + if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { + throw new Error("Auto-rollback is not enabled for this migration."); + } + + try { + const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); + const collectionNames = await autoRollbackCollection.distinct( + "collection", + { migrationFile: db.migrationFile } + ); + + for (const collectionName of collectionNames) { + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile, collection: collectionName }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, bulkWriteOperation: 1 }) + .toArray(); + + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); + } + + await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Auto-rollback failed: ${error.message}`); + } + }; +} + +module.exports = { wrapDbWithAutoRollback }; diff --git a/lib/env/database.js b/lib/env/database.js index 1a5fc636..479d7179 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -1,7 +1,7 @@ const { MongoClient } = require("mongodb"); const _get = require("lodash.get"); const config = require("./config"); -const { wrapDbCollection } = require("./wrapDbCollection"); +const { wrapDbWithAutoRollback } = require("./autoRollback"); module.exports = { async connect() { @@ -22,69 +22,9 @@ module.exports = { const db = client.db(databaseName); const originalCollection = db.collection.bind(db); - const autoRollbackExcludedCollections = [ - configContent.changelogCollectionName, - configContent.lockCollectionName, - configContent.autoRollbackCollectionName - ]; - - // Override the collection method to return wrapped collections - db.collection = (name, options) => { - const collection = originalCollection(name, options); - - if (autoRollbackExcludedCollections.includes(collection.collectionName)) { - return collection; - } - - // istanbul ignore next - if (db.isRollback - || !configContent.autoRollbackCollectionName - || !db.autoRollbackEnabled) { - - if (db.autoRollbackEnabled) { - // Auto-rollback is enabled but not properly configured - throw new Error("Auto-rollback is not enabled in the config file."); - } - return collection; - } - - return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); - }; - - // Performs auto-rollback for the current migration - db.autoRollback = async () => { - if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { - throw new Error("Auto-rollback is not enabled for this migration."); - } - - try { - const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); - const collectionNames = await autoRollbackCollection.distinct( - "collection", - { migrationFile: db.migrationFile } - ); - - for (const collectionName of collectionNames) { - const targetCollection = originalCollection(collectionName); - const rollbackEntries = await autoRollbackCollection - .find({ migrationFile: db.migrationFile, collection: collectionName }) - .sort({ timestamp: -1, orderIndex: -1 }) - .project({ _id: 0, bulkWriteOperation: 1 }) - .toArray(); - - const operations = rollbackEntries.map(e => e.bulkWriteOperation); - await targetCollection.bulkWrite(operations, { ordered: true }); - } - - await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); - } catch (error) { - /* istanbul ignore next */ - throw new Error(`Auto-rollback failed: ${error.message}`); - } - }; + wrapDbWithAutoRollback(db, configContent, originalCollection); db.close = client.close; - return { client, db, diff --git a/test/env/database-autorollback.test.js b/test/env/autoRollback.test.js similarity index 100% rename from test/env/database-autorollback.test.js rename to test/env/autoRollback.test.js diff --git a/test/env/database.test.js b/test/env/database.test.js index 76bc3d4c..95025490 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -18,10 +18,7 @@ describe("database", () => { connectTimeoutMS: 3600000, // 1 hour socketTimeoutMS: 3600000 // 1 hour } - }, - changelogCollectionName: "changelog", - lockCollectionName: "lock", - autoRollbackCollectionName: "autoRollback" + } }; } @@ -113,5 +110,4 @@ describe("database", () => { } }); }); -}); - +}); \ No newline at end of file From 497241f668020824fd23b4c863b4f0b526b2c9bb Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Mon, 15 Dec 2025 20:03:07 +0000 Subject: [PATCH 10/12] Minor jump line adjustment --- lib/env/database.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/env/database.js b/lib/env/database.js index 479d7179..df7fb89b 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -21,9 +21,7 @@ module.exports = { const db = client.db(databaseName); const originalCollection = db.collection.bind(db); - wrapDbWithAutoRollback(db, configContent, originalCollection); - db.close = client.close; return { client, From c409289e50af6e348c9957b44875c9eb70e3e2dd Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Mon, 15 Dec 2025 20:35:31 +0000 Subject: [PATCH 11/12] Refactor unit tests using vi.mock --- lib/env/autoRollback.js | 114 +++++----- lib/env/database.js | 4 +- test/env/autoRollback.test.js | 383 ++++++++++++++++------------------ test/env/database.test.js | 15 +- 4 files changed, 248 insertions(+), 268 deletions(-) diff --git a/lib/env/autoRollback.js b/lib/env/autoRollback.js index 6660a3a5..c09e5f35 100644 --- a/lib/env/autoRollback.js +++ b/lib/env/autoRollback.js @@ -122,67 +122,67 @@ function wrapDbCollection(collection, db, configContent, excludedCollections) { return collection; } -function wrapDbWithAutoRollback(db, configContent, originalCollection) { - const autoRollbackExcludedCollections = [ - configContent.changelogCollectionName, - configContent.lockCollectionName, - configContent.autoRollbackCollectionName - ]; - - // Override the collection method to return wrapped collections - db.collection = (name, options) => { - const collection = originalCollection(name, options); - - if (autoRollbackExcludedCollections.includes(collection.collectionName)) { - return collection; - } +export default { + wrapDbWithAutoRollback(db, configContent, originalCollection) { + const autoRollbackExcludedCollections = [ + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName + ]; + + // Override the collection method to return wrapped collections + db.collection = (name, options) => { + const collection = originalCollection(name, options); + + if (autoRollbackExcludedCollections.includes(collection.collectionName)) { + return collection; + } - // istanbul ignore next - if (db.isRollback - || !configContent.autoRollbackCollectionName - || !db.autoRollbackEnabled) { + // istanbul ignore next + if (db.isRollback + || !configContent.autoRollbackCollectionName + || !db.autoRollbackEnabled) { - if (db.autoRollbackEnabled) { - // Auto-rollback is enabled but not properly configured - throw new Error("Auto-rollback is not enabled in the config file."); - } - return collection; - } + if (db.autoRollbackEnabled) { + // Auto-rollback is enabled but not properly configured + throw new Error("Auto-rollback is not enabled in the config file."); + } + return collection; + } - return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); - }; + return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); + }; - // Performs auto-rollback for the current migration - db.autoRollback = async () => { - if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { - throw new Error("Auto-rollback is not enabled for this migration."); - } + // Performs auto-rollback for the current migration + db.autoRollback = async () => { + if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { + throw new Error("Auto-rollback is not enabled for this migration."); + } - try { - const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); - const collectionNames = await autoRollbackCollection.distinct( - "collection", - { migrationFile: db.migrationFile } - ); - - for (const collectionName of collectionNames) { - const targetCollection = originalCollection(collectionName); - const rollbackEntries = await autoRollbackCollection - .find({ migrationFile: db.migrationFile, collection: collectionName }) - .sort({ timestamp: -1, orderIndex: -1 }) - .project({ _id: 0, bulkWriteOperation: 1 }) - .toArray(); - - const operations = rollbackEntries.map(e => e.bulkWriteOperation); - await targetCollection.bulkWrite(operations, { ordered: true }); - } + try { + const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); + const collectionNames = await autoRollbackCollection.distinct( + "collection", + { migrationFile: db.migrationFile } + ); + + for (const collectionName of collectionNames) { + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection + .find({ migrationFile: db.migrationFile, collection: collectionName }) + .sort({ timestamp: -1, orderIndex: -1 }) + .project({ _id: 0, bulkWriteOperation: 1 }) + .toArray(); + + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); + } - await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); - } catch (error) { - /* istanbul ignore next */ - throw new Error(`Auto-rollback failed: ${error.message}`); + await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); + } catch (error) { + /* istanbul ignore next */ + throw new Error(`Auto-rollback failed: ${error.message}`); + } + }; } - }; -} - -module.exports = { wrapDbWithAutoRollback }; +}; \ No newline at end of file diff --git a/lib/env/database.js b/lib/env/database.js index e72cba1f..44bde80c 100644 --- a/lib/env/database.js +++ b/lib/env/database.js @@ -1,6 +1,6 @@ import { MongoClient } from "mongodb"; import config from "./config.js"; -const { wrapDbWithAutoRollback } = require("./autoRollback"); +import autoRollback from "./autoRollback.js"; export default { async connect() { @@ -20,7 +20,7 @@ export default { const db = client.db(databaseName); const originalCollection = db.collection.bind(db); - wrapDbWithAutoRollback(db, configContent, originalCollection); + autoRollback.wrapDbWithAutoRollback(db, configContent, originalCollection); db.close = client.close; return { client, diff --git a/test/env/autoRollback.test.js b/test/env/autoRollback.test.js index d1a7d0a7..653953df 100644 --- a/test/env/autoRollback.test.js +++ b/test/env/autoRollback.test.js @@ -1,12 +1,11 @@ -const { expect } = require("chai"); -const sinon = require("sinon"); -const proxyquire = require("proxyquire"); +vi.mock("mongodb"); + +import config from "../../lib/env/config.js"; +import mongodb from "mongodb"; +import database from "../../lib/env/database.js"; describe("database - autoRollback feature", () => { let configObj; - let database; - let config; - let mongodb; let client; function createConfigObj() { @@ -40,35 +39,18 @@ describe("database - autoRollback feature", () => { }; return { - db: sinon.stub().returns(mockDb), + db: vi.fn().mockReturnValue(mockDb), close: "theCloseFnFromMongoClient" }; } - function mockConfig() { - return { - read: sinon.stub().returns(configObj) - }; - } - - function mockMongodb() { - return { - MongoClient: { - connect: sinon.stub().returns(Promise.resolve(client)) - } - }; - } - beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); configObj = createConfigObj(); client = mockClient(); - config = mockConfig(); - mongodb = mockMongodb(); - - database = proxyquire("../../lib/env/database", { - "./config": config, - mongodb - }); + vi.spyOn(config, 'read').mockReturnValue(configObj); + vi.spyOn(mongodb.MongoClient, "connect").mockResolvedValue(client); }); describe("collection method wrapping", () => { @@ -80,35 +62,35 @@ describe("database - autoRollback feature", () => { function createMockCollection(name) { return { collectionName: name, - insertOne: sinon.stub().resolves({ insertedId: "123" }), - insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), - updateOne: sinon.stub().resolves({ modifiedCount: 1 }), - updateMany: sinon.stub().resolves({ modifiedCount: 2 }), - replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), - deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }), - findOne: sinon.stub().resolves({ _id: "doc1", name: "test" }), - find: sinon.stub().returns({ - toArray: sinon.stub().resolves([ + insertOne: vi.fn().mockResolvedValue({ insertedId: "123" }), + insertMany: vi.fn().mockResolvedValue({ insertedIds: ["1", "2"] }), + updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + updateMany: vi.fn().mockResolvedValue({ modifiedCount: 2 }), + replaceOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: vi.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 2 }), + findOne: vi.fn().mockResolvedValue({ _id: "doc1", name: "test" }), + find: vi.fn().mockReturnValue({ + toArray: vi.fn().mockResolvedValue([ { _id: "doc1", name: "test1" }, { _id: "doc2", name: "test2" } ]) }), - distinct: sinon.stub().resolves([name]) + distinct: vi.fn().mockResolvedValue([name]) }; } mockAutoRollbackCollection = { collectionName: "autoRollback", - bulkWrite: sinon.stub().resolves({ insertedCount: 1 }), - distinct: sinon.stub().resolves(["testCollection"]), - find: sinon.stub().returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves([]) + bulkWrite: vi.fn().mockResolvedValue({ insertedCount: 1 }), + distinct: vi.fn().mockResolvedValue(["testCollection"]), + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]) }), - deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 1 }) }; // Create mock db with originalCollection method @@ -122,13 +104,13 @@ describe("database - autoRollback feature", () => { mockDb = { collection: originalCollectionFunc, - close: sinon.stub(), + close: vi.fn(), isRollback: false, migrationFile: "test-migration.js", autoRollbackCounter: 0 }; - client.db.returns(mockDb); + client.db.mockReturnValue(mockDb); }); it("should wrap collection methods when autoRollbackEnabled is true", async () => { @@ -139,13 +121,13 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); - expect(typeof collection.insertOne).to.equal("function"); - expect(typeof collection.insertMany).to.equal("function"); - expect(typeof collection.updateOne).to.equal("function"); - expect(typeof collection.updateMany).to.equal("function"); - expect(typeof collection.replaceOne).to.equal("function"); - expect(typeof collection.deleteOne).to.equal("function"); - expect(typeof collection.deleteMany).to.equal("function"); + expect(typeof collection.insertOne).toBe("function"); + expect(typeof collection.insertMany).toBe("function"); + expect(typeof collection.updateOne).toBe("function"); + expect(typeof collection.updateMany).toBe("function"); + expect(typeof collection.replaceOne).toBe("function"); + expect(typeof collection.deleteOne).toBe("function"); + expect(typeof collection.deleteMany).toBe("function"); }); it("should not wrap collection methods when autoRollbackEnabled is false", async () => { @@ -155,8 +137,8 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); // Should return the original mock collection - verify by checking it has the original methods - expect(collection.insertOne).to.exist; - expect(collection.collectionName).to.equal("users"); + expect(collection.insertOne).toBeDefined(); + expect(collection.collectionName).toBe("users"); }); it("should store rollback entry when insertOne is called", async () => { @@ -169,15 +151,15 @@ describe("database - autoRollback feature", () => { await collection.insertOne({ name: "John" }); // Verify rollback entry was created - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne).to.exist; - expect(bulkWriteOps[0].insertOne.collection).to.equal("users"); - expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteOne: { filter: { _id: "123" } } }); + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne).toBeDefined(); + expect(bulkWriteOps[0].insertOne.collection).toBe("users"); + expect(bulkWriteOps[0].insertOne.migrationFile).toBe("test-migration.js"); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ deleteOne: { filter: { _id: "123" } } }); }); it("should store rollback entry when insertMany is called", async () => { @@ -190,13 +172,13 @@ describe("database - autoRollback feature", () => { const docs = [{ name: "John" }, { name: "Jane" }]; await collection.insertMany(docs); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); - expect(bulkWriteOps[0].insertOne.migrationFile).to.equal("test-migration.js"); + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ deleteMany: { filter: { _id: { $in: ["1", "2"] } } } }); + expect(bulkWriteOps[0].insertOne.migrationFile).toBe("test-migration.js"); }); it("should store rollback entry when updateOne is called", async () => { @@ -208,12 +190,12 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); await collection.updateOne({ name: "John" }, { $set: { age: 30 } }); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "test" } @@ -230,18 +212,18 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); await collection.updateMany({ age: { $gt: 20 } }, { $set: { active: true } }); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(2); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "test1" } } }); - expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).toEqual({ replaceOne: { filter: { _id: "doc2" }, replacement: { _id: "doc2", name: "test2" } @@ -258,12 +240,12 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); await collection.deleteOne({ name: "John" }); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ insertOne: { _id: "doc1", name: "test" } }); }); @@ -278,15 +260,15 @@ describe("database - autoRollback feature", () => { const docsToDelete = [{ name: "John" }, { name: "Jane" }]; await collection.deleteMany(docsToDelete); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(2); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(2); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ insertOne: { _id: "doc1", name: "test1" } }); - expect(bulkWriteOps[1].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps[1].insertOne.bulkWriteOperation).toEqual({ insertOne: { _id: "doc2", name: "test2" } }); }); @@ -299,12 +281,12 @@ describe("database - autoRollback feature", () => { const collection = result.db.collection("users"); await collection.replaceOne({ name: "John" }, { name: "John", age: 40 }); - expect(mockAutoRollbackCollection.bulkWrite.calledOnce).to.equal(true); - const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0]; + expect(mockAutoRollbackCollection.bulkWrite).toHaveBeenCalledOnce(); + const bulkWriteOps = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0]; - expect(bulkWriteOps).to.be.an("array"); - expect(bulkWriteOps).to.have.lengthOf(1); - expect(bulkWriteOps[0].insertOne.bulkWriteOperation).to.deep.equal({ + expect(bulkWriteOps).toBeInstanceOf(Array); + expect(bulkWriteOps).toHaveLength(1); + expect(bulkWriteOps[0].insertOne.bulkWriteOperation).toEqual({ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "test" } @@ -324,15 +306,15 @@ describe("database - autoRollback feature", () => { await collection.insertOne({ name: "Jane" }); await collection.insertOne({ name: "Bob" }); - expect(mockAutoRollbackCollection.bulkWrite.callCount).to.equal(3); + expect(mockAutoRollbackCollection.bulkWrite.mock.calls.length).toBe(3); - const entry1 = mockAutoRollbackCollection.bulkWrite.getCall(0).args[0][0].insertOne; - const entry2 = mockAutoRollbackCollection.bulkWrite.getCall(1).args[0][0].insertOne; - const entry3 = mockAutoRollbackCollection.bulkWrite.getCall(2).args[0][0].insertOne; + const entry1 = mockAutoRollbackCollection.bulkWrite.mock.calls[0][0][0].insertOne; + const entry2 = mockAutoRollbackCollection.bulkWrite.mock.calls[1][0][0].insertOne; + const entry3 = mockAutoRollbackCollection.bulkWrite.mock.calls[2][0][0].insertOne; - expect(entry1.orderIndex).to.equal(0); - expect(entry2.orderIndex).to.equal(1); - expect(entry3.orderIndex).to.equal(2); + expect(entry1.orderIndex).toBe(0); + expect(entry2.orderIndex).toBe(1); + expect(entry3.orderIndex).toBe(2); }); }); @@ -343,26 +325,26 @@ describe("database - autoRollback feature", () => { beforeEach(() => { mockCollection = { - insertOne: sinon.stub().resolves({ insertedId: "123" }), - insertMany: sinon.stub().resolves({ insertedIds: ["1", "2"] }), - replaceOne: sinon.stub().resolves({ modifiedCount: 1 }), - deleteOne: sinon.stub().resolves({ deletedCount: 1 }), - deleteMany: sinon.stub().resolves({ deletedCount: 2 }), - bulkWrite: sinon.stub().resolves({ insertedCount: 1 }) + insertOne: vi.fn().mockResolvedValue({ insertedId: "123" }), + insertMany: vi.fn().mockResolvedValue({ insertedIds: ["1", "2"] }), + replaceOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + deleteOne: vi.fn().mockResolvedValue({ deletedCount: 1 }), + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 2 }), + bulkWrite: vi.fn().mockResolvedValue({ insertedCount: 1 }) }; mockAutoRollbackCollection = { - distinct: sinon.stub().resolves([]), - find: sinon.stub().returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves([]) + distinct: vi.fn().mockResolvedValue([]), + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]) }), - deleteMany: sinon.stub().resolves({ deletedCount: 1 }) + deleteMany: vi.fn().mockResolvedValue({ deletedCount: 1 }) }; mockDb = { - collection: sinon.stub().callsFake((name) => { + collection: vi.fn().mockImplementation((name) => { if (name === "autoRollback") { return mockAutoRollbackCollection; } @@ -374,25 +356,18 @@ describe("database - autoRollback feature", () => { // Reset client to have close method client = { - db: sinon.stub().returns(mockDb), - close: sinon.stub() + db: vi.fn().mockReturnValue(mockDb), + close: vi.fn() }; - mongodb.MongoClient.connect.returns(Promise.resolve(client)); + vi.spyOn(mongodb.MongoClient, "connect").mockResolvedValue(client); }); it("should not execute rollback when isRollback is false", async () => { const result = await database.connect(); result.db.isRollback = false; - try { - await result.db.autoRollback(); - } catch (err) { - expect(err.message).to.equal("Auto-rollback is not enabled for this migration."); - return; - } - - expect.fail("Error was not thrown"); + await expect(result.db.autoRollback()).rejects.toThrow("Auto-rollback is not enabled for this migration."); }); it("should fetch rollback entries for the current migration file", async () => { @@ -402,24 +377,24 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockAutoRollbackCollection.distinct.calledOnce).to.equal(true); - expect(mockAutoRollbackCollection.distinct.getCall(0).args[0]).to.equal("collection"); - expect(mockAutoRollbackCollection.distinct.getCall(0).args[1]).to.deep.equal({ + expect(mockAutoRollbackCollection.distinct).toHaveBeenCalledOnce(); + expect(mockAutoRollbackCollection.distinct.mock.calls[0][0]).toBe("collection"); + expect(mockAutoRollbackCollection.distinct.mock.calls[0][1]).toEqual({ migrationFile: "test-migration.js" }); }); it("should execute insertOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [{ bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -428,25 +403,25 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ insertOne: { _id: "doc1", name: "John" } }]); }); it("should execute insertMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [ { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } }, { bulkWriteOperation: { insertOne: { _id: "doc2", name: "Jane" } } } ]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -455,16 +430,16 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([ { insertOne: { _id: "doc1", name: "John" } }, { insertOne: { _id: "doc2", name: "Jane" } } ]); }); it("should execute replaceOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [{ bulkWriteOperation: { @@ -475,10 +450,10 @@ describe("database - autoRollback feature", () => { } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -487,9 +462,9 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "John", age: 25 } @@ -498,7 +473,7 @@ describe("database - autoRollback feature", () => { }); it("should execute updateOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [{ bulkWriteOperation: { @@ -509,10 +484,10 @@ describe("database - autoRollback feature", () => { } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -521,9 +496,9 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "John" } @@ -532,7 +507,7 @@ describe("database - autoRollback feature", () => { }); it("should execute updateMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [ { @@ -553,10 +528,10 @@ describe("database - autoRollback feature", () => { } ]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -565,16 +540,16 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.have.lengthOf(2); - expect(operations[0]).to.deep.equal({ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toHaveLength(2); + expect(operations[0]).toEqual({ replaceOne: { filter: { _id: "doc1" }, replacement: { _id: "doc1", name: "John" } } }); - expect(operations[1]).to.deep.equal({ + expect(operations[1]).toEqual({ replaceOne: { filter: { _id: "doc2" }, replacement: { _id: "doc2", name: "Jane" } @@ -583,16 +558,16 @@ describe("database - autoRollback feature", () => { }); it("should execute deleteOne rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [{ bulkWriteOperation: { deleteOne: { filter: { name: "John" } } } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -601,24 +576,24 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ deleteOne: { filter: { name: "John" } } }]); }); it("should execute deleteMany rollback operation", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [{ bulkWriteOperation: { deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -627,9 +602,9 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.deep.equal([{ + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toEqual([{ deleteMany: { filter: { $or: [{ name: "John" }, { name: "Jane" }] } } }]); }); @@ -641,10 +616,10 @@ describe("database - autoRollback feature", () => { parameters: { _id: "doc1", name: "John" } }]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -653,14 +628,14 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); - expect(mockAutoRollbackCollection.deleteMany.calledOnce).to.equal(true); - expect(mockAutoRollbackCollection.deleteMany.getCall(0).args[0]).to.deep.equal({ + expect(mockAutoRollbackCollection.deleteMany).toHaveBeenCalledOnce(); + expect(mockAutoRollbackCollection.deleteMany.mock.calls[0][0]).toEqual({ migrationFile: "test-migration.js" }); }); it("should execute multiple rollback operations in reverse order", async () => { - mockAutoRollbackCollection.distinct.resolves(["users"]); + mockAutoRollbackCollection.distinct.mockResolvedValue(["users"]); const rollbackEntries = [ { bulkWriteOperation: { insertOne: { _id: "doc3", name: "Bob" } } }, @@ -668,10 +643,10 @@ describe("database - autoRollback feature", () => { { bulkWriteOperation: { insertOne: { _id: "doc1", name: "John" } } } ]; - mockAutoRollbackCollection.find.returns({ - sort: sinon.stub().returnsThis(), - project: sinon.stub().returnsThis(), - toArray: sinon.stub().resolves(rollbackEntries) + mockAutoRollbackCollection.find.mockReturnValue({ + sort: vi.fn().mockReturnThis(), + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue(rollbackEntries) }); const result = await database.connect(); @@ -681,9 +656,9 @@ describe("database - autoRollback feature", () => { await result.db.autoRollback(); // Should execute all operations in one bulkWrite - expect(mockCollection.bulkWrite.calledOnce).to.equal(true); - const operations = mockCollection.bulkWrite.getCall(0).args[0]; - expect(operations).to.have.lengthOf(3); + expect(mockCollection.bulkWrite).toHaveBeenCalledOnce(); + const operations = mockCollection.bulkWrite.mock.calls[0][0]; + expect(operations).toHaveLength(3); }); }); }); diff --git a/test/env/database.test.js b/test/env/database.test.js index 88da7c9e..1ef63161 100644 --- a/test/env/database.test.js +++ b/test/env/database.test.js @@ -1,5 +1,10 @@ vi.mock("mongodb"); vi.mock("fs/promises"); +vi.mock("../../lib/env/autoRollback.js", () => ({ + default: { + wrapDbWithAutoRollback: vi.fn() + } +})); import config from "../../lib/env/config.js"; import mongodb from "mongodb"; @@ -37,7 +42,7 @@ describe("database", () => { }; return { - db: vi.fn().mockReturnValue({ the: "db" }), + db: vi.fn().mockReturnValue(mockDb), close: "theCloseFnFromMongoClient" }; } @@ -64,10 +69,10 @@ describe("database", () => { ); expect(client.db).toHaveBeenCalledWith("testDb"); - expect(result.db).toEqual({ - the: "db", - close: "theCloseFnFromMongoClient" - }); + expect(result.db).toBeDefined(); + expect(result.db.the).toBe("db"); + expect(result.db.close).toBe("theCloseFnFromMongoClient"); + expect(typeof result.db.collection).toBe("function"); expect(result.client).toEqual(client); }); From 7bad94101a701b2540264918fc76238d3181fbdc Mon Sep 17 00:00:00 2001 From: Pedro Camata Andreon Date: Tue, 3 Mar 2026 14:10:37 +0000 Subject: [PATCH 12/12] Fix issues from merging with latest --- lib/actions/down.js | 1 + lib/actions/up.js | 1 + lib/env/autoRollback.js | 63 +++++++++++++++++++---------------- test/env/autoRollback.test.js | 28 ---------------- 4 files changed, 36 insertions(+), 57 deletions(-) diff --git a/lib/actions/down.js b/lib/actions/down.js index 9bc04922..49d7b0e0 100644 --- a/lib/actions/down.js +++ b/lib/actions/down.js @@ -22,6 +22,7 @@ export default async (db, client) => { if (item) { try { const migration = await migrationsDir.loadMigration(item.fileName); + db.migrationFile = item.fileName; await migration.down(db, client); } catch (err) { diff --git a/lib/actions/up.js b/lib/actions/up.js index 405e0219..74b0b9ef 100644 --- a/lib/actions/up.js +++ b/lib/actions/up.js @@ -22,6 +22,7 @@ export default async (db, client) => { const migrateItem = async item => { try { const migration = await migrationsDir.loadMigration(item.fileName); + db.migrationFile = item.fileName; await migration.up(db, client); } catch (err) { diff --git a/lib/env/autoRollback.js b/lib/env/autoRollback.js index c09e5f35..ddc7e264 100644 --- a/lib/env/autoRollback.js +++ b/lib/env/autoRollback.js @@ -15,7 +15,7 @@ const COLLECTION_INTERCEPTED_METHODS = [ const INVERSE_OPERATIONS = { async insertOne(collection, filterArg, operationResult) { if (!operationResult) return []; - return [{ deleteOne: { filter: { _id: operationResult.insertedId} } }]; + return [{ deleteOne: { filter: { _id: operationResult.insertedId } } }]; }, async insertMany(collection, filterArg, operationResult) { if (!operationResult) return []; @@ -73,7 +73,7 @@ function createWrappedMethod(methodName, collection, originalMethod, db, autoRol try { const filterArg = args[0]; const preOperation = await INVERSE_OPERATIONS[methodName](collection, filterArg, null); - + // Original MongoDb operation const operationResult = await originalMethod(...args); @@ -82,6 +82,9 @@ function createWrappedMethod(methodName, collection, originalMethod, db, autoRol // Combine PreOperation and postRollback operations const rollbackOperations = [...preOperation, ...postOperation]; + // Informs the rollback order + db.autoRollbackCounter = db.autoRollbackCounter ?? 0; + const timestamp = new Date(); const bulkWriteInsertOperations = rollbackOperations.map(operation => ({ insertOne: { @@ -123,66 +126,68 @@ function wrapDbCollection(collection, db, configContent, excludedCollections) { } export default { - wrapDbWithAutoRollback(db, configContent, originalCollection) { + wrapDbWithAutoRollback(db, configContent, originalCollection) { const autoRollbackExcludedCollections = [ - configContent.changelogCollectionName, - configContent.lockCollectionName, - configContent.autoRollbackCollectionName + configContent.changelogCollectionName, + configContent.lockCollectionName, + configContent.autoRollbackCollectionName ]; // Override the collection method to return wrapped collections db.collection = (name, options) => { - const collection = originalCollection(name, options); + const collection = originalCollection(name, options); - if (autoRollbackExcludedCollections.includes(collection.collectionName)) { + if (autoRollbackExcludedCollections.includes(collection.collectionName)) { return collection; - } + } - // istanbul ignore next - if (db.isRollback - || !configContent.autoRollbackCollectionName + // istanbul ignore next + if (!configContent.autoRollbackCollectionName || !db.autoRollbackEnabled) { if (db.autoRollbackEnabled) { - // Auto-rollback is enabled but not properly configured - throw new Error("Auto-rollback is not enabled in the config file."); + // Auto-rollback is enabled but not properly configured + throw new Error("Auto-rollback is not enabled in the config file."); } return collection; - } + } - return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); + return wrapDbCollection(collection, db, configContent, autoRollbackExcludedCollections); }; // Performs auto-rollback for the current migration db.autoRollback = async () => { - if (!db.isRollback || configContent.autoRollbackCollectionName === undefined) { - throw new Error("Auto-rollback is not enabled for this migration."); - } - try { + // istanbul ignore next + if (configContent.autoRollbackCollectionName === undefined + || configContent.autoRollbackCollectionName === null) { + configContent.autoRollbackCollectionName = "auto_rollback_migrations"; + } + + try { const autoRollbackCollection = originalCollection(configContent.autoRollbackCollectionName); const collectionNames = await autoRollbackCollection.distinct( - "collection", - { migrationFile: db.migrationFile } + "collection", + { migrationFile: db.migrationFile } ); for (const collectionName of collectionNames) { - const targetCollection = originalCollection(collectionName); - const rollbackEntries = await autoRollbackCollection + const targetCollection = originalCollection(collectionName); + const rollbackEntries = await autoRollbackCollection .find({ migrationFile: db.migrationFile, collection: collectionName }) .sort({ timestamp: -1, orderIndex: -1 }) .project({ _id: 0, bulkWriteOperation: 1 }) .toArray(); - const operations = rollbackEntries.map(e => e.bulkWriteOperation); - await targetCollection.bulkWrite(operations, { ordered: true }); + const operations = rollbackEntries.map(e => e.bulkWriteOperation); + await targetCollection.bulkWrite(operations, { ordered: true }); } await autoRollbackCollection.deleteMany({ migrationFile: db.migrationFile }); - } catch (error) { + } catch (error) { /* istanbul ignore next */ throw new Error(`Auto-rollback failed: ${error.message}`); - } + } }; - } + } }; \ No newline at end of file diff --git a/test/env/autoRollback.test.js b/test/env/autoRollback.test.js index 653953df..154de7fd 100644 --- a/test/env/autoRollback.test.js +++ b/test/env/autoRollback.test.js @@ -105,7 +105,6 @@ describe("database - autoRollback feature", () => { mockDb = { collection: originalCollectionFunc, close: vi.fn(), - isRollback: false, migrationFile: "test-migration.js", autoRollbackCounter: 0 }; @@ -116,7 +115,6 @@ describe("database - autoRollback feature", () => { it("should wrap collection methods when autoRollbackEnabled is true", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -144,7 +142,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entry when insertOne is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -165,7 +162,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entry when insertMany is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -184,7 +180,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entry when updateOne is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -206,7 +201,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entries when updateMany is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -234,7 +228,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entry when deleteOne is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -253,7 +246,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entries when deleteMany is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -276,7 +268,6 @@ describe("database - autoRollback feature", () => { it("should store rollback entries when replaceOne is called", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -297,7 +288,6 @@ describe("database - autoRollback feature", () => { it("should increment autoRollbackCounter for each operation", async () => { const result = await database.connect(); result.db.autoRollbackEnabled = true; - result.db.isRollback = false; result.db.migrationFile = "test-migration.js"; const collection = result.db.collection("users"); @@ -350,7 +340,6 @@ describe("database - autoRollback feature", () => { } return mockCollection; }), - isRollback: true, migrationFile: "test-migration.js" }; @@ -363,16 +352,8 @@ describe("database - autoRollback feature", () => { vi.spyOn(mongodb.MongoClient, "connect").mockResolvedValue(client); }); - it("should not execute rollback when isRollback is false", async () => { - const result = await database.connect(); - result.db.isRollback = false; - - await expect(result.db.autoRollback()).rejects.toThrow("Auto-rollback is not enabled for this migration."); - }); - it("should fetch rollback entries for the current migration file", async () => { const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -398,7 +379,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -425,7 +405,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -457,7 +436,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -491,7 +469,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -535,7 +512,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -571,7 +547,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -597,7 +572,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -623,7 +597,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback(); @@ -650,7 +623,6 @@ describe("database - autoRollback feature", () => { }); const result = await database.connect(); - result.db.isRollback = true; result.db.migrationFile = "test-migration.js"; await result.db.autoRollback();