micromongo

MongoDB-like queries over plain JavaScript arrays of objects — zero database, in-memory.

npm version

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…

Load an example:
// result appears here
Everything works in the browser except one thing: a $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
CommonJS
var mm = require("micromongo");
ESM / TypeScript
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:

Functional — on any array
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} ]
Collection — owns the array, indexes
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
Differs from MongoDB (configurable): by default 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" });
Security — $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,
  } },
]);
Computed-field stages use a pragmatic expression-operator core (arithmetic / string / comparison / conditional / boolean / array + group accumulators, plus $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 streamsfor…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) => { /* … */ });
Early termination. With no 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.
Bounded top-K for 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" } };
Fidelity. CRUD, 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.
Sessions/transactions run the callback but without real isolationabortTransaction() 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"]);
});
Isolation. Each 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
Speedfastest≈ as fast (in-memory)slowest (I/O + startup)
Setup & CI costlowlow (one dep)high (container / creds)
Parallel-test isolationtrivialper-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.

Layered strategy. Run the mock-backed suite as your default 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.

Measurement system: AMD Ryzen 7 5800HS (16 logical cores) · 31 GB RAM · Node v22.16.0 · Windows. Numbers are one run, best of several — read the shape (how each column grows with size), not the absolute values.

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.