Llegamos al final del módulo y a la lección que salda la deuda más antigua del curso. Desde el módulo 4, cuando montamos el primer servidor node:http y vendimos la primera entrada, arrastramos un problema que la validación no resolvía, que los errores bien tipados no resolvían y que ni siquiera $inc resolvía del todo: dos personas comprando a la vez la última entrada disponible. Antes de atacarlo, dos piezas que todo proyecto con base de datos necesita y que casi nadie enseña a tiempo: migraciones, para que el esquema tenga historial como lo tiene el código, y semillas, para poblar la base en un comando. Con ellas jubilaremos por fin datos/eventos.json como fuente de verdad.

Contenido

  1. Migraciones: el esquema también es código
  2. sequelize-cli y la migración inicial de Escena Viva
  3. Migraciones sobre datos existentes, reglas de oro y el caso de MongoDB
  4. Semillas: npm run semilla
  5. Transacciones, ACID y el escenario de sobreventa
  6. Transacciones en Sequelize y niveles de aislamiento
  7. Bloqueo pesimista frente a optimista
  8. comprarEntradas transaccional y la solución en MongoDB
  9. Interbloqueos, reintentos y qué no meter dentro

Migraciones: el esquema también es código

Tu código tiene historial: cada cambio es un commit, con autor, fecha, motivo y la posibilidad de revertirlo. El esquema de tu base de datos, si lo creaste con sync() o a mano con psql, no tiene nada de eso. Nadie sabe cuándo se añadió aquella columna, ni por qué, ni cómo estaba antes. Y cuando un compañero clona el repositorio, su base de datos no se parece a la tuya. Una migración es un fichero versionado, guardado junto al código, que describe un cambio de esquema en dos direcciones: up lo aplica, down lo deshace. Se ejecutan en orden y la base guarda cuáles ya aplicó. Tres beneficios lo hacen innegociable: reproducibilidad (máquina nueva, base vacía, un comando), revisión (un cambio de esquema pasa por pull request como cualquier otro) y despliegue automatizado (el CI/CD del módulo 11 las ejecuta antes de arrancar la versión nueva).

sequelize-cli y la migración inicial de Escena Viva

Se instala con npm install --save-dev sequelize-cli y npx sequelize-cli init crea la estructura estándar: config/ (conexiones), migrations/, seeders/ y models/. El config/config.json generado no nos sirve, porque la configuración vive en src/config/index.js: lo sustituimos por un config/config.js que exporte un objeto con una clave por entorno (desarrollo, pruebas, produccion), cada una con { url: configuracion.postgresUrl, dialect: 'postgres' }. Una migración es un módulo con dos funciones asíncronas que reciben queryInterface —la API para manipular el esquema— y Sequelize para los tipos. Sequelize crea una tabla SequelizeMeta con una fila por migración aplicada: al ejecutar db:migrate compara los ficheros con esa tabla y aplica solo los que faltan, en orden alfabético — por eso los nombres empiezan por marca de tiempo.

queryInterface ofrece createTable/dropTable, addColumn/removeColumn/changeColumn/renameColumn, addIndex y addConstraint (para CHECK, UNIQUE y claves foráneas), bulkInsert/bulkUpdate/bulkDelete para mover datos dentro de la propia migración, y sequelize.query cuando lo anterior no llega.

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

module.exports = {
  async up(queryInterface, Sequelize) {
    // Todo dentro de una transaccion: si algo falla, la base queda como estaba.
    await queryInterface.sequelize.transaction(async (t) => {
      await queryInterface.createTable('eventos', {
        id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
        evento_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
        titulo: { type: Sequelize.STRING(160), allowNull: false },
        sala: { type: Sequelize.STRING(120), allowNull: false },
        organizador_id: { type: Sequelize.STRING(40), allowNull: false },
        categoria: { type: Sequelize.STRING(60), allowNull: false },
        duracion_minutos: { type: Sequelize.INTEGER, allowNull: false },
        estado: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'borrador' },
      }, { transaction: t });
      await queryInterface.createTable('sesiones', {
        id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
        sesion_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
        evento_id_ref: { type: Sequelize.INTEGER, allowNull: false,
          references: { model: 'eventos', key: 'id' }, onDelete: 'CASCADE' },
        fecha_hora: { type: Sequelize.DATE, allowNull: false },
        aforo: { type: Sequelize.INTEGER, allowNull: false },
        precio_centimos: { type: Sequelize.INTEGER, allowNull: false },
        vendidas: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
      }, { transaction: t });
      // La restriccion clave del curso, declarada explicitamente.
      await queryInterface.addConstraint('sesiones', {
        fields: ['vendidas', 'aforo'], type: 'check', name: 'aforo_no_superado',
        where: { vendidas: { [Sequelize.Op.lte]: Sequelize.col('aforo') } }, transaction: t });
      await queryInterface.addIndex('eventos', ['estado', 'sala'], { transaction: t });
      // ...y del mismo modo usuarios, pedidos y entradas.
    });
  },
  // Orden INVERSO en el down: primero las tablas que dependen de otras.
  async down(queryInterface) {
    await queryInterface.dropTable('sesiones');
    await queryInterface.dropTable('eventos');
  },
};

