Arribem al final del mòdul i a la lliçó que salda el deute més antic del curs. Des del mòdul 4, quan vam muntar el primer servidor node:http i vam vendre la primera entrada, arrosseguem un problema que la validació no resolia, que els errors ben tipats no resolien i que ni tan sols $inc resolia del tot: dues persones comprant alhora l'última entrada disponible. Abans d'atacar-lo, dues peces que tot projecte amb base de dades necessita i que gairebé ningú no ensenya a temps: migracions, perquè l'esquema tingui historial com el té el codi, i llavors, per poblar la base en una ordre. Amb elles jubilarem per fi dades/esdeveniments.json com a font de veritat.

Contingut

  1. Migracions: l'esquema també és codi
  2. sequelize-cli i la migració inicial d'Escena Viva
  3. Migracions sobre dades existents, regles d'or i el cas de MongoDB
  4. Llavors: npm run llavor
  5. Transaccions, ACID i l'escenari de sobrevenda
  6. Transaccions a Sequelize i nivells d'aïllament
  7. Blocatge pessimista davant d'optimista
  8. comprarEntrades transaccional i la solució a MongoDB
  9. Interblocatges, reintents i què no ficar-hi dins

Migracions: l'esquema també és codi

El teu codi té historial: cada canvi és un commit, amb autor, data, motiu i la possibilitat de revertir-lo. L'esquema de la teva base de dades, si el vas crear amb sync() o a mà amb psql, no té res d'això. Ningú no sap quan es va afegir aquella columna, ni per què, ni com estava abans. I quan un company clona el repositori, la seva base de dades no s'assembla a la teva. Una migració és un fitxer versionat, desat al costat del codi, que descriu un canvi d'esquema en dues direccions: up l'aplica, down el desfà. S'executen en ordre i la base guarda quines ja va aplicar. Tres beneficis la fan innegociable: reproductibilitat (màquina nova, base buida, una ordre), revisió (un canvi d'esquema passa per pull request com qualsevol altre) i desplegament automatitzat (el CI/CD del mòdul 11 les executa abans d'arrencar la versió nova).

sequelize-cli i la migració inicial d'Escena Viva

S'instal·la amb npm install --save-dev sequelize-cli i npx sequelize-cli init crea l'estructura estàndard: config/ (connexions), migrations/, seeders/ i models/. El config/config.json generat no ens serveix, perquè la configuració viu a src/config/index.js: el substituïm per un config/config.js que exporti un objecte amb una clau per entorn (desenvolupament, proves, produccio), cadascuna amb { url: configuracio.postgresUrl, dialect: 'postgres' }. Una migració és un mòdul amb dues funcions asíncrones que reben queryInterface —l'API per manipular l'esquema— i Sequelize per als tipus. Sequelize crea una taula SequelizeMeta amb una fila per migració aplicada: en executar db:migrate compara els fitxers amb aquesta taula i aplica només els que falten, en ordre alfabètic — per això els noms comencen per marca de temps.

queryInterface ofereix createTable/dropTable, addColumn/removeColumn/changeColumn/renameColumn, addIndex i addConstraint (per a CHECK, UNIQUE i claus foranes), bulkInsert/bulkUpdate/bulkDelete per moure dades dins de la mateixa migració, i sequelize.query quan l'anterior no arriba.

// migrations/20260814090000-crear-esquema-inicial.js
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // Tot dins d'una transaccio: si alguna cosa falla, la base queda com estava.
    await queryInterface.sequelize.transaction(async (t) => {
      await queryInterface.createTable('esdeveniments', {
        id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
        esdeveniment_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
        titol: { type: Sequelize.STRING(160), allowNull: false },
        sala: { type: Sequelize.STRING(120), allowNull: false },
        organitzador_id: { type: Sequelize.STRING(40), allowNull: false },
        categoria: { type: Sequelize.STRING(60), allowNull: false },
        duracio_minuts: { type: Sequelize.INTEGER, allowNull: false },
        estat: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'esborrany' },
      }, { transaction: t });
      await queryInterface.createTable('sessions', {
        id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
        sessio_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
        esdeveniment_id_ref: { type: Sequelize.INTEGER, allowNull: false,
          references: { model: 'esdeveniments', key: 'id' }, onDelete: 'CASCADE' },
        data_hora: { type: Sequelize.DATE, allowNull: false },
        aforament: { type: Sequelize.INTEGER, allowNull: false },
        preu_centims: { type: Sequelize.INTEGER, allowNull: false },
        venudes: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
      }, { transaction: t });
      // La restriccio clau del curs, declarada explicitament.
      await queryInterface.addConstraint('sessions', {
        fields: ['venudes', 'aforament'], type: 'check', name: 'aforament_no_superat',
        where: { venudes: { [Sequelize.Op.lte]: Sequelize.col('aforament') } }, transaction: t });
      await queryInterface.addIndex('esdeveniments', ['estat', 'sala'], { transaction: t });
      // ...i de la mateixa manera usuaris, comandes i entrades.
    });
  },
  // Ordre INVERS al down: primer les taules que depenen d'altres.
  async down(queryInterface) {
    await queryInterface.dropTable('sessions');
    await queryInterface.dropTable('esdeveniments');
  },
};

