Escena Viva funciona sobre MongoDB y funciona bien. Esta lección no viene a desmontarlo: viene a mostrar el otro mundo, el relacional, modelando exactamente el mismo dominio en PostgreSQL para que veas con tus propios ojos qué cambia. Un desarrollador de Node que solo conoce uno de los dos modelos tiene la mitad de las herramientas. Y hay un motivo adicional muy concreto: en el mundo relacional aparecen dos piezas que resuelven de raíz problemas que arrastramos. Una es la restricción CHECK, que convierte vendidas <= aforo en una ley del motor y no en una buena intención nuestra. La otra son las transacciones, que llegan en la lección siguiente. Al final tendrás una segunda implementación de src/repositorios/eventos.js sobre Sequelize, intercambiable con la de Mongoose sin tocar controladores ni dominio.

Contenido

  1. Lo relacional en diez minutos, y por qué aquí las sesiones sí son una tabla
  2. El esquema SQL de Escena Viva
  3. Instalar Sequelize, conectar y por qué sync() está prohibido
  4. Definir modelos y tipos de datos
  5. Validaciones de modelo frente a restricciones de base de datos
  6. Asociaciones y consultas con Sequelize
  7. SQL parametrizado y la inyección
  8. El repositorio alternativo sobre Sequelize
  9. Mongoose frente a Sequelize: el criterio

Lo relacional en diez minutos

Una base de datos relacional guarda tablas: conjuntos de filas con las mismas columnas, cada una de un tipo declarado. Sobre esa estructura tan simple se construye todo:

  • Clave primaria (PK). La columna que identifica unívocamente cada fila; normalmente un id autoincremental o un UUID.
  • Clave foránea (FK). Una columna que apunta a la PK de otra tabla. El motor garantiza que el destino existe: no puedes insertar un pedido con un usuario_id inexistente, ni borrar un usuario que tenga pedidos salvo que definas qué hacer. Es la integridad referencial, y MongoDB no la tiene.
  • NOT NULL, UNIQUE, DEFAULT. La columna debe tener valor, no se repite, o toma un valor por omisión.
  • CHECK. Una condición arbitraria que toda fila debe cumplir. Es la más infravalorada y la que más nos interesa.

La diferencia cultural profunda: aquí el esquema es obligatorio y lo hace cumplir el motor. No puedes insertar una fila con un campo extra ni con un tipo equivocado. Eso incomoda al principio y salva proyectos a los tres años.

Normalización: por qué aquí las sesiones sí son una tabla

Normalizar es organizar los datos para que cada hecho se guarde una sola vez. Las formas normales son un cuerpo teórico amplio, pero en la práctica se reducen a tres reglas: nada de valores múltiples en una celda (si hay varias sesiones, se crea una tabla); cada tabla describe una sola cosa; y nada derivado de algo que no sea la clave. En MongoDB incrustamos las sesiones dentro del evento porque el modelo documental lo permite y el patrón de acceso lo favorecía. En SQL, las sesiones son una tabla propia, y no es un capricho: es la única forma natural de tener una fila por sesión con su propia clave primaria, sus propias restricciones —CHECK (vendidas <= aforo) se aplica por fila, es decir, por sesión— y sus propias claves foráneas desde entradas y pedidos. Perdemos la lectura de un solo golpe (ahora un evento con sus sesiones son dos tablas y un JOIN) y ganamos integridad garantizada por el motor. Es exactamente el compromiso que describía la tabla de la lección 07-01, ahora con nombres propios.

El esquema SQL de Escena Viva