Migraciones sobre datos existentes y reglas de oro

Los comandos son npx sequelize-cli db:migrate (aplica las pendientes), db:migrate:status (cuáles están aplicadas) y db:migrate:undo (revierte la última). Semanas después, negocio pide clasificar eventos por idioma. La tabla ya tiene datos: no puedes añadir una columna NOT NULL sin más, porque las filas existentes no tendrían valor y el motor rechazaría el cambio. El patrón correcto tiene tres pasos.

// migrations/20260901120000-anadir-idioma-a-eventos.js
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.sequelize.transaction(async (t) => {
      // 1. Anadir PERMITIENDO nulos: las filas existentes quedan a null.
      await queryInterface.addColumn('eventos', 'idioma', { type: Sequelize.STRING(5) }, { transaction: t });
      // 2. Rellenar las filas existentes con un valor sensato.
      await queryInterface.sequelize.query(
        `UPDATE eventos SET idioma = 'es' WHERE idioma IS NULL`, { transaction: t });
      // 3. Ahora que ninguna fila es nula, endurecer la restriccion.
      await queryInterface.changeColumn('eventos', 'idioma',
        { type: Sequelize.STRING(5), allowNull: false, defaultValue: 'es' }, { transaction: t });
    });
  },
  down: (queryInterface) => queryInterface.removeColumn('eventos', 'idioma'),
};

Esta secuencia —añadir permisiva, rellenar, endurecer— es la receta universal para columnas obligatorias sobre tablas pobladas. Memorízala, y con ella las reglas de oro:

  1. Nunca edites una migración ya aplicada. En tu máquina la editarías y la reejecutarías, pero en producción ya está en SequelizeMeta y no se reejecutará, así que tu cambio nunca llegará allí. Corregir se hace con una migración nueva.
  2. Toda migración debe ser reversible. Escribe el down y pruébalo: un down que no funciona es un despliegue del que no se puede volver a las tres de la mañana.
  3. Cambios compatibles hacia atrás. En un despliegue sin parada (módulo 11) conviven durante minutos la versión antigua y la nueva contra la misma base. Si tu migración renombra una columna, la antigua se rompe al instante. La técnica se llama expand and contract: añadir la columna nueva nulable mientras el código nuevo escribe en ambas, copiar los datos, desplegar código que solo usa la nueva y, por último, borrar la antigua.
  4. Datos y esquema, separados si son grandes, y prueba sobre una copia de producción. Un UPDATE sobre diez millones de filas dentro de una migración bloquea la tabla y deja el despliegue colgado; para eso, un script aparte por lotes. Y los problemas solo aparecen con volumen, nunca en tu base vacía de tres filas.

Migraciones en MongoDB

"MongoDB no tiene esquema, luego no necesita migraciones." Es falso, y es una de las confusiones más caras del sector. La ausencia de esquema en el motor no elimina el esquema: lo traslada a tu código. Si añades idioma con required: true al esquema de Mongoose, los cinco millones de documentos existentes no lo tienen y cualquier save() sobre ellos fallará. Si renombras precio a precioCentimos, los documentos viejos siguen con el nombre antiguo y tus consultas devolverán vacío sin dar ningún error.

// migrations/20260901120000-anadir-idioma.js (con migrate-mongo)
module.exports = {
  async up(db) {
    await db.collection('eventos').updateMany({ idioma: { $exists: false } }, { $set: { idioma: 'es' } });
    // Los indices de produccion se crean aqui, no con autoIndex al arrancar.
    await db.collection('eventos').createIndex({ estado: 1, sala: 1, titulo: 1 });
  },
  down: (db) => db.collection('eventos').updateMany({}, { $unset: { idioma: '' } }),
};

Diferencia real: en SQL la migración cambia la estructura y los datos se adaptan; en MongoDB la migración es una transformación de datos. Pero la disciplina —fichero versionado, up/down, registro de aplicadas, revisión en pull request— es idéntica.

Semillas: npm run semilla