Migracions sobre dades existents i regles d'or

Les ordres són npx sequelize-cli db:migrate (aplica les pendents), db:migrate:status (quines estan aplicades) i db:migrate:undo (reverteix l'última). Setmanes després, negoci demana classificar esdeveniments per idioma. La taula ja té dades: no pots afegir una columna NOT NULL sense més, perquè les files existents no tindrien valor i el motor rebutjaria el canvi. El patró correcte té tres passos.

// migrations/20260901120000-afegir-idioma-a-esdeveniments.js
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.sequelize.transaction(async (t) => {
      // 1. Afegir PERMETENT nuls: les files existents queden a null.
      await queryInterface.addColumn('esdeveniments', 'idioma', { type: Sequelize.STRING(5) }, { transaction: t });
      // 2. Emplenar les files existents amb un valor sensat.
      await queryInterface.sequelize.query(
        `UPDATE esdeveniments SET idioma = 'ca' WHERE idioma IS NULL`, { transaction: t });
      // 3. Ara que cap fila no es nulla, endurir la restriccio.
      await queryInterface.changeColumn('esdeveniments', 'idioma',
        { type: Sequelize.STRING(5), allowNull: false, defaultValue: 'ca' }, { transaction: t });
    });
  },
  down: (queryInterface) => queryInterface.removeColumn('esdeveniments', 'idioma'),
};

Aquesta seqüència —afegir permissiva, emplenar, endurir— és la recepta universal per a columnes obligatòries sobre taules poblades. Memoritza-la, i amb ella les regles d'or:

  1. Mai editis una migració ja aplicada. A la teva màquina l'editaries i la reexecutaries, però en producció ja és a SequelizeMeta i no es reexecutarà, així que el teu canvi no hi arribarà mai. Corregir es fa amb una migració nova.
  2. Tota migració ha de ser reversible. Escriu el down i prova'l: un down que no funciona és un desplegament del qual no es pot tornar a les tres de la matinada.
  3. Canvis compatibles cap enrere. En un desplegament sense aturada (mòdul 11) conviuen durant minuts la versió antiga i la nova contra la mateixa base. Si la teva migració reanomena una columna, l'antiga es trenca a l'instant. La tècnica s'anomena expand and contract: afegir la columna nova nul·lable mentre el codi nou escriu a totes dues, copiar les dades, desplegar codi que només fa servir la nova i, finalment, esborrar l'antiga.
  4. Dades i esquema, separats si són grans, i prova sobre una còpia de producció. Un UPDATE sobre deu milions de files dins d'una migració bloca la taula i deixa el desplegament penjat; per a això, un script a part per lots. I els problemes només apareixen amb volum, mai a la teva base buida de tres files.

Migracions a MongoDB

"MongoDB no té esquema, per tant no necessita migracions." És fals, i és una de les confusions més cares del sector. L'absència d'esquema al motor no elimina l'esquema: el trasllada al teu codi. Si afegeixes idioma amb required: true a l'esquema de Mongoose, els cinc milions de documents existents no el tenen i qualsevol save() sobre ells fallarà. Si reanomenes preu a preuCentims, els documents vells continuen amb el nom antic i les teves consultes retornaran buit sense donar cap error.

// migrations/20260901120000-afegir-idioma.js (amb migrate-mongo)
module.exports = {
  async up(db) {
    await db.collection('esdeveniments').updateMany({ idioma: { $exists: false } }, { $set: { idioma: 'ca' } });
    // Els indexs de produccio es creen aqui, no amb autoIndex en arrencar.
    await db.collection('esdeveniments').createIndex({ estat: 1, sala: 1, titol: 1 });
  },
  down: (db) => db.collection('esdeveniments').updateMany({}, { $unset: { idioma: '' } }),
};

Diferència real: a SQL la migració canvia l'estructura i les dades s'adapten; a MongoDB la migració és una transformació de dades. Però la disciplina —fitxer versionat, up/down, registre de les aplicades, revisió en pull request— és idèntica.

Llavors: npm run llavor