-- Usuarios. Sin contrasena: la autenticacion es el modulo 8.
CREATE TABLE usuarios (
  id     SERIAL PRIMARY KEY,
  email  VARCHAR(160) NOT NULL UNIQUE,
  nombre VARCHAR(120) NOT NULL,
  rol    VARCHAR(20)  NOT NULL DEFAULT 'asistente'
         CHECK (rol IN ('asistente', 'organizador', 'administrador')),
  creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Eventos. evento_id es el identificador de negocio (evt-001), estable y publico.
CREATE TABLE eventos (
  id               SERIAL PRIMARY KEY,
  evento_id        VARCHAR(20)  NOT NULL UNIQUE,
  titulo           VARCHAR(160) NOT NULL,
  sala             VARCHAR(120) NOT NULL,
  organizador_id   VARCHAR(40)  NOT NULL,
  categoria        VARCHAR(60)  NOT NULL,
  duracion_minutos INTEGER      NOT NULL CHECK (duracion_minutos BETWEEN 1 AND 600),
  estado           VARCHAR(20)  NOT NULL DEFAULT 'borrador'
                   CHECK (estado IN ('borrador', 'publicado', 'finalizado'))
);

-- Sesiones: aqui SI son una tabla, con su propia integridad por fila.
CREATE TABLE sesiones (
  id              SERIAL PRIMARY KEY,
  sesion_id       VARCHAR(20) NOT NULL UNIQUE,
  -- Si se borra el evento, se borran sus sesiones. Lo garantiza el motor.
  evento_id_ref   INTEGER     NOT NULL REFERENCES eventos(id) ON DELETE CASCADE,
  fecha_hora      TIMESTAMPTZ NOT NULL,
  aforo           INTEGER     NOT NULL CHECK (aforo > 0),
  vendidas        INTEGER     NOT NULL DEFAULT 0 CHECK (vendidas >= 0),
  precio_centimos INTEGER     NOT NULL CHECK (precio_centimos >= 0),
  -- LA restriccion del curso: el motor rechaza fisicamente la sobreventa.
  CONSTRAINT aforo_no_superado CHECK (vendidas <= aforo)
);

CREATE TABLE pedidos (
  id             SERIAL PRIMARY KEY,
  usuario_id     INTEGER     NOT NULL REFERENCES usuarios(id) ON DELETE RESTRICT,
  sesion_id_ref  INTEGER     NOT NULL REFERENCES sesiones(id) ON DELETE RESTRICT,
  cantidad       INTEGER     NOT NULL CHECK (cantidad BETWEEN 1 AND 6),
  total_centimos INTEGER     NOT NULL CHECK (total_centimos >= 0),
  canal          VARCHAR(20) NOT NULL CHECK (canal IN ('web', 'taquilla', 'telefono')),
  estado         VARCHAR(20) NOT NULL DEFAULT 'pendiente'
                 CHECK (estado IN ('pendiente', 'pagado', 'emitido', 'anulado')),
  creado_en      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE entradas (
  id            SERIAL PRIMARY KEY,
  codigo        VARCHAR(20) NOT NULL UNIQUE CHECK (codigo ~ '^EV-\d{4}-\d{6}$'),
  pedido_id     INTEGER     NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
  sesion_id_ref INTEGER     NOT NULL REFERENCES sesiones(id) ON DELETE RESTRICT,
  estado        VARCHAR(20) NOT NULL DEFAULT 'valida'
                CHECK (estado IN ('valida', 'usada', 'anulada')),
  usada_en      TIMESTAMPTZ,
  -- Coherencia interna: si esta usada debe tener fecha de uso, y si no, no.
  CONSTRAINT uso_coherente CHECK ((estado = 'usada') = (usada_en IS NOT NULL))
);

-- Indices de las consultas frecuentes: en Postgres las FK NO se indexan solas.
CREATE INDEX idx_eventos_estado_sala ON eventos (estado, sala);
CREATE INDEX idx_sesiones_evento     ON sesiones (evento_id_ref);
CREATE INDEX idx_pedidos_usuario     ON pedidos (usuario_id, creado_en DESC);

Detente en CONSTRAINT aforo_no_superado CHECK (vendidas <= aforo). Esa línea es la respuesta relacional al problema del curso. En MongoDB tenemos un validador que Mongoose ejecuta a veces —recuerda: no en updateOne sin runValidators, y sin acceso a otros campos en actualizaciones parciales—. Aquí es el motor quien la comprueba en cada INSERT y cada UPDATE, sin excepción, venga de tu aplicación, de un script, de alguien con psql o de una migración mal escrita. Es integridad que MongoDB, sencillamente, no te da. Y observa ON DELETE RESTRICT frente a ON DELETE CASCADE: el primero impide borrar un usuario con pedidos; el segundo borra las entradas al borrar su pedido. Nada de entradas huérfanas: el motor no lo permite.

Instalar Sequelize, conectar y por qué sync() está prohibido

# ORM + controlador de PostgreSQL, y la CLI para migraciones (leccion 07-06).
npm install sequelize pg pg-hstore && npm install --save-dev sequelize-cli
# Postgres en local con Docker; en el modulo 11 lo formalizamos con compose.
docker run -d --name postgres-escena-viva -e POSTGRES_PASSWORD=desarrollo \
  -e POSTGRES_DB=escena_viva -p 5432:5432 postgres:16
// src/db/sequelize.js — la URL entra, como siempre, por src/config/index.js.
'use strict';

const { Sequelize } = require('sequelize');
const { configuracion } = require('../config/index.js');

const sequelize = new Sequelize(configuracion.postgresUrl, {
  dialect: 'postgres',
  // Registro de SQL: utilisimo en desarrollo, ruidoso en produccion.
  logging: configuracion.entorno === 'desarrollo' ? console.log : false,
  pool: { max: 10, min: 1, idle: 10_000, acquire: 30_000 },
  define: { underscored: true, timestamps: true, createdAt: 'creado_en', updatedAt: 'actualizado_en' },
});

// authenticate() lanza un SELECT 1: comprueba credenciales y red al arrancar.
const conectar = () => sequelize.authenticate().then(() => sequelize);

module.exports = { sequelize, conectar, desconectar: () => sequelize.close() };

Sequelize puede crear las tablas desde tus modelos con sequelize.sync(), sync({ alter: true }) o sync({ force: true }). Es cómodo en la primera hora de un proyecto y en producción está prohibido, por cuatro razones que conviene entender y no solo obedecer. No hay historial: nadie sabe qué cambió, cuándo ni por qué, y no se puede revisar en un pull request ni revertir. alter: true es impredecible: para cambiar el tipo de una columna puede borrarla y recrearla, perdiendo los datos, y no detecta renombrados. No preserva datos: añadir una columna NOT NULL a una tabla con un millón de filas exige decidir qué valor tienen esas filas, y sync no lo pregunta. Y force: true en el entorno equivocado es la forma más rápida conocida de borrar una base de datos de producción. La alternativa son las migraciones, primera mitad de la lección siguiente.

Definir modelos y tipos de datos

// src/modelos-sql/sesion.js
'use strict';

const { Model, DataTypes } = require('sequelize');
const { sequelize } = require('../db/sequelize.js');

class Sesion extends Model {
  // Los getters del dominio se reproducen como propiedades de la clase.
  get libres() { return this.aforo - this.vendidas; }
  get agotada() { return this.vendidas >= this.aforo; }
}
Sesion.init(
  {
    id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
    // 'field' declara el nombre real de la columna cuando difiere del atributo.
    sesionId: { type: DataTypes.STRING(20), allowNull: false, unique: true,
      field: 'sesion_id', validate: { is: /^ses-\d{3}-\d+$/ } },
    fechaHora: { type: DataTypes.DATE, allowNull: false, field: 'fecha_hora' },
    aforo: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1 } },
    vendidas: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0 } },
    precioCentimos: { type: DataTypes.INTEGER, allowNull: false, field: 'precio_centimos' },
  },
  {
    sequelize, modelName: 'Sesion', tableName: 'sesiones', timestamps: false,
    validate: {
      // Validacion de modelo: a diferencia de la de campo, ve TODA la fila.
      aforoCoherente() {
        if (this.vendidas > this.aforo) throw new Error('Vendidas no puede superar el aforo');
      },
    },
  },
);

