Skip to content

meritt/easymongo

Repository files navigation

easymongo

NPM version Build status Coverage status Dependency status

A thin, opinionated wrapper around the official MongoDB Node.js driver. Every async method resolves to a fixed type; driver errors are swallowed and replaced with the empty default (null, false, [], or 0).

Requirements

  • Node.js ≥ 24.16
  • MongoDB server 7.0, 8.0, 8.2, or 8.3

Installation

pnpm add easymongo
# or: npm install easymongo

Usage

import { MongoClient } from 'easymongo';

const mongo = new MongoClient({ dbname: 'app' });
const users = mongo.collection('users');

const alexey = await users.save({
  name: 'Alexey',
  url: 'https://simonenko.xyz'
});
// { _id: ObjectId(...), name: 'Alexey', url: 'https://simonenko.xyz' }

const results = await users.find({ name: 'Alexey' }, { limit: 10 });
// [{ ... }, { ... }]

const one = await users.findById(alexey._id);
// document or null

await mongo.close();

The connection opens lazily on the first I/O call. Concurrent first calls share a single connect. Call close() when done.

Client

new MongoClient(server, options?)

server is a connection URL or { host?, port?, dbname }. Default host is 127.0.0.1, default port is 27017.

options is optional and is forwarded to the underlying driver. Two keys are reserved for the wrapper:

Key Default Meaning
silent false Suppress all internal error reporting
onError console.error (err, ctx) => void, ctx = { method, collection?, query? }

client.collection(name) returns a Collection. client.close() releases the connection and is safe to call more than once. Concurrent close() calls share one teardown.

MongoClient implements Symbol.asyncDispose, so it composes with await using:

{
  await using mongo = new MongoClient({ dbname: 'app' });
  const users = mongo.collection('users');
  await users.save({ name: 'Alexey' });
} // close() is invoked automatically on scope exit, even on throw

Collection methods

Method Resolves to Empty default
find(query?, options?) doc[] []
findOne(query?, options?) doc | null null
findById(id, fields?) doc | null null
each(query?, options?) AsyncIterable<doc> & AsyncDisposable empty iteration
exists(query?, options?) boolean false
count(query?, options?) number 0
distinct(field, query?, options?) any[] []
save(doc, options?) doc | null null
saveAll(docs, options?) doc[] []
update(query, $update, options?) boolean false
remove(query, options?) boolean false
removeById(id, options?) boolean false
createIndex(spec, options?) string | null null
ensureIndexes(specs) string[] []
oid(value?) ObjectId | input fresh ObjectId()

Every async method except findById accepts options.signal: AbortSignal and options.timeout: ms for cancellation (see AbortSignal and timeout) and a per-operation options.onError reporter (see Observability); for ensureIndexes, pass them inside each entry's options. oid(value?) is synchronous: it coerces a valid 24-char hex string to ObjectId, mints a fresh one for nullish input, and returns anything else untouched.

save inserts when _id is absent and replaces via upsert when present. Documents passed to save/saveAll get the same id_id alias rewrite as queries — a top-level id field never reaches the database as a literal field. saveAll delegates to an unordered insertMany; non-object entries are dropped silently, and on partial failure (e.g. one entry hits a duplicate key) it resolves to the successfully inserted subset instead of []. Non-object input to save and non-array input to saveAll are rejected and reported, like every other validation reject.

count({}) short-circuits to estimatedDocumentCount, which reads the cached collection size without a full scan. Numbers may be slightly off on sharded collections with orphans or after an unclean shutdown, but the path is roughly two orders of magnitude faster.

count(query) with a non-empty filter calls countDocuments and falls back to a streamed find cursor with _id-only projection when the driver rejects the query (operators such as $where and $near are valid in find but not in the aggregation $match that countDocuments builds). The fallback streams in batches and is bounded in memory, but it's a real round trip — prefer indexed predicates when possible.

Read options

The second argument to find and findOne:

await users.find(
  {},
  {
    limit: 10,
    skip: 0,
    sort: { name: 1 },
    batchSize: 100,
    fields: ['name', 'email']
  }
);

