07
Diseño y arquitectura

Arquitectura y decisiones de arquitectura

Diseño y arquitectura

Semana 7 — Arquitectura y decisiones de arquitectura

Objetivos

  • Definir arquitectura como el conjunto de decisiones que son caras de cambiar, y reconocerlas en un sistema real.
  • Comparar arquitectura en capas, hexagonal (puertos y adaptadores), monolito, monolito modular y microservicios, y elegir con argumentos para un equipo y un producto concretos.
  • Expresar atributos de calidad como escenarios medibles y usarlos para decidir entre alternativas.
  • Escribir un ADR con contexto, alternativas, decisión y consecuencias, y dibujar los diagramas de contexto y de contenedores del modelo C4.
  • Comprobar con una herramienta que el código respeta las dependencias entre capas que la arquitectura promete.

Sesión A — Arquitectura: capas, hexagonal, monolito y microservicios

Lecturas:

  • Martin Fowler y James Lewis, Microservices (2014): las características de un microservicio y, al final, Are Microservices the Future?
  • Martin Fowler, MonolithFirst (2015).
  • Alistair Cockburn, Hexagonal Architecture (puertos y adaptadores).

Lecturas complementarias:

Temas:

  • Qué es arquitectura: las decisiones de un sistema que son caras de cambiar después (cómo se divide en partes, cómo se comunican, dónde viven los datos, cómo se despliega). Diseño (semana 5) y arquitectura son lo mismo a distinta escala; la diferencia es el costo de equivocarse. En Kallpa, “SQLite en un archivo, en el mismo proceso que el servidor” es una decisión de arquitectura; el nombre de una función no lo es.
  • Arquitectura en capas: cada capa usa solo la de abajo. Kallpa declara http → services → store, con domain sin dependencias de base de datos ni de HTTP. La regla vale lo que vale su cumplimiento: una capa que importa “solo un tipo” de dos capas más abajo ya acopla las dos (ejemplo en el laboratorio).
  • Hexagonal o puertos y adaptadores (Cockburn): el núcleo de la aplicación define puertos (interfaces que necesita, como Clock y Notifier en Kallpa) y el mundo exterior se conecta con adaptadores (el reloj del sistema, el aviso por consola, un futuro adaptador de WhatsApp Business, el doble de prueba). En un diseño hexagonal, la dependencia apunta hacia el núcleo. Kallpa lo hace con Clock y Notifier, y por eso sus pruebas usan un reloj fijo sin tocar el código de reservas; con los datos no lo hace, porque BookingService construye sus repositorios de SQLite.
  • Monolito: un solo programa que se despliega entero. Ventajas reales: una sola base de código, llamadas locales, transacciones simples, un solo despliegue. Monolito modular: un monolito con módulos de fronteras claras, que se podría partir después. Microservicios (Fowler y Lewis): servicios que se despliegan por separado, organizados por capacidades del negocio, cada uno con sus datos, comunicándose por la red; se ganan despliegues independientes y escalado por partes a cambio de fallas parciales, latencia, consistencia eventual y mucha más operación.
  • Ley de Conway: la estructura de un sistema tiende a copiar la estructura de comunicación de la organización que lo construye. Un equipo de cinco personas con un cliente no tiene la organización que justifica diez servicios.
  • Monolith first: casi todos los sistemas con microservicios exitosos empezaron como un monolito que creció; partir antes de conocer las fronteras del dominio produce fronteras equivocadas, y moverlas entre servicios es mucho más caro que moverlas entre módulos.
  • Error común: elegir microservicios “porque escalan” sin un requisito de escala. Kallpa recibe unas decenas de reservas al día; su problema de escala no es el tráfico. Y al revés: “es un monolito” no es excusa para que todo dependa de todo.
  • Con IA: un agente que agrega una funcionalidad elige el camino más corto, que a menudo cruza capas (un handler que consulta la base de datos directamente). La arquitectura escrita (ADRs, un diagrama) sirve de contexto para el agente, y una regla automática (por ejemplo no-restricted-imports de ESLint) detecta los imports que cruzan capas aunque nadie los lea en la revisión; no ve otros cruces, como un handler que usa una conexión que le pasaron, y por eso no reemplaza la revisión.

Sesión B — Atributos de calidad y decisiones de arquitectura (ADR) · ✂️ diagramas C4

Lecturas:

Lecturas complementarias:

Temas:

  • Atributos de calidad (requisitos no funcionales de la semana 3, vistos desde la arquitectura): disponibilidad, rendimiento, seguridad, mantenibilidad, facilidad de prueba, facilidad de despliegue, costo. La norma ISO/IEC 25010 los clasifica; en la práctica importa que cada uno se pueda medir.
  • Un atributo de calidad se escribe como escenario: estímulo, entorno, respuesta y medida. “Kallpa debe ser rápida” no se puede verificar; “con 20 vecinos reservando a la vez el lunes a las 8:00, el 99 % de las reservas responde en menos de 500 ms y ninguna cancha queda reservada dos veces” sí.
  • Los atributos compiten: más disponibilidad (dos instancias del servidor) pide coordinar los datos; más seguridad (autenticación) agrega pasos para el vecino. Una decisión de arquitectura es elegir qué atributo gana, para este cliente, y escribir qué se pierde.
  • ADR (Nygard): un documento corto por decisión, que no se reescribe (si la decisión cambia, se escribe otro ADR y en el anterior solo cambia el estado), con título, estado (propuesto, aceptado, reemplazado por el ADR N), contexto, decisión y consecuencias. MADR agrega las alternativas consideradas con sus pros y contras. Viven en el repositorio (docs/adr/0001-...md) y se revisan en un pull request como el código. Si la decisión cambia, no se edita el ADR viejo: se escribe uno nuevo que lo reemplaza.
  • Error común: un ADR sin alternativas (“decidimos usar SQLite”). Si no se escribe qué más se consideró y por qué se descartó, quien llegue en un año no sabe si la decisión sigue vigente cuando cambie el contexto. Otro: escribir el ADR después, para justificar lo que ya se hizo; el valor está en tomar la decisión con el contexto a la vista.
  • La deuda técnica como decisión: “una sola instancia del servidor, sin restricción de cruces en la base de datos” puede ser deuda deliberada y prudente (Fowler) si se escribe en un ADR con la condición que obligaría a pagarla.
  • ✂️ C4 (Simon Brown): cuatro niveles de zoom. Contexto: el sistema, sus usuarios y los sistemas externos (vecinos, administración, WhatsApp). Contenedores: las piezas que se ejecutan o guardan datos por separado (la página en el navegador, el servidor Node.js, el archivo SQLite). Componentes: las partes de un contenedor (http, services, store, domain). Código: casi nunca se dibuja a mano. Error común: confundir el contenedor de C4 con un contenedor de Docker. En C4 es cualquier cosa que tiene que estar corriendo (o existir, como una base de datos) para que el sistema funcione; puede desplegarse o no en Docker.
  • Con IA: los ADRs y los diagramas son parte del contexto que se le da a un agente para que no “rediseñe” sin querer. Un modelo de lenguaje también puede redactar el primer borrador de un ADR, pero las alternativas y las consecuencias tienen que salir del equipo y del cliente: el modelo no sabe cuántas personas mantendrán el sistema ni cuánto puede pagar el complejo por un servidor.

Laboratorio — Un ADR y un diagrama de contenedores para Kallpa

Referencias: template MADR, C4: diagrama de contenedores, Mermaid en GitHub, ESLint, no-restricted-imports. Material: code/week-07/. En parejas, en su repositorio de Kallpa, en una rama nueva.

  1. Mapear las dependencias reales entre carpetas de src/: grep -rhoE "from '\.\./[a-z]+/" src/http src/services src/store src/domain | sort | uniq -c (y repetirlo por carpeta). Compararlas con lo que promete el README de Kallpa (“http usa services, domain y notifications; services usa store”) y anotar cada import que no cumpla la regla.
  2. Convertir la regla en una revisión automática: en eslint.config.js, un bloque con files: ['src/domain/**/*.ts'] y la regla no-restricted-imports con patterns que prohíban ../store/*, ../http/*, ../services/*, node:sqlite y node:http, y otro para src/http/**/*.ts que prohíba ../store/*. Correr make lint y explicar lo que reporta. Decidir con su pareja si se corrige el código o se cambia la regla, y hacerlo.
  3. Dibujar el diagrama de contexto y el de contenedores de Kallpa en Mermaid, en docs/arquitectura.md (GitHub lo dibuja al ver el archivo): personas (vecino, administración), contenedores (página web, API en Node.js, archivo SQLite), sistemas externos (WhatsApp, por ahora la consola) y, en cada flecha, qué viaja y por qué protocolo. Agregar el diagrama de componentes del contenedor de la API (http, services, store, domain, notifications).
  4. El caso para el ADR: el complejo quiere correr dos copias del servidor sobre el mismo archivo de base de datos, para que una caída no deje a los vecinos sin reservar. Detener make start si está corriendo y ejecutar desde la carpeta de Kallpa bash /work/code/week-07/dos-instancias.sh, que levanta dos servidores (puertos 3000 y 3001) sobre una misma base de datos y manda 20 reservas simultáneas de la misma hora, repartidas entre los dos. Anotar qué respuestas llegaron (con su código HTTP), qué quedó en la base de datos (sqlite3) y qué dicen los logs de los dos servidores.
  5. Leer createBooking en src/http/handlers.ts y explicar lo que vieron: ¿dónde se revisa que la hora esté libre y dónde se guarda la reserva? ¿Qué tendría que pasar entre esas dos líneas para que una cancha quede reservada dos veces? ¿Por qué dentro de un solo proceso no pasa?
  6. Escribir docs/adr/0001-instancias-y-cruces-de-reservas.md con el template de ADR de P2, que sigue a MADR con títulos en español: contexto (con el escenario de calidad medible del paso anterior), al menos tres alternativas (por ejemplo, una sola instancia y aceptar el riesgo; una restricción o una transacción en SQLite; cambiar a una base de datos con servidor), pros y contras de cada una, la decisión y sus consecuencias, incluida la deuda que queda.
  7. Abrir un pull request con el diagrama, el ADR y el cambio de ESLint, y pedir revisión a otra pareja. La revisión responde una sola pregunta: ¿alguien que no estuvo en el laboratorio entendería por qué se decidió así?

Entregas

Sin entregas esta semana. P2 (Diseño y primer sprint: design doc, ADRs, especificación de la API y un esqueleto que funciona de punta a punta) se entrega la semana 8; los ADRs de este laboratorio son el modelo para los de P2 (projects/p2-diseno/README.md).

Preguntas de repaso

  1. Den dos decisiones de Kallpa que sean de arquitectura y dos que no lo sean. ¿Qué criterio usaron para separarlas?
  2. Dibujen Kallpa como hexágono: ¿cuáles son sus puertos y qué adaptadores tiene hoy cada uno? ¿Qué adaptador nuevo haría falta para avisar por WhatsApp de verdad y qué parte del código no cambiaría?
  3. Un compañero propone separar Kallpa en tres microservicios (canchas, reservas y pagos) para el proyecto del curso. Escriban dos argumentos a favor y tres en contra, usando la ley de Conway y monolith first.
  4. Reescriban “el sistema debe estar siempre disponible” como un escenario de calidad medible para el producto de su cliente. ¿Con qué otro atributo compite?
  5. ¿Qué le falta a este ADR: “Contexto: necesitamos una base de datos. Decisión: usamos SQLite. Consecuencias: es fácil”? Reescríbanlo.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (14)Pruebas (7)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

await-intermedio.diffDescargar await-intermedio.diff
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..3c103b8 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -94,6 +94,10 @@ export function createBooking(deps: AppDependencies): Handler {
       throw new HttpError(409, 'SLOT_TAKEN', 'La cancha ya está reservada en ese horario.');
     }
 
+    // Un paso asíncrono entre la revisión y el guardado (por ejemplo, confirmar
+    // algo con un servicio externo): otras peticiones corren mientras se espera.
+    await new Promise((resolve) => setTimeout(resolve, 10));
+
     const priceCents = quotePriceCents({
       sport: court.sport,
       date: request.date,
capas-eslint.jsDescargar capas-eslint.js
// Kallpa con las reglas de capas del laboratorio de la semana 7. Para probarlas
// sin tocar su eslint.config.js: eslint -c capas-eslint.js .
// En el laboratorio, estos dos bloques van dentro de eslint.config.js.
import base from './eslint.config.js';

export default [
  ...base,
  {
    // domain no sabe de la base de datos ni de HTTP.
    files: ['src/domain/**/*.ts'],
    rules: {
      'no-restricted-imports': [
        'error',
        { patterns: ['../store/*', '../http/*', '../services/*', 'node:sqlite', 'node:http'] },
      ],
    },
  },
  {
    // http llega a los datos a través de services.
    files: ['src/http/**/*.ts'],
    rules: {
      'no-restricted-imports': ['error', { patterns: ['../store/*'] }],
    },
  },
];
cruce-de-capas.diffDescargar cruce-de-capas.diff
Ilustración escrita para la clase de la semana 7: un cambio como los que suele
proponer un agente. No es la salida de ninguna herramienta. Para listar las
reservas de una cancha, el handler recibe la conexión y arma su propio repositorio.

diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..0724cf3 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -1,3 +1,4 @@
+import type { DatabaseSync } from 'node:sqlite';
 import {
   CLOSING_HOUR,
   OPENING_HOUR,
@@ -15,7 +16,7 @@ import { quotePriceCents } from '../domain/pricing.ts';
 import { dailySummary } from '../domain/report.ts';
 import type { Notifier } from '../notifications/notifier.ts';
 import type { BookingService } from '../services/booking-service.ts';
-import type { BookingRow } from '../store/booking-repository.ts';
+import { BookingRepository, type BookingRow } from '../store/booking-repository.ts';
 import { HttpError, readJson, sendJson } from './json.ts';
 import type { Handler } from './router.ts';
 
@@ -23,6 +24,7 @@ export type AppDependencies = {
   service: BookingService;
   clock: Clock;
   notifier: Notifier;
+  db: DatabaseSync;
 };
 
 export function toBookingJson(row: BookingRow): Booking {
@@ -152,6 +154,15 @@ export function memberBookings(deps: AppDependencies): Handler {
   };
 }
 
+// GET /api/courts/:id/bookings?date=YYYY-MM-DD: las reservas de una cancha.
+export function courtBookings(deps: AppDependencies): Handler {
+  return ({ res, url, params }) => {
+    const date = url.searchParams.get('date') ?? '';
+    const rows = new BookingRepository(deps.db).listByCourtAndDate(Number(params.id), date);
+    sendJson(res, 200, rows.map(toBookingJson));
+  };
+}
+
 // GET /api/reports/daily?date=YYYY-MM-DD: la caja del día, para la administración.
 export function dailyReport(deps: AppDependencies): Handler {
   return ({ res, url }) => {
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..9626688 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -6,6 +6,7 @@ import { BookingService } from '../services/booking-service.ts';
 import {
   cancelBooking,
   courtAvailability,
+  courtBookings,
   createBooking,
   dailyReport,
   listCourts,
@@ -28,6 +29,7 @@ export function buildRouter(deps: AppDependencies): Router {
   const router = new Router();
   router.get('/api/courts', listCourts(deps));
   router.get('/api/courts/:id/availability', courtAvailability(deps));
+  router.get('/api/courts/:id/bookings', courtBookings(deps));
   router.get('/api/members', listMembers(deps));
   router.get('/api/members/:id/bookings', memberBookings(deps));
   router.post('/api/bookings', createBooking(deps));
@@ -41,6 +43,7 @@ export function createServer(options: ServerOptions): http.Server {
     service: new BookingService(options.db),
     clock: options.clock ?? systemClock,
     notifier: options.notifier ?? consoleNotifier,
+    db: options.db,
   };
   const log = options.log ?? console.log;
   const router = buildRouter(deps);
dos-instancias.shDescargar dos-instancias.sh
#!/usr/bin/env bash
# Semana 7: dos instancias de Kallpa sobre el mismo archivo de SQLite y 20
# reservas simultáneas de la misma hora, repartidas entre las dos.
# Se corre desde la carpeta de Kallpa, con `make start` detenido.
if [ ! -f src/main.ts ]; then
  echo "Corran este script desde la carpeta de Kallpa (no encuentro src/main.ts)." >&2
  exit 2
fi
if curl -s -o /dev/null localhost:3000 || curl -s -o /dev/null localhost:3001; then
  echo "Ya hay un servidor en el puerto 3000 o 3001: detengan make start primero." >&2
  exit 2
fi
export KALLPA_DB=/tmp/dos-instancias.db
rm -f "$KALLPA_DB" "$KALLPA_DB"-*
PORT=3000 node src/main.ts > /tmp/a.log 2>&1 &
A=$!
sleep 2
PORT=3001 node src/main.ts > /tmp/b.log 2>&1 &
B=$!
sleep 2
D=$(date -d '+3 days' +%F)
echo "Respuestas (cantidad y código HTTP):"
for i in $(seq 1 20); do
  curl -s -o /dev/null -w '%{http_code}\n' -X POST "localhost:$((3000 + i % 2))/api/bookings" \
    -H 'content-type: application/json' \
    -d "{\"courtId\":3,\"memberId\":2,\"date\":\"$D\",\"startHour\":9,\"teamName\":\"Prueba $i\"}" &
done | sort | uniq -c
echo "Reservas confirmadas de la cancha 3 el $D a las 9:00:"
sqlite3 "$KALLPA_DB" "SELECT id, team_name FROM bookings WHERE court_id = 3 AND date = '$D' AND start_hour = 9 AND status = 'confirmed'"
kill $A $B
wait $A $B 2>/dev/null
echo "Logs de los servidores: /tmp/a.log y /tmp/b.log"
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'capas-eslint.js'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
medir-escenario.tsDescargar medir-escenario.ts
// Mide un escenario de calidad contra Kallpa de verdad: uno o dos procesos
// sobre un mismo archivo de SQLite, y rondas de reservas simultáneas, todas
// por la misma hora.
//
//   node medir-escenario.ts <carpeta de Kallpa> <instancias> [rondas] [por ronda]
//
// Cada ronda pelea por una hora distinta, así que en cada una debería haber
// una sola reserva. Los números valen para esta máquina y este momento: sirven
// para comparar alternativas, no como promesa de lo que pasará en producción.
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
import { addDays } from '../../kallpa/src/domain/calendar.ts';
import { limaTime, systemClock } from '../../kallpa/src/domain/clock.ts';

export type Sample = { status: number; ms: number };

export type Report = {
  rounds: number;
  requests: number;
  byStatus: Record<string, number>;
  p50: number;
  p99: number;
  max: number;
  doubleBooked: number;
  roundsWithoutBooking: number;
  savedWithout200: number;
  // Rondas en que todas las peticiones que recibió una de las instancias fallaron con 500.
  roundsOneInstanceAllFailed: number;
};

// Percentil por el método del rango más cercano: el valor en la posición
// ⌈p/100 · n⌉ de las muestras ordenadas. Con 20 muestras, el p99 es el máximo.
export function percentile(values: readonly number[], p: number): number {
  if (values.length === 0) throw new Error('sin muestras');
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)];
}

function isoDate(daysAhead: number): string {
  return addDays(limaTime(systemClock.now()).date, daysAhead);
}

// Horas sin reservas de ejemplo (los datos llegan hasta dentro de 6 días).
function slot(round: number): { courtId: number; date: string; startHour: number } {
  const courts = 4;
  const hours = 9;
  const days = 7;
  if (round >= courts * hours * days) throw new Error('demasiadas rondas');
  return {
    courtId: 1 + (round % courts),
    startHour: 8 + (Math.floor(round / courts) % hours),
    date: isoDate(7 + Math.floor(round / (courts * hours))),
  };
}

async function waitUntilUp(port: number): Promise<void> {
  for (let i = 0; i < 100; i++) {
    try {
      await fetch(`http://127.0.0.1:${port}/api/courts`);
      return;
    } catch {
      await new Promise((done) => setTimeout(done, 50));
    }
  }
  throw new Error(`El servidor del puerto ${port} no respondió.`);
}

function startServer(dir: string, port: number, db: string): ChildProcess {
  return spawn('node', ['src/main.ts'], {
    cwd: dir,
    env: { ...process.env, PORT: String(port), KALLPA_DB: db },
    stdio: 'ignore',
  });
}

export async function measure(dir: string, instances: number, rounds = 100, perRound = 20, firstPort = 3100): Promise<Report> {
  const tmp = mkdtempSync(join(tmpdir(), 'escenario-'));
  const dbPath = join(tmp, 'kallpa.db');
  const servers: ChildProcess[] = [];
  const ports = Array.from({ length: instances }, (_, i) => firstPort + i);
  try {
    // De a uno: el primero crea el esquema y carga los datos de ejemplo.
    for (const port of ports) {
      servers.push(startServer(dir, port, dbPath));
      await waitUntilUp(port);
    }
    const samples: Sample[] = [];
    let roundsOneInstanceAllFailed = 0;
    for (let round = 0; round < rounds; round++) {
      const { courtId, date, startHour } = slot(round);
      const failures = new Map(ports.map((port) => [port, 0]));
      const requests = Array.from({ length: perRound }, async (_, i) => {
        const started = performance.now();
        const response = await fetch(`http://127.0.0.1:${ports[i % ports.length]}/api/bookings`, {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ courtId, memberId: 2, date, startHour, teamName: `Equipo ${i}` }),
        });
        await response.arrayBuffer();
        samples.push({ status: response.status, ms: performance.now() - started });
        const port = ports[i % ports.length];
        if (response.status === 500) failures.set(port, (failures.get(port) ?? 0) + 1);
      });
      await Promise.all(requests);
      const received = perRound / ports.length;
      if ([...failures.values()].some((n) => n === received)) roundsOneInstanceAllFailed++;
    }

    const db = new DatabaseSync(dbPath, { readOnly: true, timeout: 5000 });
    const rows = db
      .prepare(
        `SELECT court_id, date, start_hour, COUNT(*) AS n FROM bookings
         WHERE status = 'confirmed' AND date >= ? GROUP BY court_id, date, start_hour`,
      )
      .all(isoDate(7)) as { n: number }[];
    db.close();

    const byStatus: Record<string, number> = {};
    for (const s of samples) byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
    const ms = samples.map((s) => s.ms);
    const saved = rows.reduce((sum, row) => sum + row.n, 0);
    return {
      rounds,
      requests: samples.length,
      byStatus,
      p50: percentile(ms, 50),
      p99: percentile(ms, 99),
      max: percentile(ms, 100),
      doubleBooked: rows.filter((row) => row.n > 1).length,
      roundsWithoutBooking: rounds - rows.length,
      savedWithout200: saved - (byStatus['200'] ?? 0),
      roundsOneInstanceAllFailed,
    };
  } finally {
    for (const server of servers) server.kill();
    await Promise.all(
      servers.map((server) => (server.exitCode === null ? new Promise((done) => server.once('exit', done)) : null)),
    );
    rmSync(tmp, { recursive: true, force: true });
  }
}

export function format(report: Report): string {
  const statuses = Object.entries(report.byStatus)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([status, n]) => `${n}×${status}`)
    .join('  ');
  return [
    `  ${report.rounds} rondas, ${report.requests} peticiones: ${statuses}`,
    `  latencia: p50 ${report.p50.toFixed(1)} ms, p99 ${report.p99.toFixed(1)} ms, máximo ${report.max.toFixed(1)} ms`,
    `  horas reservadas dos veces: ${report.doubleBooked}`,
    `  rondas sin ninguna reserva: ${report.roundsWithoutBooking}`,
    `  reservas guardadas sin responder 200: ${report.savedWithout200}`,
    `  rondas en que una instancia solo respondió 500: ${report.roundsOneInstanceAllFailed}`,
  ].join('\n');
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [dir, instances, rounds, perRound] = process.argv.slice(2);
  if (!dir || !instances) {
    console.error('Uso: node medir-escenario.ts <carpeta de Kallpa> <instancias> [rondas] [por ronda]');
    process.exit(2);
  }
  const report = await measure(resolve(dir), Number(instances), Number(rounds ?? 100), Number(perRound ?? 20));
  console.log(format(report));
}
medir-reinicio.tsDescargar medir-reinicio.ts
// Cuánto tarda Kallpa en volver a responder después de una caída: mata el proceso
// con SIGKILL, lo levanta de nuevo sobre el mismo archivo y cuenta hasta el primer
// 200. No incluye lo que tarde en darse cuenta quien lo vigila.
//
//   node medir-reinicio.ts <carpeta de Kallpa> [veces]
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

const dir = resolve(process.argv[2] ?? 'build/kallpa');
const times = Number(process.argv[3] ?? 10);
const port = 3150;
const tmp = mkdtempSync(join(tmpdir(), 'reinicio-'));

function start(): ChildProcess {
  return spawn('node', ['src/main.ts'], {
    cwd: dir,
    env: { ...process.env, PORT: String(port), KALLPA_DB: join(tmp, 'kallpa.db') },
    stdio: 'ignore',
  });
}

async function waitUp(): Promise<void> {
  for (;;) {
    try {
      const response = await fetch(`http://127.0.0.1:${port}/api/courts`);
      await response.arrayBuffer();
      if (response.status === 200) return;
    } catch {
      await new Promise((done) => setTimeout(done, 5));
    }
  }
}

let server = start();
await waitUp();
const samples: number[] = [];
for (let i = 0; i < times; i++) {
  const exited = new Promise((done) => server.once('exit', done));
  server.kill('SIGKILL');
  await exited;
  const started = performance.now();
  server = start();
  await waitUp();
  samples.push(performance.now() - started);
}
server.kill();
rmSync(tmp, { recursive: true, force: true });
samples.sort((a, b) => a - b);
console.log(`${times} reinicios: mínimo ${samples[0].toFixed(0)} ms, mediana ${samples[Math.floor(times / 2)].toFixed(0)} ms, máximo ${samples[times - 1].toFixed(0)} ms`);
process.exit(0);
solo-espera.diffDescargar solo-espera.diff
diff --git a/src/store/database.ts b/src/store/database.ts
--- a/src/store/database.ts
+++ b/src/store/database.ts
@@ -38,7 +38,8 @@ const MIGRATIONS: string[] = [
 ];
 
 export function openDatabase(path: string): DatabaseSync {
-  const db = new DatabaseSync(path);
+  // Si otra conexión tiene el lock, esperar hasta 5 s en vez de fallar enseguida.
+  const db = new DatabaseSync(path, { timeout: 5000 });
   // SQLite no revisa las claves foráneas si no se le pide en cada conexión.
   db.exec('PRAGMA foreign_keys = ON');
   migrate(db);
transaccion.diffDescargar transaccion.diff
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..4bcb3fb 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -89,27 +89,29 @@ export function createBooking(deps: AppDependencies): Handler {
       throw new HttpError(violation.code === 'INVALID_DATE' ? 400 : 422, violation.code, violation.message);
     }
 
-    const sameDay = deps.service.bookingsForCourt(court.id, request.date).filter((b) => b.status === 'confirmed');
-    if (sameDay.some((b) => overlaps({ startHour: b.start_hour, hours: b.hours }, request))) {
-      throw new HttpError(409, 'SLOT_TAKEN', 'La cancha ya está reservada en ese horario.');
-    }
-
-    const priceCents = quotePriceCents({
-      sport: court.sport,
-      date: request.date,
-      startHour: request.startHour,
-      hours: request.hours,
-      plan: member.plan,
-    });
-    const id = deps.service.saveBooking({
-      court_id: court.id,
-      member_id: member.id,
-      date: request.date,
-      start_hour: request.startHour,
-      hours: request.hours,
-      team_name: request.teamName,
-      price_cents: priceCents,
-      created_at: deps.clock.now().toISOString(),
+    const id = deps.service.atomically(() => {
+      const sameDay = deps.service.bookingsForCourt(court.id, request.date).filter((b) => b.status === 'confirmed');
+      if (sameDay.some((b) => overlaps({ startHour: b.start_hour, hours: b.hours }, request))) {
+        throw new HttpError(409, 'SLOT_TAKEN', 'La cancha ya está reservada en ese horario.');
+      }
+
+      const priceCents = quotePriceCents({
+        sport: court.sport,
+        date: request.date,
+        startHour: request.startHour,
+        hours: request.hours,
+        plan: member.plan,
+      });
+      return deps.service.saveBooking({
+        court_id: court.id,
+        member_id: member.id,
+        date: request.date,
+        start_hour: request.startHour,
+        hours: request.hours,
+        team_name: request.teamName,
+        price_cents: priceCents,
+        created_at: deps.clock.now().toISOString(),
+      });
     });
     const booking = toBookingJson(deps.service.findBooking(id)!);
 
diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts
index 55e62cf..1ee0dee 100644
--- a/src/services/booking-service.ts
+++ b/src/services/booking-service.ts
@@ -10,8 +10,10 @@ export class BookingService {
   #courts: CourtRepository;
   #members: MemberRepository;
   #bookings: BookingRepository;
+  #db: DatabaseSync;
 
   constructor(db: DatabaseSync) {
+    this.#db = db;
     this.#courts = new CourtRepository(db);
     this.#members = new MemberRepository(db);
     this.#bookings = new BookingRepository(db);
@@ -56,4 +58,18 @@ export class BookingService {
   markCancelled(id: number, cancelledAt: string): void {
     this.#bookings.updateStatus(id, 'cancelled', cancelledAt);
   }
+
+  // Corre `work` con el lock de escritura tomado desde el principio: entre
+  // la revisión y el guardado ninguna otra conexión puede escribir.
+  atomically<T>(work: () => T): T {
+    this.#db.exec('BEGIN IMMEDIATE');
+    try {
+      const result = work();
+      this.#db.exec('COMMIT');
+      return result;
+    } catch (error) {
+      this.#db.exec('ROLLBACK');
+      throw error;
+    }
+  }
 }
diff --git a/src/store/database.ts b/src/store/database.ts
index c477503..efc5ba7 100644
--- a/src/store/database.ts
+++ b/src/store/database.ts
@@ -38,7 +38,8 @@ const MIGRATIONS: string[] = [
 ];
 
 export function openDatabase(path: string): DatabaseSync {
-  const db = new DatabaseSync(path);
+  // Si otra conexión tiene el lock, esperar hasta 5 s en vez de fallar enseguida.
+  const db = new DatabaseSync(path, { timeout: 5000 });
   // SQLite no revisa las claves foráneas si no se le pide en cada conexión.
   db.exec('PRAGMA foreign_keys = ON');
   migrate(db);
adr-ejemplo/0002-aviso-por-whatsapp.mdDescargar adr-ejemplo/0002-aviso-por-whatsapp.md
# ADR-0002: Avisar por WhatsApp con una tabla de avisos pendientes

## Estado

Propuesta (ejemplo del apunte de la semana 7).

## Contexto

Hoy Kallpa "avisa" escribiendo en la consola: `consoleNotifier` implementa el
puerto `Notifier`. El complejo va a contratar WhatsApp Business y el aviso tiene
que salir de verdad, por una llamada HTTP a un proveedor externo que puede
tardar o caerse.

Las fuerzas en juego:

- El aviso es un complemento: el vecino necesita la reserva, y la ve en la página
  aunque el mensaje no llegue. Un aviso caído no debe impedir reservar.
- El servidor procesa cada reserva sin ceder el control entre revisar que la hora
  esté libre y guardarla. Esa propiedad evita los cruces en un solo proceso, y un
  `await` a un proveedor externo en ese tramo la rompería.
- El equipo son dos personas a tiempo parcial y el complejo no paga más servidores.
  Kallpa recibe unas decenas de reservas al día.

Escenario de calidad: *con el proveedor de WhatsApp caído durante 10 minutos, en
operación normal, el 99 % de las reservas responde en menos de 500 ms y, cuando
el proveedor vuelve, cada aviso sale en menos de 2 minutos.*

## Decisión

Vamos a guardar cada aviso en una tabla `pending_notifications`, en la misma
transacción que la reserva. Un temporizador dentro del mismo proceso del servidor
la revisa cada 30 segundos, llama al proveedor y marca lo que salió.

## Alternativas consideradas

### Llamar al proveedor dentro de la petición

**A favor.** Es lo más simple: un adaptador nuevo del puerto `Notifier` y nada más.
El vecino sabe enseguida si el aviso salió.

**En contra.** Si el proveedor tarda, tarda la reserva; si se cae, hay que decidir
entre fallar la reserva o perder el aviso. No cumple el escenario. Además, el
puerto `Notifier` es síncrono: hacerlo asíncrono invita a poner un `await` en el
handler.

### Tabla de avisos pendientes y temporizador en el mismo proceso (elegida)

**A favor.** Cumple el escenario. La reserva y su aviso se guardan juntos: si el
proceso se cae, el aviso sigue pendiente. No agrega piezas para desplegar ni
operar. Se prueba con un reloj fijo y un doble del proveedor.

**En contra.** El aviso sale hasta 30 segundos después. Si el proceso se cae entre
llamar al proveedor y marcar el aviso, se repite al volver: un vecino puede recibir
dos veces el mismo mensaje. Hay que escribir y mantener el temporizador.

### Una cola de mensajes externa y un proceso aparte que envía

**A favor.** Es la forma habitual cuando hay mucho volumen o varios consumidores.
El envío escala y se despliega por separado del servidor.

**En contra.** Son dos piezas más que desplegar, vigilar y pagar, para unas decenas
de mensajes al día. La reserva y el mensaje ya no se guardan en una sola
transacción, así que aparece el caso de "se guardó la reserva, pero no el mensaje".
Nadie del equipo ha operado una cola.

## Consecuencias

- Lo que se gana: reservar deja de depender del proveedor, y cambiar de proveedor
  toca un solo adaptador.
- Lo que se pierde: el aviso ya no es inmediato, y puede llegar repetido. Lo
  aceptamos porque un mensaje repetido molesta menos que uno perdido. Hay que
  decírselo al complejo.
- Lo que ahora hay que hacer: una migración para la tabla, el temporizador y un
  límite de reintentos, para que un aviso imposible no se reintente para siempre.
- Deuda que queda, a propósito: el temporizador supone una sola instancia. Si dos
  instancias revisan la misma tabla, los dos pueden mandar el mismo aviso. Si el
  complejo decide correr dos instancias (ver ADR-0001), hay que revisar esta
  decisión.
- Cuándo revisarla: si el volumen pasa de unos cientos de avisos al día, o si otro
  sistema también necesita enterarse de las reservas.
hexagon/memory-reservations.tsDescargar hexagon/memory-reservations.ts
// Adaptador en memoria del puerto Reservations, para pruebas.
import { overlaps } from '../../../kallpa/src/domain/booking.ts';
import type { NewBooking, Reservations, SavedBooking } from './ports.ts';

export function memoryReservations(): Reservations {
  const saved: SavedBooking[] = [];
  return {
    bookIfFree(booking: NewBooking) {
      const taken = saved.some(
        (other) => other.courtId === booking.courtId && other.date === booking.date && overlaps(other, booking),
      );
      if (taken) return null;
      const stored = { ...booking, id: saved.length + 1 };
      saved.push(stored);
      return stored;
    },
  };
}
hexagon/ports.tsDescargar hexagon/ports.ts
// Los puertos: lo que el núcleo necesita del mundo exterior, dicho en palabras
// del negocio. Aquí no aparecen SQL, HTTP ni WhatsApp.
import type { BookingRequest } from '../../../kallpa/src/domain/booking.ts';

export type NewBooking = BookingRequest & { priceCents: number };
export type SavedBooking = NewBooking & { id: number };

export type Reservations = {
  // Promete las dos cosas juntas: revisar que la hora esté libre y guardar. Quien
  // implementa el puerto decide cómo; si devuelve null, la cancha estaba ocupada.
  bookIfFree(booking: NewBooking): SavedBooking | null;
};

export type BookingNotifier = {
  bookingConfirmed(booking: SavedBooking): void;
};
hexagon/reserve.tsDescargar hexagon/reserve.ts
// El núcleo: un caso de uso que solo conoce el dominio y sus puertos.
import { checkBookingRules, type BookingRequest } from '../../../kallpa/src/domain/booking.ts';
import { limaTime, type Clock } from '../../../kallpa/src/domain/clock.ts';
import { quotePriceCents } from '../../../kallpa/src/domain/pricing.ts';
import type { BookingNotifier, Reservations, SavedBooking } from './ports.ts';

export type ReserveDeps = {
  reservations: Reservations;
  notifier: BookingNotifier;
  clock: Clock;
};

export type ReserveResult =
  | { ok: true; booking: SavedBooking }
  | { ok: false; code: string };

export function reserve(deps: ReserveDeps, request: BookingRequest): ReserveResult {
  const violation = checkBookingRules(request, limaTime(deps.clock.now()));
  if (violation) return { ok: false, code: violation.code };

  // Para no alargar el ejemplo, el precio usa una cancha de fulbito y un vecino regular.
  const priceCents = quotePriceCents({
    sport: 'fulbito',
    date: request.date,
    startHour: request.startHour,
    hours: request.hours,
    plan: 'regular',
  });
  const booking = deps.reservations.bookIfFree({ ...request, priceCents });
  if (!booking) return { ok: false, code: 'SLOT_TAKEN' };

  deps.notifier.bookingConfirmed(booking);
  return { ok: true, booking };
}
hexagon/sqlite-reservations.tsDescargar hexagon/sqlite-reservations.ts
// Adaptador de SQLite del puerto Reservations. La transacción queda escondida
// aquí: el núcleo solo ve bookIfFree.
import type { DatabaseSync } from 'node:sqlite';
import type { NewBooking, Reservations } from './ports.ts';

export function createSchema(db: DatabaseSync): void {
  db.exec(`CREATE TABLE IF NOT EXISTS reservations (
    id INTEGER PRIMARY KEY, court_id INTEGER NOT NULL, member_id INTEGER NOT NULL, date TEXT NOT NULL,
    start_hour INTEGER NOT NULL, hours INTEGER NOT NULL, team_name TEXT NOT NULL, price_cents INTEGER NOT NULL
  )`);
}

export function sqliteReservations(db: DatabaseSync): Reservations {
  return {
    bookIfFree(booking: NewBooking) {
      db.exec('BEGIN IMMEDIATE');
      try {
        // Dos intervalos [a, a+h) y [b, b+k) se cruzan si cada uno empieza antes
        // de que termine el otro.
        const taken = db
          .prepare(
            `SELECT 1 FROM reservations
             WHERE court_id = ? AND date = ? AND start_hour < ? AND ? < start_hour + hours`,
          )
          .get(booking.courtId, booking.date, booking.startHour + booking.hours, booking.startHour);
        if (taken) {
          db.exec('ROLLBACK');
          return null;
        }
        const { lastInsertRowid } = db
          .prepare(
            `INSERT INTO reservations (court_id, member_id, date, start_hour, hours, team_name, price_cents)
             VALUES (?, ?, ?, ?, ?, ?, ?)`,
          )
          .run(
            booking.courtId,
            booking.memberId,
            booking.date,
            booking.startHour,
            booking.hours,
            booking.teamName,
            booking.priceCents,
          );
        db.exec('COMMIT');
        return { ...booking, id: Number(lastInsertRowid) };
      } catch (error) {
        db.exec('ROLLBACK');
        throw error;
      }
    },
  };
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

adr.test.tsDescargar adr.test.ts
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { missingHeadings, normalize, section } from '../../projects/check/lib/markdown.ts';
import { ADR_HEADINGS, ADR_STATES } from '../../projects/check/rules/spec.ts';

const text = readFileSync(new URL('./adr-ejemplo/0002-aviso-por-whatsapp.md', import.meta.url), 'utf8');

test('el ADR de ejemplo cumple lo que P2 pide de un ADR', () => {
  assert.match(text, /^# ADR-0002: /);
  assert.deepEqual(missingHeadings(text, ADR_HEADINGS), []);
  const state = normalize(section(text, 'Estado') ?? '');
  assert.ok(ADR_STATES.some((s) => state.includes(s)));
});

test('tiene al menos tres alternativas, con pros y contras', () => {
  const alternatives = section(text, 'Alternativas consideradas') ?? '';
  assert.ok((alternatives.match(/^### /gm) ?? []).length >= 3);
  assert.ok((alternatives.match(/\*\*A favor\.\*\*/g) ?? []).length >= 3);
  assert.ok((alternatives.match(/\*\*En contra\.\*\*/g) ?? []).length >= 3);
});
capas.test.tsDescargar capas.test.ts
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { appendFileSync, copyFileSync, cpSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, before, describe, test } from 'node:test';

const KALLPA = new URL('../../kallpa', import.meta.url).pathname;
const RULES = new URL('./capas-eslint.js', import.meta.url).pathname;
const CROSSING = new URL('./cruce-de-capas.diff', import.meta.url).pathname;

function lint(dir: string): { status: number | null; stdout: string } {
  const { status, stdout } = spawnSync('eslint', ['-c', 'capas-eslint.js', 'src'], { cwd: dir, encoding: 'utf8' });
  return { status, stdout };
}

describe('la regla de capas en ESLint', () => {
  let copy: string;
  before(() => {
    copy = mkdtempSync(join(tmpdir(), 'kallpa-capas-'));
    cpSync(KALLPA, copy, { recursive: true });
    copyFileSync(RULES, join(copy, 'capas-eslint.js'));
  });
  after(() => rmSync(copy, { recursive: true, force: true }));

  test('la Kallpa publicada tiene una sola violación: un import de tipo en handlers.ts', () => {
    const { status, stdout } = lint(copy);
    assert.equal(status, 1);
    assert.match(stdout, /handlers\.ts\n\s+18:1\s+error\s+'\.\.\/store\/booking-repository\.ts' import is restricted/);
    assert.match(stdout, /1 problem \(1 error, 0 warnings\)/);
  });

  test('detecta que el dominio importe la base de datos', () => {
    appendFileSync(join(copy, 'src/domain/member.ts'), "\nimport { DatabaseSync } from 'node:sqlite';\nexport const Db = DatabaseSync;\n");
    const { stdout } = lint(copy);
    assert.match(stdout, /member\.ts\n\s+\d+:1\s+error\s+'node:sqlite' import is restricted/);
  });
});

describe('un cambio que cruza capas', () => {
  test('tsc, ESLint sin la regla y las pruebas de Kallpa pasan; la regla lo detecta', () => {
    const copy = mkdtempSync(join(tmpdir(), 'kallpa-cruce-'));
    try {
      cpSync(KALLPA, copy, { recursive: true });
      copyFileSync(RULES, join(copy, 'capas-eslint.js'));
      const applied = spawnSync('git', ['apply', CROSSING], { cwd: copy, encoding: 'utf8' });
      assert.equal(applied.status, 0, applied.stderr);

      const env = { ...process.env };
      delete env.NODE_TEST_CONTEXT;
      const check = spawnSync('make', ['check'], { cwd: copy, env, encoding: 'utf8' });
      assert.equal(check.status, 0, check.stdout + check.stderr);

      const { status, stdout } = lint(copy);
      assert.equal(status, 1);
      assert.match(stdout, /19:1\s+error\s+'\.\.\/store\/booking-repository\.ts' import is restricted/);
      // handlers.ts importa ahora node:sqlite, y la regla del laboratorio no lo prohíbe.
      assert.doesNotMatch(stdout, /'node:sqlite' import is restricted/);
    } finally {
      rmSync(copy, { recursive: true, force: true });
    }
  });

  test('la regla mira las declaraciones import, no un import() dinámico', () => {
    const copy = mkdtempSync(join(tmpdir(), 'kallpa-dinamico-'));
    try {
      cpSync(KALLPA, copy, { recursive: true });
      copyFileSync(RULES, join(copy, 'capas-eslint.js'));
      appendFileSync(join(copy, 'src/domain/member.ts'), "\nexport const load = () => import('../store/database.ts');\n");
      const { status, stdout } = lint(copy);
      assert.doesNotMatch(stdout, /member\.ts/);
      assert.equal(status, 1); // sigue la violación de handlers.ts, nada más
    } finally {
      rmSync(copy, { recursive: true, force: true });
    }
  });
});
doble-reserva.test.tsDescargar doble-reserva.test.ts
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, test } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { overlaps } from '../../kallpa/src/domain/booking.ts';
import { BookingService } from '../../kallpa/src/services/booking-service.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../kallpa/src/store/seed.ts';
import type { NewBookingRow } from '../../kallpa/src/store/booking-repository.ts';

const dir = mkdtempSync(join(tmpdir(), 'doble-'));
after(() => rmSync(dir, { recursive: true, force: true }));

let counter = 0;
// Dos conexiones a un mismo archivo: lo que tienen dos instancias del servidor.
function twoConnections(): { a: DatabaseSync; b: DatabaseSync } {
  const path = join(dir, `kallpa-${++counter}.db`);
  const a = openDatabase(path);
  seedCatalog(a);
  return { a, b: openDatabase(path) };
}

const row: NewBookingRow = {
  court_id: 3,
  member_id: 2,
  date: '2026-03-11',
  start_hour: 9,
  hours: 1,
  team_name: 'Equipo',
  price_cents: 4500,
  created_at: '2026-03-09T15:00:00Z',
};
const request = { startHour: 9, hours: 1 };

function isFree(service: BookingService): boolean {
  return !service
    .bookingsForCourt(row.court_id, row.date)
    .filter((b) => b.status === 'confirmed')
    .some((b) => overlaps({ startHour: b.start_hour, hours: b.hours }, request));
}

function confirmedCount(db: DatabaseSync): number {
  const { n } = db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE status = 'confirmed'").get() as { n: number };
  return n;
}

describe('revisar y después guardar, con dos conexiones', () => {
  test('el entrelazado que deja una cancha reservada dos veces', () => {
    const { a, b } = twoConnections();
    const instanceA = new BookingService(a);
    const instanceB = new BookingService(b);

    // Los dos revisan antes de que cualquiera guarde: los dos ven la hora libre.
    assert.equal(isFree(instanceA), true);
    assert.equal(isFree(instanceB), true);
    instanceA.saveBooking({ ...row, team_name: 'Equipo A' });
    instanceB.saveBooking({ ...row, team_name: 'Equipo B' });

    assert.equal(confirmedCount(a), 2);
  });

  test('si el otro ya terminó de guardar, la revisión sí lo ve', () => {
    const { a, b } = twoConnections();
    const instanceA = new BookingService(a);
    const instanceB = new BookingService(b);
    assert.equal(isFree(instanceA), true);
    instanceA.saveBooking(row);
    assert.equal(isFree(instanceB), false);
  });
});

describe('los locks de SQLite', () => {
  test('sin espera configurada, una escritura choca enseguida con otra', () => {
    const { a, b } = twoConnections();
    assert.deepEqual({ ...(b.prepare('PRAGMA busy_timeout').get() as object) }, { timeout: 0 });
    a.exec('BEGIN IMMEDIATE');
    assert.throws(() => new BookingService(b).saveBooking(row), /database is locked/);
    a.exec('ROLLBACK');
  });

  test('una lectura pasa mientras otro prepara su escritura, pero no durante el lock exclusivo', () => {
    const { a, b } = twoConnections();
    const reader = new BookingService(b);
    a.exec('BEGIN IMMEDIATE');
    assert.equal(reader.findCourt(3)?.id, 3);
    a.exec('ROLLBACK');
    a.exec('BEGIN EXCLUSIVE');
    assert.throws(() => reader.findCourt(3), /database is locked/);
    a.exec('ROLLBACK');
  });

  test('BEGIN IMMEDIATE: mientras uno revisa y guarda, el otro ni siquiera puede empezar', () => {
    const { a, b } = twoConnections();
    a.exec('BEGIN IMMEDIATE');
    assert.throws(() => b.exec('BEGIN IMMEDIATE'), /database is locked/);
    assert.equal(isFree(new BookingService(a)), true);
    new BookingService(a).saveBooking(row);
    a.exec('COMMIT');

    b.exec('BEGIN IMMEDIATE');
    assert.equal(isFree(new BookingService(b)), false);
    b.exec('ROLLBACK');
    assert.equal(confirmedCount(a), 1);
  });

  test('BEGIN a secas no toma el lock al empezar: el segundo que escribe falla', () => {
    const { a, b } = twoConnections();
    a.exec('BEGIN');
    b.exec('BEGIN');
    assert.equal(isFree(new BookingService(a)), true);
    assert.equal(isFree(new BookingService(b)), true);
    new BookingService(a).saveBooking(row);
    // Tiene una lectura abierta y otro ya está escribiendo: no puede pasar a escribir.
    assert.throws(() => new BookingService(b).saveBooking(row), /database is locked/);
    b.exec('ROLLBACK');
    a.exec('COMMIT');
    assert.equal(confirmedCount(a), 1);
  });

  test('con WAL, quien lee no se bloquea, pero sigue habiendo un solo escritor', () => {
    const { a, b } = twoConnections();
    a.exec('PRAGMA journal_mode = WAL');
    a.exec('BEGIN IMMEDIATE');
    new BookingService(a).saveBooking(row);
    assert.equal(new BookingService(b).findCourt(3)?.id, 3);
    assert.throws(() => new BookingService(b).saveBooking(row), /database is locked/);
    a.exec('COMMIT');
  });
});

describe('una restricción en la base de datos', () => {
  function withUniqueIndex(): DatabaseSync {
    const db = openDatabase(':memory:');
    seedCatalog(db);
    db.exec(`CREATE UNIQUE INDEX one_booking_per_start ON bookings (court_id, date, start_hour)
             WHERE status = 'confirmed'`);
    return db;
  }

  test('un índice único parcial detiene dos reservas con la misma hora de inicio', () => {
    const service = new BookingService(withUniqueIndex());
    service.saveBooking(row);
    assert.throws(() => service.saveBooking(row), /UNIQUE constraint failed/);
  });

  test('pero no detiene un cruce con otra hora de inicio', () => {
    const db = withUniqueIndex();
    const service = new BookingService(db);
    service.saveBooking({ ...row, start_hour: 9, hours: 2 });
    service.saveBooking({ ...row, start_hour: 10, hours: 1 });
    assert.equal(confirmedCount(db), 2);
  });

  test('una fila por cada hora ocupada sí expresa la regla', () => {
    const db = openDatabase(':memory:');
    seedCatalog(db);
    db.exec(`CREATE TABLE booking_hours (
      court_id INTEGER NOT NULL, date TEXT NOT NULL, hour INTEGER NOT NULL,
      PRIMARY KEY (court_id, date, hour))`);
    const occupy = db.prepare('INSERT INTO booking_hours (court_id, date, hour) VALUES (?, ?, ?)');
    occupy.run(3, row.date, 9);
    occupy.run(3, row.date, 10);
    assert.throws(() => occupy.run(3, row.date, 10), /UNIQUE constraint failed/);
  });
});
dos-instancias.test.tsDescargar dos-instancias.test.ts
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';

// El script del laboratorio. No se revisa cuántos 409 y 500 salen, porque cambia
// de una corrida a otra: solo que sume 20 y que haya al menos una reserva.
test('dos-instancias.sh manda 20 reservas y reporta lo que quedó', (t) => {
  const copy = mkdtempSync(join(tmpdir(), 'kallpa-dos-'));
  try {
    cpSync(new URL('../../kallpa', import.meta.url).pathname, copy, { recursive: true });
    const script = new URL('./dos-instancias.sh', import.meta.url).pathname;
    const run = spawnSync('bash', [script], { cwd: copy, encoding: 'utf8' });
    if (run.status === 2) {
      t.skip(`no se pudo correr aquí: ${run.stderr.trim()}`);
      return;
    }
    const counts = [...run.stdout.matchAll(/^\s+(\d+) (\d{3})$/gm)].map((m) => ({ n: Number(m[1]), code: m[2] }));
    assert.equal(counts.reduce((sum, c) => sum + c.n, 0), 20, run.stdout);
    assert.ok(counts.some((c) => c.code === '200'), run.stdout);
    assert.match(run.stdout, /\d+\|Prueba \d+/);
  } finally {
    rmSync(copy, { recursive: true, force: true });
  }
});

test('dos-instancias.sh se niega a correr fuera de la carpeta de Kallpa', () => {
  const run = spawnSync('bash', [new URL('./dos-instancias.sh', import.meta.url).pathname], { cwd: tmpdir(), encoding: 'utf8' });
  assert.equal(run.status, 2);
  assert.match(run.stderr, /carpeta de Kallpa/);
});
hexagon.test.tsDescargar hexagon.test.ts
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import type { BookingRequest } from '../../kallpa/src/domain/booking.ts';
import { fixedClock } from '../../kallpa/src/domain/clock.ts';
import { memoryReservations } from './hexagon/memory-reservations.ts';
import type { BookingNotifier, Reservations, SavedBooking } from './hexagon/ports.ts';
import { reserve } from './hexagon/reserve.ts';
import { createSchema, sqliteReservations } from './hexagon/sqlite-reservations.ts';

const MONDAY_10AM = fixedClock('2026-03-09T15:00:00Z');
const request: BookingRequest = {
  courtId: 1,
  memberId: 1,
  date: '2026-03-11',
  startHour: 9,
  hours: 2,
  teamName: 'Real Barranco',
};

function recordingNotifier(): BookingNotifier & { sent: SavedBooking[] } {
  const sent: SavedBooking[] = [];
  return { sent, bookingConfirmed: (booking) => sent.push(booking) };
}

function inSqlite(): Reservations {
  const db = new DatabaseSync(':memory:');
  createSchema(db);
  return sqliteReservations(db);
}

// La misma suite para los dos adaptadores: el puerto es un contrato, y lo que
// promete tiene que valer para cualquiera que se conecte a él.
for (const [name, make] of [
  ['memoria', memoryReservations],
  ['SQLite', inSqlite],
] as const) {
  describe(`puerto Reservations: adaptador de ${name}`, () => {
    test('una hora libre se reserva y se avisa', () => {
      const notifier = recordingNotifier();
      const result = reserve({ reservations: make(), notifier, clock: MONDAY_10AM }, request);
      assert.equal(result.ok, true);
      assert.equal(notifier.sent.length, 1);
    });

    test('un cruce con otra hora de inicio también se rechaza', () => {
      const notifier = recordingNotifier();
      const deps = { reservations: make(), notifier, clock: MONDAY_10AM };
      reserve(deps, request);
      const second = reserve(deps, { ...request, startHour: 10, hours: 1 });
      assert.deepEqual(second, { ok: false, code: 'SLOT_TAKEN' });
      assert.equal(notifier.sent.length, 1);
    });

    test('otra cancha a la misma hora está libre', () => {
      const deps = { reservations: make(), notifier: recordingNotifier(), clock: MONDAY_10AM };
      reserve(deps, request);
      assert.equal(reserve(deps, { ...request, courtId: 2 }).ok, true);
    });

    test('las reglas del dominio no dependen del adaptador', () => {
      const deps = { reservations: make(), notifier: recordingNotifier(), clock: MONDAY_10AM };
      assert.deepEqual(reserve(deps, { ...request, date: '2026-03-08' }), { ok: false, code: 'IN_THE_PAST' });
    });
  });
}

test('el núcleo no importa adaptadores ni tecnología', () => {
  for (const file of ['reserve.ts', 'ports.ts']) {
    const source = readFileSync(new URL(`./hexagon/${file}`, import.meta.url), 'utf8');
    const imports = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]);
    assert.ok(imports.length > 0);
    for (const name of imports) {
      assert.doesNotMatch(name, /sqlite|node:http|memory|store|\/http\//, `${file} importa ${name}`);
    }
  }
});
medir.test.tsDescargar medir.test.ts
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { measure, percentile } from './medir-escenario.ts';

const KALLPA = new URL('../../kallpa', import.meta.url).pathname;

function patchedCopy(diff: string): string {
  const copy = mkdtempSync(join(tmpdir(), 'kallpa-medir-'));
  cpSync(KALLPA, copy, { recursive: true });
  const applied = spawnSync('git', ['apply', new URL(diff, import.meta.url).pathname], { cwd: copy, encoding: 'utf8' });
  assert.equal(applied.status, 0, applied.stderr);
  return copy;
}

test('percentil por rango más cercano: con 20 muestras, el p99 es el máximo', () => {
  const values = Array.from({ length: 20 }, (_, i) => i + 1);
  assert.equal(percentile(values, 99), 20);
  assert.equal(percentile(values, 50), 10);
  assert.equal(percentile(Array.from({ length: 1000 }, (_, i) => i + 1), 99), 990);
});

test('una instancia: una reserva por ronda y ningún 500', async () => {
  const report = await measure(KALLPA, 1, 20, 20, 3110);
  assert.deepEqual(report.byStatus, { 200: 20, 409: 380 });
  assert.equal(report.doubleBooked, 0);
});

test('dos instancias con la transacción: una reserva por ronda y ningún 500', async () => {
  const copy = patchedCopy('./transaccion.diff');
  try {
    const report = await measure(copy, 2, 50, 20, 3120);
    assert.deepEqual(report.byStatus, { 200: 50, 409: 950 });
    assert.equal(report.doubleBooked, 0);
  } finally {
    rmSync(copy, { recursive: true, force: true });
  }
});

// Ninguna de estas variantes la detectan las pruebas de Kallpa: ninguna manda
// peticiones simultáneas desde dos procesos.
for (const diff of ['./transaccion.diff', './solo-espera.diff', './await-intermedio.diff']) {
  test(`el make check de Kallpa pasa con ${diff}`, () => {
    const copy = patchedCopy(diff);
    try {
      const env = { ...process.env };
      delete env.NODE_TEST_CONTEXT;
      const run = spawnSync('make', ['check'], { cwd: copy, env, encoding: 'utf8' });
      assert.equal(run.status, 0, run.stdout + run.stderr);
    } finally {
      rmSync(copy, { recursive: true, force: true });
    }
  });
}
un-proceso.test.tsDescargar un-proceso.test.ts
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';

const KALLPA = new URL('../../kallpa', import.meta.url).pathname;
const DIFF = new URL('./await-intermedio.diff', import.meta.url).pathname;

type Helpers = typeof import('../../kallpa/test/helpers.ts');

async function postTwenty(helpers: Helpers): Promise<{ statuses: number[]; confirmed: number }> {
  const server = await helpers.startTestServer();
  try {
    const responses = await Promise.all(
      Array.from({ length: 20 }, (_, i) =>
        helpers.postJson(`${server.url}/api/bookings`, {
          courtId: 3,
          memberId: 2,
          date: '2026-03-11',
          startHour: 9,
          teamName: `Prueba ${i}`,
        }),
      ),
    );
    const { n } = server.db.prepare("SELECT COUNT(*) AS n FROM bookings WHERE status = 'confirmed'").get() as { n: number };
    return { statuses: responses.map((r) => r.status).sort(), confirmed: n };
  } finally {
    await server.close();
  }
}

test('en un solo proceso, Kallpa no reserva dos veces: el tramo entre revisar y guardar no cede el control', async () => {
  const helpers = (await import(join(KALLPA, 'test/helpers.ts'))) as Helpers;
  const { statuses, confirmed } = await postTwenty(helpers);
  assert.equal(confirmed, 1);
  assert.deepEqual(statuses, [200, ...Array<number>(19).fill(409)]);
});

test('un await entre revisar y guardar basta para reservar dos veces en un solo proceso', async () => {
  const copy = mkdtempSync(join(tmpdir(), 'kallpa-await-'));
  try {
    cpSync(KALLPA, copy, { recursive: true });
    const applied = spawnSync('git', ['apply', DIFF], { cwd: copy, encoding: 'utf8' });
    assert.equal(applied.status, 0, applied.stderr);
    const helpers = (await import(join(copy, 'test/helpers.ts'))) as Helpers;
    const { statuses, confirmed } = await postTwenty(helpers);
    assert.equal(confirmed, 20);
    assert.deepEqual(statuses, Array<number>(20).fill(200));
  } finally {
    rmSync(copy, { recursive: true, force: true });
  }
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 7: arquitectura y decisiones de arquitectura. Se usa dentro del
# contenedor del curso.
#
#   make                copia Kallpa en build/kallpa, build/solo-espera y
#                       build/transaccion (con su .diff aplicado)
#   make test           revisa los tipos (tsc), corre ESLint y las pruebas
#                       (*.test.ts), que comprueban lo que dice el apunte
#   make dependencias   el paso 1 del laboratorio: quién importa a quién
#   make capas          Kallpa revisada con las reglas de capas-eslint.js
#   make cruce          un cambio que cruza capas: pasa make check, no la regla
#   make dos-instancias el paso 4 del laboratorio, sobre build/kallpa
#   make medir          el escenario de calidad con cada variante
#   make reinicio       cuánto tarda Kallpa en volver después de una caída
#   make clean          borra build/
#
# Nada de esto modifica la Kallpa del curso (../../kallpa).

KALLPA ?= ../../kallpa
GIT = git

.PHONY: all test dependencias capas cruce dos-instancias medir reinicio clean

all: build/kallpa build/solo-espera build/transaccion

build/kallpa:
	mkdir -p build
	cp -r $(KALLPA) $@
	rm -f $@/data/*.db $@/data/*.db-*

build/solo-espera: solo-espera.diff
build/transaccion: transaccion.diff
build/solo-espera build/transaccion:
	rm -rf $@
	mkdir -p build
	cp -r $(KALLPA) $@
	rm -f $@/data/*.db $@/data/*.db-*
	cd $@ && $(GIT) apply ../../$(notdir $@).diff

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"

dependencias:
	@for capa in http services store domain notifications; do \
		echo "== src/$$capa"; \
		grep -rhoE "from '\.\./[a-z]+/" $(KALLPA)/src/$$capa | sort | uniq -c; \
	done

# ESLint termina con error cuando encuentra algo: aquí eso es lo que se quiere ver.
capas: build/kallpa
	cp capas-eslint.js build/kallpa/capas-eslint.js
	cd build/kallpa && eslint -c capas-eslint.js . ; echo "eslint terminó con código $$?"

# El cambio de ejemplo que cruza capas: el make check de Kallpa pasa, la regla no.
cruce:
	rm -rf build/cruce
	mkdir -p build
	cp -r $(KALLPA) build/cruce
	rm -f build/cruce/data/*.db build/cruce/data/*.db-*
	cp capas-eslint.js build/cruce/capas-eslint.js
	cd build/cruce && $(GIT) apply ../../cruce-de-capas.diff
	$(MAKE) --no-print-directory -C build/cruce check 2>&1 | tail -8
	cd build/cruce && eslint -c capas-eslint.js . ; echo "eslint terminó con código $$?"

dos-instancias: build/kallpa
	cd build/kallpa && bash ../../dos-instancias.sh

medir: all
	@echo "Una instancia:"
	@node medir-escenario.ts build/kallpa 1
	@echo "Dos instancias, sobre la Kallpa publicada:"
	@node medir-escenario.ts build/kallpa 2
	@echo "Dos instancias, con solo-espera.diff:"
	@node medir-escenario.ts build/solo-espera 2
	@echo "Dos instancias, con transaccion.diff:"
	@node medir-escenario.ts build/transaccion 2

reinicio: build/kallpa
	@node medir-reinicio.ts build/kallpa

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-07",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": [
      "es2024"
    ],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": [
      "node"
    ],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": [
    "*.ts",
    "hexagon"
  ],
  "exclude": [
    "build"
  ]
}