Conectar con Node.js
Primera arquitectura mínima, modular y sin frameworks
Hasta ahora has trabajado SQLite “en crudo”, directamente desde DB Browser o desde la consola, diseñando tablas, consultas, subconsultas, CTEs, vistas e índices. Eso está muy bien para entender la base de datos, pero en un proyecto real casi nunca vas a hablar con SQLite “a mano”: lo harás desde un backend.
El objetivo de este bloque es dar el salto natural:
De “sé hacer consultas SQLite aisladas” a “sé integrar SQLite dentro de un backend Node.js organizado y entendible”.
Lo vamos a hacer con una arquitectura mínima pero seria:
- Node.js usando ES Modules (import/export modernos).
- SQLite como motor de base de datos embebido.
- Sin frameworks web (sin Express, sin Koa): usaremos únicamente el módulo http de Node.
- Código organizado en módulos: servidor, base de datos, modelos y controladores.
Es decir, no vamos a montar todavía una “gran aplicación”, pero sí algo lo bastante realista para que veas todas las piezas trabajando juntas.
La idea central será construir una mini API de usuarios:
- Un servidor HTTP que escuche peticiones.
- Un módulo de base de datos que abra y comparta la conexión SQLite.
- Un modelo de usuarios que se encargue de hablar con la base de datos.
- Un controlador que reciba la petición HTTP, llame al modelo y devuelva una respuesta JSON.
A partir de este esqueleto, luego será mucho más sencillo añadir más tablas, más rutas, más lógica de negocio y nuevas capas.
Objetivo de este primer bloque
En este primer paso no buscamos tener “toda” la aplicación hecha, sino entender con claridad cómo se encadenan las capas desde que llega una petición hasta que se toca la base de datos.
Concretamente vas a ver:
-
Cómo estructurar un proyecto Node.js + SQLite con ES Modules.
Verás qué carpetas y archivos mínimos necesitas, dónde colocar la lógica de base de datos, dónde el servidor HTTP, dónde los modelos, etc. El objetivo es que no termines con un único archivo gigante imposible de mantener.
-
Cómo crear un módulo de base de datos reutilizable.
En lugar de abrir conexiones por todas partes, centralizaremos la creación de la conexión SQLite en un único módulo. Ese módulo se importará desde los modelos para ejecutar consultas preparadas. Así tendrás un punto claro donde configurar la base de datos.
-
Cómo crear un modelo sencillo de usuarios usando consultas preparadas con placeholders.
Aprovecharemos todo lo aprendido sobre seguridad e inyección SQL: en el modelo de usuarios solo utilizaremos consultas preparadas con parámetros. Verás funciones como “crear usuario”, “listar usuarios”, “buscar por id o por email”, todas hablando con SQLite de forma segura.
-
Cómo montar un servidor HTTP básico sin frameworks que utilice ese modelo.
Usaremos el módulo http de Node para crear un servidor que reciba peticiones, lea la URL y el método, y según el caso llame a las funciones del modelo de usuarios. No habrá magia: verás claramente el camino completo desde la petición hasta la consulta SQL.
Más adelante, sobre esta misma base, podremos ir sumando piezas:
- Transacciones para operaciones más delicadas (por ejemplo, crear usuario + crear registro asociado).
- Endpoints adicionales para otros recursos (orders, products, etc.).
- Un pequeño “middleware casero” para reutilizar tareas comunes (parsear JSON, manejar errores, logs).
- Una organización más cercana a un patrón MVC si quieres formalizarlo más.
De momento, la meta de este bloque es muy concreta. Que puedas mirar tu proyecto, archivo por archivo, y decir:
“Entiendo perfectamente cómo Node arranca el servidor, cómo se inicializa SQLite, cómo se comparten las consultas, y cómo todas las capas están conectadas sin necesidad de frameworks externos.”
Estructura de directorios propuesta
Antes de escribir una sola línea de código, conviene decidir dónde va a vivir cada parte de la aplicación. Eso te evitará acabar con un server.mjs gigante que lo hace todo: abrir la base de datos, definir las consultas, procesar las peticiones, etc.
Un esquema inicial sencillo podría ser:
.
└── mi-api-sqlite/
├── package.json
├── data/
│ └── app.db
└── src/
├── db/
│ └── sqliteClient.mjs
├── models/
│ └── userModel.mjs
├── controllers/
│ └── userController.mjs
└── http/
└── server.mjs
Esta estructura no es la única posible, pero sí tiene una ventaja importante: refleja mentalmente las capas que vas a usar en el backend.
Descripción detallada y con “para qué sirve”:
-
data/app.dbAquí guardaremos el archivo físico de SQLite.
Es tu base de datos real: tablas, índices, datos, todo está dentro de este fichero.
Separarlo en una carpeta
data/tiene varias ventajas:- No lo mezclas con el código fuente.
- Puedes hacer copias de seguridad fácilmente.
- Puedes cambiar la ruta en configuración sin tocar la lógica de negocio.
-
src/db/sqliteClient.mjsEste será “el punto único” donde tu backend abre la conexión a SQLite.
En lugar de llamar a
new Database(...)o similar en muchos sitios, centralizas esa lógica en un módulo. Los modelos no sabrán nada de rutas de archivos, ni de detalles de configuración: solo importarán este cliente. Es la pieza clave que une Node con SQLite de forma controlada. -
src/models/userModel.mjsAquí vive toda la lógica de acceso a datos de la tabla
users.Es el lugar donde se escribirán las consultas SQL: SELECT, INSERT, UPDATE, DELETE, usando consultas preparadas con placeholders. La idea es que, si mañana cambias la estructura de la tabla
userso añades índices, solo tengas que tocar este archivo, no todo el proyecto. El modelo “habla el idioma de la base de datos”, pero no sabe nada de HTTP ni de peticiones. -
src/controllers/userController.mjsEl controlador se sitúa un nivel por encima del modelo.
Su tarea típica será:
-
Recibir parámetros de la petición (por ejemplo, el id del usuario o el body JSON).
-
Llamar a la función adecuada del modelo (
getUserById,createUser, etc.). -
Decidir qué respuesta devolver (código de estado, JSON, mensajes de error).
El controlador “habla el idioma de la API”: sabe qué endpoints existen, qué formato de respuesta se espera, y cómo traducir un error de base de datos a un mensaje amigable para el cliente.
-
-
src/http/server.mjsEste archivo será el punto de entrada del servidor HTTP nativo de Node.
Usará el módulo
httpde Node para:-
Escuchar en un puerto.
-
Analizar el método (GET, POST, etc.) y la URL.
-
Delegar la lógica en el controlador adecuado (por ejemplo, en
userController).El servidor no debería contener consultas SQL, ni lógica de negocio compleja, ni detalles de cómo se guardan los datos.
Su responsabilidad es simplemente recibir la petición, enrutarlas hacia el controlador correcto y devolver la respuesta.
-
Sobre package.json:
- En este proyecto será importante, entre otras cosas, para definir que vamos a usar ES Modules (con
type: "module"), y para gestionar dependencias como el paquete de SQLite que elijas (por ejemplo,better-sqlite3osqlite3).
La idea general que queremos reforzar es esta:
http/se ocupa de HTTP.controllers/se ocupa de coordinar peticiones y respuestas.models/se ocupa de hablar con SQLite.db/se ocupa de abrir y configurar la conexión a la base de datos.data/guarda físicamente la base de datos.
Cuando terminemos este bloque, deberías poder mirar esta estructura y entender, de arriba abajo, cómo se conectan Node y SQLite sin ningún framework: desde la petición HTTP, pasando por controlador y modelo, hasta llegar a las consultas SQL preparadas que se ejecutan contra app.db.
Paso 1: Crear el proyecto y dependencias
En PowerShell, dentro de la carpeta donde quieras crear el proyecto:
mkdir mi-api-sqlite
cd mi-api-sqlite
npm init -y
npm install sqlite3
Ahora editas package.json para activar ES Modules:
{
"name": "mi-api-sqlite",
"version": "1.0.0",
"type": "module",
"main": "src/http/server.mjs",
"scripts": {
"start": "node src/http/server.mjs"
},
"dependencies": {
"sqlite3": "^5.1.7"
}
}
La clave es "type": "module", que te permite usar import y export.
Paso 2: Crear la base de datos y la carpeta data
Hasta ahora te enseñé cómo crear app.db usando la terminal, pero si no estás cómodo con la CLI, lo ideal es hacerlo directamente desde VSCode, de forma totalmente visual y muy intuitiva.
SQLite no necesita “instalación” ni comandos especiales para crear una base: un archivo vacío ya es una base de datos válida.
Es el propio motor (en Node, mediante la librería que usemos) quien escribirá dentro del archivo cuando ejecute consultas CREATE TABLE, INSERT, etc.
Así que vamos a crear ese archivo de la forma más sencilla posible.
1. Crear la carpeta data desde VSCode
- En la barra lateral izquierda de VSCode, haz clic derecho sobre tu proyecto.
- Selecciona New Folder.
- Llámala data.
Esta carpeta contendrá tu archivo SQLite (app.db) y, si lo deseas más adelante, copias de seguridad, exportaciones, etc.
2. Crear el archivo app.db de forma visual
- Clic derecho dentro de la carpeta
data. - Selecciona New File.
- Escribe app.db como nombre.
- Pulsa Enter.
Listo: ya tienes un archivo SQLite válido.
Aunque esté vacío, Node podrá abrirlo y crear tablas dentro.
Esto es muy importante:
- No hace falta instalar SQLite en el sistema.
- No hace falta ejecutar comandos raros.
- No hace falta usar la CLI de sqlite3.
- El archivo en blanco se convierte automáticamente en base de datos cuando Node lo abra.
3. Confirmar que funciona con DB Browser (opcional pero recomendable)
- Abre DB Browser for SQLite.
- Ve a Open Database.
- Selecciona
mi-api-sqlite/data/app.db.
Verás una base vacía sin tablas.
Todo está correcto.
4. Qué haremos después
Más adelante, cuando escribamos el módulo sqliteClient.mjs y el modelo userModel.mjs, ese archivo:
- Se abrirá automáticamente.
- Recibirá las sentencias
CREATE TABLEnecesarias. - Empezará a almacenar datos reales.
Esto es una ventaja de SQLite frente a motores como MySQL o PostgreSQL: la base se crea simplemente creando un archivo vacío.
Paso 3: Módulo de base de datos (sqliteClient.mjs)
Archivo: src/db/sqliteClient.mjs
// src/db/sqliteClient.mjs
// Importamos sqlite3 desde el paquete instalado.
// sqlite3 trabaja con callbacks, pero lo vamos a encapsular
// para que el resto de la app no tenga que preocuparse de los detalles.
import sqlite3 from "sqlite3";
// Activamos el modo "verbose" para que sqlite3 muestre más información útil
// en caso de errores. Es muy práctico durante el desarrollo.
sqlite3.verbose();
// Definimos la ruta del archivo .db. Aquí usamos una ruta relativa:
// el archivo estará dentro de la carpeta "data" en la raíz del proyecto.
// Nota: __dirname no está disponible directamente en ES Modules, así que
// para simplificar, asumimos que el proceso se lanza desde la raíz del proyecto
// y usamos una ruta relativa simple.
const DB_PATH = "data/app.db";
// Creamos una función que devuelve una instancia de Database abierta.
// Podríamos abrir una sola conexión global, pero encapsularlo en una función
// nos da más control y claridad.
export function createDbConnection() {
// new sqlite3.Database abre la conexión con el archivo.
// Si el archivo no existe, sqlite3 lo creará automáticamente.
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error("Error abriendo la base de datos SQLite:", err.message);
} else {
console.log("Conexión SQLite abierta en", DB_PATH);
}
});
return db;
}
// Esta función se encargará de ejecutar el SQL de inicialización de la base de datos.
// Su objetivo es garantizar que la tabla "users" exista antes de empezar a usar la API.
export function initializeDatabase(db) {
// Aquí podríamos tener varias sentencias CREATE TABLE IF NOT EXISTS.
// De momento, solo creamos una tabla "users" sencilla para el ejemplo.
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
// db.serialize asegura que las sentencias se ejecutan en orden.
db.serialize(() => {
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error("Error creando la tabla users:", err.message);
} else {
console.log("Tabla users verificada/creada correctamente.");
}
});
});
}
Por qué necesitamos un módulo dedicado para la base de datos
En un proyecto pequeño podrías abrir la base de datos directamente en el servidor, pero eso genera problemas en cuanto el proyecto crece.
Separar la base de datos en su propio módulo aporta:
- Centralización: todas las partes de la aplicación usan la misma conexión.
- Organización: el servidor HTTP no tiene por qué saber cómo se abre SQLite.
- Escalabilidad: si mañana cambias SQLite por otro motor, este es el único archivo que habría que modificar.
Este diseño reproduce el patrón profesional habitual: una capa para acceso a base de datos, otra para lógica de negocio, otra para HTTP.
Por qué sqlite3.verbose() es útil en desarrollo
Esta llamada activa mensajes adicionales de depuración: errores detallados, avisos sobre consultas malformadas, trazas internas del motor.
Esto no cambia el comportamiento funcional, pero sí mejora muchísimo la detección de errores mientras desarrollas. En producción normalmente se desactiva, pero para un entorno educativo es ideal.
Por qué la ruta de la base es relativa y simple
Has visto que se define:
const DB_PATH = "data/app.db";
Y seguramente te preguntarás:
¿por qué no usamos path.join o __dirname?
La respuesta es pedagógica: en ES Modules no existe __dirname por defecto, para una arquitectura clara, asumimos siempre que se ejecuta la app desde la raíz del proyecto, Node resolverá correctamente la ruta si sigues esa convención.
Por qué devolvemos la conexión en createDbConnection()
Aunque podríamos crear una conexión global, devolver el objeto db permite: tener más control sobre el ciclo de vida de la conexión, pasar la misma instancia a modelos y a procesos de inicialización, facilitar el testeo (tests con bases de datos distintas). Este diseño modular imita el estilo de ORMs ligeros o arquitecturas profesionales.
Por qué usamos serialize() para las sentencias de inicialización
SQLite permite ejecutar operaciones en paralelo, pero al iniciar la base: crear tablas, realizar migraciones, cargar datos iniciales, es fundamental que todo ocurra en un orden estricto.
db.serialize() garantiza eso. Sin esta llamada, SQLite podría intercalar operaciones, provocando errores como:
- Intentar insertar datos antes de que exista una tabla.
- Intentar realizar un SELECT a una tabla que aún no se ha creado.
Así que serialize se convierte en tu “modo seguro” para preparar la base.
Por qué usamos CREATE TABLE IF NOT EXISTS
Aquí surge un punto esencial: “Estamos creando la base de datos desde el backend, no desde DB Browser”.
Esto implica: no tienes que entrar en DB Browser cada vez que arrancas el backend, si borras app.db, el backend se encargará de reconstruir las tablas, cuando despliegues este backend en otro ordenador, se autoconfigurará al arrancar. Esto te acerca al concepto de migraciones (que veremos más adelante): el backend garantiza que la estructura mínima siempre existe.
Qué pasará después
Una vez tengas este módulo:
- El servidor lo importará para abrir la conexión.
- Los modelos lo importarán para ejecutar consultas preparadas.
- La base se inicializará automáticamente al arrancar.
En bloques siguientes, veremos cómo: convertir consultas SQL sueltas en modelos reutilizables, trabajar con placeholders desde Node, manejar errores de base de datos correctamente, integrar todo con el servidor HTTP.
Paso 4: Modelo de usuarios (userModel.mjs)
El modelo se encarga de hablar directamente con SQLite. Usa consultas preparadas, placeholders y devuelve resultados de forma limpia.
Archivo: src/models/userModel.mjs
// src/models/userModel.mjs
// Este módulo define las operaciones de acceso a datos para la tabla "users".
// No sabe nada de HTTP ni de rutas. Solo sabe hablar con la base de datos.
import { createDbConnection } from "../db/sqliteClient.mjs";
// Función para crear un nuevo usuario.
// Recibe un objeto con email y name, y devuelve una Promise con el nuevo usuario insertado.
export function createUser({ email, name }) {
const db = createDbConnection();
// Usamos la fecha actual en formato ISO sencillo.
const createdAt = new Date().toISOString();
const sql = `
INSERT INTO users (email, name, created_at)
VALUES (?, ?, ?);
`;
// Devolvemos una Promise para poder usar async/await en capas superiores.
return new Promise((resolve, reject) => {
// db.run ejecuta la sentencia. El segundo parámetro es un array de valores
// para los placeholders "?" en el mismo orden.
db.run(sql, [email, name, createdAt], function (err) {
if (err) {
// Si hay un error (por ejemplo, email duplicado), lo rechazamos.
reject(err);
} else {
// this.lastID contiene el id autoincrement que SQLite asignó al nuevo registro.
const newUser = {
id: this.lastID,
email,
name,
created_at: createdAt
};
resolve(newUser);
}
// Cerramos la conexión después de la operación para este ejemplo simple.
// En una app real, podríamos compartir una conexión única o un pool.
db.close();
});
});
}
// Función para obtener todos los usuarios.
export function getAllUsers() {
const db = createDbConnection();
const sql = `
SELECT id, email, name, created_at
FROM users
ORDER BY id ASC;
`;
return new Promise((resolve, reject) => {
// db.all devuelve todas las filas que cumplan la consulta.
db.all(sql, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
db.close();
});
});
}
// Función para obtener un usuario por id.
export function getUserById(id) {
const db = createDbConnection();
const sql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
return new Promise((resolve, reject) => {
// db.get devuelve una sola fila (o undefined si no existe).
db.get(sql, [id], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
db.close();
});
});
}
Este archivo es clave dentro de la arquitectura porque es la única capa que habla directamente con SQLite.
- El servidor HTTP no sabe SQL.
- Los controladores tampoco deberían saber SQL.
- El modelo es el punto donde Node y SQLite realmente se encuentran.
A continuación te explico punto por punto decisiones importantes del diseño.
Por qué el modelo importa createDbConnection
El modelo no crea la base por sí mismo: solo pide una conexión al módulo sqliteClient.mjs.
Esto mantiene todas las responsabilidades separadas: sqliteClient.mjs sabe dónde está el archivo .db, sabe cómo abrirlo, sabe cómo inicializarlo.
El modelo solo se dedica a ejecutar consultas. De esta forma, si mañana cambiaste el motor de base de datos, este archivo sería el que menos tendrías que modificar.
Por qué cada función abre y cierra su propia conexión (y cuándo cambiarlo)
En tu versión, cada operación abre una conexión con createDbConnection() y la cierra al terminar.
Esto tiene dos ventajas muy importantes para el aprendizaje:
- Cada función del modelo es independiente y no necesita entender cómo comparte conexiones.
- Evita errores del tipo “la base está cerrada”, “la conexión no existe” o “dos consultas se pisan”.
Por qué el modelo devuelve Promesas
SQLite en Node (usando sqlite3) funciona con callbacks, pero: los controladores serán async, el servidor HTTP también gestionará peticiones async, y tú querrás escribir:
const users = await getAllUsers();
Por eso el modelo envuelve la operación en una Promise.
Esto es clave para integrar SQLite con un backend moderno en Node sin frameworks:
- Hace que el flujo sea más lineal.
- Evita el “callback hell”.
- Permite encadenar lógica con async/await de forma natural.
En otras palabras: “La base funciona con callbacks, el resto del backend con async/await. El modelo es el adaptador entre ambos mundos”.
Por qué se usan placeholders en todas las consultas
Ya lo has visto ampliamente en temas de seguridad: concatenar valores del usuario en una query es inaceptable.
Aquí aplicamos la misma regla profesional:
- Siempre placeholders (
?) - Siempre parámetros en array (
[email, name, createdAt])
Esto protege de: inyección SQL, errores por comillas internas, y problemas al interpretar tipos de datos.
Además, los placeholders ayudan al motor a reutilizar parte del plan de ejecución (ligera optimización automática).
Qué significa this.lastID en una inserción
En sqlite3, cuando ejecutas un INSERT, el callback recibe un contexto (this) que contiene:
this.lastID: ID autogenerado del registro insertado,this.changes: número de filas afectadas.
Usar this.lastID te permite construir un objeto completo del nuevo usuario antes de devolverlo:
{
id: this.lastID,
email,
name,
created_at: createdAt
}
Esto es muy útil porque así: la capa superior no tiene que volver a preguntar a la base, no ejecutas un segundo SELECT, reduzco la carga y evito errores.
Por qué getAllUsers usa db.all y getUserById usa db.get
SQLite distingue varios métodos:
run: para INSERT/UPDATE/DELETE (no devuelven filas).all: devuelve todas las coincidencias (array).get: devuelve una sola fila (objeto).
Usar un método incorrecto generaría confusión o errores. Este patrón es habitual en el backend, en ORMs y también en Node “a pelo”.
Por qué el modelo nunca devuelve respuestas HTTP
El modelo no sabe nada de HTTP: no devuelve códigos 404, no envía JSON, no sabe qué es una petición o una ruta.
Esto es fundamental en arquitectura profesional: el modelo trabaja con datos y SQL, el controlador trabaja con lógica de API y respuestas HTTP, el servidor HTTP solo enruta peticiones.
Así se evita mezclar responsabilidad y se consigue una base sólida para crecer.
Qué falta y se añadirá más adelante
Tu modelo actual hace lo básico: insertar usuarios, obtener todos, obtener por id.
Luego añadiremos: updateUser, deleteUser, validaciones internas, transacciones, modelos relacionados (orders, tokens, etc.).
Pero lo más importante es que ya tienes el flujo completo:
Node - modelo - SQLite - modelo - controlador - servidor - cliente
Paso 5: Controlador de usuarios (userController.mjs)
El controlador es la capa que habla con el modelo y prepara la respuesta para el servidor HTTP. No sabe nada de sockets ni de low-level HTTP, solo recibe datos y devuelve objetos o errores.
Archivo: src/controllers/userController.mjs
// src/controllers/userController.mjs
// Este módulo actúa como capa intermedia entre el servidor HTTP
// y el modelo de datos. Se encarga de:
// - Validar datos básicos.
// - Llamar al modelo adecuado.
// - Decidir qué devolver al servidor (objeto, error, etc.).
import {
createUser,
getAllUsers,
getUserById
} from "../models/userModel.mjs";
// Crear un usuario a partir de un cuerpo JSON.
export async function handleCreateUser(body) {
// Validación muy básica.
if (!body || typeof body.email !== "string" || typeof body.name !== "string") {
// Lanzamos un error que luego el servidor traducirá a 400 Bad Request.
const error = new Error("Datos inválidos. Se requieren 'email' y 'name' como cadenas.");
error.statusCode = 400;
throw error;
}
// Podrías añadir más validaciones (formato de email, longitud, etc.).
const newUser = await createUser({
email: body.email,
name: body.name
});
return newUser;
}
// Obtener todos los usuarios.
export async function handleGetAllUsers() {
const users = await getAllUsers();
return users;
}
// Obtener un usuario por id (recibido como string en la URL).
export async function handleGetUserById(idParam) {
const idNumber = Number(idParam);
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
const user = await getUserById(idNumber);
if (!user) {
const error = new Error("Usuario no encontrado.");
error.statusCode = 404;
throw error;
}
return user;
}
En este punto ya tienes claras las dos capas inferiores:
- sqliteClient.mjs que abre la base y la inicializa userModel.mjs que contiene el SQL real: SELECT, INSERT…
Ahora entra en juego el controlador, que es la capa que empieza a acercarse a la API real.
El objetivo del controlador es muy concreto:
- Recibe datos ya parseados (del body, la URL, etc.).
- Los valida mínimamente (para no romper el modelo).
- Llama al modelo y espera el resultado (async/await).
- Devuelve datos o errores, pero sin preocuparse de HTTP “de bajo nivel”.
Esto te permite mantener el código limpio y modular.
Por qué el controlador NO tiene SQL
El controlador debe actuar como una “frontera lógica” entre: el mundo de la API (peticiones, parámetros, JSON, URL) y el mundo de la base de datos (consultas, placeholders, errores SQL)
Si el controlador tuviera SQL, empezarías a mezclar responsabilidades: validación mezclada con SQL, consultas pegadas a decisiones de ruta, código difícil de probar y mantener.
La regla profesional es: “El controlador no escribe SQL. Solo llama a funciones del modelo”.
Por qué el controlador usa async/await
Tus modelos devuelven Promises, porque SQLite trabaja con callbacks.
El controlador usa async/await para: escribir código más legible, manejar errores con try/catch en el servidor, responder al servidor de manera limpia sin callback hell.
Ejemplo conceptual:
const newUser = await createUser({...});
return newUser;
Esto permite que:
- El servidor pueda hacer
await handleCreateUser(body). - El error pueda ser capturado con
.catch()o dentro deltry/catch.
Sin async/await, esta capa se llenaría de callbacks y sería mucho más difícil de enseñar.
Por qué el controlador valida datos antes de pasar al modelo
Aquí ocurre algo muy importante:
- El modelo sabe SQL y base de datos.
- El controlador sabe de API y de reglas mínimas de negocio.
Validaciones típicas del controlador:
- ¿El id es un número entero positivo?
- ¿El email es una cadena?
- ¿Faltan campos obligatorios?
Esto evita: errores absurdos al modelo (como enviar email = undefined), excepciones de SQLite, errores de seguridad (inyección por parámetros incorrectos).
En resumen: “El controlador filtra y limpia la entrada antes de llegar al modelo”.
Si el modelo recibiera datos sin validar, terminarías con SQL rotos o errores difíciles de rastrear.
Por qué el controlador genera errores con statusCode
Cada vez que el controlador detecta un problema (dato inválido, usuario inexistente…), crea un error así:
const error = new Error("Mensaje");
error.statusCode = 400;
throw error;
Esto no es casualidad:
- El servidor HTTP podrá leer
err.statusCodey decidir qué enviar al cliente. - Evitas que las capas inferiores tengan que saber sobre códigos HTTP.
- Mantienes la separación de responsabilidades:
- Controlador decide qué tipo de error es.
- Servidor decide cómo traducirlo a una respuesta HTTP.
Esta técnica es muy común en backends sin framework.
Por qué el controlador devuelve objetos “limpios”
Cuando el modelo devuelve datos (por ejemplo, un usuario), esa respuesta suele estar lista para ser enviada como JSON.
Esto es ideal porque permite que:
- El controlador no tenga que transformar ni adaptar los datos.
- El servidor HTTP pueda hacer simplemente:
response.end(JSON.stringify(result));
Si algún día quisieras mostrar datos de forma diferente: formatear fechas, ocultar campos, añadir metadatos, lo harías aquí, en el controlador, sin tocar el SQL.
Por qué este diseño permite reutilizar la lógica
Como bien apuntas al final, esta capa no depende del protocolo HTTP. Eso tiene un beneficio pedagógico y práctico enorme. Puedes usar este mismo controlador para:
- Un servidor HTTP.
- Una API CLI (por ejemplo, un script que cree usuarios desde consola).
- Un sistema de tests unitarios.
- Cualquier mecanismo que necesite crear/leer usuarios.
Es decir, la lógica de negocio no está acoplada al servidor. Está en un módulo limpio y autoexplicativo.
Qué añadiremos más adelante
Este controlador es básico pero sólido. Después podremos agregar:
- Validación avanzada (email correcto, longitudes, etc.).
- Paginación (limit/offset).
- Filtrado.
- Actualización y borrado.
- Errores personalizados.
- Sanitización.
- Middlewares caseros (como parseo JSON centralizado).
Pero lo importante es que la estructura ya está clara y profesional.
Paso 6: Servidor HTTP básico (server.mjs)
Por fin, el servidor HTTP sin frameworks, usando el módulo http de Node. Aquí es donde se hace el routing manual y se conectan las peticiones con el controlador.
Archivo: src/http/server.mjs
// src/http/server.mjs
// Este archivo arranca un servidor HTTP nativo de Node.js
// que expone una pequeña API REST para la entidad "users":
// - GET /users -> lista todos los usuarios
// - GET /users/:id -> obtiene un usuario por id
// - POST /users -> crea un usuario con JSON { email, name }
import http from "http";
import { initializeDatabase, createDbConnection } from "../db/sqliteClient.mjs";
import {
handleCreateUser,
handleGetAllUsers,
handleGetUserById
} from "../controllers/userController.mjs";
// Primero, nos aseguramos de que la base de datos y la tabla "users" existen.
// Para ello, abrimos una conexión temporal y llamamos a initializeDatabase.
const initDbConnection = createDbConnection();
initializeDatabase(initDbConnection);
// Cerramos la conexión de inicialización cuando termine.
// Aquí usamos un pequeño truco con setTimeout para dar tiempo a que se ejecute el CREATE TABLE.
// En un código más elaborado, podrías usar callbacks o Promises para controlar esto.
setTimeout(() => {
initDbConnection.close();
}, 500);
// Función auxiliar para enviar respuestas JSON con un status code.
function sendJson(response, statusCode, data) {
const json = JSON.stringify(data, null, 2);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(json);
}
// Función auxiliar para leer el cuerpo JSON de una petición POST.
function parseRequestBody(request) {
return new Promise((resolve, reject) => {
let bodyData = "";
request.on("data", (chunk) => {
bodyData += chunk.toString("utf-8");
});
request.on("end", () => {
if (!bodyData) {
resolve(null);
return;
}
try {
const parsed = JSON.parse(bodyData);
resolve(parsed);
} catch (err) {
const error = new Error("El cuerpo de la petición no es JSON válido.");
error.statusCode = 400;
reject(error);
}
});
request.on("error", (err) => {
reject(err);
});
});
}
// Creamos el servidor HTTP.
const server = http.createServer(async (req, res) => {
try {
const { method, url } = req;
// Ruta: GET /users
if (method === "GET" && url === "/users") {
const users = await handleGetAllUsers();
sendJson(res, 200, { ok: true, data: users });
return;
}
// Ruta: GET /users/:id
// Comprobamos si la URL empieza por "/users/" y tiene algo más.
if (method === "GET" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2]; // /users/123 -> ["", "users", "123"]
const user = await handleGetUserById(id);
sendJson(res, 200, { ok: true, data: user });
return;
}
// Ruta: POST /users
if (method === "POST" && url === "/users") {
const body = await parseRequestBody(req);
const newUser = await handleCreateUser(body);
sendJson(res, 201, { ok: true, data: newUser });
return;
}
// Si ninguna ruta coincide, devolvemos 404.
sendJson(res, 404, { ok: false, error: "Ruta no encontrada" });
} catch (err) {
// Manejo de errores centralizado.
console.error("Error en la petición:", err);
const statusCode = err.statusCode || 500;
sendJson(res, statusCode, {
ok: false,
error: err.message || "Error interno del servidor"
});
}
});
// Arrancamos el servidor en un puerto fijo, por ejemplo 3000.
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Servidor HTTP escuchando en http://localhost:${PORT}`);
});
Este archivo es el punto donde todo tu backend cobra vida. Hasta ahora tenías piezas sueltas: la base de datos y su inicialización, un modelo que sabe ejecutar SQL, un controlador que entiende la lógica y validación.
El servidor HTTP es la capa que recibe peticiones reales del mundo exterior y las conecta con tu lógica. Es, en cierto sentido, el “portero” de tu aplicación.
Por qué inicializamos la base de datos antes de arrancar el servidor
El servidor importa:
const initDbConnection = createDbConnection();
initializeDatabase(initDbConnection);
Esto garantiza que: la base de datos existe, las tablas existen, tu API no fallará por “no such table: users”.
Fíjate que este es el equivalente a lo que harías en DB Browser, pero de forma automática cada vez que arrancas el backend. Solo después de asegurarnos de que la base está lista, arrancamos el servidor. Más adelante, cuando introduzcamos migraciones, esta parte evolucionará, pero el concepto central será el mismo: “Una API solo debe arrancar cuando su base de datos está preparada”.
Por qué hay una función sendJson independiente
El servidor escribe respuestas HTTP de bajo nivel usando:
response.writeHead(...)
response.end(...)
Eso es verboso y repetitivo. Por eso se crea sendJson(res, statusCode, data):
- Convierte el objeto en JSON.
- Configura los headers correctamente.
- Devuelve la respuesta en un solo sitio.
Tener esta función aislada:
- Evita duplicar código.
- Permite cambiar el formato globalmente (por ejemplo, añadir logging).
- Hace más legible el servidor.
En frameworks como Express esto viene hecho, pero aquí lo construyes tú para comprenderlo de verdad.
Por qué necesitamos parseRequestBody
Node no parsea el cuerpo de las peticiones por defecto. Eso significa que si llega un POST con JSON, tú recibes trozos en crudo (“chunks”).
parseRequestBody hace dos cosas:
- Reconstruir el body completo a partir de los chunks.
- Intentar parsearlo como JSON.
- Lanzar un error si el JSON es inválido.
Esto es exactamente lo que haría un body-parser de Express, pero aquí lo implementas tú: para entender cómo funciona, para tener control absoluto sobre tu servidor, para construir un backend educativo sin magia.
Cómo funciona el routing manual
Tu servidor usa patrones simples como:
if (method === "GET" && url === "/users") { ... }
o
if (method === "GET" && url.startsWith("/users/")) { ... }
Esto es un ruteo totalmente manual, sin frameworks. Es didáctico porque te muestra la “mecánica real” del servidor: lees método, lees URL, decides qué hacer
Por qué el servidor delega la lógica al controlador
Observa que el servidor no habla con la base de datos, ni con SQL. Todo pasa por el controlador:
const users = await handleGetAllUsers();
o
const newUser = await handleCreateUser(body);
Este diseño mantiene el servidor limpio y tonto. Su trabajo es básicamente:
- Recibir datos crudos.
- Delegar en el controlador.
- Enviar respuestas JSON.
- Centralizar los errores.
Y nada más.
Sin esta separación tendrías un “spaghetti server.mjs” lleno de SQL y lógica mezclada.
Por qué hay manejo de errores centralizado
Toda la lógica está envuelta en un:
try { ... } catch (err) { ... }
Esto hace posible: Atrapar errores del modelo, atrapar errores del controlador, detectar JSON mal formado, devolver un código HTTP correcto según el error. Este patrón es importantísimo:
- No tienes errores dispersos.
- No duplicas código de manejo de errores.
- El servidor es el único responsable de traducir un error interno en un HTTP status code.
En un framework, esto vendría dado por middleware.
Por qué usamos un timeout para cerrar la conexión de inicialización
SQLite crea tablas de forma asincrónica. Para asegurarte de que la tabla existe antes de cerrar la conexión inicial, introduces una pequeña espera:
setTimeout(() => initDbConnection.close(), 500);
En código profesional haríamos esto con callbacks o Promises, pero para una primera versión educativa es una solución simple y clara: la tabla se crea, cerramos la conexión, el servidor queda limpio para usar nuevas conexiones en las peticiones reales. Más adelante, cuando introduzcamos migraciones y async/await aquí también, lo haremos de forma más elegante.
El servidor ya es usable como una API real
Con este servidor puedes ejecutar:
Este es exactamente el flujo completo de un backend real:
HTTP - controlador - modelo - SQLite - modelo - controlador - HTTP
Cómo probarlo
Una vez tengas:
data/app.dbcreado,sqliteClient.mjspreparado, el modelouserModel.mjs,el controladoruserController.mjsy el servidorserver.mjsya puedes arrancar tu mini API y hablar con ella como si fuera un servicio real.
1. Arrancar el servidor desde VSCode
En la raíz del proyecto (donde está package.json), abre la terminal integrada de VSCode:
- Menú superior: View - Terminal
- Asegúrate de que estás en la ruta del proyecto (deberías ver algo tipo
C:\Ruta\mi-api-sqlite>). - Ejecuta:
npm start
Esto utiliza el script start que definiste en package.json (por ejemplo, algo tipo "start": "node src/http/server.mjs").
Si todo va bien deberías ver algo así en la terminal:
- Un mensaje de que la conexión SQLite se ha abierto.
- Un mensaje de que la tabla
usersse ha creado/verificado. - El mensaje:
Servidor HTTP escuchando en http://localhost:3000
En ese momento tu API ya está “viva”.
2. Probar el endpoint GET /users desde el navegador
La forma más básica y visual:
-
Abre tu navegador.
-
Escribe en la barra de direcciones:
http://localhost:3000/users -
Pulsa Enter.
Deberías ver una respuesta JSON. Al principio, como no has creado usuarios, probablemente algo como:
{
"ok": true,
"data": []
}
Esto ya te confirma varias cosas a la vez:
- El servidor está escuchando correctamente.
- El routing funciona para GET /users.
- El modelo se conecta a la base de datos sin errores.
- La tabla
usersexiste.
3. Probar POST /users para crear un nuevo usuario
Para enviar peticiones POST con cuerpo JSON, es más cómodo usar herramientas como:
- Postman
- Thunder Client (extensión de VSCode)
- REST Client (extensión de VSCode)
- o el panel de “Send Request” en algunas extensiones
Te explico una forma cómoda desde VSCode usando REST Client (si quieres usar Postman, el flujo es el mismo, solo cambia la interfaz).
- Instala la extensión “REST Client” en VSCode.
- Crea un archivo nuevo en el proyecto, por ejemplo
requests.http. - Añade algo como:
POST http://localhost:3000/users
Content-Type: application/json
{
"email": "test@example.com",
"name": "Usuario de prueba"
}
- Encima de la línea
POST http://...verás un enlace “Send Request”. - Haz clic ahí.
Si todo está correcto deberías recibir una respuesta similar a:
{
"ok": true,
"data": {
"id": 1,
"email": "test@example.com",
"name": "Usuario de prueba",
"created_at": "2025-12-06T09:23:45.123Z"
}
}
Eso significaría que: el body JSON se ha parseado correctamente, el controlador ha validado los datos, el modelo ha ejecutado un INSERT con placeholders, SQLite ha guardado el usuario en la tabla, la API ha devuelto el usuario recién creado con su id asignado.
4. Verificar en DB Browser que los datos están realmente en SQLite
Para reforzar mentalmente la conexión Node - SQLite:
- Abre DB Browser for SQLite.
- Archivo - Open Database.
- Selecciona
data/app.dbdentro de tu proyecto. - Ve a la pestaña “Browse Data”.
- Escoge la tabla
users.
Deberías ver la fila correspondiente al usuario que acabas de crear desde tu API. Este paso es muy importante a nivel didáctico: “visualizas que las peticiones HTTP están modificando un archivo real de SQLite, no es algo “mágico”.
5. Probar GET /users/1
Ahora que ya tienes al menos un usuario, puedes probar:
- Con el navegador:
http://localhost:3000/users/1o desderequests.http:
GET http://localhost:3000/users/1
La respuesta debería ser:
{
"ok": true,
"data": {
"id": 1,
"email": "test@example.com",
"name": "Usuario de prueba",
"created_at": "2025-12-06T09:23:45.123Z"
}
}
Si pides un id que no existe, por ejemplo /users/999, deberías obtener:
{
"ok": false,
"error": "Usuario no encontrado."
}
con un código HTTP 404. Eso confirma que el controlador está manejando bien la ausencia de datos.
6. Qué hacer si aparecen errores
Algunas cosas a revisar si algo falla:
-
¿Se está ejecutando
npm starten la raíz del proyecto y no dentro de otra carpeta? -
¿Existe el archivo
data/app.dben la ruta esperada? -
¿Hay algún error de sintaxis en los imports (rutas relativas) entre server, controllers y models?
-
¿La respuesta dice “El cuerpo de la petición no es JSON válido”?
Revisa que hayas puesto correctamente
Content-Type: application/jsony que el JSON no tenga comas de más o comillas incorrectas.
La consola de VSCode (donde corre npm start) será tu mejor amiga para detectar y entender el error.
7. A partir de aquí, cómo seguir creciendo
Una vez has comprobado que: GET /users funciona, POST /users crea registros reales en SQLite, GET /users/:id recupera usuarios, los errores se devuelven con JSON y códigos adecuados, ya tienes la base perfecta para seguir.
En siguientes pasos naturales podrás:
- Añadir endpoints de actualización:
- PUT /users/:id para actualizar nombre o email
- Añadir endpoints de borrado:
- DELETE /users/:id
- Introducir transacciones para operaciones que afecten a varias tablas.
- Extender el modelo y el controlador con más recursos:
- por ejemplo, una tabla
ordersconectada conusers.
- por ejemplo, una tabla
- Reorganizar la arquitectura hacia un MVC más completo, sin perder la claridad actual.
- Migrar de
sqlite3abetter-sqlite3onode:sqlitesi quieres un estilo más síncrono o más moderno, manteniendo la misma estructura mental.
Extender la mini API: rutas de update, delete y uso de transacciones
Vamos a seguir trabajando sobre el mismo proyecto que ya tienes montado. El objetivo ahora es:
- Añadir rutas para actualizar y borrar usuarios.
- Introducir una operación que use una transacción real en SQLite.
- Mantener el mismo estilo de comentarios extensos y estructura clara.
Te propongo este escenario concreto:
- Añadimos:
- PUT /users/:id : actualizar email y name de un usuario.
- DELETE /users/:id : borrar un usuario, pero con una transacción que:
- Inserta una entrada en una tabla de logs de borrado.
- Borra el usuario.
- Si algo falla, no se borra nada.
De paso, añadiremos una tabla nueva user_deletions_log para demostrar la transacción.
Voy a mostrarte los archivos completos actualizados para que puedas copiar y sustituir sin pensar demasiado.
1. Actualizar sqliteClient.mjs para añadir la tabla de logs
Archivo: src/db/sqliteClient.mjs
// src/db/sqliteClient.mjs
// Este módulo se encarga de:
// - Abrir la conexión con el archivo SQLite.
// - Ejecutar la inicialización de la base de datos (crear tablas si no existen).
import sqlite3 from "sqlite3";
sqlite3.verbose();
// Ruta del archivo de base de datos.
// Asumimos que el proceso se levanta desde la raíz del proyecto,
// por lo que "data/app.db" es una ruta válida.
const DB_PATH = "data/app.db";
// Crea y devuelve una nueva conexión a la base de datos.
// En este ejemplo abrimos y cerramos la conexión en cada operación
// para simplificar. Más adelante podrías refactorizarlo a un patrón
// de conexión única o un "pool" simple.
export function createDbConnection() {
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error("Error abriendo la base de datos SQLite:", err.message);
} else {
console.log("Conexión SQLite abierta en", DB_PATH);
}
});
return db;
}
// Inicializa la base de datos creando las tablas necesarias si aún no existen.
// Aquí definimos tanto "users" como "user_deletions_log" para poder
// demostrar operaciones con transacciones.
export function initializeDatabase(db) {
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
// Tabla para registrar borrados de usuarios.
// Cada vez que un usuario se borre, insertaremos una fila aquí dentro
// de una transacción, para tener un historial mínimo.
const createUserDeletionsLogTableSQL = `
CREATE TABLE IF NOT EXISTS user_deletions_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL,
deleted_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
db.serialize(() => {
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error("Error creando la tabla users:", err.message);
} else {
console.log("Tabla users verificada/creada correctamente.");
}
});
db.run(createUserDeletionsLogTableSQL, (err) => {
if (err) {
console.error("Error creando la tabla user_deletions_log:", err.message);
} else {
console.log("Tabla user_deletions_log verificada/creada correctamente.");
}
});
});
}
Qué estamos haciendo realmente en sqliteClient.mjs
Este paso amplía el módulo de base de datos para dos cosas: seguir asegurando que la tabla users exista, añadir una nueva tabla user_deletions_log que nos permitirá registrar cada borrado dentro de una transacción. La idea didáctica es clara: “No solo borramos datos, sino que dejamos “rastro” de lo que ha pasado”.
Eso lo vas a ver después cuando la transacción haga: insertar en user_deletions_log, borrar el usuario de users. Si falla alguno de esos pasos, la transacción se deshace y no se pierde coherencia.
Por qué añadir user_deletions_log
La tabla user_deletions_log cumple varias funciones interesantes:
-
Es un ejemplo realista: en muchos sistemas no se “destruyen” usuarios sin dejar constancia.
-
Te permite practicar transacciones con algo con sentido:
primero registras el borrado, luego borras el usuario.
-
Es una forma de entender que una base de datos no es solo para “guardar el estado actual”, sino también para guardar historial.
La estructura básica:
user_id: a quién se borró.email: qué email tenía en el momento del borrado.deleted_at: cuándo se borró.FOREIGN KEY (user_id) REFERENCES users(id): vínculo con la tabla original.
Aunque borres al usuario de users, este log guarda una referencia a su id y email en el momento del borrado, lo que te permite auditar acciones.
Por qué se definen las dos tablas en initializeDatabase
En initializeDatabase ahora se crean: la tabla users, la tabla user_deletions_log.
Esto garantiza que, siempre que arranques tu backend: si las tablas no existen, se crean, si ya existen, no pasa nada (gracias a IF NOT EXISTS).
Por qué seguimos usando db.serialize
Tanto para users como para user_deletions_log se usan sentencias db.run dentro de db.serialize.
Eso significa: primero se ejecuta la creación de users, después la de user_deletions_log, todo en orden, sin intercalarse.
Es importante porque: user_deletions_log tiene una clave foránea a users, aunque SQLite permite CREATE TABLE en cualquier orden, conceptualmente es más claro que la tabla principal exista “antes” que la tabla que la referencia.
Qué prepara esto para el siguiente paso
Con este cambio en sqliteClient.mjs ya tienes: la base app.db lista para guardar usuarios, una tabla user_deletions_log lista para registrar borrados, inicialización automática en el arranque del servidor.
En el siguiente paso, cuando implementemos: DELETE /users/:id, la transacción que registra el borrado y elimina el usuario, esta tabla será la pieza clave:
- Verás cómo dentro de una transacción se hace:
INSERT INTO user_deletions_log (...)DELETE FROM users WHERE id = ?
- Y cómo, si algo falla en la inserción o en el borrado, se hace
ROLLBACKy no se queda nada a medias.
2. Ampliar el modelo de usuarios con update y delete con transacción
Archivo: src/models/userModel.mjs
// src/models/userModel.mjs
// Este módulo implementa todas las operaciones de acceso a datos
// relacionadas con la entidad "users" y (en una operación concreta)
// la tabla de logs "user_deletions_log".
// No sabe nada de HTTP ni de rutas; solo habla con SQLite.
import { createDbConnection } from "../db/sqliteClient.mjs";
// Crea un usuario nuevo.
export function createUser({ email, name }) {
const db = createDbConnection();
const createdAt = new Date().toISOString();
const sql = `
INSERT INTO users (email, name, created_at)
VALUES (?, ?, ?);
`;
return new Promise((resolve, reject) => {
db.run(sql, [email, name, createdAt], function (err) {
if (err) {
reject(err);
} else {
const newUser = {
id: this.lastID,
email,
name,
created_at: createdAt
};
resolve(newUser);
}
db.close();
});
});
}
// Obtiene todos los usuarios.
export function getAllUsers() {
const db = createDbConnection();
const sql = `
SELECT id, email, name, created_at
FROM users
ORDER BY id ASC;
`;
return new Promise((resolve, reject) => {
db.all(sql, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
db.close();
});
});
}
// Obtiene un usuario por id.
export function getUserById(id) {
const db = createDbConnection();
const sql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
return new Promise((resolve, reject) => {
db.get(sql, [id], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
db.close();
});
});
}
// Actualiza un usuario por id.
// Recibe el id y un objeto con los nuevos valores de email y name.
// Devuelve el usuario ya actualizado o null si no existía.
export function updateUser({ id, email, name }) {
const db = createDbConnection();
const sql = `
UPDATE users
SET email = ?, name = ?
WHERE id = ?;
`;
return new Promise((resolve, reject) => {
db.run(sql, [email, name, id], function (err) {
if (err) {
db.close();
reject(err);
return;
}
// this.changes indica cuántas filas han sido afectadas por el UPDATE.
if (this.changes === 0) {
db.close();
resolve(null);
return;
}
// Si se ha actualizado, ahora recuperamos la fila ya modificada.
const selectSql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
db.get(selectSql, [id], (err2, row) => {
db.close();
if (err2) {
reject(err2);
} else {
resolve(row || null);
}
});
});
});
}
// Borra un usuario por id dentro de una transacción y registra un log en user_deletions_log.
// Flujo de la transacción:
// BEGIN
// 1) SELECT del usuario (para leer email y validar que existe)
// 2) INSERT en user_deletions_log
// 3) DELETE en users
// COMMIT
// Si algo falla, ROLLBACK y no se borra nada.
export function deleteUserWithLog(id) {
const db = createDbConnection();
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run("BEGIN TRANSACTION;", (err) => {
if (err) {
db.close();
reject(err);
return;
}
// Paso 1: leer el usuario
const selectSql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
db.get(selectSql, [id], (errSelect, userRow) => {
if (errSelect) {
db.run("ROLLBACK;", () => {
db.close();
reject(errSelect);
});
return;
}
if (!userRow) {
// El usuario no existe; deshacemos la transacción y devolvemos null.
db.run("ROLLBACK;", () => {
db.close();
resolve(null);
});
return;
}
// Paso 2: insertar en el log
const deletedAt = new Date().toISOString();
const insertLogSql = `
INSERT INTO user_deletions_log (user_id, email, deleted_at)
VALUES (?, ?, ?);
`;
db.run(
insertLogSql,
[userRow.id, userRow.email, deletedAt],
function (errInsertLog) {
if (errInsertLog) {
db.run("ROLLBACK;", () => {
db.close();
reject(errInsertLog);
});
return;
}
// Paso 3: borrar de users
const deleteSql = `
DELETE FROM users
WHERE id = ?;
`;
db.run(deleteSql, [id], function (errDelete) {
if (errDelete) {
db.run("ROLLBACK;", () => {
db.close();
reject(errDelete);
});
return;
}
// Si hemos llegado hasta aquí, todo ha ido bien.
db.run("COMMIT;", (errCommit) => {
db.close();
if (errCommit) {
reject(errCommit);
} else {
// Devolvemos información del usuario borrado y el timestamp del borrado.
resolve({
deleted: true,
user: userRow,
deleted_at: deletedAt
});
}
});
});
}
);
});
});
});
});
}
Qué aporta esta ampliación del modelo
Con este modelo ampliado, tu backend ya puede:
- Crear usuarios (
createUser). - Listarlos (
getAllUsers). - Obtener uno (
getUserById). - Actualizarlo (
updateUser). - Borrarlo de forma segura con registro y transacción (
deleteUserWithLog).
Es decir, tienes un CRUD casi completo, pero con un matiz importante en el borrado: no es un DELETE “a pelo”, sino un DELETE auditado y transaccional.
updateUser: patrón profesional de actualización
La función updateUser({ id, email, name }) sigue un patrón que merece la pena resaltar:
-
Ejecutar un
UPDATEcon placeholders:UPDATE users SET email = ?, name = ? WHERE id = ?; -
Revisar
this.changes:- Si
this.changes === 0, significa que ninguna fila ha sido afectada, probablemente el id no existía, devolvemosnull. - Así el controlador puede decidir si devuelve un 404, por ejemplo.
- Si
-
Si sí se ha actualizado, hacemos un
SELECT ... WHERE id = ?para recuperar la fila ya modificada y devolverla como resultado.
Este flujo tiene varias ventajas:
- El controlador no tiene que repetir lógica para comprobar si existe el usuario.
- La API puede devolver el recurso actualizado directamente (ideal para clientes frontend).
- El modelo deja clara la diferencia entre “no existe” (
null) y “error de base de datos” (rechazo de la Promise).
Este patrón es perfectamente trasladable a otras entidades (products, orders, etc.).
Por qué deleteUserWithLog es un buen ejemplo de transacción
La función deleteUserWithLog está construida para demostrar un caso realista de uso de transacciones:
“No solo borro, sino que quiero dejar constancia de lo que he borrado. Y si algo falla en el log o en el delete, no quiero que nada quede a medias”.
El flujo es:
BEGIN TRANSACTION- SELECT del usuario a borrar (leer datos y verificar existencia)
- INSERT en
user_deletions_log - DELETE en
users COMMITsi todo va bienROLLBACKsi algo falla en cualquier paso
La idea clave de una transacción es: “O se hacen todos los pasos, o no se hace ninguno”.
Aquí tienes un caso de libro:
- Si falla el INSERT en el log, no borramos al usuario.
- Si falla el DELETE del usuario, deshacemos también el log.
- Si el usuario no existe, no hacemos nada y devolvemos
nullsin dejar basura.
En términos de consistencia de datos, es mucho más robusto que un DELETE suelto.
Por qué usamos db.serialize para la transacción
En SQLite, db.serialize(() => { ... }) garantiza que: todas las operaciones dentro del bloque se ejecutan en orden, no se intercalan con otras operaciones en la misma conexión.
En una transacción, eso es justo lo que necesitas:
BEGIN TRANSACTION;- SELECT del usuario
- INSERT en el log
- DELETE del usuario
COMMIT;
Si no usaras serialize, existe el riesgo de que otros db.run pudieran colarse en medio si compartieras conexión, lo que rompería la lógica.
En este modelo pedagógico donde cada función abre su propia conexión, ya estás bastante seguro, pero usar serialize refuerza: el orden, la legibilidad: “todo este bloque pertenece a la misma historia”.
Manejo de errores paso a paso en la transacción
Fíjate que en cada paso se sigue el mismo patrón:
- Si hay error: ejecutar
ROLLBACK;luego cierra la conexión, y si no ,reject(err)
Esto ocurre en: error al leer el usuario, error al insertar en el log, error al borrar en users, error al hacer COMMIT.
La ventaja de este patrón es que: el controlador solo ve un reject normal de la Promise, no tiene que preocuparse por el estado interno de la transacción, la base de datos nunca queda en un estado a medias.
Además, en el caso especial en que el usuario no existe: no se considera un error técnico, no se lanza una excepción, se hace ROLLBACK y se devuelve null. Eso permite al controlador traducirlo a un 404 de forma limpia.
Por qué se devuelve información del usuario borrado
Al final, si todo ha ido bien, se hace:
resolve({ deleted: true, user: userRow, deleted_at: deletedAt })
Es decir, el modelo devuelve: que se ha borrado (deleted: true), qué usuario era (antes de borrarlo), cuándo se ha borrado (deleted_at).
Esto es muy útil porque: el controlador puede enviar esta información al cliente, podrías mostrar en un panel de administración: “Se ha borrado el usuario X con email Y a tal hora”. Y esta misma información es justo la que se guarda en user_deletions_log.
Relación entre esta función y la tabla user_deletions_log
La tabla user_deletions_log se diseñó específicamente para esta operación:
user_id: se toma de la fila leída en el SELECT,email: también se toma del SELECT (para guardar el email en el momento del borrado),deleted_at: la marca de tiempo que generas connew Date().toISOString().
Esto hace que el log tenga datos completos, aunque el usuario original ya no exista en users.
Si más adelante quisieras, podrías:
- Crear un
userDeletionsModel. - Listar los logs.
- Buscar quién borró qué y cuándo (si añades más columnas, como
deleted_by).
Ventaja didáctica: tienes los tres niveles de complejidad
Si te fijas, en el modelo ahora hay tres niveles claros de complejidad creciente:
- Consultas simples:
createUser,getAllUsers,getUserById.Son SELECT/INSERT directos.
- Consulta de actualización con lógica adicional:
updateUser.Usathis.changes, hace un segundo SELECT, decide entrenully un usuario.
- Operación compleja con transacción:
deleteUserWithLog.UsaBEGIN,COMMIT,ROLLBACK, múltiples sentencias y decide qué hacer si el usuario no existe.
Esto te permite diseñar una unidad didáctica donde: primero haces las funciones simples, después introduces el patrón de update, y finalmente cierras con una transacción realista.
3. Actualizar el controlador de usuarios para manejar update y delete
Archivo: src/controllers/userController.mjs
// src/controllers/userController.mjs
// Este módulo forma parte de la "capa de controladores" de tu aplicación.
// Su responsabilidad principal es:
// 1. Recibir datos "sucios" que vienen de la capa HTTP (por ejemplo, del body de una petición o de params).
// 2. Validar y transformar esos datos (tipos, rangos, campos obligatorios).
// 3. Llamar a las funciones del modelo (userModel.mjs), que son las que hablan con la base de datos.
// 4. Traducir los errores de validación a objetos Error con un statusCode apropiado,
// para que la capa HTTP (por ejemplo, server.mjs) pueda responder con el código HTTP correcto.
// Importamos las funciones del modelo de usuarios.
// Estas funciones son las que ejecutan consultas a la base de datos o realizan operaciones de negocio.
import {
createUser,
getAllUsers,
getUserById,
updateUser,
deleteUserWithLog
} from "../models/userModel.mjs";
// Crear un usuario nuevo a partir de un body JSON.
// Esta función NO sabe nada de HTTP directamente: solo recibe un "body" ya parseado (por ejemplo, desde server.mjs).
// La idea es que server.mjs se encargue de:
// - Leer la petición HTTP.
// - Parsear el JSON del body.
// - Llamar a handleCreateUser(body).
// Si hay algún problema de validación, lanzamos un Error con statusCode 400 (Bad Request),
// y será la capa HTTP quien lo convierta en una respuesta HTTP.
export async function handleCreateUser(body) {
// Validamos que el body exista y que tenga email y name como cadenas.
// Esta validación es muy básica, pero suficiente para demostrar la intención del controlador:
// asegurarse de que al modelo solo le llegan datos coherentes.
if (!body || typeof body.email !== "string" || typeof body.name !== "string") {
const error = new Error("Datos inválidos. Se requieren 'email' y 'name' como cadenas.");
// Adjuntamos un statusCode personalizado al Error.
// Más adelante, en server.mjs, podrás leer error.statusCode y devolver ese código HTTP.
error.statusCode = 400;
// Al lanzar el error, la promesa se rechaza y el flujo de ejecución pasa al catch de la capa superior.
throw error;
}
// Si los datos son válidos, delegamos la creación real del usuario al modelo.
// Aquí no se decide cómo se inserta en la base de datos; eso lo hace createUser.
const newUser = await createUser({
email: body.email,
name: body.name
});
// Devolvemos el usuario creado. La capa HTTP se encargará de serializarlo a JSON y enviarlo al cliente.
return newUser;
}
// Obtener todos los usuarios.
// Esta función es muy sencilla: no hay parámetros que validar, simplemente llama al modelo.
// Aun así, la mantenemos en la capa de controladores para ser consistentes con el resto
// y para tener un punto donde, en el futuro, podamos aplicar filtros, paginación, etc.
export async function handleGetAllUsers() {
const users = await getAllUsers();
// Devolvemos el array de usuarios tal cual nos lo entrega el modelo.
return users;
}
// Obtener un usuario por id.
// Aquí sí hay lógica de validación porque el id normalmente viene como string (por ejemplo, de la URL).
// Responsabilidades:
// 1. Convertir el parámetro id (string) a número.
// 2. Comprobar que es un entero positivo.
// 3. Pedir al modelo que busque ese usuario.
// 4. Si no existe, lanzar un error 404.
export async function handleGetUserById(idParam) {
// Convertimos el parámetro (que probablemente llega como string) a número.
const idNumber = Number(idParam);
// Comprobamos que idNumber es un entero y mayor que 0.
// Esto evita consultas absurdas a la base de datos y nos da errores más claros.
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400; // Bad Request, porque el cliente envió un parámetro inválido.
throw error;
}
// Si el id es válido, pedimos el usuario al modelo.
const user = await getUserById(idNumber);
// Si el modelo devuelve null/undefined, significa que no existe un usuario con ese id.
if (!user) {
const error = new Error("Usuario no encontrado.");
// 404 indica que el recurso solicitado no existe.
error.statusCode = 404;
throw error;
}
// Si todo va bien, devolvemos el usuario encontrado.
return user;
}
// Actualizar un usuario por id con datos del body JSON.
// Esta función combina las dos validaciones anteriores:
// - Validar el id (como en handleGetUserById).
// - Validar los campos del body (como en handleCreateUser).
// Luego llama al modelo para hacer el update y gestiona el caso de "usuario no encontrado".
export async function handleUpdateUser(idParam, body) {
// Primero, validamos el id.
const idNumber = Number(idParam);
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Después, validamos el body (igual que al crear un usuario).
if (!body || typeof body.email !== "string" || typeof body.name !== "string") {
const error = new Error("Datos inválidos. Se requieren 'email' y 'name' como cadenas.");
error.statusCode = 400;
throw error;
}
// Si todo es válido, delegamos en el modelo la actualización real.
// El modelo decidirá cómo construir y ejecutar la sentencia SQL.
const updatedUser = await updateUser({
id: idNumber,
email: body.email,
name: body.name
});
// Si el modelo devuelve algo "falsy" (por ejemplo, null),
// interpretamos que el usuario con ese id no existía y no se ha podido actualizar.
if (!updatedUser) {
const error = new Error("Usuario no encontrado para actualizar.");
error.statusCode = 404;
throw error;
}
// Si se ha actualizado correctamente, devolvemos el usuario modificado.
return updatedUser;
}
// Borrar un usuario por id usando una transacción con log de borrado.
// Aquí suponemos que deleteUserWithLog realiza dos cosas en una sola transacción:
// 1. Borrar el usuario de la tabla principal.
// 2. Insertar un registro en una tabla de logs (por ejemplo, "deleted_users_log").
// De nuevo, la capa de controlador se ocupa de validar id y traducir el caso "no existe" a un error 404.
export async function handleDeleteUser(idParam) {
// Validación del id, igual que en las otras funciones.
const idNumber = Number(idParam);
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Llamamos al modelo para ejecutar la lógica de borrado con log.
// La función del modelo debería encargarse de la transacción (BEGIN/COMMIT/ROLLBACK)
// y devolver algo que indique si se borró o no realmente el usuario.
const result = await deleteUserWithLog(idNumber);
// Si result es falsy, entendemos que no se ha encontrado el usuario a borrar.
if (!result) {
const error = new Error("Usuario no encontrado para borrar.");
error.statusCode = 404;
throw error;
}
// Devolvemos el resultado (por ejemplo, información del usuario borrado o un objeto con metadatos).
return result;
}
Qué papel juega ahora el controlador
Hasta ahora el controlador ya hacía: crear usuarios (handleCreateUser), listar (handleGetAllUsers), obtener uno (handleGetUserById).
Con estas nuevas funciones:handleUpdateUser, handleDeleteUser, el controlador completa el CRUD y además introduce un aspecto de negocio interesante:
“El borrado de usuarios no es solo un DELETE, sino una operación “especial” que implica una transacción y un log”.
El controlador sigue siendo lo que debe ser: un filtro entre HTTP y el modelo, un sitio donde validar datos, un sitio donde decidir el tipo de error (400, 404, etc.), pero sin tener ni una sola línea de SQL.
Patrón común en todas las funciones: validar primero, llamar después
Fíjate que todas las funciones siguen el mismo esquema mental:
- Validar parámetros de entrada (id, body, tipos).
- Llamar a la función adecuada del modelo.
- Interpretar el resultado del modelo (objeto válido, null, error).
- Lanzar un error con
statusCodecorrecto si algo no cuadra.
handleUpdateUser: actualizar con sentido y errores claros
Esta función recibe:
idParam: viene como texto desde la URL ("/users/123").body: viene parseado como JSON.
Hace tres cosas importantes:
-
Convierte y valida el id:
Number(idParam)- Comprueba que es un entero positivo.
- Si no, lanza error 400 (
"id debe ser un entero positivo").
Esto evita que lleguen cosas raras al modelo (como
"abc",0,-3, etc.). -
Valida el body:
- Comprueba que existe
body. - Verifica que
emailynameson cadenas.
Si falla, lanza otro 400 con un mensaje claro. Esto evita que el modelo reciba
undefinedo tipos incorrectos. - Comprueba que existe
-
Llama al modelo
updateUsery evalúa el resultado:- Si el modelo devuelve
null, significa “no existía ningún usuario con ese id”. - El controlador traduce eso a un error 404 (
"Usuario no encontrado para actualizar.").
Si el modelo devuelve el usuario actualizado, lo retorna tal cual. El servidor se encargará de convertirlo a JSON y devolverlo con un 200.
- Si el modelo devuelve
Conclusión:
handleUpdateUser encapsula toda la lógica de negocio para decidir cuándo un update es válido y cuándo debe responder “no encontrado”.
handleDeleteUser: borrado con log y transacción, pero visto desde “negocio”
Aunque el modelo deleteUserWithLog hace una transacción relativamente compleja, el controlador no ve esa complejidad:
Para él, todo se reduce a: le paso un id numérico válido, me devuelve: o null (no existía el usuario), o un objeto { deleted: true, user, deleted_at }.
La secuencia es:
-
Validar id igual que en el resto: entero positivo, si no, 400.
-
Llamar al modelo:
const result = await deleteUserWithLog(idNumber); -
Interpretar resultado:
!result: no existía el usuario → lanza 404 con"Usuario no encontrado para borrar."- resultado válido : lo devuelve al servidor.
El controlador no sabe nada de:
BEGIN TRANSACTION,COMMIT,ROLLBACK, ni siquiera deuser_deletions_log.
Para él, borrar un usuario es una operación de negocio abstracta con estas reglas:
- Si el id es válido y el usuario existe, la operación se ejecuta y se devuelve un objeto con información del borrado.
- Si el usuario no existe, se devuelve un 404.
- Si algo revienta a nivel de base de datos, el modelo lanza un error y el servidor lo convertirá en 500.
Eso es exactamente lo que buscamos en una arquitectura limpia: la transacción está en el modelo, la semántica de la operación está en el controlador.
Uso coherente de statusCode en los errores
Tanto en update como en delete: los errores de parámetros incorrectos : 400 (Bad Request), los “no existe” : 404 (Not Found)
Esta consistencia es clave de cara a: clientes frontend que consuman la API, tests automatizados, documentación de la API.
El servidor HTTP solo lee err.statusCode y envía la respuesta. Gracias a eso, el controlador es quien decide la semántica del error y el servidor solo se encarga de serializarla hacia HTTP.
Ventaja didáctica: mismo patrón en todas las operaciones
Todas las funciones del controlador (handleCreateUser, handleGetUserById, handleUpdateUser, handleDeleteUser) siguen el mismo esquema: convertir/validar el input (body, params), llamar al modelo, analizar la respuesta del modelo, lanzar errores con statusCode o devolver un objeto de éxito.
Esto te permite un molde mental claro:
“Si mañana necesitas handleCreateOrder, handleUpdateOrder, etc., el patrón es el mismo. Solo cambia el modelo que llamas y el tipo de datos que validas”.
4. Actualizar el servidor HTTP para añadir las nuevas rutas
Archivo: src/http/server.mjs
// src/http/server.mjs
// Este archivo levanta un servidor HTTP "nativo" de Node.js sin usar Express.
// Expone una pequeña API REST para gestionar usuarios ("users").
// Endpoints previstos:
// - GET /users -> lista todos los usuarios
// - GET /users/:id -> obtiene un usuario por id
// - POST /users -> crea un usuario
// - PUT /users/:id -> actualiza un usuario existente
// - DELETE /users/:id -> borra un usuario con registro en log (dentro de una transacción)
import http from "http";
import { initializeDatabase, createDbConnection } from "../db/sqliteClient.mjs";
import {
handleCreateUser,
handleGetAllUsers,
handleGetUserById,
handleUpdateUser,
handleDeleteUser
} from "../controllers/userController.mjs";
// Antes de arrancar el servidor, inicializamos la base de datos.
// La idea es:
// 1. Abrir una conexión temporal con createDbConnection().
// 2. Ejecutar initializeDatabase(connection) para crear tablas, índices, etc. si no existen.
// 3. Cerrar esa conexión una vez terminada la inicialización.
//
// Esto suele leer y ejecutar un fichero schema.sql, o crear tablas "al vuelo" en SQLite.
const initDbConnection = createDbConnection();
initializeDatabase(initDbConnection);
// Cerramos la conexión de inicialización tras un pequeño retraso.
// Este setTimeout es una forma sencilla de asegurarnos de que
// initializeDatabase ha terminado antes de cerrar la conexión.
// En un código más robusto, sería mejor esperar a una promesa o callback.
setTimeout(() => {
initDbConnection.close();
}, 500);
// Función auxiliar para enviar una respuesta JSON uniforme.
// Recibe:
// - response: el objeto http.ServerResponse de Node.
// - statusCode: el código HTTP a devolver (200, 201, 400, 500, etc.).
// - data: cualquier objeto JavaScript que se convertirá a JSON.
//
// Esta función centraliza:
// - La conversión a JSON (JSON.stringify).
// - La cabecera Content-Type.
// - El cierre de la respuesta.
function sendJson(response, statusCode, data) {
// Convertimos el objeto JS a cadena JSON, con sangría de 2 espacios para que sea legible.
const json = JSON.stringify(data, null, 2);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
// Enviamos el cuerpo y cerramos la respuesta.
response.end(json);
}
// Función auxiliar para leer y parsear el cuerpo JSON de una petición HTTP.
// Como Node nativo trabaja con streams, aquí convertimos los "chunks" en una cadena completa.
// Devuelve una Promise que se resuelve con:
// - el objeto parseado si el cuerpo es JSON válido,
// - null si no hay cuerpo,
// o se rechaza con un Error (statusCode 400) si el JSON es inválido.
function parseRequestBody(request) {
return new Promise((resolve, reject) => {
let bodyData = "";
// El evento "data" se dispara cada vez que llega un fragmento de cuerpo.
request.on("data", (chunk) => {
// Vamos concatenando todos los trozos en una sola cadena.
bodyData += chunk.toString("utf-8");
});
// El evento "end" indica que ya no quedan más datos por leer.
request.on("end", () => {
// Si no llegó ningún cuerpo (por ejemplo, en una petición sin body),
// devolvemos null para indicar "no hay datos".
if (!bodyData) {
resolve(null);
return;
}
try {
// Intentamos interpretar la cadena como JSON.
const parsed = JSON.parse(bodyData);
resolve(parsed);
} catch (err) {
// Si JSON.parse falla, creamos un error específico.
const error = new Error("El cuerpo de la petición no es JSON válido.");
error.statusCode = 400; // Bad Request: el cliente envía un cuerpo mal formado.
reject(error);
}
});
// Si ocurre un error de stream en la propia petición, rechazamos la promesa.
request.on("error", (err) => {
reject(err);
});
});
}
// Servidor HTTP principal.
// http.createServer recibe una función callback que se ejecuta en cada petición.
// En este callback:
// - Leemos el método (GET, POST, etc.) y la URL.
// - Comprobamos qué ruta y método se están usando.
// - Llamamos al controlador correspondiente (capa intermedia entre HTTP y modelo).
// - Devolvemos una respuesta JSON uniforme.
//
// El callback se marca como async para poder usar await con los controladores y parseRequestBody.
const server = http.createServer(async (req, res) => {
try {
const { method, url } = req;
// Ruta: GET /users
// Devuelve un listado de todos los usuarios.
if (method === "GET" && url === "/users") {
// Llamamos al controlador, que hablará con la base de datos.
const users = await handleGetAllUsers();
// Envolvemos la respuesta en un objeto con ok: true y data: ...
sendJson(res, 200, { ok: true, data: users });
return;
}
// Ruta: GET /users/:id
// Ejemplo de URL: /users/3
// Aquí no usamos un router sofisticado; simplemente partimos la URL por "/".
if (method === "GET" && url.startsWith("/users/")) {
const parts = url.split("/");
// parts[0] = "" (antes del primer /)
// parts[1] = "users"
// parts[2] = ":id"
const id = parts[2];
// Delegamos la lógica de validación del id y la búsqueda en el controlador.
const user = await handleGetUserById(id);
sendJson(res, 200, { ok: true, data: user });
return;
}
// Ruta: POST /users
// Crea un nuevo usuario. Espera un JSON en el body con al menos "email" y "name".
if (method === "POST" && url === "/users") {
// Leemos el cuerpo JSON de la petición.
const body = await parseRequestBody(req);
// El controlador valida los campos y llama al modelo para insertar en la base de datos.
const newUser = await handleCreateUser(body);
// 201 Created: indica que hemos creado un nuevo recurso.
sendJson(res, 201, { ok: true, data: newUser });
return;
}
// Ruta: PUT /users/:id
// Actualiza completamente un usuario (email y name).
// Este endpoint asume un "replace" de los datos principales, no una actualización parcial.
if (method === "PUT" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2];
// Leemos el body, que debería contener los campos a actualizar.
const body = await parseRequestBody(req);
// El controlador se encarga de validar id + body y de llamar al modelo.
const updatedUser = await handleUpdateUser(id, body);
sendJson(res, 200, { ok: true, data: updatedUser });
return;
}
// Ruta: DELETE /users/:id
// Borra un usuario y, a la vez, registra el borrado en una tabla de logs dentro de una transacción.
if (method === "DELETE" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2];
// El controlador se ocupa de validar el id y ejecutar la transacción de borrado + log.
const result = await handleDeleteUser(id);
sendJson(res, 200, { ok: true, data: result });
return;
}
// Si no coincide ninguna de las rutas anteriores, respondemos con 404.
// Esto actúa como "catch-all" de rutas no definidas.
sendJson(res, 404, { ok: false, error: "Ruta no encontrada" });
} catch (err) {
// Cualquier error no controlado en el flujo de arriba acaba aquí.
// Puede ser:
// - un Error lanzado por los controladores (con statusCode 400, 404, etc.),
// - un error inesperado (consultas a BD, bugs de código, etc.).
console.error("Error en la petición:", err);
// Si el error tiene un statusCode (puesto por los controladores), lo usamos.
// Si no, asumimos 500 (Internal Server Error).
const statusCode = err.statusCode || 500;
sendJson(res, statusCode, {
ok: false,
// Si hay un mensaje específico, lo devolvemos; si no, mostramos un mensaje genérico.
error: err.message || "Error interno del servidor"
});
}
});
// Definimos el puerto donde escuchará el servidor HTTP.
// 3000 es típico para desarrollo (por ejemplo, http://localhost:3000).
const PORT = 3000;
// Ponemos el servidor a escuchar. El callback se ejecuta una vez que el servidor está listo.
// Este console.log es solo para ver en la consola que todo ha arrancado correctamente.
server.listen(PORT, () => {
console.log(`Servidor HTTP escuchando en http://localhost:${PORT}`);
});
Qué añade exactamente esta actualización del servidor
Tu servidor ya tenía:
- GET /users
- GET /users/:id
- POST /users
Con este bloque ahora también expone:
- PUT /users/:id : actualización completa del usuario (email y name)
- DELETE /users/:id : borrado del usuario, pero usando la transacción con log
Es decir, tu mini API de usuarios ya es un CRUD completo y realista:
- Create: POST
- Read: GET (lista y detalle)
- Update: PUT
- Delete: DELETE con log y transacción
El servidor sigue sin saber nada de SQL (y eso es bueno)
Fíjate en algo importante:
- El servidor solo mira
methodyurl. - Según la combinación, llama a una función del controlador:
handleGetAllUsershandleGetUserByIdhandleCreateUserhandleUpdateUserhandleDeleteUser
El servidor: no conoce la estructura de la tabla users, no sabe que existe user_deletions_log, no ve ni una sentencia SQL.
Su trabajo es puramente HTTP: recibir peticiones, enrutar según método + URL, delegar en el controlador, devolver JSON con el código de estado adecuado.
Cómo encajan las nuevas rutas PUT y DELETE
El patrón que usas en las nuevas rutas es exactamente igual al de GET /users/🆔
-
Comprobar método y prefijo de URL:
if (method === "PUT" && url.startsWith("/users/")) { ... }
if (method === "DELETE" && url.startsWith("/users/")) { ... } -
Extraer el
idhaciendourl.split("/"):const parts = url.split("/");
const id = parts[2]; // /users/123 -> ["", "users", "123"] -
Llamar a la función de controlador correspondiente:
handleUpdateUser(id, body)handleDeleteUser(id)
El servidor no valida el id, no valida el body, no decide si el usuario existe o no. Todo eso está delegado al controlador.
PUT /users/🆔 actualización con body JSON
En la ruta PUT:
- Primero parseas el body de la petición con
parseRequestBody(req). - Luego pasas
idybodyahandleUpdateUser.
El flujo es:
- El servidor solo se preocupa de: extraer método y url, parsear el body a JSON, delegar en el controlador, enviar respuesta JSON.
- El controlador: valida id y body, llama al modelo, decide si devuelve 200 o lanza un 404.
- El modelo: ejecuta el UPDATE, usa
this.changespara saber si se ha actualizado algo, hace un SELECT para devolver el usuario final.
Cada capa hace su parte, y en el servidor solo ves un await handleUpdateUser(id, body) seguido de un sendJson.
DELETE /users/🆔 borrado con log y transacción
En la ruta DELETE:
- Extraes el id igual que en GET y PUT.
- Llamas a
handleDeleteUser(id). - Si todo va bien, envías
sendJson(res, 200, { ok: true, data: result }).
result será lo que devuelva el modelo a través del controlador:
{
"deleted": true,
"user": {
"id": 3,
"email": "algo@example.com",
"name": "Nombre",
"created_at": "..."
},
"deleted_at": "2025-12-06T..."
}
Lo interesante aquí es:
- El servidor no ve la transacción.
- Ni ve
BEGIN,COMMIT,ROLLBACK. - Solo ve “tengo un resultado válido, lo devuelvo al cliente”.
Si algo falla dentro del proceso transaccional: el modelo hace ROLLBACK y rechaza la Promise, el controlador no captura el error, lo deja subir, el servidor lo atrapa en el catch general, lee err.statusCode (si existe) o usa 500, envía un JSON de error.
Manejo de errores centralizado: coherencia en toda la API
El try/catch alrededor de TODO el manejo de la petición permite: que cualquier error del controlador o del modelo, acabe convertido en una respuesta JSON consistente
Ejemplo típico: si el controlador lanza un error de validación (400), o un “no encontrado” (404), o el modelo lanza un error de SQLite (por ejemplo, email duplicado), todos esos casos pasan por el mismo catch.
En el catch: se hace const statusCode = err.statusCode || 500; se envía ok: false y error: err.message.
Esto significa que tu API es coherente: todos los errores tienen el mismo formato, todos pasan por un único punto, es fácil añadir logging, métricas o trazas ahí.
Siguiente paso natural: pruebas de las nuevas rutas
Con estas rutas añadidas, ya puedes probar:
-
PUT http://localhost:3000/users/1
Body JSON:
{
"email": "nuevo@example.com",
"name": "Nombre actualizado"
}
Y luego comprobar. En DB Browser: que el usuario ya no está en users, que existe una nueva fila en user_deletions_log.
5. Cómo probar las nuevas rutas y la transacción
Con el servidor arrancado (npm start):
Crear usuario:
-
POST http://localhost:3000/users
Cuerpo JSON:
{
"email": "demo@example.com",
"name": "Demo"
}
Actualizar usuario:
-
PUT http://localhost:3000/users/1
Cuerpo JSON:
{
"email": "nuevo@example.com",
"name": "Nombre Actualizado"
}
Borrar usuario (esto dispara la transacción):
Después del DELETE, puedes inspeccionar la tabla user_deletions_log con sqlite3 CLI para ver la fila insertada.
Todas las pruebas en una solo archivo request.http. • GET /users (recién arrancado) devuelve array vacío. • POST /users crea un usuario. • GET /users/:id devuelve el usuario creado. • PUT /users/:id actualiza email y name. • DELETE /users/:id borra y devuelve objeto de borrado. • GET /users/:id del borrado devuelve 404.
###
# 1) GET /users
# Recién arrancado el servidor.
# Resultado esperado:
# - 200 OK
# - { ok: true, data: [] }
GET http://localhost:3000/users
Accept: application/json
###
# 2) POST /users
# Crea un usuario nuevo.
# Resultado esperado:
# - 201 Created
# - ok: true
# - data.id debe existir (por ejemplo 1)
POST http://localhost:3000/users
Content-Type: application/json
{
"email": "usuario1@example.com",
"name": "Usuario Inicial"
}
###
# 3) GET /users/:id
# Recupera el usuario recién creado.
# IMPORTANTE:
# - Ajusta el ID si no es 1.
# Resultado esperado:
# - 200 OK
# - email y name originales
GET http://localhost:3000/users/1
Accept: application/json
###
# 4) PUT /users/:id
# Actualiza email y name del usuario.
# Resultado esperado:
# - 200 OK
# - data.email y data.name actualizados
PUT http://localhost:3000/users/1
Content-Type: application/json
{
"email": "usuario1_actualizado@example.com",
"name": "Usuario Actualizado"
}
###
# 5) DELETE /users/:id
# Borra el usuario usando transacción y log.
# Resultado esperado:
# - 200 OK
# - data.deleted = true
# - data.user contiene los datos del usuario borrado
# - data.deleted_at existe
DELETE http://localhost:3000/users/1
Accept: application/json
###
# 6) GET /users/:id (usuario ya borrado)
# Intenta recuperar un usuario eliminado.
# Resultado esperado:
# - 404 Not Found
# - ok: false
# - error: "Usuario no encontrado."
GET http://localhost:3000/users/1
Accept: application/json
Refactor de conexiones + nueva entidad con transacciones reales
Vamos a hacer dos cosas importantes a la vez:
- Refactorar la gestión de la base de datos para usar una única conexión compartida en lugar de abrir y cerrar conexiones en cada operación.
- Añadir una nueva entidad
accountscon una operación transaccional de ejemplo: transferencias de saldo entre cuentas, registradas en una tabla de historial.
Nueva visión general de la estructura
La estructura ahora queda así:
.
└── mi-api-sqlite/
├── package.json
├── data/
│ └── app.db
└── src/
├── db/
│ └── sqliteClient.mjs
├── models/
│ ├── userModel.mjs
│ └── accountModel.mjs
├── controllers/
│ ├── userController.mjs
│ └── accountController.mjs
└── http/
└── server.mjs
Las novedades son:
- Conexión SQLite centralizada y compartida.
- Nuevas tablas:
accountsyaccount_transfers. - Nuevo modelo
accountModelcon una transacción para transferir saldo. - Nuevo controlador
accountController. - Nuevas rutas HTTP para cuentas.
1. Refactor de la conexión: conexión única compartida
Archivo: src/db/sqliteClient.mjs
// src/db/sqliteClient.mjs
// Este módulo encapsula toda la lógica de acceso a la base de datos SQLite.
// Su objetivo es que el resto de la aplicación no tenga que preocuparse por:
// - Cómo se crea la conexión.
// - Dónde está el archivo de la base de datos.
// - Cómo se configuran los PRAGMAs importantes.
// - Cómo se crean/verifican las tablas.
// Desde fuera, solo se usan dos cosas:
// - getDb() -> para obtener SIEMPRE la misma conexión compartida.
// - initializeDatabase() -> para asegurarse de que las tablas existen y los PRAGMAs están aplicados.
// Importamos sqlite3 en su versión basada en callbacks.
// El método verbose() hace que sqlite3 muestre mensajes de depuración más detallados,
// lo cual es muy útil cuando estás desarrollando y ocurre algún error en las consultas.
import sqlite3 from "sqlite3";
sqlite3.verbose();
// Ruta del archivo de base de datos.
// Aquí asumimos que el proceso se lanza desde la raíz del proyecto, de modo que
// la carpeta "data" cuelga directamente de esa raíz y contiene el archivo app.db.
// Si ejecutases Node desde otra carpeta, tendrías que ajustar esta ruta.
const DB_PATH = "data/app.db";
// Creamos una única instancia de Database al cargar este módulo.
// Este new sqlite3.Database(...) abre la conexión inmediatamente.
// El callback se ejecuta cuando se completa la apertura (con error o sin él).
// La idea es que esta conexión viva durante todo el ciclo de vida de la app,
// en lugar de estar abriendo y cerrando conexiones en cada operación.
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
// Si ocurre un error al abrir la base de datos (por ejemplo, ruta inválida
// o permisos insuficientes), lo mostramos por consola.
console.error("Error abriendo la base de datos SQLite:", err.message);
} else {
console.log("Conexión SQLite abierta en", DB_PATH);
}
});
// getDb() devuelve la instancia compartida de la base de datos.
// Esto fuerza al resto de módulos a usar siempre la MISMA conexión,
// evitando que cada uno cree su propia conexión descontrolada.
// Patrón típico: en el modelo (userModel, accountsModel, etc.) llamas a getDb()
// y a partir de ahí usas db.run, db.get, db.all, etc.
export function getDb() {
return db;
}
// initializeDatabase() se encarga de dejar la base de datos en un estado "preparado":
// 1. Aplica PRAGMAs importantes (foreign_keys, journal_mode).
// 2. Crea las tablas necesarias si aún no existen.
// Se llama normalmente una vez al arrancar la aplicación,
// aunque no pasaría nada por llamarla varias veces: los CREATE TABLE IF NOT EXISTS
// son idempotentes (no vuelven a crear la tabla si ya existe).
export function initializeDatabase() {
// db.serialize() ejecuta todas las operaciones en serie, en el orden en que se declaran.
// Esto es útil para inicialización, porque garantiza que los PRAGMAs se apliquen
// antes de crear las tablas, y que las tablas se creen en orden.
db.serialize(() => {
// PRAGMA foreign_keys = ON:
// Por defecto, SQLite no hace cumplir las claves foráneas aunque las declares.
// Con este PRAGMA, le indicamos que respete las restricciones FK (ON DELETE, etc.).
db.run("PRAGMA foreign_keys = ON;", (err) => {
if (err) {
console.error("Error activando foreign_keys:", err.message);
} else {
console.log("PRAGMA foreign_keys = ON aplicado.");
}
});
// PRAGMA journal_mode = WAL:
// Cambia el modo de registro de transacciones a WAL (Write-Ahead Logging).
// Ventajas: mejor concurrencia, ya que múltiples lectores pueden acceder
// mientras hay escrituras, lo que es interesante incluso en escenarios sencillos.
db.run("PRAGMA journal_mode = WAL;", (err) => {
if (err) {
console.error("Error estableciendo journal_mode WAL:", err.message);
} else {
console.log("PRAGMA journal_mode = WAL aplicado.");
}
});
// Definición de la tabla users.
// Esta tabla almacena usuarios básicos de la aplicación,
// con un id AUTOINCREMENT, email único y un nombre.
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
// Tabla de logs de borrado de usuarios.
// Cada vez que se borra un usuario, se puede registrar aquí quién era
// y cuándo se borró. Esto es útil para auditoría y para demostrar
// el uso de transacciones (borrar de users + insertar en log en un único bloque).
const createUserDeletionsLogTableSQL = `
CREATE TABLE IF NOT EXISTS user_deletions_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL,
deleted_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
// Tabla de cuentas (accounts), pensada para ejemplos de transferencias de saldo.
// Cada fila representa una cuenta con un propietario y un balance actual.
const createAccountsTableSQL = `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
balance REAL NOT NULL
);
`;
// Tabla de transferencias entre cuentas.
// Guarda el histórico de movimientos de dinero entre cuentas (from -> to).
// Las claves foráneas apuntan a accounts.id, y amount/created_at describen
// el importe y el momento de la transferencia.
const createAccountTransfersTableSQL = `
CREATE TABLE IF NOT EXISTS account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_account_id INTEGER NOT NULL,
to_account_id INTEGER NOT NULL,
amount REAL NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (from_account_id) REFERENCES accounts(id),
FOREIGN KEY (to_account_id) REFERENCES accounts(id)
);
`;
// Ejecutamos la creación/verificación de la tabla users.
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error("Error creando tabla users:", err.message);
} else {
console.log("Tabla users verificada/creada.");
}
});
// Ejecutamos la creación/verificación de la tabla user_deletions_log.
db.run(createUserDeletionsLogTableSQL, (err) => {
if (err) {
console.error("Error creando tabla user_deletions_log:", err.message);
} else {
console.log("Tabla user_deletions_log verificada/creada.");
}
});
// Ejecutamos la creación/verificación de la tabla accounts.
db.run(createAccountsTableSQL, (err) => {
if (err) {
console.error("Error creando tabla accounts:", err.message);
} else {
console.log("Tabla accounts verificada/creada.");
}
});
// Ejecutamos la creación/verificación de la tabla account_transfers.
db.run(createAccountTransfersTableSQL, (err) => {
if (err) {
console.error("Error creando tabla account_transfers:", err.message);
} else {
console.log("Tabla account_transfers verificada/creada.");
}
});
});
}
Qué cambio mental estamos haciendo: de “abrir/cerrar” a “conexión única”
Hasta ahora, tu enfoque era:
- En cada operación del modelo: crear una conexión nueva (
createDbConnection()), hacer la consulta, cerrar la conexión.
Ese enfoque es muy didáctico para empezar, pero tiene varios inconvenientes si lo llevamos a un backend más realista:
- Cada operación abre y cierra un archivo en disco.
- Es más difícil controlar transacciones complejas entre distintas funciones.
- No se aprovechan características como WAL o PRAGMAs aplicados una sola vez.
Con este refactor pasamos a una visión más profesional:
- Se crea una única instancia de
sqlite3.DatabaseensqliteClient.mjs. - Esa conexión se abre una vez, cuando se importa el módulo.
- Toda la app usa siempre esa misma conexión, accediendo a través de
getDb().
A nivel conceptual:
- Antes: “cada función del modelo tiene su propia micro-conexión”.
- Ahora: “hay un único canal abierto hacia la base de datos y todos los modelos lo comparten”.
Esto se parece más a lo que haces con otros motores (un pool en Postgres, una única conexión en better-sqlite3, etc.).
Por qué getDb() es ahora la pieza central
Antes tenías createDbConnection(), ahora tienes:
- Una constante
dbcreada una sola vez. - Una función
getDb()que simplemente devuelve esa misma instancia.
Esto tiene varias ventajas:
- Todos los modelos (
userModel,accountModel, etc.) usan siempre la misma conexión. - No tienes que preocuparte de si te has olvidado de cerrar una conexión.
- Las transacciones que hagas con
BEGIN/COMMITson más fáciles de razonar, porque todo sucede sobre la misma conexión.
La idea didáctica es clara: “En esta fase ya no queremos enseñar cómo abrir y cerrar conexiones, sino cómo trabajar con una única conexión “viva” de forma segura”.
Por qué activamos PRAGMAs una sola vez en initializeDatabase()
En este refactor, initializeDatabase() se encarga no solo de crear tablas, sino también de aplicar PRAGMAs importantes:
PRAGMA foreign_keys = ON;PRAGMA journal_mode = WAL;
Esto es muy significativo.
foreign_keys = ON
Por defecto, SQLite no siempre respeta las claves foráneas si no activas este PRAGMA.
Al poner:
PRAGMA foreign_keys = ON;
le estás diciendo a SQLite:
- “Si intento borrar una fila referenciada por otra tabla, quiero que me avises o lo impidas según la definición de FK.”
- “Quiero que las referencias entre tablas sean coherentes de verdad.”
Es decir, empiezas a acercarte al comportamiento clásico de otras bases relacionales.
journal_mode = WAL
WAL (Write-Ahead Logging) es un modo de diario que mejora: la concurrencia (lecturas y escrituras simultáneas), el rendimiento en muchos casos de uso web.
En un entorno pequeño educativo no es crítico, pero: te acostumbra a la idea de que el PRAGMA journal_mode es una decisión de configuración, te permite explicar de forma sencilla que SQLite tiene distintos modos de trabajo para el registro de cambios.
Lo importante es que estos PRAGMAs se ejecutan una sola vez al inicio, con la conexión ya creada, y afectan a todo lo que venga después.
Por qué ahora todas las tablas se crean aquí
En este punto, initializeDatabase() crea:
usersuser_deletions_logaccountsaccount_transfers
Es decir:
- las tablas de usuarios y su log de borrado (que ya tenías),
- las nuevas tablas de cuentas y de historial de transferencias.
Esto refuerza una idea clave:
“La inicialización de la base de datos se hace en un único lugar, controlado y centralizado”.
Ventajas:
- Si quieres añadir una entidad nueva (por ejemplo,
products), sabes exactamente dónde definir suCREATE TABLE. - Si borras
app.db, puedes reconstruir toda la estructura simplemente arrancando el backend. - En una fase posterior (migraciones), podrás evolucionar esto hacia un sistema más avanzado.
Por qué ahora no se cierra la conexión
En esta versión:
- La conexión
dbse abre al importar el módulo. - No se hace
db.close()en cada operación.
La razón es doble:
- Pedagógica:
- Queremos enseñar el patrón de “conexión viva única”, típico de servicios web que están activos mientras el proceso corre.
- Es mucho más natural cuando empiezas a trabajar con transacciones frecuentes (como las transferencias entre cuentas).
- Práctica:
- Abrir y cerrar la base continuamente tiene un coste.
- Mantener una conexión abierta es suficiente y eficiente para una API modesta.
En un servidor Node típico:
- El proceso vive mientras el servidor está escuchando.
- La conexión a SQLite vive mientras el proceso está vivo.
- Solo se cerraría explícitamente al apagar el servidor (algo que podrías añadir más adelante si lo ves útil).
Cómo afecta esto al resto de capas
Este cambio implica que:
- Los modelos (
userModel,accountModel) ya no deben llamar acreateDbConnection(), sino agetDb(). - Tampoco deben cerrar la conexión (
db.close()), porque es compartida. - Las transacciones (como las transferencias de saldo) se hacen siempre sobre la misma instancia
db.
Pensemos en la nueva entidad accounts:
- Cuando implementes una transferencia: leerás el saldo de una cuenta, leerás el saldo de otra, harás
UPDATEen las dos, insertarás enaccount_transfers, y harásCOMMIT. - Todo eso se ejecuta sobre el mismo
dbdevuelto porgetDb().
Resumen didáctico de este refactor
Con este primer paso hemos cambiado de nivel:
- Antes:
- SQLite se usaba de forma “local” en cada operación.
- Buena para aprender conceptos básicos.
- Ahora:
- SQLite está integrado como una pieza central del backend, con una conexión única, con PRAGMAs globales, y con todas las tablas inicializadas en un único sitio.
Esto prepara el terreno para: el nuevo accountModel.mjs (lecturas, escrituras, transacciones), el accountController.mjs, y las nuevas rutas HTTP en server.mjs.
2. Actualizar el modelo de usuarios para usar la conexión compartida
Archivo: src/models/userModel.mjs
// src/models/userModel.mjs
// Este módulo representa la “capa de modelo” o “capa de acceso a datos”.
// Aquí se definen las funciones que interactúan directamente con la base de datos SQLite,
// pero sin preocuparse por cómo se reciben o validan los datos desde HTTP.
// Cada función usa SIEMPRE getDb(), lo que garantiza que toda la aplicación trabaja
// con UNA única conexión compartida. Esto evita inconsistencias y problemas de bloqueo.
import { getDb } from "../db/sqliteClient.mjs";
// Crea un usuario nuevo en la tabla users.
// Recibe un objeto { email, name } ya validado por el controlador.
// Esta función se encarga de:
// - Añadir la fecha de creación.
// - Ejecutar un INSERT.
// - Devolver un objeto con el usuario recién creado.
export function createUser({ email, name }) {
const db = getDb();
const createdAt = new Date().toISOString();
// Sentencia SQL parametrizada con placeholders (?, ?, ?) para prevenir inyección SQL.
const sql = `
INSERT INTO users (email, name, created_at)
VALUES (?, ?, ?);
`;
// Devolvemos una Promise manual, porque sqlite3 trabaja con callbacks.
return new Promise((resolve, reject) => {
// db.run ejecuta una sentencia que NO devuelve filas (INSERT, UPDATE, DELETE).
// Dentro del callback, `this` apunta al statement y nos permite acceder a this.lastID.
db.run(sql, [email, name, createdAt], function (err) {
if (err) {
reject(err);
} else {
// Montamos un objeto usuario como resultado de esta operación.
resolve({
id: this.lastID,
email,
name,
created_at: createdAt
});
}
});
});
}
// Obtiene todos los usuarios.
// db.all devuelve un array con todas las filas encontradas.
export function getAllUsers() {
const db = getDb();
const sql = `
SELECT id, email, name, created_at
FROM users
ORDER BY id ASC;
`;
return new Promise((resolve, reject) => {
// db.all obtiene múltiples filas.
db.all(sql, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows); // rows será un array de objetos.
}
});
});
}
// Obtiene un usuario por id.
// db.get devuelve SOLO una fila, o undefined si no hay coincidencias.
// El controlador ya validó que id es un entero positivo.
export function getUserById(id) {
const db = getDb();
const sql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
return new Promise((resolve, reject) => {
db.get(sql, [id], (err, row) => {
if (err) {
reject(err);
} else {
// Si no hay resultado, devolvemos null para que el controlador gestione el 404.
resolve(row || null);
}
});
});
}
// Actualiza completamente un usuario existente.
// Recibe { id, email, name } ya validados por el controlador.
// Flujo típico:
// 1. Ejecuta UPDATE.
// 2. Si this.changes === 0, no existe ese usuario.
// 3. Si todo va bien, hace un SELECT para devolver el usuario actualizado.
export function updateUser({ id, email, name }) {
const db = getDb();
const sql = `
UPDATE users
SET email = ?, name = ?
WHERE id = ?;
`;
return new Promise((resolve, reject) => {
db.run(sql, [email, name, id], function (err) {
if (err) {
reject(err);
return;
}
// this.changes indica cuántas filas han sido afectadas.
// Si es 0, el usuario no existe.
if (this.changes === 0) {
resolve(null);
return;
}
// Si existe, lo consultamos de nuevo para devolver su estado final actualizado.
const selectSql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
db.get(selectSql, [id], (err2, row) => {
if (err2) {
reject(err2);
} else {
resolve(row || null);
}
});
});
});
}
// Borra un usuario con transacción y registra el borrado en user_deletions_log.
// La transacción garantiza:
// 1. Se selecciona el usuario.
// 2. Se inserta un log.
// 3. Se borra al usuario.
// 4. Solo si todo funciona -> COMMIT.
// 5. Si algo falla -> ROLLBACK.
//
// Esto es un ejemplo realista de operación atómica:
// o se hace todo, o no se hace nada.
export function deleteUserWithLog(id) {
const db = getDb();
return new Promise((resolve, reject) => {
// serialize obliga a que todo este bloque se ejecute en orden dentro de la misma conexión.
db.serialize(() => {
db.run("BEGIN TRANSACTION;", (err) => {
if (err) {
reject(err);
return;
}
const selectSql = `
SELECT id, email, name, created_at
FROM users
WHERE id = ?;
`;
// Paso 1: obtener el usuario que se quiere borrar.
db.get(selectSql, [id], (errSelect, userRow) => {
if (errSelect) {
// Cualquier fallo antes del COMMIT implica hacer rollback explícito.
db.run("ROLLBACK;", () => reject(errSelect));
return;
}
if (!userRow) {
// Si el usuario no existe, revertimos y devolvemos null (el controlador enviará 404).
db.run("ROLLBACK;", () => resolve(null));
return;
}
const deletedAt = new Date().toISOString();
// Paso 2: insertar registro en la tabla de logs.
const insertLogSql = `
INSERT INTO user_deletions_log (user_id, email, deleted_at)
VALUES (?, ?, ?);
`;
db.run(insertLogSql, [userRow.id, userRow.email, deletedAt], function (errInsertLog) {
if (errInsertLog) {
db.run("ROLLBACK;", () => reject(errInsertLog));
return;
}
// Paso 3: borrar de la tabla users.
const deleteSql = `
DELETE FROM users
WHERE id = ?;
`;
db.run(deleteSql, [id], function (errDelete) {
if (errDelete) {
db.run("ROLLBACK;", () => reject(errDelete));
return;
}
// Paso 4: confirmar la transacción.
db.run("COMMIT;", (errCommit) => {
if (errCommit) {
reject(errCommit);
} else {
// Devolvemos los datos del usuario borrado y la marca temporal del borrado.
resolve({
deleted: true,
user: userRow,
deleted_at: deletedAt
});
}
});
});
});
});
});
});
});
}
Qué ha cambiado realmente en el modelo de usuarios
A nivel de interfaz, el modelo hace lo mismo que antes:
createUsergetAllUsersgetUserByIdupdateUserdeleteUserWithLog
La diferencia está “por dentro”:
- Antes: cada función hacía
createDbConnection()y luegodb.close(). - Ahora: todas las funciones llaman a
getDb()y usan la misma conexión compartida que vive ensqliteClient.mjs.
Es decir, el “contrato externo” del modelo no cambia, pero la forma de hablar con SQLite sí, y es mucho más parecida a lo que se hace en backends reales.
Patrón nuevo: const db = getDb();
En todas las funciones verás algo así:
const db = getDb();
Esto significa: no creas una conexión nueva, no cierras la conexión al terminar, simplemente “pides el canal” hacia la base de datos.
Ese canal es el mismo para: crear usuarios, leer, actualizar, borrar con log, y en el futuro, para cuentas, transferencias, etc.
La consecuencia pedagógica es importante: en la fase anterior aprendiste a abrir/cerrar conexiones, ahora aprendes a usar una conexión viva compartida, que es el patrón normal en una API que está corriendo de forma continua.
Por qué ya no hay db.close() en el modelo
Justo por lo anterior: cerrar la conexión en cada operación dejaría al resto de la app sin base de datos, la API está pensada para estar levantada de forma indefinida, atendiendo peticiones.
Entonces: el ciclo de vida de la conexión ya no está en el modelo, está controlado globalmente en sqliteClient.mjs (y, si quisieras, en el punto de apagado del servidor).
createUser, getAllUsers, getUserById, updateUser: mismo patrón, distinta conexión
Las operaciones básicas:
createUsergetAllUsersgetUserByIdupdateUser
siguen funcionando exactamente igual que antes en términos de: uso de placeholders (?), manejo de Promises, uso de this.lastID y this.changes, devolver null cuando no hay usuario.
La única diferencia real es la fuente de db: antes: conexión nueva cada vez, ahora: una conexión única compartida.
deleteUserWithLog: ahora sí aprovechamos de verdad la conexión compartida
Aquí es donde el cambio tiene más sentido práctico.
deleteUserWithLog hace:
-
BEGIN TRANSACTION; -
SELECT del usuario
-
INSERT en
user_deletions_log -
DELETE en
users -
COMMIT;o
ROLLBACK;si algo falla
Antes, aunque ya usabas transacciones, lo hacías con una conexión abierta solo para esta función.
Ahora, al usar getDb(): esa transacción corre sobre la misma conexión que utilizan el resto de operaciones, y además está protegida por db.serialize(...), que garantiza orden y no mezcla de pasos dentro de la misma conexión.
Esto es mucho más fiel a cómo se trabaja en un backend real: todas las operaciones comparten el mismo “canal” a la base, pero dentro de una transacción concreta se asegura el orden y la atomicidad.
Papel de db.serialize() en un entorno de conexión compartida
Antes, con conexiones “de usar y tirar”, serialize era casi una formalidad.
Ahora, con una conexión viva que podrían usar varias partes de la app, serialize cobra más peso:
- Garantiza que todas las sentencias de la transacción (
BEGIN, SELECT, INSERT, DELETE,COMMIT) se ejecutan en orden dentro de esa conexión. - Evita que otras operaciones que usen
dbse cuelen en medio de tu secuencia transaccional.
Manejo de errores en la transacción: mismo patrón, mejor encaje
La lógica de errores de deleteUserWithLog sigue siendo:
- Si falla el SELECT :
ROLLBACK + reject - Si el usuario no existe :
ROLLBACK + resolve(null) - Si falla el INSERT del log :
ROLLBACK + reject - Si falla el DELETE :
ROLLBACK + reject - Si falla el
COMMIT : reject
La diferencia ahora es que no se cierra la conexión, solo se “limpia” el estado de la transacción: COMMIT o ROLLBACK, y la conexión sigue disponible para otras operaciones.
Eso encaja mucho mejor con la idea de una API en producción: hay muchas peticiones a lo largo del tiempo, unas usan transacciones, otras no, pero todas comparten el mismo db.
3. Nuevo modelo: cuentas y transferencias transaccionales
Archivo: src/models/accountModel.mjs
// src/models/accountModel.mjs
// Este módulo implementa la lógica de acceso a datos para la entidad "accounts"
// y la tabla "account_transfers". Aquí NO se valida HTTP ni se formatean respuestas,
// solo se habla con la base de datos usando SQL y la conexión compartida con getDb().
//
// Ideas clave:
// - Las funciones aquí asumen que los datos ya han sido validados en la capa de controladores.
// - Trabajamos siempre con la misma conexión SQLite (patrón "conexión centralizada").
// - La función transferBetweenAccounts es un ejemplo de operación transaccional realista:
// o se actualizan ambas cuentas y se registra la transferencia, o se revierte todo.
import { getDb } from "../db/sqliteClient.mjs";
// Crea una cuenta nueva con un saldo inicial.
//
// Parámetros:
// { owner, balance } -> objeto con el nombre del propietario y el saldo de inicio.
// Retorno (Promise):
// -> Se resuelve con un objeto { id, owner, balance } representando la cuenta creada.
// -> Se rechaza con un error si falla el INSERT.
export function createAccount({ owner, balance }) {
const db = getDb();
const sql = `
INSERT INTO accounts (owner, balance)
VALUES (?, ?);
`;
// Envolvemos la llamada callback-based de sqlite3 en una Promise para
// poder usar luego async/await en capas superiores.
return new Promise((resolve, reject) => {
db.run(sql, [owner, balance], function (err) {
if (err) {
reject(err);
} else {
// this.lastID contiene el id AUTOINCREMENT generado por SQLite.
const newAccount = {
id: this.lastID,
owner,
balance
};
resolve(newAccount);
}
});
});
}
// Obtiene todas las cuentas existentes en la tabla accounts.
//
// No recibe parámetros.
// Retorno (Promise):
// -> Array de objetos { id, owner, balance }.
// -> Si hay un error en la consulta, se rechaza con ese error.
export function getAllAccounts() {
const db = getDb();
const sql = `
SELECT id, owner, balance
FROM accounts
ORDER BY id ASC;
`;
return new Promise((resolve, reject) => {
// db.all devuelve todas las filas que cumplen la consulta.
db.all(sql, [], (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
// Operación transaccional para transferir saldo entre dos cuentas.
//
// Parámetros:
// { fromAccountId, toAccountId, amount }:
// - fromAccountId: id de la cuenta origen (se le resta saldo).
// - toAccountId: id de la cuenta destino (se le suma saldo).
// - amount: cantidad a transferir.
//
// Reglas de negocio que se garantizan dentro de UNA MISMA transacción:
// - Ambas cuentas deben existir.
// - La cuenta origen debe tener saldo suficiente.
// - Se actualizan los saldos de ambas cuentas.
// - Se inserta un registro en account_transfers con el movimiento.
// - Si cualquier paso falla, se hace ROLLBACK y nada cambia.
//
// Retorno (Promise):
// -> Se resuelve con un objeto {
// ok: true,
// transfer: { ...datos de la transferencia... },
// accounts: { from: {...}, to: {...} }
// }
// -> Se rechaza con un Error si hay problemas (cuentas no encuentran, saldo insuficiente, etc.).
export function transferBetweenAccounts({ fromAccountId, toAccountId, amount }) {
const db = getDb();
return new Promise((resolve, reject) => {
// serialize obliga a ejecutar todas las operaciones de este bloque en orden,
// algo muy importante cuando hacemos BEGIN / COMMIT / ROLLBACK manualmente.
db.serialize(() => {
// Inicio explícito de la transacción.
db.run("BEGIN TRANSACTION;", (errBegin) => {
if (errBegin) {
reject(errBegin);
return;
}
// Consulta base para leer una cuenta por id.
const selectAccountSql = `
SELECT id, owner, balance
FROM accounts
WHERE id = ?;
`;
// Paso 1: leer la cuenta origen (de aquí se resta saldo).
db.get(selectAccountSql, [fromAccountId], (errFrom, fromAccount) => {
if (errFrom) {
db.run("ROLLBACK;", () => {
reject(errFrom);
});
return;
}
// Si no existe la cuenta origen, no tiene sentido continuar.
if (!fromAccount) {
db.run("ROLLBACK;", () => {
reject(new Error("Cuenta origen no encontrada."));
});
return;
}
// Paso 2: leer la cuenta destino (aquí se suma saldo).
db.get(selectAccountSql, [toAccountId], (errTo, toAccount) => {
if (errTo) {
db.run("ROLLBACK;", () => {
reject(errTo);
});
return;
}
if (!toAccount) {
db.run("ROLLBACK;", () => {
reject(new Error("Cuenta destino no encontrada."));
});
return;
}
// Paso 3: comprobar que la cuenta origen tiene saldo suficiente.
// Importante: esta comprobación se hace dentro de la transacción para evitar
// condiciones de carrera con otras operaciones concurrentes.
if (fromAccount.balance < amount) {
db.run("ROLLBACK;", () => {
reject(new Error("Saldo insuficiente en la cuenta origen."));
});
return;
}
// Paso 4: actualizar saldos de ambas cuentas.
const updateBalanceSql = `
UPDATE accounts
SET balance = ?
WHERE id = ?;
`;
const newFromBalance = fromAccount.balance - amount;
const newToBalance = toAccount.balance + amount;
// Actualizamos primero la cuenta origen.
db.run(
updateBalanceSql,
[newFromBalance, fromAccountId],
function (errUpdateFrom) {
if (errUpdateFrom) {
db.run("ROLLBACK;", () => {
reject(errUpdateFrom);
});
return;
}
// Luego actualizamos la cuenta destino.
db.run(
updateBalanceSql,
[newToBalance, toAccountId],
function (errUpdateTo) {
if (errUpdateTo) {
db.run("ROLLBACK;", () => {
reject(errUpdateTo);
});
return;
}
// Paso 5: registrar la transferencia en account_transfers.
// Esto nos deja un historial auditable de todos los movimientos.
const createdAt = new Date().toISOString();
const insertTransferSql = `
INSERT INTO account_transfers
(from_account_id, to_account_id, amount, created_at)
VALUES (?, ?, ?, ?);
`;
db.run(
insertTransferSql,
[fromAccountId, toAccountId, amount, createdAt],
function (errInsertTransfer) {
if (errInsertTransfer) {
db.run("ROLLBACK;", () => {
reject(errInsertTransfer);
});
return;
}
// Paso 6: si todo ha ido bien, hacemos COMMIT para fijar los cambios.
db.run("COMMIT;", (errCommit) => {
if (errCommit) {
reject(errCommit);
} else {
// this.lastID aquí apunta al id del registro insertado en account_transfers.
// En algunos contextos podría no estar disponible, por eso se usa el operador ?.
resolve({
ok: true,
transfer: {
id: this?.lastID ?? null,
from_account_id: fromAccountId,
to_account_id: toAccountId,
amount,
created_at: createdAt
},
// Devolvemos también cómo han quedado las cuentas después del movimiento,
// para que la capa superior pueda mostrar saldos actualizados al cliente.
accounts: {
from: {
id: fromAccount.id,
owner: fromAccount.owner,
balance: newFromBalance
},
to: {
id: toAccount.id,
owner: toAccount.owner,
balance: newToBalance
}
}
});
}
});
}
);
}
);
}
);
});
});
});
});
});
}
Qué hace este modelo de cuentas
Este archivo introduce una nueva mini–área funcional en tu backend:
- Tabla
accounts:idownerbalance
- Tabla
account_transfers:from_account_idto_account_idamountcreated_at
Y define tres operaciones:
createAccount: crear una cuenta con saldo inicial.getAllAccounts: listar todas las cuentas.transferBetweenAccounts: transferir saldo de una cuenta a otra, dejando registro en el historial.
Lo interesante es que transferBetweenAccounts no es un simple “resta aquí, suma allí”, sino una transacción completa y coherente, ejecutada sobre la conexión compartida.
createAccount y getAllAccounts: mismo patrón que en users
Estas dos funciones se comportan igual que las que ya conoces del modelo de usuarios:
- Usan
getDb()para obtener la conexión compartida. - Usan placeholders
?para los valores. - Devuelven Promises para poder usarse con async/await.
createAccount: recibe { owner, balance }, hace INSERT INTO accounts (owner, balance) VALUES (?, ?);, devuelve el objeto con id y balance inicial.
getAllAccounts: hace un SELECT id, owner, balance FROM accounts ORDER BY id ASC;, devuelve un array de cuentas.
Estas dos funciones sirven, además, como base para poder preparar el escenario de pruebas de la transferencia: crear cuenta 1 con saldo 1000, crear cuenta 2 con saldo 500, etc.
transferBetweenAccounts: idea general
La función transferBetweenAccounts({ fromAccountId, toAccountId, amount }) implementa una operación de negocio típica:
“Quiero transferir X unidades de saldo de la cuenta A a la cuenta B, si todo está bien”.
Y lo hace respetando varias reglas:
- Ambas cuentas deben existir.
- La cuenta origen debe tener saldo suficiente.
- Se debe actualizar el saldo de las dos cuentas.
- Se debe registrar la operación en
account_transfers. - Todo eso debe ser atómico: o se completa todo, o no se hace nada.
Para conseguirlo, se apoya en: la conexión compartida (getDb()), db.serialize(...) para ejecutar los pasos en orden, BEGIN TRANSACTION, COMMIT y ROLLBACK para asegurar la atomicidad.
Estructura de la transacción paso a paso
Dentro de la función, el flujo es este:
BEGIN TRANSACTION;- SELECT de la cuenta origen.
- SELECT de la cuenta destino.
- Comprobación de saldo suficiente.
- UPDATE del saldo de la cuenta origen.
- UPDATE del saldo de la cuenta destino.
- INSERT en
account_transfers(historial). COMMIT;si todo ha ido bien. Si cualquier paso falla,ROLLBACK;y error.
Veamos las ideas importantes de cada paso.
Paso 1: BEGIN TRANSACTION;
Se inicia la transacción explicitamente:
- A partir de aquí, los cambios que hagas (UPDATE, INSERT, DELETE) no se “consolidan” en la base hasta que hagas
COMMIT. - Si algo falla por el camino, puedes hacer
ROLLBACKy la base vuelve al estado anterior alBEGIN.
Esto es exactamente lo que queremos en una transferencia: no queremos dejar saldo restado en una cuenta si no se suma en la otra, ni viceversa.
Paso 2 y 3: leer cuentas origen y destino
Se hacen dos SELECT sobre accounts: primero para fromAccountId, luego para toAccountId.
Cada SELECT comprueba: si hay error técnico : ROLLBACK + reject(error), si no existe la cuenta : ROLLBACK + reject(new Error("Cuenta origen/destino no encontrada.")).
Es decir, si algo tan básico como “no existe la cuenta” falla, no se intenta tocar balances ni registrar nada.
Paso 4: comprobar saldo suficiente
Antes de tocar nada, se comprueba:
if (fromAccount.balance < amount) {
// ROLLBACK y error "Saldo insuficiente"
}
Esto representa una regla de negocio clara: “No puedes transferir más de lo que tienes”.
Si no hay saldo suficiente, se hace ROLLBACK y se lanza el error. La base queda exactamente como estaba.
Paso 5 y 6: actualizar balances
Aquí se hace el corazón de la operación:
-
calcular el nuevo saldo de la cuenta origen:
const newFromBalance = fromAccount.balance - amount; -
calcular el saldo de la cuenta destino:
const newToBalance = toAccount.balance + amount; -
hacer dos
UPDATE accounts SET balance = ? WHERE id = ?;:- Primero para la cuenta origen.
- Luego para la cuenta destino.
Entre medias se comprueba: si falla el UPDATE de la cuenta origen : ROLLBACK + error, si falla el UPDATE de la cuenta destino : ROLLBACK + error.
De esta forma evitas: que se reste saldo y no se sume en la otra cuenta, que haya actualizaciones a medias.
Paso 7: registrar la transferencia en account_transfers
Si las dos actualizaciones han ido bien:
- se genera
createdAt = new Date().toISOString(), - se inserta en
account_transferscon:from_account_id,to_account_id,amount,created_at.
De nuevo: si este INSERT falla : ROLLBACK + error.
Este registro sirve como historial que puedes consultar después para auditar: quién transfirió a quién, cuánto, cuándo.
Paso 8: COMMIT; y construcción de la respuesta
Solo si todo lo anterior ha tenido éxito: se hace COMMIT;.
Si COMMIT da error (raro, pero posible): se rechaza la Promise con el error.
Si COMMIT va bien: se construye un objeto de respuesta que incluye:
- Información de la transferencia (ids de cuentas, amount, created_at, id del registro si está disponible).
- Los nuevos balances de la cuenta origen y destino.
Esto es muy útil para el controlador y para el cliente: el controlador solo tiene que devolver ese objeto en JSON, el cliente puede actualizar la UI con los nuevos saldos sin hacer una segunda consulta.
Por qué db.serialize() es especialmente importante aquí
Exactamente igual que en el borrado de usuarios, db.serialize(() => { ... }) hace que:
- Todos los pasos de la transacción se ejecuten en orden, en la misma conexión.
- No se mezcle nada de otras operaciones que puedan estar usando
db.
En un entorno de conexión compartida, serialize es la forma sencilla de garantizar:
“Dentro de este bloque, todo va de uno en uno, respetando el orden que yo marque”.
Para una operación tan delicada como una transferencia de saldo, es justo lo que necesitas.
4. Nuevo controlador de cuentas
Archivo: src/controllers/accountController.mjs
// src/controllers/accountController.mjs
// Este módulo es la "capa de controlador" para la entidad de cuentas.
// Su papel es:
// - Recibir datos de la capa HTTP (por ejemplo, del body de una petición).
// - Validar esos datos (tipos, rangos, campos obligatorios).
// - Llamar a las funciones del modelo (accountModel.mjs), que son las que
// realmente hablan con la base de datos.
// - Lanzar errores con un statusCode apropiado para que la capa HTTP
// pueda devolver el código de estado correcto (400, 404, etc.).
import {
createAccount,
getAllAccounts,
transferBetweenAccounts
} from "../models/accountModel.mjs";
// Crear una cuenta nueva.
// Esta función no sabe nada de HTTP directamente: simplemente recibe un "body"
// que se supone que ya ha sido parseado desde JSON en la capa HTTP.
// Flujo general:
// 1. Validar que exista body y que 'owner' sea una cadena.
// 2. Convertir balance (si viene) a número, o usar 0 por defecto.
// 3. Validar que balance sea numérico.
// 4. Llamar al modelo createAccount.
// 5. Devolver la cuenta creada.
export async function handleCreateAccount(body) {
// Validación mínima: body debe existir y tener 'owner' como string.
if (!body || typeof body.owner !== "string") {
const error = new Error("Datos inválidos. Se requiere 'owner' como cadena.");
error.statusCode = 400; // 400 Bad Request -> datos enviados por el cliente no válidos.
throw error;
}
// El saldo inicial es opcional. Si no se proporciona, asumimos 0.
// Usamos el operador de coalescencia nula (??) para que:
// - si balance es undefined o null -> se use 0.
// - si balance viene como string numérico ("100") -> luego lo convertimos con Number().
const initialBalance = Number(body.balance ?? 0);
// Number() puede devolver NaN si el valor no es convertible a número.
if (Number.isNaN(initialBalance)) {
const error = new Error("El campo 'balance' debe ser numérico.");
error.statusCode = 400;
throw error;
}
// Si las validaciones pasan, delegamos la creación en el modelo.
// El modelo se encargará de hacer el INSERT y devolver la cuenta creada.
const account = await createAccount({
owner: body.owner,
balance: initialBalance
});
return account;
}
// Obtener todas las cuentas.
// Esta función es muy simple: solo llama al modelo y devuelve el resultado.
// Aun así, se mantiene la capa de controlador para mantener una arquitectura coherente
// y poder añadir en el futuro filtros, paginación, etc.
export async function handleGetAllAccounts() {
const accounts = await getAllAccounts();
return accounts;
}
// Transferir saldo entre cuentas.
// Esta función recibe un body con los datos de la transferencia y se encarga de:
// 1. Comprobar que el body exista.
// 2. Convertir fromAccountId, toAccountId y amount a números.
// 3. Validar que los ids sean enteros positivos y que amount sea un número positivo.
// 4. Validar que las cuentas origen y destino no sean la misma.
// 5. Llamar a la función transaccional transferBetweenAccounts del modelo.
// Si alguna validación falla, lanza un Error con statusCode 400.
export async function handleTransfer(body) {
// Primero, nos aseguramos de que exista el cuerpo de la petición.
if (!body) {
const error = new Error("Cuerpo de petición vacío.");
error.statusCode = 400;
throw error;
}
// Convertimos los campos relevantes a número.
// Es típico que lleguen como cadenas desde JSON o desde la URL.
const fromId = Number(body.fromAccountId);
const toId = Number(body.toAccountId);
const amount = Number(body.amount);
// Validamos que fromAccountId sea un entero positivo.
if (!Number.isInteger(fromId) || fromId <= 0) {
const error = new Error("fromAccountId debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Validamos que toAccountId sea un entero positivo.
if (!Number.isInteger(toId) || toId <= 0) {
const error = new Error("toAccountId debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Validamos que amount sea un número positivo.
// Aquí no pedimos entero; puede ser un número con decimales.
if (Number.isNaN(amount) || amount <= 0) {
const error = new Error("amount debe ser un número positivo.");
error.statusCode = 400;
throw error;
}
// Comprobamos que las cuentas origen y destino no sean la misma.
// No tendría sentido transferir saldo de una cuenta a sí misma.
if (fromId === toId) {
const error = new Error("Las cuentas origen y destino deben ser distintas.");
error.statusCode = 400;
throw error;
}
// Si todo es válido, delegamos la lógica transaccional al modelo.
// El modelo se encargará de:
// - Comprobar que ambas cuentas existen.
// - Comprobar saldo suficiente.
// - Actualizar saldos y registrar la transferencia dentro de una transacción.
const result = await transferBetweenAccounts({
fromAccountId: fromId,
toAccountId: toId,
amount
});
return result;
}
Qué papel juega este controlador de cuentas
Este módulo hace con accounts lo mismo que el controlador de usuarios hacía con users:
- Recibe datos ya parseados (body JSON).
- Valida que esos datos tengan sentido mínimo.
- Llama al modelo (
accountModel.mjs). - Decide qué error devolver si la entrada es incorrecta.
Lo importante es que el controlador: no sabe nada de SQL, no sabe nada de transacciones, solo llama a funciones de más bajo nivel y gestiona la lógica de validación y mensajes de error.
Esto mantiene la arquitectura clara: HTTP → controlador → modelo → SQLite y de vuelta.
handleCreateAccount: crear cuentas con validación suave
Esta función se encarga de: comprobar que el body existe, exigir que haya un owner de tipo string, convertir el campo balance a número si viene, o usar 0 si no viene.
Detalles interesantes:
-
ownerobligatorio y stringSi
body.ownerno existe o no es una cadena, se lanza un error 400 con un mensaje claro. Esto evita que se creen cuentas sin dueño o con tipos raros. -
balanceopcional, pero numérico Se usa:const initialBalance = Number(body.balance ?? 0);Esto significa: si
body.balanceestá definido, lo convierte a número, si no está definido, usa 0.Luego se comprueba con
Number.isNaN(initialBalance)y, si el valor no es numérico, se lanza un 400 explicando que debe ser número. -
Llamada al modelo
Una vez validados los datos, llama a
createAccount({ owner, balance })y devuelve lo que el modelo retorne (la nueva cuenta con id y balance).
handleGetAllAccounts: controlador delgado
Esta función es un ejemplo de controlador “fino”: no necesita validar nada, simplemente llama a getAllAccounts(), devuelve el resultado.
handleTransfer: validación de reglas de negocio antes de la transacción
Esta función es la más interesante del controlador, porque está justo en la frontera entre: la capa HTTP, la lógica de negocio de “transferir saldo”.
Antes de llamar al modelo, hace muchas comprobaciones razonables.
Paso a paso:
-
Validar que el body existe
Si no hay body, se lanza un 400. Es absurdo intentar transferir saldo sin datos.
-
Convertir parámetros a número:
fromAccountIdtoAccountIdamount
Todos se convierten con
Number(...). -
Validar ids de cuentas
Se exige que:
fromIdsea entero positivo,toIdsea entero positivo.
Si no lo son, se lanzan errores 400 con mensajes específicos, por ejemplo:
"fromAccountId debe ser un entero positivo."Esto garantiza que el modelo no llegue a ejecutar SELECT con cadenas vacías, texto, etc.
-
Validar amount
Se exige que: no sea NaN, sea mayor que 0.
Esto evita transferencias de 0 o negativas, que no tienen sentido en la lógica de la app.
-
Validar que las cuentas no sean la misma. Se evita explícitamente este caso:
if (fromId === toId) {
// error 400 "Las cuentas origen y destino deben ser distintas."
}Esto refuerza una regla de negocio: no tiene lógica transferirte dinero a ti mismo desde la misma cuenta.
-
Llamar al modelo. Solo si todas esas validaciones han pasado, el controlador llama a:
transferBetweenAccounts({ fromAccountId: fromId, toAccountId: toId, amount });A partir de ahí, el modelo se encarga de: comprobar existencia de cuentas, saldo suficiente, actualizar balances, registrar transferencia, hacer COMMIT o ROLLBACK. el controlador simplemente devuelve el resultado.
Observa la separación de responsabilidades:
- El controlador valida “lo que llega del mundo exterior” (tipos, reglas básicas).
- El modelo valida “lo que ocurre dentro de la base” (existencia, coherencia de saldo).
Por qué es un buen ejemplo didáctico
Este controlador de cuentas es muy útil porque:
- Refuerza el patrón ya visto con usuarios (handle + validación + modelo).
- Introduce una operación de negocio más compleja (transferencias) sin mezclar SQL en el controlador.
- Muestra claramente que hay validaciones que pertenecen aquí (tipos, reglas sencillas) y otras al modelo (consultas a la base).
Además, es muy fácil de probar:
- Crear cuentas con
handleCreateAccounta través de la API. - Verlas con
handleGetAllAccounts. - Hacer transferencias válidas e inválidas con
handleTransfery observar las respuestas de error o éxito.
5. El controlador de usuarios sigue igual en interfaz (solo depende del userModel)
Archivo: src/controllers/userController.mjs (solo lo dejo para que tengas el conjunto completo)
// src/controllers/userController.mjs
// Este módulo forma la "capa de controladores" para la entidad de usuarios.
// Su papel es intermedio entre HTTP y la base de datos:
// - Recibe datos ya parseados (body, params) desde la capa HTTP.
// - Valida tipos y rangos (por ejemplo, que id sea entero positivo).
// - Llama a las funciones del modelo (userModel.mjs).
// - Lanza errores con statusCode para que la capa HTTP sepa qué código devolver.
import {
createUser,
getAllUsers,
getUserById,
updateUser,
deleteUserWithLog
} from "../models/userModel.mjs";
// Crear un usuario nuevo.
// Recibe un body que normalmente viene de una petición HTTP POST /users.
// Aquí no se hace parsing de JSON (eso lo hace la capa HTTP), solo validación.
export async function handleCreateUser(body) {
// Validamos que body exista y que tenga email y name como cadenas de texto.
// Si algo falla, lanzamos un Error con statusCode 400 (Bad Request).
if (!body || typeof body.email !== "string" || typeof body.name !== "string") {
const error = new Error("Datos inválidos. Se requieren 'email' y 'name' como cadenas.");
error.statusCode = 400;
throw error;
}
// Si el body es válido, delegamos la lógica de inserción al modelo.
const newUser = await createUser({
email: body.email,
name: body.name
});
// Devolvemos el usuario creado; la capa HTTP se encargará de hacer res.json(...)
return newUser;
}
// Obtener todos los usuarios.
// Aquí no hay parámetros que validar, simplemente pedimos al modelo la lista.
// Esta función existe para mantener la separación de capas y permitir
// añadir lógica extra en el futuro (paginación, filtros, etc.).
export async function handleGetAllUsers() {
const users = await getAllUsers();
return users;
}
// Obtener un usuario concreto por id.
// Suele usarse desde un GET /users/:id, donde id llega como string.
export async function handleGetUserById(idParam) {
// Convertimos el parámetro de la URL (string) a número.
const idNumber = Number(idParam);
// Validamos que sea un entero positivo.
// Si no lo es, no tiene sentido ir a la base de datos.
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Obtenemos el usuario del modelo.
const user = await getUserById(idNumber);
// Si el modelo devuelve null/undefined, el usuario no existe.
if (!user) {
const error = new Error("Usuario no encontrado.");
error.statusCode = 404; // Not Found.
throw error;
}
// Si existe, lo devolvemos.
return user;
}
// Actualizar un usuario por id con los datos del body.
// Equivale a un PUT /users/:id (actualización "completa" de email + name).
export async function handleUpdateUser(idParam, body) {
const idNumber = Number(idParam);
// Validación del id igual que en handleGetUserById.
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Validación del body igual que en handleCreateUser.
if (!body || typeof body.email !== "string" || typeof body.name !== "string") {
const error = new Error("Datos inválidos. Se requieren 'email' y 'name' como cadenas.");
error.statusCode = 400;
throw error;
}
// Si todo es válido, pedimos al modelo que actualice el usuario.
const updatedUser = await updateUser({
id: idNumber,
email: body.email,
name: body.name
});
// Si updatedUser es null, el modelo indica que no existía el usuario con ese id.
if (!updatedUser) {
const error = new Error("Usuario no encontrado para actualizar.");
error.statusCode = 404;
throw error;
}
// Devolvemos el usuario ya actualizado.
return updatedUser;
}
// Borrar un usuario por id usando una transacción con log.
// El modelo deleteUserWithLog se ocupa de:
// - Leer el usuario.
// - Insertar un registro en user_deletions_log.
// - Borrarlo de users.
// Todo ello dentro de una transacción.
export async function handleDeleteUser(idParam) {
const idNumber = Number(idParam);
// Validación del id (entero positivo), igual que en los otros handlers.
if (!Number.isInteger(idNumber) || idNumber <= 0) {
const error = new Error("El parámetro 'id' debe ser un entero positivo.");
error.statusCode = 400;
throw error;
}
// Delegamos la lógica de borrado + log en el modelo.
const result = await deleteUserWithLog(idNumber);
// Si result es null, el modelo indica que no existía el usuario a borrar.
if (!result) {
const error = new Error("Usuario no encontrado para borrar.");
error.statusCode = 404;
throw error;
}
// Devolvemos el resultado (por ejemplo, info del usuario borrado y fecha de borrado).
return result;
}
Perfecto, este encaja tal cual con todo lo que acabamos de cambiar.
Aquí lo único importante que merece la pena subrayar es esto:
- No has tenido que tocar nada en el controlador de usuarios.
- El controlador sigue llamando a:
createUsergetAllUsersgetUserByIdupdateUserdeleteUserWithLog
Y eso confirma una idea clave de arquitectura que te interesa mucho para una unidad didáctica:
- Hemos refactorizado cómo se conecta SQLite internamente (de conexiones por operación a conexión compartida).
- Hemos añadido PRAGMAs, nuevas tablas, transacciones más complejas.
- Y aun así, el controlador no se ha enterado de nada, porque su contrato con el modelo no ha cambiado.
Es justo lo que quieres:
- Si el modelo cambia “por dentro” pero mantiene su interfaz (sus funciones y parámetros), las capas superiores (controladores, servidor HTTP) no tienen por qué romperse.
- La lógica de negocio y la lógica de infraestructura están bien separadas.
6. Servidor HTTP actualizado con nuevas rutas y nueva inicialización
Archivo: src/http/server.mjs
// src/http/server.mjs
// Este archivo implementa un servidor HTTP nativo de Node.js, sin Express.
// Actúa como “capa de transporte”: recibe las peticiones HTTP, parsea JSON,
// identifica la ruta y delega toda la lógica real a los controladores.
//
// La API se divide en dos grupos:
// Users:
// - GET /users
// - GET /users/:id
// - POST /users
// - PUT /users/:id
// - DELETE /users/:id
//
// Accounts:
// - GET /accounts
// - POST /accounts
// - POST /accounts/transfer
//
// La responsabilidad de este archivo es mínima:
// - Leer método + URL.
// - Parsear el body si hace falta.
// - Llamar al controlador adecuado.
// - Enviar la respuesta JSON usando sendJson.
import http from "http";
import { initializeDatabase } from "../db/sqliteClient.mjs";
import {
handleCreateUser,
handleGetAllUsers,
handleGetUserById,
handleUpdateUser,
handleDeleteUser
} from "../controllers/userController.mjs";
import {
handleCreateAccount,
handleGetAllAccounts,
handleTransfer
} from "../controllers/accountController.mjs";
// Inicialización de la base de datos al arrancar.
// Esto ejecuta PRAGMAs, crea tablas si no existen y deja la BD lista.
// Se hace una única vez y luego toda la app usa la misma conexión.
initializeDatabase();
// Función auxiliar que estandariza cómo se envían las respuestas JSON.
// Recibe el objeto response de Node, el código HTTP y el objeto JS que se convertirá en JSON.
function sendJson(response, statusCode, data) {
const json = JSON.stringify(data, null, 2);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(json);
}
// Función auxiliar para leer el body de la petición.
// Node maneja el cuerpo como un stream de bytes, así que vamos acumulando “chunks”.
// Esta función devuelve una Promise que:
// - Se resuelve con null si no había body.
// - Se resuelve con el objeto JSON parseado si era válido.
// - Se rechaza con error si el JSON es inválido.
function parseRequestBody(request) {
return new Promise((resolve, reject) => {
let bodyData = "";
// El evento "data" se dispara cada vez que llega un fragmento.
request.on("data", (chunk) => {
bodyData += chunk.toString("utf-8");
});
// Cuando ya no quedan más datos, intentamos parsear JSON.
request.on("end", () => {
if (!bodyData) {
resolve(null);
return;
}
try {
const parsed = JSON.parse(bodyData);
resolve(parsed);
} catch (err) {
const error = new Error("El cuerpo de la petición no es JSON válido.");
error.statusCode = 400;
reject(error);
}
});
request.on("error", (err) => {
reject(err);
});
});
}
// Servidor HTTP principal.
// Cada petición pasa por esta función async.
// Aquí se identifica método + url y se delega al controlador correspondiente.
const server = http.createServer(async (req, res) => {
try {
const { method, url } = req;
// -------------------------------------------------------
// Rutas de Users
// -------------------------------------------------------
// GET /users -> devuelve todos los usuarios.
if (method === "GET" && url === "/users") {
const users = await handleGetAllUsers();
sendJson(res, 200, { ok: true, data: users });
return;
}
// GET /users/:id -> busca un usuario concreto.
// Como no tenemos un router avanzado, partimos la URL manualmente.
if (method === "GET" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2];
const user = await handleGetUserById(id);
sendJson(res, 200, { ok: true, data: user });
return;
}
// POST /users -> crea un usuario.
// Se espera un body JSON con "email" y "name".
if (method === "POST" && url === "/users") {
const body = await parseRequestBody(req);
const newUser = await handleCreateUser(body);
sendJson(res, 201, { ok: true, data: newUser });
return;
}
// PUT /users/:id -> actualiza un usuario existente.
if (method === "PUT" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2];
const body = await parseRequestBody(req);
const updatedUser = await handleUpdateUser(id, body);
sendJson(res, 200, { ok: true, data: updatedUser });
return;
}
// DELETE /users/:id -> borra un usuario y registra el borrado en log.
// Todo en una transacción implementada en el modelo.
if (method === "DELETE" && url.startsWith("/users/")) {
const parts = url.split("/");
const id = parts[2];
const result = await handleDeleteUser(id);
sendJson(res, 200, { ok: true, data: result });
return;
}
// -------------------------------------------------------
// Rutas de Accounts
// -------------------------------------------------------
// GET /accounts -> lista todas las cuentas.
if (method === "GET" && url === "/accounts") {
const accounts = await handleGetAllAccounts();
sendJson(res, 200, { ok: true, data: accounts });
return;
}
// POST /accounts -> crea una cuenta nueva.
if (method === "POST" && url === "/accounts") {
const body = await parseRequestBody(req);
const account = await handleCreateAccount(body);
sendJson(res, 201, { ok: true, data: account });
return;
}
// POST /accounts/transfer -> transfiere saldo entre cuentas.
// Esta operación es transaccional y se implementa en accountModel.
if (method === "POST" && url === "/accounts/transfer") {
const body = await parseRequestBody(req);
const result = await handleTransfer(body);
sendJson(res, 200, { ok: true, data: result });
return;
}
// -------------------------------------------------------
// Si ninguna ruta coincide, devolvemos 404
// -------------------------------------------------------
sendJson(res, 404, { ok: false, error: "Ruta no encontrada" });
} catch (err) {
// Cualquier error que ocurra en las rutas llega aquí.
// Puede ser validación, errores SQL, JSON inválido, etc.
console.error("Error en la petición:", err);
const statusCode = err.statusCode || 500;
sendJson(res, statusCode, {
ok: false,
error: err.message || "Error interno del servidor"
});
}
});
// Arrancamos el servidor en el puerto 3000.
// Desde este punto, la API está disponible en http://localhost:3000/
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Servidor HTTP escuchando en http://localhost:${PORT}`);
});
Qué añade este servidor respecto al anterior
Antes tu servidor solo conocía a los usuarios. Ahora:
- Sigue exponiendo la API de
users:- GET /users
- GET /users/:id
- POST /users
- PUT /users/:id
- DELETE /users/:id
- Añade la API de
accounts:- GET /accounts : lista todas las cuentas.
- POST /accounts : crea una cuenta nueva.
- POST /accounts/transfer : hace una transferencia entre cuentas usando la transacción que definiste en el modelo.
Y todo eso:
- Usando los controladores (
userControlleryaccountController). - Sin escribir ni una línea de SQL aquí.
- Apoyándose en la conexión compartida que se inicializa al arrancar.
Inicialización de la base de datos ahora es mucho más limpia
Antes, en la versión inicial de la API, hacías algo como:
- Crear una conexión temporal.
- Pasarla a
initializeDatabase. - Hacer un
setTimeoutpara cerrarla.
Ahora, con el refactor, la inicialización es mucho más natural:
import { initializeDatabase } from "../db/sqliteClient.mjs";
initializeDatabase();
Qué significa esto:
- Cuando se importa
sqliteClient.mjs, se crea la conexión compartidadb. initializeDatabase(): aplica los PRAGMAs (foreign_keys, WAL), crea/verifica las tablasusers,user_deletions_log,accountsyaccount_transfers.
Esta inicialización ocurre una sola vez, justo antes de empezar a aceptar peticiones HTTP.
A partir de ahí, tanto los modelos de usuarios como los de cuentas usan la misma conexión a través de getDb().
Estructura general del servidor: misma idea, más rutas
La estructura sigue siendo la misma:
parseRequestBody(req): lee el body, intenta hacerJSON.parse, si hay error, lanza un 400.sendJson(res, statusCode, data): serializadataa JSON, rellena los headers, envía la respuesta.http.createServer(async (req, res) => { ... }): leemethodyurl, hace de router manual a base deif (method === "..." && url === "..."), llama al controlador adecuado, maneja errores en untry/catchglobal.
Rutas de usuarios: sin cambios conceptuales
El bloque de rutas de usuarios es el mismo que venías usando:
- GET /users :
handleGetAllUsers - GET /users/:id : extrae
iddeurl.split("/")y llama ahandleGetUserById. - POST /users : parsea el body y llama a
handleCreateUser. - PUT /users/:id : parsea body, extrae id y llama a
handleUpdateUser. - DELETE /users/:id : extrae id y llama a
handleDeleteUser.
El servidor: no valida nada, no sabe qué es un “usuario”, solo conoce rutas, métodos y qué controlador llamar. Toda la lógica de negocio y validación está en el controlador y en el modelo.
Nuevas rutas de cuentas: patrón idéntico al de usuarios
Las rutas de cuentas siguen exactamente el mismo estilo, pero ahora apuntan al controlador de cuentas:
-
GET /accounts:
const accounts = await handleGetAllAccounts();
sendJson(res, 200, { ok: true, data: accounts }); -
POST /accounts: parsea el body con
parseRequestBody, llama ahandleCreateAccount(body), devuelve la cuenta creada con código 201. -
POST /accounts/transfer: parsea body, llama a
handleTransfer(body), que:- Valida ids y amount.
- Llama al modelo
transferBetweenAccountspara ejecutar la transacción.
De nuevo, el servidor no sabe nada de: saldo, cuentas origen/destino, transacciones SQL.
Solo pasa el body al controlador y envuelve la respuesta en JSON.
Manejo de errores centralizado: funciona igual para users y accounts
El try/catch global en el servidor se aplica a todas las rutas, tanto de usuarios como de cuentas:
- Si el controlador lanza un error con
error.statusCode: se usa ese código (400, 404, etc.). - Si el modelo lanza un error sin
statusCode(por ejemplo, un error de sqlite3 o un Error genérico): el servidor usa 500 (Error interno).
Y el formato de respuesta de error es siempre:
{
"ok": false,
"error": "Mensaje descriptivo"
}
Eso hace que toda la API sea coherente de cara al cliente, independientemente de si el fallo viene de: validación en controlador, chequeos de negocio en el modelo, fallo técnico de base de datos.
7. Cómo probar las nuevas entidades y transacciones
Con npm start:
Crear cuenta:
-
POST http://localhost:3000/accounts
Cuerpo JSON:
{
"owner": "Ana",
"balance": 500
}
Otra cuenta:
-
POST http://localhost:3000/accounts
{
"owner": "Luis",
"balance": 200
}
Listar cuentas:
Transferencia (transacción):
-
POST http://localhost:3000/accounts/transfer
{
"fromAccountId": 1,
"toAccountId": 2,
"amount": 100
}
Si el saldo es insuficiente, la transacción hará ROLLBACK y verás el error correspondiente.
Si todo va bien, verás los nuevos balances y los datos de la transferencia.
Tests automáticos para la API Node.js + SQLite
Vamos a añadir una capa de tests automatizados sobre el proyecto actual, sin añadir frameworks pesados: usaremos el runner nativo de Node (node:test) y node:assert. Eso te da:
- Tests unitarios sobre modelos (
userModel,accountModel). - Una base de datos de pruebas independiente.
- Limpieza de datos entre tests.
Todo con ES Modules y comentarios extensos.
1. Ajustar la base de datos para soportar un “modo test”
Primero, vamos a permitir que la ruta de la base de datos se pueda cambiar mediante una variable de entorno. Así podremos usar data/app.db en modo normal y data/test.db en los tests.
Archivo: src/db/sqliteClient.mjs (versión completa, actualizada)
// src/db/sqliteClient.mjs
// Este módulo centraliza TODA la gestión de la base de datos SQLite.
// Su propósito es mantener una arquitectura limpia donde:
// - Toda la app utiliza SIEMPRE una única conexión compartida.
// - Las tablas necesarias se crean de forma automática si no existen.
// - Se pueden ejecutar PRAGMAs críticos (foreign_keys y WAL) al inicio.
// - Permite cambiar fácilmente la ruta de la base de datos mediante DB_PATH,
// lo cual es fundamental en entornos de testing.
//
// Así evitamos que cada modelo cree su propia conexión o abra la BD varias veces,
// lo cual generaría inconsistencias, bloqueos innecesarios o tests frágiles.
import sqlite3 from "sqlite3";
// Activa el modo verbose de sqlite3, que imprime información adicional
// sobre ejecuciones, consultas y errores. Muy útil en desarrollo.
sqlite3.verbose();
// Ruta del archivo de base de datos:
// - En producción/desarrollo se usa "data/app.db".
// - En tests se puede definir DB_PATH para apuntar a una BD temporal,
// manteniendo la misma lógica de conexión.
const DB_PATH = process.env.DB_PATH || "data/app.db";
// Creamos la única conexión compartida de toda la aplicación.
// Este new sqlite3.Database se ejecuta al cargar el módulo.
// No se debe cerrar ni recrear repetidamente.
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error("Error abriendo la base de datos SQLite:", err.message);
} else {
console.log("Conexión SQLite abierta en", DB_PATH);
}
});
// Devuelve siempre la misma conexión compartida.
// Todas las operaciones en los modelos deben usar esta conexión,
// nunca crear una nueva.
export function getDb() {
return db;
}
// Inicializa la base de datos creando tablas si no existen
// y aplicando configuraciones importantes.
// Esta función debe llamarse al inicio de la app y también al iniciar tests.
export function initializeDatabase() {
db.serialize(() => {
// Activar claves foráneas.
// SQLite no las respeta por defecto, por lo que este PRAGMA es esencial.
db.run("PRAGMA foreign_keys = ON;", (err) => {
if (err) {
console.error("Error activando foreign_keys:", err.message);
} else {
console.log("PRAGMA foreign_keys = ON aplicado.");
}
});
// Activar modo WAL (Write-Ahead Logging).
// Mejora la concurrencia permitiendo lecturas mientras se escribe.
db.run("PRAGMA journal_mode = WAL;", (err) => {
if (err) {
console.error("Error estableciendo journal_mode WAL:", err.message);
} else {
console.log("PRAGMA journal_mode = WAL aplicado.");
}
});
// SQL para crear la tabla "users".
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
// Tabla para registrar qué usuarios se borraron y cuándo.
const createUserDeletionsLogTableSQL = `
CREATE TABLE IF NOT EXISTS user_deletions_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL,
deleted_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`;
// Tabla de cuentas bancarias.
const createAccountsTableSQL = `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
balance REAL NOT NULL
);
`;
// Tabla de historial de transferencias entre cuentas.
const createAccountTransfersTableSQL = `
CREATE TABLE IF NOT EXISTS account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_account_id INTEGER NOT NULL,
to_account_id INTEGER NOT NULL,
amount REAL NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (from_account_id) REFERENCES accounts(id),
FOREIGN KEY (to_account_id) REFERENCES accounts(id)
);
`;
// Ejecutamos cada CREATE TABLE y mostramos errores si los hay.
db.run(createUsersTableSQL, (err) => {
if (err) console.error("Error creando tabla users:", err.message);
else console.log("Tabla users verificada/creada.");
});
db.run(createUserDeletionsLogTableSQL, (err) => {
if (err) console.error("Error creando tabla user_deletions_log:", err.message);
else console.log("Tabla user_deletions_log verificada/creada.");
});
db.run(createAccountsTableSQL, (err) => {
if (err) console.error("Error creando tabla accounts:", err.message);
else console.log("Tabla accounts verificada/creada.");
});
db.run(createAccountTransfersTableSQL, (err) => {
if (err) console.error("Error creando tabla account_transfers:", err.message);
else console.log("Tabla account_transfers verificada/creada.");
});
});
}
// Función útil para testing automatizado.
// Limpia todas las tablas sin eliminar la estructura.
// Esto permite correr tests de forma determinista sin interferencias.
export function clearAllTables() {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Importante: primero borrar logs (dependen de users),
// luego users (dependen de nada),
// después transfers (dependen de accounts),
// y por último accounts.
db.run("DELETE FROM user_deletions_log;", (err1) => {
if (err1) {
reject(err1);
return;
}
db.run("DELETE FROM users;", (err2) => {
if (err2) {
reject(err2);
return;
}
db.run("DELETE FROM account_transfers;", (err3) => {
if (err3) {
reject(err3);
return;
}
db.run("DELETE FROM accounts;", (err4) => {
if (err4) {
reject(err4);
return;
}
// Si todo va bien, resolvemos la promesa.
resolve();
});
});
});
});
});
});
}
Con esto:
- En la app normal usarás
data/app.db. - En tests podrás usar
data/test.db. - Tienes una función
clearAllTables()para dejar todo limpio entre pruebas.
2. Añadir script de tests en package.json
En la raíz del proyecto, actualiza tu package.json para incluir el script de tests:
{
"name": "mi-api-sqlite",
"version": "1.0.0",
"type": "module",
"main": "src/http/server.mjs",
"scripts": {
"start": "node src/http/server.mjs",
"test": "node --test"
},
"dependencies": {
"sqlite3": "^5.1.7"
}
}
El comando node --test ejecutará todos los archivos que terminen en .test.mjs o similares dentro del proyecto.
3. Crear carpeta de tests
Crea una carpeta para los tests:
mkdir tests
Vamos a crear dos archivos:
tests/userModel.test.mjstests/accountModel.test.mjs
Probando directamente los modelos, no HTTP. Esto te deja la lógica de negocio bien cubierta.
4. Tests para userModel: creación, lectura, update y delete con log
Archivo: tests/userModel.test.mjs
// tests/userModel.test.mjs
// Tests del modelo de usuarios usando el runner nativo de Node (node:test).
// Aquí comprobamos, de forma aislada, que las funciones del modelo userModel.mjs
// se comportan como esperamos a nivel de base de datos.
//
// Casos que probamos:
// - Crear usuario.
// - Listar usuarios.
// - Obtener usuario por id.
// - Actualizar usuario.
// - Borrar usuario con log (transacción).
//
// La idea es que, si estos tests pasan, tenemos bastante confianza en que
// la capa de modelo funciona bien sin depender del servidor HTTP.
import test from "node:test";
import assert from "node:assert/strict.js";
import {
initializeDatabase,
clearAllTables,
getDb
} from "../src/db/sqliteClient.mjs";
import {
createUser,
getAllUsers,
getUserById,
updateUser,
deleteUserWithLog
} from "../src/models/userModel.mjs";
// Primer test: “setup” del entorno de pruebas.
// Aunque no es un hook global como en otros frameworks,
// lo usamos como el primer test que se ejecuta en este archivo.
//
// Qué hace:
// - initializeDatabase(): crea las tablas si no existen.
// - clearAllTables(): deja las tablas vacías (pero no las borra).
//
// Así nos aseguramos de arrancar siempre desde un estado limpio.
test("setup inicial userModel", async () => {
initializeDatabase();
await clearAllTables();
});
// Test: crear un usuario y comprobar que se guarda correctamente.
//
// Flujo:
// 1) Llamamos a createUser con email y name.
// 2) Comprobamos que el objeto devuelto tiene:
// - id definido (autoincrement).
// - email y name iguales a los enviados.
// - created_at definido.
test("createUser crea un usuario y lo devuelve con id y created_at", async () => {
const newUser = await createUser({
email: "test1@example.com",
name: "Usuario Test 1"
});
assert.ok(newUser.id, "El nuevo usuario debe tener id");
assert.equal(newUser.email, "test1@example.com");
assert.equal(newUser.name, "Usuario Test 1");
assert.ok(newUser.created_at, "El nuevo usuario debe tener created_at");
});
// Test: getAllUsers devuelve al menos el usuario creado antes.
//
// Este test se apoya en el hecho de que el test anterior ya ha insertado
// un usuario en la tabla. No es completamente independiente, pero simplifica
// el ejemplo y va mostrando una historia de uso real.
test("getAllUsers devuelve la lista de usuarios", async () => {
const users = await getAllUsers();
assert.ok(Array.isArray(users), "getAllUsers debe devolver un array");
assert.ok(users.length >= 1, "Debe haber al menos un usuario");
});
// Test: getUserById devuelve el usuario correcto.
//
// Aquí asumimos que el primer usuario insertado tiene id 1,
// porque la tabla estaba vacía cuando lo creamos.
// En un entorno más complejo podríamos buscar el id de forma dinámica,
// pero para un ejemplo sencillo es suficiente.
test("getUserById devuelve un usuario existente", async () => {
const user = await getUserById(1);
assert.ok(user, "Debe devolver un usuario");
assert.equal(user.id, 1);
assert.equal(user.email, "test1@example.com");
});
// Test: updateUser modifica email y name de un usuario existente.
//
// Pasos:
// 1) Actualizamos el usuario con id 1.
// 2) Comprobamos que el objeto devuelto tiene los nuevos valores.
// 3) Volvemos a leer el usuario desde la base de datos para asegurarnos
// de que el cambio se ha persistido correctamente.
test("updateUser actualiza los datos de un usuario", async () => {
const updated = await updateUser({
id: 1,
email: "actualizado@example.com",
name: "Nombre Actualizado"
});
assert.ok(updated, "Debe devolver el usuario actualizado");
assert.equal(updated.id, 1);
assert.equal(updated.email, "actualizado@example.com");
assert.equal(updated.name, "Nombre Actualizado");
const userFromDb = await getUserById(1);
assert.equal(userFromDb.email, "actualizado@example.com");
assert.equal(userFromDb.name, "Nombre Actualizado");
});
// Test: deleteUserWithLog borra el usuario y escribe en user_deletions_log.
//
// Este test comprueba varios aspectos de la operación transaccional:
// - El resultado indica deleted: true y contiene los datos del usuario.
// - El usuario ya no está en la tabla users.
// - Hay una entrada correspondiente en la tabla user_deletions_log.
test("deleteUserWithLog borra el usuario y registra un log de borrado", async () => {
// Ejecutamos la operación de borrado con log.
const result = await deleteUserWithLog(1);
// Comprobamos el objeto resultado.
assert.ok(result, "Debe devolver un resultado");
assert.equal(result.deleted, true, "El campo deleted debe ser true");
assert.equal(result.user.id, 1, "El log debe referirse al usuario con id 1");
assert.ok(result.deleted_at, "Debe haber un timestamp deleted_at");
// Confirmamos que el usuario ya no existe en la tabla users.
const user = await getUserById(1);
assert.equal(user, null, "El usuario ya no debe existir en la tabla users");
// Comprobación adicional: leer directamente la tabla user_deletions_log.
// Usamos getDb() para acceder a la conexión compartida y hacemos una consulta manual.
const db = getDb();
const logEntry = await new Promise((resolve, reject) => {
db.get(
`
SELECT id, user_id, email, deleted_at
FROM user_deletions_log
WHERE user_id = ?;
`,
[1],
(err, row) => {
if (err) reject(err);
else resolve(row || null);
}
);
});
assert.ok(logEntry, "Debe existir una entrada en user_deletions_log");
assert.equal(logEntry.user_id, 1);
assert.equal(logEntry.email, "actualizado@example.com");
});
Notas:
- El primer test
setup inicial userModelse usa como “preparación” manual. Si prefieres, puedes usartest.beforedesdenode:test, pero así ya es funcional y claro. - Puedes ejecutar solo este archivo con
node --test tests/userModel.test.mjssi quieres.
5. Tests para accountModel: creación y transferencias con transacción
Archivo: tests/accountModel.test.mjs
// tests/accountModel.test.mjs
// Tests del modelo de cuentas y transferencias usando el runner nativo de Node (node:test).
//
// Objetivo de este archivo de pruebas:
// - Verificar que la lógica del modelo accountModel.mjs funciona correctamente,
// sin depender del servidor HTTP.
// - Asegurarnos de que las operaciones con transacciones dejan la base de datos
// en un estado coherente, tanto cuando todo va bien como cuando hay errores.
//
// Casos que se prueban:
// 1. Crear cuentas con saldo inicial.
// 2. Listar cuentas.
// 3. Transferir saldo entre cuentas (transacción correcta).
// 4. Registrar la transferencia en la tabla account_transfers.
// 5. Lanzar error y no modificar saldos cuando no hay saldo suficiente.
import test from "node:test";
import assert from "node:assert/strict.js";
import {
initializeDatabase,
clearAllTables,
getDb
} from "../src/db/sqliteClient.mjs";
import {
createAccount,
getAllAccounts,
transferBetweenAccounts
} from "../src/models/accountModel.mjs";
// Test de preparación inicial del entorno de pruebas.
//
// Se ejecuta como un test más, pero su propósito es hacer de "setup":
// - initializeDatabase(): aplica PRAGMAs y crea las tablas si no existen.
// - clearAllTables(): deja todas las tablas vacías y listas para las pruebas.
//
// Esto garantiza que los tests de este archivo comienzan sobre una BD limpia.
test("setup inicial accountModel", async () => {
initializeDatabase();
await clearAllTables();
});
// Test: crear dos cuentas y listarlas.
//
// Flujo del test:
// 1. Crear dos cuentas con createAccount (Ana, Luis).
// 2. Comprobar que cada una recibe un id (autoincrement).
// 3. Obtener todas las cuentas con getAllAccounts.
// 4. Verificar que hay exactamente 2 cuentas y que los datos coinciden.
test("createAccount y getAllAccounts funcionan correctamente", async () => {
const acc1 = await createAccount({ owner: "Ana", balance: 500 });
const acc2 = await createAccount({ owner: "Luis", balance: 200 });
// Comprobamos que a las cuentas se les ha asignado un id.
assert.ok(acc1.id, "La cuenta 1 debe tener id");
assert.ok(acc2.id, "La cuenta 2 debe tener id");
const accounts = await getAllAccounts();
assert.equal(accounts.length, 2, "Debe haber 2 cuentas");
assert.equal(accounts[0].owner, "Ana");
assert.equal(accounts[1].owner, "Luis");
});
// Test: transferBetweenAccounts transfiere saldo correctamente.
//
// Este test asume que las cuentas creadas antes tienen id 1 y 2.
// Flujo:
// 1. Transferir 100 de la cuenta 1 a la 2.
// 2. Verificar que los saldos devueltos son 400 y 300.
// 3. Verificar que la transferencia queda registrada en account_transfers.
test("transferBetweenAccounts transfiere saldo y registra una transferencia", async () => {
const result = await transferBetweenAccounts({
fromAccountId: 1,
toAccountId: 2,
amount: 100
});
// Comprobamos el objeto resultado que devuelve el modelo.
assert.ok(result.ok, "La transferencia debe ser ok");
assert.equal(result.accounts.from.balance, 400, "La cuenta origen debe quedar con 400");
assert.equal(result.accounts.to.balance, 300, "La cuenta destino debe quedar con 300");
assert.equal(result.transfer.amount, 100);
// Comprobación adicional accediendo directamente a la tabla account_transfers.
// Usamos getDb() para lanzar una consulta manual con sqlite3.
const db = getDb();
const row = await new Promise((resolve, reject) => {
db.get(
`
SELECT id, from_account_id, to_account_id, amount, created_at
FROM account_transfers
WHERE from_account_id = ? AND to_account_id = ?;
`,
[1, 2],
(err, row) => {
if (err) reject(err);
else resolve(row || null);
}
);
});
assert.ok(row, "Debe existir una fila en account_transfers");
assert.equal(row.from_account_id, 1);
assert.equal(row.to_account_id, 2);
assert.equal(row.amount, 100);
});
// Test: la transferencia falla si no hay saldo suficiente.
//
// Aquí verificamos que la transacción se revierte cuando se intenta mover
// más dinero del que hay en la cuenta origen.
//
// Flujo:
// 1. Intentar transferir 1000 desde la cuenta 1 (que tiene 400) a la 2.
// 2. Esperar que se lance un error con mensaje de saldo insuficiente.
// 3. Volver a leer las cuentas y comprobar que los saldos siguen intactos
// (es decir, la transacción se ha hecho ROLLBACK correctamente).
test("transferBetweenAccounts falla con saldo insuficiente", async () => {
let error = null;
try {
await transferBetweenAccounts({
fromAccountId: 1,
toAccountId: 2,
amount: 1000
});
} catch (err) {
error = err;
}
// Debe haberse lanzado un error por saldo insuficiente.
assert.ok(error, "Debe lanzarse un error con saldo insuficiente");
assert.match(error.message, /Saldo insuficiente/i);
// Volvemos a leer las cuentas desde la base de datos.
const accounts = await getAllAccounts();
const acc1 = accounts.find((a) => a.id === 1);
const acc2 = accounts.find((a) => a.id === 2);
// Los saldos deben ser los mismos que después del test anterior (400 y 300),
// lo que demuestra que la transacción se deshizo correctamente.
assert.equal(acc1.balance, 400, "La cuenta origen debe mantener su saldo");
assert.equal(acc2.balance, 300, "La cuenta destino debe mantener su saldo");
});
6. Ejecutar los tests con una base de datos separada
Para no ensuciar data/app.db, usaremos data/test.db en los tests. Gracias al process.env.DB_PATH del módulo de base de datos, esto es sencillo.
En PowerShell, en la raíz del proyecto:
# Creamos la carpeta data si no existe (ya la tienes, pero por si acaso)
mkdir data -ErrorAction SilentlyContinue
# Indicamos a Node que use data/test.db como archivo de base de datos
$env:DB_PATH = "data/test.db"
# Ejecutamos los tests
npm test
SQLite creará data/test.db si no existe y los tests se encargarán de inicializar tablas y limpiar datos.
Si en algún momento quieres “resetear” completamente la BD de tests, basta con borrar el archivo:
Remove-Item data\test.db
La próxima ejecución de npm test lo volverá a crear.
7. Resumen de lo que has conseguido
Con estos cambios:
- Has pasado de una conexión por operación a una conexión compartida.
- Has introducido un “modo test” limpio y reversible via
DB_PATH. - Has creado tests automatizados que cubren:
- CRUD de usuarios con borrado transaccional y log.
- Cuentas con transferencias transaccionales y registro en tabla de historial.
- Has practicado:
- Uso de
node:testynode:assert. - Limpieza de tablas entre tests.
- Verificación directa de los efectos de las transacciones.
- Uso de
1. Pequeño refactor del servidor para poder testearlo
Necesitamos que el servidor:
- Exporte el objeto
serverpara poder arrancarlo y pararlo en los tests. - No se ponga a escuchar si estamos en modo test.
Actualiza src/http/server.mjs así:
// src/http/server.mjs
// Este archivo implementa un servidor HTTP nativo de Node.js sin usar Express.
// Su función es recibir solicitudes HTTP, interpretarlas y delegarlas a los controladores.
// La lógica de negocio está en los controladores, y la lógica de persistencia en los modelos.
//
// Este servidor expone dos grupos de endpoints:
//
// Users:
// - GET /users -> lista todos los usuarios
// - GET /users/:id -> obtiene un usuario concreto
// - POST /users -> crea un usuario
// - PUT /users/:id -> actualiza un usuario
// - DELETE /users/:id -> borra un usuario y registra el borrado en un log
//
// Accounts:
// - GET /accounts -> lista todas las cuentas
// - POST /accounts -> crea una cuenta
// - POST /accounts/transfer -> transfiere saldo entre cuentas (operación transaccional)
import http from "http";
import { initializeDatabase } from "../db/sqliteClient.mjs";
import {
handleCreateUser,
handleGetAllUsers,
handleGetUserById,
handleUpdateUser,
handleDeleteUser
} from "../controllers/userController.mjs";
import {
handleCreateAccount,
handleGetAllAccounts,
handleTransfer
} from "../controllers/accountController.mjs";
// Al importar este módulo, inicializamos la base de datos automáticamente.
// Esto ejecuta la creación de tablas y los PRAGMAs necesarios.
initializeDatabase();
// Encapsula el envío de respuestas JSON.
// Así mantenemos consistente el formato de salida en todas las rutas.
function sendJson(response, statusCode, data) {
const json = JSON.stringify(data, null, 2);
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8"
});
response.end(json);
}
// Función que lee y parsea el body JSON de la petición.
// Node.js entrega los datos del body en fragmentos (chunks) mediante eventos.
function parseRequestBody(request) {
return new Promise((resolve, reject) => {
let bodyData = "";
// En cada chunk recibido, lo acumulamos.
request.on("data", (chunk) => {
bodyData += chunk.toString("utf-8");
});
// Cuando ya no hay más datos, intentamos parsearlo como JSON.
request.on("end", () => {
if (!bodyData) {
resolve(null); // Petición sin body
return;
}
try {
const parsed = JSON.parse(bodyData);
resolve(parsed);
} catch (err) {
const error = new Error("El cuerpo de la petición no es JSON válido.");
error.statusCode = 400;
reject(error);
}
});
request.on("error", (err) => {
reject(err);
});
});
}
// Creamos el servidor HTTP, pero no lo ponemos a escuchar todavía.
// Esto permite que los tests puedan importar este módulo y controlar
// cuándo abrir el servidor en un puerto.
export const server = http.createServer(async (req, res) => {
try {
const { method, url } = req;
// ---------------------------
// Rutas de Users
// ---------------------------
if (method === "GET" && url === "/users") {
const users = await handleGetAllUsers();
sendJson(res, 200, { ok: true, data: users });
return;
}
if (method === "GET" && url.startsWith("/users/")) {
const id = url.split("/")[2];
const user = await handleGetUserById(id);
sendJson(res, 200, { ok: true, data: user });
return;
}
if (method === "POST" && url === "/users") {
const body = await parseRequestBody(req);
const newUser = await handleCreateUser(body);
sendJson(res, 201, { ok: true, data: newUser });
return;
}
if (method === "PUT" && url.startsWith("/users/")) {
const id = url.split("/")[2];
const body = await parseRequestBody(req);
const updatedUser = await handleUpdateUser(id, body);
sendJson(res, 200, { ok: true, data: updatedUser });
return;
}
if (method === "DELETE" && url.startsWith("/users/")) {
const id = url.split("/")[2];
const result = await handleDeleteUser(id);
sendJson(res, 200, { ok: true, data: result });
return;
}
// ---------------------------
// Rutas de Accounts
// ---------------------------
if (method === "GET" && url === "/accounts") {
const accounts = await handleGetAllAccounts();
sendJson(res, 200, { ok: true, data: accounts });
return;
}
if (method === "POST" && url === "/accounts") {
const body = await parseRequestBody(req);
const account = await handleCreateAccount(body);
sendJson(res, 201, { ok: true, data: account });
return;
}
if (method === "POST" && url === "/accounts/transfer") {
const body = await parseRequestBody(req);
const result = await handleTransfer(body);
sendJson(res, 200, { ok: true, data: result });
return;
}
// Si no coincide ninguna ruta, devolvemos 404.
sendJson(res, 404, { ok: false, error: "Ruta no encontrada" });
} catch (err) {
console.error("Error en la petición:", err);
// statusCode personalizado desde controladores, o 500 por defecto.
const statusCode = err.statusCode || 500;
sendJson(res, statusCode, {
ok: false,
error: err.message || "Error interno del servidor"
});
}
});
// Finalmente, arrancamos el servidor solo si NO estamos en modo test.
// Los tests pueden usar server.listen() manualmente.
const PORT = 3000;
if (process.env.NODE_ENV !== "test") {
server.listen(PORT, () => {
console.log(`Servidor HTTP escuchando en http://localhost:${PORT}`);
});
}
Con esto:
- En desarrollo:
npm startsigue funcionando. - En tests:
NODE_ENV=testy el servidor no se pone a escuchar automáticamente.
2. Test de integración HTTP con node:test y fetch
Vamos a crear un test de integración que:
- Use una base de datos de test específica (por ejemplo
data/test_http.db). - Arranque el servidor en un puerto aleatorio.
- Haga peticiones reales HTTP (
fetch) a las rutas. - Verifique el flujo completo: crear usuario, obtenerlo por id, crear cuentas, transferir.
Archivo nuevo: tests/httpServer.test.mjs
// tests/httpServer.test.mjs
// Tests de integración sobre el servidor HTTP.
// A diferencia de los tests de modelo (que llaman directamente a funciones),
// aquí lanzamos peticiones HTTP reales contra el servidor y comprobamos:
//
// - Que los endpoints devuelven los códigos de estado correctos.
// - Que el JSON de respuesta tiene la estructura esperada.
// - Que la base de datos se modifica como esperamos (usuarios y cuentas).
//
// Es decir, probamos el flujo completo:
// cliente HTTP -> servidor -> controladores -> modelos -> SQLite -> respuesta HTTP.
//
// Muy útil para detectar errores de “pegamento” entre capas.
// node:test es el runner de tests nativo de Node.
import test from "node:test";
// assert/strict da aserciones con comparación estricta (===).
import assert from "node:assert/strict.js";
// Antes de importar el servidor, configuramos el entorno de ejecución.
// Esto es crucial:
// - NODE_ENV="test" evita que el servidor se ponga a escuchar automáticamente.
// - DB_PATH apunta a una base de datos de pruebas distinta a la de desarrollo.
process.env.NODE_ENV = "test";
process.env.DB_PATH = "data/test_http.db";
// Una vez configuradas las variables de entorno, ya podemos importar
// los módulos que dependen de ellas.
import { initializeDatabase, clearAllTables } from "../src/db/sqliteClient.mjs";
import { server } from "../src/http/server.mjs";
// En Node 18+ existe fetch globalmente, así que lo reutilizamos.
const { fetch } = global;
// Arranca el servidor en un puerto aleatorio (puerto 0).
// Devolvemos la URL base para las peticiones (http://localhost:PUERTO).
async function startTestServer() {
return new Promise((resolve, reject) => {
server.listen(0, () => {
const address = server.address();
if (!address || typeof address.port !== "number") {
reject(new Error("No se pudo obtener el puerto del servidor de test."));
return;
}
const baseUrl = `http://localhost:${address.port}`;
resolve(baseUrl);
});
});
}
// Detiene el servidor para liberar el puerto al final de las pruebas.
async function stopTestServer() {
return new Promise((resolve, reject) => {
server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
// Variable global que contendrá la URL base del servidor de test.
let BASE_URL = "";
// Setup general del archivo:
// - inicializar BD (PRAGMAs, tablas)
// - limpiar todas las tablas
// - arrancar el servidor en un puerto aleatorio
test("setup inicial httpServer", async () => {
initializeDatabase();
await clearAllTables();
BASE_URL = await startTestServer();
});
// Test 1: flujo simple de usuarios vía HTTP.
// Comprobamos que la API de /users funciona de extremo a extremo:
// 1) POST /users -> crear un usuario.
// 2) GET /users -> listar usuarios (debe incluir el creado).
// 3) GET /users/:id -> obtener el usuario concreto.
test("API /users: crear y obtener usuarios", async () => {
// 1) Crear un usuario
const createRes = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "http-user@example.com",
name: "Usuario HTTP"
})
});
assert.equal(createRes.status, 201, "POST /users debe devolver 201");
const createdBody = await createRes.json();
assert.equal(createdBody.ok, true);
assert.ok(createdBody.data.id, "El usuario creado debe tener id");
const userId = createdBody.data.id;
// 2) Listar todos los usuarios
const listRes = await fetch(`${BASE_URL}/users`);
assert.equal(listRes.status, 200, "GET /users debe devolver 200");
const listBody = await listRes.json();
assert.equal(listBody.ok, true);
assert.ok(Array.isArray(listBody.data), "La respuesta de /users debe ser un array");
assert.ok(listBody.data.length >= 1, "Debe haber al menos un usuario");
// 3) Obtener usuario por id
const detailRes = await fetch(`${BASE_URL}/users/${userId}`);
assert.equal(detailRes.status, 200, "GET /users/:id debe devolver 200");
const detailBody = await detailRes.json();
assert.equal(detailBody.ok, true);
assert.equal(detailBody.data.id, userId);
assert.equal(detailBody.data.email, "http-user@example.com");
});
// Test 2: flujo de cuentas y transferencias vía HTTP.
// Probamos la parte de cuentas de la API:
// 1) POST /accounts -> crear cuenta 1.
// 2) POST /accounts -> crear cuenta 2.
// 3) POST /accounts/transfer -> transferir saldo entre ellas.
// 4) GET /accounts -> comprobar saldos actualizados.
test("API /accounts y /accounts/transfer: crear cuentas y transferir saldo", async () => {
// Crear cuenta 1
const acc1Res = await fetch(`${BASE_URL}/accounts`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
owner: "Ana HTTP",
balance: 500
})
});
assert.equal(acc1Res.status, 201, "POST /accounts debe devolver 201");
const acc1Body = await acc1Res.json();
const acc1Id = acc1Body.data.id;
// Crear cuenta 2
const acc2Res = await fetch(`${BASE_URL}/accounts`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
owner: "Luis HTTP",
balance: 200
})
});
assert.equal(acc2Res.status, 201);
const acc2Body = await acc2Res.json();
const acc2Id = acc2Body.data.id;
// Transferir 100 de acc1 a acc2
const transferRes = await fetch(`${BASE_URL}/accounts/transfer`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
fromAccountId: acc1Id,
toAccountId: acc2Id,
amount: 100
})
});
assert.equal(transferRes.status, 200, "POST /accounts/transfer debe devolver 200");
const transferBody = await transferRes.json();
assert.equal(transferBody.ok, true);
assert.equal(transferBody.data.accounts.from.balance, 400);
assert.equal(transferBody.data.accounts.to.balance, 300);
// Listar cuentas y comprobar que los saldos coinciden con lo devuelto por la transferencia.
const listAccRes = await fetch(`${BASE_URL}/accounts`);
assert.equal(listAccRes.status, 200);
const listAccBody = await listAccRes.json();
const accounts = listAccBody.data;
const acc1 = accounts.find((a) => a.id === acc1Id);
const acc2 = accounts.find((a) => a.id === acc2Id);
assert.equal(acc1.balance, 400);
assert.equal(acc2.balance, 300);
});
// Test de teardown: parar el servidor al final de la batería de tests.
// Esto libera el puerto y deja el proceso limpio.
test("teardown httpServer", async () => {
await stopTestServer();
});
Para ejecutar solo este test:
$env:DB_PATH = "data/test_http.db"
$env:NODE_ENV = "test"
node --test tests\httpServer.test.mjs
Y para ejecutar todos los tests:
$env:DB_PATH = "data/test_http.db"
$env:NODE_ENV = "test"
npm test
Ahora sí, tienes:
- Tests unitarios de modelos.
- Tests de integración vía HTTP completo.