limit, skip, sort, and batchSize pass through to the driver (findOne takes no limit or batchSize — it always fetches a single document; batchSize bounds how many documents each cursor fetch may buffer, which matters for each() over large documents).

Projection accepts three forms:

await users.find({}, { fields: ['name', 'email'] }); // whitelist; compiled to { name: 1, email: 1 }
await users.find({}, { fields: { password: 0 } }); // exclusion map, passed through as-is
await users.find({}, { projection: { name: 1 } }); // native driver shape, passed through as-is

The array form fails closed: it always produces a projection. Non-string entries are ignored, and when nothing usable remains ([], ['__proto__'], [42]) the projection collapses to { _id: 1 } — a user-controlled array cannot silently disable a field whitelist.

findById accepts the array and map forms positionally: findById(id, ['name']).

Streaming reads

each(query?, options?) returns a lazy iterable that opens a cursor on first iteration and closes it when iteration ends. It accepts the same options as find (limit, skip, sort, fields, projection, batchSize, signal, timeout, onError).

for await (const user of users.each({ active: true })) {
  await ship(user);
}

For long-running iteration, scope the cursor with await using so it is closed even on early break or thrown errors:

{
  await using cursor = users.each({ active: true });
  for await (const user of cursor) {
    if (!shouldShip(user)) {
      break;
    }
    await ship(user);
  }
}

The returned object is a factory — each for await opens its own cursor, so the same each(...) value can be iterated multiple times sequentially or in parallel. Abandoning an iterator without break/return/await using delays cursor cleanup until the generator is GC'd or the client is closed; prefer explicit lifetime management for unbounded queries.

each() exposes only Symbol.asyncIterator and Symbol.asyncDispose. There is no close() method or cancel() on the returned object — disposal is the only way to terminate iteration explicitly.

Errors during open or iteration end the loop quietly and report through the local options.onError when one was passed, otherwise through the client-level onError (or console.error), with ctx.method === 'each'. Cursor close errors are reported with ctx.method === 'each.close'.

Indexes

await users.createIndex({ email: 1 }, { unique: true });
// 'email_1' or null on conflict / driver error

await users.ensureIndexes([
  { key: { email: 1 }, options: { unique: true } },
  { key: { createdAt: -1 } },
  { key: { name: 'text' } }
]);
// ['email_1', 'createdAt_-1', 'name_text']

createIndex(spec, options?) returns the index name, or null if the driver rejects the spec or there is a conflict with an existing index.

ensureIndexes(specs) processes its input sequentially — when two entries target the same key with different options, the first one wins: it succeeds, and the rest collapse to a conflict + onError and are skipped. Returns the names of successfully created or already-present indexes.

IDs

Id values are normalized before the driver sees them:

await users.findById('4e4e1638c85e808431000003'); // string coerced to ObjectId
await users.findById(existingObjectId); // passed through

await users.find({ _id: '4e4e1638c85e808431000003' }); // coerced to ObjectId
await users.find({ id: '4e4e1638c85e808431000003' }); // id alias rewritten to _id
await users.find({ _id: { $in: ['4e4e1638c85e808431000003', someObjectId] } }); // mixed $in coerced
await users.find({
  _id: { $nin: ['4e4e1638c85e808431000003', '4e4e1638c85e808431000004'] }
}); // same for $nin
await users.find({ _id: { $gt: '4e4e1638c85e808431000003' } }); // scalar comparisons too: $eq/$ne/$gt/$gte/$lt/$lte

Strings that are not valid 24-character hex pass through unchanged, so numeric and UUID _id schemes keep working.

findById/removeById reject null, undefined, and plain-object ids before the driver sees them (reported as Invalid id rejected): an operator object like { $ne: null } smuggled from request input would otherwise match — or delete — an arbitrary document. Use findOne({ _id: { ... } }) for intentional operator queries.

Apart from this id normalization, query objects are passed to the driver unsanitized — that is the point of a thin wrapper. Never feed raw request input (req.body, req.query) into a query position; validate and build the filter yourself.

Observability

Public methods never throw. On failure the driver error is passed to the configured reporter and replaced with the method's empty default.

const url = 'mongodb://localhost:27017/app';