Una llavor carrega les dades inicials. El nostre cas és entranyable: els 3 esdeveniments i les 7 sessions de dades/esdeveniments.json, el fitxer que ens acompanya des del mòdul 3, es converteixen en la càrrega inicial de la base de dades. Deixa de ser el magatzem i passa a ser la llavor. S'invoca amb npm run llavor i, amb --reiniciar, esborra abans de carregar (llavor:neta).

// scripts/llavor.js (requires omesos per brevetat)
'use strict';

const RUTA_LLAVOR = path.join(__dirname, '..', 'dades', 'esdeveniments.json');
const USUARIS_DEMO = [
  { correu: '[email protected]', nom: 'Lucia Serrano', rol: 'assistent' },
  { correu: '[email protected]', nom: 'Marc Oliveras', rol: 'organitzador' },
  { correu: '[email protected]', nom: 'Administracio', rol: 'administrador' },
];
/**
 * Carrega el cataleg. Es IDEMPOTENT: executar-lo dues vegades deixa el mateix
 * estat que executar-lo una vegada. S'aconsegueix amb upsert sobre la clau de negoci.
 */
async function sembrar({ reiniciar = false } = {}) {
  await connectar();
  if (reiniciar) await Promise.all([Esdeveniment.deleteMany({}), Usuari.deleteMany({})]);
  const { esdeveniments } = JSON.parse(await readFile(RUTA_LLAVOR, 'utf8'));
  let inserits = 0;

  for (const { id, sessions, ...dades } of esdeveniments) {
    const resultat = await Esdeveniment.updateOne(
      { esdevenimentId: id },   // clau de negoci: evt-001, evt-002, evt-003
      { $set: { ...dades, estat: dades.estat ?? 'publicat',
        sessions: sessions.map((sessio) => ({
          sessioId: sessio.id, dataHora: new Date(sessio.dataHora), aforament: sessio.aforament,
          venudes: sessio.venudes, preuCentims: sessio.preuCentims,
        })) } },
      { upsert: true, runValidators: true }, // crea si no existeix, actualitza si existeix
    );
    if (resultat.upsertedCount > 0) inserits += 1;
  }
  for (const usuari of USUARIS_DEMO) {
    await Usuari.updateOne({ correu: usuari.correu }, { $set: usuari }, { upsert: true });
  }
  // Comprovacio de la carrega: ha de donar 7 sessions, aforament 3000, venudes 1811.
  const [total] = await Esdeveniment.aggregate([{ $unwind: '$sessions' },
    { $group: { _id: null, sessions: { $sum: 1 },
      aforament: { $sum: '$sessions.aforament' }, venudes: { $sum: '$sessions.venudes' } } }]);
  console.log(`[llavor] nous: ${inserits}; sessions: ${total.sessions}, ` +
    `aforament: ${total.aforament}, venudes: ${total.venudes}`);
  await desconnectar();
}

// Executable com a script (npm run llavor) i importable des de les proves.
if (require.main === module) {
  sembrar({ reiniciar: process.argv.includes('--reiniciar') })
    .catch((error) => { console.error('[llavor]', error.message); process.exit(1); });
}
module.exports = { sembrar };

La idempotència separa una llavor professional d'un script d'usar i llençar: s'aconsegueix amb upsert sobre la clau de negoci, mai amb insert. Pots executar-la a cada arrencada de desenvolupament, després de cada migració i abans de cada demostració sense por de duplicar res. I el seu valor va més enllà del desenvolupament: al mòdul 9, quan escriguem proves, una llavor determinista és el que permet afirmar "després de vendre 2 entrades de ses-001-1, en queden 86", perquè l'estat de partida és conegut i reproduïble. Combinada amb una base de dades en memòria o efímera per suite, fa que les proves d'integració siguin ràpides i aïllades. Ho desenvoluparem allà; aquí n'hi ha prou de deixar l'eina a punt.

Transaccions, ACID i l'escenari de sobrevenda

Una transacció és un conjunt d'operacions que el motor tracta com una de sola. Aplicat a algú que compra dues entrades: atomicitat significa que reservar aforament, crear la comanda i emetre dues entrades passa tot o res, sense estats intermedis visibles —si falla la segona entrada, l'aforament torna sol i la comanda no existeix—; consistència, que en confirmar continuen complint-se totes les restriccions (la CHECK (venudes <= aforament), les claus foranes, els NOT NULL) i que si el resultat en violés alguna el motor reverteix tot; aïllament, que mentre la teva transacció és en curs una altra no veu els teus canvis a mitges; i durabilitat, que quan el motor diu "confirmat" és al disc i una tallada de llum un mil·lisegon després no ho desfà. Sense transaccions, cada operació és atòmica per separat, però el conjunt no ho és: aquest és exactament el problema anotat en majúscules dins de crearComanda a la lliçó 07-03. Vegem-ho: ses-001-1 té aforament 400 i 399 venudes, queda una entrada, i Lucía i Marc premen "comprar" al mateix segon.