Una semilla carga los datos iniciales. Nuestro caso es entrañable: los 3 eventos y las 7 sesiones de datos/eventos.json, el fichero que nos acompaña desde el módulo 3, se convierten en la carga inicial de la base de datos. Deja de ser el almacén y pasa a ser la semilla. Se invoca con npm run semilla y, con --reiniciar, borra antes de cargar (semilla:limpia).

// scripts/semilla.js (requires omitidos por brevedad)
'use strict';

const RUTA_SEMILLA = path.join(__dirname, '..', 'datos', 'eventos.json');
const USUARIOS_DEMO = [
  { email: '[email protected]', nombre: 'Lucia Serrano', rol: 'asistente' },
  { email: '[email protected]', nombre: 'Marc Oliveras', rol: 'organizador' },
  { email: '[email protected]', nombre: 'Administracion', rol: 'administrador' },
];
/**
 * Carga el catalogo. Es IDEMPOTENTE: ejecutarlo dos veces deja el mismo estado
 * que ejecutarlo una vez. Se consigue con upsert sobre la clave de negocio.
 */
async function sembrar({ reiniciar = false } = {}) {
  await conectar();
  if (reiniciar) await Promise.all([Evento.deleteMany({}), Usuario.deleteMany({})]);
  const { eventos } = JSON.parse(await readFile(RUTA_SEMILLA, 'utf8'));
  let insertados = 0;

  for (const { id, sesiones, ...datos } of eventos) {
    const resultado = await Evento.updateOne(
      { eventoId: id },   // clave de negocio: evt-001, evt-002, evt-003
      { $set: { ...datos, estado: datos.estado ?? 'publicado',
        sesiones: sesiones.map((sesion) => ({
          sesionId: sesion.id, fechaHora: new Date(sesion.fechaHora), aforo: sesion.aforo,
          vendidas: sesion.vendidas, precioCentimos: sesion.precioCentimos,
        })) } },
      { upsert: true, runValidators: true }, // crea si no existe, actualiza si existe
    );
    if (resultado.upsertedCount > 0) insertados += 1;
  }
  for (const usuario of USUARIOS_DEMO) {
    await Usuario.updateOne({ email: usuario.email }, { $set: usuario }, { upsert: true });
  }
  // Comprobacion de la carga: debe dar 7 sesiones, aforo 3000, vendidas 1811.
  const [total] = await Evento.aggregate([{ $unwind: '$sesiones' },
    { $group: { _id: null, sesiones: { $sum: 1 },
      aforo: { $sum: '$sesiones.aforo' }, vendidas: { $sum: '$sesiones.vendidas' } } }]);
  console.log(`[semilla] nuevos: ${insertados}; sesiones: ${total.sesiones}, ` +
    `aforo: ${total.aforo}, vendidas: ${total.vendidas}`);
  await desconectar();
}

// Ejecutable como script (npm run semilla) e importable desde las pruebas.
if (require.main === module) {
  sembrar({ reiniciar: process.argv.includes('--reiniciar') })
    .catch((error) => { console.error('[semilla]', error.message); process.exit(1); });
}
module.exports = { sembrar };

La idempotencia separa una semilla profesional de un script de usar y tirar: se consigue con upsert sobre la clave de negocio, nunca con insert. Puedes ejecutarla en cada arranque de desarrollo, tras cada migración y antes de cada demo sin miedo a duplicar nada. Y su valor va más allá del desarrollo: en el módulo 9, cuando escribamos pruebas, una semilla determinista es lo que permite afirmar "tras vender 2 entradas de ses-001-1, quedan 86", porque el estado de partida es conocido y reproducible. Combinada con una base de datos en memoria o efímera por suite, hace que las pruebas de integración sean rápidas y aisladas. Lo desarrollaremos allí; aquí basta con dejar la herramienta lista.

Transacciones, ACID y el escenario de sobreventa

Una transacción es un conjunto de operaciones que el motor trata como una sola. Aplicado a alguien que compra dos entradas: atomicidad significa que reservar aforo, crear el pedido y emitir dos entradas ocurre todo o nada, sin estados intermedios visibles —si falla la segunda entrada, el aforo vuelve solo y el pedido no existe—; consistencia, que al confirmar siguen cumpliéndose todas las restricciones (la CHECK (vendidas <= aforo), las claves foráneas, los NOT NULL) y que si el resultado violara alguna el motor revierte todo; aislamiento, que mientras tu transacción está en curso otra no ve tus cambios a medias; y durabilidad, que cuando el motor dice "confirmado" está en disco y un corte de luz un milisegundo después no lo deshace. Sin transacciones, cada operación es atómica por separado, pero el conjunto no lo es: ese es exactamente el problema anotado en mayúsculas dentro de crearPedido en la lección 07-03. Veámoslo: ses-001-1 tiene aforo 400 y 399 vendidas, queda una entrada, y Lucía y Marc pulsan "comprar" en el mismo segundo.

