Mongoose 243,355 sessions are the driver's sessions of Multi-Document Transactions with one extra chore: every operation that should join the transaction must be told about it. Pass { session }; nothing else changes.
async function addReview(userId, rating) {
const session = await mongoose.startSession();
try {
return await session.withTransaction(async () => {
await Book.updateOne({ _id: bookId }, { $inc: { reviewCount: 1 } }, { session });
const [review] = await Review.create([{ bookId, userId, rating }], { session });
return review._id;
});
} finally { await session.endSession(); }
}
const state = async (l) => console.log(l, (await Book.findById(bookId)).reviewCount,
'reviews counted,', await Review.countDocuments(), 'stored');
await addReview('u_1', 5); await state('committed :'); // the unique index allows one
try { await addReview('u_1', 3); } // the same user, a second time
catch (err) { console.log('second one :', err.constructor.name, 'code', err.code); }
await state('after abort:'); // the $inc rolled back with the failed insertcommitted : 1 reviews counted, 1 stored second one : MongoServerError code 11000 after abort: 1 reviews counted, 1 stored
withTransaction is the API to use: it starts the transaction, commits it, and retries the whole callback on a TransientTransactionError or an unknown commit result — the retry loop Transaction Retries spelled out by hand. When the duplicate-key error escapes the callback the transaction aborts, so reviewCount stays at 1; without one it would have reached 2 with a single review to show for it.
Note the shape of Review.create([...], { session }): Model.create treats a second argument as options only when the first is an array, so create({ ... }, { session }) silently inserts two documents instead. The other trap is a missed { session } — that operation runs outside the transaction and survives the abort, with no warning.
Indexes
schema.index({ bookId: 1, userId: 1 }, { unique: true }) declares an index next to the schema that needs it, and by default Mongoose calls createIndex for each one when the model is first used. That autoIndex convenience is wrong in production, where index builds compete with live traffic at deploy time, unannounced. On a connection opened with { autoIndex: false }, Review.listIndexes() reported only [ '_id_' ]; one await Review.syncIndexes() — which returned [], the list of indexes it dropped — turned that into [ '_id_', 'one_review_per_user', 'bookId_1_rating_-1' ].
syncIndexes() creates what the schema declares and drops what it does not, which makes it excellent in a migration step and dangerous against a collection where an operator added an index by hand; createIndexes() is the additive half. Either way, the index is the only rule in this section MongoDB 1,815 itself enforces: a schema validator lives in your process, a unique index lives in the database, and only one of them survives a colleague with a mongosh 403 prompt.