micromongo
MongoDB-like queries over plain JavaScript arrays of objects — zero database, in-memory.
An array of objects (documents, in MongoDB's terms) is a very common
data structure. If your app works with this kind of data, you want
something lightweight, and you already know MongoDB's query syntax,
micromongo lets you run the same
find/update/aggregate you'd
write against MongoDB — directly over the array. It runs in
Node and the browser, ships TypeScript types, and
has no dependencies you need a server for.
micromongo exposes every operation two ways. The
functional API
(mm.<op>(array, …)) works on any array directly
and is always a linear scan — it can't own the array, so it can't
keep an index valid. The
Collection / Cursor API wraps the
array (which it then owns), returning chainable cursors and
supporting opt-in ordered indexes for scale.
Same results either way — only speed differs.
Try it live
This runs the real micromongo engine
in your browser (the committed IIFE build — the same
engine as the npm package, no server). Edit the data and the
expression, then Run (it also re-runs as you type).
mm (the functional API) and db (a small
collection registry) are in scope; the value of the last expression
is shown.
Loading the in-browser build…
$where written as a string (e.g.
{ $where: "this.qty > 20" }) runs your text as live
code here, so only use it with queries you wrote yourself — never a
string from an untrusted source. A $where written as a
function, and $expr, are always safe.
Install
npm install --save micromongo
var mm = require("micromongo");
import mm from "micromongo";
// (needs esModuleInterop; else:
// import mm = require("micromongo"))
The library is written in TypeScript and ships type
definitions (.d.ts), so the read/write/aggregate surface
is typed against your document shape:
interface User { _id: number; email: string; age: number; }
const users: User[] = [{ _id: 1, email: "a@b.c", age: 30 }];
mm.find(users, { age: { $gt: 18 } }); // inferred return type: User[]
mm.updateOne(users, { _id: 1 }, { $inc: { age: 1 } });
mm.find(users, { age: { $gt: "old" } }); // ✗ compile error — $gt on age wants a number
There's also a leaner micromongo/core entry (functional
API only, no Collection/Cursor/index layer) for the smallest browser
bundle, and a browser IIFE build that exposes a global
micromongo.
Target Node version: ≥ 8.
Quick start
The same task, done both ways:
var mm = require("micromongo");
var orders = [
{ _id: 1, status: "A", qty: 30 },
{ _id: 2, status: "B", qty: 10 },
{ _id: 3, status: "A", qty: 50 },
];
mm.find(orders, { status: "A" });
// → [ {_id:1,…}, {_id:3,…} ]
mm.aggregate(orders, [
{ $group: { _id: "$status",
total: { $sum: "$qty" } } },
]);
// → [ {_id:'A',total:80}, {_id:'B',total:10} ]
var mm = require("micromongo");
var c = new mm.Collection([
{ _id: 1, status: "A", qty: 30 },
{ _id: 2, status: "B", qty: 10 },
{ _id: 3, status: "A", qty: 50 },
]);
c.createIndex({ status: 1 });
c.find({ status: "A" })
.sort({ qty: -1 })
.limit(1)
.toArray();
// → [ {_id:3,…} ] (served via IXSCAN)
By default a query is a linear scan over the array,
plenty fast for typical in-memory data. For larger collections, a
Collection can carry an
opt-in ordered index to serve equality,
range, sort, $in, compound-prefix and $or
queries from the index instead of scanning — see
Performance.
Operations — functional vs Collection / Cursor
Each row shows the same task done both ways, computed
live against the engine in your browser. The
functional form takes the array as its first
argument; the Collection form omits it (the
Collection owns the data, kept in c). For ops that exist
on only one surface, the other cell shows the workaround. Each
call line and the seed data are editable — type to re-run.
Functional — mm.*(array, …)
|
Collection / Cursor | MongoDB compatibility |
|---|---|---|
| Loading examples… | ||
✅ full · ⚠️ with limits · — micromongo-specific, no Mongo equivalent. See the full compatibility matrix for per-query-operator, per-update-operator, and per-aggregation-stage status.
Reads
count()
Returns the number of documents matching a query.
var mm = require("micromongo");
mm.count([{ a: 1 }, { a: 2 }, { a: 3 }], { a: { $gte: 2 } }); // → 2
mm.count([{ a: 1 }, { a: 2 }, { a: 3 }], {}); // → 3 (empty query ⇒ total)
find() / findOne()
find() returns a deep copy of the documents matching
query, with fields filtered by an optional
projection. If documents carry an _id,
projection follows Mongo's rule of including _id by
default. findOne() returns the first match, or
null.
var array = [
{ qty: 10, price: 10 }, { qty: 10, price: 0 },
{ qty: 20, price: 10 }, { qty: 20, price: 0 },
{ qty: 30, price: 10 }, { qty: 30, price: 0 },
];
var query = { $or: [ { quantity: { $eq: 20 } }, { price: { $lt: 10 } } ] };
mm.find(array, query, { qty: 1 });
// → [ { qty: 10 }, { qty: 20 }, { qty: 30 } ]
mm.findOne(array, query, { qty: 1 });
// → { qty: 10 }
distinct()
Distinct values for a field across matching documents (array-valued fields are flattened, like MongoDB; deep-equal dedup; dotted paths).
mm.distinct([{ a: 1 }, { a: 2 }, { a: 1 }], "a"); // → [ 1, 2 ]
mm.distinct([{ tags: ["x", "y"] }, { tags: ["y"] }], "tags"); // → [ 'x', 'y' ]
Writes & updates
Reads (find/findOne/count/distinct/aggregate) are non-mutating and return deep copies.
The write family mutates the passed array in place
and returns a driver-shaped report.
insert() / insertOne() /
insertMany()
mm.insertOne(array, { _id: 1, a: 1 });
// → { acknowledged: true, insertedId: 1, insertedCount: 1 }
mm.insertMany(array, [{ _id: 1 }, { _id: 2 }]);
// → { acknowledged: true, insertedCount: 2, insertedIds: { '0': 1, '1': 2 } }
mm.insert(array, doc); // dispatches to insertOne
mm.insert(array, [docs]); // …or insertMany
micromongo does not auto-generate
_id (reads stay non-mutating). To match MongoDB, set
mm.configure({ autoId: true }) — then inserts and
upserts generate a unique string _id for any document
lacking one (an explicit _id is always preserved).
updateOne() / updateMany()
updateOne updates the first match;
updateMany updates all. The
update must be an operator document
($set, $inc, …). modifiedCount
follows MongoDB: a matched-but-unchanged document contributes
0.
var array = [
{ _id: 1, status: "A", qty: 10 },
{ _id: 2, status: "A", qty: 20 },
{ _id: 3, status: "B", qty: 30 },
];
mm.updateOne(array, { status: "A" }, { $set: { status: "C" } });
// → { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
mm.updateMany(array, { status: "A" }, { $inc: { qty: 5 } });
// → { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// upsert — no match inserts a doc built from the query + update:
mm.updateOne(array, { _id: 7 }, { $set: { status: "NEW" } }, { upsert: true });
// → { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: 7, upsertedCount: 1 }
Update operators — field ($set
$unset $inc $mul
$min $max $rename
$currentDate $setOnInsert
$bit), array ($push $addToSet
$pop $pull $pullAll, with
$each/$position/$slice/$sort
modifiers), and positional (see below).
Positional $ / $[] / $[<id>]
The query-bound positional $ updates the
first array element matched by the query. The array
field must appear in the query condition (either directly or via
$elemMatch). For every element use
$[]; for a filtered subset use
$[<id>] with arrayFilters.
var students = [{ _id: 1, grades: [85, 80, 80] }];
mm.updateOne(students, { _id: 1, grades: 80 }, { $set: { "grades.$": 82 } });
// students[0] → { _id: 1, grades: [ 85, 82, 80 ] } // first 80 (index 1) changed
// into an array of sub-documents, via $elemMatch:
mm.updateOne(
classes,
{ _id: 4, grades: { $elemMatch: { grade: 85 } } },
{ $set: { "grades.$.std": 6 } },
);
replaceOne()
Replaces the first match with a plain document (no
operators), preserving its position. Supports
{ upsert: true }.
mm.replaceOne(array, { _id: 1 }, { _id: 1, status: "Z" });
// → { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
findOneAndUpdate() / findOneAndReplace() /
findOneAndDelete()
Like the corresponding write, but
return the affected document instead of a report —
by default the document as it was before the modification
(MongoDB's default), or null if nothing matched.
var before = mm.findOneAndUpdate(array, { _id: 1 }, { $set: { a: 2 } }); // pre-update doc
var deleted = mm.findOneAndDelete(array, { _id: 1 }); // the removed doc
deleteOne() / deleteMany() /
remove()
mm.deleteOne(array, query); // → { acknowledged: true, deletedCount: 1 }
mm.deleteMany(array, query); // → { acknowledged: true, deletedCount: 2 }
remove() is deprecated (the MongoDB
driver dropped it in v4) — use deleteOne/deleteMany. Kept for back-compat; it returns the legacy
{ nRemoved } shape.
bulkWrite()
A batch of heterogeneous writes in one call, returning an aggregated
BulkWriteResult. Each operation is exactly one of
insertOne/updateOne/updateMany/replaceOne/deleteOne/deleteMany.
var res = mm.bulkWrite(pizzas, [
{ insertOne: { document: { _id: 3, type: "beef", size: "medium", price: 6 } } },
{ updateOne: { filter: { type: "cheese" }, update: { $set: { price: 8 } } } },
{ deleteOne: { filter: { type: "pepperoni" } } },
{ replaceOne: { filter: { type: "vegan" }, replacement: { type: "tofu", size: "small", price: 4 } } },
]);
// → { acknowledged: true, insertedCount: 1, matchedCount: 2, modifiedCount: 2,
// deletedCount: 1, upsertedCount: 0, insertedIds: { '0': 3 }, upsertedIds: {} }
options.ordered (default true) stops at the
first error; false attempts every op and collects
per-op failures into writeErrors: [{ index, errmsg }].
Query operators
micromongo supports the MongoDB query-operator families:
comparison ($eq $ne
$gt $gte $lt
$lte $in $nin),
logical ($and $or
$nor $not), element
($exists $type),
evaluation ($mod $regex
$where $text $expr),
array ($all $elemMatch
$size), bitwise ($bits*),
and geospatial ($geoWithin
$near …).
$regex
mm.find([{ a: "abc" }, { a: "bcd" }, { a: "cde" }], { a: { $regex: /^bc/ } });
// → [ { a: 'bcd' } ]
$expr
Use an aggregation expression inside a
query — e.g. to compare two fields of the same document. Unlike
$where, $expr runs no arbitrary
JS (no vm), so it's safe over untrusted input.
var monthlyBudget = [
{ _id: 1, category: "food", budget: 400, spent: 450 },
{ _id: 2, category: "drinks", budget: 100, spent: 150 },
{ _id: 3, category: "misc", budget: 500, spent: 300 },
];
mm.find(monthlyBudget, { $expr: { $gt: ["$spent", "$budget"] } });
// → docs 1 and 2 (spent > budget)
// $expr combines with ordinary query clauses:
mm.find(monthlyBudget, {
category: { $in: ["food", "misc"] },
$expr: { $gt: ["$spent", "$budget"] },
});
// → doc 1
$rand
{ $rand: {} } is an aggregation expression returning a
random float in [0, 1) — most often used via
$expr to sample documents:
// keep ~half the documents at random:
mm.find(voters, { $expr: { $lt: [0.5, { $rand: {} }] } });
$where
$where runs a JS predicate per document (the document is
bound as this). It can be a function or a string.
mm.find(array, { $where: function () { return this.qty > 20; } });
mm.find(array, { $where: "this.qty > 20" });
$where: the string form
executes arbitrary JavaScript (in Node via the vm
module, which is not a security sandbox; in the browser via
new Function). Treat $where as
trusted-input-only — never assemble a
$where string from end-user input. For computed queries
over untrusted input, prefer a value-based query or
$expr (which runs no JS).
Aggregation
aggregate(array, stages) deep-copies the input, then
folds each stage's output into the next (non-mutating).
Every aggregation stage MongoDB defines that's feasible
in-memory is implemented: $match,
$project, $limit, $skip,
$sort, $unwind, $group,
$addFields/$set, $unset,
$count, $sortByCount,
$replaceRoot/$replaceWith,
$sample, $redact, $geoNear,
$lookup, $out, $indexStats.
mm.aggregate(orders, [
{ $match: { status: "A" } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } },
{ $limit: 5 },
]);
$unwind accepts the shorthand or the full form:
mm.aggregate(array, [{ $unwind: "$customer.items" }]);
mm.aggregate(array, [
{ $unwind: {
path: "$customer.items",
includeArrayIndex: "idx",
preserveNullAndEmptyArrays: true,
} },
]);
$rand). Date /
type-conversion / set operators and a few stages
($facet/$bucket/…) are partial or
unimplemented — see the
full matrix.
Collections & indexes
For larger data (or a stateful, driver-shaped object), wrap the array
in a Collection. It owns the array, forwards
reads/writes/aggregations to the same engine, and adds
opt-in ordered indexes:
var orders = new mm.Collection([ /* … */ ]);
orders.createIndex({ status: 1 }); // single-field / multikey / compound
orders.find({ status: "A" }).sort({ qty: -1 }).limit(2).toArray(); // lazy Cursor
orders.find({ status: "A" }).explain(); // { stage: 'IXSCAN', … } — doesn't run the query
The index is a pure accelerator, safe by construction:
it only ever supplies a candidate superset that the match
engine re-filters, so it never changes results — only speed. It
serves equality, range ($gt/$gte/$lt/$lte), sort, $in, array (multikey),
compound-prefix, and $or (when every branch is
index-served); everything else transparently falls back to the scan.
Use c.find(q).explain() to see the chosen plan
(COLLSCAN/IXSCAN/IXSCAN+FILTER/OR).
mm.collection(name, array) / mm.db.<name>
register named collections so $out/$lookup
can resolve a collection by name.
Streaming cursors
A Collection cursor is lazy: besides
.toArray() it streams — for…of,
spread ([...cursor]), for await, and a Node
.stream() — pulling one document at a time instead of
materializing the whole result.
var c = new mm.Collection(bigArray);
// for…of / spread — pulls lazily
for (const doc of c.find({ status: "A" }).limit(5)) { /* … */ }
const first3 = [...c.find({ status: "A" }).limit(3)];
// for await (same lazy stream; driver-shape compatible)
for await (const doc of c.find({ status: "A" })) { /* … */ }
// Node Readable object-stream (pipe / event consumers)
c.find({ status: "A" }).stream().on("data", (doc) => { /* … */ });
sort, a
limit(k) stream stops scanning once k docs are
emitted — find(q).limit(5) over a million-element array
reads only until it has 5, in constant memory.
sort + limit.
A global sort must scan every candidate (you can't
know the top-K without seeing them all), but micromongo never
buffers them: sort().limit(k) keeps only a
k-sized heap while scanning — O(N·log K) time,
O(K) memory, never the full sorted array. Results (incl. tie
order) are identical to a full sort.
Streaming is a pure performance path — [...cursor] always
deep-equals cursor.toArray() (a randomized equivalence
test guards this). Streamed documents are deep copies, like every read.
(.stream() is Node-only; in the browser build it throws
a clear message — use for…of/for await instead.)
Extending — registerOperator()
Add a custom query operator (the blessed extension point). A registered operator — including yours — is visible immediately to both the functional API and Collections.
mm.registerOperator("post", "$isEven", function (value) {
return value % 2 === 0;
});
mm.find([{ n: 1 }, { n: 2 }], { n: { $isEven: true } }); // → [ { n: 2 } ]
kind is 'post' (field-level comparison),
'pre' (whole-document / logical), or
'preprocess' (run once before matching).
CLI / REPL
micromongo ships a mongosh-flavored,
in-memory command-line tool. There's no server, so instead
of a connection string you load local JSON arrays as collections with
--load file.json:name (repeatable).
# interactive shell, like mongosh:
micromongo --load orders.json:orders --load customers.json:customers
micromongo> show collections
[ { name: 'orders', count: 5 }, { name: 'customers', count: 12 } ]
micromongo> db.orders.find({ status: 'A' }).sort({ qty: -1 }).limit(2).toArray()
micromongo> db.orders.createIndex({ status: 1 })
micromongo> db.orders.find({ status: 'A' }).explain()
{ stage: 'IXSCAN', indexed: true, exact: true, plan: { index: 'status_1', … } }
# one-shot --eval (repeatable; only the last result prints):
micromongo --eval "db.orders.find({status:'A'}).toArray()" --load orders.json:orders
# run a script file:
micromongo --file report.js --load orders.json:orders
Shell commands: show collections/show dbs,
use <name> (cosmetic), load(),
save(), help, exit. Anything
else runs as JavaScript against the live API. Add
--shell after --eval/--file to
stay interactive; --quiet suppresses the banner.
MongoDB driver mock — micromongo/mock
micromongo/mock is a drop-in,
mongodb-driver-shaped adapter backed by the in-memory
engine — so other projects can run their existing test
suites against micromongo instead of a live MongoDB. It
mirrors the native driver: MongoClient /
Db / Collection /
FindCursor / AggregationCursor /
ObjectId, with async (Promise) results,
for await cursors, and auto-ObjectId
_id on insert.
const { MongoClient, ObjectId } = require("micromongo/mock");
const client = await MongoClient.connect("mongodb://localhost/test");
const users = client.db().collection("users");
await users.insertOne({ name: "ada", age: 36 }); // → { acknowledged, insertedId: ObjectId(…) }
const adults = await users
.find({ age: { $gte: 18 } })
.sort({ age: -1 })
.toArray();
Point the code under test at it without changing that code — e.g. in Jest:
// jest.config.js
module.exports = { moduleNameMapper: { "^mongodb$": "micromongo/mock" } };
aggregate,
bulkWrite + the fluent
initialize*BulkOp builders, and the index methods
(createIndex(es)/listIndexes/indexExists/…)
all work. Cursor wire/planner hints
(hint/collation/maxTimeMS/…)
are chainable no-ops (meaningless in-memory).
ObjectId uses the consumer's real
bson
if installed (optional peer dependency), else a self-contained
24-hex fallback.
abortTransaction() does not roll back (there's a single
in-memory array). Server-only features throw loudly
(watch() change streams, Atlas
*SearchIndex*) so a test depending on a real server
fails instead of silently passing.
Wiring it into your test runner
There are two ways to get the mock in front of your code under
test. Pick by whether that code requires
mongodb directly or receives a Db.
Style 1 — module replacement. The app keeps
require("mongodb") untouched; the runner rewrites that
specifier to micromongo/mock. No app change at all.
// Jest — jest.config.js
module.exports = { moduleNameMapper: { "^mongodb$": "micromongo/mock" } };
// Vitest — vitest.config.ts
export default { resolve: { alias: { mongodb: "micromongo/mock" } } };
// …then your app code and tests keep importing "mongodb" as usual:
const { MongoClient } = require("mongodb"); // ← resolves to micromongo/mock under test
Style 2 — dependency injection. Runners without a
module-mapping layer (node:test, Mocha, AVA, …) need no
config: import the mock in the test and pass its Db into
the code under test (which should take a Db rather than
importing a driver — good practice regardless). Works everywhere.
// repository.js — the code under test takes a Db; never imports a driver
function makeUserRepo(db) {
const users = db.collection("users");
return {
create: (u) => users.insertOne(u).then((r) => r.insertedId),
adults: () => users.find({ age: { $gte: 18 } }).sort({ age: 1 }).toArray(),
};
}
// user.test.mjs — runner-agnostic (shown with node:test)
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { MongoClient } from "micromongo/mock"; // ← swap for "mongodb" in production wiring
import { makeUserRepo } from "./repository.js";
let repo;
beforeEach(async () => {
const client = await MongoClient.connect("mongodb://localhost/test"); // fresh ⇒ isolated
repo = makeUserRepo(client.db());
});
test("finds adults sorted by age", async () => {
await repo.create({ name: "kid", age: 12 });
await repo.create({ name: "ada", age: 36 });
assert.deepEqual((await repo.adults()).map((u) => u.name), ["ada"]);
});
MongoClient owns its
databases, so a fresh client per test (in
beforeEach) gives clean state with no global reset — and
makes parallel/concurrent runners (AVA, Vitest threads) safe by
construction.
Mock vs. stubs vs. a real server
Three ways to test code that talks to MongoDB, from lightest to
most faithful. micromongo/mock sits in the middle:
no server, but your real query / update / aggregation logic
actually runs against the real engine (a hand stub can't catch a
wrong $-operator or a broken pipeline — it just returns
whatever you canned).
| Concern | Hand-stubbed unit canned consts / sinon |
micromongo/mock |
Real mongod local / docker / Atlas |
|---|---|---|---|
| Runs your real query/update/agg logic | ❌ returns canned data | ✅ real engine | ✅ |
| Server needed / works offline | ✅ none | ✅ none | ❌ needs mongod |
| Speed | fastest | ≈ as fast (in-memory) | slowest (I/O + startup) |
| Setup & CI cost | low | low (one dep) | high (container / creds) |
| Parallel-test isolation | trivial | per-client (easy) | needs per-test db/namespace |
| Catches wrong-operator / bad-pipeline bugs | ❌ | ✅ | ✅ |
| Fully Mongo-faithful (txn isolation, change streams, real index/perf) | n/a | ⚠️ mostly — see compatibility | ✅ ground truth |
Pick this when… reach for a
hand stub only to isolate code where the query is
incidental (you're testing the caller, not the query). Use
micromongo/mock for the bulk of your
data-layer tests — the fast inner loop where you want the real query
semantics without a server. Keep a thin
real-mongod tier for what the mock can't model
(transaction isolation, change streams, index/perf behavior, and any
documented divergence) and to gate
releases.
npm test (no server, milliseconds), and gate
releases with the same tests against a real mongod behind an opt-in
script, e.g. test:e2e. micromongo dogfoods exactly this:
its
test:mongo
differential harness replays one test body against both
micromongo/mock and a real MongoDB and asserts identical
results — the reference pattern for “one suite, two backends.”
Performance
A bare query (the functional mm.* API, or a
Collection with no index) is a linear scan — O(n) in the
array length. That's fast for typical in-memory sizes; a
Collection index removes the scan for equality (→ O(1)),
range, and sort queries.
Linear-scan latency (ms), whole-operation:
size count (ms) find (ms) $sort (ms)
----------------------------------------------
1 000 0.39 1.15 1.54
10 000 2.32 6.29 12.77
100 000 21.41 63.33 127.91
$where is far slower — it runs JS in a Node
vm per document, so it can't be indexed and scales
worst of all:
size $where (ms)
---------------------
1 000 534
5 000 2527
Indexed lookups (Collection) vs. linear scan
A Collection that owns its data can opt into an
ordered index (createIndex('sku')) that
serves equality, range, sort, $in, compound-prefix and
$or from the index instead of scanning; anything
unindexable transparently falls back to the scan. Measured by
test/performance-index.js with
process.hrtime.bigint() (nanosecond resolution), best of
several warmed-up runs.
Single-equality findOne (lookups spread
evenly, so average scan depth grows with n) — µs per lookup:
size scan (µs) indexed (µs) speedup
---------------------------------------------
1 000 126.57 2.74 46x
10 000 1107.03 1.91 579x
100 000 10707.10 1.17 9138x
Scan cost grows ~10× per 10× more data (~100 µs → ~1 ms → ~10 ms per lookup), while the indexed lookup stays ~1–3 µs regardless of collection size (a single hash hit) — so the speedup itself scales with the data, ~50× at 1 000 docs to ~9 000× at 100 000.
Range $gte (≈ top 1%) — a binary-search
slice of the ordered index instead of a full scan (ms,
whole-operation):
size scan (ms) indexed (ms) speedup
---------------------------------------------
1 000 0.746 0.034 22x
10 000 2.615 0.094 28x
100 000 27.896 0.830 34x
Full sort by the indexed field — returned in index order, skipping the comparison sort (ms, whole-operation):
size scan (ms) indexed (ms) speedup
---------------------------------------------
1 000 1.928 0.940 2x
10 000 18.200 6.812 3x
100 000 263.616 111.824 2x
Sort speedup is bounded — both paths still materialize/copy every doc;
the index removes the O(n·log n) comparison pass, not the O(n) copy.
Reproduce every table with npm run _test
(test/performance.js
+
test/performance-index.js; the 10 000/100 000 scan rows are it.skip-ped by
default — un-skip to reproduce).
Compatibility
micromongo aims for MongoDB-compatible semantics
(baseline: MongoDB 3.2 docs, plus selected newer operators like
$expr/$rand). Every MongoDB-compatible
operator/stage is verified by a *-mongodoc.js test
ported verbatim from the official MongoDB docs. High-level summary:
| Reads |
count find findOne
distinct + lazy Cursor — ✅
|
| Writes |
insert* delete*/remove
update* replaceOne
findOneAnd* bulkWrite — ✅
|
| Comparison |
$eq $ne $gt $gte $lt $lte $in $nin — ✅
($ne/$nin use strict, non-deep
compare)
|
| Logical | $and $or $nor $not — ✅ |
| Element |
$exists $type — ✅ ($type uses JS
types)
|
| Evaluation |
$mod $regex $where $text $expr — ✅
($regex needs a RegExp;
$where runs JS — see security note)
|
| Array | $all $elemMatch $size — ✅ |
| Bitwise |
$bitsAllSet $bitsAnySet $bitsAllClear $bitsAnyClear
— ✅
|
| Geospatial |
$geoWithin $geoIntersects $near $nearSphere
(planar/haversine; no spatial index) — ✅
|
| Projection |
inclusion/exclusion, _id default,
$slice $elemMatch $ $meta:"textScore" — ✅
|
| Update operators |
field, array (+ modifiers), positional
$/$[]/$[<id>],
$bit, upsert — ✅
|
| Aggregation stages | every stage feasible in-memory — ✅ |
| Aggregation expressions |
pragmatic core (+ accumulators, $rand);
date/type-conversion/set operators are partial — ⚠️
|
| Not supported |
mapReduce, legacy update,
$jsonSchema, and server/storage-bound methods — by
design (no server)
|
The full, per-operator status table is the canonical source: compatibility.md.