sequenceDiagram
    participant L as Peticion Lucia
    participant BD as Base de datos<br/>ses-001-1
    participant M as Peticion Marc
    Note over BD: aforo 400 / vendidas 399<br/>queda 1 entrada
    L->>BD: 1. leer sesion
    BD-->>L: vendidas = 399, libres = 1
    M->>BD: 2. leer sesion
    BD-->>M: vendidas = 399, libres = 1
    Note over L,M: Las DOS comprobaciones pasan:<br/>1 libre >= 1 solicitada
    L->>BD: 3. escribir vendidas = 400
    BD-->>L: confirmado
    M->>BD: 4. escribir vendidas = 400
    BD-->>M: confirmado
    L->>BD: 5. crear pedido + entrada EV-2026-000431
    M->>BD: 6. crear pedido + entrada EV-2026-000432
    Note over BD: SOBREVENTA:<br/>401 entradas para 400 butacas

Analicemos por qué fallan las defensas que ya tenemos. La comprobación previa no basta: entre el paso 2 y el paso 4 hay una ventana, y el estado que Marc leyó ya no es el real cuando escribe. Cualquier lógica del tipo "leo, decido, escribo" tiene esa ventana, y en Node es especialmente fácil de abrir, porque cada await cede el control al bucle de eventos, que atiende a Marc justo en ese hueco. Hacer la ventana más pequeña no la elimina: solo hace el fallo más raro y más difícil de reproducir, que es peor. $inc tampoco basta: resuelve la pérdida de actualizaciones —si ambos suman 1, el resultado es 401 y no 400, porque cada suma se aplica sobre el valor real—, pero suma incondicionalmente, sin saber que 401 supera el aforo; hemos cambiado un dato incorrecto por un dato correcto que denuncia una sobreventa ya cometida. Y la validación del esquema tampoco: el validador de Mongoose se ejecuta al guardar un documento completo, y en un updateOne con $inc no tiene acceso al resultado ni al aforo; aunque lo tuviera, se ejecuta en tu proceso, así que dos procesos Node validarían por su cuenta y ambos aprobarían.

La solución debe cumplir una condición: la comprobación y la escritura han de ser una sola operación indivisible para el motor, y hay dos formas de conseguirlo.

Transacciones en Sequelize y niveles de aislamiento

// GESTIONADA (recomendada): confirma al terminar sin errores, revierte si lanza.
const pedido = await sequelize.transaction(async (t) => {
  const sesion = await Sesion.findOne({ where: { sesionId }, transaction: t });
  await sesion.increment('vendidas', { by: cantidad, transaction: t });
  return Pedido.create({ /* ... */ }, { transaction: t });
});

// NO GESTIONADA: control manual. Si olvidas el rollback, la transaccion queda
// abierta y retiene una conexion del pool hasta que expire.
const t = await sequelize.transaction();
try {
  await Sesion.increment('vendidas', { by: cantidad, where: { sesionId }, transaction: t });
  await t.commit();
} catch (error) {
  await t.rollback(); throw error;
}

Usa la gestionada por defecto; la no gestionada, solo si necesitas puntos de guardado o lógica condicional compleja. El detalle que se olvida siempre: hay que propagar { transaction: t } a cada una de las consultas. Una consulta sin t se ejecuta fuera de la transacción, en otra conexión, y no verá los cambios pendientes ni se revertirá con ellos. Es el bug más común y el más silencioso: todo parece funcionar hasta que algo falla y descubres datos a medias.

El aislamiento perfecto sería ejecutar las transacciones de una en una, pero eso destruiría el rendimiento. Los niveles permiten elegir cuánta corrección pagas en concurrencia.

Nivel Lectura sucia Lectura no repetible Lectura fantasma Coste
Lectura no confirmada Posible Posible Posible Mínimo
Lectura confirmada Evitada Posible Posible Bajo — por defecto en PostgreSQL
Lectura repetible Evitada Evitada Evitada en Postgres Medio
Serializable Evitada Evitada Evitada Alto: puede abortar transacciones

Las tres anomalías con nuestro ejemplo. Lectura sucia: lees vendidas = 400 de una transacción que aún no ha confirmado y que acabará revirtiéndose; decides sobre un dato que nunca existió. Lectura no repetible: lees 399, otra transacción confirma, vuelves a leer y ahora vale 400 — dos lecturas del mismo dato en la misma transacción dan resultados distintos, y es la anomalía que causa nuestra sobreventa. Lectura fantasma: cuentas 5 entradas, otra transacción inserta una y al recontar hay 6. Con { isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE } PostgreSQL detecta el conflicto y aborta una de las dos transacciones con un error de serialización: es correcto, pero traslada el trabajo a tu código, que debe reintentar. Para la venta de entradas hay una solución más simple y barata.

