api-database-mongodb
Native MongoDB driver (the mongodb npm package) - MongoClient lifecycle, typed collections, CRUD result shapes, cursors, aggregation pipelines, index design, transactions
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-mongodb/skills/api-database-mongodb
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
MongoDB Native Driver Patterns
Quick Guide: Talk to MongoDB through the official
mongodbdriver with no schema layer in between. Create ONEMongoClientper process and reuse it -- it owns the connection pool. Type collections with a generic:db.collection<UserDoc>("users"). Write operations return acknowledgements, never documents.find()returns a lazy cursor; stream it withfor awaitinstead oftoArray()for anything unbounded. Put$matchfirst in every pipeline so it can use an index. Verify indexes withexplain("executionStats")rather than assuming. Transactions need a replica set, a session on every operation, and a callback that can safely run twice.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST create exactly ONE MongoClient per process and reuse it -- the client owns a connection pool, so constructing one per request opens a new pool per request and exhausts the server's connection limit)
(You MUST pass { session } to EVERY operation inside a transaction -- an operation without it silently runs outside the transaction and is not rolled back)
(You MUST write withTransaction callbacks to be safely re-runnable -- the driver retries them on transient errors, so any side effect outside the transaction happens more than once)
(You MUST iterate or close every cursor you open -- an abandoned cursor holds server-side resources until it times out)
(You MUST NOT expect write operations to return documents -- insertOne returns { acknowledged, insertedId } and updateOne returns counts; only the findOneAnd* family returns a document)
(You MUST verify a query uses the index you intended with explain("executionStats") -- an unindexed query succeeds silently and only fails once the collection is large)
</critical_requirements>
Auto-detection: mongodb, MongoClient, ServerApiVersion, client.db, db.collection, insertOne, insertMany, updateOne, findOneAndUpdate, deleteOne, bulkWrite, FindCursor, AggregationCursor, toArray, ObjectId, WithId, OptionalUnlessRequiredId, Filter, UpdateFilter, createIndex, createIndexes, explain, startSession, withTransaction, readPreference, writeConcern, maxPoolSize, serverSelectionTimeoutMS, MongoServerError, code 11000
When to use:
- Talking to MongoDB directly with no schema or modelling layer in between
- Aggregation-heavy workloads (reporting, analytics, materialised views)
- Bulk and batch pipelines where per-document overhead is the bottleneck
- Index design, query-plan investigation, and performance work
- Multi-document transactions with explicit session control
- Serverless and edge runtimes where client and pool lifecycle must be controlled by hand
Key patterns covered:
- Client and pool lifecycle (one client per process, startup and shutdown, serverless reuse)
- Typed collections and the driver's document type helpers
- CRUD and the result shapes each operation actually returns
- Cursors: lazy evaluation, streaming, batching, and pagination that stays fast
- Aggregation pipeline construction, stage ordering, and memory limits
- Index types, compound key ordering, and verification with
explain - Transactions: sessions, retry semantics, and when not to use one
- Error handling on driver-specific error codes
When NOT to use:
- You want schemas, validation, middleware hooks or population handled for you -- use an ODM layer instead of building one on top of this
- Highly relational data with multi-table joins and foreign key constraints (use a relational database)
- Simple key-value caching (use a dedicated key-value store)
- Time-series data at very large scale (use a purpose-built time-series database)
Detailed Resources:
- For decision tables, connection-option reference, and operator lookup, see reference.md
Core Patterns:
- examples/core.md - Client lifecycle, typed collections, CRUD and result shapes, error handling
Query Patterns:
- examples/queries.md - Filters, projection, cursors, streaming, keyset pagination, counting
Aggregation:
- examples/aggregation.md - Pipeline construction,
$lookup,$facet,$merge, typed output
Indexing:
- examples/indexes.md - Single-field, compound (ESR), partial, TTL, text, geospatial, and
explain
Advanced Patterns:
- examples/patterns.md - Transactions, bulk writes, change streams, schema evolution, serverless
<decision_framework>
Decision Framework
How should this read run?
How many documents can this return?
├─ One → findOne()
├─ A bounded page → find().limit(n).toArray()
└─ Unbounded or unknown → for await (const doc of find(...))
└─ Stopping early? → cursor.close() when you break out
Query or pipeline?
Does the answer need reshaping, grouping, or data from another collection?
├─ NO → find() with a filter and a projection
└─ YES → aggregate()
├─ Grouping/totals → $match first, then $group
├─ Joining a collection → $lookup, with the foreign field indexed
├─ Several answers at once→ $facet (one pass, not N queries)
└─ Result reused often → $merge into a materialised collection
Transaction or not?
How many documents change?
├─ One → No transaction. Single-document writes are already atomic.
│ Use $inc / $set / arrayFilters to do it in one update.
└─ Many → Do they have to change together?
├─ NO → Separate writes. A transaction adds cost for nothing.
└─ YES → withTransaction, { session } on every operation,
callback safe to run twice, replica set required.
Which compound index?
Order the keys by how the query uses them (ESR):
1. Equality fields — matched exactly ({ tenantId: x })
2. Sort fields — the sort key, in sort order
3. Range fields — $gt / $lt / $in
Then run explain("executionStats") and confirm IXSCAN.
Getting the order wrong still produces an index, and it still gets ignored.
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Constructing a
MongoClientper request or per operation -- every client opens its own pool, so concurrency multiplies into hundreds of sockets and the server starts refusing connections. One client per process, shared. - Missing
{ session }on an operation inside a transaction -- that operation runs outside the transaction, commits independently, and is not rolled back when the transaction aborts. Nothing errors. - Side effects inside a
withTransactioncallback -- the driver retries the callback on transient errors, so emails send twice and queue messages publish twice. Only database work belongs inside it. toArray()on an unbounded query -- memory scales with the collection, so it passes against development data and exhausts the process in production.- Assuming a write returned a document --
insertOneandupdateOnereturn acknowledgements, so every field read off them isundefinedand the failure appears somewhere else entirely. - Trusting a query is indexed without
explain-- an unindexed query is correct at every size, so it is invisible until the collection is large enough to cause an outage. - Interpolating user input into a filter object -- an attacker-supplied object containing
$neor$gtbecomes an operator rather than a value. Coerce inputs to their expected primitive type before they reach a filter.
Medium Priority Issues:
- Treating
modifiedCount === 0as "not found" -- a no-op update matches without modifying, which is a successful update, not a missing document. - Deep
skip()pagination -- the server walks and discards every skipped document, so page 500 costs 500 pages of work. Use a keyset on an indexed sort field. - Creating indexes in a request path rather than a migration -- builds contend with live traffic and repeat on every process start.
$lookupagainst an unindexed foreign field -- the lookup runs per input document, so this is a collection scan multiplied by the number of inputs.- Indexing a low-cardinality field on its own -- an index on a two-value field examines roughly half the collection and rarely beats a scan.
- Omitting
writeConcernon writes that must survive a failover -- the default acknowledges from the primary only. - Leaving a cursor unconsumed after breaking out of a loop -- server-side resources are held until it times out.
Common Mistakes:
- Forgetting
returnDocument: "after"onfindOneAndUpdate-- the default returns the pre-update document. - Passing a 24-character hex string where an
ObjectIdis required -- the filter matches nothing rather than erroring, because it is a valid string comparison against a non-string field. - Using
ObjectId.isValid()as input validation -- it returnstruefor any 12-character string, so"123456789012"passes. Test against a 24-hex-character pattern. - Reusing one session across concurrent operations -- a session is single-threaded; parallel work needs separate sessions.
- Not handling
code === 11000from a unique index -- duplicate key is an expected outcome of a race, not an exceptional one. - Comparing
Datevalues against ISO strings -- BSON dates and strings never compare equal.
Gotchas & Edge Cases:
- The driver auto-connects on first operation, so a missing
connect()surfaces bad credentials on the first query instead of at startup. Call it explicitly to fail fast. - Driver 5 removed callback support entirely -- every operation returns a promise, and callback-style code from older examples throws.
- Driver 6 changed the
findOneAnd*return shape -- it returns the document directly;includeResultMetadata: truerestores the older{ value, ok, lastErrorObject }wrapper. find()sends nothing until iterated, so a query with a syntax error throws where it is awaited, not where it is built.- A cursor's first batch is small (101 documents) and later batches fill up to 16 MB, so the first
next()is fast and a later one can pause noticeably. - Documents are capped at 16 MB -- an unbounded embedded array eventually makes a document unwritable, and the failure arrives long after the design decision that caused it.
- Aggregation stages have a 100 MB memory limit -- a large
$groupor$sortfails unlessallowDiskUse: trueis set. $sortonly uses an index at the start of a pipeline. After a$groupor$projectit sorts in memory against that limit.- TTL deletion is not immediate -- the background task runs about once a minute, so expired documents remain readable briefly. The field must hold a BSON
Date; a number or string is ignored silently. - A text index is limited to one per collection, so adding a second requires dropping the first.
createIndexis idempotent for an identical key and options, but the same key with different options raisesIndexOptionsConflict.- Transactions need a replica set -- a standalone server rejects them, which is why a transaction can pass in staging and fail on a developer's single-node machine.
- Transactions have a server-side lifetime limit (60 seconds by default) and abort when it is exceeded, so long-running work does not belong inside one.
writeConcernis per-operation and per-transaction, and the transaction's own concern governs the commit regardless of what the individual operations asked for.
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST create exactly ONE MongoClient per process and reuse it -- the client owns a connection pool, so constructing one per request opens a new pool per request and exhausts the server's connection limit)
(You MUST pass { session } to EVERY operation inside a transaction -- an operation without it silently runs outside the transaction and is not rolled back)
(You MUST write withTransaction callbacks to be safely re-runnable -- the driver retries them on transient errors, so any side effect outside the transaction happens more than once)
(You MUST iterate or close every cursor you open -- an abandoned cursor holds server-side resources until it times out)
(You MUST NOT expect write operations to return documents -- insertOne returns { acknowledged, insertedId } and updateOne returns counts; only the findOneAnd* family returns a document)
(You MUST verify a query uses the index you intended with explain("executionStats") -- an unindexed query succeeds silently and only fails once the collection is large)
Failure to follow these rules will exhaust the connection pool under load, lose writes that appeared to be transactional, and ship queries whose cost is invisible until the collection is too large to fix quietly.
</critical_reminders>
Files (skills)
-
examples
-
aggregation.md 9.1 KB
# MongoDB Aggregation Examples > Pipeline construction, stage ordering, joins, faceting and materialised views with the native driver. See [SKILL.md](../SKILL.md) for the decisions behind these. **Core:** See [core.md](core.md). **Queries:** See [queries.md](queries.md). **Indexes:** See [indexes.md](indexes.md). **Advanced:** See [patterns.md](patterns.md). --- ## Pattern 1: Pipeline Construction and Stage Order ### Good Example -- Revenue Report ```typescript import type { Document, ObjectId } from "mongodb"; const TOP_CUSTOMER_COUNT = 10; type RevenueByCustomer = { _id: ObjectId; total: number; orderCount: number; }; async function topCustomers( orders: Collection<OrderDoc>, tenantId: ObjectId, since: Date, ): Promise<RevenueByCustomer[]> { return orders .aggregate<RevenueByCustomer>([ // 1. Narrow first. This is the only stage that can use an index. { $match: { tenantId, status: "complete", createdAt: { $gte: since } } }, // 2. Shrink documents before they flow through the expensive stages. { $project: { customerId: 1, total: 1 } }, // 3. Aggregate. { $group: { _id: "$customerId", total: { $sum: "$total" }, orderCount: { $sum: 1 }, }, }, { $sort: { total: -1 } }, { $limit: TOP_CUSTOMER_COUNT }, ]) .toArray(); } export { topCustomers }; ``` **Why good:** `$match` first lets a compound index on `{ tenantId, createdAt }` do the narrowing, `$project` cuts each document to two fields before grouping, and the explicit generic types an output shape that no longer resembles `OrderDoc` ### Bad Example -- Matching After Grouping ```typescript // BAD [ { $group: { _id: "$customerId", total: { $sum: "$total" } } }, { $match: { status: "complete" } }, ]; ``` **Why bad:** the group has already read the entire collection, so no index is usable and the work is done before anything is filtered; worse, `status` does not exist in the grouped output, so the match silently returns nothing and the report shows zero rows rather than an error ### Good Example -- Allowing Disk Use on a Large Group ```typescript const results = await orders .aggregate<RevenueByCustomer>(pipeline, { allowDiskUse: true }) .toArray(); ``` **Why good:** each stage is capped at 100 MB of memory, so a group or sort over a large dataset fails without this; spilling to disk is slower but completes --- ## Pattern 2: $lookup (Joins) ### Good Example -- Lookup with a Sub-Pipeline ```typescript const RECENT_ORDER_LIMIT = 5; type CustomerWithOrders = { _id: ObjectId; email: string; recentOrders: Array<{ _id: ObjectId; total: number; createdAt: Date }>; }; const results = await customers .aggregate<CustomerWithOrders>([ { $match: { tenantId } }, { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", // MUST be indexed as: "recentOrders", pipeline: [ { $match: { status: "complete" } }, { $sort: { createdAt: -1 } }, { $limit: RECENT_ORDER_LIMIT }, { $project: { total: 1, createdAt: 1 } }, ], }, }, ]) .toArray(); ``` **Why good:** the sub-pipeline filters, sorts, limits and projects inside the lookup, so each customer carries five small documents instead of every order they have ever placed ### Bad Example -- Unbounded Lookup on an Unindexed Field ```typescript // BAD { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", // no index as: "orders", // every order, every field }, } ``` **Why bad:** without an index on `customerId` the lookup scans the orders collection once per input customer, so 1,000 customers means 1,000 scans; embedding every order in full can also push a result document past the 16 MB limit, at which point the pipeline fails outright **Before using `$lookup` at all:** if these two collections are always read together, the data may belong in one document. A join you perform on every read is a modelling decision to revisit, not just a query to tune. --- ## Pattern 3: $facet for Several Answers in One Pass ### Good Example -- Results and Metadata Together ```typescript const FACET_PAGE_SIZE = 20; type SearchResult = { items: Array<WithId<ProductDoc>>; totalCount: Array<{ count: number }>; byCategory: Array<{ _id: string; count: number }>; }; const [result] = await products .aggregate<SearchResult>([ { $match: filter }, // runs once, shared by every facet below { $facet: { items: [{ $sort: { score: -1 } }, { $limit: FACET_PAGE_SIZE }], totalCount: [{ $count: "count" }], byCategory: [{ $group: { _id: "$category", count: { $sum: 1 } } }], }, }, ]) .toArray(); const total = result.totalCount.at(0)?.count ?? 0; ``` **Why good:** one round trip and one shared `$match` instead of three queries repeating the same filter, so the counts cannot disagree with the page the way three separate queries can when data changes between them **Note the shapes:** `$count` yields an array that is empty when nothing matched, which is why the total is read with `.at(0)?.count ?? 0` rather than indexed directly. --- ## Pattern 4: Date Grouping ### Good Example -- Daily Totals ```typescript type DailyTotal = { _id: string; total: number }; const daily = await orders .aggregate<DailyTotal>([ { $match: { tenantId, createdAt: { $gte: since } } }, { $group: { // $dateToString groups in a named timezone, so "day" means the // user's day rather than the server's. _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: reportTimezone, }, }, total: { $sum: "$total" }, }, }, { $sort: { _id: 1 } }, ]) .toArray(); ``` **Why good:** the timezone is explicit, so a report does not silently shift by a day for users outside UTC, and sorting the formatted `YYYY-MM-DD` key sorts chronologically --- ## Pattern 5: $merge for Materialised Views ### Good Example -- Pre-Computing an Expensive Report ```typescript // Runs on a schedule; readers query daily_revenue directly and pay nothing. await orders .aggregate([ { $match: { createdAt: { $gte: since } } }, { $group: { _id: { tenantId: "$tenantId", day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } }, }, total: { $sum: "$total" }, }, }, { $merge: { into: "daily_revenue", on: "_id", whenMatched: "replace", whenNotMatched: "insert", }, }, ]) .toArray(); // the pipeline only runs when the cursor is consumed ``` **Why good:** `$merge` updates the target incrementally rather than replacing it, so readers never see an empty collection mid-refresh, and the expensive aggregation is paid once per schedule instead of once per reader **The `toArray()` is load-bearing:** `aggregate()` returns a lazy cursor, so a pipeline ending in `$merge` writes nothing at all until the cursor is consumed. --- ## Pattern 6: Typing Aggregation Output ### Good Example -- Declaring the Output Shape ```typescript type StatusBreakdown = { _id: OrderStatus; count: number }; const breakdown = await orders .aggregate<StatusBreakdown>([ { $match: { tenantId } }, { $group: { _id: "$status", count: { $sum: 1 } } }, ]) .toArray(); ``` **Why good:** without the generic the cursor is typed as `Document`, so every field access is untyped and a renamed group key stays silent until it reaches a consumer ### Bad Example -- Reusing the Collection Type ```typescript // BAD: the pipeline output is nothing like OrderDoc const breakdown = await orders.aggregate<OrderDoc>(groupPipeline).toArray(); breakdown[0].total; // typed as number, actually undefined ``` **Why bad:** the type claims fields the grouped output never had, so reads are `undefined` with no compile error -- the exact failure the generic exists to prevent **Aggregation output is not validated either.** The generic describes what the pipeline is expected to produce; nothing checks that it did. --- ## Performance Notes | Concern | What to do | | -------------------------- | --------------------------------------------------------------------------------- | | Index usage | Only a leading `$match` (and a leading `$sort`) can use one. Put `$match` first. | | Document size mid-pipeline | `$project` or `$unset` early — every later stage carries whatever you left in. | | Memory | 100 MB per stage. Large `$group` or `$sort` needs `allowDiskUse: true`. | | Verifying a plan | `aggregate(pipeline, { explain: true })` — same reasoning as `explain` on a find. | | `$lookup` cost | Index the `foreignField`, always. It runs per input document. | | Repeated expensive reports | `$merge` into a collection on a schedule; read from that. | | Result size | Stream with `for await` rather than `toArray()` when the output is unbounded. | -
core.md 11.9 KB
# MongoDB Core Examples > Client lifecycle, typed collections, CRUD and the result shapes each operation returns, and error handling. See [SKILL.md](../SKILL.md) for the decisions behind these. **Query patterns:** See [queries.md](queries.md). **Aggregation:** See [aggregation.md](aggregation.md). **Indexes:** See [indexes.md](indexes.md). **Advanced:** See [patterns.md](patterns.md). --- ## Pattern 1: Client and Pool Lifecycle ### Good Example -- One Client per Process ```typescript import { MongoClient, ServerApiVersion } from "mongodb"; import type { Db } from "mongodb"; const POOL_SIZE_MAX = 20; const POOL_SIZE_MIN = 2; const SERVER_SELECTION_TIMEOUT_MS = 5_000; const MAX_IDLE_TIME_MS = 60_000; function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} environment variable is required`); return value; } const client = new MongoClient(requireEnv("MONGODB_URI"), { maxPoolSize: POOL_SIZE_MAX, minPoolSize: POOL_SIZE_MIN, serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, maxIdleTimeMS: MAX_IDLE_TIME_MS, retryWrites: true, retryReads: true, serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true, }, }); let database: Db | undefined; async function connectDatabase(): Promise<Db> { if (database) return database; // Operations auto-connect, so this call is optional. It exists to turn bad // credentials and DNS failures into a startup error instead of a runtime one. await client.connect(); database = client.db(requireEnv("MONGODB_DB")); return database; } export { client, connectDatabase }; ``` **Why good:** one client and one pool for the whole process, every numeric option is a named constant, the Stable API pins server behaviour across upgrades, `strict: true` rejects commands outside the versioned API rather than letting them break silently later ### Good Example -- Graceful Shutdown ```typescript const SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"] as const; function registerShutdown(): void { for (const signal of SHUTDOWN_SIGNALS) { process.once(signal, async () => { // Waits for checked-out connections to return before closing the pool. await client.close(); process.exit(0); }); } } export { registerShutdown }; ``` **Why good:** `close()` drains in-flight operations rather than severing them, and `once` avoids stacking a second handler if the signal repeats ### Bad Example -- A Client per Request ```typescript // BAD: a new pool on every call export async function getUser(id: string) { const client = new MongoClient(process.env.MONGODB_URI!); await client.connect(); const user = await client.db("app").collection("users").findOne({ _id: id }); await client.close(); return user; } ``` **Why bad:** each `MongoClient` opens its own pool, so 100 concurrent requests open 100 pools and the server hits its connection limit; the TLS and auth handshake is also paid per request, adding latency that profiling attributes to the query rather than to the connection ### Good Example -- Serverless Client Reuse ```typescript // Serverless runtimes freeze and thaw the same process, so a client cached // outside the handler survives between invocations and its pool is reused. const SERVERLESS_POOL_SIZE = 5; let cached: MongoClient | undefined; async function getClient(): Promise<MongoClient> { if (cached) return cached; cached = new MongoClient(requireEnv("MONGODB_URI"), { maxPoolSize: SERVERLESS_POOL_SIZE, minPoolSize: 0, maxIdleTimeMS: MAX_IDLE_TIME_MS, }); await cached.connect(); return cached; } export { getClient }; ``` **Why good:** module scope outlives the handler so warm invocations skip the handshake entirely, a small pool keeps total connections bounded when the platform scales to many instances, and `minPoolSize: 0` lets idle instances release sockets instead of holding them **Why not `client.close()` here:** closing at the end of a handler discards the pool the next invocation would have reused, which turns every request back into a cold connect. --- ## Pattern 2: Typed Collections ### Good Example -- Document Types and the Driver's Helpers ```typescript import type { Collection, ObjectId, OptionalUnlessRequiredId, WithId, } from "mongodb"; type UserDoc = { _id: ObjectId; email: string; displayName: string; roles: string[]; createdAt: Date; }; function usersCollection(db: Db): Collection<UserDoc> { return db.collection<UserDoc>("users"); } // Reads carry _id: WithId<UserDoc> is UserDoc with _id guaranteed present. async function findByEmail( db: Db, email: string, ): Promise<WithId<UserDoc> | null> { return usersCollection(db).findOne({ email }); } // Inserts may omit _id and let the server generate one. That is what // OptionalUnlessRequiredId expresses, and insertOne accepts it directly. async function createUser( db: Db, input: OptionalUnlessRequiredId<UserDoc>, ): Promise<ObjectId> { const { insertedId } = await usersCollection(db).insertOne(input); return insertedId; } export { createUser, findByEmail, usersCollection }; ``` **Why good:** the generic checks filters, updates and projections against `UserDoc` so a misspelled field is a compile error rather than a query matching nothing, and the two helpers express the real difference between a document you read and one you are about to insert ### Good Example -- Typed Projections ```typescript type UserSummary = { email: string; displayName: string }; // The projection changes the result shape, so the generic on project() // re-types the cursor to match what actually comes back. async function listSummaries(db: Db): Promise<UserSummary[]> { return usersCollection(db) .find({}) .project<UserSummary>({ email: 1, displayName: 1, _id: 0 }) .toArray(); } export { listSummaries }; ``` **Why good:** without the generic the results are still typed as full `UserDoc`, so code reads `createdAt` off an object that never contained it and gets `undefined` at runtime with no type error ### Bad Example -- Trusting the Generic as Validation ```typescript // BAD: treating a compile-time type as a runtime guarantee const user = await usersCollection(db).findOne({ _id: id }); sendEmail(user!.email.toLowerCase()); // throws if email is missing or not a string ``` **Why bad:** the driver never validates documents against the generic, so a record written by an older schema version, a migration, or another service can be missing `email` entirely; the type says otherwise and the failure is a runtime `TypeError` ### Good Example -- Validating at the Trust Boundary ```typescript function toUserSummary(doc: WithId<UserDoc>): UserSummary { if (typeof doc.email !== "string" || typeof doc.displayName !== "string") { throw new Error(`User ${doc._id.toHexString()} is missing required fields`); } return { email: doc.email, displayName: doc.displayName }; } export { toUserSummary }; ``` **Why good:** the check runs once where documents enter application code, so downstream consumers get a type that has actually been verified, and a malformed record names itself in the error instead of surfacing as `undefined` three call frames away --- ## Pattern 3: CRUD and Result Shapes ### Good Example -- Insert ```typescript const insertResult = await users.insertOne({ email: "a@example.com", displayName: "A", roles: [], createdAt: new Date(), }); // { acknowledged: true, insertedId: ObjectId(...) } const manyResult = await users.insertMany(docs, { ordered: false }); // { acknowledged: true, insertedCount: n, insertedIds: { 0: ObjectId(...) } } ``` **Why good:** `ordered: false` lets the remaining documents insert when one fails, instead of stopping at the first error -- the right default for bulk imports where partial progress is useful ### Good Example -- Update, and Reading the Counts Correctly ```typescript const result = await users.updateOne({ _id: id }, { $set: { displayName } }); if (result.matchedCount === 0) { throw new NotFoundError(`No user with id ${id.toHexString()}`); } // matchedCount 1 with modifiedCount 0 means the value was already correct. // That is a successful update, not a missing document. ``` **Why good:** the not-found check uses `matchedCount`, which is the count that actually answers it, so re-submitting an unchanged form does not produce a spurious 404 ### Good Example -- Returning the Updated Document ```typescript // The findOneAnd* family is the only one that returns a document. const updated = await users.findOneAndUpdate( { _id: id }, { $set: { displayName }, $currentDate: { updatedAt: true } }, { returnDocument: "after" }, ); if (!updated) throw new NotFoundError(`No user with id ${id.toHexString()}`); ``` **Why good:** one round trip instead of an update followed by a read, `returnDocument: "after"` returns the new state rather than the default pre-update state, and `$currentDate` timestamps from the server clock so it does not drift between application hosts ### Good Example -- Upsert ```typescript const result = await counters.updateOne( { name: "signups" }, { $inc: { value: 1 }, $setOnInsert: { createdAt: new Date() } }, { upsert: true }, ); const wasCreated = result.upsertedCount === 1; ``` **Why good:** `$setOnInsert` applies only when the document is created, so `createdAt` is not overwritten on subsequent increments, and `upsertedCount` distinguishes creation from update without a second query ### Bad Example -- Expecting a Document Back ```typescript // BAD const user = await users.insertOne(doc); console.log(user.email); // undefined const updated = await users.updateOne(filter, update); return updated.displayName; // undefined ``` **Why bad:** both return acknowledgements, so these reads are `undefined` rather than errors; the value flows onward and fails at whatever finally uses it, which is usually a serializer or a template far from the query ### Good Example -- Delete ```typescript const { deletedCount } = await users.deleteOne({ _id: id }); if (deletedCount === 0) throw new NotFoundError(`No user with id ${id}`); const { deletedCount: purged } = await sessions.deleteMany({ expiresAt: { $lt: new Date() }, }); ``` --- ## Pattern 4: Error Handling ### Good Example -- Duplicate Key as an Expected Outcome ```typescript import { MongoServerError } from "mongodb"; const DUPLICATE_KEY_CODE = 11000; async function registerUser(db: Db, input: OptionalUnlessRequiredId<UserDoc>) { try { return await usersCollection(db).insertOne(input); } catch (error) { if ( error instanceof MongoServerError && error.code === DUPLICATE_KEY_CODE ) { throw new ConflictError(`Email ${input.email} is already registered`); } throw error; } } export { registerUser }; ``` **Why good:** two concurrent registrations are a race the unique index is there to settle, so the duplicate is an expected result to translate rather than a crash, and unrecognised errors rethrow instead of being swallowed ### Good Example -- Distinguishing Connectivity from Query Errors ```typescript import { MongoServerSelectionError } from "mongodb"; try { await connectDatabase(); } catch (error) { if (error instanceof MongoServerSelectionError) { // No reachable server within serverSelectionTimeoutMS: wrong URI, network, // firewall, or an IP not on the cluster's allow list. throw new Error( "Database unreachable — check MONGODB_URI and network access", ); } throw error; } ``` **Why good:** server selection failures are infrastructure problems with completely different remedies from query errors, and naming them stops an operator debugging a query that was never sent ### Bad Example -- Catching Everything ```typescript // BAD try { await users.insertOne(doc); } catch { return null; // duplicate key, validation failure and network partition, indistinguishable } ``` **Why bad:** unrelated failures collapse into one silent `null`, so a network partition looks exactly like a duplicate email and the caller cannot act correctly on either -
indexes.md 9.7 KB
# MongoDB Index Examples > Index types, compound key ordering, and verifying with `explain` using the native driver. See [SKILL.md](../SKILL.md) for the decisions behind these. **Core:** See [core.md](core.md). **Queries:** See [queries.md](queries.md). **Aggregation:** See [aggregation.md](aggregation.md). **Advanced:** See [patterns.md](patterns.md). --- ## Pattern 1: Creating Indexes ### Good Example -- Declared Once, Applied Deliberately ```typescript import type { Collection, IndexDescription } from "mongodb"; const ORDER_INDEXES: IndexDescription[] = [ { key: { tenantId: 1, createdAt: -1 }, name: "tenant_created" }, { key: { customerId: 1 }, name: "customer" }, { key: { reference: 1 }, name: "reference_unique", unique: true }, ]; // Called from a migration or a controlled startup step — never a request path. async function ensureOrderIndexes(orders: Collection<OrderDoc>): Promise<void> { await orders.createIndexes(ORDER_INDEXES); } export { ensureOrderIndexes, ORDER_INDEXES }; ``` **Why good:** the full index set is one reviewable list rather than scattered calls, explicit names make `explain` output and drop operations legible, and `createIndexes` is idempotent so re-running it is safe ### Bad Example -- Creating Indexes in a Request Path ```typescript // BAD export async function listOrders(filter: Filter<OrderDoc>) { await orders.createIndex({ tenantId: 1 }); // every request return orders.find(filter).toArray(); } ``` **Why bad:** the call runs on every request, and the first build on a large collection contends with live traffic; a schema change that alters the index then conflicts with the existing one and starts throwing on a path that used to work --- ## Pattern 2: Compound Indexes and the ESR Rule Order compound keys by how the query uses them: **E**quality first, then **S**ort, then **R**ange. ### Good Example -- ESR Applied ```typescript // Query: // find({ tenantId, status: { $in: [...] } }).sort({ createdAt: -1 }) // equality range sort await orders.createIndex( { tenantId: 1, createdAt: -1, status: 1 }, // E, S, R { name: "tenant_created_status" }, ); ``` **Why good:** the equality field seeks to one section of the index, the sort field is then already in order so no in-memory sort is needed, and the range field filters what remains ### Bad Example -- Range Before Sort ```typescript // BAD: R before S await orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 }); ``` **Why bad:** a range on `status` leaves the index scattered across many ranges, so `createdAt` is no longer traversed in order and the server sorts in memory — the index exists, is used, and still does not remove the sort, which is why this looks fine in `explain` until you read the sort stage ### Good Example -- Sort Direction on Compound Keys ```typescript // Serves sort({ tenantId: 1, createdAt: -1 }) and its exact inverse // sort({ tenantId: -1, createdAt: 1 }) — a mixed sort is not covered. await orders.createIndex({ tenantId: 1, createdAt: -1 }); ``` **Why good:** an index can be walked forwards or backwards, so one index serves both a sort and its complete reversal, and knowing that avoids creating a second index that adds write cost for nothing --- ## Pattern 3: Unique and Partial Indexes ### Good Example -- Conditional Uniqueness ```typescript // Enforce unique email among active users only, so soft-deleted rows // do not block a re-registration. await users.createIndex( { email: 1 }, { name: "email_unique_active", unique: true, partialFilterExpression: { deletedAt: { $exists: false } }, }, ); ``` **Why good:** the constraint matches the rule the application actually has, and the partial index is smaller and cheaper to maintain than one covering every document ### Good Example -- Indexing a Sparse Field ```typescript // Only some orders have an external reference; index only those. await orders.createIndex( { externalRef: 1 }, { name: "external_ref", partialFilterExpression: { externalRef: { $exists: true } }, }, ); ``` **Why good:** documents without the field are absent from the index entirely, keeping it small **A partial index is only used when the query provably matches its filter.** A query without `externalRef: { $exists: true }` (or a condition implying it) falls back to a collection scan, silently. --- ## Pattern 4: TTL Indexes ### Good Example -- Expiring Sessions ```typescript const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // The indexed field MUST hold a BSON Date. A number or an ISO string // is ignored and nothing is ever deleted. await sessions.createIndex( { expiresAt: 1 }, { name: "session_ttl", expireAfterSeconds: SESSION_TTL_SECONDS }, ); ``` **Why good:** expiry is enforced by the server rather than a cleanup job that can stop running, and the constant states the retention window in one place ### Good Example -- Per-Document Expiry ```typescript // expireAfterSeconds: 0 deletes each document at the time its own field holds, // which lets different documents expire on different schedules. await tokens.createIndex( { expiresAt: 1 }, { name: "token_ttl", expireAfterSeconds: 0 }, ); await tokens.insertOne({ value, expiresAt: new Date(Date.now() + ttlMs) }); ``` **Why good:** one index serves many retention policies, so a short-lived token and a long-lived one coexist without a second index **Deletion is not immediate.** The background task runs roughly every minute, so expired documents stay readable for a short window. Filter on `expiresAt` in queries where that matters rather than trusting the sweep. --- ## Pattern 5: Text Indexes ### Good Example -- Full-Text Search ```typescript const TITLE_WEIGHT = 10; const BODY_WEIGHT = 1; const SEARCH_RESULT_LIMIT = 20; await articles.createIndex( { title: "text", body: "text" }, { name: "article_text", weights: { title: TITLE_WEIGHT, body: BODY_WEIGHT } }, ); type ScoredArticle = WithId<ArticleDoc> & { score: number }; const results = await articles .find({ $text: { $search: query } }) .project<ScoredArticle>({ score: { $meta: "textScore" }, title: 1, body: 1 }) .sort({ score: { $meta: "textScore" } }) .limit(SEARCH_RESULT_LIMIT) .toArray(); ``` **Why good:** weighting ranks a title match above a body match, and the score is both projected and sorted on, which are two separate steps — sorting without projecting it produces an error **One text index per collection.** Adding a second requires dropping the first, so the field list has to be decided as a whole rather than extended later. --- ## Pattern 6: Geospatial Indexes ### Good Example -- Nearby Search ```typescript const SEARCH_RADIUS_METRES = 5_000; const NEARBY_LIMIT = 20; // Coordinates are [longitude, latitude] — that order, always. await venues.createIndex({ location: "2dsphere" }, { name: "venue_location" }); const nearby = await venues .find({ location: { $near: { $geometry: { type: "Point", coordinates: [longitude, latitude] }, $maxDistance: SEARCH_RADIUS_METRES, }, }, }) .limit(NEARBY_LIMIT) .toArray(); ``` **Why good:** `$near` returns results already ordered nearest-first so no sort stage is needed, and `$maxDistance` in metres bounds the scan **The coordinate order is the usual defect here:** GeoJSON is `[longitude, latitude]` while most APIs and every map UI say "lat, lng". Reversed coordinates produce plausible-looking results in the wrong hemisphere rather than an error. --- ## Pattern 7: Verifying with explain ### Good Example -- Confirming the Index Is Used ```typescript const plan = await orders .find({ tenantId, status: "complete" }) .sort({ createdAt: -1 }) .explain("executionStats"); ``` Read three things from the output: | Field | Want | A problem when | | ------------------------------------------------- | -------------- | ------------------------------------------------------------- | | `winningPlan.stage` | `IXSCAN` | `COLLSCAN` — no index is being used at all | | `executionStats.totalDocsExamined` vs `nReturned` | Close together | Examined ≫ returned — the index narrows poorly | | A `SORT` stage in the plan | Absent | Present — the sort is happening in memory, not from the index | ### Good Example -- Finding Unused Indexes ```typescript // Every index costs write throughput and storage. One never read is pure cost. const usage = await orders.aggregate([{ $indexStats: {} }]).toArray(); ``` **Why good:** `accesses.ops` per index turns index cleanup into evidence rather than guesswork — but read it only from a server that has been up long enough to be representative, since the counters reset on restart ### Bad Example -- Assuming the Index Works ```typescript // BAD await orders.createIndex({ tenantId: 1, status: 1 }); // ...ship it, and find out at scale ``` **Why bad:** an unindexed or badly ordered query returns correct results at every size, so nothing fails in review or in tests — the defect only appears when the collection is large, which is exactly when it is hardest to fix --- ## Index Rules of Thumb - Index every field you filter or sort on in a query that runs often; leave the rest unindexed. - Prefer one well-ordered compound index over several single-field ones — the server generally uses one index per query. - Do not index a low-cardinality field alone (a two-value status), but it can earn its place as a later key in a compound index. - Every index slows writes and consumes storage. An index with no reads is pure cost. - Build indexes before a collection grows, not after it has become a problem. - `explain` before and after. An index you did not verify is a hypothesis. -
patterns.md 10.5 KB
# MongoDB Advanced Patterns > Transactions, bulk writes, change streams, schema evolution and serverless usage with the native driver. See [SKILL.md](../SKILL.md) for the decisions behind these. **Core:** See [core.md](core.md). **Queries:** See [queries.md](queries.md). **Aggregation:** See [aggregation.md](aggregation.md). **Indexes:** See [indexes.md](indexes.md). --- ## Pattern 1: Transactions ### Good Example -- Transfer with a Session ```typescript import type { ClientSession, MongoClient, ObjectId } from "mongodb"; async function transfer( client: MongoClient, from: ObjectId, to: ObjectId, amount: number, ): Promise<void> { const session = client.startSession(); try { await session.withTransaction( async () => { const accounts = client.db(DB_NAME).collection<AccountDoc>("accounts"); const debited = await accounts.updateOne( { _id: from, balance: { $gte: amount } }, // the guard is in the filter { $inc: { balance: -amount } }, { session }, ); // Throwing aborts the transaction: the credit below never happens, // and the debit above is rolled back. if (debited.matchedCount === 0) { throw new InsufficientFundsError(from); } await accounts.updateOne( { _id: to }, { $inc: { balance: amount } }, { session }, ); }, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" }, }, ); } finally { await session.endSession(); } } export { transfer }; ``` **Why good:** the balance check lives in the filter so it is evaluated atomically with the write rather than in a read-then-write race, `withTransaction` commits on return and aborts on throw, `w: "majority"` means a committed transfer survives a failover, and the `finally` releases the session on every path ### Bad Example -- A Missing Session ```typescript // BAD await session.withTransaction(async () => { await accounts.updateOne( { _id: from }, { $inc: { balance: -amount } }, { session }, ); await accounts.updateOne({ _id: to }, { $inc: { balance: amount } }); // no session }); ``` **Why bad:** the credit runs outside the transaction and commits on its own, so if the transaction aborts the debit is rolled back and the credit is not — money is created, no error is raised, and the two accounts disagree ### Bad Example -- Side Effects Inside the Callback ```typescript // BAD await session.withTransaction(async () => { await orders.insertOne(order, { session }); await sendConfirmationEmail(order); // not transactional, and retried }); ``` **Why bad:** the driver re-runs the callback on transient errors, so a retry sends a second email for one order; nothing outside the database can be rolled back, so it must not be inside the callback. Record the intent in the transaction and act on it after the commit. ### Good Example -- Parallel Work Is Not Allowed on One Session ```typescript // BAD: a session is single-threaded await Promise.all([ accounts.updateOne(a, updateA, { session }), accounts.updateOne(b, updateB, { session }), ]); // GOOD: sequential within the transaction await accounts.updateOne(a, updateA, { session }); await accounts.updateOne(b, updateB, { session }); ``` **Why bad:** operations on one session must be ordered; issuing them concurrently produces errors that appear intermittently under load and not at all in a test ### Good Example -- Not Using a Transaction ```typescript // A single-document write is already atomic. A transaction around it // adds coordination cost and buys nothing. await accounts.updateOne( { _id: id, balance: { $gte: amount } }, { $inc: { balance: -amount }, $push: { history: entry } }, ); ``` **Why good:** the guard, the decrement and the history append apply as one atomic document update, which is both cheaper than a transaction and available on a standalone server **Transactions require a replica set or sharded cluster.** A standalone server rejects them, which is how a transaction passes in staging and fails on a single-node development machine. --- ## Pattern 2: Bulk Writes ### Good Example -- Batched Mixed Operations ```typescript import type { AnyBulkWriteOperation } from "mongodb"; const BULK_BATCH_SIZE = 1_000; async function syncProducts( products: Collection<ProductDoc>, incoming: ProductInput[], ): Promise<number> { let modified = 0; for (let start = 0; start < incoming.length; start += BULK_BATCH_SIZE) { const batch = incoming.slice(start, start + BULK_BATCH_SIZE); const operations: AnyBulkWriteOperation<ProductDoc>[] = batch.map( (item) => ({ updateOne: { filter: { sku: item.sku }, update: { $set: item, $setOnInsert: { createdAt: new Date() } }, upsert: true, }, }), ); // ordered: false lets the server apply operations in parallel and keep // going past a failure, rather than stopping at the first one. const result = await products.bulkWrite(operations, { ordered: false }); modified += result.modifiedCount + result.upsertedCount; } return modified; } export { syncProducts }; ``` **Why good:** one round trip per thousand documents instead of one per document, explicit batching keeps a single request from growing past the server's limits, and `ordered: false` means one bad record does not abandon the rest of the batch ### Bad Example -- A Round Trip per Document ```typescript // BAD for (const item of incoming) { await products.updateOne({ sku: item.sku }, { $set: item }, { upsert: true }); } ``` **Why bad:** network latency is paid once per document, so 10,000 items at 2 ms each spend 20 seconds waiting rather than working — the database is not the bottleneck, the round trips are **Reading bulk errors:** with `ordered: false` a partial failure throws `MongoBulkWriteError`, whose `result` holds what did succeed and whose `writeErrors` names each failure by index. Discarding the error discards the record of what already applied. --- ## Pattern 3: Change Streams ### Good Example -- Reacting to Writes ```typescript const changeStream = orders.watch([{ $match: { operationType: "insert" } }], { fullDocument: "updateLookup", }); try { for await (const change of changeStream) { if (change.operationType === "insert") { await onOrderCreated(change.fullDocument); } } } finally { await changeStream.close(); } ``` **Why good:** the pipeline filters server-side so uninteresting events never cross the network, and the `finally` closes the stream rather than leaving it open against the server ### Good Example -- Resuming After a Restart ```typescript // Persist the resume token with the work it corresponds to, so a restart // continues from the last processed change instead of replaying or skipping. const resumeAfter = await loadResumeToken(); const stream = orders.watch([], resumeAfter ? { resumeAfter } : {}); for await (const change of stream) { await handle(change); await saveResumeToken(change._id); } ``` **Why good:** without a stored token a restart resumes from now, silently losing every change that occurred while the process was down **Change streams need a replica set,** and resumability is bounded by the oplog window — a process down longer than the oplog retains cannot resume and must fall back to a reconciliation pass. --- ## Pattern 4: Schema Evolution Without a Schema ### Good Example -- Versioned Documents ```typescript const CURRENT_SCHEMA_VERSION = 2; type UserDocV2 = { _id: ObjectId; schemaVersion: number; email: string; name: { first: string; last: string }; // v1 had a single `fullName` }; // Read path tolerates both shapes; the migration runs in the background. function normalizeUser(doc: WithId<Document>): UserDocV2 { if (doc.schemaVersion === CURRENT_SCHEMA_VERSION) return doc as UserDocV2; const [first = "", ...rest] = String(doc.fullName ?? "").split(" "); return { _id: doc._id, schemaVersion: CURRENT_SCHEMA_VERSION, email: String(doc.email), name: { first, last: rest.join(" ") }, }; } export { CURRENT_SCHEMA_VERSION, normalizeUser }; ``` **Why good:** a stored version field makes "which shape is this?" answerable rather than inferred from which fields happen to be present, and the tolerant read path means the migration does not have to complete before the new code ships ### Good Example -- Server-Side Validation ```typescript // MongoDB can enforce a schema even though the driver does not. await db.command({ collMod: "users", validator: { $jsonSchema: { bsonType: "object", required: ["email", "schemaVersion"], properties: { email: { bsonType: "string" }, schemaVersion: { bsonType: "int" }, }, }, }, validationLevel: "moderate", // existing invalid documents are left alone }); ``` **Why good:** the guarantee lives with the data, so it holds for every writer including scripts and other services, and `moderate` applies it to new and valid-existing documents without rejecting a backlog that has not been migrated --- ## Pattern 5: Serverless and Edge ### Good Example -- Reusing the Client Across Invocations ```typescript const SERVERLESS_POOL_SIZE = 5; const MAX_IDLE_TIME_MS = 30_000; let clientPromise: Promise<MongoClient> | undefined; function getClient(): Promise<MongoClient> { // Caching the promise rather than the client means concurrent cold // invocations share one connect() instead of racing several. clientPromise ??= new MongoClient(requireEnv("MONGODB_URI"), { maxPoolSize: SERVERLESS_POOL_SIZE, minPoolSize: 0, maxIdleTimeMS: MAX_IDLE_TIME_MS, }).connect(); return clientPromise; } export { getClient }; ``` **Why good:** warm invocations reuse the pool and skip the handshake, caching the promise removes the race between simultaneous cold starts, and a small pool with `minPoolSize: 0` keeps total connections bounded when the platform scales out to many instances ### Bad Example -- Closing the Client per Invocation ```typescript // BAD export async function handler() { const client = await getClient(); try { return await doWork(client); } finally { await client.close(); // discards the pool the next invocation would reuse } } ``` **Why bad:** every invocation becomes a cold connect, so the platform's reuse of the process buys nothing and each request pays the full TLS and auth handshake **Connection limits are the real constraint here.** Instances × `maxPoolSize` is the ceiling, and a platform that scales to 200 instances with a pool of 100 asks for 20,000 connections from a cluster that permits far fewer. -
queries.md 9.1 KB
# MongoDB Query Examples > Filters, projection, cursors, streaming, pagination and counting with the native driver. See [SKILL.md](../SKILL.md) for the decisions behind these. **Core:** See [core.md](core.md). **Aggregation:** See [aggregation.md](aggregation.md). **Indexes:** See [indexes.md](indexes.md). **Advanced:** See [patterns.md](patterns.md). --- ## Pattern 1: Filters ### Good Example -- Typed Filter Construction ```typescript import type { Filter } from "mongodb"; const MAX_RESULTS = 100; type OrderQuery = { tenantId: ObjectId; status?: OrderStatus; minTotal?: number; since?: Date; }; function buildOrderFilter(query: OrderQuery): Filter<OrderDoc> { const filter: Filter<OrderDoc> = { tenantId: query.tenantId }; if (query.status !== undefined) filter.status = query.status; if (query.minTotal !== undefined) filter.total = { $gte: query.minTotal }; if (query.since !== undefined) filter.createdAt = { $gte: query.since }; return filter; } export { buildOrderFilter }; ``` **Why good:** `Filter<OrderDoc>` checks every field name and operator against the document type, and the explicit `!== undefined` tests keep a legitimate `0` or empty string from being dropped the way a truthiness check would drop them ### Bad Example -- User Input Straight into a Filter ```typescript // BAD: the request body becomes the filter const orders = await collection .find({ tenantId, status: req.body.status }) .toArray(); ``` **Why bad:** a JSON body can supply an object rather than a string, so `{"status": {"$ne": null}}` turns the filter into an operator and returns every order in the tenant; the query is valid and nothing errors ### Good Example -- Coercing Before Filtering ```typescript const ORDER_STATUSES = ["pending", "complete", "cancelled"] as const; type OrderStatus = (typeof ORDER_STATUSES)[number]; function parseStatus(input: unknown): OrderStatus | undefined { if (typeof input !== "string") return undefined; return ORDER_STATUSES.find((status) => status === input); } export { ORDER_STATUSES, parseStatus }; ``` **Why good:** the value reaching the filter is a primitive drawn from a known set, so an injected operator object cannot survive the parse ### Good Example -- ObjectId Validation ```typescript import { ObjectId } from "mongodb"; const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; function parseObjectId(input: string): ObjectId | undefined { // ObjectId.isValid() returns true for ANY 12-character string, so // "123456789012" passes it. Test the hex representation instead. if (!OBJECT_ID_PATTERN.test(input)) return undefined; return new ObjectId(input); } export { parseObjectId }; ``` **Why good:** rejects the 12-character strings `ObjectId.isValid()` accepts, so a malformed path parameter becomes a 400 rather than a query that quietly matches nothing --- ## Pattern 2: Projection ### Good Example -- Fetching Only What Is Used ```typescript type OrderListItem = { _id: ObjectId; total: number; createdAt: Date }; const items = await orders .find(filter) .project<OrderListItem>({ total: 1, createdAt: 1 }) .limit(MAX_RESULTS) .toArray(); ``` **Why good:** projection happens on the server, so large unused fields never cross the network, and the generic re-types the cursor to the shape actually returned ### Good Example -- Excluding Instead of Including ```typescript // Inclusion and exclusion cannot be mixed in one projection, except for _id. const user = await users.findOne( { _id: id }, { projection: { passwordHash: 0 } }, ); ``` **Why good:** an exclusion projection keeps returning new fields as the document evolves while guaranteeing the sensitive one is never among them, which an inclusion list would need updating to match ### Bad Example -- Filtering Fields in Application Code ```typescript // BAD const all = await orders.find(filter).toArray(); const items = all.map(({ _id, total, createdAt }) => ({ _id, total, createdAt, })); ``` **Why bad:** every field of every document is serialised, transferred and parsed before being discarded, so the cost is paid in full and only the memory afterwards is saved --- ## Pattern 3: Cursors and Streaming ### Good Example -- Streaming an Unbounded Read ```typescript async function emailActiveUsers(users: Collection<UserDoc>): Promise<number> { let sent = 0; // for await pulls one batch at a time, so memory stays flat whether the // collection holds a thousand documents or ten million. for await (const user of users.find({ isActive: true })) { await sendDigest(user.email); sent += 1; } return sent; } export { emailActiveUsers }; ``` **Why good:** the cursor is fully consumed so it closes on its own, and peak memory is one batch rather than the whole result set ### Good Example -- Closing a Cursor You Abandon ```typescript const cursor = orders.find({ status: "pending" }); try { for await (const order of cursor) { if (await shouldStop(order)) break; // leaves the cursor open } } finally { await cursor.close(); } ``` **Why good:** breaking out mid-iteration leaves an open server-side cursor holding resources until it times out; the `finally` releases it immediately and is harmless when the loop finished normally ### Good Example -- Batch Size for Slow Per-Document Work ```typescript const SLOW_CONSUMER_BATCH_SIZE = 20; const cursor = orders .find({ status: "pending" }) .batchSize(SLOW_CONSUMER_BATCH_SIZE); ``` **Why good:** smaller batches shorten the pause when a batch is fetched and reduce the work discarded if the consumer stops early, which matters when each document triggers slow work ### Bad Example -- Buffering Everything ```typescript // BAD const everyone = await users.find({}).toArray(); for (const user of everyone) await sendDigest(user.email); ``` **Why bad:** the entire collection is materialised before the first email is sent, so memory scales with the data and the process dies once the collection outgrows the heap -- which it will not do in development --- ## Pattern 4: Pagination ### Good Example -- Keyset Pagination ```typescript const PAGE_SIZE = 25; type Page<T> = { items: T[]; nextCursor: string | undefined }; // Requires an index on the sort key. _id is monotonic enough for most feeds; // use a compound key when sorting on something else. async function pageOrders( orders: Collection<OrderDoc>, after: ObjectId | undefined, ): Promise<Page<WithId<OrderDoc>>> { const filter: Filter<OrderDoc> = after ? { _id: { $lt: after } } : {}; const items = await orders .find(filter) .sort({ _id: -1 }) .limit(PAGE_SIZE) .toArray(); const last = items.at(-1); return { items, nextCursor: items.length === PAGE_SIZE ? last?._id.toHexString() : undefined, }; } export { pageOrders }; ``` **Why good:** every page costs the same because the index seeks straight to the cursor position, and `nextCursor` is `undefined` on a short page so the caller knows the feed ended without an extra count query ### Bad Example -- Deep Offset Pagination ```typescript // BAD const items = await orders .find(filter) .sort({ createdAt: -1 }) .skip(page * PAGE_SIZE) // page 500 walks 12,500 documents to discard them .limit(PAGE_SIZE) .toArray(); ``` **Why bad:** the server walks and discards every skipped document, so cost grows with page depth and the last pages time out; results also shift when a document is inserted between requests, so items can be seen twice or missed entirely --- ## Pattern 5: Counting ### Good Example -- Choosing the Right Count ```typescript // Accurate, but scans the index or collection to answer. const pending = await orders.countDocuments({ status: "pending" }); // Reads collection metadata: constant time, but approximate, and it takes // no filter. Fine for a dashboard total, wrong for anything that must reconcile. const approximateTotal = await orders.estimatedDocumentCount(); ``` **Why good:** the choice is made explicitly on whether the number must be exact, rather than reaching for whichever is faster and discovering the imprecision later ### Good Example -- Existence Without Counting ```typescript // findOne with a projection stops at the first match; counting does not. const exists = (await users.findOne({ email }, { projection: { _id: 1 } })) !== null; ``` **Why good:** counting scans every match to produce a number the caller then compares to zero, while this stops as soon as one document is found --- ## Pattern 6: Distinct and Sorting ### Good Example -- Distinct Values ```typescript const statuses = await orders.distinct("status", { tenantId }); ``` **Why good:** the deduplication happens on the server, so only the distinct values cross the network rather than every document **Limitation:** the result must fit in a single 16 MB reply. For a high-cardinality field use a `$group` pipeline, which streams instead. ### Good Example -- Sorting on an Indexed Field ```typescript const recent = await orders .find({ tenantId }) .sort({ createdAt: -1 }) // needs an index whose keys match this sort .limit(PAGE_SIZE) .toArray(); ``` **Why good:** a sort backed by an index reads in order and stops at the limit; an unindexed sort loads every match into memory first and fails outright once that exceeds the server's sort limit
-
-
reference.md 11.6 KB
# MongoDB Native Driver Reference > Quick lookup: decision tables, connection options, result shapes, operators, and error codes. See [SKILL.md](SKILL.md) for patterns and [examples/](examples/) for full implementations. --- ## Decision Tables ### Which read method | You need | Use | | -------------------------------- | --------------------------------------------- | | One document | `findOne(filter)` | | A bounded page | `find(filter).limit(n).toArray()` | | An unbounded set | `for await (const doc of find(filter))` | | Reshaped, grouped or joined data | `aggregate(pipeline)` | | An exact count | `countDocuments(filter)` | | An approximate total, instantly | `estimatedDocumentCount()` (takes no filter) | | Unique values of a field | `distinct(field, filter)` (16 MB result cap) | | Existence only | `findOne(filter, { projection: { _id: 1 } })` | ### Which write method | You need | Use | | --------------------------------- | ------------------------------------------------------- | | Insert one / many | `insertOne` / `insertMany(docs, { ordered: false })` | | Change fields, discard the result | `updateOne` / `updateMany` | | Change fields, get the document | `findOneAndUpdate(f, u, { returnDocument: "after" })` | | Insert-or-update | `updateOne(f, u, { upsert: true })` with `$setOnInsert` | | Overwrite a whole document | `replaceOne` | | Many mixed operations | `bulkWrite(ops, { ordered: false })` | | Delete | `deleteOne` / `deleteMany` | ### Result shapes | Operation | Returns | | -------------------- | -------------------------------------------------------------------------- | | `insertOne` | `{ acknowledged, insertedId }` | | `insertMany` | `{ acknowledged, insertedCount, insertedIds }` | | `updateOne/Many` | `{ acknowledged, matchedCount, modifiedCount, upsertedCount, upsertedId }` | | `replaceOne` | Same as `updateOne` | | `deleteOne/Many` | `{ acknowledged, deletedCount }` | | `bulkWrite` | Counts per operation type, plus `insertedIds` / `upsertedIds` | | `findOneAnd*` | **The document, or `null`** — the only family that returns one | | `find` / `aggregate` | A lazy cursor — nothing runs until it is iterated | `matchedCount` answers "did it exist". `modifiedCount` answers "did it change". They differ on a no-op update, and only the first one answers a not-found check. --- ## Connection Options | Option | What it controls | Typical | | ---------------------------- | ------------------------------------------------ | ------------------------------------------- | | `maxPoolSize` | Max connections in the pool | 10–20 per process | | `minPoolSize` | Connections kept warm | 2 (0 in serverless) | | `maxIdleTimeMS` | Before an idle connection is closed | 30–60 s | | `serverSelectionTimeoutMS` | How long to look for a reachable server | 5 s | | `connectTimeoutMS` | TCP/TLS handshake timeout | 10 s | | `socketTimeoutMS` | Inactivity on an established socket | Usually leave unset | | `timeoutMS` | One end-to-end budget covering a whole operation | Prefer over per-stage ones | | `retryWrites` / `retryReads` | Retry once after a transient network error | `true` (the default) | | `writeConcern` | How many nodes acknowledge a write | `{ w: "majority" }` | | `readPreference` | Which node serves reads | `primary` unless stale reads are acceptable | | `serverApi` | Pin the versioned API across server upgrades | `{ version: v1, strict: true }` | ### Connection string ``` mongodb+srv://<user>:<password>@<cluster>/<db>?retryWrites=true&w=majority mongodb://127.0.0.1:27017/<db> # local — prefer 127.0.0.1 over localhost ``` `mongodb+srv://` resolves hosts through DNS SRV records, so the host list is not hard-coded. Percent-encode any password containing reserved characters (`@`, `:`, `/`, `?`, `#`), or authentication fails with an error that names the wrong host. **Prefer `127.0.0.1` to `localhost` locally:** Node resolves `localhost` to IPv6 first on modern versions, and a server bound only to IPv4 then appears unreachable until server selection times out. --- ## Query Operators | Operator | Matches | | -------------------------- | ------------------------------------------------------------ | | `$eq` `$ne` | Equal / not equal | | `$gt` `$gte` `$lt` `$lte` | Ranges | | `$in` `$nin` | Membership of an array of values | | `$exists` | Field present or absent | | `$type` | BSON type | | `$regex` | Pattern — anchored (`^`) can use an index; unanchored cannot | | `$and` `$or` `$nor` `$not` | Logical composition | | `$all` | Array contains all listed values | | `$elemMatch` | One array element matches every condition | | `$size` | Exact array length | | `$expr` | Compare two fields of the same document | | `$text` | Text index search | | `$near` `$geoWithin` | Geospatial | `{ tags: { $gt: "a", $lt: "z" } }` matches a document where _some_ element beats each condition separately. Use `$elemMatch` when one element must satisfy all of them. ## Update Operators | Operator | Effect | | ------------------------- | ---------------------------------------------------- | | `$set` `$unset` | Set or remove fields | | `$setOnInsert` | Apply only when an upsert creates the document | | `$inc` `$mul` | Arithmetic, atomically | | `$min` `$max` | Set only if lower / higher than the current value | | `$rename` | Rename a field | | `$currentDate` | Server clock, avoiding host clock drift | | `$push` `$addToSet` | Append / append-if-absent | | `$pull` `$pullAll` `$pop` | Remove elements | | `$each` `$slice` `$sort` | Modifiers for `$push` — bound an array as you append | | `arrayFilters` | Update matching array elements by condition | Bound an array as you write it rather than trimming it later: ```typescript const MAX_HISTORY = 50; await doc.updateOne(filter, { $push: { history: { $each: [entry], $slice: -MAX_HISTORY } }, }); ``` ## Aggregation Stages | Stage | Purpose | | -------------------------------- | ------------------------------------------------------ | | `$match` | Filter — **first**, so it can use an index | | `$project` `$addFields` `$unset` | Reshape; use early to shrink documents | | `$group` | Aggregate by key | | `$sort` `$limit` `$skip` | Order and slice — only a leading `$sort` uses an index | | `$lookup` | Join — index the `foreignField` | | `$unwind` | One document per array element | | `$facet` | Several sub-pipelines over one input | | `$bucket` `$bucketAuto` | Group into ranges | | `$merge` `$out` | Write results to a collection | | `$count` | Count into a single-field document | | `$setWindowFields` | Running totals, rankings, moving averages | --- ## Error Codes | Condition | Detect with | | -------------------------- | ------------------------------------------------------- | | Duplicate key | `MongoServerError` with `code === 11000` | | No reachable server | `MongoServerSelectionError` | | Network failure mid-op | `MongoNetworkError` | | Partial bulk failure | `MongoBulkWriteError` — read `result` and `writeErrors` | | Document failed validation | `MongoServerError` with `code === 121` | | Transaction can be retried | Error label `TransientTransactionError` | | Commit outcome unknown | Error label `UnknownTransactionCommitResult` | `withTransaction` already retries both transaction labels. Handle them by hand only outside it. --- ## mongosh Commands ```bash mongosh "mongodb+srv://cluster.example.net/appdb" --username admin show dbs use appdb show collections db.orders.findOne({ status: "pending" }) db.orders.countDocuments({ tenantId }) db.orders.getIndexes() db.orders.createIndex({ tenantId: 1, createdAt: -1 }, { name: "tenant_created" }) db.orders.dropIndex("tenant_created") db.orders.aggregate([{ $indexStats: {} }]) db.orders.find({ tenantId }).sort({ createdAt: -1 }).explain("executionStats") db.currentOp({ "secs_running": { $gt: 5 } }) # long-running operations db.serverStatus().connections # current vs available ``` --- ## Version Notes The driver's major version determines several behaviours worth checking against the version actually installed: - **Callbacks were removed** — every operation returns a promise. Callback-style code from older material throws. - **`findOneAnd*` returns the document directly.** Pass `includeResultMetadata: true` for the older `{ value, ok, lastErrorObject }` wrapper. - **`timeoutMS`** provides one end-to-end budget per operation, superseding the older per-stage timeout options. - **The `background` index option is ignored** — the driver still accepts it, but MongoDB 4.2+ builds every index without blocking, so the option no longer changes anything. -
SKILL.md 24 KB
--- name: api-database-mongodb description: Native MongoDB driver (the mongodb npm package) - MongoClient lifecycle, typed collections, CRUD result shapes, cursors, aggregation pipelines, index design, transactions --- # MongoDB Native Driver Patterns > **Quick Guide:** Talk to MongoDB through the official `mongodb` driver with no schema layer in between. Create ONE `MongoClient` per process and reuse it -- it owns the connection pool. Type collections with a generic: `db.collection<UserDoc>("users")`. Write operations return acknowledgements, never documents. `find()` returns a lazy cursor; stream it with `for await` instead of `toArray()` for anything unbounded. Put `$match` first in every pipeline so it can use an index. Verify indexes with `explain("executionStats")` rather than assuming. Transactions need a replica set, a session on every operation, and a callback that can safely run twice. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST create exactly ONE `MongoClient` per process and reuse it -- the client owns a connection pool, so constructing one per request opens a new pool per request and exhausts the server's connection limit)** **(You MUST pass `{ session }` to EVERY operation inside a transaction -- an operation without it silently runs outside the transaction and is not rolled back)** **(You MUST write `withTransaction` callbacks to be safely re-runnable -- the driver retries them on transient errors, so any side effect outside the transaction happens more than once)** **(You MUST iterate or close every cursor you open -- an abandoned cursor holds server-side resources until it times out)** **(You MUST NOT expect write operations to return documents -- `insertOne` returns `{ acknowledged, insertedId }` and `updateOne` returns counts; only the `findOneAnd*` family returns a document)** **(You MUST verify a query uses the index you intended with `explain("executionStats")` -- an unindexed query succeeds silently and only fails once the collection is large)** </critical_requirements> --- **Auto-detection:** mongodb, MongoClient, ServerApiVersion, client.db, db.collection, insertOne, insertMany, updateOne, findOneAndUpdate, deleteOne, bulkWrite, FindCursor, AggregationCursor, toArray, ObjectId, WithId, OptionalUnlessRequiredId, Filter, UpdateFilter, createIndex, createIndexes, explain, startSession, withTransaction, readPreference, writeConcern, maxPoolSize, serverSelectionTimeoutMS, MongoServerError, code 11000 **When to use:** - Talking to MongoDB directly with no schema or modelling layer in between - Aggregation-heavy workloads (reporting, analytics, materialised views) - Bulk and batch pipelines where per-document overhead is the bottleneck - Index design, query-plan investigation, and performance work - Multi-document transactions with explicit session control - Serverless and edge runtimes where client and pool lifecycle must be controlled by hand **Key patterns covered:** - Client and pool lifecycle (one client per process, startup and shutdown, serverless reuse) - Typed collections and the driver's document type helpers - CRUD and the result shapes each operation actually returns - Cursors: lazy evaluation, streaming, batching, and pagination that stays fast - Aggregation pipeline construction, stage ordering, and memory limits - Index types, compound key ordering, and verification with `explain` - Transactions: sessions, retry semantics, and when not to use one - Error handling on driver-specific error codes **When NOT to use:** - You want schemas, validation, middleware hooks or population handled for you -- use an ODM layer instead of building one on top of this - Highly relational data with multi-table joins and foreign key constraints (use a relational database) - Simple key-value caching (use a dedicated key-value store) - Time-series data at very large scale (use a purpose-built time-series database) **Detailed Resources:** - For decision tables, connection-option reference, and operator lookup, see [reference.md](reference.md) **Core Patterns:** - [examples/core.md](examples/core.md) - Client lifecycle, typed collections, CRUD and result shapes, error handling **Query Patterns:** - [examples/queries.md](examples/queries.md) - Filters, projection, cursors, streaming, keyset pagination, counting **Aggregation:** - [examples/aggregation.md](examples/aggregation.md) - Pipeline construction, `$lookup`, `$facet`, `$merge`, typed output **Indexing:** - [examples/indexes.md](examples/indexes.md) - Single-field, compound (ESR), partial, TTL, text, geospatial, and `explain` **Advanced Patterns:** - [examples/patterns.md](examples/patterns.md) - Transactions, bulk writes, change streams, schema evolution, serverless --- <philosophy> ## Philosophy The native driver is a thin, faithful mapping of the MongoDB wire protocol into TypeScript. It gives you the database's own vocabulary -- commands, cursors, pipelines, sessions -- with nothing interpreting them on your behalf. **Its value is that nothing is hidden, and its cost is that nothing is provided.** There is no schema, no validation, no lifecycle hook, no lazy reference resolution. Whatever structure your documents have is the structure your code maintains. That trade is worth making when the database's own model is the thing you are working with: aggregation pipelines, index behaviour, bulk throughput, transaction boundaries. It is a poor trade when what you actually wanted was application-layer modelling, because building a half-schema by hand is strictly worse than adopting one. **Core principles:** 1. **One client, one pool, one process.** `MongoClient` is a long-lived object that manages a pool of sockets. Creating one per request is the single most expensive mistake available here, and it looks like correct resource hygiene while doing the opposite. 2. **The driver returns what the server returned.** Write commands return acknowledgements and counts, not documents. Code that assumes otherwise reads `undefined` rather than failing, so the mistake surfaces far from its cause. 3. **Cursors are lazy and finite.** `find()` sends nothing until iterated, and the resulting cursor holds server-side state until it is exhausted or closed. Stream what is unbounded; buffer only what you have bounded. 4. **Push work into the database.** An aggregation pipeline runs beside the data. The equivalent JavaScript runs after every candidate document has crossed the network. 5. **An index is a claim to be verified.** `createIndex` succeeding proves the index exists, not that your query uses it. `explain` is the only thing that proves the second. 6. **Types are yours to assert.** A collection generic is a compile-time promise about documents the driver never validates. Treat data crossing a trust boundary as unvalidated until you have validated it. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Client and Pool Lifecycle Construct one `MongoClient` at startup, reuse it everywhere, close it on shutdown. The client is thread-safe and pools internally, so sharing one is both correct and faster. ```typescript import { MongoClient, ServerApiVersion } from "mongodb"; const POOL_SIZE_MAX = 20; const POOL_SIZE_MIN = 2; const SERVER_SELECTION_TIMEOUT_MS = 5_000; const client = new MongoClient(requireEnv("MONGODB_URI"), { maxPoolSize: POOL_SIZE_MAX, minPoolSize: POOL_SIZE_MIN, serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true, }, }); await client.connect(); // optional, but fails fast on bad credentials or DNS ``` ```typescript // BAD: a client per request export async function getUser(id: string) { const client = new MongoClient(uri); // a new pool, every request await client.connect(); // ... } ``` **Why bad:** each client opens its own pool, so concurrent requests multiply into hundreds of sockets and the server refuses new connections; the handshake cost is also paid per request instead of once See [examples/core.md](examples/core.md#pattern-1-client-and-pool-lifecycle) for startup, graceful shutdown, and serverless client reuse. --- ### Pattern 2: Typed Collections The collection generic describes the document as _stored_. The driver's helpers then derive the right shape per operation -- `WithId<T>` for reads, `OptionalUnlessRequiredId<T>` for inserts, so a caller may omit `_id` and let the server generate it. ```typescript import type { ObjectId, WithId } from "mongodb"; type UserDoc = { _id: ObjectId; email: string; createdAt: Date; }; const users = client.db(DB_NAME).collection<UserDoc>("users"); const user: WithId<UserDoc> | null = await users.findOne({ email }); ``` **Why good:** filters, updates and projections are all checked against `UserDoc`, so a typo in a field name is a compile error rather than a query that silently matches nothing **The generic is an assertion, not a guarantee.** The driver does not validate documents against it. A collection written by an older version of the code, or by another service, can contain anything. See [examples/core.md](examples/core.md#pattern-2-typed-collections) for projection typing, nested field paths, and validating untrusted documents. --- ### Pattern 3: CRUD and What It Returns Every write returns an acknowledgement describing what happened. None of them return the document -- except the `findOneAnd*` family, which exists for exactly that. ```typescript const { insertedId } = await users.insertOne({ email, createdAt: new Date() }); const { matchedCount, modifiedCount } = await users.updateOne( { _id: id }, { $set: { email } }, ); // The one family that returns a document. In driver 6 it returns the document // itself; pass includeResultMetadata: true for the older wrapped shape. const updated = await users.findOneAndUpdate( { _id: id }, { $set: { email } }, { returnDocument: "after" }, ); ``` **`matchedCount` and `modifiedCount` differ, and the gap is meaningful:** matched-but-not-modified means the document was found and already held those values. Treating `modifiedCount === 0` as "not found" reports a spurious 404 for a no-op update. ```typescript // BAD: expecting the document back const user = await users.insertOne(doc); console.log(user.email); // undefined -- this is an acknowledgement, not a document ``` **Why bad:** the result is `{ acknowledged, insertedId }`, so every field read off it is `undefined` and the failure surfaces wherever that value is finally used, not here See [examples/core.md](examples/core.md#pattern-3-crud-and-result-shapes) for upserts, `bulkWrite`, and duplicate-key handling. --- ### Pattern 4: Cursors and Streaming Reads `find()` builds a cursor and sends nothing. The query runs when you iterate. `toArray()` buffers every matching document into memory, which is fine for a bounded page and a liability for anything else. ```typescript const DEFAULT_PAGE_SIZE = 50; // Bounded: buffering is fine const page = await users .find({ isActive: true }) .project<{ email: string }>({ email: 1, _id: 0 }) .limit(DEFAULT_PAGE_SIZE) .toArray(); // Unbounded: stream, so memory stays flat regardless of collection size for await (const user of users.find({ isActive: true })) { await sendDigest(user); } ``` ```typescript // BAD: buffering an unbounded result const everyone = await users.find({}).toArray(); // the whole collection, in memory ``` **Why bad:** memory grows with the collection rather than the page, so this passes in development against a small dataset and takes the process out in production Deep `skip()` degrades the same way for a different reason: the server walks and discards every skipped document. Paginate on an indexed sort key instead. See [examples/queries.md](examples/queries.md) for keyset pagination, batch sizing, and explicit cursor cleanup. --- ### Pattern 5: Aggregation Pipelines Stage order is the whole performance story. `$match` first can use an index; anywhere else it filters documents already loaded and streamed through earlier stages. ```typescript type RevenueByCustomer = { _id: ObjectId; total: number }; const results = await orders .aggregate<RevenueByCustomer>([ { $match: { status: "complete", createdAt: { $gte: since } } }, // first: uses an index { $project: { customerId: 1, total: 1 } }, // early: shrinks documents { $group: { _id: "$customerId", total: { $sum: "$total" } } }, { $sort: { total: -1 } }, { $limit: TOP_CUSTOMER_COUNT }, ]) .toArray(); ``` **Why good:** `$match` narrows using an index before anything else runs, `$project` cuts document size before the group, and the explicit generic types the output shape, which no longer resembles the input ```typescript // BAD: filtering after grouping { $group: { _id: "$customerId", total: { $sum: "$total" } } }, { $match: { status: "complete" } }, // every document was grouped first ``` **Why bad:** the group has already processed the whole collection, so the index is unusable and the filter now runs against grouped output where `status` no longer exists -- it silently matches nothing See [examples/aggregation.md](examples/aggregation.md) for `$lookup`, `$facet`, `$merge`, and the memory limits. --- ### Pattern 6: Indexes and Verification Create indexes in a migration or a startup routine you control, never in a request path. Compound key order follows **ESR**: equality fields first, then sort fields, then range fields. ```typescript // Query: find({ tenantId, status: { $gte: x } }).sort({ createdAt: -1 }) await orders.createIndex( { tenantId: 1, createdAt: -1, status: 1 }, // E, S, R { name: "tenant_created_status" }, ); ``` Then prove it. `createIndex` succeeding says nothing about whether your query uses it: ```typescript const plan = await orders .find(filter) .sort({ createdAt: -1 }) .explain("executionStats"); // Want IXSCAN, not COLLSCAN, and totalDocsExamined close to nReturned. ``` **Why this matters:** an unindexed query returns correct results at every size, so the defect is invisible until the collection is large enough for it to hurt, at which point it is a production incident rather than a test failure. See [examples/indexes.md](examples/indexes.md) for partial, TTL, text and geospatial indexes, and reading `explain` output. --- ### Pattern 7: Transactions Reach for one only when two or more documents must change together. Single-document writes are already atomic, so a transaction wrapped around one buys nothing and costs coordination. ```typescript const session = client.startSession(); try { await session.withTransaction(async () => { await accounts.updateOne( { _id: from }, { $inc: { balance: -amount } }, { session }, ); await accounts.updateOne( { _id: to }, { $inc: { balance: amount } }, { session }, ); }); } finally { await session.endSession(); } ``` **Why good:** `withTransaction` commits on success and aborts on throw, retries transient errors on your behalf, and the `finally` releases the session even when the transaction fails Two rules that are easy to miss: - **Every operation needs `{ session }`.** One that omits it runs outside the transaction, is not rolled back, and raises no error to say so. - **The callback can run more than once.** Retries re-run it, so any side effect that is not itself transactional -- an email, a queue publish, a counter in another store -- happens again on every retry. Transactions require a replica set or sharded cluster; a standalone server rejects them. See [examples/patterns.md](examples/patterns.md#pattern-1-transactions) for read/write concerns, retry semantics, and the single-document alternative. </patterns> --- <decision_framework> ## Decision Framework **How should this read run?** ``` How many documents can this return? ├─ One → findOne() ├─ A bounded page → find().limit(n).toArray() └─ Unbounded or unknown → for await (const doc of find(...)) └─ Stopping early? → cursor.close() when you break out ``` **Query or pipeline?** ``` Does the answer need reshaping, grouping, or data from another collection? ├─ NO → find() with a filter and a projection └─ YES → aggregate() ├─ Grouping/totals → $match first, then $group ├─ Joining a collection → $lookup, with the foreign field indexed ├─ Several answers at once→ $facet (one pass, not N queries) └─ Result reused often → $merge into a materialised collection ``` **Transaction or not?** ``` How many documents change? ├─ One → No transaction. Single-document writes are already atomic. │ Use $inc / $set / arrayFilters to do it in one update. └─ Many → Do they have to change together? ├─ NO → Separate writes. A transaction adds cost for nothing. └─ YES → withTransaction, { session } on every operation, callback safe to run twice, replica set required. ``` **Which compound index?** ``` Order the keys by how the query uses them (ESR): 1. Equality fields — matched exactly ({ tenantId: x }) 2. Sort fields — the sort key, in sort order 3. Range fields — $gt / $lt / $in Then run explain("executionStats") and confirm IXSCAN. Getting the order wrong still produces an index, and it still gets ignored. ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - **Constructing a `MongoClient` per request or per operation** -- every client opens its own pool, so concurrency multiplies into hundreds of sockets and the server starts refusing connections. One client per process, shared. - **Missing `{ session }` on an operation inside a transaction** -- that operation runs outside the transaction, commits independently, and is not rolled back when the transaction aborts. Nothing errors. - **Side effects inside a `withTransaction` callback** -- the driver retries the callback on transient errors, so emails send twice and queue messages publish twice. Only database work belongs inside it. - **`toArray()` on an unbounded query** -- memory scales with the collection, so it passes against development data and exhausts the process in production. - **Assuming a write returned a document** -- `insertOne` and `updateOne` return acknowledgements, so every field read off them is `undefined` and the failure appears somewhere else entirely. - **Trusting a query is indexed without `explain`** -- an unindexed query is correct at every size, so it is invisible until the collection is large enough to cause an outage. - **Interpolating user input into a filter object** -- an attacker-supplied object containing `$ne` or `$gt` becomes an operator rather than a value. Coerce inputs to their expected primitive type before they reach a filter. **Medium Priority Issues:** - Treating `modifiedCount === 0` as "not found" -- a no-op update matches without modifying, which is a successful update, not a missing document. - Deep `skip()` pagination -- the server walks and discards every skipped document, so page 500 costs 500 pages of work. Use a keyset on an indexed sort field. - Creating indexes in a request path rather than a migration -- builds contend with live traffic and repeat on every process start. - `$lookup` against an unindexed foreign field -- the lookup runs per input document, so this is a collection scan multiplied by the number of inputs. - Indexing a low-cardinality field on its own -- an index on a two-value field examines roughly half the collection and rarely beats a scan. - Omitting `writeConcern` on writes that must survive a failover -- the default acknowledges from the primary only. - Leaving a cursor unconsumed after breaking out of a loop -- server-side resources are held until it times out. **Common Mistakes:** - Forgetting `returnDocument: "after"` on `findOneAndUpdate` -- the default returns the pre-update document. - Passing a 24-character hex string where an `ObjectId` is required -- the filter matches nothing rather than erroring, because it is a valid string comparison against a non-string field. - Using `ObjectId.isValid()` as input validation -- it returns `true` for any 12-character string, so `"123456789012"` passes. Test against a 24-hex-character pattern. - Reusing one session across concurrent operations -- a session is single-threaded; parallel work needs separate sessions. - Not handling `code === 11000` from a unique index -- duplicate key is an expected outcome of a race, not an exceptional one. - Comparing `Date` values against ISO strings -- BSON dates and strings never compare equal. **Gotchas & Edge Cases:** - **The driver auto-connects on first operation**, so a missing `connect()` surfaces bad credentials on the first query instead of at startup. Call it explicitly to fail fast. - **Driver 5 removed callback support entirely** -- every operation returns a promise, and callback-style code from older examples throws. - **Driver 6 changed the `findOneAnd*` return shape** -- it returns the document directly; `includeResultMetadata: true` restores the older `{ value, ok, lastErrorObject }` wrapper. - **`find()` sends nothing until iterated**, so a query with a syntax error throws where it is awaited, not where it is built. - **A cursor's first batch is small** (101 documents) and later batches fill up to 16 MB, so the first `next()` is fast and a later one can pause noticeably. - **Documents are capped at 16 MB** -- an unbounded embedded array eventually makes a document unwritable, and the failure arrives long after the design decision that caused it. - **Aggregation stages have a 100 MB memory limit** -- a large `$group` or `$sort` fails unless `allowDiskUse: true` is set. - **`$sort` only uses an index at the start of a pipeline.** After a `$group` or `$project` it sorts in memory against that limit. - **TTL deletion is not immediate** -- the background task runs about once a minute, so expired documents remain readable briefly. The field must hold a BSON `Date`; a number or string is ignored silently. - **A text index is limited to one per collection**, so adding a second requires dropping the first. - **`createIndex` is idempotent for an identical key and options**, but the same key with different options raises `IndexOptionsConflict`. - **Transactions need a replica set** -- a standalone server rejects them, which is why a transaction can pass in staging and fail on a developer's single-node machine. - **Transactions have a server-side lifetime limit** (60 seconds by default) and abort when it is exceeded, so long-running work does not belong inside one. - **`writeConcern` is per-operation and per-transaction**, and the transaction's own concern governs the commit regardless of what the individual operations asked for. </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST create exactly ONE `MongoClient` per process and reuse it -- the client owns a connection pool, so constructing one per request opens a new pool per request and exhausts the server's connection limit)** **(You MUST pass `{ session }` to EVERY operation inside a transaction -- an operation without it silently runs outside the transaction and is not rolled back)** **(You MUST write `withTransaction` callbacks to be safely re-runnable -- the driver retries them on transient errors, so any side effect outside the transaction happens more than once)** **(You MUST iterate or close every cursor you open -- an abandoned cursor holds server-side resources until it times out)** **(You MUST NOT expect write operations to return documents -- `insertOne` returns `{ acknowledged, insertedId }` and `updateOne` returns counts; only the `findOneAnd*` family returns a document)** **(You MUST verify a query uses the index you intended with `explain("executionStats")` -- an unindexed query succeeds silently and only fails once the collection is large)** **Failure to follow these rules will exhaust the connection pool under load, lose writes that appeared to be transactional, and ship queries whose cost is invisible until the collection is too large to fix quietly.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.