sequenceDiagram
    participant L as Peticio Lucia
    participant BD as Base de dades<br/>ses-001-1
    participant M as Peticio Marc
    Note over BD: aforament 400 / venudes 399<br/>queda 1 entrada
    L->>BD: 1. llegir sessio
    BD-->>L: venudes = 399, lliures = 1
    M->>BD: 2. llegir sessio
    BD-->>M: venudes = 399, lliures = 1
    Note over L,M: Les DUES comprovacions passen:<br/>1 lliure >= 1 sollicitada
    L->>BD: 3. escriure venudes = 400
    BD-->>L: confirmat
    M->>BD: 4. escriure venudes = 400
    BD-->>M: confirmat
    L->>BD: 5. crear comanda + entrada EV-2026-000431
    M->>BD: 6. crear comanda + entrada EV-2026-000432
    Note over BD: SOBREVENDA:<br/>401 entrades per a 400 butaques

Analitzem per què fallen les defenses que ja tenim. La comprovació prèvia no basta: entre el pas 2 i el pas 4 hi ha una finestra, i l'estat que Marc va llegir ja no és el real quan escriu. Qualsevol lògica del tipus "llegeixo, decideixo, escric" té aquesta finestra, i a Node és especialment fàcil d'obrir, perquè cada await cedeix el control al bucle d'esdeveniments, que atén Marc justament en aquest forat. Fer la finestra més petita no l'elimina: només fa la fallada més rara i més difícil de reproduir, que és pitjor. $inc tampoc no basta: resol la pèrdua d'actualitzacions —si tots dos sumen 1, el resultat és 401 i no 400, perquè cada suma s'aplica sobre el valor real—, però suma incondicionalment, sense saber que 401 supera l'aforament; hem canviat una dada incorrecta per una dada correcta que denuncia una sobrevenda ja comesa. I la validació de l'esquema tampoc: el validador de Mongoose s'executa en desar un document complet, i en un updateOne amb $inc no té accés al resultat ni a l'aforament; encara que el tingués, s'executa al teu procés, així que dos processos Node validarien pel seu compte i tots dos aprovarien.

La solució ha de complir una condició: la comprovació i l'escriptura han de ser una sola operació indivisible per al motor, i hi ha dues maneres d'aconseguir-ho.

Transaccions a Sequelize i nivells d'aïllament

// GESTIONADA (recomanada): confirma en acabar sense errors, reverteix si llanca.
const comanda = await sequelize.transaction(async (t) => {
  const sessio = await Sessio.findOne({ where: { sessioId }, transaction: t });
  await sessio.increment('venudes', { by: quantitat, transaction: t });
  return Comanda.create({ /* ... */ }, { transaction: t });
});

// NO GESTIONADA: control manual. Si oblides el rollback, la transaccio queda
// oberta i rete una connexio del pool fins que expiri.
const t = await sequelize.transaction();
try {
  await Sessio.increment('venudes', { by: quantitat, where: { sessioId }, transaction: t });
  await t.commit();
} catch (error) {
  await t.rollback(); throw error;
}

Fes servir la gestionada per defecte; la no gestionada, només si necessites punts de desament o lògica condicional complexa. El detall que sempre s'oblida: cal propagar { transaction: t } a cadascuna de les consultes. Una consulta sense t s'executa fora de la transacció, en una altra connexió, i no veurà els canvis pendents ni es revertirà amb ells. És el bug més comú i el més silenciós: tot sembla funcionar fins que alguna cosa falla i descobreixes dades a mitges.

L'aïllament perfecte seria executar les transaccions d'una en una, però això destruiria el rendiment. Els nivells permeten triar quanta correcció pagues en concurrència.

Nivell Lectura bruta Lectura no repetible Lectura fantasma Cost
Lectura no confirmada Possible Possible Possible Mínim
Lectura confirmada Evitada Possible Possible Baix — per defecte a PostgreSQL
Lectura repetible Evitada Evitada Evitada a Postgres Mitjà
Serialitzable Evitada Evitada Evitada Alt: pot avortar transaccions

Les tres anomalies amb el nostre exemple. Lectura bruta: llegeixes venudes = 400 d'una transacció que encara no ha confirmat i que acabarà revertint-se; decideixes sobre una dada que no va existir mai. Lectura no repetible: llegeixes 399, una altra transacció confirma, tornes a llegir i ara val 400 — dues lectures de la mateixa dada dins de la mateixa transacció donen resultats diferents, i és l'anomalia que causa la nostra sobrevenda. Lectura fantasma: comptes 5 entrades, una altra transacció n'insereix una i en recomptar n'hi ha 6. Amb { isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE } PostgreSQL detecta el conflicte i avorta una de les dues transaccions amb un error de serialització: és correcte, però trasllada la feina al teu codi, que ha de reintentar. Per a la venda d'entrades hi ha una solució més simple i barata.