Bloqueo pesimista frente a optimista

Pesimista: asumo que habrá conflicto y bloqueo la fila antes de tocarla. SELECT ... FOR UPDATE la reserva, y cualquier otra transacción que quiera bloquearla espera hasta que yo confirme o revierta.

BEGIN;
-- Bloquea esta fila: Marc esperara aqui hasta que Lucia termine.
SELECT vendidas, aforo FROM sesiones WHERE sesion_id = 'ses-001-1' FOR UPDATE;
UPDATE sesiones SET vendidas = vendidas + 1 WHERE sesion_id = 'ses-001-1';
COMMIT;

En Sequelize se pide con lock: t.LOCK.UPDATE. Cuando Marc despierta, lee el valor actualizado, 400, comprueba que no quedan libres y falla limpiamente con un 409 AFORO_INSUFICIENTE. La ventana ha desaparecido. Optimista: asumo que el conflicto es raro, no bloqueo nada, y detecto al escribir si alguien se adelantó, mediante una columna version que se incrementa en cada cambio; el UPDATE lleva la versión leída en el where, y si no afecta a ninguna fila es que otro se adelantó y hay que reintentar desde cero.

// El UPDATE solo afecta a filas cuya version siga siendo la que lei.
const [afectadas] = await Sesion.update(
  { vendidas: nuevasVendidas, version: version + 1 },
  { where: { sesionId, version }, transaction: t });
if (afectadas === 0) {
  throw new ConflictoDeEstado('La sesion cambio mientras comprabas', { codigo: 'ESTADO_INVALIDO' });
}
Pesimista (FOR UPDATE) Optimista (version)
Coste sin conflicto Un bloqueo, algo de espera Ninguno
Coste con conflicto Espera, resolución garantizada Reintento completo desde cero
Riesgo Interbloqueos, contención Reintentos en cascada bajo alta contención
Ideal para Conflictos frecuentes sobre pocas filas Conflictos raros

Cuál encaja en la venta de entradas. El pesimista, sin duda. La venta tiene un patrón muy característico: cuando salen las entradas de un concierto esperado, miles de personas compiten por la misma fila durante pocos minutos. Con bloqueo optimista la mayoría de intentos fallarían y reintentarían, generando más carga justo en el peor momento y una experiencia pésima. Con bloqueo pesimista las peticiones se serializan sobre esa fila y cada una obtiene respuesta definitiva a la primera: o tienes entrada, o no queda. Además la transacción es cortísima, así que la contención dura milisegundos. El optimista sería la elección correcta para editar la ficha de un evento, donde dos organizadores coinciden una vez al mes.

comprarEntradas transaccional y la solución en MongoDB

La versión definitiva. Compárala con la de la lección 07-03, comentario en mayúsculas incluido.

// src/repositorios/compras-sql.js (requires omitidos por brevedad)
'use strict';

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

/**
 * Compra atomica: reserva el aforo, crea el pedido y emite las entradas.
 * O las tres cosas, o ninguna. No hay estado intermedio posible.
 */