module.exports = { Sesion };

Evento, Pedido, Entrada y Usuario se definen igual, con ENUM para estado, canal y rol.

Tipo Sequelize Tipo SQL (Postgres) Uso en Escena Viva
INTEGER / BIGINT integer / bigint aforo, vendidas, precioCentimos
STRING(n) / TEXT varchar(n) / text titulo, codigo / descripciones largas
DECIMAL(p,s) numeric Decimal exacto — no lo usamos
FLOAT / DOUBLE real / double Nunca para dinero
DATE / DATEONLY timestamptz / date fechaHora, marcas de tiempo
BOOLEAN / ENUM(...) boolean / tipo enum Banderas / estado, canal, rol
JSONB / UUID jsonb / uuid Metadatos flexibles / claves opacas

Sobre DECIMAL frente a enteros en céntimos: DECIMAL es exacto y correcto para dinero, pero en Node llega como cadena —un number no puede representar todos los decimales de 128 bits— y obliga a usar aritmética decimal en cada operación. Nuestra convención evita el problema de raíz: los enteros de JavaScript son exactos hasta 2⁵³, más que suficiente para cualquier recaudación. FLOAT y DOUBLE están descartados sin discusión: 0.1 + 0.2 !== 0.3. Y JSONB merece una nota: PostgreSQL guarda JSON binario, indexable y consultable con operadores propios, así que puedes tener campos documentales dentro de una base relacional; la frontera entre los dos mundos es menos nítida de lo que sugieren los debates.

