MongoDB
The driver works and is tested, but it has not yet met the bar we set for production: MongoDB's full conformance suites, and the connection pool under sustained load. Try it and report what breaks; keep production workloads on a setup you can fall back to until it is marked stable.
Status
Working today
| Area | |
|---|---|
| MongoDB 7.0 and 8.x: one server or a replica set, with discovery, monitoring, failover and read preferences | |
mongodb:// and mongodb+srv:// (SRV and TXT lookups, TLS on by default) | |
TLS with verified certificates, SCRAM-SHA-256 and SCRAM-SHA-1, --allow-net scoping | |
CRUD, cursors, aggregation, bulk writes, indexes, cancellation with AbortSignal | |
| Sessions, causal consistency, transactions, retried reads and writes | |
Every type, int64 as bigint, passing MongoDB's BSON corpus | |
| MongoDB's discovery and monitoring, server selection, max-staleness and seedlist suites |
In the pipeline
| Area | |
|---|---|
| MongoDB's CRUD, transactions, retryable and sessions suites (covered by our own live tests today), and the connection-pool suite | |
| A soak test and failover under load for the connection pool, and a published benchmark | |
maxIdleTimeMS, minPoolSize, waitQueueTimeoutMS, socketTimeoutMS: a connection string with one is refused today | |
Change streams, sharded clusters (mongos), load-balanced mode, wire compression, client-level bulkWrite, GridFS | |
| X.509 client certificates, then AWS IAM and OIDC |
Client-side field-level and Queryable Encryption are not planned: they depend on MongoDB's C library, and esrun links no native database code.
Getting started
mongodb is built into runtime:db: there is nothing to install.
import { connect, mongodb } from "runtime:db"; await using client = await connect("mongodb://localhost:27017/app", { driver: mongodb }); const users = client.db().collection("users"); await users.insertOne({ name: "Ada", joined: new Date() }); const ada = await users.findOne({ name: "Ada" });
The methods are the ones the official MongoDB driver has, so its documentation and examples carry over: insertOne, find, updateMany, aggregate, bulkWrite, createIndex. Every option and result is on the MongoDB reference.
It connects to MongoDB 7.0 or later: one server or a replica set, named directly or through mongodb+srv://. Sharded clusters and change streams are not supported yet.
Permissions
Connecting needs --allow-net for the server's host and port:
esrun --allow-net=db.internal:27017 app.js
Credentials and TLS
Credentials go in the URL, percent-encoded, or in the options:
const client = await connect("mongodb://db.internal:27017/app", { driver: mongodb, auth: { username: "app", password: env.MONGO_PASSWORD }, tls: true, });
The authentication mechanism is negotiated: SCRAM-SHA-256 when the server offers it, SCRAM-SHA-1 otherwise. The user is looked up in the URL's database, or admin; set authSource when it is defined elsewhere.
TLS is verified. A server with a certificate from a private authority needs that authority trusted, with tlsCAFile (which also needs --allow-read for the file) or ca:
const client = await connect("mongodb://db.internal:27017/app?tlsCAFile=certs/ca.pem", { driver: mongodb, });
tlsInsecure and tlsAllowInvalidCertificates are refused. Trust the authority that signed the certificate instead.
Documents and types
A document is a plain object, and its values are BSON: _id is a BSON.ObjectId, a 64-bit integer reads as a bigint, and a date as a Date.
import { BSON } from "runtime:serialization"; const user = await users.findOne({ _id: new BSON.ObjectId(id) }); await users.updateOne({ _id: user._id }, { $inc: { visits: 1 } });
How each JavaScript value is stored is in the BSON type mapping. Two cases are worth knowing. A count the server keeps as a 64-bit integer arrives as a bigint (5n). A whole number written from JavaScript is stored as a 32-bit integer; wrap it in BSON.Double when it must be a double.
insertOne gives a document without an _id a new ObjectId, and returns it as insertedId. It inserts a copy: the object you passed is not changed.
Reading many documents
find and aggregate return a cursor. Nothing is sent until you read it, so sort, limit, skip and project chain first:
for await (const user of users.find({ active: true }).sort({ joined: -1 }).limit(50)) { console.log(user.name); } const totals = await orders .aggregate([{ $group: { _id: "$customer", total: { $sum: "$amount" } } }]) .toArray();
Results arrive a batch at a time. Leaving a for await early releases the cursor on the server; a cursor read with next() should be closed with close() or await using.
Stopping an operation
Every operation takes signal and timeoutMS:
const recent = await users .find({ active: true }, { signal: request.signal, timeoutMS: 2_000 }) .toArray();
A stopped operation rejects with the signal's reason, or ERR_DB_TIMEOUT, and the client stays usable. timeoutMS in the connection string applies to every operation that does not give its own.
Errors
A failure is a DbError with a portable code and the server's own number on backendCode:
try { await users.insertOne({ email }); } catch (e) { if (e.code === "ERR_DB_UNIQUE_VIOLATION") return conflict(e.server.keyValue); throw e; }
e.server carries the server's codeName and errorLabels. A failed write in insertMany or bulkWrite also carries every writeErrors entry and the result of what was written before it.
Sessions
Operations run in sessions without your asking. Start one yourself when several operations belong together: a read in it sees the session's earlier writes.
await using session = client.startSession(); await orders.insertOne(order, { session }); const placed = await orders.findOne({ _id: order._id }, { session });
Replica sets
List one or more members and name the set; the client finds the rest and follows elections:
const client = await connect( "mongodb://db1.internal,db2.internal/app?replicaSet=rs0", { driver: mongodb }, );
Writes go to the primary. Reads do too, unless you say otherwise, for the whole client or for one read:
const reports = await connect(url, { driver: mongodb, readPreference: "secondaryPreferred" }); const nearby = await orders .find({ day }, { readPreference: { mode: "nearest", tags: [{ dc: "eu" }] } }) .toArray();
Atlas and other SRV connection strings
A mongodb+srv:// string, as MongoDB Atlas gives out, works as it is. TLS is on by default. DNS supplies the members, so allow the lookup's host and each member it returns:
const client = await connect( "mongodb+srv://app:secret@cluster0.abcde.mongodb.net/shop", { driver: mongodb }, );
esrun --allow-net=cluster0.abcde.mongodb.net,ac-1.abcde.mongodb.net:27017,ac-2.abcde.mongodb.net:27017,ac-3.abcde.mongodb.net:27017 app.js
A bare --allow-net covers all of them.
A read from a secondary may not see the latest writes; maxStalenessSeconds keeps reads off secondaries that have fallen behind.
During an election there is briefly no primary. Writes wait for the new one, up to serverSelectionTimeoutMS (30 seconds by default), and then proceed. A write caught by the step-down itself is retried once on the new primary, and the server applies it only once; so is a read whose connection drops. See retries for which operations qualify. To reach one member and nothing else, use directConnection=true.
Every member must be reachable under the name the set's configuration gives it, and allowed by --allow-net.
Transactions
On a replica set, client.transaction runs a function in a transaction and commits it: every write in it is kept, or none is.
await client.transaction(async (tx) => { const accounts = tx.db().collection("accounts"); await accounts.updateOne({ _id: from }, { $inc: { balance: -amount } }); await accounts.updateOne({ _id: to }, { $inc: { balance: amount } }); });
Inside the function, tx is the client seen through the transaction: its databases and collections run in it without being passed a session. Throwing abandons the transaction. When the server reports a conflict, or the commit's outcome is unknown, the transaction or the commit is retried for up to two minutes, so the function may run more than once: keep anything that must not happen twice, like sending an email, outside it.
A standalone server has no transactions, and transaction says so. For finer control, sessions have startTransaction, commitTransaction and abortTransaction.
Pool size
A client keeps a pool of connections to each server, 100 at most by default. Set maxPoolSize in the URL or the options, or pool: { max } in connect(). Operations past that wait for a connection; signal and timeoutMS stop the wait too. An idle client keeps nothing running, so a program that has finished exits without close().