Escena Viva ja persisteix a MongoDB i els controladors no saben que existeix. Ara toca la part de la feina que separa una aplicació que funciona d'una aplicació que aguanta: com es relacionen les dades entre si i com es consulten quan les preguntes deixen de ser trivials.
Prendrem la decisió de modelatge més important del món documental —incrustar o referenciar—, veurem com funciona populate de veritat i per què no és un JOIN, mesurarem el problema N+1, defensarem una desnormalització deliberada, construirem els informes d'Escena Viva amb el framework d'agregació —jubilant els que al mòdul 3 fèiem llegint CSV— i aprendrem a mirar els índexs amb explain() en lloc de suposar.
Contingut
- Incrustar davant de referenciar
- Les decisions d'Escena Viva, justificades una a una
- Referències amb
refipopulate - El problema N+1: mesurar-lo i resoldre'l
- Desnormalització deliberada
- El framework d'agregació com a canonada
- Informes reals d'Escena Viva
$lookupdavant depopulate- Índexs de debò i
explain() - Índexs parcials, cost d'escriptura, text i geoespacial
Incrustar davant de referenciar
En una base de dades relacional aquesta decisió no existeix: tot se separa en taules i s'uneix amb JOIN. A MongoDB tens les dues opcions, i triar malament és la causa número u de projectes que "es tornen lents sense saber per què".
Incrustar és ficar les dades filles dins del document pare, com les nostres sessions dins de l'Esdeveniment. Referenciar és desar-les en una altra col·lecció i emmagatzemar només el seu identificador, com Comanda.usuariId.
| Criteri | Incrusta si... | Referencia si... |
|---|---|---|
| Cardinalitat | Pocs fills (desenes), nombre acotat | Molts fills, sense sostre conegut |
| Mida | El document es manté petit | El conjunt es pot acostar als 16 MB |
| Accés conjunt | Gairebé sempre es llegeixen pare i fill junts | El fill es consulta pel seu compte |
| Volatilitat | Els fills canvien poc, o amb el pare | Els fills canvien molt més que el pare |
| Compartició | El fill pertany a un únic pare | Diversos pares referencien el mateix fill |
| Consulta independent | Mai no busques fills sense saber el pare | Llistes, pagines o filtres fills globalment |
El límit de 16 MB per document no és una recomanació: és un límit dur de BSON. Un document que creix indefinidament hi xocarà, i la fallada arribarà en producció, amb dades reals, un dia qualsevol. Però molt abans ja fa mal: MongoDB reescriu el document sencer a cada actualització, així que un document de 2 MB al qual afegeixes un element petit mou 2 MB de disc i de xarxa. Existeix una tercera via, el patró de subconjunt: incrustar els pocs fills que es mostren sempre (les 3 darreres ressenyes) i referenciar la resta.
Les decisions d'Escena Viva, justificades una a una
Les sessions s'incrusten a l'esdeveniment. Cardinalitat acotada: entre 1 i 7 al nostre catàleg, potser 60 en una obra en cartell. Mida ridícula: cinc camps, uns 150 bytes. Accés sempre conjunt: GET /esdeveniments/evt-001 retorna l'esdeveniment amb les seves sessions i la fitxa web les mostra totes. No es comparteixen: una sessió no té sentit fora del seu esdeveniment. L'única objecció seriosa és la volatilitat —venudes canvia a cada compra i això reescriu el document—, però com que és diminut el cost és menyspreable. Incrustar guanya amb claredat.
Les comandes es referencien. Creixement sense sostre: un festival en pot generar 20.000. Dins de l'esdeveniment, el document creixeria fins a rebentar i cada compra reescriuria megabytes. A més es consulten pel seu compte ("les meves comandes", "comandes d'avui") sense partir de l'esdeveniment, i tenen cicle de vida propi amb quatre estats.
Les entrades es referencien. És l'entitat de més volum i s'hi accedeix pel seu codi a la porta, individualment, sense saber la comanda. Tenen el seu propi cicle (valida → usada | anullada).
L'usuari es referencia des de la comanda. Un usuari té N comandes i no volem duplicar el seu nom i correu a cadascuna: si canvia el correu, caldria reescriure-les totes.
graph TD
subgraph esdeveniments["Colleccio: esdeveniments"]
E["Esdeveniment evt-001<br/>Concierto de Otono<br/>Teatro Almendra"]
S1["sessio ses-001-1<br/>aforament 400 / venudes 312"]
S2["sessio ses-001-2<br/>aforament 400 / venudes 289"]
E -->|incrustada| S1
E -->|incrustada| S2
end
subgraph usuaris["Colleccio: usuaris"]
U["Usuari<br/>rol assistent"]
end
subgraph comandes["Colleccio: comandes"]
P["Comanda<br/>sessioId ses-001-1<br/>quantitat 2"]
end
subgraph entrades["Colleccio: entrades"]
T1["Entrada EV-2026-000431"]
T2["Entrada EV-2026-000432"]
end
U -.->|referencia usuariId| P
P -.->|referencia comandaId| T1
P -.->|referencia comandaId| T2
T1 -.->|referencia sessioId| S1
Les fletxes contínues són dades que viuen al mateix document; les discontínues, referències que exigeixen una consulta addicional. Aquesta distinció visual és exactament la diferència de cost.
Referències amb ref i populate
Ja vam declarar les referències: usuariId: { type: Schema.Types.ObjectId, ref: 'Usuari' }. El ref diu quin model hi ha a l'altre costat; populate substitueix l'identificador pel document.
// Sense populate: usuariId es un ObjectId. Amb populate, el document sencer.
const ambUsuari = await Comanda.findById(id).populate('usuariId').lean();
// Poblat selectiu: nomes els camps necessaris.
const lleuger = await Comanda.findById(id)
.populate({ path: 'usuariId', select: 'nom correu rol -_id' })
.lean();
// Poblat imbricat: entrada -> comanda -> usuari.
const entrada = await Entrada.findOne({ codi: 'EV-2026-000431' })
.populate({
path: 'comandaId',
select: 'sessioId quantitat estat usuariId',
populate: { path: 'usuariId', select: 'nom correu' },
})
.lean();I ara el més important, el que la documentació esmenta de passada i arruïna aplicacions: populate no és un JOIN. MongoDB no uneix res. Mongoose executa la teva consulta, recull els identificadors del camp poblat, llança una segona consulta (Usuari.find({ _id: { $in: [...] } })) i cus els resultats en memòria, al teu procés Node. Conseqüències:
- Un
populatesobre 50 comandes són 2 consultes, no 50: Mongoose agrupa els identificadors. Un poblat imbricat de dos nivells, 3 consultes. - El cost de cosir el paga el teu bucle d'esdeveniments, no el servidor de base de dades.
- No pots filtrar el pare per un camp del fill. "Comandes l'usuari de les quals sigui organitzador" no s'expressa amb
populate; l'opciómatchno descarta pares, deixa el camp anulli la comanda continua apareixent. Per a això,$lookupamb$match.
El problema N+1: mesurar-lo i resoldre'l
Apareix quan, per resoldre una llista de N elements, llances una consulta per element. És facilíssim d'escriure sense adonar-se'n:
// MALAMENT: N+1. Una consulta per a les comandes i una MES per cada comanda.
const comandes = await Comanda.find({ estat: 'pagat' }).limit(100).lean(); // 1
for (const comanda of comandes) {
comanda.usuari = await Usuari.findById(comanda.usuariId).lean(); // 100
}Amb una latència modesta de 2 ms per consulta, són 202 ms d'espera pura i seqüencial per a una resposta que hauria de trigar 5 ms. I escala amb el trànsit: 100 peticions simultànies són 10.100 consultes. Mesurar-ho és trivial i ho hauries de fer abans d'optimitzar res: mongoose.set('debug', true) imprimeix cada consulta que s'envia al motor.
// Solucio 1: populate. 2 consultes en total. Suficient el 90 % de les vegades.
await Comanda.find({ estat: 'pagat' }).limit(100)
.populate({ path: 'usuariId', select: 'nom correu' }).lean();
// Solucio 2: dues consultes manuals i un Map. Control total, sense magia.
const comandes = await Comanda.find({ estat: 'pagat' }).limit(100).lean();
const ids = [...new Set(comandes.map((comanda) => String(comanda.usuariId)))];
const usuaris = await Usuari.find({ _id: { $in: ids } }).select('nom correu').lean();
const perId = new Map(usuaris.map((usuari) => [String(usuari._id), usuari]));
const units = comandes.map((c) => ({ ...c, usuari: perId.get(String(c.usuariId)) }));
// Solucio 3: $lookup en una agregacio. UNA sola consulta; uneix el motor.
await Comanda.aggregate([
{ $match: { estat: 'pagat' } },
{ $limit: 100 },
{ $lookup: { from: 'usuaris', localField: 'usuariId', foreignField: '_id', as: 'usuari' } },
{ $unwind: '$usuari' },
{ $project: { sessioId: 1, quantitat: 1, 'usuari.nom': 1 } },
]);Desnormalització deliberada
sessio.venudes és informació redundant: es podria calcular comptant entrades vàlides. La desem igualment, i no per mandra.
A favor: comprovar l'aforament abans de vendre és l'operació més freqüent i més sensible a la latència de la plataforma, i llegir un enter d'un document ja carregat costa zero, mentre que comptar entrades exigeix una agregació sobre milions de documents. El catàleg mostra "queden 88 entrades" a cada targeta: amb el comptador, el llistat surt d'una sola lectura. I permet l'actualització atòmica condicional que resoldrà la sobrevenda, perquè només es pot comparar contra un camp que existeixi al document.
El preu, dit amb claredat: hi ha dues fonts de veritat per al mateix fet i poden divergir. Si es creen entrades sense incrementar el comptador, o a l'inrevés, o un procés mor entre totes dues operacions, el catàleg menteix. I cap mecanisme del motor no ho impedeix: MongoDB no coneix aquesta relació. Aquest preu es paga en tres nivells:
- Atomicitat del conjunt: que reservar aforament, crear la comanda i emetre entrades passi tot o res. És la lliçó 07-06.
- Conciliació periòdica: una tasca programada que recompta i corregeix deixant traça. La xarxa de seguretat.
- Una única porta d'escriptura: ningú no toca
venudesni crea entrades fora del repositori. Si hi ha deu llocs que toquen el comptador, la incoherència és qüestió de temps.
// Conciliacio: recompte real d'entrades per sessio, per contrastar.
const reals = await Entrada.aggregate([
{ $match: { estat: { $in: ['valida', 'usada'] } } },
{ $group: { _id: '$sessioId', reals: { $sum: 1 } } },
]);El framework d'agregació com a canonada
Si el mòdul 3 et va deixar còmode amb pipeline() i els streams, l'agregació et resultarà familiar: és una canonada d'etapes on cadascuna rep documents, els transforma i passa el resultat a la següent. La diferència és que s'executa dins del servidor de base de dades, a prop de les dades, i no al teu procés.
| Etapa | Què fa | Anàleg en arrays |
|---|---|---|
$match |
Filtra documents | filter |
$project / $addFields |
Tria o calcula camps | map |
$group |
Agrupa per clau i acumula | reduce |
$sort / $limit / $skip |
Ordena i retalla | sort, slice |
$unwind |
Desdobla un array en un document per element | flatMap |
$lookup |
Uneix amb una altra col·lecció | JOIN |
$facet |
Diverses canonades en paral·lel sobre la mateixa entrada | Diversos reduce alhora |
Dues regles d'or sobre l'ordre, perquè el rendiment en depèn. $match com més aviat millor: és l'única etapa que pot fer servir índexs, i només si va al principi; filtrar al final significa processar tota la col·lecció. I $project per descartar camps pesants aviat: menys bytes per la canonada i menys memòria, perquè cada etapa té un límit de 100 MB (superable amb allowDiskUse: true, però si el necessites, replanteja la consulta).
Informes reals d'Escena Viva
Al mòdul 3 calculàvem l'ocupació llegint dades/vendes.csv amb streams. Aquell va ser un exercici excel·lent de canonades, però ara les dades són a la base i el motor les agrega millor que nosaltres.
// src/informes/agregats.js — recaptacio per sala.
async function recaptacioPerSala() {
return Esdeveniment.aggregate([
// 1. Nomes el que esta a la venda o tancat; descarta esborranys.
{ $match: { estat: { $in: ['publicat', 'finalitzat'] } } },
// 2. Una fila per sessio: l'array passa a documents independents.
{ $unwind: '$sessions' },
// 3. Agrupem per sala i acumulem. Els diners, enter per enter.
{ $group: {
_id: '$sala',
recaptacioCentims: { $sum: { $multiply: ['$sessions.venudes', '$sessions.preuCentims'] } },
entradesVenudes: { $sum: '$sessions.venudes' },
aforamentTotal: { $sum: '$sessions.aforament' },
sessions: { $sum: 1 },
} },
// 4. Donem forma a la sortida i calculem l'ocupacio derivada.
{ $project: {
_id: 0, sala: '$_id', recaptacioCentims: 1, entradesVenudes: 1, sessions: 1,
ocupacio: { $round: [{ $divide: ['$entradesVenudes', '$aforamentTotal'] }, 4] },
} },
{ $sort: { recaptacioCentims: -1 } },
]);
}Amb el nostre catàleg llavor (7 sessions, aforament total 3000, 1811 venudes), aquesta canonada retorna les tres sales ordenades per recaptació i una ocupació global del 60,4 %.
// Ocupacio mitjana per categoria. $addToSet acumula valors unics; amb $size
// equival al COUNT(DISTINCT ...) de SQL.
async function ocupacioPerCategoria() {
return Esdeveniment.aggregate([
{ $match: { estat: { $in: ['publicat', 'finalitzat'] } } },
{ $unwind: '$sessions' },
{ $group: {
_id: '$categoria',
venudes: { $sum: '$sessions.venudes' },
aforament: { $sum: '$sessions.aforament' },
esdeveniments: { $addToSet: '$esdevenimentId' },
} },
{ $project: {
_id: 0, categoria: '$_id', nombreEsdeveniments: { $size: '$esdeveniments' },
ocupacio: { $round: [{ $divide: ['$venudes', '$aforament'] }, 4] },
} },
{ $sort: { ocupacio: -1 } },
]);
}
// Ranquing de sessions mes venudes.
async function sessionsMesVenudes(limit = 10) {
return Esdeveniment.aggregate([
{ $match: { estat: 'publicat' } },
{ $unwind: '$sessions' },
{ $project: {
_id: 0, sessioId: '$sessions.sessioId', esdeveniment: '$titol', sala: '$sala',
dataHora: '$sessions.dataHora', venudes: '$sessions.venudes',
lliures: { $subtract: ['$sessions.aforament', '$sessions.venudes'] },
} },
{ $sort: { venudes: -1 } },
{ $limit: limit },
]);
}
// Vendes per mes, sobre la colleccio de comandes. Agrupem per la cadena
// 'yyyy-MM', que a mes ordena alfabeticament be.
async function vendesPerMes(any) {
return Comanda.aggregate([
{ $match: {
estat: { $in: ['pagat', 'emes'] },
createdAt: { $gte: new Date(`${any}-01-01`), $lt: new Date(`${any + 1}-01-01`) },
} },
{ $group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
comandes: { $sum: 1 },
entrades: { $sum: '$quantitat' },
importCentims: { $sum: '$totalCentims' },
} },
{ $sort: { _id: 1 } },
]);
}I un panell complet en una sola consulta amb $facet: cada branca rep els mateixos documents d'entrada i produeix el seu propi array, substituint tres viatges a la base per un. És l'etapa perfecta per a panells de control.
async function panellDeControl() {
const [panell] = await Esdeveniment.aggregate([
{ $match: { estat: 'publicat' } },
{ $unwind: '$sessions' },
{ $facet: {
perSala: [{ $group: { _id: '$sala', venudes: { $sum: '$sessions.venudes' } } }],
perCategoria: [{ $group: { _id: '$categoria', venudes: { $sum: '$sessions.venudes' } } }],
totals: [{ $group: {
_id: null, sessions: { $sum: 1 },
aforament: { $sum: '$sessions.aforament' }, venudes: { $sum: '$sessions.venudes' },
} }],
} },
]);
return panell;
}$lookup davant de populate
populate (Mongoose) |
$lookup (agregació) |
|
|---|---|---|
| On s'uneix | Al teu procés Node | Al servidor de MongoDB |
| Nombre de consultes | 1 + 1 per nivell poblat | 1 |
| Filtrar el pare per camps del fill | No | Sí, amb $match posterior |
| Agregar sobre el resultat unit | No | Sí, és una etapa més |
| Fa servir l'esquema i els seus tipus | Sí | No: treballes amb la col·lecció crua |
Criteri pràctic: populate per servir documents a l'API; $lookup per a informes i quan necessitis filtrar o agregar sobre la relació. Un detall que despista: a $lookup, from és el nom real de la col·lecció (usuaris, en plural i minúscules, tal com el pluralitza Mongoose des del model Usuari), no el nom del model.
Índexs de debò i explain()
Un índex és un arbre B ordenat pels valors d'un o diversos camps, amb punters als documents. Sense ell, "dóna'm els esdeveniments del Teatro Almendra" obliga a llegir tots els documents: un collection scan o COLLSCAN. Amb ell, el motor baixa per l'arbre: un IXSCAN.
En un índex compost l'ordre dels camps mana, com en una llista ordenada per cognom i després per nom: serveix per cercar per cognom, i per cognom+nom, però no només per nom. És el principi del prefix, aplicat a esquemaEsdeveniment.index({ estat: 1, sala: 1, titol: 1 }):
| Consulta | Fa servir l'índex? |
|---|---|
{ estat: 'publicat' } |
Sí (prefix d'1 camp) |
{ estat, sala } |
Sí (prefix de 2 camps) |
{ estat, sala } ordenat per titol |
Sí, sencer: filtre + ordenació |
{ sala: 'Sala Boveda' } o { titol: 'Jazz' } |
No: no són prefix |
La regla nemotècnica és ESR: primer els camps comparats per Equaltat, després el de Sort i finalment els de Rang. Un camp de rang abans del d'ordenació obliga el motor a ordenar en memòria, que és justament el que vols evitar.
const pla = await Esdeveniment.find({ sala: 'Teatro Almendra', estat: 'publicat' })
.explain('executionStats');
pla.executionStats.executionTimeMillis; // temps real
pla.executionStats.totalDocsExamined; // documents llegits
pla.executionStats.nReturned; // documents retornats
pla.queryPlanner.winningPlan.inputStage.stage; // 'IXSCAN' o 'COLLSCAN'Es llegeix amb dos indicadors. stage: 'COLLSCAN' significa que no hi ha índex utilitzable: en una col·lecció petita tant se val, en una de gran és una alarma. I la proporció totalDocsExamined / nReturned: l'ideal és 1, examinar exactament el que retornes; si n'examines 50.000 per retornar-ne 20, l'índex no està fent la seva feina encara que existeixi. Un cas especial excel·lent: si l'índex conté tots els camps que la consulta necessita (filtre i projecció), MongoDB respon sense tocar els documents —consulta coberta— i totalDocsExamined val 0.
Índexs parcials, cost d'escriptura, text i geoespacial
Un índex únic normal rebutja duplicats incloent-hi els null: si deu entrades tenen codiExtern: null, la segona ja viola la unicitat. La solució és l'índex parcial, que només indexa els documents que compleixen un filtre.
// Unic nomes entre les entrades que realment tenen codi extern.
esquemaEntrada.index(
{ codiExtern: 1 },
{ unique: true, partialFilterExpression: { codiExtern: { $type: 'string' } } },
);
// Un usuari no pot tenir dues comandes PENDENTS per a la mateixa sessio,
// pero si diverses pagades.
esquemaComanda.index(
{ usuariId: 1, sessioId: 1 },
{ unique: true, partialFilterExpression: { estat: 'pendent' } },
);I el recordatori incòmode: cada índex es paga a cada escriptura. Inserir un document amb sis índexs són set estructures per actualitzar. Ocupa memòria —idealment els índexs caben a la RAM— i disc. Un índex que cap consulta no fa servir és pur cost, i MongoDB et deixa auditar-ho amb Esdeveniment.collection.aggregate([{ $indexStats: {} }]): si accesses.ops continua a 0 després de setmanes en producció, sobra.
// Index de text: cerca per paraules, amb pesos per camp.
esquemaEsdeveniment.index({ titol: 'text', categoria: 'text' }, { weights: { titol: 10 } });
await Esdeveniment.find({ $text: { $search: 'jazz primavera' } }).lean();
// Index geoespacial: sales a prop d'una coordenada.
esquemaSala.index({ ubicacio: '2dsphere' });Només hi pot haver un índex de text per col·lecció, i per a cercadors seriosos (sinònims, correcció, rellevància afinada) l'habitual és delegar en un motor especialitzat com Elasticsearch o Meilisearch, no forçar MongoDB.
Errors Comuns i Consells
- Incrustar quelcom que creix sense límit. El dia que un esdeveniment acumuli 30.000 comandes incrustades, superarà els 16 MB i no es podrà escriure. No hi ha pedaç, hi ha migració.
- Creure que
populateés unJOIN. Són consultes addicionals, i dins d'un bucle es converteixen en N+1. - Filtrar per un camp poblat.
populateambmatchno descarta pares: deixa el camp anull. Fes servir$lookup+$match. - Posar
$matchal final de l'agregació. Perds l'índex i processes tota la col·lecció. - Oblidar
$unwindabans d'agrupar per un camp d'un array. Sumaries arrays sencers en lloc dels seus elements. - Crear índexs "per si de cas". Cadascun alenteix les escriptures. Crea'ls des de consultes mesurades i audita'ls amb
$indexStats. - Consell:
explain('executionStats')en qualsevol consulta d'una ruta calenta, abans de donar-la per bona. - Consell: una agregació amb tres
$lookupimbricats sol ser el senyal que aquell subsistema encaixaria millor en un model relacional. Bon moment per llegir la lliçó següent sense prejudicis.
Exercicis
Exercici 1: informe d'organitzador
Escriu una agregació resumPerOrganitzador() que retorni, per a cada organitzadorId, el nombre d'esdeveniments publicats, el total de sessions, les entrades venudes, la recaptació en cèntims i l'ocupació mitjana, ordenat per recaptació descendent.
Exercici 2: caçar un N+1
Aquest codi llista les entrades d'una sessió amb el nom del comprador. Indica quantes consultes llança per a 200 entrades i reescriu-lo amb una de sola.
const entrades = await Entrada.find({ sessioId, estat: 'valida' }).lean();
for (const entrada of entrades) {
const comanda = await Comanda.findById(entrada.comandaId).lean();
const usuari = await Usuari.findById(comanda.usuariId).lean();
entrada.comprador = usuari.nom;
}Exercici 3: dissenyar l'índex
Escena Viva afegeix la pantalla "properes sessions amb entrades d'una sala": filtra per estat: 'publicat' i sala, exigeix sessions amb dataHora >= ara i ordena per sessions.dataHora ascendent. Proposa l'índex, justifica l'ordre amb la regla ESR i digues què esperaries veure a explain().
Solucions
Exercici 1.
async function resumPerOrganitzador() {
return Esdeveniment.aggregate([
{ $match: { estat: 'publicat' } },
{ $unwind: '$sessions' },
{ $group: {
_id: '$organitzadorId',
esdeveniments: { $addToSet: '$esdevenimentId' },
sessions: { $sum: 1 },
venudes: { $sum: '$sessions.venudes' },
aforament: { $sum: '$sessions.aforament' },
recaptacioCentims: {
$sum: { $multiply: ['$sessions.venudes', '$sessions.preuCentims'] },
},
} },
{ $project: {
_id: 0, organitzadorId: '$_id', sessions: 1, venudes: 1, recaptacioCentims: 1,
nombreEsdeveniments: { $size: '$esdeveniments' },
ocupacioMitjana: { $round: [{ $divide: ['$venudes', '$aforament'] }, 4] },
} },
{ $sort: { recaptacioCentims: -1 } },
]);
}Exercici 2. Llança 401 consultes: 1 per a les entrades, 200 per a les comandes i 200 per als usuaris. Amb $lookup es resol en una:
const entrades = await Entrada.aggregate([
{ $match: { sessioId, estat: 'valida' } },
{ $lookup: { from: 'comandes', localField: 'comandaId', foreignField: '_id', as: 'comanda' } },
{ $unwind: '$comanda' },
{ $lookup: { from: 'usuaris', localField: 'comanda.usuariId', foreignField: '_id', as: 'comprador' } },
{ $unwind: '$comprador' },
{ $project: { codi: 1, estat: 1, comprador: '$comprador.nom' } },
]);Exercici 3. Índex: { estat: 1, sala: 1, 'sessions.dataHora': 1 }. Per ESR, estat i sala es comparen per igualtat i van primer; sessions.dataHora compleix alhora el paper d'ordenació i de rang, i va al final. En ser un camp d'un array de subdocuments, MongoDB el tracta com a índex multiclau. A explain('executionStats') esperaríem stage: 'IXSCAN', absència d'una etapa SORT en memòria —l'ordenació la serveix el mateix índex— i una relació totalDocsExamined / nReturned propera a 1.
Conclusió
Has après a modelar relacions a MongoDB amb criteri: quan incrustar i quan referenciar, amb la taula de criteris i les decisions d'Escena Viva justificades una per una. Saps què fa populate de veritat —consultes addicionals cosides al teu procés, no un JOIN—, com detectar i matar un N+1, i per què mantenim venudes desnormalitzat acceptant conscientment el deute de coherència que genera. Has construït amb el framework d'agregació els informes que al mòdul 3 sortien d'un CSV: recaptació per sala, ocupació per categoria, rànquing de sessions i vendes per mes, a més d'un panell complet amb $facet. I ja no suposes res sobre índexs: els dissenyes amb la regla ESR i els verifiques amb explain().
També has vist els límits. $lookup imbricat, integritat referencial que ningú no garanteix, una invariant venudes <= aforament que depèn de la teva disciplina en lloc del motor. Res d'això no desqualifica MongoDB —és la persistència oficial d'Escena Viva i funciona—, però deixa una pregunta legítima a l'aire: com seria això en un sistema relacional?
A la lliçó següent ho responem modelant el mateix domini a PostgreSQL amb Sequelize: taules, claus foranes, normalització, una restricció CHECK (venudes <= aforament) que el motor fa complir passi el que passi, include com un JOIN real d'una sola consulta, SQL parametritzat davant de la injecció, i una segona implementació de src/repositoris/esdeveniments.js que demostrarà que l'arquitectura de la lliçó 07-01 no era teoria.
Curs de Node.js: De Principiant a Avançat
Mòdul 1: Introducció a Node.js
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