Validaciones de modelo frente a restricciones de base de datos

Es la misma pregunta que en 07-02 con zod y Mongoose, con la misma respuesta: hacen falta las dos, porque protegen de cosas distintas.

Validación de Sequelize Restricción del motor
Dónde se ejecuta En tu proceso, antes del INSERT En PostgreSQL, siempre
A quién protege A tu aplicación A todos: scripts, psql, otras aplicaciones
Mensaje Bueno, en español, campo a campo Técnico: violates check constraint "aforo_no_superado"
Coste Cero viajes a la base Un viaje que acaba en error
Se salta con validate: false, bulkCreate, SQL crudo Nada. Es infranqueable

La estrategia profesional: validación en el modelo para dar buenos mensajes y ahorrar viajes; restricción en la base como red de seguridad definitiva. Si solo pudieras elegir una, elige la restricción: es la única que no se puede burlar. Un CHECK sobrevive a tu código, a tu ORM y a tu equipo.

Asociaciones

// src/modelos-sql/asociaciones.js
// Un evento tiene muchas sesiones; la FK vive en la tabla 'sesiones'.
Evento.hasMany(Sesion, { as: 'sesiones', foreignKey: 'evento_id_ref', onDelete: 'CASCADE' });
Sesion.belongsTo(Evento, { as: 'evento', foreignKey: 'evento_id_ref' });
Usuario.hasMany(Pedido, { as: 'pedidos', foreignKey: 'usuario_id' });
Pedido.belongsTo(Usuario, { as: 'usuario', foreignKey: 'usuario_id' });
Sesion.hasMany(Pedido, { as: 'pedidos', foreignKey: 'sesion_id_ref' });
Pedido.hasMany(Entrada, { as: 'entradas', foreignKey: 'pedido_id', onDelete: 'CASCADE' });
Entrada.belongsTo(Pedido, { as: 'pedido', foreignKey: 'pedido_id' });