async function comprarEntradas({ usuarioId, sesionId, cantidad, canal }) {
  return sequelize.transaction(async (t) => {
    // 1. Bloqueo pesimista sobre la fila. Cualquier otra compra de esta misma
    //    sesion espera aqui hasta que confirmemos o revirtamos.
    const sesion = await Sesion.findOne({
      where: { sesionId }, transaction: t, lock: t.LOCK.UPDATE });
    if (!sesion) {
      throw new RecursoNoEncontrado(`No existe ${sesionId}`, { codigo: 'SESION_NO_ENCONTRADA' });
    }
    // 2. Comprobacion del aforo. Ahora SI es fiable: nadie mas toca la fila.
    //    Lanzar aqui revierte la transaccion entera automaticamente.
    const libres = sesion.aforo - sesion.vendidas;
    if (libres < cantidad) {
      throw new ConflictoDeEstado('No quedan entradas suficientes', {
        codigo: 'AFORO_INSUFICIENTE', detalles: { libres, solicitadas: cantidad } });
    }
    // 3. Reserva del aforo. La CHECK del motor es la ultima red de seguridad.
    sesion.vendidas += cantidad;
    await sesion.save({ transaction: t });
    // 4. Pedido.
    const pedido = await Pedido.create({
      usuarioId, sesionIdRef: sesion.id, cantidad, canal,
      totalCentimos: sesion.precioCentimos * cantidad, estado: 'pagado',
    }, { transaction: t });
    // 5. Entradas. El secuencial sale de una secuencia de la BASE, no de un
    //    contador en memoria: dos procesos Node nunca generan el mismo codigo.
    const [{ siguiente }] = await sequelize.query("SELECT nextval('entradas_codigo_seq') AS siguiente",
      { type: sequelize.QueryTypes.SELECT, transaction: t });
    const anio = new Date().getUTCFullYear();
    const entradas = await Entrada.bulkCreate(
      Array.from({ length: cantidad }, (unused, i) => ({
        codigo: generarCodigo(anio, Number(siguiente) + i),
        pedidoId: pedido.id, sesionIdRef: sesion.id, estado: 'valida',
      })), { transaction: t, validate: true });
    // 6. Estado final. Al retornar sin lanzar, Sequelize confirma; si algo
    //    hubiera fallado en cualquier punto, la base habria quedado igual.
    pedido.estado = 'emitido';
    await pedido.save({ transaction: t });
    return { pedido, entradas, libresRestantes: sesion.aforo - sesion.vendidas };
  });
}
module.exports = { comprarEntradas };

Vuelve al escenario de Lucía y Marc con este código. Lucía entra, bloquea la fila, ve 1 libre, vende y confirma. Marc estaba esperando en el paso 1; despierta, lee vendidas = 400, ve 0 libres y recibe un 409 AFORO_INSUFICIENTE limpio, con su código de error y su mensaje. No hay sobreventa. No hay estado a medias. No hay una entrada emitida sin pedido ni un pedido sin entradas. El problema que abrimos en el módulo 4 está cerrado. En MongoDB se resuelve sin transacción, aprovechando que una actualización de un solo documento sí es atómica: la clave es meter la condición dentro del filtro.

/**
 * Reserva atomica: la comprobacion del aforo forma parte del FILTRO, asi que la
 * condicion y la escritura son una sola operacion indivisible para el motor.
 */
async function reservarAforo(sesionId, cantidad) {
  const documento = await Evento.findOneAndUpdate(
    { 'sesiones.sesionId': sesionId,
      sesiones: { $elemMatch: { sesionId,
        $expr: { $lte: ['$vendidas', { $subtract: ['$aforo', cantidad] }] } } } },
    { $inc: { 'sesiones.$.vendidas': cantidad } },
    { new: true },
  );
  // Si no casa nada: o la sesion no existe, o no habia aforo. No se ha escrito
  // NADA, asi que no hay nada que revertir.
  if (!documento) {
    throw new ConflictoDeEstado('No quedan entradas suficientes',
      { codigo: 'AFORO_INSUFICIENTE', detalles: { sesionId, solicitadas: cantidad } });
  }
  return documento;
}

Por qué funciona: el filtro vendidas <= aforo - cantidad y el $inc se evalúan y aplican bajo el bloqueo de documento del propio motor. Si Marc llega después de Lucía, su filtro no casa y findOneAndUpdate devuelve null sin escribir. Es una operación de comparar-e-intercambiar, la misma idea que un compare-and-swap de programación concurrente: rápida, sin transacción y sin requisitos de infraestructura. Su límite: solo cubre un documento, y el pedido y las entradas viven en otras colecciones. Para eso están las sesiones multidocumento, que se abren con mongoose.startSession() y se usan con sesionMongo.withTransaction(async () => { ... }) —que confirma al terminar, revierte si lanza y además reintenta los errores transitorios—, propagando { session } a todas las operaciones, igual que { transaction: t } en Sequelize. Y un requisito operativo que sorprende: las transacciones de MongoDB exigen un conjunto de réplicas; un mongod suelto en tu portátil no las soporta y hay que arrancarlo con --replSet, aunque sea de un solo nodo (en Atlas vienen de serie). Además tienen coste: mantienen instantáneas, caducan a los 60 segundos por defecto y pueden abortar por conflicto de escritura. El criterio: en MongoDB usa la actualización atómica condicional siempre que el problema quepa en un documento —nuestro caso para el aforo, gracias a haber incrustado las sesiones— y reserva las transacciones multidocumento para cuando el cambio abarque varias colecciones de forma innegociable.

Interbloqueos, reintentos y qué no meter dentro

Un interbloqueo ocurre cuando dos transacciones se esperan mutuamente: Lucía bloquea la sesión A y quiere la B; Marc bloquea la B y quiere la A. PostgreSQL lo detecta y aborta una con el error 40P01. Se evita bloqueando siempre en el mismo orden (por ejemplo, sesiones ordenadas por identificador ascendente, lo que hace el ciclo imposible), con transacciones cortas y bloqueando lo mínimo. Y cuando aun así ocurra, se reintenta con espera creciente:

/** Reintenta ante errores TRANSITORIOS (interbloqueo, fallo de serializacion). */
async function conReintentos(operacion, { intentos = 3, esperaBase = 50 } = {}) {
  for (let intento = 1; intento <= intentos; intento += 1) {
    try { return await operacion(); } catch (error) {
      // Solo estos codigos son transitorios; cualquier otro se propaga tal cual.
      if (!['40001', '40P01'].includes(error.parent?.code) || intento === intentos) throw error;
      // Espera exponencial con aleatoriedad, para no reintentar todos a la vez.
      const espera = esperaBase * 2 ** (intento - 1) + Math.random() * 25;
      await new Promise((resolver) => setTimeout(resolver, espera));
    }
  }
}

Fíjate en el matiz: solo se reintentan errores transitorios. Un AFORO_INSUFICIENTE no se reintenta jamás; no es un fallo temporal, es una respuesta. Y hay cosas que nunca deben entrar en una transacción. Las llamadas HTTP a servicios externos, porque un servicio lento mantiene los bloqueos abiertos durante segundos, el pool se agota y la aplicación entera se para. El envío de correos, por lo mismo y porque además no es reversible: si la transacción revierte, el correo ya salió. Los cobros a la pasarela de pago, que no se deshacen con un ROLLBACK: se cobra antes, o se compensa después. La escritura de ficheros y la emisión de eventos del GestorDeVentas, que no participan en la transacción y harían actuar a los suscriptores sobre datos que quizá se reviertan. Y los bucles largos o cálculos pesados, que alargan la transacción y la contención. La regla: dentro, solo operaciones de base de datos y las mínimas. Todo lo demás va antes (si debe condicionar la compra) o después (si debe reaccionar a ella). En Escena Viva el cobro se autoriza antes, la transacción registra la venta, y el correo de confirmación y el evento venta-registrada se emiten después de confirmar. Cuando ese "después" deba ser fiable —reintentos, orden, entrega garantizada— se convierte en una cola de trabajos, que es lo que veremos con Redis en el módulo 10.

Errores Comunes y Consejos

  • Editar una migración ya aplicada. No llegará a los entornos que ya la ejecutaron. Corrige con una migración nueva.
  • Olvidar { transaction: t } en una consulta, que se ejecuta fuera y no se revierte, o dejar una transacción no gestionada sin rollback en el catch, que retiene una conexión del pool hasta que expire; con suficientes, la aplicación se cuelga.
  • Confiar solo en la comprobación previa. Entre leer y escribir hay una ventana, siempre. Bloqueo o filtro atómico.
  • Meter una llamada HTTP dentro de la transacción, o reintentar errores de negocio: lo primero agota el pool en el primer pico de tráfico y lo segundo no arregla nada, porque AFORO_INSUFICIENTE no mejora reintentando.
  • Creer que MongoDB no necesita migraciones. El esquema existe igual; solo que vive en tu código y en documentos que ya no lo cumplen.
  • Consejo: prueba la concurrencia de verdad —50 compras simultáneas contra una sesión con 10 entradas— y mide la duración de tus transacciones: cualquiera que supere los 100 ms merece revisión. Sin esa prueba no sabes si tu solución funciona: crees que funciona.

Ejercicios

Ejercicio 1: devolución transaccional

Implementa anularCompra(pedidoId) con Sequelize de forma transaccional: bloquea el pedido y su sesión, comprueba que el estado permite anular (pagado o emitido), marca las entradas como anuladas, devuelve el aforo y marca el pedido como anulado. Justifica en qué orden bloqueas.

Ejercicio 2: probar la concurrencia

Escribe un script que ponga una sesión de prueba con aforo 10 y vendidas: 0, lance 50 llamadas simultáneas a comprarEntradas de 1 entrada con Promise.allSettled, y verifique que exactamente 10 se resuelven, 40 se rechazan con AFORO_INSUFICIENTE y vendidas acaba valiendo 10.

Soluciones

Ejercicio 1. Se bloquea primero el pedido y después la sesión; mantener siempre ese mismo orden en todas las transacciones que toquen ambas tablas es precisamente lo que hace imposible un interbloqueo.

