MongoDB

DBType.MongoDB runs the same models and repositories against a document store. Relations, hooks, versioning, caching, encryption and migrations behave as they do on the SQL backends, and clauses MongoDB cannot answer are refused rather than approximated. Start with the server: MongoDB supports transactions only on a replica set, and every repository write uses one.

Install the Driver

The mongodb package is an optional dependency of the ORM, so it is installed alongside rather than delivered by it.

terminal
❯bun add mongodb

Nothing fails at import time while it is absent. The driver is loaded on the first MongoDB statement, and a missing one throws MONGO_DRIVER_MISSING there.

Configuration

Set type to DBType.MongoDB and supply a standard MongoDB connection string.

config/database.ts
import { DBType, type DBConfig } from "stabilize-orm";
const dbConfig: DBConfig = {
type: DBType.MongoDB,
// Both query parameters on this string are load-bearing. See below.
connectionString:
"mongodb://127.0.0.1:27017/mydb?directConnection=true&replicaSet=rs0",
retryAttempts: 3,
retryDelay: 1000,
};
export default dbConfig;

mongoOptions passes driver settings to MongoClient unchanged. Keys are the driver's own option names; the ORM does not rename, filter or validate them.

config/database.ts
const dbConfig: DBConfig = {
type: DBType.MongoDB,
connectionString:
"mongodb://127.0.0.1:27017/mydb?directConnection=true&replicaSet=rs0",
mongoOptions: {
tls: true,
authSource: "admin",
maxPoolSize: 20,
},
};

database selects the database when the connection string has no path. When the string has one, that path is used and database is ignored.

The connection opens on the first statement rather than in the DBClient constructor, so an unreachable server surfaces on your first query. Concurrent first queries share a single connection attempt.

Replica Set Requirement

A standalone mongod can read but not write

Transactions require a replica set or a sharded cluster, and every repository write runs inside one. On a standalone server every write therefore fails, not only code that opened a transaction by hand. The connection succeeds and reads answer normally, which is what makes this easy to misread as a query problem. The client logs a warning at connect time, and the write itself fails with TX_ERROR.

typescript
// Against a standalone mongod, with no replica set:
await userRepo.create({ email: "ada@example.com" });
// StabilizeError (TX_ERROR): MongoDB transactions require a replica set or
// sharded cluster, and this server is a standalone. Every write goes through
// a transaction, so start the server with --replSet and run rs.initiate()
// (or point the connection at an existing replica set).

For local development a single node is enough. Start it as a replica set and initiate it once:

terminal
# Start a single node that calls itself a replica set:
mongod --replSet rs0 --dbpath ./data/db --bind_ip_all
# Then, once, against that server:
mongosh --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "127.0.0.1:27017" }] })'

The connection string must then carry directConnection=true. rs.initiate records the member by the address you give it, which inside a container is the container's host and port rather than the one your process dialled. A driver performing topology discovery reads that address out of the set configuration and reconnects to it, and from the host there is nothing listening there. directConnection=true keeps the driver on the connection it already has and skips discovery.

config/database.ts
const dbConfig: DBConfig = {
type: DBType.MongoDB,
// replicaSet names the set; directConnection skips topology discovery, which
// would otherwise be handed the member's container address and fail.
connectionString:
"mongodb://127.0.0.1:27017/mydb?directConnection=true&replicaSet=rs0",
};

Reads work on a standalone, so the connection is not refused. A read-only deployment against a standalone server is valid.

Transactions

A MongoDB transaction is a session. The ORM opens one on the client and attaches it to every statement made through the transaction client, which keeps the parent MongoClient and adds the session. The callback is the same as on any other backend.

transaction.ts
await orm.transaction(async (txClient) => {
const userRepo = orm.getRepository(User);
const profileRepo = orm.getRepository(Profile);
const user = await userRepo.create({ email: "ada@example.com" }, {}, txClient);
await profileRepo.create({ userId: user.id }, {}, txClient);
});

Pass txClient to every call inside the callback. A call that uses the ORM client instead takes a different session and runs outside the transaction.

The driver's withTransaction handles commit and rollback under readConcern: snapshot and writeConcern: majority, and ends the session whether the transaction committed or rolled back. It also replays the callback when the server reports a transient error, which is how two concurrent creates allocating ids from the same counter document both succeed. The callback is therefore not guaranteed to run exactly once, so an after* hook that sends an email, publishes to a queue or calls a webhook can fire twice. Make those idempotent, or move them outside the transaction.

What Works Unchanged

These use the same methods, the same options and the same return values as the SQL backends.

  • The whole Repository API — create, find, update, delete, findMany, paginate and the rest.
  • All four relation kinds, including many-to-many link management through attach, detach and sync.
  • Versioning and time travel — asOf, history and rollback, against a history collection the migration creates alongside the model.
  • Optimistic locking, soft delete and recover, and query scopes.
  • Every lifecycle hook, and validation — the same rules, promoted to server-enforced $jsonSchema validators as well.
  • Caching, both cache-aside and write-through, over the same cache configuration.
  • AES-GCM column encryption for encrypted: true columns, decoded on read exactly as on the SQL backends.
  • Aggregates — count, sum, avg, min, max and countDistinct.
  • Cursor pagination through findMany, which renders as a $gt / $lt filter rather than an offset.
  • bulkCreate and bulkUpsert, firstOrCreate and updateOrCreate, and increment / decrement.
  • Transactions, subject to the replica set above.

Unsupported Query Clauses

MongoDB has no joins and no SQL text, and none of it is emulated. A clause with no equivalent is recorded when you write it and the query throws when it executes, naming every offending method at once. Twenty-two query-builder methods are refused this way, along with the client's rawQuery and rawExec:

MethodReplacement
rawQuery, rawExecThere is no statement to send. Use the repository API, or the client's mongoFind / mongoUpdateOne / mongoCommand methods, which take documents rather than SQL.
join, innerJoin, leftJoin, rightJoin, fullJoin, crossJoinwithRelations(), which loads related documents with batched reads instead of a join.
union, unionAllTwo queries, or a single $or filter.
with, withRecursiveNothing — MongoDB has no common table expressions.
whereRaw, whereNot, whereRefThe structured condition methods — whereEq, whereCompare, whereIn, whereLike — or updateBy() and deleteBy() for a bulk write.
where, orWhereThe same structured condition methods. Both take raw SQL text, and none of it is parsed.
whereExists, whereNotExistswithRelations(), or a second query.
selectRaw, orderByRaw, groupByRaw, havingselect(), orderBy() and the aggregate helpers.
distinctcountDistinct() for a count. There is no DISTINCT over a projection.
blocked-query.ts
await userRepo
.find()
.innerJoin("posts", "posts.user_id = users.id")
.whereRaw("LOWER(name) = 'ada'")
.execute(orm.client);
// StabilizeError (MONGO_UNSUPPORTED): This query cannot be translated to
// MongoDB: innerJoin, whereRaw have no MongoDB equivalent. MongoDB is a
// document store — it has no joins, no set operations and no SQL text. Use
// withRelations() for related documents, or run this query against a SQL backend.
// innerJoin: INNER JOIN posts ON posts.user_id = users.id
// whereRaw: LOWER(name) = 'ada'

The message quotes the fragment that could not be translated, under the method that produced it, so a blocked query reports the join condition and the raw SQL rather than method names alone.

The error is raised at execution rather than at the call. The builder stays dialect-agnostic until a client is supplied, which is what lets one builder render for both a SQL backend and MongoDB.

Locking

MongoDB has no SELECT … FOR UPDATE and nothing that stands in for it: a document read cannot be held against a concurrent writer. lock() and forUpdate() are skipped and the query still answers correctly, matching how the SQL Server path already treats an untranslatable FOR UPDATE.

A locked read and an unlocked read are indistinguishable, so a read-modify-write built on lockForUpdate() produces a lost update with nothing to observe. Repository.lockForUpdate() logs a warning for that reason.

typescript
const user = await userRepo.lockForUpdate(1);
// log warn: lockForUpdate on users takes no lock on MongoDB: use a transaction
// or an atomic update for read-modify-write

A lock() called on the builder by hand and passed straight to execute() is skipped without a warning; the builder holds no logger.

For a read-modify-write that has to be safe, use a transaction or an atomic update — increment() with a condition, or an optimistic-locking column.

Behaviour Differences