new MongoClient(url); // default: reports via console.error
new MongoClient(url, { silent: true }); // suppress all internal logging
new MongoClient(url, { onError: (err, ctx) => console.error(ctx.method, err) }); // custom handler

silent: true mutes the whole client-level channel — including a custom client-level onError. A per-operation options.onError is the one thing it does not suppress (see below).

The ctx passed to onError has the shape { method, collection?, query? }, e.g. { method: 'findOne', collection: 'users', query: { email: 'a@b.c' } }.

Mind what reaches your logs: ctx.query is the original query object and may carry PII or secrets, and driver error messages can embed document values too (a duplicate-key E11000 includes the offending value). The default console.error reporter prints only [easymongo] <collection>.<method> failed: plus the error — never ctx.query — but a custom onError owns redaction of everything it forwards.

A throwing onError is itself caught and ignored, and a rejected promise from an async handler is defused the same way; a broken reporter cannot take down the caller (the rejection is silently dropped, so do your own catching inside async handlers if you care about their failures).

Per-operation onError

Every method that takes options also accepts a local onError(err, ctx). When a function is passed, it takes ownership of the error for that one call: the client-level onError and the default console.error stay silent, and silent: true does not suppress it — an explicitly passed callback is a requested channel, not internal logging.

This is the escape hatch for callers that need to know whether this particular operation failed — no shared state, no races between parallel operations, works with a client configured elsewhere:

let failure = null;
const docs = await users.find(query, { onError: (err) => (failure = err) });
if (failure) {
  throw failure; // re-raising is the caller's decision, not the library's
}

The same pattern detects a mid-stream cursor error in each(), which otherwise just truncates the iteration:

let failure = null;
for await (const doc of users.each(query, {
  onError: (err) => (failure = err)
})) {
  // ...
}
if (failure) {
  throw failure; // the stream was cut short
}

findById has no options slot — use findOne({ _id: id }, { onError }). For ensureIndexes, pass onError inside each spec's options. A throwing local handler is caught and ignored, same as the client-level one. One caveat for each(): the local handler should not capture its own iterator, or an abandoned iterator can never be garbage-collected and its cursor stays pinned until the client is closed.

Empty filter protection

update(query, $update) and remove(query) reject empty filters. null, undefined, and {} short-circuit to false without touching the driver. The rejection is reported through onError (or console.error) with { method, collection, query } so it stays visible.

await users.update(maybeMissing, { $set: { url: 'x' } }); // false, nothing rewritten
await users.remove(undefined); // false, nothing deleted

To wipe a collection, use a non-empty filter such as { _id: { $exists: true } }, or call the native driver directly via client.open(name).

Positional updates with arrayFilters

update(query, $update, options) forwards options.arrayFilters to the driver, enabling positional updates over array elements:

await pages.update(
  { _id: pageId },
  { $set: { 'links.$[el].url': '/new' } },
  { arrayFilters: [{ 'el.type': 'related' }] }
);

Only arrayFilters, signal, and timeout are forwarded; other driver-level update options are ignored. Use client.open(name) directly if you need them.

AbortSignal and timeout

Every async method except findById accepts options.signal and forwards it to the driver (for ensureIndexes, inside each entry's options). Pre-aborted signals collapse to the empty default and emit through onError:

const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 200);

const docs = await users.find({}, { signal: ctrl.signal });
// [] if the operation was aborted before completion

options.timeout: ms arms a per-operation deadline (AbortSignal.timeout under the hood) and composes with a caller signal via AbortSignal.any — the operation aborts when either fires. Non-positive and non-numeric values are ignored.

const docs = await users.find({}, { timeout: 500, signal: ctrl.signal });
// [] when the deadline fires first, [] when the caller aborts first

Aborting mid-iteration of each() ends the loop quietly and reports through onError.

update({}, ..., {signal: aborted}) and remove({}, {signal: aborted}) hit the empty-filter guard first, so the abort is invisible in onError for that one combination — pass a non-empty filter when both apply.

Author

License

MIT. See LICENSE.

About

Opinionated, fail-silent wrapper around the MongoDB Node.js driver

Resources

License

Stars

Watchers

Forks

Contributors