Mongoose 8.4.0 was released on May 17, 2024 and includes several noteworthy new features. 8.4 is the most significant minor release of the 8.x release series so far. The biggest new feature is the transactionAsyncLocalStorage option, which opts in to using Node.js' AsyncLocalStorage API to track transaction sessions automatically. No need to explicitly pass a session option to every operation in a transaction. There's also an inferRawDocType helper for TypeScript that addresses a key design issue in inferSchemaType. In this blog post, I'll cover both how to use transactionAsyncLocalStorage and the reasoning behind inferRawDocType.

transactionAsyncLocalStorage, or, No More session Options

Normally, MongoDB transactions require you to pass a session option for every database operation in the transaction to track which transaction this operation is part of. If you don't set the session option, your operation won't be part of your transaction.

const Test = mongoose.model('Test', mongoose.Schema({ name: String }));

const doc = await Test.create({ name: 'test' });

// Save a new doc in a transaction that aborts
await connection.transaction(async(session) => {
  doc.name = 'test-updated';

  // You **need** to set `session` here by default. Otherwise
  // `save()` won't execute as part of the transaction.
  await doc.save({ session });
  throw new Error('Oops');
}).catch(() => {});

The need to pass session is a pain point, because it is easy to forget the session option. In the above example, if you forget the session option, the changes to the document's name won't get rolled back when the transaction fails.

Using Node's AsyncLocalStorage API, Mongoose's transaction() function can set session for you automatically. Here's how you can enable transactionAsyncLocalStorage globally:

mongoose.set('transactionAsyncLocalStorage', true);

Once you enable transactionAsyncLocalStorage, Mongoose's transaction() function will wrap your transaction executor function in an asyncLocalStorage.run() call with the session option. Then, Mongoose will pull the session from async local storage automatically when you call save() or find(). Which means every operation in the transaction() executor will automatically be part of the transaction without you having to explicitly pass the session option.

const doc = await Test.create({ name: 'test' });

// Save a new doc in a transaction that aborts. Even without
// `session`, `doc.save()` executes within the transaction
// because of `transactionAsyncLocalStorage`. 
await connection.transaction(async() => {
  doc.name = 'test-updated';

  await doc.save(); // Notice no session here
  throw new Error('Oops');
}).catch(() => {});

AsyncLocalStorage bubbles to any function calls, so your transactions can call helper functions and Mongoose will still automatically attach the session.

await connection.transaction(async() => {
  doc.name = 'test-updated';

  // The `save()` call in `saveDoc()` will still automatically
  // get `session` attached even though it is in another function.
  await saveDoc(doc);
  throw new Error('Oops');
}).catch(() => {});

async function saveDoc(doc) {
  await doc.save();
}

Currently transactionAsyncLocalStorage is only supported as a global option. There's no way to enable transactionAsyncLocalStorage for only some transactions. However, there is no harm to setting the session option if you have transactionAsyncLocalStorage enabled. For example, the following still works as expected even though the session option is unnecessary.

const doc = await Test.create({ name: 'test' });

await connection.transaction(async(session) => {
  doc.name = 'test-updated';

  // `session` isn't necessary here if `transactionAsyncLocalStorage`
  // is enabled. But specifying `session` won't cause any errors or
  // cause the operation to execute outside the transaction.
  await doc.save({ session });
  throw new Error('Oops');
}).catch(() => {});

Getting the Lean Document Type with inferRawDocType

Mongoose 8.4 also introduces an inferRawDocType helper that returns the raw document type from a schema definition. The raw document type is the return value from lean queries, .toObject(), and .toJSON(). It also represents the shape of the object when serialized with JSON.stringify(). So the raw document type has ObjectIds, Dates, and Buffers as objects; but subdocuments are represented as POJOs and Mongoose arrays are represented as vanilla JavaScript arrays.

import mongoose from 'mongoose';

const schemaDefinition = {
  name: {
    type: String,
    required: true
  },
  myId: {
    type: mongoose.Schema.Types.ObjectId
  },
  subdocs: [new mongoose.Schema({ subdocName: String })]
};
const schema = new mongoose.Schema(schemaDefinition);

/**
 * RawDocType is the following type:
 * {
 *    name: string;
 *    myId?: mongoose.Types.ObjectId;
 *    subdocs: {
 *        subdocName?: string;
 *    }[];
 * }
 */
type RawDocType = mongoose.InferRawDocType<typeof schemaDefinition>;

How is InferRawDocType different from Mongoose's existing InferSchemaType helper? First, InferSchemaType takes the typeof the schema as an argument, not the schema definition object. Second, InferSchemaType returns the document with document arrays as Mongoose document arrays, rather than vanilla JavaScript arrays of POJOs.

import mongoose from 'mongoose';

const schemaDefinition = {
  name: {
    type: String,
    required: true
  },
  myId: {
    type: mongoose.Schema.Types.ObjectId
  },
  subdocs: [new mongoose.Schema({ subdocName: String })]
};
const schema = new mongoose.Schema(schemaDefinition);

// InferredSchemaType will have `subdocs` as `mongoose.Types.DocumentArray`
type InferredSchemaType = mongoose.InferSchemaType<typeof schema>;

In general, InferSchemaType is more for Mongoose's internal use for automatic type inference. The result type from InferSchemaType doesn't line up with the raw document type or the hydrated document type. If you need the raw document type, you should use InferRawDocType.

Moving On

transactionAsyncLocalStorage and InferRawDocType are just two of the 9 new features in Mongoose 8.4. There's also an officially supported omitUndefined() helper function, a listDatabases() function, schema-level default readConcern, and more. Make sure you upgrade to take advantage of the new features!

Want to become your team's MongoDB expert? "Mastering Mongoose" distills 10 years of hard-earned lessons building Mongoose apps at scale into 153 pages. That means you can learn what you need to know to build production-ready full-stack apps with Node.js and MongoDB in a few days. Get your copy!

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