Blocatge pessimista davant d'optimista

Pessimista: assumeixo que hi haurà conflicte i bloco la fila abans de tocar-la. SELECT ... FOR UPDATE la reserva, i qualsevol altra transacció que la vulgui blocar espera fins que jo confirmi o reverteixi.

BEGIN;
-- Bloca aquesta fila: Marc esperara aqui fins que Lucia acabi.
SELECT venudes, aforament FROM sessions WHERE sessio_id = 'ses-001-1' FOR UPDATE;
UPDATE sessions SET venudes = venudes + 1 WHERE sessio_id = 'ses-001-1';
COMMIT;

A Sequelize es demana amb lock: t.LOCK.UPDATE. Quan Marc es desperta, llegeix el valor actualitzat, 400, comprova que no en queden de lliures i falla netament amb un 409 AFORAMENT_INSUFICIENT. La finestra ha desaparegut. Optimista: assumeixo que el conflicte és rar, no bloco res, i detecto en escriure si algú se m'ha avançat, mitjançant una columna versio que s'incrementa a cada canvi; l'UPDATE porta la versió llegida al where, i si no afecta cap fila és que un altre s'ha avançat i cal reintentar des de zero.

// L'UPDATE nomes afecta files la versio de les quals continui sent la que vaig llegir.
const [afectades] = await Sessio.update(
  { venudes: novesVenudes, versio: versio + 1 },
  { where: { sessioId, versio }, transaction: t });
if (afectades === 0) {
  throw new ConflicteDEstat('La sessio ha canviat mentre compraves', { codi: 'ESTAT_INVALID' });
}
Pessimista (FOR UPDATE) Optimista (versio)
Cost sense conflicte Un blocatge, una mica d'espera Cap
Cost amb conflicte Espera, resolució garantida Reintent complet des de zero
Risc Interblocatges, contenció Reintents en cascada sota alta contenció
Ideal per a Conflictes freqüents sobre poques files Conflictes rars

Quin encaixa a la venda d'entrades. El pessimista, sens dubte. La venda té un patró molt característic: quan surten les entrades d'un concert esperat, milers de persones competeixen per la mateixa fila durant pocs minuts. Amb blocatge optimista la majoria d'intents fallarien i reintentarien, generant més càrrega just en el pitjor moment i una experiència pèssima. Amb blocatge pessimista les peticions es serialitzen sobre aquesta fila i cadascuna obté resposta definitiva a la primera: o tens entrada, o no en queda. A més la transacció és curtíssima, així que la contenció dura mil·lisegons. L'optimista seria l'elecció correcta per editar la fitxa d'un esdeveniment, on dos organitzadors coincideixen un cop al mes.

comprarEntrades transaccional i la solució a MongoDB

La versió definitiva. Compara-la amb la de la lliçó 07-03, comentari en majúscules inclòs.

// src/repositoris/compres-sql.js (requires omesos per brevetat)
'use strict';

const generarCodi = (any, n) => `EV-${any}-${String(n).padStart(6, '0')}`;

/**
 * Compra atomica: reserva l'aforament, crea la comanda i emet les entrades.
 * O les tres coses, o cap. No hi ha estat intermedi possible.
 */
