Skip to main content

Transacciones

Cuando empezaste a trabajar con SQL aprendiste a insertar, actualizar y eliminar datos como operaciones independientes. Sin embargo, en las aplicaciones reales estas operaciones rara vez ocurren de forma aislada. Un backend normalmente necesita ejecutar varias instrucciones que deben comportarse como una sola unidad. Si una parte falla, todo debe revertirse. Si todo va bien, debe confirmarse sin riesgo de dejar los datos en un estado intermedio. Ese mecanismo se llama transacción.

Qué es una transacción

Una transacción es un bloque de operaciones que se ejecutan de forma atómica. La idea fundamental es sencilla:

  • Todas las operaciones se completan correctamente o ninguna se aplica.
  • Si ocurre un error, los cambios vuelven al estado inicial.
  • Si todo sale bien, se confirman los cambios.

SQLite garantiza estas propiedades mediante su motor de almacenamiento. Lo importante es que tú decides cuándo empieza y cuándo termina una transacción.

Las transacciones son esenciales cuando varias instrucciones dependen unas de otras:

  • Crear un pedido y sus líneas.
  • Registrar un usuario junto a su perfil.
  • Mover dinero entre cuentas.
  • Actualizar varias tablas como parte de una única acción.

Sin transacciones, podrías terminar con datos incoherentes si alguna operación falla en mitad del proceso.

Cómo se abre y se cierra una transacción en SQLite

SQLite usa tres instrucciones básicas:

BEGIN;
-- Operaciones
COMMIT;

Y si ocurre algo inesperado:

ROLLBACK;

Este flujo lo ejecutarás igual en CLI y más adelante desde Node.js.

Ejemplo simple

Supongamos una tabla:

CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
owner TEXT NOT NULL,
balance REAL NOT NULL
);

Insertamos algunos datos:

INSERT INTO accounts (owner, balance) VALUES ('Ana', 500);
INSERT INTO accounts (owner, balance) VALUES ('Luis', 200);

Ahora queremos transferir 100 de Ana a Luis:

IMPORTANTE: DB Browser ya tiene una transacción abierta sin que tú lo sepas, y cuando tu script intenta hacer BEGIN, SQLite responde: no se puede iniciar una transacción dentro de otra

¿Por qué pasa?

Porque DB Browser, según la configuración y según el modo en el que estés editando datos o ejecutando SQL, abre automáticamente una transacción interna. Entonces tu BEGIN choca con esa transacción escondida. SOLUCIÓN: Comenta BEGIN poniéndole dos guiones delante.

-- Transacción "externa"
BEGIN;

-- Paso 1: crear una nueva cuenta con saldo inicial
-- INSERT INTO accounts (owner, balance)
-- VALUES ('Cuenta temporal', 0);

-- Ahora queremos hacer una transferencia dentro de esta lógica
SAVEPOINT transferencia_ana_luis;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1; -- Ana

UPDATE accounts
SET balance = balance + 100
WHERE id = 2; -- Luis

-- Imagina que aquí haces una validación y detectas un problema:
-- por ejemplo, saldo de Ana < 0 o reglas de negocio que no se cumplen.

-- Si hay un problema, deshacemos SOLO la parte del SAVEPOINT:
ROLLBACK TO transferencia_ana_luis;

-- Y liberamos el savepoint (obligatorio antes del COMMIT final)
RELEASE transferencia_ana_luis;

-- La transacción externa sigue viva: la nueva cuenta sigue creada
COMMIT;

Si ambas operaciones funcionan, el COMMIT deja todo registrado en disco.

Pero si ocurre un error (por ejemplo, falta de saldo, typo, fallo lógico):

ROLLBACK;

La base vuelve a su estado antes del BEGIN.

Qué ocurre si no usas transacciones

Imagina que haces la transferencia sin transacción:

Primero:

UPDATE accounts SET balance = balance - 100 WHERE id = 1;

Y luego:

UPDATE accounts SET balance = balance + 100 WHERE id = 2;

Si entre ambas ocurre un fallo (corte eléctrico, fallo lógico en código, excepción en Node), podrías dejar el sistema así:

  • A Ana ya le quitaste los 100.
  • A Luis no se los sumaste.

El dinero desaparece. Esto es un ejemplo real del tipo de incoherencias que las transacciones evitan.

BEGIN, BEGIN IMMEDIATE y BEGIN EXCLUSIVE

SQLite permite tres variantes para controlar el nivel de bloqueo:

BEGIN (modo por defecto)

  • No bloquea inmediatamente la base de datos.
  • Solo bloquea cuando realmente ejecuta una operación de escritura.
  • Es la opción más flexible.

BEGIN IMMEDIATE

  • Adquiere un bloqueo de escritura desde el inicio.
  • Garantiza que la transacción podrá escribir cuando lo necesite.
  • Útil cuando esperas escribir seguro y no quieres que otra transacción te adelante.

BEGIN EXCLUSIVE

  • Bloquea la base para cualquier otra operación de lectura o escritura.
  • Solo recomendado en casos excepcionales.

Ejemplo:

BEGIN IMMEDIATE;
UPDATE ...
COMMIT;

Savepoints: transacciones dentro de transacciones

SQLite permite dividir una transacción grande en pequeños bloques llamados savepoints. Son útiles cuando necesitas:

  • Deshacer solo una parte de la operación.
  • Probar una acción y revertirla sin cancelar todo.

Sintaxis:

SAVEPOINT paso1;
-- Cambios
ROLLBACK TO paso1;

Y cuando estás listo:

RELEASE paso1;

Ejemplo:

--BEGIN;

SAVEPOINT validar_saldo;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Si detectas saldo negativo:
ROLLBACK TO validar_saldo;

-- Si todo está bien:
RELEASE validar_saldo;

COMMIT;

Los savepoints son especialmente útiles en aplicaciones backend donde parte de la lógica de negocio puede fallar sin querer invalidar toda la transacción.

Transacciones en operaciones múltiples

Un caso común: crear un pedido.

BEGIN;

INSERT INTO orders (user_id, total) VALUES (5, 0);

INSERT INTO order_items (order_id, product_id, quantity)
VALUES (last_insert_rowid(), 12, 3);

UPDATE orders
SET total = (SELECT SUM(quantity * price)
FROM order_items JOIN products ON product_id = products.id
WHERE order_id = last_insert_rowid())
WHERE id = last_insert_rowid();

COMMIT;

Si cualquier parte falla, puedes:

ROLLBACK;

Sin transacciones, podrías terminar con líneas huérfanas, totales mal calculados o pedidos incompletos.

Autocommit: lo que hace SQLite si no usas BEGIN

SQLite opera en modo autocommit cuando no hay ninguna transacción activa, lo que significa:

  • Cada consulta de escritura es una transacción independiente.
  • Cada INSERT o UPDATE se confirma automáticamente.

Esto está bien para scripts simples, pero peligroso en lógica compleja porque pierdes atomicidad. Por eso es recomendable usar transacciones explícitas en cualquier backend.

Qué problemas evita una transacción en backend

  • Inconsistencias por mitad de una operación.
  • Condiciones de carrera entre dos peticiones simultáneas.
  • Datos huérfanos o duplicados.
  • Estados parciales en escrituras masivas.
  • Errores silenciosos cuando varias consultas depende entre sí.

Estas situaciones son diarias en cualquier API real. Si vienes del mundo Node.js, te encontrarás escenarios donde una petición realiza varias operaciones y necesitas garantizar coherencia.

Ejemplo completo de transacciones en SQLite con DB Browser

Vamos a montar un ejemplo totalmente nuevo para ver, de forma muy concreta, cómo funcionan las transacciones en SQLite en un caso realista. Tomaremos como punto de partida la teoría que tienes en tu documento de transacciones.

La idea: un pequeño sistema de préstamos de biblioteca, donde un socio pide un libro. Para registrar el préstamo hay que:

  • Insertar un registro en la tabla de préstamos.
  • Actualizar el stock del libro.
  • Registrar el movimiento en una tabla de auditoría.

Todo eso debe hacerse como una única transacción.

Escenario realista: sistema de préstamos de una biblioteca

Supón que tienes:

  • Una tabla de socios.
  • Una tabla de libros con el número de copias disponibles.
  • Una tabla de préstamos activos.
  • Una tabla de movimientos de préstamo (auditoría).

Si algo falla en mitad del proceso (por ejemplo, intentas prestar un libro que ya no tiene copias), necesitas poder deshacer todos los cambios.

Crear la base de datos con DB Browser for SQLite

Pasos en DB Browser:

  1. Abre DB Browser for SQLite.

  2. Menú File → New Database…

  3. Elige una carpeta y llama al archivo, por ejemplo:

    biblioteca_transacciones.db

  4. DB Browser te pedirá crear una primera tabla, pero puedes cancelar ese diálogo, porque lo haremos con SQL.

  5. Ve a la pestaña “Execute SQL”.

A partir de aquí, copia y pega las instrucciones en “Execute SQL” y pulsa el botón “Play” para ejecutarlas.

Crear las tablas

En la pestaña “Execute SQL”, pega y ejecuta este bloque:

PRAGMA foreign_keys = ON;

DROP TABLE IF EXISTS movimientos_prestamo;
DROP TABLE IF EXISTS prestamos;
DROP TABLE IF EXISTS libros;
DROP TABLE IF EXISTS socios;

CREATE TABLE socios (
id_socio INTEGER PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
fecha_alta TEXT NOT NULL DEFAULT (date('now'))
);

CREATE TABLE libros (
id_libro INTEGER PRIMARY KEY,
titulo TEXT NOT NULL,
autor TEXT NOT NULL,
copias_totales INTEGER NOT NULL,
copias_disponibles INTEGER NOT NULL
);

CREATE TABLE prestamos (
id_prestamo INTEGER PRIMARY KEY,
id_socio INTEGER NOT NULL,
id_libro INTEGER NOT NULL,
fecha_prestamo TEXT NOT NULL DEFAULT (date('now')),
fecha_devolucion TEXT,
FOREIGN KEY (id_socio) REFERENCES socios(id_socio),
FOREIGN KEY (id_libro) REFERENCES libros(id_libro)
);

CREATE TABLE movimientos_prestamo (
id_movimiento INTEGER PRIMARY KEY,
id_prestamo INTEGER,
id_socio INTEGER NOT NULL,
id_libro INTEGER NOT NULL,
tipo_movimiento TEXT NOT NULL, -- 'ALTA_PRESTAMO', 'DEVOLUCION', etc.
fecha TEXT NOT NULL DEFAULT (datetime('now')),
detalle TEXT,
FOREIGN KEY (id_prestamo) REFERENCES prestamos(id_prestamo),
FOREIGN KEY (id_socio) REFERENCES socios(id_socio),
FOREIGN KEY (id_libro) REFERENCES libros(id_libro)
);

Comprueba en la pestaña “Browse Data” que las tablas se han creado correctamente (puedes ir cambiando de tabla en el desplegable).

Insertar datos de prueba

Ahora, en “Execute SQL”, inserta algunos socios y libros:

INSERT INTO socios (nombre, email)
VALUES
('Ana García', 'ana@example.com'),
('Luis Pérez', 'luis@example.com');

INSERT INTO libros (titulo, autor, copias_totales, copias_disponibles)
VALUES
('El nombre del viento', 'Patrick Rothfuss', 3, 3),
('Clean Code', 'Robert C. Martin', 2, 2),
('Introducción a SQLite', 'Alguien', 1, 1);

Puedes ir a “Browse Data” y ver:

  • Tabla socios: deberías ver 2 filas.
  • Tabla libros: deberías ver 3 filas con sus copias totales y disponibles.

Ver el estado inicial de los datos

En “Execute SQL”, prueba estas consultas de lectura:

SELECT * FROM socios;
SELECT * FROM libros;
SELECT * FROM prestamos;
SELECT * FROM movimientos_prestamo;

Deberías ver:

  • prestamos vacío.
  • movimientos_prestamo vacío.
  • libros.copias_disponibles igual a copias_totales en todos.

Ese es tu estado inicial, “limpio”.

Registrar un préstamo correcto usando una transacción

Caso: Ana (id_socio = 1) pide prestado el libro “Clean Code” (id_libro = 2).

Queremos que, dentro de una sola transacción:

  1. Comprobemos que hay copias disponibles.
  2. Actualicemos copias_disponibles.
  3. Insertemos el préstamo en prestamos.
  4. Insertemos un movimiento en movimientos_prestamo.

En “Execute SQL”, pega y ejecuta este bloque completo:

-- Iniciamos la transacción
--BEGIN TRANSACTION;

-- Paso 1: comprobar copias disponibles del libro id 2
SELECT id_libro, titulo, copias_disponibles
FROM libros
WHERE id_libro = 2;

-- Paso 2: restar una copia disponible
UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 2
AND copias_disponibles > 0;

-- Opcional: comprobar que la actualización realmente afectó a una fila
-- (en DB Browser no ves las "rows affected" fácilmente, pero podrías volver a consultar)
SELECT id_libro, titulo, copias_disponibles
FROM libros
WHERE id_libro = 2;

-- Paso 3: insertar el préstamo
INSERT INTO prestamos (id_socio, id_libro)
VALUES (1, 2);

-- Guardamos el id del préstamo recién creado
-- Nota: last_insert_rowid() devuelve el último id autoincrementado de la conexión
INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
)
VALUES (
last_insert_rowid(), -- id_prestamo recién creado
1, -- id_socio (Ana)
2, -- id_libro (Clean Code)
'ALTA_PRESTAMO',
'Ana toma prestado "Clean Code"'
);

-- Si todo ha ido bien, confirmamos los cambios
COMMIT;

En DB Browser puedes ejecutar todo el bloque a la vez. Si no hay ningún error, la transacción se confirma con COMMIT.

Ahora revisa el resultado:

En “Execute SQL”:

SELECT * FROM libros WHERE id_libro = 2;
SELECT * FROM prestamos;
SELECT * FROM movimientos_prestamo;

Y observa:

  • En libros, el libro id_libro = 2 debería tener copias_disponibles = 1 (antes tenía 2).
  • En prestamos, debería aparecer un préstamo nuevo para id_socio = 1 e id_libro = 2.
  • En movimientos_prestamo, debería haber un movimiento con tipo_movimiento = 'ALTA_PRESTAMO' y un id_prestamo que coincide con el de prestamos.

La idea clave: estas modificaciones han quedado todas juntas. No hay estado intermedio visible, porque se confirmaron de golpe con COMMIT.

Simular un fallo y usar ROLLBACK

Vamos ahora a simular un error realista: intentar registrar un préstamo para un socio que no existe.

Por ejemplo, usar id_socio = 999 (inexistente). Queremos ver cómo, si algo falla, podemos volver al estado anterior.

Primero, mira el estado actual de un libro con pocas copias. Vamos a usar el libro id_libro = 3 (“Introducción a SQLite”), que solo tiene 1 copia:

SELECT * FROM libros WHERE id_libro = 3;
SELECT * FROM prestamos;
SELECT * FROM movimientos_prestamo;

Comprueba que copias_disponibles es 1.

Ahora vamos a intentar hacer un préstamo defectuoso dentro de una transacción:

--BEGIN TRANSACTION;

-- Restar una copia del libro 3
UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 3
AND copias_disponibles > 0;

-- Insertar un préstamo con un socio inexistente (id_socio = 999)
INSERT INTO prestamos (id_socio, id_libro)
VALUES (999, 3);

En este punto es probable que veas un error de restricción de clave foránea al ejecutar el bloque, porque id_socio = 999 no existe en socios. Según la configuración de DB Browser, puede que:

  • El script se corte al primer error y no llegue a COMMIT.
  • La transacción quede abierta hasta que decidas qué hacer.

Para asegurarte de que deshaces todo, ejecuta ahora:

ROLLBACK;

Con eso le estás diciendo a SQLite: “deshaz todos los cambios que se hayan hecho desde BEGIN TRANSACTION”.

Comprueba el estado de nuevo:

SELECT * FROM libros WHERE id_libro = 3;
SELECT * FROM prestamos WHERE id_libro = 3;
SELECT * FROM movimientos_prestamo WHERE id_libro = 3;

Debería ser igual que antes de intentar el préstamo defectuoso:

  • libros.copias_disponibles del libro 3 sigue siendo 1.
  • No hay nuevos préstamos con id_libro = 3.
  • No hay nuevos movimientos con id_libro = 3.

La clave: aunque llegaste a ejecutar el UPDATE que restaba 1 copia, al final el ROLLBACK devolvió el libro a su estado original. Ningún cambio parcial sobrevivió.

Comparar con el caso sin transacciones

Para ver el peligro de no usar transacciones, puedes probar lo siguiente (mejor en una tabla de prueba o con copias_disponibles controladas).

Imagina que haces esto, pero sin BEGIN ni COMMIT:

-- Sin transacción
UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 3
AND copias_disponibles > 0;

-- Aquí ocurre un error en la aplicación, y nunca llegas a insertar el préstamo
-- (por ejemplo, un fallo en Node, o un bug de código)

En modo autocommit, SQLite confirmará el UPDATE inmediatamente. El libro habrá perdido una copia, pero no se habrá creado un préstamo ni un movimiento. Te quedas con los datos en un estado incoherente: alguien “ha cogido” el libro, pero no existe ningún préstamo asociado.

Eso es exactamente lo que las transacciones evitan cuando las usas bien.

Qué has practicado con este ejemplo

En este ejemplo has visto:

  • Cómo crear un pequeño esquema realista para una biblioteca.
  • Cómo inicializar datos de prueba.
  • Cómo ejecutar una transacción completa en DB Browser con BEGIN TRANSACTION y COMMIT.
  • Cómo simular un error y usar ROLLBACK para volver al estado inicial.
  • La diferencia práctica entre usar transacciones y operar en modo autocommit.

Extensión del ejercicio

Devoluciones de libros dentro de una transacción

Vamos a continuar con la misma base de datos biblioteca_transacciones.db que creaste antes.

La idea de la devolución es simétrica al préstamo:

  • Marcar en prestamos la fecha de devolución.
  • Sumar una copia en libros.copias_disponibles.
  • Registrar un movimiento DEVOLUCION en movimientos_prestamo.
  • Todo dentro de una única transacción.

Antes de nada, mira qué préstamos tienes:

En DB Browser, pestaña “Execute SQL”:

SELECT * FROM prestamos;

Apunta el id_prestamo del préstamo que hicimos antes. Supongamos que es 1 y que corresponde a:

  • id_prestamo = 1
  • id_socio = 1 (Ana)
  • id_libro = 2 (“Clean Code”)

Si tu id es otro, simplemente sustitúyelo en las consultas.

Ahora ejecuta esta transacción para registrar la devolución:

--BEGIN TRANSACTION;

-- Paso 1: consultar el préstamo para asegurarnos de que existe
SELECT * FROM prestamos
WHERE id_prestamo = 1;

-- Paso 2: actualizar la fecha_devolucion
UPDATE prestamos
SET fecha_devolucion = date('now')
WHERE id_prestamo = 1
AND fecha_devolucion IS NULL;

-- Paso 3: recuperar el id_libro y id_socio
-- (en DB Browser puedes hacer una SELECT aparte para verlo)
SELECT id_socio, id_libro
FROM prestamos
WHERE id_prestamo = 1;

-- Paso 4: sumar una copia disponible al libro asociado
UPDATE libros
SET copias_disponibles = copias_disponibles + 1
WHERE id_libro = 2;

-- Paso 5: registrar el movimiento de devolución
INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
)
VALUES (
1, -- id_prestamo
1, -- id_socio (Ana)
2, -- id_libro (Clean Code)
'DEVOLUCION',
'Ana devuelve "Clean Code"'
);

COMMIT;

Comprueba los resultados:

SELECT * FROM prestamos WHERE id_prestamo = 1;
SELECT * FROM libros WHERE id_libro = 2;
SELECT * FROM movimientos_prestamo WHERE id_prestamo = 1;

Deberías ver:

  • En prestamos, fecha_devolucion rellena con la fecha de hoy.
  • En libros, copias_disponibles del libro 2 se ha incrementado en 1.
  • En movimientos_prestamo, aparece un registro con tipo_movimiento = 'DEVOLUCION'.

El punto importante: si algo hubiera fallado en mitad del proceso, podrías hacer ROLLBACK y dejar todo como estaba antes de la devolución.

Uso de SAVEPOINT para deshacer solo una parte de la lógica

Ahora vamos a ver algo más fino: usar SAVEPOINT para deshacer solo una parte de la transacción, pero no toda.

Escenario: un socio va a la biblioteca y quiere llevarse dos libros a la vez:

  • Libro A: “El nombre del viento” (id_libro = 1).
  • Libro B: “Introducción a SQLite” (id_libro = 3).

Imaginemos que:

  • El préstamo del libro A se hace bien.
  • El préstamo del libro B falla (por ejemplo, porque no hay copias suficientes, o por un error de clave foránea).

Queremos que:

  • El préstamo del libro A se mantenga.
  • El intento fallido del libro B se deshaga.
  • Todo ello dentro de una única transacción grande.

Para que sea más claro, primero deja algunos datos en un estado razonable. Por ejemplo, asegúrate de que los libros tienen al menos 1 copia disponible:

SELECT * FROM libros;

Si quieres, puedes reajustar manualmente los valores para probar:

UPDATE libros SET copias_disponibles = 3 WHERE id_libro = 1;
UPDATE libros SET copias_disponibles = 1 WHERE id_libro = 3;

Supongamos que el socio será Ana (id_socio = 1).

Ahora, ejecuta este bloque:

BEGIN TRANSACTION;

-- SAVEPOINT para el préstamo del libro A (id_libro = 1)
SAVEPOINT prestamo_libro_A;

UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 1
AND copias_disponibles > 0;

INSERT INTO prestamos (id_socio, id_libro)
VALUES (1, 1);

INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
)
VALUES (
last_insert_rowid(), -- id_prestamo libro A
1,
1,
'ALTA_PRESTAMO',
'Ana toma prestado "El nombre del viento"'
);

-- Si todo ha ido bien, liberamos el SAVEPOINT del préstamo A
RELEASE SAVEPOINT prestamo_libro_A;

-- SAVEPOINT para el préstamo del libro B (id_libro = 3)
SAVEPOINT prestamo_libro_B;

UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 3
AND copias_disponibles > 0;

-- Aquí vamos a provocar un error a propósito para simular fallo
-- Por ejemplo, usar un id_socio inexistente
INSERT INTO prestamos (id_socio, id_libro)
VALUES (9999, 3);

-- Si llegáramos aquí, registraríamos el movimiento,
-- pero en realidad el INSERT de arriba debe fallar por clave foránea.
INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
)
VALUES (
last_insert_rowid(), -- id_prestamo libro B
9999,
3,
'ALTA_PRESTAMO',
'Socio inexistente toma "Introducción a SQLite"'
);

-- Liberaríamos el SAVEPOINT si todo fuese bien
RELEASE SAVEPOINT prestamo_libro_B;

-- Si todo lo anterior hubiera salido bien de verdad:
COMMIT;

Al ejecutar este bloque, cuando llegue al INSERT con id_socio = 9999, lo normal es que SQLite lance un error de clave foránea y DB Browser detenga la ejecución del script.

Para ver realmente el efecto de SAVEPOINT, puedes hacerlo en dos pasos:

  1. Ejecutar solo la parte del libro A y el RELEASE SAVEPOINT prestamo_libro_A.
  2. Ejecutar manualmente la parte del libro B, y después un ROLLBACK TO prestamo_libro_B.

Una forma más controlada de verlo:

BEGIN TRANSACTION;

-- Parte A (correcta)
SAVEPOINT prestamo_libro_A;

UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 1
AND copias_disponibles > 0;

INSERT INTO prestamos (id_socio, id_libro)
VALUES (1, 1);

INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
)
VALUES (
last_insert_rowid(),
1,
1,
'ALTA_PRESTAMO',
'Ana toma prestado "El nombre del viento" (SAVEPOINT)'
);

RELEASE SAVEPOINT prestamo_libro_A;

-- Parte B (fallida)
SAVEPOINT prestamo_libro_B;

UPDATE libros
SET copias_disponibles = copias_disponibles - 1
WHERE id_libro = 3
AND copias_disponibles > 0;

-- Simulamos que detectamos un problema ANTES de hacer el INSERT.
-- Por ejemplo: vemos que el socio no cumple alguna condición.
-- En vez de seguir, deshacemos solo lo que afecta al libro B:
ROLLBACK TO prestamo_libro_B;

-- Ahora decidimos no hacer el préstamo del libro B.
-- Pero sí queremos conservar el préstamo del libro A.

COMMIT;

Después de esto, comprueba:

SELECT * FROM libros WHERE id_libro IN (1, 3);
SELECT * FROM prestamos;
SELECT * FROM movimientos_prestamo;

Deberías ver:

  • El libro 1 (id_libro = 1) ha perdido 1 copia y tiene un préstamo y un movimiento asociados.
  • El libro 3 (id_libro = 3) sigue con sus copias como estaban antes de la transacción, porque lo que hicimos con él se deshizo con ROLLBACK TO prestamo_libro_B.
  • La transacción global se cerró con COMMIT, así que todo lo relativo al libro A se ha confirmado.

Idea clave: los SAVEPOINT te permiten “marcar” sub-bloques dentro de una transacción grande. Puedes deshacer solo una parte y mantener el resto.

Ejecutar el mismo ejercicio desde un pequeño script de Node.js

Ahora vamos a ver algo muy parecido, pero controlado desde un script de Node.js con ES Modules.

Objetivo:

  • Conectarnos a biblioteca_transacciones.db.
  • Ejecutar un préstamo dentro de una transacción.
  • Usar try/catch para hacer COMMIT o ROLLBACK según haya error o no.

Preparar el proyecto Node.js

En Windows, crea una carpeta para el ejemplo, por ejemplo:

  • C:\Users\tu_usuario\Documents\sqlite-transacciones-biblioteca

Copia dentro de esa carpeta el archivo biblioteca_transacciones.db que has creado con DB Browser, o apunta la ruta donde está.

Abre esa carpeta con VSCode.

En la terminal integrada de VSCode (PowerShell), ejecuta:

npm init -y

Esto creará un package.json básico.

Edita package.json y añade "type": "module" para usar ES Modules:

{
"name": "sqlite-transacciones-biblioteca",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node transaccion-prestamo.mjs"
},
"license": "MIT"
}

Instala better-sqlite3 (librería síncrona muy cómoda para este tipo de ejemplos):

npm install better-sqlite3

Crear el script de transacciones

Crea un archivo transaccion-prestamo.mjs en la raíz del proyecto con este contenido:

// transaccion-prestamo.mjs
// Ejemplo sencillo de transacciones con SQLite usando better-sqlite3 y ES Modules

import Database from "better-sqlite3";

// Abre la base de datos (ajusta la ruta si está en otro sitio)
const db = new Database("./biblioteca_transacciones.db");

// Función que intenta registrar un préstamo dentro de una transacción
function crearPrestamo({ idSocio, idLibro }) {
console.log(`Intentando crear préstamo. Socio=${idSocio}, Libro=${idLibro}`);

try {
// Iniciar transacción
db.exec("BEGIN TRANSACTION");

// 1. Comprobar copias disponibles
const libro = db
.prepare("SELECT * FROM libros WHERE id_libro = ?")
.get(idLibro);

if (!libro) {
throw new Error("El libro no existe");
}

if (libro.copias_disponibles <= 0) {
throw new Error("No hay copias disponibles para este libro");
}

// 2. Restar una copia disponible
const updateInfo = db
.prepare(
"UPDATE libros SET copias_disponibles = copias_disponibles - 1 WHERE id_libro = ?"
)
.run(idLibro);

if (updateInfo.changes !== 1) {
throw new Error("No se pudo actualizar el stock del libro");
}

// 3. Insertar el préstamo
const insertPrestamo = db.prepare(
"INSERT INTO prestamos (id_socio, id_libro) VALUES (?, ?)"
);

const resultPrestamo = insertPrestamo.run(idSocio, idLibro);

const idPrestamo = resultPrestamo.lastInsertRowid;

// 4. Insertar el movimiento de auditoría
const insertMovimiento = db.prepare(`
INSERT INTO movimientos_prestamo (
id_prestamo,
id_socio,
id_libro,
tipo_movimiento,
detalle
) VALUES (?, ?, ?, ?, ?)
`);

insertMovimiento.run(
idPrestamo,
idSocio,
idLibro,
"ALTA_PRESTAMO",
`Préstamo desde Node.js. Socio=${idSocio}, Libro=${idLibro}`
);

// Si todo ha ido bien, confirmamos la transacción
db.exec("COMMIT");

console.log("Préstamo creado correctamente. id_prestamo =", idPrestamo);
} catch (err) {
// Si cualquier paso falla, deshacemos la transacción
console.error("Error durante la transacción:", err.message);
try {
db.exec("ROLLBACK");
console.log("Se ha hecho ROLLBACK de la transacción.");
} catch (rollbackErr) {
console.error("Error al hacer ROLLBACK:", rollbackErr.message);
}
}
}

// Ejemplo 1: préstamo correcto (ajusta los ids según tus datos)
crearPrestamo({ idSocio: 1, idLibro: 1 });

// Ejemplo 2: préstamo que falle (por ejemplo, socio inexistente)
crearPrestamo({ idSocio: 9999, idLibro: 2 });

// Cerrar la base de datos al terminar
db.close();

En este script:

  • Abrimos la base con new Database("./biblioteca_transacciones.db").
  • En crearPrestamo:
    • Hacemos BEGIN TRANSACTION.
    • Comprobamos que el libro existe y tiene copias disponibles.
    • Actualizamos el stock.
    • Insertamos el préstamo.
    • Insertamos el movimiento.
    • Hacemos COMMIT si todo va bien.
  • Si salta cualquier error, caemos en el catch y hacemos ROLLBACK.
  • Después, ejecutamos dos llamadas:
    • Una con un socio válido (idSocio: 1).
    • Otra con un socio que no existe (idSocio: 9999) para provocar error de clave foránea.

Ejecutar el script en VSCode

En la terminal de VSCode, dentro de la carpeta del proyecto:

npm start

Observa la salida:

  • Deberías ver un mensaje indicando que el primer préstamo se creó correctamente.
  • Luego un error en el segundo préstamo, y un mensaje diciendo que se ha hecho ROLLBACK.

Vuelve a DB Browser, abre biblioteca_transacciones.db y consulta:

SELECT * FROM libros;
SELECT * FROM prestamos;
SELECT * FROM movimientos_prestamo;

Comprueba que:

  • El libro del primer préstamo ha reducido su copias_disponibles y hay un nuevo registro en prestamos y movimientos_prestamo.
  • El segundo intento no ha dejado cambios parciales: si el error se produjo al insertar el préstamo, tampoco se habrá quedado el stock del libro mal.

Con esto ya tienes:

  • Transacciones básicas con BEGIN, COMMIT y ROLLBACK.
  • Devoluciones de libros dentro de una transacción.
  • Uso de SAVEPOINT para deshacer solo una parte.
  • El mismo flujo de negocio ejecutado desde un script Node.js real usando SQLite.

Ejercicio Guiado Completo: API HTTP para Biblioteca con SQLite

Voy a transformar este ejercicio en una guía paso a paso completa y organizada. Te explicaré todo desde cero, incluyendo la estructura de carpetas, la configuración de la base de datos y cada archivo que necesitas crear.

Paso 1: Crear la estructura de carpetas completa

Primero, abre VSCode y sigue estos pasos:

  1. Abre una nueva terminal (Terminal → Nueva Terminal o Ctrl+Shift+`)

  2. Crea la carpeta del proyecto:

    mkdir sqlite-transacciones-biblioteca
    cd sqlite-transacciones-biblioteca

  3. Crea la estructura de carpetas:

    mkdir src
    mkdir src/servicios
    mkdir src/http

Tu estructura debería verse así:

sqlite-transacciones-biblioteca/
├── src/
│ ├── servicios/
│ └── http/

Paso 2: Inicializar el proyecto Node.js

En la terminal, dentro de la carpeta del proyecto:

npm init -y

Esto creará un archivo package.json básico.

Paso 3: Crear el archivo package.json completo

Reemplaza el contenido de package.json con:

{
"name": "sqlite-transacciones-biblioteca",
"version": "1.0.0",
"description": "API HTTP para gestión de biblioteca con SQLite y transacciones",
"main": "src/http/server.mjs",
"type": "module",
"scripts": {
"start": "node src/http/server.mjs",
"dev": "node --watch src/http/server.mjs",
"test": "echo \"Error: no hay tests configurados\" && exit 1"
},
"keywords": ["sqlite", "node", "api", "transacciones"],
"author": "OslarCode",
"license": "Apache-2.0",
"dependencies": {
"better-sqlite3": "^11.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

Paso 4: Instalar dependencias

npm install better-sqlite3

Paso 5: Crear la base de datos con DB Browser

Opción A: Si ya tienes la base de datos biblioteca_transacciones.db

  • Cópiala a la raíz del proyecto

Opción B: Si necesitas crear la base de datos desde cero

  1. Descarga e instala DB Browser for SQLite desde https://sqlitebrowser.org/
  2. Abre DB Browser y haz clic en "Nueva base de datos"
  3. Guárdala como biblioteca_transacciones.db en la carpeta del proyecto
  4. Ejecuta este SQL en la pestaña "Ejecutar SQL":
-- Tabla de socios
CREATE TABLE IF NOT EXISTS socios (
id_socio INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
fecha_registro DATE DEFAULT (date('now'))
);

-- Tabla de libros
CREATE TABLE IF NOT EXISTS libros (
id_libro INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
autor TEXT NOT NULL,
isbn TEXT UNIQUE,
copias_totales INTEGER NOT NULL DEFAULT 1,
copias_disponibles INTEGER NOT NULL DEFAULT 1
);

-- Tabla de préstamos
CREATE TABLE IF NOT EXISTS prestamos (
id_prestamo INTEGER PRIMARY KEY AUTOINCREMENT,
id_socio INTEGER NOT NULL,
id_libro INTEGER NOT NULL,
fecha_prestamo DATE DEFAULT (date('now')),
fecha_devolucion DATE,
FOREIGN KEY (id_socio) REFERENCES socios(id_socio),
FOREIGN KEY (id_libro) REFERENCES libros(id_libro)
);

-- Tabla de auditoría/movimientos
CREATE TABLE IF NOT EXISTS movimientos_prestamo (
id_movimiento INTEGER PRIMARY KEY AUTOINCREMENT,
id_prestamo INTEGER NOT NULL,
id_socio INTEGER NOT NULL,
id_libro INTEGER NOT NULL,
tipo_movimiento TEXT NOT NULL,
detalle TEXT,
fecha_movimiento TIMESTAMP DEFAULT (datetime('now')),
FOREIGN KEY (id_prestamo) REFERENCES prestamos(id_prestamo),
FOREIGN KEY (id_socio) REFERENCES socios(id_socio),
FOREIGN KEY (id_libro) REFERENCES libros(id_libro)
);

-- Insertar datos de prueba
INSERT OR IGNORE INTO socios (id_socio, nombre, email) VALUES
(1, 'Ana García', 'ana@email.com'),
(2, 'Luis Martínez', 'luis@email.com'),
(3, 'Carlos Rodríguez', 'carlos@email.com');

INSERT OR IGNORE INTO libros (id_libro, titulo, autor, copias_totales, copias_disponibles) VALUES
(1, 'El Quijote', 'Miguel de Cervantes', 5, 5),
(2, 'Cien años de soledad', 'Gabriel García Márquez', 3, 3),
(3, '1984', 'George Orwell', 2, 2),
(4, 'Don Juan Tenorio', 'José Zorrilla', 4, 4);

INSERT OR IGNORE INTO prestamos (id_prestamo, id_socio, id_libro, fecha_prestamo) VALUES
(1, 1, 1, '2024-01-15'),
(2, 2, 3, '2024-01-20');

INSERT OR IGNORE INTO movimientos_prestamo (id_prestamo, id_socio, id_libro, tipo_movimiento, detalle) VALUES
(1, 1, 1, 'ALTA_PRESTAMO', 'Préstamo inicial de prueba'),
(2, 2, 3, 'ALTA_PRESTAMO', 'Préstamo inicial de prueba');

  1. Haz clic en "Grabar cambios" (el icono del disquete)
  2. Verifica que las tablas se crearon en la pestaña "Estructura de la BD"

Paso 6: Crear los archivos del proyecto

Archivo 1: src/db.mjs

Crea el archivo src/db.mjs con este contenido:

// src/db.mjs
// Conexión centralizada a la base de datos SQLite

import Database from "better-sqlite3";
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// Obtener la ruta del directorio actual (ES Modules compatible)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Crear la conexión a la base de datos
// Nota: La base de datos está en la raíz del proyecto (un nivel arriba de src/)
const dbPath = join(__dirname, '..', 'biblioteca_transacciones.db');
console.log(`Conectando a la base de datos en: ${dbPath}`);

const db = new Database(dbPath);

// Activar las claves foráneas (IMPORTANTE para integridad referencial)
db.pragma("foreign_keys = ON");

// Habilitar el modo WAL para mejor concurrencia (opcional pero recomendado)
db.pragma("journal_mode = WAL");

// Verificar que la conexión funciona
try {
const version = db.prepare("SELECT sqlite_version() as version").get();
console.log(`✅ Conectado a SQLite versión ${version.version}`);
} catch (error) {
console.error("❌ Error al conectar con la base de datos:", error.message);
process.exit(1);
}

export default db;

Archivo 2: src/servicios/prestamosService.mjs

Crea el archivo src/servicios/prestamosService.mjs:

// src/servicios/prestamosService.mjs
// Servicio de lógica de negocio para préstamos y devoluciones

import db from "../db.mjs";

/**
* Lista todos los libros con información de stock
* @returns {Array} Lista de libros
*/
export function listarLibros() {
try {
const stmt = db.prepare(`
SELECT
id_libro,
titulo,
autor,
isbn,
copias_totales,
copias_disponibles,
(copias_totales - copias_disponibles) as copias_prestadas
FROM libros
ORDER BY titulo
`);
return stmt.all();
} catch (error) {
console.error("Error al listar libros:", error.message);
throw new Error("No se pudieron obtener los libros");
}
}

/**
* Lista todos los préstamos activos
* @returns {Array} Lista de préstamos
*/
export function listarPrestamos() {
try {
const stmt = db.prepare(`
SELECT
p.id_prestamo,
p.id_socio,
s.nombre as nombre_socio,
s.email as email_socio,
p.id_libro,
l.titulo as titulo_libro,
l.autor as autor_libro,
p.fecha_prestamo,
p.fecha_devolucion,
CASE
WHEN p.fecha_devolucion IS NULL THEN 'PENDIENTE'
ELSE 'DEVUELTO'
END as estado
FROM prestamos p
JOIN socios s ON p.id_socio = s.id_socio
JOIN libros l ON p.id_libro = l.id_libro
ORDER BY p.fecha_prestamo DESC
`);
return stmt.all();
} catch (error) {
console.error("Error al listar préstamos:", error.message);
throw new Error("No se pudieron obtener los préstamos");
}
}

/**
* Lista préstamos pendientes (sin devolver)
* @returns {Array} Préstamos pendientes
*/
export function listarPrestamosPendientes() {
try {
const stmt = db.prepare(`
SELECT
p.id_prestamo,
s.nombre as socio,
l.titulo as libro,
p.fecha_prestamo,
julianday('now') - julianday(p.fecha_prestamo) as dias_transcurridos
FROM prestamos p
JOIN socios s ON p.id_socio = s.id_socio
JOIN libros l ON p.id_libro = l.id_libro
WHERE p.fecha_devolucion IS NULL
ORDER BY p.fecha_prestamo
`);
return stmt.all();
} catch (error) {
console.error("Error al listar préstamos pendientes:", error.message);
throw new Error("No se pudieron obtener los préstamos pendientes");
}
}

/**
* Crea un nuevo préstamo con transacción
* @param {number} idSocio - ID del socio
* @param {number} idLibro - ID del libro
* @returns {Object} Resultado de la operación
*/
export function crearPrestamo(idSocio, idLibro) {
// Validaciones básicas
if (!idSocio || !idLibro) {
throw new Error("Se requieren idSocio e idLibro");
}

if (typeof idSocio !== 'number' || typeof idLibro !== 'number') {
throw new Error("idSocio e idLibro deben ser números");
}

// Iniciar transacción
db.exec("BEGIN TRANSACTION");

try {
// 1. Verificar que el socio existe
const socio = db
.prepare("SELECT * FROM socios WHERE id_socio = ?")
.get(idSocio);

if (!socio) {
throw new Error(`El socio con ID ${idSocio} no existe`);
}

// 2. Verificar que el libro existe
const libro = db
.prepare("SELECT * FROM libros WHERE id_libro = ?")
.get(idLibro);

if (!libro) {
throw new Error(`El libro con ID ${idLibro} no existe`);
}

// 3. Verificar disponibilidad
if (libro.copias_disponibles <= 0) {
throw new Error(`No hay copias disponibles de "${libro.titulo}"`);
}

// 4. Actualizar stock del libro
const updateResult = db
.prepare("UPDATE libros SET copias_disponibles = copias_disponibles - 1 WHERE id_libro = ?")
.run(idLibro);

if (updateResult.changes !== 1) {
throw new Error("Error al actualizar el stock del libro");
}

// 5. Crear el préstamo
const prestamoResult = db
.prepare("INSERT INTO prestamos (id_socio, id_libro) VALUES (?, ?)")
.run(idSocio, idLibro);

const idPrestamo = prestamoResult.lastInsertRowid;

// 6. Registrar movimiento de auditoría
db.prepare(`
INSERT INTO movimientos_prestamo
(id_prestamo, id_socio, id_libro, tipo_movimiento, detalle)
VALUES (?, ?, ?, ?, ?)
`).run(
idPrestamo,
idSocio,
idLibro,
"PRESTAMO",
`Préstamo creado - Socio: ${socio.nombre}, Libro: ${libro.titulo}`
);

// 7. Confirmar transacción
db.exec("COMMIT");

console.log(`✅ Préstamo ${idPrestamo} creado exitosamente`);

return {
success: true,
message: "Préstamo creado correctamente",
idPrestamo: idPrestamo,
fechaPrestamo: new Date().toISOString().split('T')[0]
};

} catch (error) {
// 8. Revertir en caso de error
db.exec("ROLLBACK");
console.error("❌ Error en crearPrestamo:", error.message);
throw error;
}
}

/**
* Registra la devolución de un préstamo
* @param {number} idPrestamo - ID del préstamo
* @returns {Object} Resultado de la operación
*/
export function devolverPrestamo(idPrestamo) {
if (!idPrestamo) {
throw new Error("Se requiere idPrestamo");
}

db.exec("BEGIN TRANSACTION");

try {
// 1. Obtener información del préstamo
const prestamo = db
.prepare(`
SELECT p.*, l.id_libro, l.titulo, s.id_socio, s.nombre
FROM prestamos p
JOIN libros l ON p.id_libro = l.id_libro
JOIN socios s ON p.id_socio = s.id_socio
WHERE p.id_prestamo = ?
`)
.get(idPrestamo);

if (!prestamo) {
throw new Error(`El préstamo con ID ${idPrestamo} no existe`);
}

// 2. Verificar que no esté ya devuelto
if (prestamo.fecha_devolucion) {
throw new Error(`El préstamo ${idPrestamo} ya fue devuelto el ${prestamo.fecha_devolucion}`);
}

// 3. Marcar como devuelto
const updateResult = db
.prepare("UPDATE prestamos SET fecha_devolucion = date('now') WHERE id_prestamo = ?")
.run(idPrestamo);

if (updateResult.changes !== 1) {
throw new Error("Error al actualizar la fecha de devolución");
}

// 4. Actualizar stock del libro
const libroResult = db
.prepare("UPDATE libros SET copias_disponibles = copias_disponibles + 1 WHERE id_libro = ?")
.run(prestamo.id_libro);

if (libroResult.changes !== 1) {
throw new Error("Error al actualizar el stock del libro");
}

// 5. Registrar movimiento de auditoría
db.prepare(`
INSERT INTO movimientos_prestamo
(id_prestamo, id_socio, id_libro, tipo_movimiento, detalle)
VALUES (?, ?, ?, ?, ?)
`).run(
idPrestamo,
prestamo.id_socio,
prestamo.id_libro,
"DEVOLUCION",
`Devolución registrada - Libro: ${prestamo.titulo}`
);

// 6. Confirmar transacción
db.exec("COMMIT");

console.log(`✅ Préstamo ${idPrestamo} devuelto exitosamente`);

return {
success: true,
message: "Devolución registrada correctamente",
idPrestamo: idPrestamo,
fechaDevolucion: new Date().toISOString().split('T')[0]
};

} catch (error) {
db.exec("ROLLBACK");
console.error("❌ Error en devolverPrestamo:", error.message);
throw error;
}
}

/**
* Obtiene estadísticas de la biblioteca
* @returns {Object} Estadísticas
*/
export function obtenerEstadisticas() {
try {
const totalLibros = db
.prepare("SELECT COUNT(*) as total FROM libros")
.get();

const totalSocios = db
.prepare("SELECT COUNT(*) as total FROM socios")
.get();

const prestamosActivos = db
.prepare("SELECT COUNT(*) as total FROM prestamos WHERE fecha_devolucion IS NULL")
.get();

const prestamosTotales = db
.prepare("SELECT COUNT(*) as total FROM prestamos")
.get();

return {
totalLibros: totalLibros.total,
totalSocios: totalSocios.total,
prestamosActivos: prestamosActivos.total,
prestamosTotales: prestamosTotales.total,
fechaConsulta: new Date().toISOString()
};
} catch (error) {
console.error("Error al obtener estadísticas:", error.message);
throw new Error("No se pudieron obtener las estadísticas");
}
}

Archivo 3: src/http/server.mjs

Crea el archivo src/http/server.mjs:

// src/http/server.mjs
// Servidor HTTP para la API de biblioteca

import http from 'http';
import {
listarLibros,
listarPrestamos,
listarPrestamosPendientes,
crearPrestamo,
devolverPrestamo,
obtenerEstadisticas
} from '../servicios/prestamosService.mjs';

const PORT = 3000;

/**
* Analiza el cuerpo de una petición JSON
* @param {http.IncomingMessage} req - Objeto de petición
* @returns {Promise<Object>} Datos parseados
*/
function parseJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';

req.on('data', chunk => {
body += chunk.toString();
});

req.on('end', () => {
try {
if (body) {
resolve(JSON.parse(body));
} else {
resolve({});
}
} catch (error) {
reject(new Error('JSON inválido'));
}
});

req.on('error', reject);
});
}

/**
* Envía una respuesta JSON
* @param {http.ServerResponse} res - Objeto de respuesta
* @param {number} statusCode - Código HTTP
* @param {Object} data - Datos a enviar
*/
function sendJsonResponse(res, statusCode, data) {
const jsonData = JSON.stringify(data, null, 2);

res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(jsonData),
'Access-Control-Allow-Origin': '*', // Para pruebas con Hoppscotch
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});

res.end(jsonData);
}

/**
* Maneja errores y envía respuesta apropiada
* @param {http.ServerResponse} res - Objeto de respuesta
* @param {Error} error - Error ocurrido
*/
function handleError(res, error) {
console.error('Error en la API:', error.message);

let statusCode = 500;
let errorMessage = error.message;

// Mapear tipos de error a códigos HTTP
if (error.message.includes('no existe') ||
error.message.includes('ya fue devuelto') ||
error.message.includes('Se requieren') ||
error.message.includes('deben ser números')) {
statusCode = 400; // Bad Request
} else if (error.message.includes('No hay copias disponibles')) {
statusCode = 409; // Conflict
} else if (error.message.includes('JSON inválido')) {
statusCode = 415; // Unsupported Media Type
}

sendJsonResponse(res, statusCode, {
error: true,
message: errorMessage,
timestamp: new Date().toISOString()
});
}

// Crear el servidor HTTP
const server = http.createServer(async (req, res) => {
const { method, url } = req;

// Manejar preflight CORS para Hoppscotch
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
res.end();
return;
}

console.log(`${new Date().toISOString()} - ${method} ${url}`);

// Enrutamiento
try {
// RUTAS GET
if (method === 'GET') {
// Raíz - Documentación de la API
if (url === '/' || url === '/api') {
sendJsonResponse(res, 200, {
api: 'Biblioteca con Transacciones SQLite',
version: '1.0.0',
endpoints: {
raiz: 'GET /',
libros: 'GET /libros',
prestamos: 'GET /prestamos',
prestamosPendientes: 'GET /prestamos/pendientes',
estadisticas: 'GET /estadisticas',
crearPrestamo: 'POST /prestamos',
devolverPrestamo: 'POST /devoluciones'
},
ejemplos: {
crearPrestamo: {
method: 'POST',
url: '/prestamos',
body: { "idSocio": 1, "idLibro": 1 }
},
devolverPrestamo: {
method: 'POST',
url: '/devoluciones',
body: { "idPrestamo": 1 }
}
}
});
return;
}

// Listar libros
if (url === '/libros') {
const libros = listarLibros();
sendJsonResponse(res, 200, {
success: true,
count: libros.length,
data: libros
});
return;
}

// Listar todos los préstamos
if (url === '/prestamos') {
const prestamos = listarPrestamos();
sendJsonResponse(res, 200, {
success: true,
count: prestamos.length,
data: prestamos
});
return;
}

// Listar préstamos pendientes
if (url === '/prestamos/pendientes') {
const pendientes = listarPrestamosPendientes();
sendJsonResponse(res, 200, {
success: true,
count: pendientes.length,
data: pendientes
});
return;
}

// Estadísticas
if (url === '/estadisticas') {
const stats = obtenerEstadisticas();
sendJsonResponse(res, 200, {
success: true,
data: stats
});
return;
}
}

// RUTAS POST
if (method === 'POST') {
// Crear préstamo
if (url === '/prestamos') {
try {
const body = await parseJsonBody(req);

// Validar que vengan los campos requeridos
if (!body.idSocio || !body.idLibro) {
throw new Error('Se requieren idSocio e idLibro en el cuerpo de la petición');
}

// Convertir a números
const idSocio = Number(body.idSocio);
const idLibro = Number(body.idLibro);

// Validar que sean números válidos
if (isNaN(idSocio) || isNaN(idLibro)) {
throw new Error('idSocio e idLibro deben ser números válidos');
}

const resultado = crearPrestamo(idSocio, idLibro);
sendJsonResponse(res, 201, {
success: true,
message: 'Préstamo creado exitosamente',
data: resultado
});
return;

} catch (error) {
handleError(res, error);
return;
}
}

// Devolver préstamo
if (url === '/devoluciones') {
try {
const body = await parseJsonBody(req);

if (!body.idPrestamo) {
throw new Error('Se requiere idPrestamo en el cuerpo de la petición');
}

const idPrestamo = Number(body.idPrestamo);

if (isNaN(idPrestamo)) {
throw new Error('idPrestamo debe ser un número válido');
}

const resultado = devolverPrestamo(idPrestamo);
sendJsonResponse(res, 200, {
success: true,
message: 'Devolución registrada exitosamente',
data: resultado
});
return;

} catch (error) {
handleError(res, error);
return;
}
}
}

// Ruta no encontrada
sendJsonResponse(res, 404, {
error: true,
message: `Ruta no encontrada: ${method} ${url}`,
suggestion: 'Consulta GET / para ver los endpoints disponibles'
});

} catch (error) {
handleError(res, error);
}
});

// Iniciar el servidor
server.listen(PORT, () => {
console.log(`
🚀 Servidor API iniciado correctamente!

📍 URL: <http://localhost>:${PORT}

📚 Endpoints disponibles:

GET / → Documentación de la API
GET /libros → Listar todos los libros con stock
GET /prestamos → Listar todos los préstamos
GET /prestamos/pendientes → Listar préstamos sin devolver
GET /estadisticas → Estadísticas de la biblioteca

POST /prestamos → Crear nuevo préstamo
Body: { "idSocio": 1, "idLibro": 1 }

POST /devoluciones → Registrar devolución
Body: { "idPrestamo": 1 }

🔧 Prueba la API con Hoppscotch en:
<https://hoppscotch.io/?method=GET&url=http://localhost:${PORT}>

💡 Consejo: Mantén esta ventana abierta mientras pruebas la API
`);
});

// Manejar cierre elegante del servidor
process.on('SIGINT', () => {
console.log('\\n👋 Apagando servidor...');
server.close(() => {
console.log('✅ Servidor cerrado correctamente');
process.exit(0);
});
});

Paso 7: Crear archivo de prueba (opcional)

Crea un archivo test-api.mjs en la raíz para probar rápidamente:

// test-api.mjs
// Script para probar rápidamente la API

import http from 'http';

const BASE_URL = '<http://localhost:3000>';

function testEndpoint(method, endpoint, data = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3000,
path: endpoint,
method: method,
headers: {
'Content-Type': 'application/json'
}
};

const req = http.request(options, (res) => {
let responseData = '';

res.on('data', (chunk) => {
responseData += chunk;
});

res.on('end', () => {
try {
const json = JSON.parse(responseData);
resolve({
status: res.statusCode,
data: json
});
} catch (error) {
resolve({
status: res.statusCode,
data: responseData
});
}
});
});

req.on('error', reject);

if (data) {
req.write(JSON.stringify(data));
}

req.end();
});
}

async function runTests() {
console.log('🧪 Iniciando pruebas de la API...\\n');

try {
// 1. Probar endpoint raíz
console.log('1. Probando GET /');
const root = await testEndpoint('GET', '/');
console.log(` Status: ${root.status}`);
console.log(` Respuesta: ${JSON.stringify(root.data, null, 2)}\\n`);

// 2. Listar libros
console.log('2. Probando GET /libros');
const libros = await testEndpoint('GET', '/libros');
console.log(` Status: ${libros.status}`);
console.log(` Total libros: ${libros.data.count}\\n`);

// 3. Listar préstamos
console.log('3. Probando GET /prestamos');
const prestamos = await testEndpoint('GET', '/prestamos');
console.log(` Status: ${prestamos.status}`);
console.log(` Total préstamos: ${prestamos.data.count}\\n`);

// 4. Estadísticas
console.log('4. Probando GET /estadisticas');
const stats = await testEndpoint('GET', '/estadisticas');
console.log(` Status: ${stats.status}`);
console.log(` Libros: ${stats.data.data.totalLibros}`);
console.log(` Socios: ${stats.data.data.totalSocios}`);
console.log(` Préstamos activos: ${stats.data.data.prestamosActivos}\\n`);

console.log('✅ Todas las pruebas GET completadas correctamente!');
console.log('\\n📝 Para probar POST /prestamos y POST /devoluciones:');
console.log(' Usa Hoppscotch o ejecuta el servidor y prueba manualmente.');

} catch (error) {
console.error('❌ Error en las pruebas:', error.message);
console.log('\\n💡 Asegúrate de que el servidor esté ejecutándose:');
console.log(' Ejecuta "npm start" en otra terminal primero.');
}
}

// Ejecutar pruebas si el servidor está corriendo
setTimeout(() => {
runTests();
}, 1000);

Paso 8: Ejecutar el servidor

En la terminal de VSCode (PowerShell), ejecuta:

npm start

Deberías ver un mensaje como:

Servidor API iniciado correctamente!
URL: <http://localhost:3000>
...

Paso 9: Probar la API con Hoppscotch

  1. Abre tu navegador y ve a: https://hoppscotch.io
  2. Configura Hoppscotch:
    • Método: GET
    • URL: http://localhost:3000
    • Haz clic en "Send"
  3. Prueba estos endpoints:

GET /libros

Método: GET
URL: http://localhost:3000/libros

Respuesta esperada:

{
"success": true,
"count": 4,
"data": [
{
"id_libro": 1,
"titulo": "El Quijote",
"autor": "Miguel de Cervantes",
"copias_totales": 5,
"copias_disponibles": 5
},
...más libros
]
}

POST /prestamos

Método: POST
URL: http://localhost:3000/prestamos
Headers: Content-Type: application/json
Body (JSON):
{
"idSocio": 1,
"idLibro": 1
}

Respuesta exitosa (201 Created):

{
"success": true,
"message": "Préstamo creado exitosamente",
"data": {
"success": true,
"message": "Préstamo creado correctamente",
"idPrestamo": 3,
"fechaPrestamo": "2024-01-25"
}
}

POST /devoluciones

Método: POST
URL: http://localhost:3000/devoluciones
Headers: Content-Type: application/json
Body (JSON):
{
"idPrestamo": 3
}

Respuesta exitosa (200 OK):

{
"success": true,
"message": "Devolución registrada exitosamente",
"data": {
"success": true,
"message": "Devolución registrada correctamente",
"idPrestamo": 3,
"fechaDevolucion": "2024-01-25"
}
}

Paso 10: Verificar transacciones en DB Browser

  1. Abre DB Browser for SQLite
  2. Carga la base de datos biblioteca_transacciones.db
  3. Verifica los cambios:
    • Tabla libros: copias_disponibles debe cambiar al hacer préstamos/devoluciones
    • Tabla prestamos: Deben aparecer nuevos registros
    • Tabla movimientos_prestamo: Debe registrar cada operación

Conceptos importantes aprendidos:

1. Transacciones en SQLite

db.exec("BEGIN TRANSACTION");
// Operaciones...
db.exec("COMMIT"); // O ROLLBACK si hay error

2. ES Modules en Node.js

  • Usamos import/export en lugar de require/module.exports
  • Necesitamos "type": "module" en package.json

3. Better-sqlite3 (síncrono)

  • Más simple que sqlite3 (asíncrono)
  • db.prepare() para consultas preparadas
  • .run() para INSERT/UPDATE/DELETE
  • .all() para SELECT múltiples registros
  • .get() para SELECT un registro

4. HTTP Nativo de Node.js

  • Sin frameworks como Express
  • Control total sobre request/response
  • Debemos parsear JSON manualmente

5. Manejo de errores robusto

  • Try-catch en cada operación
  • Rollback automático en errores
  • Códigos HTTP apropiados

Solución de problemas comunes:

Error: "Cannot find module"

# Elimina node_modules y reinstala
rm -r node_modules
npm install

Error: "Port 3000 already in use"

# En Windows, encuentra y mata el proceso
netstat -ano | findstr :3000
taskkill /PID [PID] /F

Error de conexión a la base de datos

  • Verifica que biblioteca_transacciones.db esté en la raíz del proyecto
  • Verifica permisos de lectura/escritura

Ejercicios adicionales (para practicar):

  1. Añadir validación de datos: Validar que el socio no tenga más de 3 préstamos pendientes
  2. Añadir búsqueda: Endpoint GET /libros/buscar?q=quijote
  3. Añadir paginación: GET /libros?page=1&limit=10
  4. Crear frontend simple: HTML + JavaScript que consuma esta API

Resumen del flujo completo:

1. Cliente HTTP (Hoppscotch) → POST /prestamos
2. server.mjs → Parsea request, valida datos
3. prestamosService.mjs → Inicia transacción
4. SQLite → Verifica socio, libro, stock
5. SQLite → Actualiza stock, crea préstamo, registra movimiento
6. SQLite → COMMIT transacción
7. Cliente ← Respuesta JSON 201 Created

¡Felicidades! Has creado una API HTTP completa con:

  • Base de datos SQLite con transacciones
  • Backend Node.js con ES Modules
  • API RESTful con HTTP nativo
  • Manejo robusto de errores
  • Pruebas con Hoppscotch