easymongo
v6.3.0
Published
Thin, opinionated wrapper around the MongoDB Node.js driver with a fail-silent promise API.
Readme
easymongo
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 easymongoUsage
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.
Trust boundary
This is a thin wrapper, not a sanitizing layer. Read this before pointing it at request input.
- Filters reach the driver unsanitized on every method.
find,findOne,exists,count,distinct,update,remove, andeach()(easy to miss, since it's the streaming path) all pass the query through as-is. - The update-document is unsanitized too.
update()'s 2nd argument applies exactly as given; an untrusted$set/$incpayload is a mass-assignment vector. options.fields/projection/sortaren't sanitized either. The fail-closed array whitelist only blocks a degenerate input ([],['__proto__']);fields: ['password']is a valid whitelist entry and goes straight through.- The only guarded surface is the scalar
_idslot infindById/removeById, which reject a plain-object id.
Never pass req.body/req.query directly as a query, an update-document, or an options object. Build and validate it yourself.
Also:
- Empty and failure look identical. A
[],null, orfalsetells you nothing about whether the operation failed. See Per-operationonErrorfor the pattern that tells them apart. - A literal
idfield is a footgun. Documents synced from an external API (Stripe, YouTube, anything REST-shaped) often already have their ownid, which silently becomes_idinstead of staying a literal field. See IDs for the mechanics. - A connection string with no db path is a footgun too.
mongodb://127.0.0.1:27017(no/dbname) is not validated and silently defaults to the driver'stestdatabase. See Client. - Driver error messages can carry more than the default logger prints.
ctx.queryis never printed by the default reporter, but the underlying error message can still embed field values (a duplicate-key error, for one). See Observability.
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. Once close() resolves, the client is permanently closed. A later operation does not reopen a new pool; it collapses to its own empty default and is reported, same as any other swallowed error.
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 throwCollection 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. A plain-object _id (e.g. {$gt: ''}, operator smuggling) is rejected and reported, same as findById/removeById reject a plain-object id. 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, falling 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). $where itself (at any depth inside $and/$or/$nor) never falls back, because re-running it via the cursor would execute that JS against every document, so it collapses to 0 like any other rejected query. The fallback streams in batches, bounded in memory, but is a real round trip; it's reported via onError before it runs, so the cost is observable.
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 since 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-isThe 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 }, so 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, the rest collapse to a conflict + onError, and are skipped. Returns the names of successfully created or already-present indexes. One exception to every other validation reject being reported: a non-array specs argument, or a hostile getter on an entry's key/options, is skipped without calling onError.
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/$lteStrings 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; see Trust boundary for the full picture, including the update-document and read options.
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 handlersilent: 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: '[email protected]' } }.
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, since an explicitly passed callback is a requested channel, not internal logging.
This lets a caller check whether this particular call failed, without racing against other concurrent calls to a shared client-level handler:
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, so 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 a filter whose values are all undefined (e.g. {field: undefined}) 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 deletedTo 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 completionoptions.timeout: ms arms a per-operation deadline (AbortSignal.timeout under the hood) and composes with a caller signal via AbortSignal.any, aborting 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 firstAborting 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.
