api-database-mongoose
MongoDB ODM with schemas, validation, middleware, and TypeScript support
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-mongoose/skills/api-database-mongoose
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
Mongoose ODM Patterns
Quick Guide: Use Mongoose as the ODM layer for MongoDB. Let TypeScript infer types from schema definitions instead of duplicating interfaces. Register all middleware before calling
model()-- hooks added after compilation are silently ignored. Use.lean()for any read-only query. Pass{ session }to every operation inside a transaction or enabletransactionAsyncLocalStorage. Prefersession.withTransaction()over manual commit/abort. Use127.0.0.1instead oflocalhostin connection strings (Node.js 18+ IPv6 preference causes timeouts).
<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 define all middleware (pre/post hooks) BEFORE calling model() -- hooks registered after model compilation are silently ignored with no error)
(You MUST pass { session } to EVERY operation inside a transaction -- missing session causes that operation to run outside the transaction silently)
(You MUST use .lean() for read-only queries returning API responses -- skipping lean wastes 3x memory on hydration overhead)
(You MUST use 127.0.0.1 instead of localhost in connection strings -- Node.js 18+ prefers IPv6 and localhost causes connection timeouts)
(You MUST NOT use findOneAndUpdate/updateOne and expect pre('save') to fire -- only save() and create() trigger document middleware)
(You MUST NOT use next() callbacks in pre hooks on Mongoose 9 -- use async/await instead; next() was removed in v9)
</critical_requirements>
Auto-detection: Mongoose, mongoose, mongoose.connect, Schema, model, ObjectId, populate, HydratedDocument, InferSchemaType, InferRawDocType, pre('save'), post('save'), lean, mongoose.startSession, withTransaction, discriminator, virtual, Schema.Types.ObjectId, Types.ObjectId
When to use:
- Defining MongoDB schemas and models with Mongoose
- TypeScript integration with schema type inference
- Middleware hooks (pre/post save, validate, find, delete)
- Population (resolving references between collections)
- Transactions with session management
- Virtuals and instance/static methods
- Discriminators (single collection inheritance)
- Connection management (single and multi-database)
Key patterns covered:
- Schema definition with automatic TypeScript inference
- TypeScript typing (HydratedDocument, InferSchemaType, methods/statics/virtuals generics)
- CRUD operations (create, find, update, delete, lean vs hydrated)
- Middleware hooks and their execution rules
- Population with field selection and limits
- Transactions (withTransaction, transactionAsyncLocalStorage)
- Validation (built-in validators, custom validators, error messages)
- Virtuals (computed, populate virtuals)
- Discriminators (inheritance pattern)
- Connection setup and multi-database
When NOT to use:
- Raw MongoDB driver queries without schema enforcement (use the native driver)
- Relational data with complex joins and foreign key constraints (use a relational database)
- Simple key-value storage (use a dedicated key-value store)
Detailed Resources:
- For decision frameworks, quick reference tables, and migration notes, see reference.md
Core Patterns:
- examples/core.md -- Connection, schema definition, TypeScript typing, model creation, CRUD, validation
Middleware & Lifecycle:
- examples/middleware.md -- Pre/post hooks, error handling middleware, query middleware, soft delete
Relationships & Population:
- examples/population.md -- Populate, virtual populate, discriminators, embedding vs referencing
Transactions & Advanced:
- examples/transactions.md -- Sessions, withTransaction, transactionAsyncLocalStorage, connection management
<red_flags>
RED FLAGS
High Priority Issues:
- Registering middleware after
model()call -- hooks are silently ignored, no error thrown - Running operations in parallel inside a transaction (
Promise.all()) -- MongoDB does not support parallel operations within a single transaction session - Missing
{ session }on any operation inside a transaction -- that operation runs outside the transaction silently - Using
localhostin connection strings on Node.js 18+ -- IPv6 preference causes connection timeouts, use127.0.0.1 - Mutating a document fetched with
.lean()and calling.save()-- lean returns plain objects without Mongoose methods
Medium Priority Issues:
- Using
findOneAndUpdate/updateOneand expectingpre('save')to fire -- onlysave()andcreate()trigger document middleware - Unbounded
.populate()withoutlimitor field selection -- can return thousands of documents per populate call, each is a separate DB round-trip - Not passing
runValidators: trueonfindOneAndUpdate-- schema validation is skipped by default on direct updates - Using
Schema.Types.ObjectIdin TypeScript interfaces -- useTypes.ObjectIdfor interfaces,Schema.Types.ObjectIdfor schema definitions only - Creating indexes in production application code instead of migration scripts -- index builds can lock the collection
Common Mistakes:
- Forgetting
{ new: true }onfindOneAndUpdate-- returns the old document by default, not the updated one - Using
next()callbacks in pre hooks on Mongoose 9 --next()was removed in v9, use async/await - Not handling duplicate key errors (error code 11000) from unique indexes
- Using
.lean()on write operations -- lean is for reads only - Checking
doc.isNewinpost('save')hooks -- alwaysfalseafter save; capture inpre('save')viathis.$locals.wasNew - Defining the same middleware hook multiple times without realizing they stack (all run, not just the last one)
- Using
extends Documenton interfaces -- deprecated pattern that breaks type inference for lean documents and query filters
Gotchas & Edge Cases:
- MongoDB has a 16 MB document size limit -- deeply embedded arrays can silently hit this
- Mongoose buffers all operations until connected -- queries queue silently if connection fails, which can mask connection issues in development
deleteOne/deleteManyon the Model do not trigger documentpre('deleteOne')middleware -- they trigger query middleware instead; usedoc.deleteOne()for document middleware- Virtual properties are excluded from
toJSON()/toObject()by default -- set{ toJSON: { virtuals: true } }in schema options or they disappear in API responses insertMany()does not triggersavemiddleware -- it triggersinsertManymodel middleware only- Mongoose 9 renamed
FilterQuerytoQueryFilter-- update TypeScript imports if upgrading - Mongoose 9 disallows pipeline-style updates by default -- pass
{ updatePipeline: true }or they throw create()with an array requires array syntax for{ session }:Model.create([data], { session })-- the non-array formModel.create(data, { session })does not work in transactions
</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 define all middleware (pre/post hooks) BEFORE calling model() -- hooks registered after model compilation are silently ignored with no error)
(You MUST pass { session } to EVERY operation inside a transaction -- missing session causes that operation to run outside the transaction silently)
(You MUST use .lean() for read-only queries returning API responses -- skipping lean wastes 3x memory on hydration overhead)
(You MUST use 127.0.0.1 instead of localhost in connection strings -- Node.js 18+ prefers IPv6 and localhost causes connection timeouts)
(You MUST NOT use findOneAndUpdate/updateOne and expect pre('save') to fire -- only save() and create() trigger document middleware)
(You MUST NOT use next() callbacks in pre hooks on Mongoose 9 -- use async/await instead; next() was removed in v9)
Failure to follow these rules will cause silent middleware bypass, transaction isolation failures, or connection timeouts.
</critical_reminders>
Files (skills)
-
examples
-
core.md 16.6 KB
# Mongoose - Core Examples > Connection, schema definition, TypeScript typing, model creation, CRUD, and validation. See [SKILL.md](../SKILL.md) for core concepts. **Middleware & lifecycle:** See [middleware.md](middleware.md). **Population & relationships:** See [population.md](population.md). **Transactions & advanced:** See [transactions.md](transactions.md). --- ## Pattern 1: Connection Setup ### Good Example -- Production Connection ```typescript import mongoose from "mongoose"; const POOL_SIZE_MAX = 10; const POOL_SIZE_MIN = 2; const SERVER_SELECTION_TIMEOUT_MS = 5000; const SOCKET_TIMEOUT_MS = 45000; async function connectDatabase(): Promise<typeof mongoose> { const uri = process.env.MONGODB_URI; if (!uri) { throw new Error("MONGODB_URI environment variable is required"); } try { const connection = await mongoose.connect(uri, { maxPoolSize: POOL_SIZE_MAX, minPoolSize: POOL_SIZE_MIN, serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, socketTimeoutMS: SOCKET_TIMEOUT_MS, retryWrites: true, retryReads: true, }); console.log(`Connected to MongoDB: ${mongoose.connection.name}`); return connection; } catch (error) { console.error("Failed to connect to MongoDB:", error); throw error; } } export { connectDatabase }; ``` **Why good:** Environment variable for URI, named constants for all numeric values, try/catch for initial connection, typed return ### Good Example -- Connection Events and Graceful Shutdown ```typescript function setupConnectionEvents(): void { mongoose.connection.on("connected", () => { console.log("MongoDB connected"); }); mongoose.connection.on("error", (err) => { console.error("MongoDB connection error:", err); }); mongoose.connection.on("disconnected", () => { console.warn("MongoDB disconnected"); }); } async function disconnectDatabase(): Promise<void> { await mongoose.connection.close(); console.log("MongoDB connection closed"); } process.on("SIGINT", async () => { await disconnectDatabase(); process.exit(0); }); export { setupConnectionEvents, disconnectDatabase }; ``` **Why good:** Handles all critical lifecycle events, different log levels for severity, graceful shutdown on SIGINT ### Good Example -- Multiple Connections (Multi-Database) ```typescript import mongoose from "mongoose"; // createConnection() returns a separate Connection object // Each connection has its own pool, models, and middleware const primaryDb = await mongoose .createConnection(process.env.PRIMARY_MONGODB_URI!) .asPromise(); const analyticsDb = await mongoose .createConnection(process.env.ANALYTICS_MONGODB_URI!) .asPromise(); // Models are bound to their specific connection const User = primaryDb.model("User", userSchema); const AnalyticsEvent = analyticsDb.model("AnalyticsEvent", eventSchema); export { User, AnalyticsEvent }; ``` **Why good:** Separate pools for different workloads, `.asPromise()` for await support, models explicitly bound to connections ### Bad Example -- Hardcoded Connection ```typescript // BAD: Everything wrong mongoose.connect("mongodb://admin:password123@localhost:27017/mydb"); ``` **Why bad:** Hardcoded credentials in source code, `localhost` fails on Node.js 18+ (IPv6 preference), no pool configuration, no error handling --- ## Pattern 2: Schema Definition with Validation ### Good Example -- Complete Schema ```typescript import { Schema, model } from "mongoose"; const MIN_NAME_LENGTH = 2; const MAX_NAME_LENGTH = 100; const MIN_PRICE = 0; const SKU_PATTERN = /^[A-Z]{2}-\d{6}$/; const productSchema = new Schema( { name: { type: String, required: [true, "Product name is required"], minlength: [ MIN_NAME_LENGTH, `Name must be at least ${MIN_NAME_LENGTH} characters`, ], maxlength: [ MAX_NAME_LENGTH, `Name must be at most ${MAX_NAME_LENGTH} characters`, ], trim: true, index: true, }, sku: { type: String, required: true, unique: true, uppercase: true, match: [SKU_PATTERN, "SKU must match format XX-000000"], }, price: { type: Number, required: true, min: [MIN_PRICE, "Price cannot be negative"], validate: { validator: (v: number) => Number.isFinite(v), message: "Price must be a finite number", }, }, category: { type: String, required: true, enum: { values: ["electronics", "clothing", "food", "books"] as const, message: "{VALUE} is not a valid category", }, }, tags: { type: [String], default: [] }, specifications: { type: Map, of: Schema.Types.Mixed }, isActive: { type: Boolean, default: true }, }, { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true }, }, ); const Product = model("Product", productSchema); export { Product, productSchema }; ``` **Why good:** Named constants for validation limits, custom error messages on every validator, `as const` preserves enum literal types, trim/uppercase transforms, Map for flexible key-value data, schema options for timestamps and virtual serialization ### Good Example -- Subdocument Schema ```typescript import { Schema, model } from "mongoose"; const addressSchema = new Schema( { street: { type: String, required: true }, city: { type: String, required: true }, state: { type: String, required: true }, zipCode: { type: String, required: true, match: /^\d{5}(-\d{4})?$/ }, country: { type: String, default: "US" }, }, { _id: false }, // No separate _id for embedded subdocuments ); const customerSchema = new Schema( { name: { type: String, required: true }, email: { type: String, required: true, unique: true, lowercase: true }, shippingAddress: { type: addressSchema, required: true }, billingAddress: { type: addressSchema }, addresses: { type: [addressSchema], validate: { validator: (v: unknown[]) => v.length <= 10, message: "Maximum 10 addresses allowed", }, }, }, { timestamps: true }, ); const Customer = model("Customer", customerSchema); export { Customer, customerSchema, addressSchema }; ``` **Why good:** Reusable subdocument schema, `{ _id: false }` avoids unnecessary ObjectIds on embedded documents, array-level validation to bound the array size, schema reused for both shipping and billing ### Bad Example -- No Validation ```typescript // BAD: No validation, no types, no constraints const userSchema = new Schema({ name: String, email: String, age: Number, role: String, }); ``` **Why bad:** No `required` constraints (all fields optional), no validation rules, no enum for role, no custom error messages, no trim/lowercase transforms --- ## Pattern 3: TypeScript -- Automatic Inference ### Good Example -- Simple Model (Preferred) ```typescript import { Schema, model, type InferSchemaType } from "mongoose"; const blogPostSchema = new Schema( { title: { type: String, required: true }, slug: { type: String, required: true, unique: true }, content: { type: String, required: true }, authorId: { type: Schema.Types.ObjectId, ref: "User", required: true }, tags: [{ type: String }], isPublished: { type: Boolean, default: false }, viewCount: { type: Number, default: 0 }, }, { timestamps: true }, ); // TypeScript automatically infers the document type const BlogPost = model("BlogPost", blogPostSchema); // Extract the inferred type for use in other files type BlogPostDoc = InferSchemaType<typeof blogPostSchema>; export { BlogPost, blogPostSchema }; export type { BlogPostDoc }; ``` **Why good:** No manual interface duplication, TypeScript infers types from schema, `InferSchemaType` exports the shape for consumers, named exports ### Good Example -- Full Typing with Methods, Statics, and Virtuals ```typescript import { Schema, model, type HydratedDocument, type Model, type Types, } from "mongoose"; const SALT_ROUNDS = 12; // 1. Raw document interface (what's stored in MongoDB) interface IUser { email: string; passwordHash: string; firstName: string; lastName: string; role: "admin" | "user" | "moderator"; lastLoginAt?: Date; } // 2. Instance methods interface interface IUserMethods { comparePassword(candidate: string): Promise<boolean>; updateLastLogin(): Promise<void>; } // 3. Virtuals interface interface IUserVirtuals { fullName: string; } // 4. Static methods interface interface IUserStatics { findByEmail(email: string): Promise<UserDocument | null>; } // 5. Composed model type = Model + Statics type UserModel = Model<IUser, {}, IUserMethods, IUserVirtuals> & IUserStatics; // 6. Hydrated document type for external consumers type UserDocument = HydratedDocument<IUser, IUserMethods & IUserVirtuals>; // 7. Schema with all generic parameters const userSchema = new Schema< IUser, UserModel, IUserMethods, {}, IUserVirtuals >( { email: { type: String, required: true, unique: true, lowercase: true }, passwordHash: { type: String, required: true }, firstName: { type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true }, role: { type: String, enum: ["admin", "user", "moderator"], default: "user", }, lastLoginAt: { type: Date }, }, { timestamps: true, toJSON: { virtuals: true, transform(_doc, ret) { delete ret.passwordHash; // Never expose password hash return ret; }, }, }, ); // Instance methods userSchema.methods.comparePassword = async function ( candidate: string, ): Promise<boolean> { // Use bcrypt.compare(candidate, this.passwordHash) in production return candidate === this.passwordHash; }; userSchema.methods.updateLastLogin = async function (): Promise<void> { this.lastLoginAt = new Date(); await this.save(); }; // Virtuals userSchema.virtual("fullName").get(function () { return `${this.firstName} ${this.lastName}`; }); // Static methods userSchema.statics.findByEmail = function (email: string) { return this.findOne({ email: email.toLowerCase() }); }; // Middleware -- MUST be defined BEFORE model() userSchema.pre("save", async function () { if (this.isModified("passwordHash")) { // Hash password here (e.g., bcrypt.hash(this.passwordHash, SALT_ROUNDS)) } }); // model() -- AFTER all middleware, methods, virtuals, and statics const User = model<IUser, UserModel>("User", userSchema); export { User, userSchema }; export type { IUser, IUserMethods, UserDocument }; ``` **Why good:** Separate interfaces for document/methods/virtuals/statics, correct generic parameter order on Schema and model, `HydratedDocument` type exported for consumers, password excluded from JSON, middleware registered before `model()`, named constants ### Bad Example -- Type Duplication ```typescript // BAD: Interface duplicates schema -- they drift apart interface IProduct { name: string; // says required price: number; // says required } const productSchema = new Schema({ name: String, // actually optional -- no 'required' price: Number, // no validation }); // Interface says name is always string, but schema allows undefined const Product = model<IProduct>("Product", productSchema); ``` **Why bad:** Interface and schema define different constraints, they will drift out of sync, TypeScript thinks `name` is always defined but MongoDB allows undefined --- ## Pattern 4: ObjectId References ### Good Example -- Typed References ```typescript import { Schema, model } from "mongoose"; const commentSchema = new Schema( { postId: { type: Schema.Types.ObjectId, // Schema.Types.ObjectId in schema definition ref: "BlogPost", required: true, index: true, }, authorId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true, }, parentId: { type: Schema.Types.ObjectId, ref: "Comment", default: null, }, // Self-reference for threads content: { type: String, required: true, maxlength: 5000 }, likes: { type: Number, default: 0 }, }, { timestamps: true }, ); // Compound index for common query pattern commentSchema.index({ postId: 1, createdAt: -1 }); const Comment = model("Comment", commentSchema); export { Comment, commentSchema }; ``` **Why good:** `Schema.Types.ObjectId` in schema (not `Types.ObjectId`), `ref` for populate support, indexes on foreign keys, compound index for common query ### Bad Example -- Wrong ObjectId Type ```typescript import { Types } from "mongoose"; // BAD: Using Types.ObjectId in schema definition const schema = new Schema({ userId: { type: Types.ObjectId, ref: "User" }, // WRONG type for schema }); ``` **Why bad:** `Types.ObjectId` is for TypeScript interfaces and runtime values. `Schema.Types.ObjectId` is the correct type for schema definitions. --- ## Pattern 5: CRUD Operations ### Good Example -- Create ```typescript // Single document -- triggers pre('save') middleware const user = await User.create({ name: "Alice", email: "alice@example.com", role: "admin", }); // Bulk insert -- triggers insertMany middleware, NOT save middleware const BATCH_SIZE = 1000; const users = generateUsers(BATCH_SIZE); await User.insertMany(users, { ordered: false }); // ordered: false continues inserting after errors (skips duplicates) ``` ### Good Example -- Read with Lean ```typescript const PAGE_SIZE = 20; // Read-only response -- use lean() const user = await User.findById(id).lean(); // Paginated list const activeAdmins = await User.find({ role: "admin", isActive: true }) .select("name email role") .sort({ name: 1 }) .limit(PAGE_SIZE) .lean(); ``` **Why good:** `.lean()` for read-only responses (3x memory savings), `.select()` for projection, named constant for page size ### Good Example -- Update ```typescript // Update with save() -- triggers pre('save') middleware const user = await User.findById(id); if (!user) { throw new Error(`User not found: ${id}`); } user.name = "Updated Name"; await user.save(); // Direct update -- does NOT trigger save middleware await User.findByIdAndUpdate( id, { $set: { name: "Updated" } }, { new: true, runValidators: true }, ); // Bulk update await User.updateMany( { isActive: false }, { $set: { archivedAt: new Date() } }, ); ``` **Why good:** `save()` when middleware matters, `{ new: true }` returns updated document, `{ runValidators: true }` enforces schema validation on direct updates ### Good Example -- Delete ```typescript await User.findByIdAndDelete(id); const DAYS_TO_KEEP = 30; const cutoffDate = new Date(Date.now() - DAYS_TO_KEEP * 24 * 60 * 60 * 1000); await User.deleteMany({ isActive: false, archivedAt: { $lt: cutoffDate } }); ``` ### Bad Example -- Lean Then Save ```typescript // BAD: lean() returns plain objects -- no Mongoose methods const user = await User.findById(id).lean(); user.name = "Updated"; await user.save(); // TypeError: user.save is not a function ``` **Why bad:** `.lean()` returns plain JavaScript objects without Mongoose methods. Cannot call `.save()`, `.populate()`, or any instance method. ### Bad Example -- Missing runValidators ```typescript // BAD: Schema validation skipped on direct updates by default await User.findByIdAndUpdate(id, { $set: { email: "not-an-email" }, // No validation! Saves invalid data }); ``` **Why bad:** `findByIdAndUpdate` skips schema validation by default. Always pass `{ runValidators: true }` to enforce validation on direct updates. --- ## Pattern 6: Schema Options ### Good Example -- Comprehensive Options ```typescript const auditLogSchema = new Schema( { action: { type: String, required: true }, userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, resource: { type: String, required: true }, details: { type: Schema.Types.Mixed }, }, { timestamps: true, // Include virtuals in JSON and object output toJSON: { virtuals: true, versionKey: false }, toObject: { virtuals: true }, // Optimistic concurrency control (uses __v) optimisticConcurrency: true, // Explicit collection name (default: lowercase plural of model name) collection: "audit_logs", // Disable automatic index creation in production autoIndex: process.env.NODE_ENV !== "production", }, ); export { auditLogSchema }; ``` **Why good:** Virtuals in serialization, version key hidden from JSON, optimistic concurrency for safe concurrent updates, explicit collection name, autoIndex disabled in production (create indexes via migration scripts instead) --- _For middleware patterns, see [middleware.md](middleware.md). For population, see [population.md](population.md). For transactions, see [transactions.md](transactions.md)._ -
middleware.md 7.9 KB
# Mongoose - Middleware & Lifecycle Examples > Pre/post hooks, error handling middleware, query middleware, soft delete, and middleware ordering. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand schema definition and model creation from [core.md](core.md). **Population:** See [population.md](population.md). **Transactions:** See [transactions.md](transactions.md). --- ## Pattern 1: Document Middleware (Pre/Post Save) ### Good Example -- Pre-Save with Conditional Logic ```typescript import { Schema, model } from "mongoose"; const SALT_ROUNDS = 12; const userSchema = new Schema({ email: { type: String, required: true, unique: true }, password: { type: String, required: true }, name: { type: String, required: true }, slug: { type: String, unique: true }, }); // Hash password only when modified -- Mongoose 9: async, no next() userSchema.pre("save", async function () { if (this.isModified("password")) { // this.password = await bcrypt.hash(this.password, SALT_ROUNDS); } }); // Generate slug from name when name changes userSchema.pre("save", function () { if (this.isModified("name")) { this.slug = this.name.toLowerCase().replace(/\s+/g, "-"); } }); // MUST register all middleware BEFORE model() const User = model("User", userSchema); export { User }; ``` **Why good:** `isModified()` check prevents re-hashing on every save, Mongoose 9 async pattern (no `next()` callback), middleware defined before `model()` call ### Good Example -- Capturing isNew in Post-Save ```typescript // isNew is ALWAYS false in post('save') hooks // Mongoose sets it to false after successful save // Capture it in pre('save') via $locals userSchema.pre("save", function () { this.$locals.wasNew = this.isNew; }); userSchema.post("save", function (doc) { if (doc.$locals.wasNew) { // Send welcome email only for new users console.log(`New user created: ${doc.email}`); } }); ``` **Why good:** `$locals` persists data between pre and post hooks on the same operation, solves the `isNew` gotcha where it's always false in post-save ### Bad Example -- Middleware After model() ```typescript const User = model("User", userSchema); // BAD: Registered AFTER model() -- SILENTLY IGNORED userSchema.pre("save", function () { this.updatedAt = new Date(); }); // No error thrown -- this hook will never execute ``` **Why bad:** Middleware registered after `model()` compilation is silently ignored. No error, no warning -- data integrity silently broken. ### Bad Example -- Using next() on Mongoose 9 ```typescript // BAD: next() removed in Mongoose 9 userSchema.pre("save", function (next) { if (this.isModified("password")) { // ... hash password } next(); // TypeError in Mongoose 9 }); ``` **Why bad:** Mongoose 9 removed `next()` callback from pre hooks. Use async/await or return a Promise instead. --- ## Pattern 2: Error Handling Middleware ### Good Example -- Transform Duplicate Key Errors ```typescript const DUPLICATE_KEY_ERROR = 11000; userSchema.post("save", function (error: any, _doc: any, next: Function) { if (error.name === "MongoServerError" && error.code === DUPLICATE_KEY_ERROR) { next(new Error("A user with this email already exists")); } else { next(error); } }); ``` **Why good:** Named constant for error code, transforms cryptic MongoDB duplicate key error into user-friendly message, passes through other errors unchanged ### Good Example -- Validation Error Formatting ```typescript userSchema.post("validate", function (error: any, _doc: any, next: Function) { if (error.name === "ValidationError") { const messages = Object.values(error.errors).map((err: any) => err.message); next(new Error(`Validation failed: ${messages.join(", ")}`)); } else { next(error); } }); ``` **Why good:** Collects all validation error messages into a single readable string, preserves original error for non-validation errors --- ## Pattern 3: Query Middleware ### Good Example -- Soft Delete Filter ```typescript // Automatically exclude soft-deleted documents from all find queries userSchema.pre("find", function () { this.where({ deletedAt: { $exists: false } }); }); userSchema.pre("findOne", function () { this.where({ deletedAt: { $exists: false } }); }); userSchema.pre("countDocuments", function () { this.where({ deletedAt: { $exists: false } }); }); ``` **Why good:** Consistent soft-delete filtering across all read operations, no application code needed to remember the filter ### Good Example -- Query Logging ```typescript const SLOW_QUERY_THRESHOLD_MS = 100; userSchema.pre("find", function () { this.set("startTime", Date.now()); }); userSchema.post("find", function (docs) { const startTime = this.get("startTime") as number; const duration = Date.now() - startTime; if (duration > SLOW_QUERY_THRESHOLD_MS) { console.warn(`Slow query (${duration}ms):`, this.getFilter()); } }); ``` **Why good:** Named constant for threshold, measures actual query execution time, logs only slow queries with their filters --- ## Pattern 4: Complete Soft Delete Plugin ### Good Example -- Reusable Soft Delete ```typescript import { Schema, type Query } from "mongoose"; interface ISoftDeletable { deletedAt?: Date; deletedBy?: string; } function applySoftDelete<T>(schema: Schema<T & ISoftDeletable>): void { // Add soft-delete fields to the schema schema.add({ deletedAt: { type: Date, default: null }, deletedBy: { type: String, default: null }, }); // Filter deleted documents from all queries by default schema.pre("find", function () { if (!this.getOptions().includeDeleted) { this.where({ deletedAt: null }); } }); schema.pre("findOne", function () { if (!this.getOptions().includeDeleted) { this.where({ deletedAt: null }); } }); schema.pre("countDocuments", function () { if (!this.getOptions().includeDeleted) { this.where({ deletedAt: null }); } }); // Add softDelete instance method schema.methods.softDelete = async function (deletedBy?: string) { this.deletedAt = new Date(); this.deletedBy = deletedBy ?? null; return this.save(); }; // Add restore instance method schema.methods.restore = async function () { this.deletedAt = null; this.deletedBy = null; return this.save(); }; } export { applySoftDelete }; export type { ISoftDeletable }; // Usage: // const postSchema = new Schema({ title: String, content: String }); // applySoftDelete(postSchema); // const Post = model("Post", postSchema); // // Normal queries auto-exclude deleted: // await Post.find(); // Include deleted with escape hatch: // await Post.find().setOptions({ includeDeleted: true }); ``` **Why good:** Reusable plugin function, automatic filtering on all query types, escape hatch with `includeDeleted` option, `softDelete` and `restore` methods on documents, uses `save()` so middleware fires on soft-delete --- ## Pattern 5: Middleware Ordering and Stacking ### Important: Middleware Stacks, Not Replaces ```typescript // Multiple pre('save') hooks ALL run -- they don't replace each other userSchema.pre("save", function () { console.log("Hook 1 runs"); }); userSchema.pre("save", function () { console.log("Hook 2 also runs"); }); // Both hooks execute in registration order ``` ### Execution Order for save() ``` 1. pre('validate') -- schema validation hasn't run yet 2. post('validate') -- schema validation passed 3. pre('save') -- document about to be written 4. post('save') -- document successfully written ``` **Key insight:** `save()` automatically triggers `validate()` first. All `pre('validate')` and `post('validate')` hooks run before any `pre('save')` hook. --- ## Middleware Type Reference See [reference.md](../reference.md#middleware-execution-matrix) for the complete middleware execution matrix showing which operations trigger which hooks. --- _For core patterns, see [core.md](core.md). For population, see [population.md](population.md). For transactions, see [transactions.md](transactions.md)._ -
population.md 10.6 KB
# Mongoose - Population & Relationships Examples > Populate, virtual populate, discriminators, and embedding vs referencing. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand schema definition and ObjectId references from [core.md](core.md). **Middleware:** See [middleware.md](middleware.md). **Transactions:** See [transactions.md](transactions.md). --- ## Pattern 1: Populate with Field Selection ### Good Example -- Selective Population ```typescript const MAX_COMMENTS = 50; const post = await BlogPost.findById(id) .populate("authorId", "name email avatar") // Only these fields from author .populate({ path: "comments", select: "content authorId createdAt", options: { sort: { createdAt: -1 }, limit: MAX_COMMENTS, }, populate: { path: "authorId", select: "name avatar", // Nested populate for comment authors }, }) .lean(); ``` **Why good:** Field selection on every populate call reduces data transfer, limit on nested array prevents unbounded results, nested populate for deep references, sorted by recency ### Good Example -- Conditional Population ```typescript interface PopulateConfig { includeAuthor?: boolean; includeComments?: boolean; } async function getPost(id: string, config: PopulateConfig = {}) { let query = BlogPost.findById(id); if (config.includeAuthor) { query = query.populate("authorId", "name email"); } if (config.includeComments) { query = query.populate({ path: "comments", select: "content createdAt", options: { limit: 20, sort: { createdAt: -1 } }, }); } return query.lean(); } export { getPost }; ``` **Why good:** Populate only when needed (each populate is a separate DB query), chainable query builder, avoids unnecessary round-trips ### Bad Example -- Unbounded Populate ```typescript // BAD: Populating all fields, no limits const post = await BlogPost.findById(id) .populate("authorId") // ALL author fields .populate("comments") // ALL comments -- could be thousands .populate("relatedPosts"); // ALL related posts // Each populate is a separate DB query with no bounds ``` **Why bad:** No field selection wastes bandwidth and memory, no limit on comments could return thousands of documents, each populate adds a database round-trip --- ## Pattern 2: Virtual Populate (Reverse References) ### Good Example -- Populate Virtual ```typescript import { Schema, model } from "mongoose"; const authorSchema = new Schema( { name: { type: String, required: true }, email: { type: String, required: true }, }, { toJSON: { virtuals: true }, toObject: { virtuals: true }, }, ); // Virtual populate -- look up posts where Post.authorId matches Author._id // No array stored on the author document authorSchema.virtual("posts", { ref: "Post", localField: "_id", foreignField: "authorId", options: { sort: { createdAt: -1 } }, }); // Virtual with count only (more efficient when you just need the count) authorSchema.virtual("postCount", { ref: "Post", localField: "_id", foreignField: "authorId", count: true, // Only returns the count, not the documents }); const Author = model("Author", authorSchema); // Usage const author = await Author.findById(id) .populate("posts") .populate("postCount"); // author.posts = Post[] (array of post documents) // author.postCount = number export { Author }; ``` **Why good:** Virtual populate for reverse relationships without storing an array on the parent, `count: true` for efficient counts, sort option for consistent ordering, schema options to include virtuals in serialization --- ## Pattern 3: Virtuals (Computed Properties) ### Good Example -- Getter and Setter Virtuals ```typescript import { Schema, model } from "mongoose"; const userSchema = new Schema( { firstName: { type: String, required: true }, lastName: { type: String, required: true }, email: { type: String, required: true }, }, { toJSON: { virtuals: true }, toObject: { virtuals: true }, }, ); // Read-only virtual userSchema.virtual("fullName").get(function () { return `${this.firstName} ${this.lastName}`; }); // Virtual with getter AND setter userSchema .virtual("displayName") .get(function () { return `${this.firstName} ${this.lastName}`; }) .set(function (fullName: string) { const parts = fullName.split(" "); this.firstName = parts[0] ?? ""; this.lastName = parts.slice(1).join(" "); }); // Computed virtual from another field userSchema.virtual("emailDomain").get(function () { return this.email.slice(this.email.indexOf("@") + 1); }); const User = model("User", userSchema); export { User }; ``` **Why good:** Schema options enable virtuals in JSON/object serialization (otherwise they're invisible in API responses), getter + setter for two-way virtual, multiple computed virtuals ### Important Gotcha ```typescript // BAD: Forgetting toJSON option -- virtuals disappear in API responses const schema = new Schema({ firstName: String, lastName: String }); schema.virtual("fullName").get(function () { return `${this.firstName} ${this.lastName}`; }); const doc = await Model.findById(id); JSON.stringify(doc); // { firstName: "John", lastName: "Doe" } -- no fullName! ``` **Why bad:** Virtual properties are excluded from `toJSON()`/`toObject()` by default. Must set `{ toJSON: { virtuals: true } }` in schema options. --- ## Pattern 4: Discriminators (Single Collection Inheritance) ### Good Example -- Event System with Discriminators ```typescript import { Schema, model } from "mongoose"; // Base event schema const eventSchema = new Schema( { userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, timestamp: { type: Date, default: Date.now, index: true }, metadata: { type: Schema.Types.Mixed }, }, { discriminatorKey: "eventType", timestamps: true, }, ); const Event = model("Event", eventSchema); // Discriminator for page views -- adds type-specific fields const PageViewEvent = Event.discriminator( "PageView", new Schema({ url: { type: String, required: true }, referrer: { type: String }, duration: { type: Number }, // seconds }), ); // Discriminator for purchases const PurchaseEvent = Event.discriminator( "Purchase", new Schema({ orderId: { type: Schema.Types.ObjectId, ref: "Order", required: true }, amount: { type: Number, required: true }, currency: { type: String, default: "USD" }, }), ); // Query all events (returns mixed types) const allEvents = await Event.find({ userId: someUserId }); // Query only purchases (returns only PurchaseEvent documents) const MIN_PURCHASE_AMOUNT = 100; const purchases = await PurchaseEvent.find({ userId: someUserId, amount: { $gte: MIN_PURCHASE_AMOUNT }, }); export { Event, PageViewEvent, PurchaseEvent }; ``` **Why good:** Single collection for all events (efficient indexing on shared fields), `discriminatorKey` specifies which field stores the type, each subtype has its own schema and validation, can query base type for all events or specific type for filtered results --- ## Pattern 5: Embedding vs Referencing ### Good Example -- Embedding (Co-Accessed, Bounded Data) ```typescript import { Schema, model } from "mongoose"; // Addresses are always accessed with the user and bounded (few per user) const userSchema = new Schema( { name: { type: String, required: true }, email: { type: String, required: true, unique: true }, addresses: [ { label: { type: String, required: true }, // "home", "work" street: { type: String, required: true }, city: { type: String, required: true }, state: { type: String, required: true }, zipCode: { type: String, required: true }, isDefault: { type: Boolean, default: false }, }, ], preferences: { theme: { type: String, enum: ["light", "dark"] as const, default: "light", }, language: { type: String, default: "en" }, }, }, { timestamps: true }, ); const User = model("User", userSchema); export { User }; ``` **Why good:** Addresses always accessed with user, bounded (few per user), never shared between users, single read fetches everything ### Good Example -- Referencing (Independent, Unbounded Data) ```typescript // Posts are independent, unbounded, and accessed separately from the user const postSchema = new Schema( { authorId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true, // Index on foreign key for efficient lookups }, title: { type: String, required: true }, content: { type: String, required: true }, tags: [{ type: String }], }, { timestamps: true }, ); const Post = model("Post", postSchema); export { Post }; ``` **Why good:** Posts grow without limit, accessed independently, can be queried/paginated without loading the user, index on `authorId` for efficient lookups ### Good Example -- Denormalization (Hybrid Pattern) ```typescript // Store snapshot of frequently-accessed data in the parent const orderSchema = new Schema( { customerId: { type: Schema.Types.ObjectId, ref: "Customer", required: true, }, // Denormalized snapshot avoids populate on every read customerSnapshot: { name: { type: String, required: true }, email: { type: String, required: true }, }, items: [ { productId: { type: Schema.Types.ObjectId, ref: "Product", required: true, }, // Price at time of order (immutable -- not current price) name: { type: String, required: true }, unitPrice: { type: Number, required: true }, quantity: { type: Number, required: true, min: 1 }, }, ], total: { type: Number, required: true }, }, { timestamps: true }, ); export { orderSchema }; ``` **Why good:** Customer snapshot avoids populate on every order read, product price captured at order time (won't change if product price changes later), full customer data available via `customerId` when needed ### Bad Example -- Embedding Unbounded Data ```typescript // BAD: Embedding an unbounded array const userSchema = new Schema({ name: String, posts: [ { title: String, content: String, // Large text per post comments: [{ content: String, authorId: Schema.Types.ObjectId }], }, ], }); ``` **Why bad:** Posts grow unbounded and hit 16 MB document size limit, deeply nested comments make querying impossible, updating one post requires reading/writing the entire user document --- _For core patterns, see [core.md](core.md). For middleware, see [middleware.md](middleware.md). For transactions, see [transactions.md](transactions.md)._ -
transactions.md 11.6 KB
# Mongoose - Transactions & Advanced Examples > Sessions, withTransaction, transactionAsyncLocalStorage, connection management, cursor pagination, and indexes. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand schema definition and CRUD from [core.md](core.md). **Middleware:** See [middleware.md](middleware.md). **Population:** See [population.md](population.md). --- ## Pattern 1: Transactions with withTransaction() ### Good Example -- Transfer with Session ```typescript import mongoose from "mongoose"; async function transferFunds( fromAccountId: string, toAccountId: string, amount: number, ): Promise<void> { const session = await mongoose.startSession(); try { // withTransaction handles commit, abort, and retry automatically await session.withTransaction(async () => { // Debit source account (atomic check + update) const source = await Account.findOneAndUpdate( { _id: fromAccountId, balance: { $gte: amount } }, { $inc: { balance: -amount } }, { new: true, session }, // { session } on EVERY operation ); if (!source) { throw new Error("Insufficient funds or account not found"); } // Credit destination account const destination = await Account.findOneAndUpdate( { _id: toAccountId }, { $inc: { balance: amount } }, { new: true, session }, ); if (!destination) { throw new Error("Destination account not found"); } // Record the transfer -- array syntax for create() with session await Transfer.create( [{ fromAccountId, toAccountId, amount, status: "completed" }], { session }, ); }); } finally { await session.endSession(); // Always clean up } } export { transferFunds }; ``` **Why good:** `withTransaction()` handles commit/abort/retry automatically, `{ session }` passed to every operation, balance check with `$gte` prevents overdraft atomically, `finally` ensures session cleanup, `create()` uses array syntax for session support ### Bad Example -- Missing Session on Some Operations ```typescript // BAD: Not all operations have { session } const session = await mongoose.startSession(); session.startTransaction(); await Account.findOneAndUpdate( { _id: fromId }, { $inc: { balance: -amount } }, // Missing { session } -- runs OUTSIDE the transaction! ); await Account.findOneAndUpdate( { _id: toId }, { $inc: { balance: amount } }, { session }, // Only this one is in the transaction ); await session.commitTransaction(); ``` **Why bad:** First update runs outside the transaction and cannot be rolled back, inconsistent state if second update fails, no `endSession()` in finally block ### Bad Example -- Parallel Operations in Transaction ```typescript // BAD: MongoDB does not support parallel operations in a single session const session = await mongoose.startSession(); await session.withTransaction(async () => { await Promise.all([ Account.findOneAndUpdate( { _id: fromId }, { $inc: { balance: -100 } }, { session }, ), Account.findOneAndUpdate( { _id: toId }, { $inc: { balance: 100 } }, { session }, ), ]); // Error: "Cannot run operation with session that has ended" }); ``` **Why bad:** MongoDB does not support parallel operations within a single transaction session. Operations must be sequential within a transaction. --- ## Pattern 2: transactionAsyncLocalStorage (Mongoose 7.8+) ### Good Example -- Automatic Session Propagation ```typescript import mongoose from "mongoose"; // Enable once at startup -- session is automatically injected into all operations mongoose.set("transactionAsyncLocalStorage", true); async function createOrderWithTransaction( orderData: OrderInput, ): Promise<void> { // connection.transaction() wraps the callback in AsyncLocalStorage await mongoose.connection.transaction(async () => { // No { session } needed -- automatically propagated via AsyncLocalStorage const order = await Order.create(orderData); await Product.updateMany( { _id: { $in: orderData.items.map((i) => i.productId) } }, { $inc: { stock: -1 } }, ); await Customer.findByIdAndUpdate(orderData.customerId, { $push: { orderIds: order._id }, }); // If anything throws, ALL operations are rolled back }); } export { createOrderWithTransaction }; ``` **Why good:** Eliminates manual `{ session }` passing (the biggest source of transaction bugs), cleaner code, no risk of forgetting session, automatic rollback on error **When to use:** Mongoose 7.8+ projects. Especially valuable when operations span multiple functions/services where passing session through all parameters is cumbersome. ### Good Example -- Connection.transaction() with Automatic State Reset ```typescript mongoose.set("transactionAsyncLocalStorage", true); async function updateWithRollbackSafety(userId: string): Promise<void> { const user = await User.findById(userId); if (!user) throw new Error("User not found"); await mongoose.connection.transaction(async () => { user.name = "New Name"; await user.save(); // Automatically uses the transaction session // If this throws, user.name reverts to the original value await AuditLog.create({ action: "name_change", userId }); }); // After successful transaction: user.name is "New Name" // After failed transaction: user.name is reverted by Mongoose } export { updateWithRollbackSafety }; ``` **Why good:** `connection.transaction()` integrates with Mongoose change tracking -- if the transaction aborts, Mongoose automatically resets document state --- ## Pattern 3: create() Array Syntax for Transactions ### Important Gotcha ```typescript // CORRECT: Array syntax for create() with session await User.create([{ name: "Alice", email: "alice@test.com" }], { session }); // WRONG: Non-array syntax ignores the session option await User.create({ name: "Alice", email: "alice@test.com" }, { session }); // The { session } is treated as a second document, NOT as options ``` **Why this matters:** `Model.create(doc, options)` only accepts options when the first argument is an array. With a single object, the second argument is interpreted as another document to create. This is a common transaction bug where the operation appears to work but runs outside the transaction. --- ## Pattern 4: Cursor-Based Pagination ### Good Example -- Keyset Pagination (Efficient) ```typescript import mongoose from "mongoose"; const PAGE_SIZE = 20; interface PaginationResult<T> { data: T[]; nextCursor: string | null; hasMore: boolean; } async function getUsersWithCursor( cursor?: string, ): Promise<PaginationResult<Record<string, unknown>>> { const query: Record<string, unknown> = { isActive: true }; if (cursor) { query._id = { $gt: new mongoose.Types.ObjectId(cursor) }; } // Fetch one extra to determine hasMore without a separate count query const users = await User.find(query) .select("name email role createdAt") .sort({ _id: 1 }) .limit(PAGE_SIZE + 1) .lean(); const hasMore = users.length > PAGE_SIZE; const data = hasMore ? users.slice(0, PAGE_SIZE) : users; const nextCursor = hasMore ? String(data[data.length - 1]._id) : null; return { data, nextCursor, hasMore }; } export { getUsersWithCursor }; ``` **Why good:** Cursor-based pagination avoids skip/limit performance issues on large collections, fetch one extra to determine hasMore without separate count query, consistent performance regardless of page depth ### Bad Example -- Deep Offset Pagination ```typescript // BAD: skip/limit degrades with page depth const PAGE_SIZE = 20; const page = 5000; const users = await User.find({ isActive: true }) .skip((page - 1) * PAGE_SIZE) // Scans and discards 99,980 documents .limit(PAGE_SIZE); ``` **Why bad:** MongoDB scans and discards all skipped documents, performance degrades linearly with page depth, page 5000 requires scanning ~100k documents --- ## Pattern 5: Cursor-Based Iteration for Large Datasets ### Good Example -- Processing Large Collections ```typescript const BATCH_LOG_INTERVAL = 1000; async function processAllOrders(): Promise<number> { let processed = 0; const cursor = Order.find({ status: "pending" }) .sort({ createdAt: 1 }) .cursor(); for await (const order of cursor) { await processOrder(order); processed += 1; if (processed % BATCH_LOG_INTERVAL === 0) { console.log(`Processed ${processed} orders`); } } return processed; } export { processAllOrders }; ``` **Why good:** Cursor-based iteration keeps memory constant regardless of result size, `for await...of` for clean async iteration, progress logging at intervals ### Bad Example -- Loading Everything into Memory ```typescript // BAD: Loads ALL matching documents into memory at once const orders = await Order.find({ status: "pending" }); // 1 million pending orders? Out of memory crash ``` **Why bad:** Loads entire result set into memory, can crash with OOM for large collections --- ## Pattern 6: Indexes ### Good Example -- Compound Index (ESR Rule) ```typescript const orderSchema = new Schema({ status: { type: String, enum: ["pending", "shipped", "delivered"] as const }, customerId: { type: Schema.Types.ObjectId, ref: "Customer" }, total: { type: Number }, createdAt: { type: Date, default: Date.now }, }); // Query: { status: "shipped", total: { $gte: 100 } }, sort: { createdAt: -1 } // ESR Rule: Equality first, Sort second, Range last orderSchema.index({ status: 1, createdAt: -1, total: 1 }); // Query: { customerId }, sort: { createdAt: -1 } orderSchema.index({ customerId: 1, createdAt: -1 }); const Order = model("Order", orderSchema); export { Order }; ``` **Why good:** ESR (Equality-Sort-Range) rule maximizes index efficiency, separate compound index per query pattern ### Good Example -- TTL Index (Auto-Expiring Documents) ```typescript const SESSION_EXPIRY_SECONDS = 60 * 60 * 24; // 24 hours const sessionSchema = new Schema({ userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, token: { type: String, required: true, unique: true }, createdAt: { type: Date, default: Date.now }, }); sessionSchema.index( { createdAt: 1 }, { expireAfterSeconds: SESSION_EXPIRY_SECONDS }, ); const Session = model("Session", sessionSchema); export { Session }; ``` **Why good:** Named constant for expiry, TTL index for automatic cleanup by MongoDB background thread, no application-level cleanup needed ### Good Example -- Partial and Sparse Indexes ```typescript // Partial index: smaller, only indexes active records userSchema.index( { email: 1 }, { partialFilterExpression: { isActive: true }, unique: true, // Unique among active users only }, ); // Sparse index: allows multiple null/undefined values userSchema.index({ googleId: 1 }, { unique: true, sparse: true }); ``` **Why good:** Partial index is smaller and faster, uniqueness enforced only for active records, sparse index allows multiple users without googleId ### Index Best Practices 1. **Disable autoIndex in production** -- set `{ autoIndex: false }` in schema options, create indexes via migration scripts 2. **Follow ESR rule** for compound indexes -- Equality, Sort, Range 3. **One text index per collection** -- include all searchable fields in one index 4. **Use `explain("executionStats")`** to verify index usage -- look for `IXSCAN` not `COLLSCAN` 5. **Remove unused indexes** -- they slow writes and consume storage 6. **Limit to ~10 indexes per collection** -- more indexes slow write operations --- _For core patterns, see [core.md](core.md). For middleware, see [middleware.md](middleware.md). For population, see [population.md](population.md)._
-
-
reference.md 9.2 KB
# Mongoose Reference > Decision frameworks, quick reference tables, schema types, query operators, and migration notes. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. --- ## Decision Framework ### When to Use `save()` vs Direct Update ``` Do you need pre('save') / post('save') middleware to fire? |-- YES --> findById() then .save() |-- NO --> Do you need schema validation on the update? |-- YES --> findByIdAndUpdate() with { runValidators: true } |-- NO --> findByIdAndUpdate() or updateOne() ``` ### When to Use `.lean()` ``` Are you sending the result directly as an API response? |-- YES --> Use .lean() (3x memory savings, plain objects) |-- NO --> Do you need to call .save(), .populate(), or instance methods? |-- YES --> Do NOT use lean (need hydrated document) |-- NO --> Use .lean() (faster, less memory) ``` ### TypeScript: Automatic Inference vs Explicit Interface ``` Does your model have instance methods, statics, or virtuals? |-- YES --> Use explicit interfaces with Schema generics | (IDoc, IDocMethods, IDocVirtuals, IDocStatics) |-- NO --> Let Mongoose infer types automatically from schema Use InferSchemaType / InferRawDocType if you need the type elsewhere ``` ### Embed vs Reference ``` Is the related data always accessed with the parent? |-- YES --> Is it bounded (won't grow without limit)? | |-- YES --> Is it small (< 100 items)? | | |-- YES --> EMBED in the parent document | | |-- NO --> Consider EMBED with pagination or REFERENCE | |-- NO --> REFERENCE (unbounded arrays hit 16 MB limit) |-- NO --> Is the related data shared across many parents? |-- YES --> REFERENCE with ObjectId |-- NO --> Is it updated independently from the parent? |-- YES --> REFERENCE with ObjectId |-- NO --> EMBED in the parent document ``` ### Populate vs Aggregation $lookup ``` Do you need to resolve references? |-- YES --> Is it a simple parent-child with field selection? | |-- YES --> Use .populate() with select | |-- NO --> Do you need filtering/sorting on the joined data? | |-- YES --> Use aggregation $lookup + $match | |-- NO --> Use .populate() with match option |-- NO --> Skip population, use IDs directly ``` --- ## Schema Types Quick Reference | Mongoose Type | TypeScript Interface | Schema Definition | | ----------------------- | -------------------- | ---------------------------------------- | | `String` | `string` | `name: { type: String }` | | `Number` | `number` | `age: { type: Number }` | | `Boolean` | `boolean` | `isActive: { type: Boolean }` | | `Date` | `Date` | `createdAt: { type: Date }` | | `Buffer` | `Buffer` | `data: { type: Buffer }` | | `Schema.Types.ObjectId` | `Types.ObjectId` | `ref: { type: Schema.Types.ObjectId }` | | `Schema.Types.Mixed` | `any` | `metadata: { type: Schema.Types.Mixed }` | | `[String]` | `string[]` | `tags: [{ type: String }]` | | `Map` | `Map<string, V>` | `meta: { type: Map, of: String }` | | `Schema.Types.UUID` | `string` | `uuid: { type: Schema.Types.UUID }` | **Critical distinction:** `Schema.Types.ObjectId` is for schema definitions. `Types.ObjectId` is for TypeScript interfaces and runtime values. Mixing them up causes type errors. --- ## Middleware Execution Matrix | Operation | Middleware Hook Triggered | Type | | -------------------------- | ------------------------------------------ | --------- | | `doc.save()` | `pre/post('validate')`, `pre/post('save')` | document | | `Model.create()` | `pre/post('validate')`, `pre/post('save')` | document | | `Model.insertMany()` | `pre/post('insertMany')` | model | | `Model.findOneAndUpdate()` | `pre/post('findOneAndUpdate')` | query | | `Model.updateOne()` | `pre/post('updateOne')` | query | | `doc.updateOne()` | `pre/post('updateOne')` | document | | `Model.updateMany()` | `pre/post('updateMany')` | query | | `Model.findOneAndDelete()` | `pre/post('findOneAndDelete')` | query | | `Model.deleteOne()` | `pre/post('deleteOne')` | query | | `doc.deleteOne()` | `pre/post('deleteOne')` | document | | `Model.deleteMany()` | `pre/post('deleteMany')` | query | | `Model.find()` | `pre/post('find')` | query | | `Model.findOne()` | `pre/post('findOne')` | query | | `Model.aggregate()` | `pre/post('aggregate')` | aggregate | **Key insights:** - `insertMany()` triggers `insertMany` model middleware only -- NOT `save` or `validate` hooks - `save()` and `create()` are the ONLY operations that trigger `pre('save')` and `pre('validate')` - `Model.deleteOne()` triggers **query** middleware; `doc.deleteOne()` triggers **document** middleware -- same method name, different middleware type --- ## Common Query Operators | Operator | Description | Example | | -------------- | ----------------------- | ----------------------------------------- | | `$eq` | Equal | `{ age: { $eq: 25 } }` | | `$ne` | Not equal | `{ status: { $ne: "deleted" } }` | | `$gt` / `$gte` | Greater than (or equal) | `{ age: { $gte: 18 } }` | | `$lt` / `$lte` | Less than (or equal) | `{ price: { $lt: 100 } }` | | `$in` / `$nin` | In / not in array | `{ role: { $in: ["admin", "mod"] } }` | | `$exists` | Field exists | `{ avatar: { $exists: true } }` | | `$regex` | Regular expression | `{ name: { $regex: /^A/i } }` | | `$or` | Logical OR | `{ $or: [{ a: 1 }, { b: 2 }] }` | | `$and` | Logical AND | `{ $and: [{ a: 1 }, { b: 2 }] }` | | `$elemMatch` | Array element match | `{ tags: { $elemMatch: { $eq: "js" } } }` | ## Common Update Operators | Operator | Description | Example | | --------------- | ---------------------- | ----------------------------------- | | `$set` | Set field value | `{ $set: { name: "New" } }` | | `$unset` | Remove field | `{ $unset: { temp: "" } }` | | `$inc` | Increment | `{ $inc: { count: 1 } }` | | `$push` | Add to array | `{ $push: { tags: "new" } }` | | `$pull` | Remove from array | `{ $pull: { tags: "old" } }` | | `$addToSet` | Add unique to array | `{ $addToSet: { tags: "unique" } }` | | `$rename` | Rename field | `{ $rename: { old: "new" } }` | | `$min` / `$max` | Update if less/greater | `{ $min: { low: 5 } }` | --- ## Connection Options | Option | Default | Notes | | -------------------------- | ------- | ------------------------------------- | | `maxPoolSize` | 100 | Maximum sockets in connection pool | | `minPoolSize` | 0 | Minimum sockets maintained | | `serverSelectionTimeoutMS` | 30000 | Time to find an available server | | `socketTimeoutMS` | 0 | Time before killing inactive sockets | | `retryWrites` | true | Retry failed writes automatically | | `retryReads` | true | Retry failed reads automatically | | `family` | 0 | Force IPv4 (4) or IPv6 (6) | | `bufferCommands` | true | Queue operations before connection | | `autoIndex` | true | Auto-create indexes (disable in prod) | --- ## Mongoose 9 Migration Notes Key breaking changes from Mongoose 8 to 9 (released November 2025): | Change | Before (v8) | After (v9) | | -------------------- | ----------------------------------------- | ----------------------------------- | | Pre hook callbacks | `pre('save', function(next) { next(); })` | `pre('save', async function() { })` | | Type alias | `FilterQuery<T>` | `QueryFilter<T>` | | Pipeline updates | Allowed by default | Require `{ updatePipeline: true }` | | `isValidObjectId(6)` | Returns `true` | Returns `false` | | Node.js minimum | 16+ | 18+ | | `create()` generics | Accepted arbitrary generics | Type-checked against schema | | UUID representation | String via getter | `bson.UUID` instances | -
SKILL.md 13.7 KB
--- name: api-database-mongoose description: MongoDB ODM with schemas, validation, middleware, and TypeScript support --- # Mongoose ODM Patterns > **Quick Guide:** Use Mongoose as the ODM layer for MongoDB. Let TypeScript infer types from schema definitions instead of duplicating interfaces. Register all middleware before calling `model()` -- hooks added after compilation are silently ignored. Use `.lean()` for any read-only query. Pass `{ session }` to every operation inside a transaction or enable `transactionAsyncLocalStorage`. Prefer `session.withTransaction()` over manual commit/abort. Use `127.0.0.1` instead of `localhost` in connection strings (Node.js 18+ IPv6 preference causes timeouts). --- <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 define all middleware (pre/post hooks) BEFORE calling `model()` -- hooks registered after model compilation are silently ignored with no error)** **(You MUST pass `{ session }` to EVERY operation inside a transaction -- missing session causes that operation to run outside the transaction silently)** **(You MUST use `.lean()` for read-only queries returning API responses -- skipping lean wastes 3x memory on hydration overhead)** **(You MUST use `127.0.0.1` instead of `localhost` in connection strings -- Node.js 18+ prefers IPv6 and `localhost` causes connection timeouts)** **(You MUST NOT use `findOneAndUpdate`/`updateOne` and expect `pre('save')` to fire -- only `save()` and `create()` trigger document middleware)** **(You MUST NOT use `next()` callbacks in pre hooks on Mongoose 9 -- use async/await instead; `next()` was removed in v9)** </critical_requirements> --- **Auto-detection:** Mongoose, mongoose, mongoose.connect, Schema, model, ObjectId, populate, HydratedDocument, InferSchemaType, InferRawDocType, pre('save'), post('save'), lean, mongoose.startSession, withTransaction, discriminator, virtual, Schema.Types.ObjectId, Types.ObjectId **When to use:** - Defining MongoDB schemas and models with Mongoose - TypeScript integration with schema type inference - Middleware hooks (pre/post save, validate, find, delete) - Population (resolving references between collections) - Transactions with session management - Virtuals and instance/static methods - Discriminators (single collection inheritance) - Connection management (single and multi-database) **Key patterns covered:** - Schema definition with automatic TypeScript inference - TypeScript typing (HydratedDocument, InferSchemaType, methods/statics/virtuals generics) - CRUD operations (create, find, update, delete, lean vs hydrated) - Middleware hooks and their execution rules - Population with field selection and limits - Transactions (withTransaction, transactionAsyncLocalStorage) - Validation (built-in validators, custom validators, error messages) - Virtuals (computed, populate virtuals) - Discriminators (inheritance pattern) - Connection setup and multi-database **When NOT to use:** - Raw MongoDB driver queries without schema enforcement (use the native driver) - Relational data with complex joins and foreign key constraints (use a relational database) - Simple key-value storage (use a dedicated key-value store) **Detailed Resources:** - For decision frameworks, quick reference tables, and migration notes, see [reference.md](reference.md) **Core Patterns:** - [examples/core.md](examples/core.md) -- Connection, schema definition, TypeScript typing, model creation, CRUD, validation **Middleware & Lifecycle:** - [examples/middleware.md](examples/middleware.md) -- Pre/post hooks, error handling middleware, query middleware, soft delete **Relationships & Population:** - [examples/population.md](examples/population.md) -- Populate, virtual populate, discriminators, embedding vs referencing **Transactions & Advanced:** - [examples/transactions.md](examples/transactions.md) -- Sessions, withTransaction, transactionAsyncLocalStorage, connection management --- <philosophy> ## Philosophy Mongoose provides schema-based modeling for MongoDB. Its value is the **application-layer enforcement** of structure, validation, middleware, and type safety on top of MongoDB's flexible document model. **Core principles:** 1. **Schema-first** -- Define schemas before models. Schemas enforce structure, validation, defaults, and middleware at the application layer. 2. **Infer, don't duplicate** -- Let Mongoose infer TypeScript types from schema definitions. Only define explicit interfaces when adding methods, statics, or virtuals. 3. **Middleware before model** -- All pre/post hooks must be registered before `model()`. This is the single most common Mongoose bug -- hooks added after compilation are silently ignored. 4. **Lean for reads** -- `.lean()` returns plain JavaScript objects (3x less memory). Use it for every read-only query. Only skip lean when you need Mongoose document methods. 5. **Session discipline** -- Every operation inside a transaction must receive `{ session }`. One missed session means that operation runs outside the transaction with no error. 6. **Validate at the schema** -- Push validation into schema definitions (required, min, max, enum, custom validators with error messages). Don't validate in application code what the schema can enforce. **When to use Mongoose:** - You want schema enforcement and validation on MongoDB documents - You need middleware hooks (pre/post save, validate, find) - You want automatic TypeScript type inference from schemas - You need population (reference resolution between collections) - You want computed properties (virtuals) and instance methods **When NOT to use Mongoose:** - Performance-critical bulk operations where the ODM overhead matters (use native driver) - You only need raw MongoDB queries without schema enforcement - You're doing heavy aggregation-only workloads (aggregation pipelines bypass most Mongoose features) </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Connection Setup Establish a single connection at application startup. Use environment variables for credentials. Never hardcode connection strings. Use `127.0.0.1` instead of `localhost` (Node.js 18+ IPv6 preference causes timeouts). ```typescript // Named constants for pool/timeout, env var for URI, typed return const connection = await mongoose.connect(process.env.MONGODB_URI!, { maxPoolSize: POOL_SIZE_MAX, serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, }); ``` See [examples/core.md](examples/core.md) Pattern 1 for production connection setup, event handling, graceful shutdown, and multi-database connections. --- ### Pattern 2: Schema Definition with TypeScript Inference Let Mongoose infer types from the schema definition. Only use explicit interfaces when adding methods, statics, or virtuals. Use `as const` on enum arrays to preserve literal types. ```typescript const userSchema = new Schema( { email: { type: String, required: true, unique: true, lowercase: true }, role: { type: String, enum: ["admin", "user"] as const, default: "user" }, }, { timestamps: true }, ); const User = model("User", userSchema); // TypeScript infers types from schema ``` See [examples/core.md](examples/core.md) Patterns 2-3 for complete schemas with validation, subdocuments, `InferSchemaType`, and full generic typing with methods/statics/virtuals. --- ### Pattern 3: Explicit Typing (Methods, Statics, Virtuals) When a model has instance methods, statics, or virtuals, use the full generic parameter set. Define separate interfaces for `IDoc`, `IDocMethods`, `IDocVirtuals`, and `IDocStatics`. Export `HydratedDocument<IDoc, IDocMethods & IDocVirtuals>` for consumers. ```typescript type UserModel = Model<IUser, {}, IUserMethods, IUserVirtuals> & IUserStatics; type UserDocument = HydratedDocument<IUser, IUserMethods & IUserVirtuals>; const userSchema = new Schema< IUser, UserModel, IUserMethods, {}, IUserVirtuals >( { /* fields */ }, { toJSON: { virtuals: true } }, ); ``` See [examples/core.md](examples/core.md) Pattern 3 for the complete implementation with all interfaces, generic parameters, methods, virtuals, statics, and middleware ordering. --- ### Pattern 4: CRUD Operations Key rules: use `.lean()` for read-only queries (3x memory savings), `save()` when middleware must fire, `{ new: true, runValidators: true }` on direct updates. Never call `.save()` on a lean result (plain object, no methods). ```typescript const users = await User.find({ isActive: true }).select("name email").lean(); await User.findByIdAndUpdate( id, { $set: { name: "New" } }, { new: true, runValidators: true }, ); ``` See [examples/core.md](examples/core.md) Pattern 5 for create, read, update, delete, bulk operations, and common mistakes. --- ### Pattern 5: Schema Validation Push validation into schema definitions: use `required` with messages, `min`/`max`/`minlength`/`maxlength` with messages, `match` for regex, `enum` with `as const` and `{VALUE}` message template, and custom `validate` functions. Use named constants for all numeric limits. ```typescript name: { type: String, required: [true, "Name is required"], minlength: [MIN_LEN, "Too short"] }, status: { type: String, enum: { values: ["draft", "active"] as const, message: "{VALUE} invalid" } }, ``` See [examples/core.md](examples/core.md) Pattern 2 for complete validation schemas, subdocuments, and array validation. </patterns> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Registering middleware after `model()` call -- hooks are silently ignored, no error thrown - Running operations in parallel inside a transaction (`Promise.all()`) -- MongoDB does not support parallel operations within a single transaction session - Missing `{ session }` on any operation inside a transaction -- that operation runs outside the transaction silently - Using `localhost` in connection strings on Node.js 18+ -- IPv6 preference causes connection timeouts, use `127.0.0.1` - Mutating a document fetched with `.lean()` and calling `.save()` -- lean returns plain objects without Mongoose methods **Medium Priority Issues:** - Using `findOneAndUpdate`/`updateOne` and expecting `pre('save')` to fire -- only `save()` and `create()` trigger document middleware - Unbounded `.populate()` without `limit` or field selection -- can return thousands of documents per populate call, each is a separate DB round-trip - Not passing `runValidators: true` on `findOneAndUpdate` -- schema validation is skipped by default on direct updates - Using `Schema.Types.ObjectId` in TypeScript interfaces -- use `Types.ObjectId` for interfaces, `Schema.Types.ObjectId` for schema definitions only - Creating indexes in production application code instead of migration scripts -- index builds can lock the collection **Common Mistakes:** - Forgetting `{ new: true }` on `findOneAndUpdate` -- returns the old document by default, not the updated one - Using `next()` callbacks in pre hooks on Mongoose 9 -- `next()` was removed in v9, use async/await - Not handling duplicate key errors (error code 11000) from unique indexes - Using `.lean()` on write operations -- lean is for reads only - Checking `doc.isNew` in `post('save')` hooks -- always `false` after save; capture in `pre('save')` via `this.$locals.wasNew` - Defining the same middleware hook multiple times without realizing they stack (all run, not just the last one) - Using `extends Document` on interfaces -- deprecated pattern that breaks type inference for lean documents and query filters **Gotchas & Edge Cases:** - MongoDB has a 16 MB document size limit -- deeply embedded arrays can silently hit this - Mongoose buffers all operations until connected -- queries queue silently if connection fails, which can mask connection issues in development - `deleteOne`/`deleteMany` on the Model do not trigger document `pre('deleteOne')` middleware -- they trigger query middleware instead; use `doc.deleteOne()` for document middleware - Virtual properties are excluded from `toJSON()`/`toObject()` by default -- set `{ toJSON: { virtuals: true } }` in schema options or they disappear in API responses - `insertMany()` does not trigger `save` middleware -- it triggers `insertMany` model middleware only - Mongoose 9 renamed `FilterQuery` to `QueryFilter` -- update TypeScript imports if upgrading - Mongoose 9 disallows pipeline-style updates by default -- pass `{ updatePipeline: true }` or they throw - `create()` with an array requires array syntax for `{ session }`: `Model.create([data], { session })` -- the non-array form `Model.create(data, { session })` does not work in transactions </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 define all middleware (pre/post hooks) BEFORE calling `model()` -- hooks registered after model compilation are silently ignored with no error)** **(You MUST pass `{ session }` to EVERY operation inside a transaction -- missing session causes that operation to run outside the transaction silently)** **(You MUST use `.lean()` for read-only queries returning API responses -- skipping lean wastes 3x memory on hydration overhead)** **(You MUST use `127.0.0.1` instead of `localhost` in connection strings -- Node.js 18+ prefers IPv6 and `localhost` causes connection timeouts)** **(You MUST NOT use `findOneAndUpdate`/`updateOne` and expect `pre('save')` to fire -- only `save()` and `create()` trigger document middleware)** **(You MUST NOT use `next()` callbacks in pre hooks on Mongoose 9 -- use async/await instead; `next()` was removed in v9)** **Failure to follow these rules will cause silent middleware bypass, transaction isolation failures, or connection timeouts.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.