async function comprarEntrades({ usuariId, sessioId, quantitat, canal }) {
  return sequelize.transaction(async (t) => {
    // 1. Blocatge pessimista sobre la fila. Qualsevol altra compra d'aquesta
    //    mateixa sessio espera aqui fins que confirmem o revertim.
    const sessio = await Sessio.findOne({
      where: { sessioId }, transaction: t, lock: t.LOCK.UPDATE });
    if (!sessio) {
      throw new RecursNoTrobat(`No existeix ${sessioId}`, { codi: 'SESSIO_NO_TROBADA' });
    }
    // 2. Comprovacio de l'aforament. Ara SI que es fiable: ningu mes toca la fila.
    //    Llancar aqui reverteix la transaccio sencera automaticament.
    const lliures = sessio.aforament - sessio.venudes;
    if (lliures < quantitat) {
      throw new ConflicteDEstat('No queden entrades suficients', {
        codi: 'AFORAMENT_INSUFICIENT', detalls: { lliures, sollicitades: quantitat } });
    }
    // 3. Reserva de l'aforament. La CHECK del motor es l'ultima xarxa de seguretat.
    sessio.venudes += quantitat;
    await sessio.save({ transaction: t });
    // 4. Comanda.
    const comanda = await Comanda.create({
      usuariId, sessioIdRef: sessio.id, quantitat, canal,
      totalCentims: sessio.preuCentims * quantitat, estat: 'pagat',
    }, { transaction: t });
    // 5. Entrades. El sequencial surt d'una sequencia de la BASE, no d'un
    //    comptador en memoria: dos processos Node mai no generen el mateix codi.
    const [{ seguent }] = await sequelize.query("SELECT nextval('entrades_codi_seq') AS seguent",
      { type: sequelize.QueryTypes.SELECT, transaction: t });
    const any = new Date().getUTCFullYear();
    const entrades = await Entrada.bulkCreate(
      Array.from({ length: quantitat }, (unused, i) => ({
        codi: generarCodi(any, Number(seguent) + i),
        comandaId: comanda.id, sessioIdRef: sessio.id, estat: 'valida',
      })), { transaction: t, validate: true });
    // 6. Estat final. En retornar sense llancar, Sequelize confirma; si alguna
    //    cosa hagues fallat en qualsevol punt, la base hauria quedat igual.
    comanda.estat = 'emes';
    await comanda.save({ transaction: t });
    return { comanda, entrades, lliuresRestants: sessio.aforament - sessio.venudes };
  });
}
module.exports = { comprarEntrades };

Torna a l'escenari de Lucía i Marc amb aquest codi. Lucía entra, bloca la fila, veu 1 lliure, ven i confirma. Marc esperava al pas 1; es desperta, llegeix venudes = 400, veu 0 lliures i rep un 409 AFORAMENT_INSUFICIENT net, amb el seu codi d'error i el seu missatge. No hi ha sobrevenda. No hi ha estat a mitges. No hi ha una entrada emesa sense comanda ni una comanda sense entrades. El problema que vam obrir al mòdul 4 està tancat. A MongoDB es resol sense transacció, aprofitant que una actualització d'un sol document sí que és atòmica: la clau és ficar la condició dins del filtre.

/**
 * Reserva atomica: la comprovacio de l'aforament forma part del FILTRE, aixi que
 * la condicio i l'escriptura son una sola operacio indivisible per al motor.
 */
async function reservarAforament(sessioId, quantitat) {
  const document = await Esdeveniment.findOneAndUpdate(
    { 'sessions.sessioId': sessioId,
      sessions: { $elemMatch: { sessioId,
        $expr: { $lte: ['$venudes', { $subtract: ['$aforament', quantitat] }] } } } },
    { $inc: { 'sessions.$.venudes': quantitat } },
    { new: true },
  );
  // Si no casa res: o la sessio no existeix, o no hi havia aforament. No s'ha
  // escrit RES, aixi que no hi ha res a revertir.
  if (!document) {
    throw new ConflicteDEstat('No queden entrades suficients',
      { codi: 'AFORAMENT_INSUFICIENT', detalls: { sessioId, sollicitades: quantitat } });
  }
  return document;
}

Per què funciona: el filtre venudes <= aforament - quantitat i el $inc s'avaluen i s'apliquen sota el blocatge de document del mateix motor. Si Marc arriba després de Lucía, el seu filtre no casa i findOneAndUpdate retorna null sense escriure. És una operació de comparar-i-intercanviar, la mateixa idea que un compare-and-swap de programació concurrent: ràpida, sense transacció i sense requisits d'infraestructura. El seu límit: només cobreix un document, i la comanda i les entrades viuen en altres col·leccions. Per a això existeixen les sessions multidocument, que s'obren amb mongoose.startSession() i es fan servir amb sessioMongo.withTransaction(async () => { ... }) —que confirma en acabar, reverteix si llança i a més reintenta els errors transitoris—, propagant { session } a totes les operacions, igual que { transaction: t } a Sequelize. I un requisit operatiu que sorprèn: les transaccions de MongoDB exigeixen un conjunt de rèpliques; un mongod solt al teu portàtil no les admet i cal arrencar-lo amb --replSet, encara que sigui d'un sol node (a Atlas vénen de sèrie). A més tenen cost: mantenen instantànies, caduquen als 60 segons per defecte i poden avortar per conflicte d'escriptura. El criteri: a MongoDB fes servir l'actualització atòmica condicional sempre que el problema càpiga en un document —el nostre cas per a l'aforament, gràcies a haver incrustat les sessions— i reserva les transaccions multidocument per quan el canvi abasti diverses col·leccions de manera innegociable.

Interblocatges, reintents i què no ficar-hi dins

