Guides
Queries and updates
Array paths, $elemMatch, positional operators, arrayFilters and upsert.
Rivyn's filter and update language follows MongoDB closely enough that most queries port across unchanged. This page covers the parts that are easy to get wrong.
Dotted paths reach through arrays
A dotted path does not stop at an array — it descends into every element. "punishments.id"
matches when any punishment carries that id:
await users.find({ "punishments.id": 3 });
await users.find({ "punishments.oldRoles.id": "r1" }); // nested arrays tooA numeric segment is an index instead, when the field really is an array:
await users.find({ "punishments.0.active": true }); // only the first oneOn an object keyed by number — a free-form map — the same segment is read as a key, so
"rolesToBeWon.5" addresses the entry 5, not the sixth element of anything.
$elemMatch
A plain dotted filter checks each condition independently, so two conditions can be
satisfied by two different elements. $elemMatch requires one element to satisfy all
of them:
// Any punishment with id 2, and any punishment that is active — possibly different ones.
await users.find({ "punishments.id": 2, "punishments.active": true });
// One punishment that is both.
await users.find({ punishments: { $elemMatch: { id: 2, active: true } } });It also works on arrays of plain values:
await users.find({ scores: { $elemMatch: { $gt: 5, $lt: 10 } } });Negative operators
$ne and $nin mean "no value matches", so on an array path every element must satisfy
them. { "punishments.id": { $ne: 1 } } excludes a user who has any punishment with id 1.
Regex flags
Flags travel in $options:
await users.find({ username: { $regex: "^ar", $options: "i" } });$each and $addToSet
$each spreads several values into one operator. $addToSet compares by value, so objects
deduplicate properly rather than by reference:
await users.updateOne({ id }, { $push: { reasons: { $each: ["spam", "raid"] } } });
await users.updateOne(
{ id },
{ $addToSet: { roles: { $each: [{ id: "r1" }, { id: "r2" }] } } },
);Positional operators
$ updates the element the filter matched:
await users.updateOne(
{ id: "550", "punishments.id": 2 },
{ $set: { "punishments.$.active": false } },
);The element is resolved from the filter's own conditions on that array, so the filter has to name it. If nothing matches, the document is left alone rather than mutated at index 0.
$[] updates every element, and $[name] updates the ones selected by arrayFilters:
await users.updateMany({}, { $set: { "punishments.$[].reviewed": true } });
// Rewrite a role id wherever it appears, at any depth.
await users.updateMany(
{ "punishments.oldRoles.id": oldId },
{ $set: { "punishments.$[].oldRoles.$[role].id": newId } },
{ arrayFilters: [{ "role.id": oldId }] },
);Positional paths never create missing structure. Unlike $set on a plain path, which
builds intermediate objects as needed, "missing.$.field" on a document without missing
is a no-op.
Upsert
await users.updateOne(
{ id: "550" },
{ $set: { username: "arel" } },
{ upsert: true },
);When nothing matches, the document is seeded from the filter's plain equality fields and
the update is applied on top. Dotted keys expand: { "a.b": 1 } becomes { a: { b: 1 } }.
Operator conditions such as { age: { $gt: 18 } } carry no single value, so they are
skipped.
An upsert does not apply schema defaults — it only knows the filter and the update. If you need a new document to arrive with every nested default populated, read first and fall back to
Model.create(), which runs the schema.
findOneAndUpdate accepts upsert too, and returns the created document only with
returnNew: true — a document that did not exist has no "before" state to return.