// Muchos a muchos: Sequelize usa (o crea) la tabla intermedia indicada en through.
Evento.belongsToMany(Etiqueta, { through: 'eventos_etiquetas', foreignKey: 'evento_id' });
Asociación Clave foránea Métodos que añade
A.hasOne(B) / A.belongsTo(B) En B / en A a.getB(), a.setB(), a.createB()
A.hasMany(B) En B a.getBs(), a.addB(), a.removeB(), a.countBs()
A.belongsToMany(B) En la tabla intermedia a.getBs(), a.addB(), a.setBs(), a.hasB()
const evento = await Evento.findOne({ where: { eventoId: 'evt-001' } });
// Consulta ADICIONAL: equivale a Sesion.findAll({ where: { evento_id_ref: evento.id } }).
const sesiones = await evento.getSesiones();
// createSesion inserta con la FK ya rellena. Muy conveniente.
await evento.createSesion({ sesionId: 'ses-001-3', aforo: 400, vendidas: 0, precioCentimos: 2500 });

getSesiones() es otra consulta: si lo llamas dentro de un bucle sobre eventos, acabas de reinventar el N+1 de la lección anterior. La solución en Sequelize es include.

Consultas con Sequelize

const { Op } = require('sequelize');

const publicados = await Evento.findAll({
  where: { estado: 'publicado', sala: 'Teatro Almendra' },
  attributes: ['eventoId', 'titulo', 'sala'],   // el equivalente de la proyeccion
  order: [['titulo', 'ASC']], limit: 20,
});
const proximas = await Sesion.findAll({
  where: {
    fechaHora: { [Op.between]: [new Date('2026-03-01'), new Date('2026-03-16')] },
    // Comparar dos COLUMNAS entre si: aqui es trivial, en Mongo hacia falta $expr.
    vendidas: { [Op.lt]: sequelize.col('aforo') },
  },
});

// findAndCountAll devuelve filas y total en una llamada: ideal para paginar.
const { rows, count } = await Pedido.findAndCountAll({
  where: { usuarioId: 7, estado: { [Op.ne]: 'anulado' } }, limit: 20, offset: 0,
});

Los operadores viven en Op y se corresponden casi uno a uno con los de MongoDB: Op.eq/Op.ne y las comparaciones (gt, gte, lt, lte) son $eq, $ne, $gt…; Op.between, Op.in y Op.notIn son $gte+$lte, $in y $nin; Op.like/Op.iLike (que ignora mayúsculas) hacen el papel de $regex; y Op.and, Op.or, Op.not son los lógicos.

Y ahora la diferencia clave frente a populate:

// include genera un JOIN de verdad: UNA SOLA consulta al motor.
const catalogo = await Evento.findAll({
  where: { estado: 'publicado' },
  include: [{
    association: 'sesiones',
    // Se puede filtrar el PADRE por columnas del hijo: required convierte el
    // LEFT JOIN en INNER JOIN, descartando eventos sin sesiones libres.
    where: { vendidas: { [Op.lt]: sequelize.col('sesiones.aforo') } },
    required: true,
    attributes: ['sesionId', 'fechaHora', 'aforo', 'vendidas', 'precioCentimos'],
  }],
  order: [['titulo', 'ASC']],
});

// Agregaciones con fn, col y literal: la recaudacion por sala de la 07-04.
// raw: true devuelve objetos planos sin instanciar modelos: el lean() de Sequelize.
const porSala = await Sesion.findAll({
  attributes: [[col('evento.sala'), 'sala'],
    [fn('SUM', literal('vendidas * precio_centimos')), 'recaudacionCentimos']],
  include: [{ association: 'evento', attributes: [], where: { estado: 'publicado' } }],
  group: [col('evento.sala')], raw: true,
});

Esto es lo que populate no puede hacer. Con include: una consulta, el motor une, filtra y ordena con sus índices y su optimizador, y puedes filtrar el evento por una condición sobre sus sesiones; con populate eran dos consultas cosidas en Node y filtrar el padre era imposible. A cambio, un include mal planteado sobre tres relaciones puede generar un producto cartesiano enorme; para eso existe separate: true.

SQL parametrizado y la inyección

En el módulo 6 prometimos volver sobre la inyección. Se entiende en dos bloques.

// NUNCA. Concatenar entrada del usuario en SQL.
const [filas] = await sequelize.query(`SELECT * FROM eventos WHERE sala = '${req.query.sala}'`);