Un interblocatge passa quan dues transaccions s'esperen mútuament: Lucía bloca la sessió A i vol la B; Marc bloca la B i vol l'A. PostgreSQL ho detecta i n'avorta una amb l'error 40P01. S'evita blocant sempre en el mateix ordre (per exemple, sessions ordenades per identificador ascendent, cosa que fa el cicle impossible), amb transaccions curtes i blocant el mínim. I quan tot i així passi, es reintenta amb espera creixent:

/** Reintenta davant d'errors TRANSITORIS (interblocatge, fallada de serialitzacio). */
async function ambReintents(operacio, { intents = 3, esperaBase = 50 } = {}) {
  for (let intent = 1; intent <= intents; intent += 1) {
    try { return await operacio(); } catch (error) {
      // Nomes aquests codis son transitoris; qualsevol altre es propaga tal qual.
      if (!['40001', '40P01'].includes(error.parent?.code) || intent === intents) throw error;
      // Espera exponencial amb aleatorietat, per no reintentar tots alhora.
      const espera = esperaBase * 2 ** (intent - 1) + Math.random() * 25;
      await new Promise((resoldre) => setTimeout(resoldre, espera));
    }
  }
}

Fixa't en el matís: només es reintenten errors transitoris. Un AFORAMENT_INSUFICIENT no es reintenta mai; no és una fallada temporal, és una resposta. I hi ha coses que mai no han d'entrar en una transacció. Les crides HTTP a serveis externs, perquè un servei lent manté els blocatges oberts durant segons, el pool s'exhaureix i l'aplicació sencera s'atura. L'enviament de correus, pel mateix i perquè a més no és reversible: si la transacció reverteix, el correu ja ha sortit. Els cobraments a la passarel·la de pagament, que no es desfan amb un ROLLBACK: es cobra abans, o es compensa després. L'escriptura de fitxers i l'emissió d'esdeveniments del GestorDeVendes, que no participen en la transacció i farien actuar els subscriptors sobre dades que potser es reverteixen. I els bucles llargs o càlculs pesants, que allarguen la transacció i la contenció. La regla: a dins, només operacions de base de dades i les mínimes. Tota la resta va abans (si ha de condicionar la compra) o després (si ha de reaccionar-hi). A Escena Viva el cobrament s'autoritza abans, la transacció registra la venda, i el correu de confirmació i l'esdeveniment venda-registrada s'emeten després de confirmar. Quan aquest "després" hagi de ser fiable —reintents, ordre, lliurament garantit— es converteix en una cua de treballs, que és el que veurem amb Redis al mòdul 10.

Errors Comuns i Consells

  • Editar una migració ja aplicada. No arribarà als entorns que ja la van executar. Corregeix amb una migració nova.
  • Oblidar { transaction: t } en una consulta, que s'executa fora i no es reverteix, o deixar una transacció no gestionada sense rollback al catch, que reté una connexió del pool fins que expiri; amb prou casos, l'aplicació es penja.
  • Confiar només en la comprovació prèvia. Entre llegir i escriure hi ha una finestra, sempre. Blocatge o filtre atòmic.
  • Ficar una crida HTTP dins de la transacció, o reintentar errors de negoci: el primer exhaureix el pool al primer pic de trànsit i el segon no arregla res, perquè AFORAMENT_INSUFICIENT no millora reintentant.
  • Creure que MongoDB no necessita migracions. L'esquema existeix igualment; només que viu al teu codi i en documents que ja no el compleixen.
  • Consell: prova la concurrència de debò —50 compres simultànies contra una sessió amb 10 entrades— i mesura la durada de les teves transaccions: qualsevol que superi els 100 ms mereix revisió. Sense aquesta prova no saps si la teva solució funciona: et penses que funciona.

Exercicis

Exercici 1: devolució transaccional

Implementa anullarCompra(comandaId) amb Sequelize de forma transaccional: bloca la comanda i la seva sessió, comprova que l'estat permet anul·lar (pagat o emes), marca les entrades com a anullades, retorna l'aforament i marca la comanda com a anullat. Justifica en quin ordre bloques.

Exercici 2: provar la concurrència

Escriu un script que posi una sessió de prova amb aforament 10 i venudes: 0, llanci 50 crides simultànies a comprarEntrades d'1 entrada amb Promise.allSettled, i verifiqui que exactament 10 es resolen, 40 es rebutgen amb AFORAMENT_INSUFICIENT i venudes acaba valent 10.

Solucions

Exercici 1. Es bloca primer la comanda i després la sessió; mantenir sempre aquest mateix ordre en totes les transaccions que toquin les dues taules és precisament el que fa impossible un interblocatge.

