Mongoose 5.10.0 was released on August 14, 2020. Mongoose 5.10 is a semver minor version that introduces several important new features. In this article, I'll describe what is arguably the most important new feature: the Connection#transaction() function, which improves Mongoose's support for MongoDB transactions.

Why Mongoose Needs a Transaction Wrapper

The primary goal of a transaction is to let you update multiple documents in MongoDB in isolation, and potentially undo all the updates if some error occurs by aborting the transaction. For example, if you start a transaction and insert two documents, calling abortTransaction() makes it so neither of the documents end up in the database:

const session = await Customer.startSession();
session.startTransaction();

// Passing the `session` option is how you indicate that `create()` is part of a transaction
await Customer.create([{ name: 'Test' }], { session: session });
await Customer.create([{ name: 'Test2' }], { session: session });

await session.abortTransaction();

const count = await Customer.countDocuments();
count; // 0, no documents were inserted, because the transaction was aborted.

Sessions have a convenient withTransaction() function that handles aborting the transaction if an error occurs. For example, the below code shows that the withTransaction() function successfully undoes any changes when the transaction executor function throws an error.

const session = await db.startSession();

await User.create({ name: 'MongoDB 4.2' });

await session.withTransaction(async function executor() {
  const user = await User.findOne({ name: 'MongoDB 4.2' }).session(session);

  user.name = 'MongoDB 4.4';
  // By default, `save()` uses the session associated with the `findOne()`
  // query, so this `save()` is part of a transaction.
  await user.save();

  throw new Error('Oops!');
}).catch(() => {});

const doc = await Customer.findOne();
doc.name; // 'MongoDB 4.2', `withTransaction()` aborted the transaction.

The withTransaction() function makes working with transactions more convenient because it automatically aborts the transaction if the executor throws an error. Unfortunately, because withTransaction() is part of the MongoDB driver, it doesn't know about Mongoose and so it doesn't reset Mongoose document state.

In other words, if you create a document outside a transaction, try to save() the document within a transaction, and then abort the transaction, Mongoose will still think the document is saved!

const session = await db.startSession();

const doc = new User({ name: 'MongoDB 4.4' });

await session.withTransaction(async function executor() {
  await doc.save({ session });

  throw new Error('Oops!');
});

doc.isNew; // `false`, Mongoose thinks the document was saved!

This limitation is also important when you load a document outside of a transaction, modify the document within a transaction, and then abort the transaction.

const session = await db.startSession();

const doc = await User.create({ name: 'MongoDB 4.2' });

await session.withTransaction(async function executor() {
  doc.name = 'MongoDB 4.4';

  await doc.save({ session });

  throw new Error('Oops!');
});

doc.modifiedPaths(); // []
await doc.save();

doc.name; // 'MongoDB 4.4'

const fromDb = await User.findById(doc._id);
// 'MongoDB 4.2', Mongoose didn't persist the changes because it thinks `save()` succeeded!
fromDb.name;

Introducing Connection#transaction()

In Mongoose 5.10, connections have a transaction() function that you should use as a drop-in replacement for withTransaction(). Mongoose's transaction() function calls withTransaction() under the hood, but it also handles resetting document state in the event of a failed transaction.

const session = await db.startSession();

const doc = await User.create({ name: 'MongoDB 4.2' });

// Use Mongoose connection's `transaction()` instead of `withTransaction()`
await db.transaction(async function executor() {
  doc.name = 'MongoDB 4.4';

  await doc.save({ session });

  throw new Error('Oops!');
});

doc.modifiedPaths(); // ['name']
await doc.save();

doc.name; // 'MongoDB 4.4'

const fromDb = await User.findById(doc._id);
// 'MongoDB 4.4', the `transaction()` function makes sure `doc` knows to revert
// its internal state when the transaction is aborted.
fromDb.name;

Specifically, the transaction() function resets the document's change tracking and isNew value. If you save a new document and then abort the transaction, the transaction() function will handle resetting the value of Document#isNew.

const session = await db.startSession();

const doc = new User({ name: 'MongoDB 4.4' });

await db.transaction(async function executor() {
  await doc.save({ session });

  throw new Error('Oops!');
});

doc.isNew; // `true`, the `transaction()` function reset `isNew`

The transaction() function is a method on Mongoose connections. If you're working with the Mongoose global, you should use mongoose.connection.transaction():

const mongoose = require('mongoose');

mongoose.connection.transaction(async function executor() {
  // ...
});

If you're working with multiple connections, you can use conn.transaction():

const mongoose = require('mongoose');

const conn = mongoose.createConnection(process.env.MONGODB_URI);
conn.transaction(async function executor() {
  // ...
});

Moving On

The transaction() function is just one of 16 new features in Mongoose 5.10. Mongoose 5.10 also adds an officially supported optimisticConcurrency option, improved support for Atlas Text Search, and the ability to disable _id for subdocuments globally. You can find the full list on the Mongoose changelog. Make sure you upgrade to take advantage of all the new features!

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus