MongoDB driver for ObjectStack — native document database support via the official MongoDB Node.js driver.
This driver has no row-level tenant isolation: it ignores
DriverOptions.tenantId, so reads carry no tenant predicate and writes are not stamped with a tenant column. Rather than serve a multi-tenant deployment without isolation, it refuses to start in one — see Multi-tenancy below. Use@objectstack/driver-sql(PostgreSQL / MySQL / SQLite) for multi-tenant deployments.
pnpm add @objectstack/driver-mongodb mongodbexport OS_DATABASE_URL=mongodb://localhost:27017/myapp
pnpm devThe ObjectStack CLI auto-registers this driver when OS_DATABASE_URL starts with
mongodb:// or mongodb+srv://. You do not need to set
OS_DATABASE_DRIVER separately — the URL scheme is enough. Set it only as an
explicit override (recognised values: mongodb, mongo).
import { defineStack } from '@objectstack/spec';
import { MongoDBDriver } from '@objectstack/driver-mongodb';
export default defineStack({
driver: new MongoDBDriver({
url: 'mongodb://localhost:27017/myapp',
database: 'myapp', // Optional: overrides URI database
maxPoolSize: 10, // Optional: connection pool size (default: 10)
minPoolSize: 1, // Optional: minimum pool (default: 1)
connectTimeoutMS: 10000, // Optional: connection timeout
}),
});| Option | Type | Default | Description |
|---|---|---|---|
url |
string |
required | MongoDB connection URI |
database |
string |
from URI | Database name |
maxPoolSize |
number |
10 |
Max connection pool size |
minPoolSize |
number |
1 |
Min connection pool size |
connectTimeoutMS |
number |
10000 |
Connection timeout (ms) |
serverSelectionTimeoutMS |
number |
5000 |
Server selection timeout (ms) |
options |
MongoClientOptions |
{} |
Additional MongoClient options |
| Category | Capability | Supported |
|---|---|---|
| CRUD | create, read, update, delete | ✅ |
| Bulk | bulkCreate, bulkUpdate, bulkDelete | ✅ |
| Query | filters, sorting, pagination, aggregations | ✅ |
| Transactions | Multi-document transactions | ✅ (requires replica set) |
| Streaming | Cursor-based async iteration | ✅ |
| Schema | Collection + index sync | ✅ |
| Advanced | Full-text search, JSON queries, geospatial | ✅ |
| Joins | Cross-collection joins ($lookup) | ❌ |
| Window Functions | ROW_NUMBER, RANK, etc. | ❌ |
| Multi-tenancy | Row-level tenant isolation | ❌ (boots single-tenant only) |
The driver is a single-tenant / embedded driver. Unlike
@objectstack/driver-sql — which resolves a tenant column per object, injects a
WHERE tenant_field = ? predicate on reads and stamps that column on writes —
this driver implements none of that layer. In a multi-tenant deployment every
query would read, update and delete other tenants' documents.
So instead of running unisolated, it fails fast at startup (#3724):
| Signal | Where it is checked | Result |
|---|---|---|
Tenancy posture is not single — OS_TENANCY_POSTURE=group|isolated, or derived from OS_MULTI_ORG_ENABLED=true |
new MongoDBDriver(), re-checked in connect() before a socket is opened |
throws MongoDBMultiTenantUnsupportedError |
An object declares tenancy.enabled: true |
syncSchema() / syncSchemasBatch() |
throws, naming every offending object |
The error carries code === 'MONGODB_MULTI_TENANT_UNSUPPORTED' so a host can
recognise it without matching on the message:
import {
MongoDBMultiTenantUnsupportedError,
MULTI_TENANT_UNSUPPORTED_CODE,
} from '@objectstack/driver-mongodb';There is deliberately no override flag — one would restore exactly the
silent cross-tenant access the guard exists to prevent. To run MongoDB, keep the
deployment single-tenant (OS_TENANCY_POSTURE=single or unset, OS_MULTI_ORG_ENABLED
unset or false, no object declaring tenancy.enabled: true); to go
multi-tenant, switch to @objectstack/driver-sql.
ObjectStack uses string IDs (nanoid). The driver:
- Stores
idas a regular string field with a unique index - Auto-generates
idvia nanoid if not provided - Never exposes MongoDB's internal
_idfield in results
All ObjectStack filter operators are supported:
// MongoDB-style filters (pass-through)
await driver.find('task', {
where: {
status: { $in: ['active', 'pending'] },
priority: { $gte: 3 },
title: { $contains: 'urgent' },
deleted_at: { $null: true },
}
});
// Legacy array-style filters
await driver.find('task', {
where: [['status', '=', 'active'], 'or', ['priority', '>=', 5]]
});Transactions require a MongoDB replica set (including single-node replica sets for development).
const session = await driver.beginTransaction();
try {
await driver.create('order', { total: 100 }, { transaction: session });
await driver.update('inventory', itemId, { stock: newStock }, { transaction: session });
await driver.commit(session);
} catch (error) {
await driver.rollback(session);
throw error;
}Schema sync creates collections and indexes:
Field-level unique fields index themselves; everything else is declared in the
object's indexes[] — the one surface an index is declared on (a field-level
indexed flag is not a FieldSchema key and never built an index,
#2377 / #6810).
A lookup field that declares reference also indexes itself, as
idx_FIELD_lookup, for join performance; so does a user field. A lookup
with no reference does not — there is no declared target to join to.
syncSchema after the upgrade builds it across collections that already hold
data, which costs real IO and time on large ones. See CHANGELOG.md for the
operational detail. Later boots are no-ops.
await driver.syncSchema('account', {
name: 'account',
fields: {
name: { type: 'string', unique: true },
email: { type: 'email' },
company_id: { type: 'lookup', reference: 'company' },
},
indexes: [{ fields: ['email'] }],
});
// Creates: idx_id_unique, idx_created_at, idx_updated_at,
// idx_name_unique, idx_company_id_lookup, idx_emailconst results = await driver.aggregate('order', {
where: { status: 'completed' },
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total_revenue' },
{ function: 'count', alias: 'order_count' },
],
groupBy: ['region'],
});Register as an ObjectStack plugin:
import mongodbPlugin from '@objectstack/driver-mongodb';
// The plugin registers automatically via onEnable
kernel.use(mongodbPlugin, {
url: 'mongodb://localhost:27017/myapp',
});# Run tests (the suites that need a real mongod SKIP — see below)
pnpm test
# Run every suite, including the ones that need a real mongod
OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1 pnpm test
# Build
pnpm buildSeven suites here need a real MongoDB, which mongodb-memory-server provides by
downloading a ~123 MB binary on first use. With a cold cache, two vitest workers
downloaded it at the same time and the loser's rename failed as an unhandled
rejection — turning an all-green run into exit 1 and ejecting unrelated PRs
from the merge queue. Those suites are therefore gated behind
OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1: without it they skip, each printing
one line that names this issue and the switch, and no download starts.
The rest of the package's tests — filter translation, the shared filter-logic
conformance case-set over the emitted documents, sort specs, tenancy guard,
temporal helpers — run by default and need no binary. The gate lives in
src/test-mongod.ts, which documents the mechanism and what a default run gives
up.
Apache-2.0. See LICENSING.md.