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
- Lo relacional en diez minutos, y por qué aquí las sesiones sí son una tabla
- El esquema SQL de Escena Viva
- Instalar Sequelize, conectar y por qué
sync()está prohibido - Definir modelos y tipos de datos
- Validaciones de modelo frente a restricciones de base de datos
- Asociaciones y consultas con Sequelize
- SQL parametrizado y la inyección
- El repositorio alternativo sobre Sequelize
- 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
idautoincremental o unUUID. - 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_idinexistente, 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
JOINsin él recorre la tabla entera. - Usar
FLOATpara dinero, o concatenar entrada de usuario ensequelize.query: enteros en céntimos yreplacements, siempre. - Confiar solo en las validaciones del modelo. Se saltan con
bulkCreateo SQL crudo. Las restricciones del motor no. - Cargar relaciones dentro de un bucle con
getSesiones(): es el N+1 con otro nombre. Usainclude. - Consejo: activa
logging: console.logen desarrollo y lee el SQL que genera Sequelize —es la mejor forma de aprender SQL y de detectar consultas absurdas—, y aprendeEXPLAIN ANALYZEde PostgreSQL: es elexplain()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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