Si el cliente envía sala=x' OR '1'='1, la sentencia ejecutada es SELECT * FROM eventos WHERE sala = 'x' OR '1'='1' y devuelve la tabla entera. Con sala=x'; DROP TABLE entradas; -- el motor recibe dos sentencias y la segunda destruye datos. El fallo conceptual es que el dato del usuario se ha convertido en código SQL: el motor recibe una cadena y no puede distinguir qué parte escribiste tú y qué parte el atacante.

// BIEN. Parametrizado: el dato viaja SEPARADO de la sentencia.
const filas = await sequelize.query(
  'SELECT * FROM eventos WHERE sala = :sala AND estado = :estado',
  { replacements: { sala: req.query.sala, estado: 'publicado' }, type: sequelize.QueryTypes.SELECT },
);

Por qué es inmune, y no es "porque escapa las comillas": con parámetros, el controlador envía la plantilla de la sentencia por un lado y los valores por otro. PostgreSQL analiza y planifica la sentencia antes de conocer los valores; cuando llegan, el árbol sintáctico ya está fijado y un valor solo puede ocupar el hueco de un literal. x' OR '1'='1 se busca como el texto literal de una sala llamada así, no como lógica. Es una separación estructural, no un filtrado de caracteres, y por eso funciona incluso con entradas que nadie previó. Todo el ORM parametriza por defecto: cada where, cada create, cada update genera SQL con marcadores, así que mientras uses su API estás protegido sin pensarlo. El riesgo aparece en tres sitios: sequelize.query sin replacements, sequelize.literal con datos de usuario dentro, y cualquier SQL construido con plantillas de cadena. Un matiz importante: los parámetros sustituyen valores, no identificadores, así que un ORDER BY dinámico exige lista blanca.

// Ordenacion dinamica segura: validamos contra una lista cerrada.
const COLUMNAS = new Set(['titulo', 'sala', 'creado_en']);
const columna = COLUMNAS.has(req.query.orden) ? req.query.orden : 'titulo';
const eventos = await Evento.findAll({ order: [[columna, req.query.dir === 'desc' ? 'DESC' : 'ASC']] });

El ORM cubre casi todo, pero hay consultas —CTEs recursivas, funciones de ventana, INSERT ... ON CONFLICT afinados— que se escriben mejor a mano. Bajar a SQL crudo con sequelize.query y sustituciones no es un fracaso: es usar la herramienta adecuada. Eso sí, encapsúlalo dentro del repositorio: que un controlador no vea nunca una cadena SQL.

El repositorio alternativo sobre Sequelize

Aquí la arquitectura de la lección 07-01 cobra el premio: misma firma pública, misma salida —instancias del dominio—, otro motor debajo.

// src/repositorios/eventos-sql.js
'use strict';

const { Op } = require('sequelize');
const { Evento: ModeloEvento } = require('../modelos-sql/evento.js');
const { Evento } = require('../dominio/evento.js');

/** Traduce una fila (con sus sesiones) a la instancia del dominio de siempre. */
function aDominio({ eventoId, sesiones = [], ...resto }) {
  return Evento.desdeJSON({ ...resto, id: eventoId,
    sesiones: sesiones.map(({ sesionId, fechaHora, ...datos }) => ({
      ...datos, id: sesionId, fechaHora: new Date(fechaHora).toISOString(),
    })),
  });
}

async function obtenerCatalogo() {
  const filas = await ModeloEvento.findAll({
    where: { estado: { [Op.ne]: 'borrador' } },
    include: [{ association: 'sesiones' }],   // un JOIN, una sola consulta
    order: [['titulo', 'ASC']],
  });
  return filas.map((fila) => aDominio(fila.get({ plain: true })));
}

async function obtenerEventoPorId(eventoId) {
  const fila = await ModeloEvento.findOne({ where: { eventoId }, include: ['sesiones'] });
  return fila ? aDominio(fila.get({ plain: true })) : null;
}

module.exports = { obtenerCatalogo, obtenerEventoPorId };
// src/repositorios/index.js — un unico interruptor para cambiar de motor.
const repositorioEventos = configuracion.motorDatos === 'postgres'
  ? require('./eventos-sql.js')
  : require('./eventos.js');
module.exports = { repositorioEventos };

Ese interruptor es toda la prueba que necesitábamos: los cuatro controladores de eventos, las rutas, el middleware de validación, la jerarquía de errores y el dominio entero funcionan igual con MongoDB o con PostgreSQL. La abstracción que parecía burocracia en la lección 07-01 acaba de ahorrar un reescrito completo.

Mongoose frente a Sequelize: el criterio

Aspecto Mongoose (MongoDB) Sequelize (SQL)
Esquema Del ODM; el motor no lo exige Del motor; obligatorio y verificado
Datos anidados Naturales (subdocumentos) Tabla aparte, o JSONB
Relaciones populate (consultas extra) o $lookup include = JOIN real, una consulta
Integridad referencial Tuya Del motor, con claves foráneas
Restricciones tipo CHECK No existen Sí, infranqueables
Migraciones Externas (migrate-mongo) sequelize-cli, integradas
Transacciones Multidocumento, con réplicas Nativas, de serie, maduras
Agregación Tubería aggregate SQL: GROUP BY, ventanas, CTEs
Escalado / aprendizaje Sharding integrado; curva suave desde JS Réplicas y particionado manual; hay que aprender SQL

El criterio, sin dogmas. Elige documental cuando tus datos son agregados autocontenidos, el esquema evoluciona rápido, el volumen de lectura es alto y la forma varía entre elementos. Elige relacional cuando hay dinero, invariantes duras, muchas relaciones cruzadas e informes complejos, y cuando la corrección importa más que la flexibilidad. Escena Viva, honestamente, es un caso de libro para el modelo relacional: aforos, pagos, entradas nominales, contabilidad. Lo hemos hecho en MongoDB porque funciona y porque hay que saber hacerlo, y en la lección siguiente veremos las dos soluciones a la sobreventa, una por motor. Otras opciones que conviene conocer: Prisma, el ORM moderno más popular en Node, con su propio lenguaje de esquema, tipado excelente y migraciones muy cuidadas, aunque se aleja del SQL y las consultas complejas se le atragantan; y Knex, que no es un ORM sino un constructor de consultas con API encadenable, sin modelos ni magia. Muchos equipos combinan un ORM para el CRUD y Knex o SQL crudo para lo difícil.

Errores Comunes y Consejos

  • Llamar a sync({ alter: true }) en el arranque. Un día alterará algo que no querías, en producción, sin registro. Migraciones.
  • No indexar las claves foráneas. PostgreSQL crea el índice de la PK, pero no el de las columnas FK: un JOIN sin él recorre la tabla entera.
  • Usar FLOAT para dinero, o concatenar entrada de usuario en sequelize.query: enteros en céntimos y replacements, siempre.
  • Confiar solo en las validaciones del modelo. Se saltan con bulkCreate o SQL crudo. Las restricciones del motor no.
  • Cargar relaciones dentro de un bucle con getSesiones(): es el N+1 con otro nombre. Usa include.
  • Consejo: activa logging: console.log en desarrollo y lee el SQL que genera Sequelize —es la mejor forma de aprender SQL y de detectar consultas absurdas—, y aprende EXPLAIN ANALYZE de PostgreSQL: es el explain() de la lección anterior con más información.

Ejercicios

Ejercicio 1: modelar la entrada

Escribe src/modelos-sql/entrada.js con Model.init: codigo (único, con validate.is para el formato EV-<año>-<6 dígitos>), pedidoId, sesionIdRef, estado (ENUM, por defecto valida) y usadaEn (DATE, nulo). Añade las asociaciones y di qué método generado te daría las entradas de un pedido.

Ejercicio 2: arreglar una inyección

Este endpoint es vulnerable. Explica el ataque concreto y reescríbelo de forma segura, sabiendo que orden puede valer titulo o sala.

const { termino, orden } = req.query;
const filas = await sequelize.query(
  `SELECT * FROM eventos WHERE titulo LIKE '%${termino}%' ORDER BY ${orden}`,
);

Soluciones

Ejercicio 1.

Entrada.init({
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  codigo: { type: DataTypes.STRING(20), allowNull: false, unique: true,
    validate: { is: /^EV-\d{4}-\d{6}$/ } },
  pedidoId: { type: DataTypes.INTEGER, allowNull: false, field: 'pedido_id' },
  sesionIdRef: { type: DataTypes.INTEGER, allowNull: false, field: 'sesion_id_ref' },
  estado: { type: DataTypes.ENUM('valida', 'usada', 'anulada'), allowNull: false,
    defaultValue: 'valida' },
  usadaEn: { type: DataTypes.DATE, field: 'usada_en' },
}, { sequelize, modelName: 'Entrada', tableName: 'entradas', updatedAt: false });

Con Pedido.hasMany(Entrada, { as: 'entradas', foreignKey: 'pedido_id' }), el método es pedido.getEntradas(). Para varios pedidos a la vez, include en lugar del método, para no caer en N+1.

Ejercicio 2. Hay dos vulnerabilidades. En termino, un valor como x%' UNION SELECT id, email, nombre, rol, null, null FROM usuarios -- filtra la tabla de usuarios entera. En orden, titulo; DROP TABLE entradas; -- inyecta una segunda sentencia. Y orden no se puede parametrizar porque es un identificador, no un valor: hay que validarlo contra una lista blanca.

const COLUMNAS = new Set(['titulo', 'sala']);
const columna = COLUMNAS.has(req.query.orden) ? req.query.orden : 'titulo';

const filas = await sequelize.query(
  `SELECT evento_id, titulo, sala FROM eventos WHERE titulo ILIKE :patron ORDER BY ${columna} ASC`,
  { replacements: { patron: `%${req.query.termino ?? ''}%` }, type: sequelize.QueryTypes.SELECT },
);

El % va dentro del valor del parámetro, no de la plantilla: así sigue siendo dato. Y columna se interpola solo después de haber sido validada contra un conjunto cerrado.

Conclusión

Has visto el mismo dominio dos veces, y esa es la lección. Sabes leer y escribir un esquema relacional con claves primarias y foráneas, NOT NULL, UNIQUE y esa CHECK (vendidas <= aforo) que convierte una invariante de negocio en una ley del motor. Sabes conectar Sequelize a PostgreSQL desde la configuración, por qué sync() no puede pisar producción, cómo definir modelos con tipos adecuados —enteros en céntimos, nunca coma flotante—, por qué conviven las validaciones del modelo y las restricciones de la base, cómo declarar asociaciones y qué métodos generan, y cómo consultar con where, include —un JOIN real de una sola consulta, a diferencia de populate—, agregaciones y SQL crudo cuando hace falta. Has cerrado la promesa del módulo 6: la inyección se evita separando la sentencia de los datos, no escapando comillas. Y sobre todo, el patrón repositorio ha hecho lo que prometía: src/repositorios/eventos-sql.js sustituye a src/repositorios/eventos.js con un interruptor de configuración, y ni un solo controlador ni una sola clase del dominio se ha enterado.

Queda la última lección del módulo, y es la que estabas esperando desde el módulo 4. Migraciones para versionar el esquema como se versiona el código, semillas para cargar por fin los 3 eventos y las 7 sesiones de datos/eventos.json y jubilar el fichero para siempre, y transacciones: qué es ACID en la práctica, cómo dos compradores concurrentes agotan la última entrada, por qué ni $inc ni una comprobación previa bastan, niveles de aislamiento, bloqueo pesimista frente a optimista, y la implementación definitiva de comprarEntradas que reserva aforo, crea el pedido y emite las entradas o no hace absolutamente nada.

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