async function anullarCompra(comandaId, motiu = "sollicitud del client") {
  return sequelize.transaction(async (t) => {
    const comanda = await Comanda.findByPk(comandaId, { transaction: t, lock: t.LOCK.UPDATE });
    if (!comanda) {
      throw new RecursNoTrobat(`No existeix la comanda ${comandaId}`, { codi: 'COMANDA_NO_TROBADA' });
    }
    if (!['pagat', 'emes'].includes(comanda.estat)) {
      throw new ConflicteDEstat(`Una comanda ${comanda.estat} no s'anulla`, { codi: 'ESTAT_INVALID' });
    }
    const sessio = await Sessio.findByPk(comanda.sessioIdRef, { transaction: t, lock: t.LOCK.UPDATE });
    await Entrada.update({ estat: 'anullada' },
      { where: { comandaId: comanda.id, estat: 'valida' }, transaction: t });
    sessio.venudes -= comanda.quantitat;
    await sessio.save({ transaction: t });
    Object.assign(comanda, { estat: 'anullat', anullatEl: new Date(), motiuAnullacio: motiu });
    await comanda.save({ transaction: t });
    return comanda;
  });
}

Exercici 2.

// scripts/prova-concurrencia.js
async function provar() {
  await Sessio.update({ venudes: 0, aforament: 10 }, { where: { sessioId: 'ses-999-1' } });
  const intents = Array.from({ length: 50 }, () =>
    comprarEntrades({ usuariId: 1, sessioId: 'ses-999-1', quantitat: 1, canal: 'web' }));
  const resultats = await Promise.allSettled(intents);
  const exits = resultats.filter((un) => un.status === 'fulfilled').length;
  const exhaurits = resultats.filter(
    (un) => un.status === 'rejected' && un.reason.codi === 'AFORAMENT_INSUFICIENT').length;
  const sessio = await Sessio.findOne({ where: { sessioId: 'ses-999-1' } });
  console.log(`exits: ${exits} (10), exhaurits: ${exhaurits} (40), venudes: ${sessio.venudes} (10)`);
  await sequelize.close();
}

Si alguna vegada de cada cent execucions dóna 11 èxits, tens una condició de carrera; executa'l diverses vegades, perquè les fallades de concurrència són intermitents per naturalesa i aquest és justament el motiu pel qual cal buscar-les a propòsit.

Conclusió

El mòdul 7 es tanca, i amb ell l'etapa del fitxer JSON. Vam començar entenent per què dades/esdeveniments.json havia deixat de servir i què aporta un SGBD; vam modelar Escena Viva amb MongoDB i Mongoose, amb les seves sessions incrustades, els seus validadors, els seus virtuals i els seus índexs; vam escriure el CRUD complet i vam jubilar src/cataleg-dades.js substituint-lo per src/repositoris/esdeveniments.js sense que els controladors se n'assabentessin; vam explorar relacions, populate, el problema N+1 i el framework d'agregació, que es va endur per davant els informes que fèiem llegint CSV; vam visitar el món relacional amb PostgreSQL i Sequelize, on una CHECK (venudes <= aforament) converteix una invariant en llei del motor i on include fa un JOIN de veritat; i avui hem versionat l'esquema amb migracions, carregat el catàleg amb una llavor idempotent —els 3 esdeveniments i les 7 sessions, aforament 3000, 1811 venudes— i resolt, per fi, el problema que arrossegàvem des del mòdul 4.

Perquè això és el més important: Escena Viva ja no sobrevèn. Ni amb SELECT ... FOR UPDATE dins d'una transacció a PostgreSQL, ni amb findOneAndUpdate i el seu filtre condicional a MongoDB. Dos motors, dues tècniques, una mateixa garantia: o la compra passa sencera, o no passa res. I malgrat tot, hi ha una cosa que t'hauria d'inquietar. Torna a mirar comprarEntrades. Rep un usuariId… d'on? Ara mateix, del que el client vulgui enviar. Qualsevol pot cridar l'API. Qualsevol pot comprar en nom de Lucía, consultar les comandes de Marc, publicar un esdeveniment al Teatro Almendra sense ser-ne l'organitzador o anul·lar les entrades d'un desconegut. No hi ha usuaris de veritat, no hi ha contrasenyes, no hi ha sessions, no hi ha permisos. El model Usuari fa temps que espera des de la lliçó 07-02 amb el seu camp rol —assistent, organitzador, administrador— sense que ningú l'utilitzi per a res.

Al mòdul 8 omplim aquest buit: autenticació i autorització. Registre d'usuaris i hash de contrasenyes fet com cal, sessions i galetes amb Passport, JSON Web Tokens, control d'accés basat en els rols que ja tenim modelats, i les bones pràctiques de seguretat que converteixen una API que funciona en una API en la qual es pot confiar. Les dades ja estan a resguard de la concurrència; toca posar-les a resguard dels desconeguts.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats