---
name: mongodb-experto
description: >
  MongoDB: schema design patterns, aggregation pipeline, indexes, transactions
  y Mongoose ODM. Cargar cuando se diseñen schemas MongoDB, se optimicen queries,
  se usen aggregations complejas o se implementen change streams.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para diseño de esquemas en bases de datos relacionales (PostgreSQL, MySQL) — para bases de datos SQL cargar `postgresql-experto`."
  - "No cargar para caching con Redis aunque el proyecto también use MongoDB — para Redis cargar `redis-experto`."
  - "No cargar para búsqueda full-text avanzada (relevance scoring, facets, sinónimos) aunque MongoDB Atlas Search lo soporte — para búsqueda avanzada cargar el skill de Elasticsearch o Typesense."
  - "No cargar para pipelines ETL de ingestión masiva hacia MongoDB desde fuentes externas — para ETL cargar `datos-etl`."
---
# MongoDB Experto — Schema Design y Aggregation

MongoDB no tiene schema, pero el diseño del schema importa más que en SQL porque
las consultas determinan cómo organizar los datos. Este skill cubre los patrones
de diseño (embedded vs referenced), el aggregation pipeline, tipos de indexes y
transacciones con sesiones.

## Cuándo NO cargar

- La base de datos es relacional (PostgreSQL, MySQL, SQLite): cargar `postgresql-experto`.
- La tarea es caching con Redis aunque el proyecto también use MongoDB: cargar `redis-experto`.
- La búsqueda requiere relevance scoring, facets o sinónimos avanzados: cargar el skill de Elasticsearch o Typesense.
- La tarea es ingestión masiva desde fuentes externas como ETL: cargar `datos-etl`.

## Cuándo cargar este skill

Invoca `Skill("mongodb-experto")` cuando:

- Se diseñe la estructura de documentos de una colección nueva
- Se escriban aggregation pipelines con `$lookup`, `$group` o `$unwind`
- Se creen índices para optimizar queries
- Se implementen transacciones multi-documento
- Se usen change streams para reactividad
- Se configuren modelos con Mongoose ODM

## Conceptos clave

### Embedded vs Referenced

Embeber documentos (subdocumentos) es idiomático en MongoDB. La regla de oro:
si siempre lees A junto con B, embedear B en A. Si B se consulta independientemente
o crece sin límite, referenciar con un ID. Un array de subdocumentos que crece
sin límite (outlier pattern) destruye el rendimiento.

### Aggregation Pipeline

El pipeline procesa documentos en etapas. Cada etapa recibe documentos de la
anterior y entrega documentos a la siguiente. El orden importa: `$match` y
`$limit` primero para reducir el volumen antes de operaciones costosas.

### Indexes en MongoDB

Los índices funcionan igual que en SQL: aceleran lecturas a costa de escrituras.
Los compound indexes siguen las reglas ESR (Equality, Sort, Range). Los partial
indexes indexan solo los documentos que cumplen una condición — ideales para
colecciones con campos opcionales o estados.

## Reglas obligatorias

### $match PRIMERO en el pipeline para usar indexes

```javascript
// MAL — $group antes de $match: procesa TODOS los documentos
db.pedidos.aggregate([
    { $group: { _id: '$usuarioId', total: { $sum: '$monto' } } },
    { $match: { total: { $gt: 1000 } } },  // demasiado tarde
]);

// BIEN — $match primero, reduce el dataset antes de operaciones costosas
db.pedidos.aggregate([
    { $match: { estatus: 'pagado', creadoEn: { $gte: new Date('2026-01-01') } } },
    { $group: { _id: '$usuarioId', total: { $sum: '$monto' } } },
    { $match: { total: { $gt: 1000 } } },
    { $sort: { total: -1 } },
    { $limit: 10 },
]);
```

### NUNCA arrays sin límite en documentos de alta escritura

```javascript
// MAL — array que crece sin limite (outlier pattern)
// Un documento puede exceder 16MB y las queries por el array seran lentas
{
  _id: ObjectId("..."),
  nombre: "Juan",
  actividades: [ /* puede ser miles de entradas */ ]
}

// BIEN — coleccion separada con referencia
// actividades/{usuarioId, tipo, fecha, detalle}
// Consultar con: db.actividades.find({ usuarioId: ObjectId("...") })
```

### Indexes compound con orden ESR (Equality, Sort, Range)

```javascript
// Para la query: { estatus: "activo", precio: { $gte: 100, $lte: 500 } }
// ordenada por: { creadoEn: -1 }

// MAL — orden incorrecto, no usa el index eficientemente
db.productos.createIndex({ precio: 1, estatus: 1, creadoEn: -1 });

// BIEN — ESR: primero igualdad (estatus), luego sort (creadoEn), luego rango (precio)
db.productos.createIndex({ estatus: 1, creadoEn: -1, precio: 1 });
```

### Transacciones solo cuando son necesarias — tiene costo de rendimiento

Las transacciones en MongoDB tienen overhead significativo. Para operaciones
que se pueden modelar con un solo documento (actualización atómica), usar
operadores atómicos: `$set`, `$push`, `$inc`, `findOneAndUpdate`.

## Patrones recomendados

### Schema Design: Bucket Pattern para series de tiempo

```javascript
// MAL — un documento por medicion (millones de documentos)
{ sensorId: "s1", temperatura: 22.5, timestamp: ISODate("2026-01-01T00:00:00Z") }
{ sensorId: "s1", temperatura: 22.6, timestamp: ISODate("2026-01-01T00:01:00Z") }

// BIEN — bucket de 1 hora (60 mediciones por documento)
{
    sensorId: "s1",
    inicio: ISODate("2026-01-01T00:00:00Z"),
    fin: ISODate("2026-01-01T00:59:59Z"),
    count: 60,
    sum: 1353.0,
    min: 22.1,
    max: 23.2,
    mediciones: [22.5, 22.6, 22.7, ...] // array acotado
}
```

### Aggregation Pipeline con $lookup optimizado

```javascript
// $lookup con pipeline para filtrar ANTES del join (mas eficiente)
db.pedidos.aggregate([
    { $match: { estatus: 'pendiente' } },
    {
        $lookup: {
            from: 'usuarios',
            let: { usuarioId: '$usuarioId' },
            pipeline: [
                { $match: { $expr: { $eq: ['$_id', '$$usuarioId'] } } },
                { $project: { nombre: 1, email: 1 } },  // solo campos necesarios
            ],
            as: 'usuario',
        },
    },
    { $unwind: '$usuario' },
    { $project: { monto: 1, 'usuario.nombre': 1, 'usuario.email': 1 } },
]);
```

### Mongoose — Model con virtuals y métodos

```typescript
import { Schema, model, Document } from 'mongoose';

interface IProducto extends Document {
    nombre: string;
    precio: number;
    stock: number;
    estaDisponible(): boolean;
}

const productoSchema = new Schema<IProducto>({
    nombre:  { type: String, required: true, trim: true, maxlength: 200 },
    precio:  { type: Number, required: true, min: 0 },
    stock:   { type: Number, required: true, default: 0, min: 0 },
}, {
    timestamps: true,    // agrega createdAt y updatedAt automáticamente
    versionKey: false,
});

// Método de instancia
productoSchema.methods.estaDisponible = function(): boolean {
    return this.stock > 0;
};

// Virtual (no se persiste en BD)
productoSchema.virtual('precioConIVA').get(function() {
    return this.precio * 1.16;
});

// Índice compuesto con partial filter
productoSchema.index(
    { nombre: 1, categoria: 1 },
    { unique: true, partialFilterExpression: { activo: true } }
);

export const Producto = model<IProducto>('Producto', productoSchema);
```

### Transacciones multi-documento

```typescript
const sesion = await mongoose.startSession();
try {
    sesion.startTransaction();

    const pedido = await Pedido.create([{
        usuarioId, items, total,
    }], { session: sesion });

    await Promise.all(items.map(item =>
        Producto.findByIdAndUpdate(
            item.productoId,
            { $inc: { stock: -item.cantidad } },
            { session: sesion, new: true }
        )
    ));

    await sesion.commitTransaction();
    return pedido[0];
} catch (error) {
    await sesion.abortTransaction();
    throw error;
} finally {
    sesion.endSession();
}
```

### Change Streams para reactividad

```typescript
// Escuchar cambios en pedidos nuevos
const changeStream = Pedido.watch([
    { $match: { operationType: 'insert' } },
]);

changeStream.on('change', (cambio) => {
    const nuevoPedido = cambio.fullDocument;
    // notificar, actualizar cache, etc.
});

// Limpiar al terminar el proceso
process.on('SIGINT', async () => {
    await changeStream.close();
});
```

## Anti-patrones conocidos

### $lookup en colecciones grandes sin index en el campo join

```javascript
// Antes de usar $lookup, verificar que existe un index en el campo referenciado
// db.usuarios.createIndex({ _id: 1 }) — ya existe por defecto
// Pero para campos custom: db.pedidos.createIndex({ clienteId: 1 })
db.explain("executionStats").aggregate([...]);
// Buscar IXSCAN, no COLLSCAN en la etapa de lookup
```

### findOne().then() en un loop — usar $in o $lookup

```typescript
// MAL — N+1 en MongoDB
for (const pedido of pedidos) {
    pedido.usuario = await Usuario.findById(pedido.usuarioId); // query por cada pedido
}

// BIEN — una query con $in
const ids = pedidos.map(p => p.usuarioId);
const usuarios = await Usuario.find({ _id: { $in: ids } });
const mapaUsuarios = new Map(usuarios.map(u => [u._id.toString(), u]));
pedidos.forEach(p => { p.usuario = mapaUsuarios.get(p.usuarioId.toString()); });
```

## Checklist de verificación

- [ ] Arrays en documentos tienen límite definido (no crecen sin cota)
- [ ] `$match` es la primera etapa del pipeline cuando filtra por campo indexado
- [ ] Compound indexes siguen orden ESR para queries con igualdad + sort + rango
- [ ] `$lookup` filtra con pipeline interno antes de hacer el join
- [ ] Transacciones solo para operaciones verdaderamente multi-documento
- [ ] Mongoose models con `timestamps: true` y validaciones en el schema
- [ ] Indexes documentados con la query que los justifica

## Referencias

- [MongoDB Schema Design Patterns](https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-summary/)
- [Aggregation Pipeline](https://www.mongodb.com/docs/manual/core/aggregation-pipeline/)
- [Mongoose Docs](https://mongoosejs.com/docs/)

## Gotchas / Errores comunes no obvios

**Un `$lookup` entre colecciones en diferentes databases no es posible con MongoDB nativo y falla silenciosamente devolviendo arrays vacíos**: si `pedidos` está en `db_ventas` y `usuarios` en `db_usuarios`, el `$lookup` produce `usuario: []` en todos los documentos sin error. Causa: `$lookup` solo funciona dentro de la misma base de datos. Fix: para colecciones en diferentes bases de datos, materializar la relación duplicando el subconjunto de datos necesario en la misma BD (patrón de denormalización explícita), o realizar dos queries separadas en la aplicación y hacer el join en memoria.

**`findOneAndUpdate` con `{ upsert: true }` crea un documento duplicado cuando dos requests concurrentes llegan al mismo tiempo y no hay índice único en el campo de búsqueda**: sin un índice único, MongoDB puede insertar dos documentos porque ambas requests evalúan "no existe" antes de que cualquiera complete el insert. Causa: `upsert: true` no garantiza atomicidad cross-process sin un índice único de respaldo. Fix: crear un índice único en el campo usado en el filtro de `findOneAndUpdate` antes de usar `upsert: true`. El índice garantiza que el segundo insert falle con `E11000 duplicate key error` en lugar de crear un duplicado.

**Change streams en versiones de MongoDB < 6.0 se desconectan silenciosamente después de 30 minutos si el `resumeToken` no se persiste y la aplicación no reconecta automáticamente**: el `changeStream.on('change', ...)` deja de recibir eventos sin emitir un error visible. La aplicación sigue corriendo pero ya no procesa cambios. Causa: los change streams tienen un timeout de cursor configurable y requieren reconexión explícita con el `resumeToken`. Fix: implementar `changeStream.on('error', ...)` y reconexión automática: guardar el último `resumeToken` procesado en una colección de estado y recrear el change stream con `{ resumeAfter: ultimoToken }` cuando detecte la desconexión.

**El `$group` con `$sum` sobre un campo que puede ser `null` en algunos documentos produce resultados incorrectos porque MongoDB trata `null` como `0` en operaciones aritméticas**: si `monto` es `null` en el 30% de los documentos y el resto son valores positivos, `$sum: '$monto'` subestima el total ignorando silenciosamente los nulos en lugar de reportarlos. Causa: MongoDB sigue las reglas de BSON donde `null` en operaciones numéricas equivale a ausencia (no propaga `null` como lo haría SQL con `SUM`). Fix: antes del `$group`, agregar una etapa `$addFields: { monto: { $ifNull: ['$monto', 0] } }` para hacer explícito el tratamiento de nulos, o usar `$cond` para distinguir documentos sin monto de documentos con monto cero.
