Cada vez que node --watch reinicia el servidor, los cafés vuelven a ser dos y el pedido ped_5001 resucita en pendiente_pago. Los arrays en memoria nos han servido para construir el contrato sin distracciones, pero no son una base de datos: no sobreviven a un reinicio, no pueden garantizar que descontar stock de tres líneas ocurra de forma atómica y no escalan más allá de unos pocos miles de registros. Hoy los sustituimos por SQLite con better-sqlite3 detrás del patrón repositorio, y lo haremos cumpliendo la promesa que hicimos en 03-01: no se toca ni una línea de los controladores ni de los servicios. Por el camino veremos el esquema SQL de Tienda Aroma con el dinero en céntimos enteros, migraciones versionadas, sentencias preparadas y por qué eliminan la inyección SQL, transacciones para crear un pedido, control de concurrencia optimista, el problema N+1 y la paginación por cursor implementada de verdad.
Contenido
- Qué cambia hoy en el proyecto
- El patrón repositorio
- Qué base de datos y por qué SQLite en este curso
better-sqlite3: síncrono y sin sorpresas- El esquema SQL de Tienda Aroma
snake_caseen la base de datos ycamelCaseen el JSON- Migraciones: qué son y por qué se versionan
- El script de migración y los datos de siembra
- La conexión:
src/config/base-datos.js src/repositorios/cafes-sqlite.jscon sentencias preparadas- Por qué las sentencias preparadas evitan la inyección SQL
- Consultas dinámicas seguras: filtros, orden y paginación
- Transacciones: crear un pedido descontando stock
- Concurrencia optimista y
conflicto_version - El problema N+1 al cargar las líneas
- Paginación por cursor de verdad
- ORMs: cuándo compensan
- Conexiones y cierre ordenado
- Qué cambia hoy en el proyecto
| Fichero | Acción |
|---|---|
migraciones/001-inicial.sql |
Nuevo: esquema completo |
migraciones/aplicar.js |
Nuevo: aplica las migraciones pendientes |
migraciones/sembrar.js |
Nuevo: datos iniciales del curso |
src/config/base-datos.js |
Nuevo: abre y configura la conexión |
src/repositorios/cafes-sqlite.js |
Nuevo: repositorio de cafés sobre SQL |
src/repositorios/pedidos-sqlite.js |
Nuevo: repositorio de pedidos sobre SQL |
src/repositorios/indice.js |
Nuevo: elige qué implementación se usa |
src/servicios/cafes.js |
Modificado: una línea de import |
src/servicios/pedidos.js |
Modificado: una línea de import |
src/servidor.js |
Modificado: cierra la base de datos al apagar |
package.json |
Modificado: scripts migrar y sembrar |
src/repositorios/*-memoria.js |
Se conservan: los usaremos en las pruebas de 03-08 |
Que la lista de modificaciones sea tan corta es el resultado que buscábamos. Si los controladores hubieran hablado directamente con el array, hoy tocaríamos veinte ficheros.
- El patrón repositorio
Un repositorio es un objeto que ofrece operaciones sobre una colección de entidades y esconde por completo dónde y cómo están guardadas. La interfaz que ya venimos usando desde 03-02:
| Método | Qué hace | Devuelve |
|---|---|---|
buscar(criterios) |
Consulta con filtros, orden y paginación | { elementos, total } |
buscarPorId(id) |
Uno concreto | La entidad o undefined |
crear(datos) |
Inserta | La entidad creada |
actualizar(id, cambios) |
Modifica | La entidad actualizada o undefined |
borrar(id) |
Borrado lógico | true / false |
Las tres propiedades que hacen que valga la pena:
- Sustituibilidad.
cafes-memoria.jsycafes-sqlite.jscumplen la misma interfaz. Cambiar de uno a otro es cambiar unimport, y en 03-08 usaremos precisamente el de memoria como doble de prueba. - Localización del SQL. Todo el SQL de cafés está en un fichero. Cuando una consulta va lenta, sabes dónde mirar; cuando cambia una columna, sabes qué revisar.
- Vocabulario de dominio. El servicio pide
buscarPorId('caf_001'), no ejecuta unSELECT. La lógica de negocio se lee sin ruido técnico.
Y el límite del patrón, dicho con honestidad: no hace que las bases de datos sean intercambiables por arte de magia. Una consulta con JSON_EXTRACT de SQLite no funciona igual en MongoDB. Lo que el repositorio garantiza es que, si hay que reescribirla, se reescribe en un sitio.
graph LR C[controladores] --> S[servicios] S --> I[repositorios/indice.js] I --> M[cafes-memoria.js] I --> Q[cafes-sqlite.js] Q --> D[(SQLite)]
- Qué base de datos y por qué SQLite en este curso
| SQLite | PostgreSQL | MongoDB | |
|---|---|---|---|
| Tipo | Relacional, en fichero | Relacional, cliente-servidor | Documental |
| Instalación | Ninguna: es una librería | Servidor aparte | Servidor aparte |
| Concurrencia de escritura | Un escritor a la vez | Muchos, con MVCC | Muchos |
| Transacciones | Sí, ACID | Sí, ACID y muy completas | Sí, desde la 4.0 |
| Esquema | Rígido (con CHECK) |
Rígido y rico en tipos | Flexible |
| Tipo para dinero | INTEGER (céntimos) |
NUMERIC(10,2) o BIGINT |
Decimal128 o entero |
| Cuándo usarlo | Desarrollo, pruebas, aplicaciones pequeñas, integradas | La opción por defecto en producción | Datos poco estructurados o muy variables |
Por qué SQLite en el curso: porque no hay nada que instalar ni configurar, la base de datos es un fichero que puedes borrar y regenerar en un segundo, y el SQL que escribiremos es SQL estándar, así que lo aprendido se transfiere. Lo que aprendas de sentencias preparadas, transacciones, índices y N+1 vale exactamente igual en PostgreSQL.
Qué cambiaría con PostgreSQL. Poco en el diseño y bastante en la mecánica: se usaría el paquete pg con un pool de conexiones, las llamadas serían asíncronas (await cliente.query(...)), los marcadores de posición serían $1, $2 en vez de ?, y las transacciones se escribirían con BEGIN/COMMIT explícitos sobre una conexión reservada del pool. Los tipos ganarían precisión: TIMESTAMPTZ para las fechas, NUMERIC disponible para el dinero, JSONB con índices para las notas de cata. La estructura del repositorio, sin embargo, sería la misma; por eso empezar por SQLite no es una vía muerta.
Qué cambiaría con MongoDB. Más: desaparecen las uniones, un pedido guardaría sus líneas incrustadas en el propio documento —lo cual, curiosamente, elimina de raíz el problema N+1 de la sección 15— y las transacciones multidocumento existen pero se usan mucho menos. Es una elección razonable para catálogos con atributos muy variables; para pedidos, con su integridad referencial y sus totales, lo relacional encaja mejor.
better-sqlite3: síncrono y sin sorpresas
better-sqlite3: síncrono y sin sorpresasLa rareza de better-sqlite3 es que su API es síncrona: db.prepare(sql).get(id) devuelve la fila directamente, sin await. En un ecosistema donde todo lo que toca entrada/salida es asíncrono, esto choca. Y es correcto:
- SQLite no es un servidor: no hay red, no hay latencia. La consulta es una lectura de fichero, a menudo desde la caché del sistema operativo, que tarda microsegundos.
- Envolver esa operación en una promesa añadiría más sobrecarga que la propia consulta.
- El código resultante es más simple y las transacciones son triviales: sin
awaitde por medio, no hay riesgo de que otra petición se cuele en mitad de una transacción.
La contrapartida: si una consulta tarda 200 ms, bloquea el bucle de eventos y ninguna otra petición avanza durante ese tiempo. La consecuencia práctica es que hay que mantener las consultas rápidas —con índices, sin escaneos completos— y no hacer informes pesados en el mismo proceso que sirve la API. Con PostgreSQL y pg, todo sería asíncrono y este problema no existiría.
Nuestros servicios ya están escritos de forma síncrona desde 03-03, así que la migración es directa. Si mañana pasáramos a PostgreSQL, habría que convertir en async los métodos del repositorio, del servicio y del controlador —y ahí el envoltorio asincrono() que veremos en 03-07 se vuelve imprescindible.
- El esquema SQL de Tienda Aroma
-- migraciones/001-inicial.sql
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------
-- CAFÉS
-- ---------------------------------------------------------------
CREATE TABLE cafes (
id TEXT PRIMARY KEY, -- 'caf_001', clave de texto
nombre TEXT NOT NULL,
origen TEXT NOT NULL,
tueste TEXT NOT NULL CHECK (tueste IN ('claro', 'medio', 'oscuro')),
precio_centimos INTEGER NOT NULL CHECK (precio_centimos > 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
notas_cata TEXT NOT NULL DEFAULT '[]', -- array JSON serializado
descripcion TEXT, -- admite NULL
fecha_creacion TEXT NOT NULL, -- ISO-8601 UTC con Z
activo INTEGER NOT NULL DEFAULT 1, -- borrado lógico: 1 / 0
version INTEGER NOT NULL DEFAULT 1 -- concurrencia optimista
);
-- Índices para los filtros y la ordenación decididos en 02-06
CREATE INDEX idx_cafes_origen ON cafes (origen);
CREATE INDEX idx_cafes_tueste ON cafes (tueste);
CREATE INDEX idx_cafes_precio ON cafes (precio_centimos);
CREATE INDEX idx_cafes_activo ON cafes (activo);
-- ---------------------------------------------------------------
-- CLIENTES (se llena de verdad en 03-06)
-- ---------------------------------------------------------------
CREATE TABLE clientes (
id TEXT PRIMARY KEY, -- 'cli_842'
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, -- unicidad garantizada por la BD
hash_contrasena TEXT NOT NULL, -- NUNCA la contraseña en claro
rol TEXT NOT NULL DEFAULT 'cliente'
CHECK (rol IN ('cliente', 'empleado', 'administrador', 'socio')),
fecha_creacion TEXT NOT NULL
);
-- ---------------------------------------------------------------
-- PEDIDOS
-- ---------------------------------------------------------------
CREATE TABLE pedidos (
id TEXT PRIMARY KEY, -- 'ped_5001'
cliente_id TEXT NOT NULL REFERENCES clientes(id),
estado TEXT NOT NULL DEFAULT 'pendiente_pago'
CHECK (estado IN ('pendiente_pago', 'pagado', 'enviado')),
total_centimos INTEGER NOT NULL CHECK (total_centimos >= 0),
fecha_creacion TEXT NOT NULL,
fecha_pago TEXT,
fecha_envio TEXT,
clave_idempotencia TEXT UNIQUE, -- Idempotency-Key de 02-03
version INTEGER NOT NULL DEFAULT 1
);
-- Índice compuesto: sirve al filtro por cliente Y a la paginación por
-- cursor de la sección 16, que ordena por (fecha_creacion, id).
CREATE INDEX idx_pedidos_cliente_fecha ON pedidos (cliente_id, fecha_creacion DESC, id DESC);
CREATE INDEX idx_pedidos_estado_fecha ON pedidos (estado, fecha_creacion DESC, id DESC);
-- ---------------------------------------------------------------
-- LÍNEAS DE PEDIDO
-- ---------------------------------------------------------------
CREATE TABLE lineas_pedido (
pedido_id TEXT NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
cafe_id TEXT NOT NULL REFERENCES cafes(id),
nombre_cafe TEXT NOT NULL, -- copia CONGELADA del nombre
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_centimos INTEGER NOT NULL, -- precio CONGELADO del día de la compra
PRIMARY KEY (pedido_id, cafe_id) -- un café no puede repetirse en un pedido
);
CREATE INDEX idx_lineas_pedido ON lineas_pedido (pedido_id);
-- ---------------------------------------------------------------
-- RESEÑAS
-- ---------------------------------------------------------------
CREATE TABLE resenas (
id TEXT PRIMARY KEY, -- 'res_101'
cafe_id TEXT NOT NULL REFERENCES cafes(id) ON DELETE CASCADE,
cliente_id TEXT NOT NULL REFERENCES clientes(id),
puntuacion INTEGER NOT NULL CHECK (puntuacion BETWEEN 1 AND 5),
comentario TEXT,
estado TEXT NOT NULL DEFAULT 'pendiente_moderacion'
CHECK (estado IN ('pendiente_moderacion', 'publicada', 'rechazada')),
fecha_creacion TEXT NOT NULL
);
CREATE INDEX idx_resenas_cafe ON resenas (cafe_id, estado);Decisiones del esquema que conviene justificar una a una:
precio_centimos INTEGER. Es la decisión de 02-05 llevada a la base de datos. Un REAL (coma flotante) sería un error de diseño: SELECT SUM(precio) FROM ... acumularía errores de redondeo y el total del pedido podría diferir en céntimos del que se le mostró al cliente. Con enteros, la suma es exacta por definición. En PostgreSQL se podría usar NUMERIC(10,2), que también es exacto; el entero de céntimos funciona en cualquier motor.
Claves primarias de texto (caf_001). Rompe la costumbre del INTEGER AUTOINCREMENT, y a cambio da lo que decidimos en 02-02: identificadores opacos que dicen de qué tipo son y que no filtran cuántos registros hay. El coste es un índice algo mayor y comparaciones de cadena en lugar de enteros, irrelevante a esta escala.
CHECK en los enumerados. La validación de Zod (03-04) ya rechaza un tueste inválido, pero el CHECK protege frente a cualquier vía de entrada: un script de importación, una corrección manual con sqlite3, un bug futuro. Es defensa en profundidad, y no sustituye a la validación en el borde, la complementa: el CHECK produce un error de base de datos, no un 400 amable.
notas_cata como texto JSON. SQLite no tiene tipo array. Guardamos '["cítrico","floral"]' y lo parseamos al leer. Es aceptable porque nunca filtramos por una nota concreta con SQL; el ?q= busca dentro del texto. Si mañana hiciera falta filtrar por nota, lo correcto sería una tabla cafes_notas con una fila por nota. En PostgreSQL usaríamos JSONB con un índice GIN y no habría discusión.
activo INTEGER. SQLite no tiene booleano: se usa 1 y 0. Y hay un detalle práctico importante: better-sqlite3 rechaza los booleanos de JavaScript como parámetros; hay que pasar 1 o 0 explícitamente. Es un error que sorprende la primera vez.
Fechas como TEXT en ISO-8601 UTC. SQLite tampoco tiene tipo fecha. El formato ISO-8601 tiene una propiedad valiosísima: el orden alfabético coincide con el orden cronológico, así que ORDER BY fecha_creacion DESC funciona correctamente sobre texto. Eso es exactamente lo que hace posible la paginación por cursor de la sección 16.
version INTEGER. Es el contador para el control de concurrencia optimista de la sección 14.
snake_case en la base de datos y camelCase en el JSON
snake_case en la base de datos y camelCase en el JSONTenemos tres vocabularios y hay que saber dónde se traduce cada uno:
| Capa | Convención | Ejemplo |
|---|---|---|
| Base de datos | snake_case, unidades internas |
precio_centimos |
| Modelo interno (repositorio hacia arriba) | camelCase, unidades internas |
precioCentimos |
| Representación pública (JSON) | camelCase, unidades públicas |
precioEuros |
Y las dos fronteras:
- BD → modelo interno: en el repositorio, en una función
aModelo(fila). - Modelo interno → JSON: en el mapeador de 03-03 (
cafeARepresentacion).
Podríamos habernos ahorrado la primera traducción usando camelCase también en las columnas. No lo hacemos porque snake_case es la convención universal en SQL —las herramientas, los volcados y los DBA lo esperan— y porque en PostgreSQL los identificadores en mayúsculas obligan a entrecomillar en cada consulta. Que la traducción viva en una sola función del repositorio hace el coste despreciable.
- Migraciones: qué son y por qué se versionan
Una migración es un cambio del esquema escrito como un fichero SQL, numerado, inmutable y versionado en Git.
El problema que resuelve es este: tú creas la tabla cafes en tu portátil ejecutando SQL a mano. Tu compañero no la tiene. El servidor de pruebas tiene una versión antigua sin la columna version. Producción tiene otra distinta. Nadie sabe qué esquema hay en cada sitio, y aplicar un cambio se convierte en un ritual manual con actas.
Las reglas de las migraciones, que no admiten excepciones:
- Numeradas y ordenadas:
001-inicial.sql,002-anadir-descuentos.sql. - Inmutables: una migración aplicada jamás se edita. Si estaba mal, se corrige con otra nueva.
- Versionadas en Git, junto al código que las necesita.
- Registradas: la propia base de datos guarda cuáles se han aplicado.
Esa cuarta regla exige una tabla más, que añadimos al principio del fichero de migraciones:
- El script de migración y los datos de siembra
// migraciones/aplicar.js
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { baseDatos } from '../src/config/base-datos.js';
const carpeta = dirname(fileURLToPath(import.meta.url));
// Tabla de control: qué migraciones se han aplicado ya.
baseDatos.exec(`
CREATE TABLE IF NOT EXISTS migraciones (
nombre TEXT PRIMARY KEY,
aplicada_en TEXT NOT NULL
);
`);
const yaAplicadas = new Set(
baseDatos.prepare('SELECT nombre FROM migraciones').all().map((fila) => fila.nombre)
);
// Se ordenan por nombre: por eso el prefijo numérico con ceros a la izquierda.
const ficheros = readdirSync(carpeta)
.filter((f) => f.endsWith('.sql'))
.sort();
let aplicadas = 0;
for (const fichero of ficheros) {
if (yaAplicadas.has(fichero)) {
console.log(`- ${fichero}: ya aplicada`);
continue;
}
const sql = readFileSync(join(carpeta, fichero), 'utf8');
// Cada migración se aplica DENTRO DE UNA TRANSACCIÓN: si el fichero
// tiene diez sentencias y falla la séptima, no queda un esquema a medias.
const ejecutar = baseDatos.transaction(() => {
baseDatos.exec(sql);
baseDatos
.prepare('INSERT INTO migraciones (nombre, aplicada_en) VALUES (?, ?)')
.run(fichero, new Date().toISOString());
});
ejecutar();
console.log(`+ ${fichero}: aplicada`);
aplicadas++;
}
console.log(`\n${aplicadas} migración(es) aplicada(s). Esquema al día.`);Y la siembra, que carga los datos con los que trabajamos todo el curso:
// migraciones/sembrar.js
import { baseDatos } from '../src/config/base-datos.js';
const insertarCafe = baseDatos.prepare(`
INSERT INTO cafes (id, nombre, origen, tueste, precio_centimos, stock,
notas_cata, descripcion, fecha_creacion, activo, version)
VALUES (@id, @nombre, @origen, @tueste, @precioCentimos, @stock,
@notasCata, @descripcion, @fechaCreacion, 1, 1)
`);
const cafes = [
{
id: 'caf_001',
nombre: 'Etiopía Yirgacheffe',
origen: 'Etiopía',
tueste: 'claro',
precioCentimos: 1450,
stock: 120,
notasCata: JSON.stringify(['cítrico', 'floral', 'té negro']),
descripcion: null,
fechaCreacion: '2026-01-15T09:00:00Z',
},
{
id: 'caf_002',
nombre: 'Colombia Huila',
origen: 'Colombia',
tueste: 'medio',
precioCentimos: 1290,
stock: 80,
notasCata: JSON.stringify(['chocolate', 'caramelo', 'nuez']),
descripcion: null,
fechaCreacion: '2026-01-20T11:15:00Z',
},
];
// Toda la siembra, en una sola transacción.
const sembrar = baseDatos.transaction(() => {
baseDatos.prepare('DELETE FROM lineas_pedido').run();
baseDatos.prepare('DELETE FROM pedidos').run();
baseDatos.prepare('DELETE FROM cafes').run();
baseDatos.prepare('DELETE FROM clientes').run();
for (const cafe of cafes) insertarCafe.run(cafe);
baseDatos
.prepare(
`INSERT INTO clientes (id, nombre, email, hash_contrasena, rol, fecha_creacion)
VALUES ('cli_842', 'Marta García', '[email protected]', 'pendiente-de-03-06',
'cliente', '2026-01-10T08:00:00Z')`
)
.run();
baseDatos
.prepare(
`INSERT INTO pedidos (id, cliente_id, estado, total_centimos, fecha_creacion, version)
VALUES ('ped_5001', 'cli_842', 'pendiente_pago', 2900, '2026-03-14T10:30:00Z', 1)`
)
.run();
baseDatos
.prepare(
`INSERT INTO lineas_pedido (pedido_id, cafe_id, nombre_cafe, cantidad, precio_centimos)
VALUES ('ped_5001', 'caf_001', 'Etiopía Yirgacheffe', 2, 1450)`
)
.run();
});
sembrar();
console.log('Datos de siembra cargados: 2 cafés, 1 cliente, 1 pedido.');Con los scripts en package.json:
"scripts": {
"migrar": "node migraciones/aplicar.js",
"sembrar": "node migraciones/sembrar.js",
"bd:reiniciar": "rm -f datos/aroma.db && npm run migrar && npm run sembrar"
}+ 001-inicial.sql: aplicada 1 migración(es) aplicada(s). Esquema al día. Datos de siembra cargados: 2 cafés, 1 cliente, 1 pedido.
Siembra y migración son cosas distintas. La migración cambia la estructura y se ejecuta en todos los entornos, producción incluida. La siembra mete datos y solo tiene sentido en desarrollo y en pruebas. Mezclarlas —INSERT dentro de una migración— es un error clásico que acaba metiendo cafés de prueba en producción.
- La conexión:
src/config/base-datos.js
src/config/base-datos.js// src/config/base-datos.js
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { entorno } from './entorno.js';
// Nos aseguramos de que exista la carpeta del fichero .db
if (entorno.rutaBaseDatos !== ':memory:') {
mkdirSync(dirname(entorno.rutaBaseDatos), { recursive: true });
}
export const baseDatos = new Database(entorno.rutaBaseDatos);
// --- PRAGMAs: configuración del motor ---
// WAL (Write-Ahead Logging): permite que las lecturas no bloqueen a la
// escritura ni al revés. Es la diferencia entre una SQLite de juguete y
// una utilizable por una API con concurrencia.
baseDatos.pragma('journal_mode = WAL');
// Las claves foráneas están DESACTIVADAS por defecto en SQLite, por
// compatibilidad histórica. Sin esta línea, REFERENCES no se comprueba.
baseDatos.pragma('foreign_keys = ON');
// Si otra conexión tiene la base bloqueada, esperar hasta 5 s antes de
// fallar con SQLITE_BUSY, en vez de rendirse al instante.
baseDatos.pragma('busy_timeout = 5000');
/** Cierra la conexión. Lo llama el apagado ordenado de servidor.js. */
export function cerrarBaseDatos() {
baseDatos.close();
}La línea de foreign_keys = ON es la que más disgustos evita: sin ella, SQLite acepta alegremente una línea de pedido que apunta a un café inexistente y el problema se descubre meses después con datos huérfanos.
src/repositorios/cafes-sqlite.js con sentencias preparadas
src/repositorios/cafes-sqlite.js con sentencias preparadas// src/repositorios/cafes-sqlite.js
import { baseDatos } from '../config/base-datos.js';
/** Fila de la base de datos → modelo interno de la aplicación. */
function aModelo(fila) {
if (!fila) return undefined;
return {
id: fila.id,
nombre: fila.nombre,
origen: fila.origen,
tueste: fila.tueste,
precioCentimos: fila.precio_centimos,
stock: fila.stock,
notasCata: JSON.parse(fila.notas_cata),
descripcion: fila.descripcion,
fechaCreacion: fila.fecha_creacion,
activo: fila.activo === 1,
version: fila.version,
};
}
// --- Sentencias preparadas ---
// Se preparan UNA VEZ, al cargar el módulo, y se reutilizan en cada
// petición. SQLite analiza el SQL y calcula el plan de ejecución solo la
// primera vez; después, ejecutar es sustituir parámetros y correr.
const sentencias = {
porId: baseDatos.prepare('SELECT * FROM cafes WHERE id = ? AND activo = 1'),
insertar: baseDatos.prepare(`
INSERT INTO cafes (id, nombre, origen, tueste, precio_centimos, stock,
notas_cata, descripcion, fecha_creacion, activo, version)
VALUES (@id, @nombre, @origen, @tueste, @precioCentimos, @stock,
@notasCata, @descripcion, @fechaCreacion, 1, 1)
`),
actualizar: baseDatos.prepare(`
UPDATE cafes
SET nombre = @nombre, origen = @origen, tueste = @tueste,
precio_centimos = @precioCentimos, stock = @stock,
notas_cata = @notasCata, descripcion = @descripcion,
version = version + 1
WHERE id = @id AND activo = 1
`),
borradoLogico: baseDatos.prepare(
'UPDATE cafes SET activo = 0, version = version + 1 WHERE id = ? AND activo = 1'
),
siguienteNumero: baseDatos.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) + 1 AS siguiente FROM cafes"
),
};
export const repositorioCafes = {
buscarPorId(id) {
return aModelo(sentencias.porId.get(id));
},
crear(datos) {
const numero = sentencias.siguienteNumero.get().siguiente;
const registro = {
id: `caf_${String(numero).padStart(3, '0')}`,
nombre: datos.nombre,
origen: datos.origen,
tueste: datos.tueste,
precioCentimos: datos.precioCentimos,
stock: datos.stock,
notasCata: JSON.stringify(datos.notasCata ?? []),
descripcion: datos.descripcion ?? null,
fechaCreacion: new Date().toISOString(),
};
sentencias.insertar.run(registro);
return this.buscarPorId(registro.id);
},
actualizar(id, cambios) {
const actual = this.buscarPorId(id);
if (!actual) return undefined;
// Fusionamos el estado actual con los cambios: así el mismo SQL sirve
// para PUT (llegan todos los campos) y PATCH (llegan algunos).
const fusionado = { ...actual, ...cambios };
sentencias.actualizar.run({
id,
nombre: fusionado.nombre,
origen: fusionado.origen,
tueste: fusionado.tueste,
precioCentimos: fusionado.precioCentimos,
stock: fusionado.stock,
notasCata: JSON.stringify(fusionado.notasCata),
descripcion: fusionado.descripcion,
});
return this.buscarPorId(id);
},
borrar(id) {
// .run() devuelve, entre otras cosas, cuántas filas se han modificado.
return sentencias.borradoLogico.run(id).changes > 0;
},
// buscar() se implementa en la sección 12: necesita SQL dinámico.
};Sobre la generación de identificadores: siguienteNumero calcula el máximo existente y suma uno. Funciona porque SQLite serializa las escrituras, y lo envolveremos en la misma transacción que la inserción. En PostgreSQL usaríamos una SEQUENCE, y en un sistema distribuido lo habitual sería un ULID o un UUID con prefijo (caf_01HQ...), que se genera sin consultar nada y no revela el volumen de negocio.
- Por qué las sentencias preparadas evitan la inyección SQL
Compara estas dos formas de buscar por origen:
// PELIGROSO: concatenación de texto. NUNCA hagas esto.
const sql = `SELECT * FROM cafes WHERE origen = '${origen}'`;
baseDatos.prepare(sql).all();
// SEGURO: marcador de posición.
baseDatos.prepare('SELECT * FROM cafes WHERE origen = ?').all(origen);Con la primera, si el cliente envía ?origen=x' OR '1'='1, el SQL que se ejecuta es:
Devuelve el catálogo entero. Y con un poco más de imaginación —x'; DROP TABLE pedidos; --— destruye datos. Esa técnica está en el primer puesto de los riesgos de seguridad desde hace veinte años y sigue funcionando en aplicaciones reales.
Por qué la versión con ? es inmune. No es que "escape las comillas": es que el SQL y los datos viajan por caminos separados. La sentencia se analiza y se compila antes de conocer los valores, produciendo un plan de ejecución con huecos. Cuando se ejecuta, cada valor se coloca en su hueco como dato, ya en el motor, sin volver a pasar por el analizador. Por definición, un valor no puede convertirse en instrucción: x' OR '1'='1 se busca literalmente como origen, no encuentra nada, y devuelve cero filas. El motor no ve una comilla especial; ve una cadena de veinte caracteres.
Tres consecuencias prácticas:
- Marcadores en todos los valores. Siempre. Aunque el dato "venga de dentro", aunque sea un número, aunque estés seguro.
- Los marcadores solo valen para valores, no para nombres de tabla, de columna ni para
ASC/DESC. Eso se resuelve con listas blancas, como veremos ahora mismo. - La validación de 03-04 no sustituye a esto. Es defensa en profundidad: la validación filtra formas, las sentencias preparadas hacen imposible el ataque.
- Consultas dinámicas seguras: filtros, orden y paginación
buscar() tiene que combinar filtros opcionales. La técnica es construir la lista de condiciones y la lista de parámetros a la vez, sin concatenar nunca un valor:
// src/repositorios/cafes-sqlite.js (continuación)
/**
* Lista blanca de ordenación: nombre público → columna real.
* Es OBLIGATORIA porque ORDER BY no admite marcadores de posición y su
* valor tendría que concatenarse. Solo se concatena lo que sale de aquí.
*/
const COLUMNAS_ORDENABLES = {
nombre: 'nombre',
precioEuros: 'precio_centimos',
stock: 'stock',
fechaCreacion: 'fecha_creacion',
id: 'id',
};
export function buscarCafes(criterios = {}) {
const {
origen, tueste, precioMinCentimos, precioMaxCentimos,
disponible, q, ordenar = [], limite = 20, desplazamiento = 0,
} = criterios;
const condiciones = ['activo = 1'];
const parametros = [];
if (origen !== undefined) {
condiciones.push('LOWER(origen) = LOWER(?)');
parametros.push(origen);
}
if (tueste !== undefined) {
condiciones.push('tueste = ?');
parametros.push(tueste);
}
if (precioMinCentimos !== undefined) {
condiciones.push('precio_centimos >= ?');
parametros.push(precioMinCentimos);
}
if (precioMaxCentimos !== undefined) {
condiciones.push('precio_centimos <= ?');
parametros.push(precioMaxCentimos);
}
if (disponible !== undefined) {
condiciones.push(disponible ? 'stock > 0' : 'stock = 0');
}
if (q !== undefined) {
// El comodín va en el PARÁMETRO, no en el SQL: sigue siendo un dato.
condiciones.push('(nombre LIKE ? OR origen LIKE ? OR notas_cata LIKE ?)');
const patron = `%${q}%`;
parametros.push(patron, patron, patron);
}
const donde = `WHERE ${condiciones.join(' AND ')}`;
// --- Total: la MISMA cláusula WHERE, sin ORDER BY ni LIMIT ---
const total = baseDatos
.prepare(`SELECT COUNT(*) AS n FROM cafes ${donde}`)
.get(...parametros).n;
// --- ORDER BY a partir de la lista blanca ---
const trozos = ordenar
.map(({ campo, descendente }) => {
const columna = COLUMNAS_ORDENABLES[campo];
if (!columna) return null; // campo no permitido: se ignora
return `${columna} ${descendente ? 'DESC' : 'ASC'}`;
})
.filter(Boolean);
trozos.push('id ASC'); // desempate estable (03-03)
const ordenSql = `ORDER BY ${trozos.join(', ')}`;
const filas = baseDatos
.prepare(`SELECT * FROM cafes ${donde} ${ordenSql} LIMIT ? OFFSET ?`)
.all(...parametros, limite, desplazamiento);
return { elementos: filas.map(aModelo), total };
}Tres puntos críticos:
El total sale de una consulta aparte con el mismo WHERE. No se puede obtener de la consulta paginada, porque LIMIT recorta. Y tiene que llevar exactamente los mismos filtros, o el cliente calculará mal el número de páginas. Es un COUNT(*) extra por petición, y es el precio de la paginación por offset.
ORDER BY se construye por concatenación, pero solo con valores de COLUMNAS_ORDENABLES. El nombre que envía el cliente se usa como clave de búsqueda en el mapa, nunca como texto SQL. Si no está en el mapa, no hay columna. Es imposible inyectar nada por ahí.
El comodín del LIKE va en el parámetro. '%' || ? || '%' también sería válido, pero poner el patrón completo en el parámetro es más claro. Lo que nunca puede hacerse es LIKE '%${q}%'.
Una nota de rendimiento: LIKE '%texto%' con comodín inicial no puede usar un índice y obliga a recorrer la tabla entera. Con 200 cafés es irrelevante; con 200.000 registros haría falta un índice de texto completo (FTS5 en SQLite, tsvector en PostgreSQL) o un motor de búsqueda dedicado. Es una limitación conocida y aceptada del ?q= que prometimos en 02-06.
Finalmente, el selector de implementación:
// src/repositorios/indice.js
export { repositorioCafes } from './cafes-sqlite.js';
export { repositorioPedidos } from './pedidos-sqlite.js';Y en src/servicios/cafes.js cambia una línea:
// Antes: import { repositorioCafes } from '../repositorios/cafes-memoria.js';
import { repositorioCafes } from '../repositorios/indice.js';Ahí está la promesa cumplida. Reinicia, prueba curl -s http://localhost:3000/v1/cafes | jq .total y comprueba que todo responde igual… y que ahora sobrevive a los reinicios.
- Transacciones: crear un pedido descontando stock
Crear un pedido no es una operación: son varias que deben ocurrir todas o ninguna.
- Comprobar que cada café existe y tiene stock.
- Insertar la fila del pedido.
- Insertar una línea por café.
- Descontar el stock de cada café.
Sin transacción, un fallo en el paso 4 —por ejemplo, porque el stock ya no alcanza— dejaría un pedido cobrable con el inventario sin tocar. Eso es corrupción de datos.
Una transacción garantiza las cuatro propiedades ACID; la que nos importa aquí es la atomicidad: o se aplican todos los cambios, o ninguno.
// src/repositorios/pedidos-sqlite.js
import { baseDatos } from '../config/base-datos.js';
const sentencias = {
insertarPedido: baseDatos.prepare(`
INSERT INTO pedidos (id, cliente_id, estado, total_centimos,
fecha_creacion, clave_idempotencia, version)
VALUES (@id, @clienteId, 'pendiente_pago', @totalCentimos,
@fechaCreacion, @claveIdempotencia, 1)
`),
insertarLinea: baseDatos.prepare(`
INSERT INTO lineas_pedido (pedido_id, cafe_id, nombre_cafe, cantidad, precio_centimos)
VALUES (@pedidoId, @cafeId, @nombreCafe, @cantidad, @precioCentimos)
`),
// El descuento de stock lleva su propia condición de seguridad:
// 'AND stock >= ?' hace que la fila NO se actualice si no hay bastante.
descontarStock: baseDatos.prepare(
'UPDATE cafes SET stock = stock - ?, version = version + 1 WHERE id = ? AND stock >= ?'
),
cafeParaPedido: baseDatos.prepare(
'SELECT id, nombre, precio_centimos, stock FROM cafes WHERE id = ? AND activo = 1'
),
siguienteNumero: baseDatos.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 5000) + 1 AS siguiente FROM pedidos"
),
};
/**
* Crea un pedido de forma ATÓMICA.
* baseDatos.transaction(fn) devuelve una función: al invocarla, ejecuta
* BEGIN, corre el cuerpo y hace COMMIT. Si el cuerpo lanza una excepción,
* hace ROLLBACK automático y vuelve a lanzar el error.
*/
export const crearPedidoAtomico = baseDatos.transaction(
({ clienteId, lineas, claveIdempotencia = null }) => {
const id = `ped_${sentencias.siguienteNumero.get().siguiente}`;
const fechaCreacion = new Date().toISOString();
let totalCentimos = 0;
const lineasResueltas = lineas.map((linea) => {
const cafe = sentencias.cafeParaPedido.get(linea.cafeId);
if (!cafe) {
// Lanzar aquí provoca ROLLBACK: nada de lo hecho antes persiste.
throw Object.assign(new Error(`El café '${linea.cafeId}' no existe`), {
codigoDominio: 'cafe_no_encontrado',
});
}
if (cafe.stock < linea.cantidad) {
throw Object.assign(
new Error(`Solo quedan ${cafe.stock} unidades de '${cafe.nombre}'`),
{ codigoDominio: 'stock_insuficiente' }
);
}
totalCentimos += cafe.precio_centimos * linea.cantidad;
return {
pedidoId: id,
cafeId: cafe.id,
nombreCafe: cafe.nombre, // congelado
cantidad: linea.cantidad,
precioCentimos: cafe.precio_centimos, // congelado
};
});
sentencias.insertarPedido.run({
id, clienteId, totalCentimos, fechaCreacion, claveIdempotencia,
});
for (const linea of lineasResueltas) {
sentencias.insertarLinea.run(linea);
// Segunda comprobación de stock, esta vez en el propio UPDATE.
const resultado = sentencias.descontarStock.run(
linea.cantidad, linea.cafeId, linea.cantidad
);
if (resultado.changes === 0) {
throw Object.assign(
new Error(`Sin stock suficiente de '${linea.cafeId}'`),
{ codigoDominio: 'stock_insuficiente' }
);
}
}
return id;
}
);Qué pasa si falla a mitad. Supón que la segunda línea se queda sin stock porque otro cliente se adelantó por 30 milisegundos. La excepción sube, better-sqlite3 ejecuta ROLLBACK y el estado de la base de datos vuelve exactamente a como estaba: no hay pedido, no hay líneas, y el stock del primer café no se ha descontado. El servicio traduce ese error a 409 stock_insuficiente y el cliente puede reintentar. Sin transacción tendríamos un pedido incompleto, una línea huérfana y stock descontado por un pedido que nunca existió.
Por qué el stock se comprueba dos veces. La primera lectura (cafeParaPedido) sirve para dar un mensaje de error útil con el nombre del café y las unidades que quedan. La segunda, la condición AND stock >= ? dentro del UPDATE, es la que de verdad protege: es una comprobación y una escritura en una sola sentencia atómica, así que no hay ventana entre leer y escribir. Este patrón —comprobar en el WHERE de la actualización y mirar changes— es la forma correcta de evitar que dos peticiones simultáneas vendan la misma última bolsa de café.
Un detalle de better-sqlite3 que hay que respetar: dentro de una transacción no puede haber await. Como su API es síncrona, no lo necesitamos; pero si mezclaras una llamada asíncrona ahí dentro, la transacción se cerraría antes de tiempo. Con PostgreSQL, en cambio, todo el bloque sería async y habría que reservar una conexión del pool para toda la transacción.
- Concurrencia optimista y
conflicto_version
conflicto_versionOtro problema de concurrencia, distinto del stock: la actualización perdida.
sequenceDiagram participant A as Empleado A participant API participant BD as Base de datos A->>API: GET /v1/cafes/caf_001 (precio 14.50, version 3) Note over API: Empleado B lee lo mismo A->>API: PATCH precio 15.90 API->>BD: UPDATE ... version 3 → 4 Note over API: B envía PATCH stock 200 con version 3 API->>BD: UPDATE ... WHERE version = 3 BD-->>API: 0 filas modificadas API-->>A: 409 conflicto_version
Sin control, el segundo PATCH machacaría el precio nuevo con el que B leyó hace un minuto, y nadie se enteraría. El bloqueo optimista lo detecta: cada actualización exige la versión que el cliente leyó.
// src/repositorios/cafes-sqlite.js (añadido)
const actualizarConVersion = baseDatos.prepare(`
UPDATE cafes
SET nombre = @nombre, origen = @origen, tueste = @tueste,
precio_centimos = @precioCentimos, stock = @stock,
notas_cata = @notasCata, descripcion = @descripcion,
version = version + 1
WHERE id = @id AND activo = 1 AND version = @version
`);
/**
* Actualiza solo si la versión coincide.
* @returns el café actualizado, o null si hubo conflicto de versión.
*/
export function actualizarCafeConVersion(id, cambios, versionEsperada) {
const actual = repositorioCafes.buscarPorId(id);
if (!actual) return undefined;
const fusionado = { ...actual, ...cambios };
const resultado = actualizarConVersion.run({
id,
version: versionEsperada,
nombre: fusionado.nombre,
origen: fusionado.origen,
tueste: fusionado.tueste,
precioCentimos: fusionado.precioCentimos,
stock: fusionado.stock,
notasCata: JSON.stringify(fusionado.notasCata),
descripcion: fusionado.descripcion,
});
// 0 filas modificadas con el recurso existente = otro lo cambió antes.
return resultado.changes === 0 ? null : repositorioCafes.buscarPorId(id);
}Y el servicio traduce ese null al 409 conflicto_version del catálogo:
{
"error": {
"codigo": "conflicto_version",
"mensaje": "El café 'caf_001' ha sido modificado por otra persona. Vuelve a cargarlo y repite el cambio.",
"detalles": []
}
}Dos precisiones sobre el catálogo de 02-04. Allí conflicto_version aparecía asociado a 412 Precondition Failed, que es el código correcto cuando el cliente envía la condición en la cabecera If-Match con un ETag —el mecanismo estándar de HTTP, que veremos con el caché condicional en 04-06—. Aquí la condición viaja como un campo version en el cuerpo, sin cabecera condicional, y entonces el código adecuado es 409 Conflict: la petición era válida pero choca con el estado actual del recurso. Mismo problema, mismo código de catálogo, dos códigos HTTP según cómo se exprese la condición. Y el matiz sobre el nombre "optimista": se llama así porque no bloquea nada; asume que los conflictos son raros y se limita a detectarlos. El pesimista (SELECT ... FOR UPDATE) bloquea la fila, y solo compensa cuando los conflictos son frecuentes.
- El problema N+1 al cargar las líneas
Así es como no se cargan los pedidos con sus líneas:
// MAL: 1 consulta para los pedidos + N consultas, una por pedido.
const pedidos = baseDatos.prepare('SELECT * FROM pedidos LIMIT 20').all();
for (const pedido of pedidos) {
pedido.lineas = baseDatos
.prepare('SELECT * FROM lineas_pedido WHERE pedido_id = ?')
.all(pedido.id);
}Con 20 pedidos son 21 consultas. El código parece inocente porque la consulta está escondida dentro del bucle, y por eso el problema N+1 es tan común. En SQLite, con la base en local, el coste es pequeño; con PostgreSQL en otra máquina y 2 ms de red por consulta, esos 21 viajes son 42 ms de latencia pura para devolver una página. Y si la lista fueran 100 pedidos, 200 ms.
La solución: una segunda consulta para todas las líneas de golpe.
// src/repositorios/pedidos-sqlite.js (continuación)
function cargarLineasDe(pedidos) {
if (pedidos.length === 0) return pedidos;
// Un marcador '?' por cada id: (?, ?, ?, ...). El SQL se genera, pero
// los VALORES siguen siendo parámetros: no hay concatenación de datos.
const marcadores = pedidos.map(() => '?').join(', ');
const filas = baseDatos
.prepare(
`SELECT pedido_id, cafe_id, nombre_cafe, cantidad, precio_centimos
FROM lineas_pedido
WHERE pedido_id IN (${marcadores})
ORDER BY pedido_id, cafe_id`
)
.all(...pedidos.map((p) => p.id));
// Agrupamos en memoria: coste O(n), sin consultas adicionales.
const porPedido = new Map();
for (const fila of filas) {
if (!porPedido.has(fila.pedido_id)) porPedido.set(fila.pedido_id, []);
porPedido.get(fila.pedido_id).push({
cafeId: fila.cafe_id,
nombre: fila.nombre_cafe,
cantidad: fila.cantidad,
precioCentimos: fila.precio_centimos,
});
}
return pedidos.map((pedido) => ({ ...pedido, lineas: porPedido.get(pedido.id) ?? [] }));
}Dos consultas, sea cual sea el número de pedidos. La alternativa sería un único JOIN, que también funciona pero devuelve el pedido repetido tantas veces como líneas tenga y obliga a deduplicar. Con dos consultas el código queda más claro y el rendimiento es equivalente.
Un aviso: IN (...) tiene un límite de parámetros (999 por defecto en SQLite). Como nuestras páginas son de 100 como máximo, no nos afecta; en un proceso por lotes habría que trocear.
- Paginación por cursor de verdad
En 02-06 decidimos que /v1/pedidos usaría cursor, y ahora se ve por qué. Con OFFSET, el motor tiene que recorrer y descartar todas las filas anteriores:
-- Para devolver 20 filas, SQLite lee y descarta 100.000. Cada vez.
SELECT * FROM pedidos ORDER BY fecha_creacion DESC, id DESC LIMIT 20 OFFSET 100000;Es el deep paging: la página 1 es instantánea y la 5.000 tarda segundos. Y además es inestable: si entra un pedido nuevo mientras el cliente pagina, todo se desplaza una posición y ve un pedido repetido.
El cursor resuelve las dos cosas guardando el último elemento visto en lugar de un número de posición:
-- Sin OFFSET: el índice salta directamente al punto de corte.
SELECT * FROM pedidos
WHERE (fecha_creacion, id) < (?, ?)
ORDER BY fecha_creacion DESC, id DESC
LIMIT 20;La comparación de tuplas (a, b) < (x, y) es SQL estándar y significa "a < x, o bien a = x y b < y". Es exactamente el desempate que necesitábamos, expresado en una condición.
// src/repositorios/pedidos-sqlite.js (continuación)
/** Codifica el cursor en base64url para que sea OPACO (02-06). */
function codificarCursor(pedido) {
return Buffer.from(JSON.stringify({ f: pedido.fecha_creacion, i: pedido.id })).toString(
'base64url'
);
}
function descodificarCursor(cursor) {
try {
const { f, i } = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
if (typeof f !== 'string' || typeof i !== 'string') return null;
return { fecha: f, id: i };
} catch {
return null; // cursor manipulado o corrupto → 400 parametro_invalido
}
}
export function buscarPedidosPorCursor({ clienteId, estado, cursor, limite = 20 }) {
const condiciones = [];
const parametros = [];
if (clienteId !== undefined) {
condiciones.push('cliente_id = ?');
parametros.push(clienteId);
}
if (estado !== undefined) {
condiciones.push('estado = ?');
parametros.push(estado);
}
if (cursor !== undefined) {
const punto = descodificarCursor(cursor);
if (!punto) return { cursorInvalido: true };
condiciones.push('(fecha_creacion, id) < (?, ?)');
parametros.push(punto.fecha, punto.id);
}
const donde = condiciones.length > 0 ? `WHERE ${condiciones.join(' AND ')}` : '';
// Pedimos UNA fila de más: si viene, es que hay página siguiente.
const filas = baseDatos
.prepare(
`SELECT * FROM pedidos ${donde}
ORDER BY fecha_creacion DESC, id DESC
LIMIT ?`
)
.all(...parametros, limite + 1);
const hayMas = filas.length > limite;
const pagina = hayMas ? filas.slice(0, limite) : filas;
return {
elementos: cargarLineasDe(pagina).map(aModeloPedido),
cursorSiguiente: hayMas ? codificarCursor(pagina[pagina.length - 1]) : null,
};
}Link: <http://localhost:3000/v1/pedidos?limite=20&cursor=eyJmIjoiMjAyNi0wMy0xNFQxMDozMDowMFoiLCJpIjoicGVkXzUwMDEifQ>; rel="next"Tres decisiones a subrayar. El cursor es opaco: base64 de un JSON interno. No es cifrado —cualquiera puede descodificarlo— pero sí es explícitamente "no es asunto tuyo": el contrato de 02-06 prohíbe construirlo a mano, y así podemos cambiar su contenido sin romper a nadie. Pedir limite + 1 filas es el truco estándar para saber si hay más sin un COUNT extra. Y la paginación por cursor no ofrece total ni salto a la última página: es el precio que se paga, y por eso el contrato reservó el cursor para /v1/pedidos —donde el histórico es enorme y solo se avanza— y dejó el offset en /v1/cafes, donde el catálogo es pequeño y saber el total importa.
El índice idx_pedidos_cliente_fecha (cliente_id, fecha_creacion DESC, id DESC) está construido justo para esta consulta: el motor localiza el punto de corte con una búsqueda en el árbol y lee 21 filas seguidas. El coste no depende de lo lejos que esté la página.
- ORMs: cuándo compensan
| Herramienta | Enfoque | Notas |
|---|---|---|
| Prisma | Esquema propio + cliente generado | Excelente experiencia y tipos; genera sus migraciones |
| Drizzle | SQL con tipos, muy fino | Cercano al SQL; poco peso en tiempo de ejecución |
| Sequelize | ORM clásico, activo-record | Veterano; abstrae mucho |
| TypeORM | ORM con decoradores | Popular con NestJS (05-03) |
| Knex | Constructor de consultas, no ORM | Compone SQL sin ocultarlo |
A favor: menos código repetitivo, migraciones integradas, tipado del resultado, portabilidad entre motores, y relaciones cargadas sin escribir el JOIN.
En contra: una abstracción más que aprender y depurar; consultas generadas que sorprenden; el problema N+1 oculto —un .lineas que parece un acceso a propiedad y en realidad dispara una consulta—; y dificultad para expresar SQL avanzado.
El criterio: si tu aplicación tiene muchas entidades con relaciones parecidas y CRUD repetitivo, un ORM ahorra semanas. Si tiene pocas entidades y consultas exigentes, el SQL directo tras un repositorio —lo que hemos hecho— es más simple y más rápido. Y con el patrón repositorio, la decisión es reversible: adoptar Prisma mañana significaría reescribir los ficheros de src/repositorios/, y nada más.
- Conexiones y cierre ordenado
SQLite es un fichero y no necesita pool: una única conexión por proceso, abierta al arrancar. El modo WAL permite lecturas concurrentes con la escritura, y busy_timeout gestiona los choques de escritores.
Con PostgreSQL sí haría falta un pool: abrir una conexión TCP cuesta decenas de milisegundos, así que se mantiene un conjunto reutilizable (new Pool({ max: 10 })). Ahí aparecen problemas propios —agotamiento del pool por conexiones que no se devuelven, transacciones que hay que ejecutar sobre una conexión reservada— que no tenemos aquí.
Lo que sí hay que hacer es cerrar la base de datos al apagar. Actualizamos src/servidor.js:
// src/servidor.js (modificado)
import { app } from './app.js';
import { entorno } from './config/entorno.js';
import { cerrarBaseDatos } from './config/base-datos.js';
const servidor = app.listen(entorno.puerto, () => {
console.log(`API de Tienda Aroma escuchando en ${entorno.baseUrl}/v1`);
});
function cerrarOrdenadamente(senal) {
console.log(`\nRecibida la señal ${senal}. Cerrando...`);
servidor.close(() => {
// El orden importa: primero dejamos de aceptar y terminamos las
// peticiones en curso, y solo entonces cerramos la base de datos.
cerrarBaseDatos();
console.log('Servidor y base de datos cerrados.');
process.exit(0);
});
}
process.on('SIGINT', () => cerrarOrdenadamente('SIGINT'));
process.on('SIGTERM', () => cerrarOrdenadamente('SIGTERM'));close() de better-sqlite3 vuelca el WAL al fichero principal y libera el bloqueo. Sin él, un apagado brusco deja ficheros -wal y -shm sueltos que SQLite sabe recuperar, pero no hay razón para depender de eso.
Errores Comunes y Consejos
1. Concatenar valores en el SQL. Inyección garantizada. Marcadores de posición siempre, sin excepciones ni "es que este valor viene de dentro".
2. Olvidar PRAGMA foreign_keys = ON. SQLite las ignora por defecto y acabas con líneas que apuntan a pedidos inexistentes.
3. Pasar un booleano de JavaScript como parámetro. better-sqlite3 lo rechaza. Convierte a 1 / 0.
4. Guardar dinero en REAL. El error más caro de la lista. INTEGER de céntimos, o NUMERIC donde exista.
5. Editar una migración ya aplicada. Tu base se actualiza, la de tus compañeros no, y el registro de migraciones miente. Corrige siempre con una migración nueva.
6. Mezclar siembra y migración. Los datos de prueba acaban en producción.
7. Consultar dentro de un bucle. Es el N+1. Si ves un prepare dentro de un for, párate: casi siempre hay una versión con IN (...).
8. Calcular el total sobre la consulta paginada. El COUNT va con los mismos filtros y sin LIMIT.
9. Prepared statements dentro del manejador. db.prepare() en cada petición desperdicia el análisis del SQL. Prepara al cargar el módulo y reutiliza.
Consejo: cuando una consulta vaya lenta, pide el plan antes de tocar nada: EXPLAIN QUERY PLAN SELECT .... Si aparece SCAN TABLE, falta un índice; si aparece SEARCH TABLE ... USING INDEX, va bien.
Ejercicios
Ejercicio 1
Escribe la migración 002-anadir-descuentos.sql, que añade a cafes una columna descuento_porcentaje (entero, entre 0 y 50, por defecto 0). Explica por qué esta migración es un cambio retrocompatible para los consumidores de la API (02-07) y qué habría que hacer además para que el campo aparezca en el JSON.
Ejercicio 2
Implementa buscarResenasDeCafe(cafeId, { estado, limite, desplazamiento }) en un nuevo src/repositorios/resenas-sqlite.js. Debe devolver { elementos, total }, filtrar por estado si se indica, ordenar por fecha_creacion DESC con desempate por id, y no devolver reseñas de un café inexistente sin distinguirlo de un café sin reseñas. Indica qué índice del esquema la sostiene.
Ejercicio 3
Dos empleados abren a la vez la ficha de caf_001 (version: 7). El primero cambia el precio a 15,90 € y el segundo, treinta segundos después, cambia el stock a 200 usando la versión 7 que leyó. Describe qué ocurre paso a paso con actualizarCafeConVersion, qué responde la API al segundo, y compáralo con lo que pasaría sin control de versión. Después explica por qué este mecanismo no serviría para el descuento de stock del pedido y qué se usa en su lugar.
Soluciones
Solución 1
-- migraciones/002-anadir-descuentos.sql
ALTER TABLE cafes ADD COLUMN descuento_porcentaje INTEGER NOT NULL DEFAULT 0;
-- SQLite no permite añadir un CHECK a una tabla existente con ALTER TABLE,
-- así que la restricción se aplica en el esquema Zod (03-04) y, si se
-- quisiera en la base, habría que recrear la tabla. En PostgreSQL sería:
-- ALTER TABLE cafes ADD CONSTRAINT chk_descuento
-- CHECK (descuento_porcentaje BETWEEN 0 AND 50);
CREATE INDEX idx_cafes_descuento ON cafes (descuento_porcentaje)
WHERE descuento_porcentaje > 0; -- índice parcial: solo los que tienen descuentoPor qué es retrocompatible: la columna tiene DEFAULT 0 y NOT NULL, así que las filas existentes se rellenan solas y ninguna inserción anterior deja de funcionar. Desde el punto de vista de la API, añadir un campo a una representación es un cambio aditivo (02-07): un consumidor que ignore descuentoPorcentaje sigue funcionando igual, porque un cliente JSON bien escrito ignora los campos que no conoce. No haría falta una v2.
Qué falta para que aparezca en el JSON: tres cosas, y ninguna en la base de datos. Añadir descuentoPorcentaje: fila.descuento_porcentaje en aModelo() del repositorio; añadirlo a cafeARepresentacion() en el mapeador; y añadirlo a openapi.yaml con su descripción. Que hagan falta exactamente esos tres sitios, y que sean siempre los mismos, es la señal de que la arquitectura por capas está bien puesta.
Solución 2
// src/repositorios/resenas-sqlite.js
import { baseDatos } from '../config/base-datos.js';
function aModelo(fila) {
return {
id: fila.id,
cafeId: fila.cafe_id,
clienteId: fila.cliente_id,
puntuacion: fila.puntuacion,
comentario: fila.comentario,
estado: fila.estado,
fechaCreacion: fila.fecha_creacion,
};
}
const cafeExiste = baseDatos.prepare('SELECT 1 FROM cafes WHERE id = ? AND activo = 1');
export function buscarResenasDeCafe(cafeId, { estado, limite = 20, desplazamiento = 0 } = {}) {
// Distinguir "café inexistente" de "café sin reseñas" exige comprobarlo:
// el primero es 404 cafe_no_encontrado; el segundo, 200 con datos vacíos.
if (!cafeExiste.get(cafeId)) return { cafeInexistente: true };
const condiciones = ['cafe_id = ?'];
const parametros = [cafeId];
if (estado !== undefined) {
condiciones.push('estado = ?');
parametros.push(estado);
}
const donde = `WHERE ${condiciones.join(' AND ')}`;
const total = baseDatos
.prepare(`SELECT COUNT(*) AS n FROM resenas ${donde}`)
.get(...parametros).n;
const filas = baseDatos
.prepare(
`SELECT * FROM resenas ${donde}
ORDER BY fecha_creacion DESC, id DESC
LIMIT ? OFFSET ?`
)
.all(...parametros, limite, desplazamiento);
return { elementos: filas.map(aModelo), total };
}Índice que la sostiene: idx_resenas_cafe ON resenas (cafe_id, estado). Cubre el filtro obligatorio por cafe_id y, cuando se añade estado, también el segundo. La ordenación por fecha_creacion DESC no está cubierta, así que el motor ordena en memoria el subconjunto de reseñas de ese café —aceptable, porque son pocas—. Si un café llegara a tener miles, el índice ideal sería (cafe_id, estado, fecha_creacion DESC, id DESC), que serviría filtro y orden de una vez. Compruébalo con EXPLAIN QUERY PLAN.
Solución 3
Paso a paso:
- Ambos leen
caf_001conversion: 7. - El primero envía su cambio con
version: 7. ElUPDATE ... WHERE id = 'caf_001' AND version = 7encuentra la fila, aplica el precio y sube la versión a 8.changes === 1, y la API responde200con la representación actualizada. - El segundo envía su cambio con
version: 7. ElWHERE ... AND version = 7no encuentra ninguna fila, porque ahora vale 8.changes === 0y el café sí existe → conflicto. - La API responde
409 conflicto_versioncon el mensaje que le pide recargar y repetir.
Sin control de versión: el segundo UPDATE habría escrito su copia completa del café, incluido el precio_centimos de 1450 que leyó hace medio minuto. El cambio de precio del primero desaparece sin dejar rastro: ningún error, ningún log, ningún indicio. Es la actualización perdida, y su gravedad está en que es silenciosa: el primer empleado ve su 200 OK, se va tranquilo, y el precio antiguo reaparece.
Por qué no sirve para el stock del pedido. Son dos problemas distintos. El bloqueo optimista exige que el cliente haya leído el recurso y devuelva su versión, y protege una sustitución completa del recurso. El descuento de stock, en cambio, es una operación relativa —"resta 2 a lo que haya"— que nadie ha leído previamente: el comprador no envía la versión del café ni tiene por qué conocerla. Si lo hiciera, dos compras simultáneas de cafés distintos del mismo pedido darían 409 constantemente y la tienda sería inusable.
Lo que se usa en su lugar es la condición dentro del propio UPDATE:
Aquí la lectura y la escritura ocurren en la misma sentencia atómica, así que no hay ventana entre comprobar y actualizar. Si dos peticiones intentan comprar la última bolsa, una obtiene changes === 1 y la otra changes === 0, y esta última recibe 409 stock_insuficiente. La regla general: el bloqueo optimista para actualizaciones absolutas; la condición en el WHERE para las relativas.
Conclusión
Los datos de Tienda Aroma ya viven en una base de datos de verdad, y el cambio ha costado tres líneas de import fuera de la carpeta repositorios/. Eso es lo que compra el patrón repositorio: el resto de la aplicación nunca supo dónde estaban los cafés. El esquema SQL traduce las decisiones de contrato a estructura —precio_centimos entero porque el dinero nunca es coma flotante, claves primarias de texto con prefijo, CHECK en los enumerados como defensa en profundidad, fechas ISO-8601 cuyo orden alfabético es el cronológico, e índices puestos exactamente donde 02-06 dijo que habría filtros y ordenaciones—. Las migraciones numeradas e inmutables convierten "el esquema" en algo que se despliega, se revisa en un pull request y se reproduce en cualquier máquina con npm run migrar.
Y has visto los cuatro problemas que separan una capa de datos ingenua de una profesional. Las sentencias preparadas, que no escapan comillas sino que mandan el SQL y los datos por caminos distintos, con la lista blanca para lo único que no admite marcadores, el ORDER BY. Las transacciones, que hacen que crear un pedido descontando stock de varias líneas sea todo o nada, con la condición AND stock >= ? en el UPDATE como verdadera protección frente a dos compradores simultáneos. El control de concurrencia optimista con version, que convierte una actualización perdida silenciosa en un 409 conflicto_version visible. Y el N+1, junto con la paginación por cursor que hace que la página cinco mil cueste lo mismo que la primera.
Queda una puerta abierta de par en par: cualquiera puede crear, modificar y borrar cafés, y GET /v1/pedidos devuelve los pedidos de todos los clientes a quien pregunte. En 03-06, Autenticación y autorización, la cerramos: distinguiremos autenticación de autorización —el 401 del 403 que el contrato separa desde 02-04—, registraremos clientes con la contraseña protegida con bcrypt, emitiremos y verificaremos JWT firmados con el secreto que ya vive en .env, escribiremos el middleware que distingue token ausente de token caducado y devuelve WWW-Authenticate, y aplicaremos los roles cliente, empleado, administrador y socio con una matriz de permisos por endpoint y comprobación de propiedad a nivel de recurso.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