These do not throw. They behave differently, and the difference shows up in the data rather than in an error.

  • DECIMAL is stored as a double. MongoDB has no exact decimal unless the caller supplies a Decimal128, so a DECIMAL column loses precision the way any binary float does. Store money as an INTEGER or BIGINT in the smallest unit, or as a STRING.
  • Auto-increment ids come from a counter that rolls back. Ids are reserved with a $inc against a stabilize_counters collection keyed by collection name, inside the same transaction as the write. An aborted transaction returns its ids and the next insert reuses them. MySQL behaves the other way round: InnoDB's auto-increment counter is not transactional, so an aborted insert leaks the gap. Code that assumes an id is never issued twice has to account for this.
  • $inc initialises an absent field. increment() is emitted as $inc, so it runs server-side and is as safe under concurrency as SQL's col = col + ?. Applied to a field that does not exist it writes the increment, where SQL computes NULL + n and leaves the column NULL.

Type Mapping

There is no SQL type to map onto, so each DataTypes member becomes the list of BSON types the collection's validator accepts. Several members accept more than one: a JavaScript integer arrives as an int inside 32-bit range and a double beyond it, so accepting only int would reject every id past two billion.

DataTypesBSON types
DataTypes.STRINGstring
DataTypes.TEXTstring
DataTypes.INTEGERint | long | double
DataTypes.BIGINTint | long | double
DataTypes.FLOATdouble | int | long | decimal
DataTypes.DOUBLEdouble | int | long | decimal
DataTypes.DECIMALdouble | int | long | decimal
DataTypes.BOOLEANbool
DataTypes.DATEdate | string
DataTypes.DATETIMEdate | string
DataTypes.JSONobject | array | string
DataTypes.UUIDstring
DataTypes.BLOBbinData | string

An unrecognised type falls back to string rather than failing, as it falls back on every other backend, and an encrypted: true column is always a string whatever it was declared as, because the ciphertext is what is stored.

The column named id is stored as the document's _id. An integer id is generated by the counters collection described above; a STRING or UUID id is supplied by the caller and stays required.

Collections, Validators and Indexes

A migration against MongoDB creates collections, indexes and $jsonSchema validators. The generated up is a createCollection carrying the validator, followed by the collection's createIndex steps; the down is a dropCollection, since the indexes and the validator go with it. Migrations are recorded in a stabilize_migrations collection, where the migration's name is the _id.

Adding a column is therefore a validator and index change only. A document either carries a field or it does not, so there is nothing to backfill: a new optional column widens the validator, and every existing document already satisfies it.

Two rules the generated schema always follows

  • validationLevel is "moderate", never "strict". Under strict, an update to a document that lacks a newly declared required field is rejected, so update() would fail on exactly the documents the migration declared the field for. moderate applies the validator only where it already holds.
  • Every unique index is sparse: true. MongoDB treats two documents that both lack a field as both null and collides them, where SQL treats two NULLs as distinct. Without sparse, email: { unique: true } would reject the second document that simply omits email, where the same model on any SQL backend accepts it.

createIndex is idempotent in MongoDB: asking for an index that already exists with the same spec and options is a no-op, so a second autoMigrate run is safe. Asking for the same index name with different options raises IndexOptionsConflict, which needs a drop first. An existing collection is not re-validated either: changing a validator is what collMod does, and applying that on every autoMigrate would replace a validator a DBA had tightened by hand.

Migration steps are not wrapped in a transaction. createIndex is not permitted inside one, and DDL is not transactional in MongoDB at all, so a transaction would either fail on the first index or imply an atomicity that is not there. A migration that fails halfway leaves a partially migrated collection and no ledger entry, so re-running it applies it again from the start. MySQL's auto-committing DDL behaves the same way.

Limitations

  • No raw SQL. rawQuery, rawExec and every clause that carries SQL text throw MONGO_UNSUPPORTED; see Unsupported Query Clauses above for the replacements. The escape hatch is at the driver level: the client exposes the commands the ORM does not model — mongoFind, mongoUpdateOne, mongoAggregate, mongoCommand and the rest — which take and return documents directly.
  • lockForUpdate() returns the document without protecting it. It warns rather than throwing, because the query itself is answerable.
  • No savepoints. Nested transaction() calls reuse the outer transaction on every backend, so an inner failure rolls back the whole thing.
  • poolStats() is SQL Server only. It reads size, borrowed and available off the mssql pool. For any other driver it returns { active: -1, idle: -1, total: -1 }, MongoDB included.

whereILike is not among the limitations. It emits ILIKE on the other backends, which only Postgres understands, so the same call does nothing useful on MySQL, SQLite or SQL Server. MongoDB has a case-insensitive regular expression, so it renders as { name: { $regex: pattern, $options: "is" } } and answers. Postgres and MongoDB are the two backends where this call does something. The s alongside the i is the dotall flag, since SQL's % has to match a newline the way .* otherwise would not.