async function anularCompra(pedidoId, motivo = 'solicitud del cliente') {
  return sequelize.transaction(async (t) => {
    const pedido = await Pedido.findByPk(pedidoId, { transaction: t, lock: t.LOCK.UPDATE });
    if (!pedido) {
      throw new RecursoNoEncontrado(`No existe el pedido ${pedidoId}`, { codigo: 'PEDIDO_NO_ENCONTRADO' });
    }
    if (!['pagado', 'emitido'].includes(pedido.estado)) {
      throw new ConflictoDeEstado(`Un pedido ${pedido.estado} no se anula`, { codigo: 'ESTADO_INVALIDO' });
    }
    const sesion = await Sesion.findByPk(pedido.sesionIdRef, { transaction: t, lock: t.LOCK.UPDATE });
    await Entrada.update({ estado: 'anulada' },
      { where: { pedidoId: pedido.id, estado: 'valida' }, transaction: t });
    sesion.vendidas -= pedido.cantidad;
    await sesion.save({ transaction: t });
    Object.assign(pedido, { estado: 'anulado', anuladoEn: new Date(), motivoAnulacion: motivo });
    await pedido.save({ transaction: t });
    return pedido;
  });
}

Ejercicio 2.

// scripts/prueba-concurrencia.js
async function probar() {
  await Sesion.update({ vendidas: 0, aforo: 10 }, { where: { sesionId: 'ses-999-1' } });
  const intentos = Array.from({ length: 50 }, () =>
    comprarEntradas({ usuarioId: 1, sesionId: 'ses-999-1', cantidad: 1, canal: 'web' }));
  const resultados = await Promise.allSettled(intentos);
  const exitos = resultados.filter((uno) => uno.status === 'fulfilled').length;
  const agotados = resultados.filter(
    (uno) => uno.status === 'rejected' && uno.reason.codigo === 'AFORO_INSUFICIENTE').length;
  const sesion = await Sesion.findOne({ where: { sesionId: 'ses-999-1' } });
  console.log(`exitos: ${exitos} (10), agotados: ${agotados} (40), vendidas: ${sesion.vendidas} (10)`);
  await sequelize.close();
}

Si alguna vez de cada cien ejecuciones da 11 éxitos, tienes una condición de carrera; ejecútalo varias veces, porque los fallos de concurrencia son intermitentes por naturaleza y ese es justamente el motivo por el que hay que buscarlos a propósito.

Conclusión

El módulo 7 se cierra, y con él la etapa del fichero JSON. Empezamos entendiendo por qué datos/eventos.json había dejado de servir y qué aporta un SGBD; modelamos Escena Viva con MongoDB y Mongoose, con sus sesiones incrustadas, sus validadores, sus virtuals y sus índices; escribimos el CRUD completo y jubilamos src/catalogo-datos.js sustituyéndolo por src/repositorios/eventos.js sin que los controladores se enteraran; exploramos relaciones, populate, el problema N+1 y el framework de agregación, que se llevó por delante los informes que hacíamos leyendo CSV; visitamos el mundo relacional con PostgreSQL y Sequelize, donde una CHECK (vendidas <= aforo) convierte una invariante en ley del motor y donde include hace un JOIN de verdad; y hoy hemos versionado el esquema con migraciones, cargado el catálogo con una semilla idempotente —los 3 eventos y las 7 sesiones, aforo 3000, 1811 vendidas— y resuelto, por fin, el problema que arrastrábamos desde el módulo 4.

Porque eso es lo importante: Escena Viva ya no sobrevende. Ni con SELECT ... FOR UPDATE dentro de una transacción en PostgreSQL, ni con findOneAndUpdate y su filtro condicional en MongoDB. Dos motores, dos técnicas, una misma garantía: o la compra ocurre entera, o no ocurre nada. Y sin embargo, hay algo que debería inquietarte. Vuelve a mirar comprarEntradas. Recibe un usuarioId… ¿de dónde? Ahora mismo, de lo que el cliente quiera enviar. Cualquiera puede llamar a la API. Cualquiera puede comprar en nombre de Lucía, consultar los pedidos de Marc, publicar un evento en el Teatro Almendra sin ser su organizador o anular las entradas de un desconocido. No hay usuarios de verdad, no hay contraseñas, no hay sesiones, no hay permisos. El modelo Usuario lleva esperando desde la lección 07-02 con su campo rol —asistente, organizador, administrador— sin que nadie lo use para nada.

En el módulo 8 llenamos ese hueco: autenticación y autorización. Registro de usuarios y hash de contraseñas hecho como es debido, sesiones y cookies con Passport, JSON Web Tokens, control de acceso basado en los roles que ya tenemos modelados, y las buenas prácticas de seguridad que convierten una API que funciona en una API en la que se puede confiar. Los datos ya están a salvo de la concurrencia; toca ponerlos a salvo de los desconocidos.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados