Semana 16 — Mantenimiento, deuda técnica y deprecación
Objetivos
- Distinguir los tipos de mantenimiento y explicar por qué la mayor parte del costo de un sistema llega después de su primera versión.
- Cambiar código heredado con seguridad: puntos de cambio, costuras (seams) y pruebas de caracterización.
- Clasificar la deuda técnica con el cuadrante de Fowler y hacerla visible para que se pueda decidir sobre ella.
- Planificar una migración gradual (strangler fig, parallel change) y una deprecación con dos versiones conviviendo.
- Presentar y defender un producto: explicar cualquier parte del código, de las pruebas y de las decisiones, y del uso de IA que se hizo.
Sesión A — Mantenimiento: código heredado, deuda técnica y deprecación
Lecturas:
- SWEG, cap. 15, Deprecation.
- SWEG, cap. 22, Large-Scale Changes.
Lecturas complementarias:
- Martin Fowler, Technical Debt Quadrant, StranglerFigApplication y Danilo Sato, Parallel Change.
- IETF, RFC 9745, The Deprecation HTTP Response Header Field (2025) y RFC 8594, The Sunset HTTP Header Field.
- Hyrum’s Law.
Temas:
- Mantenimiento es todo lo que se hace después de la primera entrega: corregir errores (correctivo), adaptarse a un entorno que cambia, como una versión nueva de Node o una ley (adaptativo), agregar o mejorar funcionalidades (perfectivo) y reducir el riesgo de problemas futuros (preventivo). Es la mayor parte de la vida y del costo de un sistema; la semana 1 lo llamó sostenibilidad.
- Código heredado, en la definición de Michael Feathers, es código sin pruebas: no importa quién lo escribió ni cuándo, sino que no se puede cambiar sabiendo si se rompió algo. El procedimiento para cambiarlo: identificar los puntos de cambio, encontrar dónde se puede observar el comportamiento, romper las dependencias que impiden probar (una costura, como el
Clockinyectable de Kallpa), escribir pruebas y recién después cambiar. - Pruebas de caracterización: registran lo que el código hace hoy, no lo que debería hacer. Antes de cambiar la caja del día de Kallpa, se fijan sus salidas actuales para varios días de ejemplo; si una caracterización revela un comportamiento raro, se anota como posible bug y se decide aparte, sin mezclarlo con el refactoring.
- Deuda técnica (Ward Cunningham): un atajo que hace más rápido el cambio de hoy y más caros los siguientes; los intereses son ese costo extra. El cuadrante de Fowler la clasifica en deliberada o inadvertida, imprudente o prudente (“no tenemos tiempo para diseñar” frente a “entregamos ahora y sabemos lo que cuesta”). La deuda que no está escrita no se puede pagar: va en issues, con el costo que genera y una propuesta, y las decisiones conscientes, en ADRs.
- Migraciones graduales. Strangler fig: el sistema nuevo crece alrededor del viejo, detrás de la misma interfaz, y le va quitando funciones hasta que el viejo se puede apagar. Parallel change (expand, migrate, contract): se agrega la forma nueva sin quitar la vieja, se migran todos los usos y recién entonces se quita la vieja. Las migraciones de la base de datos siguen la misma regla: solo se agregan al final y cada una es compatible con la versión anterior del código, para que un rollback (semana 13) siga siendo posible.
- Deprecación (SWEG cap. 15): retirar un sistema o una API cuesta, y por la ley de Hyrum siempre hay alguien que depende de lo que se quiere quitar. Advisory (se avisa y se recomienda migrar) frente a compulsory (con fecha límite, y quien deprecia ayuda a migrar). En HTTP, una respuesta anuncia que está deprecada con la cabecera
Deprecation(RFC 9745) y la fecha en que dejará de funcionar conSunset(RFC 8594). Ejemplo en Kallpa: pasarGET /api/members/:id/bookingsde devolver una lista a devolver un objeto{ bookings }, con las dos formas conviviendo durante un tiempo y una forma de medir quién sigue usando la vieja. Cuando el cambio toca miles de archivos (SWEG cap. 22, large-scale changes), se hace con herramientas, se parte en pedazos que se pueden revisar e integrar por separado y se apoya en la integración continua para saber que ninguno rompió nada. - Error común: reescribir todo desde cero porque el código viejo “es un desastre”. La reescritura completa pierde el conocimiento que el código tenía guardado (cada caso raro que alguien ya resolvió), tarda más de lo previsto y, mientras tanto, hay que mantener los dos sistemas. Casi siempre conviene una migración gradual con pruebas de caracterización.
- Con IA: los agentes son buenos para cambios mecánicos a gran escala si hay pruebas que caractericen el comportamiento; sin ellas, producen diffs enormes que nadie puede verificar. Y el código que el equipo aceptó sin entender es código heredado desde el día en que se hizo commit: nadie sabe por qué está así ni qué se rompe si se cambia.
Sesión B — Presentaciones finales
Lecturas:
- El enunciado de P5 (
projects/p5-producto/README.md): qué se presenta, qué se entrega y qué se revisa. - SWEG, cap. 3, Knowledge Sharing (lo que necesita saber quien mantenga el producto).
Lecturas complementarias:
- SWEG, cap. 10, Documentation.
Temas:
- Cada equipo presenta su producto ante el curso y, si puede venir, ante su cliente. La presentación no es un recorrido por las pantallas: cuenta el problema del cliente, lo que se construyó para resolverlo y cómo se sabe que funciona.
- Qué se muestra: la demo del producto desplegado haciendo una tarea real del cliente; una decisión de diseño con las alternativas que se descartaron (un ADR); la evidencia de calidad (el pipeline, las pruebas que verifican la regla de negocio más difícil, el postmortem del incidente); y qué se le entrega al cliente para que alguien más lo pueda mantener.
- El uso de IA, con datos del registro: en qué ayudó, qué se rechazó y por qué, y un caso en que una herramienta produjo algo incorrecto que pareció correcto y cómo se descubrió.
- La retrospectiva del semestre: qué haría distinto el equipo en un proyecto nuevo, con un ejemplo concreto de cada sprint. Lo que no salió bien se cuenta igual, con lo que se aprendió.
- Preguntas del curso y del cliente al final de cada presentación. Cualquier integrante puede responder cualquier pregunta.
- Error común: una demo que depende de que nada falle. Se prepara con datos inventados conocidos, se prueba el día anterior en el mismo entorno y se tiene un plan B (un video corto de la demo) por si la red o el despliegue fallan en la sala.
Laboratorio — Defensa oral de los proyectos
Guía: el enunciado de P5 (projects/p5-producto/README.md), sección de la defensa oral. El docente publica antes el orden y el horario de los equipos.
La defensa comprueba lo que dice la política del curso: cada integrante responde por todo lo que el equipo entregó, lo haya escrito a mano o con una herramienta. No se prepara una presentación; se prepara el repositorio y se conoce el código.
- Antes de la defensa, cada integrante clona el repositorio desde cero en una carpeta nueva, corre
npm ci, el check del equipo (npm run check) y la revisión automática de P5 (make -f /work/projects/p5-producto/Makefile check, como enprojects/README.md) en el contenedor del curso, y levanta el producto. Si algo no funciona en un clon limpio, se arregla antes del día. - Cada integrante repasa las partes del código que no escribió: lee los pull requests de los demás, los ADRs, la estrategia de pruebas, el postmortem y el registro de uso de IA, y anota lo que no entiende para preguntarlo al equipo.
- En la defensa, el docente elige a una persona y una parte del repositorio (un archivo, un pull request, una prueba, un ADR, una entrada del registro de uso de IA) y le pide que la explique: qué hace, por qué está así, qué alternativa se descartó y qué se rompería si se cambia.
- Un cambio en vivo: el docente pide un cambio pequeño al producto (una regla nueva, un campo, un mensaje de error) y una persona elegida en el momento lo hace en una rama, con su prueba, y corre
npm run check, explicando en voz alta cada paso. Si usa una herramienta de IA, explica y verifica cada línea que acepta. - Preguntas sobre la operación: dónde está desplegado, cómo se vuelve a la versión anterior, qué muestra el pipeline cuando falla y qué dice el postmortem del incidente.
- Al terminar el laboratorio, cada integrante entrega la evaluación entre pares de su equipo.
Entregas
- P5 entrega: Producto final: revisión de seguridad (OWASP Top 10), producto entregado al cliente, documentación para quien lo mantenga, retrospectiva, registro de uso de IA y defensa oral (
projects/p5-producto/README.md). - El examen final se toma en el período de exámenes, después de esta semana (ver el calendario).
Preguntas de repaso
- Den un ejemplo de mantenimiento correctivo, adaptativo, perfectivo y preventivo en Kallpa o en su proyecto.
- ¿Qué es una prueba de caracterización y en qué se diferencia de una prueba unitaria escrita con TDD? Si una caracterización muestra un comportamiento que parece un bug, ¿qué hacen con ella?
- Ubiquen en el cuadrante de Fowler una deuda técnica de su proyecto. ¿Cuánto les cuesta hoy, qué propuesta escribirían en el issue y cuándo la pagarían?
- Planifiquen con parallel change el paso de
GET /api/members/:id/bookingsde devolver una lista a devolver{ bookings }: qué se hace en cada etapa, qué cabeceras se agregan, cómo saben cuándo pueden quitar la forma vieja y qué pasa si se apuran. - Un equipo quiere usar un agente para renombrar un campo en todo su repositorio, en 300 archivos. ¿Qué tendría que existir antes de empezar y cómo partirían el cambio para poder revisarlo?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (38)Pruebas (8)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// node --test marca a sus procesos hijos con NODE_TEST_CONTEXT, y un node --test
// hijo que ve esa marca cambia su salida. Lo que aquí se corre es un programa aparte.
export function cleanEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
return env;
}// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint, sin información de tipos.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'ejemplos/pagina-nueva.js'] },
js.configs.recommended,
tseslint.configs.recommended,
);// La regla no-deprecated necesita información de tipos (semana 11).
import process from 'node:process';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig({
files: ['ejemplos/deprecado.ts'],
extends: [tseslint.configs.base],
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: process.cwd() } },
rules: { '@typescript-eslint/no-deprecated': 'error' },
});// Prevenir el retroceso: que nadie agregue usos nuevos de la ruta deprecada mientras
// se migran los viejos. Revisa los textos del código, como el SQL de la semana 11.
import { defineConfig } from 'eslint/config';
export default defineConfig({
files: ['**/*.js', '**/*.ts'],
languageOptions: { sourceType: 'module' },
rules: {
'no-restricted-syntax': [
'error',
{
selector: "TemplateElement[value.raw=/^\\/api\\/members\\//], Literal[value=/^\\/api\\/members\\/.*bookings/]",
message: 'GET /api/members/:id/bookings está deprecada y deja de responder el 1 de enero de 2028. Usen /api/v2/members/:id/bookings, que devuelve { bookings }.',
},
],
},
});// El strangler fig en pequeño: una fachada delante de la Kallpa vieja. Quien llama
// ve siempre la misma API; por dentro, cada prefijo va a un sistema u otro.
// legacy lo atiende el sistema viejo
// shadow responde el viejo y, en segundo plano, se compara con el nuevo
// replacement lo atiende el sistema nuevo
import http from 'node:http';
export type Mode = 'legacy' | 'shadow' | 'replacement';
export type Route = { prefix: string; mode: Mode };
export type FacadeOptions = {
legacyUrl: string;
replacementUrl: string;
routes: Route[];
log: (line: string) => void;
};
export function modeFor(path: string, routes: readonly Route[]): Mode {
return routes.find((route) => path.startsWith(route.prefix))?.mode ?? 'legacy';
}
async function readBody(req: http.IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks);
}
export function createFacade(options: FacadeOptions): http.Server {
return http.createServer(async (req, res) => {
try {
const path = req.url ?? '/';
const mode = modeFor(path, options.routes);
const body = await readBody(req);
const send = (base: string) =>
fetch(base + path, {
method: req.method ?? 'GET',
headers: { 'content-type': req.headers['content-type'] ?? 'application/json' },
...(body.length > 0 ? { body } : {}),
});
const response = await send(mode === 'replacement' ? options.replacementUrl : options.legacyUrl);
const text = await response.text();
res.writeHead(response.status, { 'content-type': response.headers.get('content-type') ?? 'application/json' });
res.end(text);
// Solo lecturas: un POST mandado a los dos sistemas haría dos reservas.
if (mode === 'shadow' && req.method === 'GET') {
const other = await send(options.replacementUrl);
const otherText = await other.text();
const same = other.status === response.status && otherText === text;
options.log(`${same ? 'SHADOW-OK' : 'SHADOW-DIFF'} ${path}`);
}
} catch (error) {
options.log(`FACADE-ERROR ${String(error)}`);
if (!res.headersSent) res.writeHead(502);
res.end();
}
});
}// Dos formas de cambiar `members.phone` por `members.whatsapp`, con el código
// anterior corriendo encima: es lo que pasa durante un rollback.
import type { DatabaseSync } from 'node:sqlite';
// Incompatible: en el momento en que corre, el código anterior deja de funcionar.
export const RENAME_PHONE = 'ALTER TABLE members RENAME COLUMN phone TO whatsapp';
// Compatible: solo agrega. La columna nueva acepta NULL, porque el código anterior
// va a seguir insertando vecinos sin llenarla.
export const EXPAND_WHATSAPP = `
ALTER TABLE members ADD COLUMN whatsapp TEXT;
UPDATE members SET whatsapp = phone;
PRAGMA user_version = 3;
`;
// Se aplica mucho después, cuando ninguna versión a la que se pueda volver lee `phone`.
export const CONTRACT_PHONE = `
ALTER TABLE members DROP COLUMN phone;
PRAGMA user_version = 4;
`;
export type NewMember = { id: number; name: string; whatsapp: string };
// El código nuevo durante la transición: escribe en las dos columnas y lee la nueva,
// con la vieja como respaldo para las filas que insertó el código anterior.
export function insertMemberNew(db: DatabaseSync, name: string, phone: string): number {
const result = db.prepare("INSERT INTO members (name, phone, whatsapp, plan) VALUES (?, ?, ?, 'regular')").run(name, phone, phone);
return Number(result.lastInsertRowid);
}
export function findMemberNew(db: DatabaseSync, id: number): NewMember | undefined {
const row = db.prepare('SELECT id, name, COALESCE(whatsapp, phone) AS whatsapp FROM members WHERE id = ?').get(id) as NewMember | undefined;
return row && { ...row };
}// Una Kallpa con los datos de ejemplo alrededor de un lunes fijo, y su reloj. Las
// reservas no cambian de una corrida a otra: se pueden comparar con un archivo.
import type { AddressInfo } from 'node:net';
import type { Server } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { fixedClock } from '../../kallpa/src/domain/clock.ts';
import { createServer } from '../../kallpa/src/http/server.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedDatabase } from '../../kallpa/src/store/seed.ts';
export const TODAY = '2026-03-09';
export const MONDAY_10AM = '2026-03-09T15:00:00Z';
// Días con algo que mirar: pasados, hoy, una cancelación, dos horas que cruzan
// las 18:00, un sábado y un día sin reservas.
export const SAMPLE_DAYS = ['2026-03-06', '2026-03-08', '2026-03-09', '2026-03-10', '2026-03-11', '2026-03-14', '2026-03-20'];
export function seededDatabase(path = ':memory:'): DatabaseSync {
const db = openDatabase(path);
seedDatabase(db, TODAY);
return db;
}
export async function listen(server: Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
export function close(server: Server): Promise<void> {
server.closeAllConnections();
return new Promise((resolve) => server.close(() => resolve()));
}
export function seededServer(db: DatabaseSync): Server {
return createServer({ db, clock: fixedClock(MONDAY_10AM), notifier: { bookingConfirmed: () => {} }, log: () => {} });
}
export async function dailyReports(url: string): Promise<Record<string, unknown>> {
const reports: Record<string, unknown> = {};
for (const day of SAMPLE_DAYS) {
reports[day] = await (await fetch(`${url}/api/reports/daily?date=${day}`)).json();
}
return reports;
}diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..d90a582 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -12,7 +12,6 @@ import {
import { isValidDate } from '../domain/calendar.ts';
import { limaTime, type Clock } from '../domain/clock.ts';
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';
@@ -159,8 +158,7 @@ export function dailyReport(deps: AppDependencies): Handler {
if (!isValidDate(date)) {
throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
}
- const bookings = deps.service.bookingsOn(date).map(toBookingJson);
- sendJson(res, 200, dailySummary(date, deps.service.listCourts(), bookings));
+ sendJson(res, 200, deps.service.dailySummary(date));
};
}
diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts
index 55e62cf..e196bbf 100644
--- a/src/services/booking-service.ts
+++ b/src/services/booking-service.ts
@@ -4,14 +4,18 @@ import type { Member } from '../domain/member.ts';
import { BookingRepository, type BookingRow, type NewBookingRow } from '../store/booking-repository.ts';
import { CourtRepository } from '../store/court-repository.ts';
import { MemberRepository } from '../store/member-repository.ts';
+import { dailySummarySql } from '../store/report-sql.ts';
+import type { DailySummary } from '../domain/report.ts';
// Punto de entrada a los datos de reservas para la capa HTTP.
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);
@@ -53,6 +57,10 @@ export class BookingService {
return this.#bookings.insert(row);
}
+ dailySummary(date: string): DailySummary {
+ return dailySummarySql(this.#db, date);
+ }
+
markCancelled(id: number, cancelledAt: string): void {
this.#bookings.updateStatus(id, 'cancelled', cancelledAt);
}
diff --git a/src/store/report-sql.ts b/src/store/report-sql.ts
new file mode 100644
index 0000000..9b30fae
--- /dev/null
+++ b/src/store/report-sql.ts
@@ -0,0 +1,25 @@
+import type { DatabaseSync } from 'node:sqlite';
+import type { CourtSummary, DailySummary } from '../domain/report.ts';
+
+export function dailySummarySql(db: DatabaseSync, date: string): DailySummary {
+ const courts = db
+ .prepare(
+ `SELECT c.id AS courtId, c.name AS courtName,
+ COUNT(*) AS bookings,
+ SUM(b.hours) AS hours,
+ SUM(b.price_cents) AS revenueCents
+ FROM bookings b
+ JOIN courts c ON c.id = b.court_id
+ WHERE b.date = ? AND b.status = 'confirmed'
+ GROUP BY c.id
+ ORDER BY c.id`,
+ )
+ .all(date) as CourtSummary[];
+ return {
+ date,
+ bookings: courts.reduce((total, c) => total + c.bookings, 0),
+ hours: courts.reduce((total, c) => total + c.hours, 0),
+ revenueCents: courts.reduce((total, c) => total + c.revenueCents, 0),
+ courts: courts.map((c) => ({ ...c })),
+ };
+}// Guarda en golden/caja.json lo que responde hoy la caja del día de Kallpa para
// los días de ejemplo. Se corre una vez, antes de cambiar nada.
// make golden
import { writeFileSync } from 'node:fs';
import { close, dailyReports, listen, seededDatabase, seededServer } from '../seeded.ts';
const db = seededDatabase();
const server = seededServer(db);
const reports = await dailyReports(await listen(server));
writeFileSync('golden/caja.json', JSON.stringify(reports, null, 2) + '\n');
await close(server);
db.close();// El sistema nuevo: un servicio que solo sabe de la caja del día.
import http from 'node:http';
import type { DatabaseSync } from 'node:sqlite';
import type { DailySummary } from '../../../kallpa/src/domain/report.ts';
import { sendError, sendJson, HttpError } from '../../../kallpa/src/http/json.ts';
import { isValidDate } from '../../../kallpa/src/domain/calendar.ts';
export type Summarize = (db: DatabaseSync, date: string) => DailySummary;
export function createReportService(db: DatabaseSync, summarize: Summarize): http.Server {
return http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const date = url.searchParams.get('date');
if (!isValidDate(date)) {
sendError(res, new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).'));
return;
}
sendJson(res, 200, summarize(db, date));
});
}// Ilustración escrita para la clase: un cambio como los que suele proponer un
// agente cuando se le pide "calcula la caja en SQL". Se lee bien y pasa
// `make check`. Tiene dos diferencias con lo que Kallpa hace hoy.
import type { DatabaseSync } from 'node:sqlite';
import type { CourtSummary, DailySummary } from '../../../kallpa/src/domain/report.ts';
export function dailySummarySql(db: DatabaseSync, date: string): DailySummary {
const courts = db
.prepare(
`SELECT c.id AS courtId, c.name AS courtName,
COUNT(*) AS bookings,
SUM(b.hours) AS hours,
SUM(b.price_cents) AS revenueCents
FROM bookings b
JOIN courts c ON c.id = b.court_id
WHERE b.date = ? AND b.status = 'confirmed'
GROUP BY c.id
ORDER BY c.id`,
)
.all(date) as CourtSummary[];
return {
date,
bookings: courts.reduce((total, c) => total + c.bookings, 0),
hours: courts.reduce((total, c) => total + c.hours, 0),
revenueCents: courts.reduce((total, c) => total + c.revenueCents, 0),
courts: courts.map((c) => ({ ...c })),
};
}// La caja del día calculada en SQL en lugar de traer las reservas a memoria.
// Un refactoring: tiene que responder lo mismo que dailySummary.
import type { DatabaseSync } from 'node:sqlite';
import type { CourtSummary, DailySummary } from '../../../kallpa/src/domain/report.ts';
export function dailySummarySql(db: DatabaseSync, date: string): DailySummary {
const courts = db
.prepare(
`SELECT c.id AS courtId, c.name AS courtName,
COUNT(b.id) AS bookings,
COALESCE(SUM(b.hours), 0) AS hours,
COALESCE(SUM(b.price_cents), 0) AS revenueCents
FROM courts c
LEFT JOIN bookings b ON b.court_id = c.id AND b.date = ? AND b.status = 'confirmed'
GROUP BY c.id, c.name
ORDER BY c.name`,
)
.all(date) as CourtSummary[];
return {
date,
bookings: courts.reduce((total, c) => total + c.bookings, 0),
hours: courts.reduce((total, c) => total + c.hours, 0),
revenueCents: courts.reduce((total, c) => total + c.revenueCents, 0),
courts: courts.map((c) => ({ ...c })),
};
}// La caracterización de la caja contra la versión del agente. Falla a propósito.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { dailySummarySql } from '../caja/report-sql-agente.ts';
import { seededDatabase } from '../seeded.ts';
const golden = JSON.parse(readFileSync('golden/caja.json', 'utf8')) as Record<string, unknown>;
test('la caja del 20 de marzo, un día sin reservas', () => {
assert.deepEqual(dailySummarySql(seededDatabase(), '2026-03-20'), golden['2026-03-20']);
});
test('la caja del 9 de marzo', () => {
assert.deepEqual(dailySummarySql(seededDatabase(), '2026-03-09'), golden['2026-03-09']);
});// make facade: la caja del día se muda detrás de la fachada, por fases.
import { dailySummarySql as agentVersion } from '../caja/report-sql-agente.ts';
import { dailySummarySql } from '../caja/report-sql.ts';
import { createReportService, type Summarize } from '../caja/report-service.ts';
import { createFacade, type Mode } from '../facade.ts';
import { close, listen, SAMPLE_DAYS, seededDatabase, seededServer } from '../seeded.ts';
const db = seededDatabase();
const legacy = seededServer(db);
const legacyUrl = await listen(legacy);
async function phase(title: string, mode: Mode, summarize: Summarize): Promise<void> {
console.log(`\n=== ${title} (/api/reports/ en modo ${mode})`);
const replacement = createReportService(db, summarize);
const replacementUrl = await listen(replacement);
const log = (line: string) => console.log(line);
const facade = createFacade({ legacyUrl, replacementUrl, routes: [{ prefix: '/api/reports/', mode }], log });
const url = await listen(facade);
for (const day of SAMPLE_DAYS) {
const response = await fetch(`${url}/api/reports/daily?date=${day}`);
const body = (await response.json()) as { courts: unknown[] };
await new Promise((resolve) => setTimeout(resolve, 20));
console.log(` ${day}: ${response.status}, ${body.courts.length} canchas`);
}
const courts = await (await fetch(`${url}/api/courts`)).json() as { courts: unknown[] };
console.log(` /api/courts lo sigue atendiendo Kallpa: ${courts.courts.length} canchas`);
await close(facade);
await close(replacement);
}
await phase('Fase 1: se compara la versión con errores', 'shadow', agentVersion);
await phase('Fase 2: se compara la versión corregida', 'shadow', dailySummarySql);
await phase('Fase 3: atiende el sistema nuevo', 'replacement', dailySummarySql);
await close(legacy);
db.close();// make migraciones: el código publicado de Kallpa corriendo sobre cada migración.
import { DatabaseSync } from 'node:sqlite';
import { MemberRepository } from '../../../kallpa/src/store/member-repository.ts';
import { CONTRACT_PHONE, EXPAND_WHATSAPP, findMemberNew, insertMemberNew, RENAME_PHONE } from '../migraciones.ts';
import { seededDatabase } from '../seeded.ts';
function tryOld(label: string, db: DatabaseSync): void {
try {
const members = new MemberRepository(db).list();
console.log(`${label}: el código anterior lista ${members.length} vecinos`);
} catch (error) {
console.log(`${label}: el código anterior falla: ${(error as Error).message}`);
}
}
const renamed = seededDatabase();
renamed.exec(RENAME_PHONE);
tryOld('RENAME COLUMN', renamed);
const db = seededDatabase();
db.exec(EXPAND_WHATSAPP);
tryOld('ADD COLUMN', db);
const version = db.prepare('PRAGMA user_version').get() as { user_version: number };
console.log(`user_version: ${version.user_version}; el código anterior solo conoce 2 migraciones`);
const older = new MemberRepository(db).insert({ name: 'Vecina del rollback', phone: '900000001', plan: 'regular' });
const newer = insertMemberNew(db, 'Vecina nueva', '900000002');
console.log('insertó el código anterior:', findMemberNew(db, older));
console.log('insertó el código nuevo:', findMemberNew(db, newer));
db.exec(CONTRACT_PHONE);
tryOld('DROP COLUMN', db);// El primer paso de una prueba de caracterización: una aserción que sabemos que
// falla, para que el fallo nos diga qué hace el código. Falla a propósito.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { closingReport } from '../heredado/cierre-con-costura.ts';
import { seededDatabase } from '../seeded.ts';
test('¿qué dice el cierre del 8 de marzo?', () => {
const db = seededDatabase();
assert.equal(closingReport(db, new Date('2026-03-09T15:00:00Z'), '2026-03-08'), '?');
});// make renombrar: el mismo cambio mecánico de dos formas, en copias de Kallpa.
// Renombrar el campo priceCents de Booking a totalCents.
import { execSync, spawnSync } from 'node:child_process';
import { cpSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import ts from 'typescript';
const KALLPA = process.env.KALLPA ?? '../../kallpa';
function copyKallpa(name: string): string {
const dir = resolve('build/renombrar', name);
rmSync(dir, { recursive: true, force: true });
cpSync(KALLPA, dir, { recursive: true, filter: (path) => !path.includes('/data/') });
return dir;
}
function check(dir: string): void {
for (const [label, command, args] of [['tsc', 'tsc', ['-p', '.']], ['pruebas', 'node', ['--test', 'test/**/*.test.ts']]] as const) {
const result = spawnSync(command, args, { cwd: dir, encoding: 'utf8' });
const output = result.stdout + result.stderr;
if (label === 'tsc') console.log(` tsc: ${result.status === 0 ? 'sin errores' : (output.match(/error TS/g)?.length ?? 0) + ' errores'}`);
else console.log(` pruebas: ${/^ℹ pass (\d+)$/m.exec(output)?.[1]} pasan, ${/^ℹ fail (\d+)$/m.exec(output)?.[1]} fallan`);
if (label === 'pruebas') for (const line of output.match(/^✖ .*$/gm)?.slice(0, 2) ?? []) console.log(` ${line}`);
}
}
// El servicio de lenguaje: el mismo que usa el editor para "renombrar símbolo".
function renameWithTypes(dir: string, file: string, oldName: string, newName: string): Map<string, number> {
const config = ts.parseJsonConfigFileContent(ts.readConfigFile(join(dir, 'tsconfig.json'), ts.sys.readFile).config, ts.sys, dir);
const host: ts.LanguageServiceHost = {
getCompilationSettings: () => config.options,
getScriptFileNames: () => config.fileNames,
getScriptVersion: () => '1',
getScriptSnapshot: (name) => (ts.sys.fileExists(name) ? ts.ScriptSnapshot.fromString(ts.sys.readFile(name) ?? '') : undefined),
getCurrentDirectory: () => dir,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
directoryExists: ts.sys.directoryExists,
getDirectories: ts.sys.getDirectories,
};
const service = ts.createLanguageService(host);
const path = join(dir, file);
const position = readFileSync(path, 'utf8').indexOf(`${oldName}:`);
const locations = service.findRenameLocations(path, position, false, false, {}) ?? [];
const byFile = new Map<string, ts.RenameLocation[]>();
for (const location of locations) byFile.set(location.fileName, [...(byFile.get(location.fileName) ?? []), location]);
const counts = new Map<string, number>();
for (const [name, list] of byFile) {
let text = readFileSync(name, 'utf8');
for (const location of [...list].sort((a, b) => b.textSpan.start - a.textSpan.start)) {
const before = text.slice(0, location.textSpan.start);
const after = text.slice(location.textSpan.start + location.textSpan.length);
text = before + (location.prefixText ?? '') + newName + (location.suffixText ?? '') + after;
}
writeFileSync(name, text);
counts.set(relative(dir, name), list.length);
}
return counts;
}
function remaining(dir: string, name: string): string {
return execSync(`grep -rln ${name} src test public || true`, { cwd: dir, encoding: 'utf8' }).trim().split('\n').join(', ');
}
console.log('=== 1. Con sed, en todo el repositorio');
const withSed = copyKallpa('sed');
execSync(`grep -rl priceCents src test public | xargs sed -i 's/priceCents/totalCents/g'`, { cwd: withSed });
check(withSed);
const seededResponse = readFileSync(join(withSed, 'src/http/handlers.ts'), 'utf8').match(/totalCents: row\.price_cents/);
console.log(` handlers.ts: ${seededResponse?.[0]}`);
console.log(` quedan con priceCents: ${remaining(withSed, 'priceCents') || 'ninguno'}`);
console.log('\n=== 2. Con el servicio de lenguaje de TypeScript');
const withTypes = copyKallpa('tipos');
const counts = renameWithTypes(withTypes, 'src/domain/booking.ts', 'priceCents', 'totalCents');
for (const [file, count] of [...counts].sort()) console.log(` ${String(count).padStart(2)} ${file}`);
check(withTypes);
console.log(` quedan con priceCents: ${remaining(withTypes, 'priceCents')}`);// Deprecar a nivel de código: @deprecated es lo que leen el editor y el linter. Por
// sí solo, el compilador no se queja.
export function formatSoles(cents: number): string {
return `S/ ${(cents / 100).toFixed(2)}`;
}
/**
* @deprecated Redondea mal los céntimos. Usen `formatSoles(cents)`, que recibe céntimos.
*/
export function toSoles(amount: number): string {
return `S/ ${amount.toFixed(2)}`;
}
export const label = toSoles(112.5);// Una pantalla nueva, copiada de un ejemplo viejo del mismo repositorio.
export async function loadHistory(memberId) {
const response = await fetch(`/api/members/${memberId}/bookings`);
return response.json();
}
export async function loadHistoryV2(memberId) {
const response = await fetch(`/api/v2/members/${memberId}/bookings`);
return (await response.json()).bookings;
}diff --git a/src/domain/booking.ts b/src/domain/booking.ts
index d58bac2..f0ecf13 100644
--- a/src/domain/booking.ts
+++ b/src/domain/booking.ts
@@ -7,6 +7,7 @@ export const CLOSING_HOUR = 22; // a las 22:00 se apagan las luces
export const MAX_HOURS_PER_BOOKING = 3;
export const BOOKING_WINDOW_DAYS = 14; // se reserva con hasta dos semanas de anticipación
export const CANCELLATION_NOTICE_HOURS = 3;
+export const TEAM_NAME_MIN_LENGTH = 3;
export const TEAM_NAME_MAX_LENGTH = 40;
export const BOOKING_STATUSES = ['confirmed', 'cancelled'] as const;
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..3df3088 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -2,6 +2,7 @@ import {
CLOSING_HOUR,
OPENING_HOUR,
TEAM_NAME_MAX_LENGTH,
+ TEAM_NAME_MIN_LENGTH,
checkBookingRules,
isCancellableAt,
overlaps,
@@ -178,11 +179,11 @@ function parseBookingRequest(body: unknown): BookingRequest {
throw new HttpError(400, 'VALIDATION_ERROR', 'Falta el campo date (AAAA-MM-DD).');
}
const teamName = typeof fields.teamName === 'string' ? fields.teamName.trim() : '';
- if (teamName.length === 0 || teamName.length > TEAM_NAME_MAX_LENGTH) {
+ if (teamName.length < TEAM_NAME_MIN_LENGTH || teamName.length > TEAM_NAME_MAX_LENGTH) {
throw new HttpError(
400,
'VALIDATION_ERROR',
- `El nombre del equipo es obligatorio y tiene como máximo ${TEAM_NAME_MAX_LENGTH} caracteres.`,
+ `El nombre del equipo tiene entre ${TEAM_NAME_MIN_LENGTH} y ${TEAM_NAME_MAX_LENGTH} caracteres.`,
);
}
return { courtId, memberId, date: fields.date, startHour, hours, teamName };
diff --git a/test/nombre-equipo.test.ts b/test/nombre-equipo.test.ts
new file mode 100644
index 0000000..83b38e2
--- /dev/null
+++ b/test/nombre-equipo.test.ts
@@ -0,0 +1,14 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { postJson, startTestServer } from './helpers.ts';
+
+test('un nombre de equipo de 2 caracteres se rechaza y uno de 3 se acepta', async () => {
+ const server = await startTestServer();
+ const request = { courtId: 1, memberId: 1, date: '2026-03-10', startHour: 9 };
+ const short = await postJson(`${server.url}/api/bookings`, { ...request, teamName: 'AB' });
+ assert.equal(short.status, 400);
+ assert.equal(short.body.error.code, 'VALIDATION_ERROR');
+ const ok = await postJson(`${server.url}/api/bookings`, { ...request, teamName: 'ABC' });
+ assert.equal(ok.status, 200);
+ await server.close();
+});#!/usr/bin/env bash
# Ensayo de la defensa, sobre una copia de Kallpa: el clon limpio y el cambio en vivo.
set -euo pipefail
KALLPA="${KALLPA:-../../kallpa}"
HERE="$PWD"
git() { command git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main -c advice.detachedHead=false "$@"; }
resumen() { grep -E '^(ℹ (pass|fail)|.*error TS)' || true; }
rm -rf build/ensayo
mkdir -p build/ensayo
cp -r "$KALLPA" build/ensayo/origen
rm -f build/ensayo/origen/data/*.db build/ensayo/origen/data/*.db-*
cd build/ensayo/origen
# Alguien del equipo usa un archivo que está en .gitignore y nunca subió.
echo '*.local.ts' >> .gitignore
git init -q && git add -A && git commit -qm "Kallpa publicada"
cat >> src/config.ts <<'TS'
export { HOLIDAYS } from './holidays.local.ts';
TS
echo "export const HOLIDAYS = ['2026-07-28'];" > src/holidays.local.ts
git add -A && git commit -qm "Agrega los feriados"
echo '=== En la laptop de quien lo escribió'
make --no-print-directory check 2>&1 | resumen
echo '=== git status'
git status --short | wc -l
echo '=== En un clon limpio'
cd ..
git clone -q origen clon
cd clon
(make --no-print-directory check 2>&1 || true) | resumen
echo '=== Después de subir lo que faltaba'
cd ../origen
git rm -q --cached src/holidays.local.ts 2>/dev/null || true
mv src/holidays.local.ts src/holidays.ts
sed -i "s#./holidays.local.ts#./holidays.ts#" src/config.ts
git add -A && git commit -qm "Sube los feriados"
cd .. && rm -rf clon && git clone -q origen clon && cd clon
make --no-print-directory check 2>&1 | resumen
echo '=== El cambio en vivo: un nombre de equipo tiene al menos 3 caracteres'
git switch -q -c equipo-nombre-minimo
git apply --index "$HERE/ensayo/cambio-en-vivo.diff"
git diff --cached --stat
make --no-print-directory check 2>&1 | resumen{
"2026-03-06": {
"date": "2026-03-06",
"bookings": 1,
"hours": 2,
"revenueCents": 22500,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 1,
"hours": 2,
"revenueCents": 22500
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 0,
"hours": 0,
"revenueCents": 0
}
]
},
"2026-03-08": {
"date": "2026-03-08",
"bookings": 2,
"hours": 3,
"revenueCents": 21375,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 1,
"hours": 1,
"revenueCents": 10125
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 1,
"hours": 2,
"revenueCents": 11250
}
]
},
"2026-03-09": {
"date": "2026-03-09",
"bookings": 3,
"hours": 5,
"revenueCents": 48750,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 1,
"hours": 1,
"revenueCents": 6000
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 1,
"hours": 2,
"revenueCents": 20250
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 1,
"hours": 2,
"revenueCents": 22500
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 0,
"hours": 0,
"revenueCents": 0
}
]
},
"2026-03-10": {
"date": "2026-03-10",
"bookings": 2,
"hours": 3,
"revenueCents": 21250,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 1,
"hours": 1,
"revenueCents": 11250
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 1,
"hours": 2,
"revenueCents": 10000
}
]
},
"2026-03-11": {
"date": "2026-03-11",
"bookings": 2,
"hours": 3,
"revenueCents": 22125,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 1,
"hours": 2,
"revenueCents": 12000
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 1,
"hours": 1,
"revenueCents": 10125
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 0,
"hours": 0,
"revenueCents": 0
}
]
},
"2026-03-14": {
"date": "2026-03-14",
"bookings": 1,
"hours": 2,
"revenueCents": 11250,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 1,
"hours": 2,
"revenueCents": 11250
}
]
},
"2026-03-20": {
"date": "2026-03-20",
"bookings": 0,
"hours": 0,
"revenueCents": 0,
"courts": [
{
"courtId": 4,
"courtName": "Básquet",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 1,
"courtName": "Fulbito 1",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 2,
"courtName": "Fulbito 2",
"bookings": 0,
"hours": 0,
"revenueCents": 0
},
{
"courtId": 3,
"courtName": "Vóley",
"bookings": 0,
"hours": 0,
"revenueCents": 0
}
]
}
}CIERRE DE CAJA 2026-03-06
Fulbito 1 19:00 Los Pumas de Comas S/ 225.00
TOTAL S/ 225.00CIERRE DE CAJA 2026-03-08
Fulbito 1 20:00 Deportivo Rímac S/ 91.13
Vóley 18:00 Vóley Femenino San Juan S/ 101.25
TOTAL S/ 192.38CIERRE DE CAJA 2026-03-09
Básquet 17:00 Básquet Ate S/ 60.00
Fulbito 1 19:00 Real Barranco S/ 182.25
Fulbito 2 20:00 Los Cracks del Jueves S/ 225.00
TOTAL S/ 467.25CIERRE DE CAJA 2026-03-10
Fulbito 1 18:00 Los Pumas de Comas S/ 112.50
Vóley 9:00 Las Matadoras S/ 100.00
TOTAL S/ 212.50CIERRE DE CAJA 2026-03-11
Básquet 17:00 Juventud San Martín S/ 120.00
Fulbito 2 21:00 Promo 2015 Fe y Alegría S/ 91.13
TOTAL S/ 211.13CIERRE DE CAJA 2026-03-14
Vóley 10:00 Vóley Femenino San Juan S/ 101.25
TOTAL S/ 101.25CIERRE DE CAJA 2026-03-20
TOTAL S/ 0.00// El mismo cierre, con los cambios mínimos para poder probarlo: la lógica pasa a
// una función que recibe la base de datos y el instante, y devuelve el texto.
// Nada más cambió: ni los nombres, ni el cálculo, ni lo raro que tiene.
import { DatabaseSync } from 'node:sqlite';
type Row = { court: string; start_hour: number; team_name: string; price_cents: number; plan: string; status: string };
export function closingReport(db: DatabaseSync, now: Date, dateArgument?: string): string {
const date = dateArgument ?? now.toISOString().slice(0, 10);
const rows = db
.prepare(
`SELECT c.name AS court, b.start_hour, b.team_name, b.price_cents, m.plan, b.status
FROM bookings b JOIN courts c ON c.id = b.court_id JOIN members m ON m.id = b.member_id
WHERE b.date = ? ORDER BY c.name, b.start_hour`,
)
.all(date) as Row[];
const lines = [`CIERRE DE CAJA ${date}`];
let total = 0;
for (const row of rows) {
if (row.status === 'cancelled') continue;
let amount = row.price_cents / 100;
if (row.plan === 'socio') amount = amount * 0.9;
total += amount;
lines.push(`${row.court.padEnd(10)} ${String(row.start_hour).padStart(2)}:00 ${row.team_name.padEnd(26)} S/ ${amount.toFixed(2)}`);
}
lines.push(`TOTAL S/ ${total.toFixed(2)}`);
if (total > 500) lines.push('Depositar en el banco');
return lines.join('\n') + '\n';
}
if (import.meta.main) {
const db = new DatabaseSync(process.env.KALLPA_DB ?? 'data/kallpa.db');
process.stdout.write(closingReport(db, new Date(), process.argv[2]));
}// Cierre de caja de la administración. Lo escribió hace años alguien que ya no
// está en el complejo y no tiene pruebas. Doris lo corre cada noche, y le pasa
// la fecha a mano porque "sin fecha sale mal de noche".
//
// KALLPA_DB=data/kallpa.db node cierre-de-caja.ts 2026-03-11
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(process.env.KALLPA_DB ?? 'data/kallpa.db');
const date = process.argv[2] ?? new Date().toISOString().slice(0, 10);
type Row = { court: string; start_hour: number; team_name: string; price_cents: number; plan: string; status: string };
const rows = db
.prepare(
`SELECT c.name AS court, b.start_hour, b.team_name, b.price_cents, m.plan, b.status
FROM bookings b JOIN courts c ON c.id = b.court_id JOIN members m ON m.id = b.member_id
WHERE b.date = ? ORDER BY c.name, b.start_hour`,
)
.all(date) as Row[];
console.log(`CIERRE DE CAJA ${date}`);
let total = 0;
for (const row of rows) {
if (row.status === 'cancelled') continue;
let amount = row.price_cents / 100;
if (row.plan === 'socio') amount = amount * 0.9;
total += amount;
console.log(`${row.court.padEnd(10)} ${String(row.start_hour).padStart(2)}:00 ${row.team_name.padEnd(26)} S/ ${amount.toFixed(2)}`);
}
console.log(`TOTAL S/ ${total.toFixed(2)}`);
if (total > 500) console.log('Depositar en el banco');// El cambio que pidió Doris: al final del cierre, cuántas reservas fueron de
// socios y cuántas de regulares. Se agrega al lado del código viejo (una función
// nueva, con sus propias pruebas) en lugar de reescribirlo.
import { DatabaseSync } from 'node:sqlite';
import { closingReport } from './cierre-con-costura.ts';
export type PlanCount = { plan: string; bookings: number };
export function bookingsByPlan(db: DatabaseSync, date: string): PlanCount[] {
const rows = db
.prepare(
`SELECT m.plan, COUNT(*) AS bookings
FROM bookings b JOIN members m ON m.id = b.member_id
WHERE b.date = ? AND b.status = 'confirmed'
GROUP BY m.plan ORDER BY m.plan`,
)
.all(date) as PlanCount[];
return rows.map((row) => ({ ...row }));
}
export function closingReportByPlan(db: DatabaseSync, now: Date, dateArgument?: string): string {
const old = closingReport(db, now, dateArgument);
const date = dateArgument ?? now.toISOString().slice(0, 10);
const counts = bookingsByPlan(db, date).map((c) => `${c.plan}: ${c.bookings}`);
return `${old}RESERVAS ${counts.join(', ') || 'ninguna'}\n`;
}diff --git a/README.md b/README.md
index 77a7445..f8ed5ce 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,8 @@ un código estable que los clientes HTTP pueden comparar sin leer el mensaje:
| `GET /api/courts` | Lista las canchas | `200 { courts: [{ id, name, sport }] }` |
| `GET /api/courts/:id/availability?date=YYYY-MM-DD` | Horas libres y ocupadas de una cancha ese día | `200 { courtId, date, slots: [{ hour, available, teamName? }] }` |
| `GET /api/members` | Lista a los vecinos registrados | `200 { members: [{ id, name, phone, plan }] }` |
-| `GET /api/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 [booking, ...]` |
+| `GET /api/members/:id/bookings` | **Deprecada, deja de responder el 1 de enero de 2028.** Usen la de abajo | `200 [booking, ...]` |
+| `GET /api/v2/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 { bookings: [booking, ...] }` |
| `POST /api/bookings` | Reserva | `200 { booking }` |
| `POST /api/bookings/:id/cancel` | Cancela una reserva | `200 { booking }` |
| `GET /api/reports/daily?date=YYYY-MM-DD` | La caja del día, para la administración: reservas, horas y dinero por cancha | `200 { date, bookings, hours, revenueCents, courts: [...] }` |
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..ec3043e 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -143,12 +143,30 @@ export function cancelBooking(deps: AppDependencies): Handler {
};
}
-// GET /api/members/:id/bookings
+// La v1 sigue respondiendo igual: solo se anuncia su fin (RFC 9745 y RFC 8594).
+export const MEMBER_BOOKINGS_V1_DEPRECATED_AT = 1798761600; // 1 de enero de 2027, 00:00 UTC
+export const MEMBER_BOOKINGS_V1_SUNSET = 'Sat, 01 Jan 2028 00:00:00 GMT';
+
+function bookingsOfMember(deps: AppDependencies, id: string): Booking[] {
+ const member = deps.service.findMember(Number(id));
+ if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${id}.`);
+ return deps.service.bookingsForMember(member.id).map(toBookingJson);
+}
+
+// GET /api/members/:id/bookings: arreglo suelto, deprecado.
export function memberBookings(deps: AppDependencies): Handler {
return ({ res, params }) => {
- const member = deps.service.findMember(Number(params.id));
- if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${params.id}.`);
- sendJson(res, 200, deps.service.bookingsForMember(member.id).map(toBookingJson));
+ res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
+ res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
+ res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
+ sendJson(res, 200, bookingsOfMember(deps, params.id));
+ };
+}
+
+// GET /api/v2/members/:id/bookings: un objeto, que puede crecer.
+export function memberBookingsV2(deps: AppDependencies): Handler {
+ return ({ res, params }) => {
+ sendJson(res, 200, { bookings: bookingsOfMember(deps, params.id) });
};
}
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..ed30624 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -11,6 +11,7 @@ import {
listCourts,
listMembers,
memberBookings,
+ memberBookingsV2,
type AppDependencies,
} from './handlers.ts';
import { HttpError, sendError } from './json.ts';
@@ -30,6 +31,7 @@ export function buildRouter(deps: AppDependencies): Router {
router.get('/api/courts/:id/availability', courtAvailability(deps));
router.get('/api/members', listMembers(deps));
router.get('/api/members/:id/bookings', memberBookings(deps));
+ router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps));
router.post('/api/bookings', createBooking(deps));
router.post('/api/bookings/:id/cancel', cancelBooking(deps));
router.get('/api/reports/daily', dailyReport(deps));
@@ -66,6 +68,8 @@ export function createServer(options: ServerOptions): http.Server {
}
} finally {
log(`${req.method} ${req.url} ${res.statusCode}`);
+ // Para saber quién sigue usando lo deprecado. Sin el número de vecino: no hace falta.
+ if (res.hasHeader('Deprecation')) log(`DEPRECATED /api/members/:id/bookings ua="${req.headers['user-agent'] ?? '?'}"`);
}
});
}
diff --git a/test/v2.test.ts b/test/v2.test.ts
new file mode 100644
index 0000000..67946b3
--- /dev/null
+++ b/test/v2.test.ts
@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, test } from 'node:test';
+import { postJson, startTestServer, type TestServer } from './helpers.ts';
+
+// El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00.
+describe('las reservas de un vecino, en v1 y en v2', () => {
+ let server: TestServer;
+
+ before(async () => {
+ server = await startTestServer();
+ await postJson(`${server.url}/api/bookings`, { courtId: 1, memberId: 2, date: '2026-03-11', startHour: 10, teamName: 'Real Barranco' });
+ });
+
+ after(async () => {
+ await server.close();
+ });
+
+ test('v1 responde lo mismo que antes y anuncia su fin', async () => {
+ const response = await fetch(`${server.url}/api/members/2/bookings`);
+ const body = await response.json();
+ assert.equal(response.status, 200);
+ assert.ok(Array.isArray(body));
+ assert.equal(response.headers.get('deprecation'), '@1798761600');
+ assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT');
+ assert.equal(response.headers.get('link'), '</api/v2/members/2/bookings>; rel="successor-version"');
+ });
+
+ test('v2 devuelve las mismas reservas dentro de un objeto y sin aviso', async () => {
+ const v1 = await (await fetch(`${server.url}/api/members/2/bookings`)).json();
+ const response = await fetch(`${server.url}/api/v2/members/2/bookings`);
+ assert.deepEqual(await response.json(), { bookings: v1 });
+ assert.equal(response.headers.get('deprecation'), null);
+ });
+
+ test('los errores no cambian entre versiones', async () => {
+ const response = await fetch(`${server.url}/api/v2/members/99/bookings`);
+ assert.equal(response.status, 404);
+ assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'MEMBER_NOT_FOUND');
+ });
+});diff --git a/public/app.js b/public/app.js
index 7963b5f..b1cadd7 100644
--- a/public/app.js
+++ b/public/app.js
@@ -54,7 +54,7 @@ async function loadSlots() {
}
async function loadMyBookings() {
- const bookings = await api(`/api/members/${memberSelect.value}/bookings`);
+ const { bookings } = await api(`/api/v2/members/${memberSelect.value}/bookings`);
myBookings.innerHTML = bookings
.map((b) => {
const text = `${b.date} · ${b.startHour}:00 a ${b.startHour + b.hours}:00 · ${b.teamName} · ${soles(b.priceCents)}`;
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index ec3043e..e338202 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -153,9 +153,24 @@ function bookingsOfMember(deps: AppDependencies, id: string): Booking[] {
return deps.service.bookingsForMember(member.id).map(toBookingJson);
}
+// Apagones anunciados: durante estas ventanas la v1 ya responde como responderá
+// después del fin. Así aparece quien no sabía que dependía de ella.
+export const MEMBER_BOOKINGS_V1_BROWNOUTS = [
+ { from: '2027-09-01T15:00:00Z', to: '2027-09-01T16:00:00Z' },
+ { from: '2027-10-05T15:00:00Z', to: '2027-10-05T21:00:00Z' },
+ { from: '2027-11-02T13:00:00Z', to: '2027-11-03T13:00:00Z' },
+];
+
+export function isBrownout(instant: Date): boolean {
+ return MEMBER_BOOKINGS_V1_BROWNOUTS.some(({ from, to }) => instant >= new Date(from) && instant < new Date(to));
+}
+
// GET /api/members/:id/bookings: arreglo suelto, deprecado.
export function memberBookings(deps: AppDependencies): Handler {
return ({ res, params }) => {
+ if (isBrownout(deps.clock.now())) {
+ throw new HttpError(410, 'ENDPOINT_REMOVED', `Esta ruta deja de existir: usen /api/v2/members/${params.id}/bookings, que devuelve { bookings }.`);
+ }
res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
diff --git a/test/v2-migrado.test.ts b/test/v2-migrado.test.ts
new file mode 100644
index 0000000..13e6bbd
--- /dev/null
+++ b/test/v2-migrado.test.ts
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+import { isBrownout } from '../src/http/handlers.ts';
+import { startTestServer } from './helpers.ts';
+
+test('la página ya no usa la ruta deprecada', () => {
+ const page = readFileSync('public/app.js', 'utf8');
+ assert.doesNotMatch(page, /`\/api\/members\/[^`]*\/bookings`/);
+ assert.match(page, /\/api\/v2\/members\//);
+});
+
+test('durante un apagón la v1 responde 410 con lo que hay que hacer; la v2 sigue', async () => {
+ const server = await startTestServer({ now: '2027-09-01T15:30:00Z' });
+ const old = await fetch(`${server.url}/api/members/2/bookings`);
+ assert.equal(old.status, 410);
+ assert.match(((await old.json()) as { error: { message: string } }).error.message, /\/api\/v2\/members\/2\/bookings/);
+ assert.equal((await fetch(`${server.url}/api/v2/members/2/bookings`)).status, 200);
+ await server.close();
+});
+
+test('un apagón empieza y termina a la hora anunciada', () => {
+ assert.equal(isBrownout(new Date('2027-09-01T14:59:59Z')), false);
+ assert.equal(isBrownout(new Date('2027-09-01T15:00:00Z')), true);
+ assert.equal(isBrownout(new Date('2027-09-01T16:00:00Z')), false);
+});diff --git a/README.md b/README.md
index f8ed5ce..b32869f 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ un código estable que los clientes HTTP pueden comparar sin leer el mensaje:
| `GET /api/courts` | Lista las canchas | `200 { courts: [{ id, name, sport }] }` |
| `GET /api/courts/:id/availability?date=YYYY-MM-DD` | Horas libres y ocupadas de una cancha ese día | `200 { courtId, date, slots: [{ hour, available, teamName? }] }` |
| `GET /api/members` | Lista a los vecinos registrados | `200 { members: [{ id, name, phone, plan }] }` |
-| `GET /api/members/:id/bookings` | **Deprecada, deja de responder el 1 de enero de 2028.** Usen la de abajo | `200 [booking, ...]` |
+| `GET /api/members/:id/bookings` | Ya no existe. Usen la de abajo | `410 ENDPOINT_REMOVED` |
| `GET /api/v2/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 { bookings: [booking, ...] }` |
| `POST /api/bookings` | Reserva | `200 { booking }` |
| `POST /api/bookings/:id/cancel` | Cancela una reserva | `200 { booking }` |
@@ -135,6 +135,7 @@ Cuerpo de `POST /api/bookings`:
| `INVALID_DATE` | 400 | La fecha no tiene el formato `YYYY-MM-DD` |
| `COURT_NOT_FOUND`, `MEMBER_NOT_FOUND`, `BOOKING_NOT_FOUND` | 404 | No existe |
| `NOT_FOUND` | 404 | La ruta no existe |
+| `ENDPOINT_REMOVED` | 410 | La ruta existió y se retiró; el mensaje dice cuál usar |
| `METHOD_NOT_ALLOWED` | 405 | La ruta existe, con otro método |
| `SLOT_TAKEN` | 409 | La cancha ya está reservada en ese horario |
| `ALREADY_CANCELLED` | 409 | La reserva ya estaba cancelada |
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index e338202..6996c6f 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -143,38 +143,17 @@ export function cancelBooking(deps: AppDependencies): Handler {
};
}
-// La v1 sigue respondiendo igual: solo se anuncia su fin (RFC 9745 y RFC 8594).
-export const MEMBER_BOOKINGS_V1_DEPRECATED_AT = 1798761600; // 1 de enero de 2027, 00:00 UTC
-export const MEMBER_BOOKINGS_V1_SUNSET = 'Sat, 01 Jan 2028 00:00:00 GMT';
-
function bookingsOfMember(deps: AppDependencies, id: string): Booking[] {
const member = deps.service.findMember(Number(id));
if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${id}.`);
return deps.service.bookingsForMember(member.id).map(toBookingJson);
}
-// Apagones anunciados: durante estas ventanas la v1 ya responde como responderá
-// después del fin. Así aparece quien no sabía que dependía de ella.
-export const MEMBER_BOOKINGS_V1_BROWNOUTS = [
- { from: '2027-09-01T15:00:00Z', to: '2027-09-01T16:00:00Z' },
- { from: '2027-10-05T15:00:00Z', to: '2027-10-05T21:00:00Z' },
- { from: '2027-11-02T13:00:00Z', to: '2027-11-03T13:00:00Z' },
-];
-
-export function isBrownout(instant: Date): boolean {
- return MEMBER_BOOKINGS_V1_BROWNOUTS.some(({ from, to }) => instant >= new Date(from) && instant < new Date(to));
-}
-
-// GET /api/members/:id/bookings: arreglo suelto, deprecado.
-export function memberBookings(deps: AppDependencies): Handler {
- return ({ res, params }) => {
- if (isBrownout(deps.clock.now())) {
- throw new HttpError(410, 'ENDPOINT_REMOVED', `Esta ruta deja de existir: usen /api/v2/members/${params.id}/bookings, que devuelve { bookings }.`);
- }
- res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
- res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
- res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
- sendJson(res, 200, bookingsOfMember(deps, params.id));
+// GET /api/members/:id/bookings ya no existe. Se responde 410 por un tiempo, para
+// que quien se haya quedado atrás lea qué hacer.
+export function memberBookingsRemoved(): Handler {
+ return ({ params }) => {
+ throw new HttpError(410, 'ENDPOINT_REMOVED', `Esta ruta ya no existe: usen /api/v2/members/${params.id}/bookings, que devuelve { bookings }.`);
};
}
diff --git a/src/http/server.ts b/src/http/server.ts
index ed30624..b39a724 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -10,7 +10,7 @@ import {
dailyReport,
listCourts,
listMembers,
- memberBookings,
+ memberBookingsRemoved,
memberBookingsV2,
type AppDependencies,
} from './handlers.ts';
@@ -30,7 +30,7 @@ export function buildRouter(deps: AppDependencies): Router {
router.get('/api/courts', listCourts(deps));
router.get('/api/courts/:id/availability', courtAvailability(deps));
router.get('/api/members', listMembers(deps));
- router.get('/api/members/:id/bookings', memberBookings(deps));
+ router.get('/api/members/:id/bookings', memberBookingsRemoved());
router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps));
router.post('/api/bookings', createBooking(deps));
router.post('/api/bookings/:id/cancel', cancelBooking(deps));
@@ -68,8 +68,6 @@ export function createServer(options: ServerOptions): http.Server {
}
} finally {
log(`${req.method} ${req.url} ${res.statusCode}`);
- // Para saber quién sigue usando lo deprecado. Sin el número de vecino: no hace falta.
- if (res.hasHeader('Deprecation')) log(`DEPRECATED /api/members/:id/bookings ua="${req.headers['user-agent'] ?? '?'}"`);
}
});
}
diff --git a/test/v2-migrado.test.ts b/test/v2-migrado.test.ts
index 13e6bbd..2fdb7b5 100644
--- a/test/v2-migrado.test.ts
+++ b/test/v2-migrado.test.ts
@@ -1,26 +1,9 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
-import { isBrownout } from '../src/http/handlers.ts';
-import { startTestServer } from './helpers.ts';
test('la página ya no usa la ruta deprecada', () => {
const page = readFileSync('public/app.js', 'utf8');
assert.doesNotMatch(page, /`\/api\/members\/[^`]*\/bookings`/);
assert.match(page, /\/api\/v2\/members\//);
});
-
-test('durante un apagón la v1 responde 410 con lo que hay que hacer; la v2 sigue', async () => {
- const server = await startTestServer({ now: '2027-09-01T15:30:00Z' });
- const old = await fetch(`${server.url}/api/members/2/bookings`);
- assert.equal(old.status, 410);
- assert.match(((await old.json()) as { error: { message: string } }).error.message, /\/api\/v2\/members\/2\/bookings/);
- assert.equal((await fetch(`${server.url}/api/v2/members/2/bookings`)).status, 200);
- await server.close();
-});
-
-test('un apagón empieza y termina a la hora anunciada', () => {
- assert.equal(isBrownout(new Date('2027-09-01T14:59:59Z')), false);
- assert.equal(isBrownout(new Date('2027-09-01T15:00:00Z')), true);
- assert.equal(isBrownout(new Date('2027-09-01T16:00:00Z')), false);
-});
diff --git a/test/v2.test.ts b/test/v2.test.ts
index 67946b3..9320e1a 100644
--- a/test/v2.test.ts
+++ b/test/v2.test.ts
@@ -3,7 +3,7 @@ import { after, before, describe, test } from 'node:test';
import { postJson, startTestServer, type TestServer } from './helpers.ts';
// El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00.
-describe('las reservas de un vecino, en v1 y en v2', () => {
+describe('las reservas de un vecino, solo en v2', () => {
let server: TestServer;
before(async () => {
@@ -15,24 +15,20 @@ describe('las reservas de un vecino, en v1 y en v2', () => {
await server.close();
});
- test('v1 responde lo mismo que antes y anuncia su fin', async () => {
+ test('la ruta vieja responde 410 y dice cuál usar', async () => {
const response = await fetch(`${server.url}/api/members/2/bookings`);
- const body = await response.json();
- assert.equal(response.status, 200);
- assert.ok(Array.isArray(body));
- assert.equal(response.headers.get('deprecation'), '@1798761600');
- assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT');
- assert.equal(response.headers.get('link'), '</api/v2/members/2/bookings>; rel="successor-version"');
+ const { error } = (await response.json()) as { error: { code: string; message: string } };
+ assert.equal(response.status, 410);
+ assert.equal(error.code, 'ENDPOINT_REMOVED');
+ assert.match(error.message, /\/api\/v2\/members\/2\/bookings/);
});
- test('v2 devuelve las mismas reservas dentro de un objeto y sin aviso', async () => {
- const v1 = await (await fetch(`${server.url}/api/members/2/bookings`)).json();
- const response = await fetch(`${server.url}/api/v2/members/2/bookings`);
- assert.deepEqual(await response.json(), { bookings: v1 });
- assert.equal(response.headers.get('deprecation'), null);
+ test('v2 devuelve un objeto con las reservas', async () => {
+ const body = (await (await fetch(`${server.url}/api/v2/members/2/bookings`)).json()) as { bookings: { teamName: string }[] };
+ assert.deepEqual(body.bookings.map((b) => b.teamName), ['Real Barranco']);
});
- test('los errores no cambian entre versiones', async () => {
+ test('los errores de v2 siguen siendo los mismos', async () => {
const response = await fetch(`${server.url}/api/v2/members/99/bookings`);
assert.equal(response.status, 404);
assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'MEMBER_NOT_FOUND');// Un cliente HTTP que no está en el repositorio: el panel de la liga de fulbito,
// escrito por otro equipo cuando la ruta devolvía un arreglo.
export type PanelBooking = { date: string; startHour: number; teamName: string; status: string };
export async function leaguePanel(baseUrl: string, memberId: number): Promise<string[]> {
const response = await fetch(`${baseUrl}/api/members/${memberId}/bookings`, {
headers: { 'user-agent': 'panel-liga/1.2' },
});
const bookings = (await response.json()) as PanelBooking[];
return bookings
.filter((b) => b.status === 'confirmed')
.map((b) => `${b.date} ${b.startHour}:00 ${b.teamName}`);
}// Recorre las etapas del parallel change: qué ve el cliente viejo en cada una y
// cómo se mide quién sigue usando la ruta deprecada.
import { leaguePanel } from './cliente-panel.ts';
import { startStage } from './stage-server.ts';
for (const stage of ['1', '2', '3']) {
const server = await startStage(stage);
console.log(`\n=== etapa ${stage}`);
const old = await fetch(`${server.url}/api/members/5/bookings`);
console.log(`GET /api/members/5/bookings → ${old.status}`);
for (const name of ['deprecation', 'sunset', 'link']) {
if (old.headers.has(name)) console.log(` ${name}: ${old.headers.get(name)}`);
}
const v2 = await fetch(`${server.url}/api/v2/members/5/bookings`);
const body = (await v2.json()) as { bookings: unknown[] };
console.log(`GET /api/v2/members/5/bookings → ${v2.status}, ${body.bookings.length} reservas`);
try {
console.log('panel de la liga:', await leaguePanel(server.url, 5));
} catch (error) {
console.log('panel de la liga:', String(error));
}
await server.stop();
}
// La medición de la etapa 1: tres clientes que no se conocen entre sí.
const server = await startStage('1');
for (const agent of ['panel-liga/1.2', 'panel-liga/1.2', 'panel-liga/1.2', 'Mozilla/5.0', 'Mozilla/5.0', 'script-contador']) {
await fetch(`${server.url}/api/members/5/bookings`, { headers: { 'user-agent': agent } });
}
await fetch(`${server.url}/api/v2/members/5/bookings`, { headers: { 'user-agent': 'Mozilla/5.0' } });
await server.stop();
const counts = new Map<string, number>();
for (const line of server.logs.filter((l) => l.startsWith('DEPRECATED'))) counts.set(line, (counts.get(line) ?? 0) + 1);
console.log('\n=== quién sigue usando la ruta vieja (etapa 1)');
for (const [line, count] of [...counts].sort((a, b) => b[1] - a[1])) console.log(`${String(count).padStart(4)} ${line}`);// Levanta la Kallpa de una etapa (build/etapas/N), con los datos de ejemplo, un
// reloj fijo y el log en memoria.
import type { Server } from 'node:http';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { close, listen, MONDAY_10AM, TODAY } from '../seeded.ts';
export type StageServer = { url: string; logs: string[]; stop(): Promise<void> };
type ServerModule = { createServer(options: unknown): Server };
type DatabaseModule = { openDatabase(path: string): { close(): void } };
type SeedModule = { seedDatabase(db: unknown, today: string): void };
type ClockModule = { fixedClock(instant: string): unknown };
const load = <T>(stage: string, file: string) =>
import(pathToFileURL(join(process.cwd(), 'build/etapas', stage, 'src', file)).href) as Promise<T>;
export async function startStage(stage: string, now = MONDAY_10AM): Promise<StageServer> {
const { createServer } = await load<ServerModule>(stage, 'http/server.ts');
const { openDatabase } = await load<DatabaseModule>(stage, 'store/database.ts');
const { seedDatabase } = await load<SeedModule>(stage, 'store/seed.ts');
const { fixedClock } = await load<ClockModule>(stage, 'domain/clock.ts');
const db = openDatabase(':memory:');
seedDatabase(db, TODAY);
const logs: string[] = [];
const server = createServer({ db, clock: fixedClock(now), notifier: { bookingConfirmed: () => {} }, log: (line: string) => logs.push(line) });
const url = await listen(server);
return {
url,
logs,
stop: async () => {
await close(server);
db.close();
},
};
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// La caja del día de Kallpa, tal como responde hoy. No dice que esté bien: dice
// que un cambio que no quería cambiar nada no cambió nada.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { dailySummarySql } from './caja/report-sql.ts';
import { close, dailyReports, listen, SAMPLE_DAYS, seededDatabase, seededServer } from './seeded.ts';
import { dailySummarySql as agentSql } from './caja/report-sql-agente.ts';
type Report = { date: string; bookings: number; revenueCents: number; courts: { courtName: string; revenueCents: number }[] };
const golden = JSON.parse(readFileSync('golden/caja.json', 'utf8')) as Record<string, Report>;
test('la caja responde hoy lo mismo que quedó guardado', async () => {
const db = seededDatabase();
const server = seededServer(db);
assert.deepEqual(await dailyReports(await listen(server)), golden);
await close(server);
db.close();
});
test('los días de ejemplo cubren reservas, una cancelación y un día vacío', () => {
assert.deepEqual(Object.keys(golden), SAMPLE_DAYS);
assert.equal(golden['2026-03-20']?.bookings, 0);
assert.equal(golden['2026-03-20']?.courts.length, 4);
});
test('la versión en SQL responde lo mismo que dailySummary en todos los días', () => {
const db = seededDatabase();
for (const day of SAMPLE_DAYS) assert.deepEqual(dailySummarySql(db, day), golden[day], day);
db.close();
});
test('la versión del agente se ve igual en días con todo lleno, pero no lo es', () => {
const db = seededDatabase();
assert.notDeepEqual(agentSql(db, '2026-03-20'), golden['2026-03-20']);
assert.equal(agentSql(db, '2026-03-20').courts.length, 0);
assert.deepEqual(agentSql(db, '2026-03-09').courts.map((c) => c.courtName), ['Fulbito 1', 'Fulbito 2', 'Básquet']);
assert.equal(agentSql(db, '2026-03-09').revenueCents, golden['2026-03-09']?.revenueCents);
db.close();
});
test('lo que queda fijado incluye un error conocido: dos horas que cruzan las 18:00', () => {
// Miércoles: el README de Kallpa dice S/ 60 + S/ 75 = S/ 135. La Kallpa publicada
// cobra S/ 120. La caracterización lo fija; el arreglo va en otro cambio.
const basquet = golden['2026-03-11']?.courts.find((c) => c.courtName === 'Básquet');
assert.equal(basquet?.revenueCents, 12000);
});// El cierre de caja heredado: lo que hace hoy, antes y después de la costura.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { test } from 'node:test';
import { closingReport } from './heredado/cierre-con-costura.ts';
import { bookingsByPlan, closingReportByPlan } from './heredado/cierre-por-plan.ts';
import { SAMPLE_DAYS, seededDatabase } from './seeded.ts';
const golden = (day: string) => readFileSync(`golden/cierre-${day}.txt`, 'utf8');
const NIGHT = new Date('2026-03-10T01:00:00Z'); // lunes 9 de marzo, 20:00 en Lima
const MORNING = new Date('2026-03-09T15:00:00Z'); // lunes 9 de marzo, 10:00 en Lima
function runOriginal(script: string, dbPath: string, day: string): string {
const result = spawnSync('node', [script, day], { encoding: 'utf8', env: { ...process.env, KALLPA_DB: dbPath } });
assert.equal(result.status, 0, result.stderr);
return result.stdout;
}
test('el script original, sin tocarlo, imprime lo que quedó guardado', () => {
mkdirSync('build/cierre', { recursive: true });
const path = 'build/cierre/kallpa.db';
rmSync(path, { force: true });
seededDatabase(path).close();
for (const day of SAMPLE_DAYS) {
assert.equal(runOriginal('heredado/cierre-de-caja.ts', path, day), golden(day), day);
assert.equal(runOriginal('heredado/cierre-con-costura.ts', path, day), golden(day), day);
}
});
test('con la costura, la función devuelve lo mismo que imprimía el script', () => {
const db = seededDatabase();
for (const day of SAMPLE_DAYS) assert.equal(closingReport(db, MORNING, day), golden(day), day);
});
test('caracterización: sin fecha, de mañana usa el día de Lima', () => {
const report = closingReport(seededDatabase(), MORNING);
assert.match(report, /^CIERRE DE CAJA 2026-03-09\n/);
});
test('caracterización: sin fecha, desde las 19:00 usa el día siguiente (posible bug)', () => {
const report = closingReport(seededDatabase(), NIGHT);
assert.match(report, /^CIERRE DE CAJA 2026-03-10\n/);
});
test('caracterización: a los socios les quita 10 % otra vez (posible bug)', () => {
// Luis es socio. Su reserva ya se guardó con descuento: 22 500 × 0.9 = 20 250.
const db = seededDatabase();
const row = db.prepare("SELECT price_cents FROM bookings WHERE team_name = 'Real Barranco' AND date = '2026-03-09'").get() as { price_cents: number };
assert.equal(row.price_cents, 20250);
assert.match(closingReport(db, MORNING, '2026-03-09'), /Real Barranco\s+S\/ 182\.25/);
});
test('caracterización: el aviso de depósito sale con más de S/ 500', () => {
assert.match(closingReport(seededDatabase(), MORNING, '2026-03-09'), /TOTAL S\/ 467\.25\n$/);
const db = seededDatabase();
assert.doesNotMatch(closingReport(db, MORNING, '2026-03-09'), /Depositar/);
db.prepare(
`INSERT INTO bookings (court_id, member_id, date, start_hour, hours, team_name, price_cents, created_at)
VALUES (3, 1, '2026-03-09', 8, 2, 'Las Matadoras', 10000, '2026-03-08T14:00:00.000Z')`,
).run();
assert.match(closingReport(db, MORNING, '2026-03-09'), /TOTAL S\/ 567\.25\nDepositar en el banco\n$/);
});
test('el cierre y la caja de Kallpa difieren en S/ 20.25, 10 % de la reserva del socio', () => {
const caja = JSON.parse(readFileSync('golden/caja.json', 'utf8')) as Record<string, { revenueCents: number }>;
assert.equal(caja['2026-03-09']?.revenueCents, 48750);
assert.match(golden('2026-03-09'), /TOTAL S\/ 467\.25/);
});
test('el cambio agrega una línea y deja igual todo lo anterior', () => {
const db = seededDatabase();
const report = closingReportByPlan(db, MORNING, '2026-03-09');
assert.equal(report, `${golden('2026-03-09')}RESERVAS regular: 2, socio: 1\n`);
});
test('el conteo por plan no cuenta canceladas y un día vacío no tiene filas', () => {
const db = seededDatabase();
assert.deepEqual(bookingsByPlan(db, '2026-03-20'), []);
assert.deepEqual(bookingsByPlan(db, '2026-03-10'), [{ plan: 'regular', bookings: 2 }]);
assert.match(closingReportByPlan(db, MORNING, '2026-03-20'), /RESERVAS ninguna\n$/);
});// Los archivos de demos/ que fallan a propósito, con el fallo que muestra el apunte.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
import { cleanEnv } from './clean-env.ts';
function run(...args: string[]): { status: number | null; output: string } {
const result = spawnSync('node', args, { encoding: 'utf8', env: cleanEnv() });
return { status: result.status, output: result.stdout + result.stderr };
}
test('la primera caracterización falla y su mensaje dice qué hace el código', () => {
const { status, output } = run('--test', 'demos/primera-caracterizacion.check.ts');
assert.equal(status, 1);
assert.match(output, /Deportivo Rímac {12}S\/ 91\.13/);
assert.match(output, /TOTAL S\/ 192\.38/);
});
test('la caracterización de la caja rechaza la versión del agente, por dos motivos', () => {
const { status, output } = run('--test', 'demos/caja-agente.check.ts');
assert.equal(status, 1);
assert.match(output, /\+ {3}courts: \[\],/);
assert.match(output, /ℹ fail 2/);
});
test('las migraciones hacen lo que dice el apunte', () => {
const { status, output } = run('demos/migraciones.ts');
assert.equal(status, 0);
assert.match(output, /RENAME COLUMN: el código anterior falla: no such column: phone/);
assert.match(output, /ADD COLUMN: el código anterior lista 10 vecinos/);
assert.match(output, /DROP COLUMN: el código anterior falla: no such column: phone/);
});
test('el ensayo de la defensa: la laptop pasa, el clon falla, y el cambio en vivo queda verde', () => {
const result = spawnSync('bash', ['ensayo/ensayo.sh'], { encoding: 'utf8', env: cleanEnv() });
assert.equal(result.status, 0, result.stdout + result.stderr);
const [laptop, clone, fixed, live] = result.stdout.split(/^=== /m).filter((part) => /^(En la laptop|En un clon|Después|El cambio)/.test(part));
assert.match(laptop ?? '', /ℹ fail 0/);
assert.match(clone ?? '', /TS2307/);
assert.match(fixed ?? '', /ℹ fail 0/);
assert.match(live ?? '', /ℹ pass 41/);
});// El parallel change sobre Kallpa: cada etapa pasa make check, y el cliente viejo
// sigue funcionando hasta que se contrae.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { leaguePanel } from './parallel/cliente-panel.ts';
import { startStage } from './parallel/stage-server.ts';
const summary = (name: string) => {
const log = readFileSync(`build/logs/${name}.log`, 'utf8');
return { pass: Number(/^ℹ pass (\d+)$/m.exec(log)?.[1]), fail: Number(/^ℹ fail (\d+)$/m.exec(log)?.[1]) };
};
test('las tres etapas pasan make check, cada una por su cuenta', () => {
assert.deepEqual(summary('etapa-1'), { pass: 43, fail: 0 });
assert.deepEqual(summary('etapa-2'), { pass: 46, fail: 0 });
assert.deepEqual(summary('etapa-3'), { pass: 44, fail: 0 });
});
test('etapa 1: la ruta vieja no cambia de forma, anuncia su fin y queda en el log', async () => {
const server = await startStage('1');
const response = await fetch(`${server.url}/api/members/5/bookings`, { headers: { 'user-agent': 'panel-liga/1.2' } });
assert.equal(response.status, 200);
assert.ok(Array.isArray(await response.json()));
assert.equal(response.headers.get('deprecation'), '@1798761600');
assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT');
assert.ok(server.logs.includes('DEPRECATED /api/members/:id/bookings ua="panel-liga/1.2"'));
assert.deepEqual(await leaguePanel(server.url, 5), ['2026-03-10 9:00 Las Matadoras']);
await server.stop();
});
test('el fin no es anterior a la deprecación, como pide RFC 9745', () => {
assert.ok(Date.parse('Sat, 01 Jan 2028 00:00:00 GMT') >= 1798761600 * 1000);
});
test('etapa 2: en un apagón el cliente viejo falla, y fuera de él sigue funcionando', async () => {
const normal = await startStage('2');
assert.deepEqual(await leaguePanel(normal.url, 5), ['2026-03-10 9:00 Las Matadoras']);
await normal.stop();
const brownout = await startStage('2', '2027-09-01T15:30:00Z');
await assert.rejects(leaguePanel(brownout.url, 5), /bookings\.filter is not a function/);
await brownout.stop();
});
test('etapa 3: la ruta vieja responde 410 y el cliente viejo se rompe', async () => {
const server = await startStage('3');
const response = await fetch(`${server.url}/api/members/5/bookings`);
assert.equal(response.status, 410);
assert.equal(response.headers.get('deprecation'), null);
await assert.rejects(leaguePanel(server.url, 5), /bookings\.filter is not a function/);
await server.stop();
});
test('contraer sin migrar la página: make check pasa igual y la página sigue en la ruta vieja', () => {
assert.deepEqual(summary('apurados'), { pass: 43, fail: 0 });
assert.match(readFileSync('build/etapas/apurados/public/app.js', 'utf8'), /const bookings = await api\(`\/api\/members\//);
});
test('la caja en SQL del agente también pasa make check en Kallpa', () => {
assert.deepEqual(summary('caja-agente'), { pass: 40, fail: 0 });
});// La fachada: quien llama no se entera, y las diferencias aparecen en el log.
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import { test } from 'node:test';
import { dailySummarySql as agentVersion } from './caja/report-sql-agente.ts';
import { createReportService, type Summarize } from './caja/report-service.ts';
import { dailySummarySql } from './caja/report-sql.ts';
import { createFacade, modeFor, type Mode } from './facade.ts';
import { close, listen, seededDatabase, seededServer } from './seeded.ts';
async function scenario(mode: Mode, summarize: Summarize, run: (url: string, logs: string[]) => Promise<void>) {
const db = seededDatabase();
const servers: Server[] = [];
const start = async (server: Server) => {
servers.push(server);
return listen(server);
};
const legacyUrl = await start(seededServer(db));
const replacementUrl = await start(createReportService(db, summarize));
const logs: string[] = [];
const url = await start(createFacade({ legacyUrl, replacementUrl, routes: [{ prefix: '/api/reports/', mode }], log: (l) => logs.push(l) }));
await run(url, logs);
for (const server of servers) await close(server);
db.close();
}
const until = async (done: () => boolean) => {
for (let i = 0; i < 100 && !done(); i++) await new Promise((resolve) => setTimeout(resolve, 5));
};
test('lo que no tiene ruta va al sistema viejo', () => {
assert.equal(modeFor('/api/courts', []), 'legacy');
assert.equal(modeFor('/api/reports/daily?date=2026-03-09', [{ prefix: '/api/reports/', mode: 'shadow' }]), 'shadow');
});
test('en shadow responde el viejo, aunque el nuevo tenga errores, y queda la diferencia', async () => {
await scenario('shadow', agentVersion, async (url, logs) => {
const body = (await (await fetch(`${url}/api/reports/daily?date=2026-03-20`)).json()) as { courts: unknown[] };
assert.equal(body.courts.length, 4);
await until(() => logs.length > 0);
assert.deepEqual(logs, ['SHADOW-DIFF /api/reports/daily?date=2026-03-20']);
});
});
test('en shadow, con la versión correcta, no hay diferencias', async () => {
await scenario('shadow', dailySummarySql, async (url, logs) => {
for (const day of ['2026-03-09', '2026-03-10', '2026-03-20']) await fetch(`${url}/api/reports/daily?date=${day}`);
await until(() => logs.length === 3);
assert.ok(logs.every((line) => line.startsWith('SHADOW-OK')), logs.join('\n'));
});
});
test('un error de validación también se compara', async () => {
await scenario('shadow', dailySummarySql, async (url, logs) => {
assert.equal((await fetch(`${url}/api/reports/daily`)).status, 400);
await until(() => logs.length > 0);
assert.deepEqual(logs, ['SHADOW-OK /api/reports/daily']);
});
});
test('una escritura no se manda dos veces', async () => {
const db = seededDatabase();
const legacy = seededServer(db);
const legacyUrl = await listen(legacy);
let replacementCalls = 0;
const replacement = createReportService(db, (d, date) => {
replacementCalls++;
return dailySummarySql(d, date);
});
const replacementUrl = await listen(replacement);
const facade = createFacade({ legacyUrl, replacementUrl, routes: [{ prefix: '/api/', mode: 'shadow' }], log: () => {} });
const url = await listen(facade);
const before = (db.prepare('SELECT COUNT(*) AS n FROM bookings').get() as { n: number }).n;
const response = await fetch(`${url}/api/bookings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courtId: 3, memberId: 1, date: '2026-03-12', startHour: 8, teamName: 'Las Matadoras' }),
});
assert.equal(response.status, 200);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal((db.prepare('SELECT COUNT(*) AS n FROM bookings').get() as { n: number }).n, before + 1);
assert.equal(replacementCalls, 0);
for (const server of [facade, replacement, legacy]) await close(server);
db.close();
});
test('en replacement atiende el nuevo, y el resto sigue en el viejo', async () => {
await scenario('replacement', agentVersion, async (url) => {
const report = (await (await fetch(`${url}/api/reports/daily?date=2026-03-20`)).json()) as { courts: unknown[] };
assert.equal(report.courts.length, 0);
const courts = (await (await fetch(`${url}/api/courts`)).json()) as { courts: unknown[] };
assert.equal(courts.courts.length, 4);
});
});// Prevenir el retroceso: ESLint avisa en el uso nuevo y no en el migrado.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
function lint(config: string, file: string): { ruleId: string; line: number; message: string }[] {
const result = spawnSync('eslint', ['--no-config-lookup', '-c', config, '-f', 'json', file], { encoding: 'utf8' });
const [report] = JSON.parse(result.stdout) as { messages: { ruleId: string; line: number; message: string }[] }[];
return report?.messages ?? [];
}
test('la regla avisa en la ruta vieja, en la línea 3, y no en la nueva', () => {
const messages = lint('eslint.retroceso.js', 'ejemplos/pagina-nueva.js');
assert.deepEqual(messages.map((m) => [m.ruleId, m.line]), [['no-restricted-syntax', 3]]);
assert.match(messages[0]?.message ?? '', /\/api\/v2\/members/);
});
test('no-deprecated avisa donde se usa lo marcado con @deprecated', () => {
const messages = lint('eslint.deprecado.js', 'ejemplos/deprecado.ts');
assert.deepEqual(messages.map((m) => [m.ruleId, m.line]), [['@typescript-eslint/no-deprecated', 14]]);
});
test('tsc no se queja de @deprecated: el archivo compila', () => {
const result = spawnSync('tsc', ['-p', '.'], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stdout);
});// El rollback solo es posible si la migración es compatible con el código anterior.
import assert from 'node:assert/strict';
import { mkdirSync, rmSync } from 'node:fs';
import { test } from 'node:test';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { MemberRepository } from '../../kallpa/src/store/member-repository.ts';
import { CONTRACT_PHONE, EXPAND_WHATSAPP, findMemberNew, insertMemberNew, RENAME_PHONE } from './migraciones.ts';
import { seededDatabase } from './seeded.ts';
test('después de un RENAME COLUMN, el código anterior ya no puede leer a los vecinos', () => {
const db = seededDatabase();
db.exec(RENAME_PHONE);
assert.throws(() => new MemberRepository(db).list(), /no such column: phone/);
});
test('después de agregar la columna, el código anterior sigue leyendo y escribiendo', () => {
const db = seededDatabase();
db.exec(EXPAND_WHATSAPP);
const repository = new MemberRepository(db);
assert.equal(repository.list().length, 10);
const id = repository.insert({ name: 'Vecina', phone: '900000001', plan: 'regular' });
assert.equal(repository.findById(id)?.phone, '900000001');
});
test('el código anterior abre una base de datos con una migración que no conoce', () => {
mkdirSync('build/migraciones', { recursive: true });
const path = 'build/migraciones/kallpa.db';
rmSync(path, { force: true });
const db = seededDatabase(path);
db.exec(EXPAND_WHATSAPP);
db.close();
const reopened = openDatabase(path);
assert.deepEqual({ ...(reopened.prepare('PRAGMA user_version').get() as object) }, { user_version: 3 });
assert.equal(new MemberRepository(reopened).list().length, 10);
reopened.close();
});
test('el código nuevo lee bien las filas que escribió el anterior', () => {
const db = seededDatabase();
db.exec(EXPAND_WHATSAPP);
const older = new MemberRepository(db).insert({ name: 'Antes', phone: '900000001', plan: 'regular' });
const newer = insertMemberNew(db, 'Después', '900000002');
assert.equal(findMemberNew(db, older)?.whatsapp, '900000001');
assert.equal(findMemberNew(db, newer)?.whatsapp, '900000002');
assert.equal(findMemberNew(db, 1)?.whatsapp, '987654321');
});
test('contraer cierra la puerta: después de quitar phone ya no se puede volver', () => {
const db = seededDatabase();
db.exec(EXPAND_WHATSAPP);
db.exec('UPDATE members SET whatsapp = phone WHERE whatsapp IS NULL');
db.exec(CONTRACT_PHONE);
assert.throws(() => new MemberRepository(db).list(), /no such column: phone/);
});// Lo que el apunte dice del cambio mecánico, comprobado.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
import { cleanEnv } from './clean-env.ts';
test('un renombre mecánico puede pasar los tipos y romper el contrato JSON', () => {
const result = spawnSync('node', ['demos/renombrar.ts'], { encoding: 'utf8', env: cleanEnv() });
assert.equal(result.status, 0, result.stderr);
const [withSed, withTypes] = result.stdout.split('=== 2.');
assert.match(withSed ?? '', /tsc: sin errores/);
assert.match(withSed ?? '', /quedan con priceCents: ninguno/);
assert.match(withTypes ?? '', /tsc: sin errores/);
assert.match(withTypes ?? '', /quedan con priceCents: .*public\/app\.js/);
assert.match(withTypes ?? '', /test\/http\.test\.ts/);
assert.match(withSed ?? '', /40 pasan, 0 fallan/);
assert.match(withTypes ?? '', /39 pasan, 1 fallan/);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 16: mantenimiento, código heredado, deuda técnica y deprecación. Se usa
# dentro del contenedor del curso.
#
# make aplica las tres etapas del parallel change, y el cambio de
# la caja en SQL, a copias de Kallpa en build/, y corre su
# make check; la salida queda en build/logs/
# make test revisa los tipos (tsc), corre ESLint, prepara lo anterior y
# corre las pruebas (*.test.ts), que comprueban lo que dice el
# apunte
# make golden vuelve a guardar lo que responde la caja de Kallpa en
# golden/caja.json
# make primera la primera prueba de caracterización del cierre, que falla
# a propósito
# make caja-agente la caracterización contra la caja en SQL escrita para la
# clase, que falla a propósito
# make etapas cada etapa del parallel change, con cliente viejo y medición
# make apurados contraer sin migrar: make check en verde y la página rota
# make migraciones una migración incompatible y una compatible, con el código
# anterior corriendo encima
# make facade el strangler fig: la fachada delante de la Kallpa vieja
# make renombrar renombra priceCents en una copia con el servicio de TypeScript
# make retroceso ESLint impide agregar usos nuevos de lo deprecado
# make ensayo ensayo de la defensa: clon limpio y un cambio en vivo
# make clean borra build/
#
# Los ejemplos leen la Kallpa del curso (../../kallpa) sin modificarla.
KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main
HERE = $(CURDIR)
export KALLPA
.PHONY: all stages test golden primera caja-agente etapas apurados migraciones facade renombrar retroceso ensayo clean
all: stages
# Cada etapa parte de la anterior. Un commit por etapa, como se integraría.
stages:
rm -rf build/etapas build/logs build/caja-agente
mkdir -p build/etapas build/logs
cp -r $(KALLPA) build/etapas/0
rm -f build/etapas/0/data/*.db build/etapas/0/data/*.db-*
cd build/etapas/0 && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
cp -r build/etapas/0 build/etapas/1
cd build/etapas/1 && $(GIT) apply $(HERE)/parallel/1-expandir.diff && $(GIT) add -A && $(GIT) commit -qm "Expandir"
cp -r build/etapas/1 build/etapas/2
cd build/etapas/2 && $(GIT) apply $(HERE)/parallel/2-migrar.diff && $(GIT) add -A && $(GIT) commit -qm "Migrar"
cp -r build/etapas/2 build/etapas/3
cd build/etapas/3 && $(GIT) apply $(HERE)/parallel/3-contraer.diff && $(GIT) add -A && $(GIT) commit -qm "Contraer"
cp -r build/etapas/3 build/etapas/apurados
cd build/etapas/apurados && $(GIT) checkout -q HEAD~3 -- public/app.js && rm -f test/v2-migrado.test.ts
cp -r build/etapas/0 build/caja-agente
cd build/caja-agente && $(GIT) apply $(HERE)/caja/agente.diff
$(MAKE) --no-print-directory -C build/etapas/1 check > build/logs/etapa-1.log 2>&1
$(MAKE) --no-print-directory -C build/etapas/2 check > build/logs/etapa-2.log 2>&1
$(MAKE) --no-print-directory -C build/etapas/3 check > build/logs/etapa-3.log 2>&1
$(MAKE) --no-print-directory -C build/etapas/apurados check > build/logs/apurados.log 2>&1
$(MAKE) --no-print-directory -C build/caja-agente check > build/logs/caja-agente.log 2>&1
test: all
tsc -p .
eslint .
node --test "*.test.ts"
golden:
node caja/fijar-caja.ts
primera:
-node --test demos/primera-caracterizacion.check.ts
caja-agente:
-node --test demos/caja-agente.check.ts
etapas: all
@for n in 1 2 3; do echo "--- etapa $$n"; tail -8 build/logs/etapa-$$n.log | grep -E '^ℹ (tests|pass|fail)'; done
node parallel/recorrido.ts
apurados: all
@tail -8 build/logs/apurados.log | grep -E '^ℹ (tests|pass|fail)'
@cd build/etapas/apurados && grep -n 'api(`/api/members' public/app.js
migraciones:
node demos/migraciones.ts
facade:
node demos/facade.ts
renombrar:
node demos/renombrar.ts
retroceso:
-eslint --no-config-lookup -c eslint.retroceso.js ejemplos/pagina-nueva.js
ensayo:
bash ensayo/ensayo.sh
clean:
rm -rf build{
"name": "week-16",
"private": true,
"type": "module"
}{
"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", "heredado", "caja", "demos", "parallel", "ejemplos", "ensayo"],
"exclude": ["build"]
}