Un migrador propio: drizzle-kit dejaba de aplicar sin decirlo

Su troceador de SQL no entiende los cuerpos `$$` de plpgsql, y este
esquema tiene dos funciones —la que comprueba que el reparto de autoría
suma cien y la que impide ciclos en el linaje de géneros—. A partir de
esa migración deja de aplicar nada y termina con éxito.

Se descubrió porque `cancion.nota` seguía existiendo dos migraciones
después de borrarla. Es decir: durante un rato la base y el esquema
dijeron cosas distintas y nada avisó.

`scripts/db/migrar.mjs` trocea por el mismo separador que escribe
Drizzle, aplica en orden, anota en su misma tabla y con su mismo hash, y
cuando algo falla dice en qué sentencia y deshace.

De paso:

- 0004 rehace `cancion_principal` alrededor del `DROP COLUMN`. Un
  `CREATE VIEW ... SELECT *` expande las columnas al crearse, así que la
  vista impedía borrar la columna.
- 0005 le da procedencia a `persona`. Las figuras de un género las
  escribe un modelo igual que las fichas, con fechas que hay que
  verificar; sin esto no se distingue a quien firma una canción del
  catálogo de una semblanza importada sin revisar.
- `marked`, para componer en el servidor el Markdown que ahora vive en
  una columna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent be28a29693
commit 8272f3c195

@ -1 +1,18 @@
ALTER TABLE "cancion" DROP COLUMN "nota"; -- La columna `nota` sobraba: nació de leer al revés los archivos de canción.
-- El cuerpo del Markdown NO es un comentario, es la letra; el campo `letra:`
-- del frontmatter —que aparece en un solo archivo— son los créditos de quién
-- la firma, y esos van a `cancion_persona`.
-- La vista se rehace alrededor del cambio.
--
-- `CREATE VIEW ... SELECT *` expande las columnas al crearse, así que la vista
-- queda dependiendo de cada una: ni recoge las que se añadan después ni deja
-- borrar las que hay. Lo peor de las dos opciones. Mientras siga siendo `*`,
-- toda migración que toque las columnas de `cancion` tiene que quitarla y
-- volver a ponerla, como aquí.
DROP VIEW "cancion_principal";
--> statement-breakpoint
ALTER TABLE "cancion" DROP COLUMN "nota";
--> statement-breakpoint
CREATE VIEW "cancion_principal" AS
SELECT * FROM "cancion" WHERE "version_de_id" IS NULL;

@ -0,0 +1,5 @@
ALTER TABLE "persona" ADD COLUMN "origen" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "persona" ADD COLUMN "modelo_origen" text;--> statement-breakpoint
ALTER TABLE "persona" ADD COLUMN "importado_en" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "persona" ADD COLUMN "revisado_por" text;--> statement-breakpoint
ALTER TABLE "persona" ADD COLUMN "revisado_en" timestamp with time zone;

File diff suppressed because it is too large Load Diff

@ -36,6 +36,13 @@
"when": 1788879010816, "when": 1788879010816,
"tag": "0004_letra_es_el_cuerpo", "tag": "0004_letra_es_el_cuerpo",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788882211391,
"tag": "0005_procedencia_en_persona",
"breakpoints": true
} }
] ]
} }

13
package-lock.json generated

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"marked": "^18.0.12",
"pg": "^8.23.0", "pg": "^8.23.0",
"stripe": "^22.6.1" "stripe": "^22.6.1"
}, },
@ -4571,6 +4572,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz",
"integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdast-util-find-and-replace": { "node_modules/mdast-util-find-and-replace": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",

@ -17,15 +17,15 @@
"test:e2e": "playwright install && node scripts/construir.mjs && playwright test", "test:e2e": "playwright install && node scripts/construir.mjs && playwright test",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "node --env-file=.env scripts/db/migrar.mjs",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"medios": "node scripts/medios.mjs", "medios": "node scripts/medios.mjs",
"predev": "node scripts/medios.mjs", "predev": "node scripts/medios.mjs",
"prebuild": "node scripts/medios.mjs", "prebuild": "node scripts/medios.mjs",
"db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs", "db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs",
"db:probar": "node --env-file=.env scripts/db/probar-migracion.mjs",
"db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs", "db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs",
"db:medios": "node --env-file=.env scripts/db/volcar-medios.mjs" "db:medios": "node --env-file=.env scripts/db/volcar-medios.mjs",
"db:generos": "node --env-file=.env scripts/db/sembrar-generos.mjs"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@ -58,6 +58,7 @@
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"marked": "^18.0.12",
"pg": "^8.23.0", "pg": "^8.23.0",
"stripe": "^22.6.1" "stripe": "^22.6.1"
} }

@ -0,0 +1,111 @@
/**
* Aplica las migraciones pendientes.
*
* npm run db:migrate # aplica lo que falte
* npm run db:migrate -- --ver # solo dice qué falta
*
* ## Por qué no `drizzle-kit migrate`
*
* Porque miente. Su troceador de SQL no entiende los cuerpos `$$` de las
* funciones en plpgsql —y este esquema tiene dos, la que comprueba que el
* reparto de autoría suma cien y la que impide ciclos en el linaje de
* géneros—, así que a partir de esa migración deja de aplicar nada **y termina
* sin error**. Se descubrió porque una columna que se había borrado en una
* migración seguía existiendo dos migraciones después.
*
* Esto trocea por el mismo separador que escribe Drizzle, aplica en orden,
* anota en su misma tabla y con su mismo hash —el SHA-256 del archivo—, y si
* algo falla dice **en qué sentencia** y deshace.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const CARPETA = 'drizzle';
const SOLO_VER = process.argv.includes('--ver');
/** Trocea por el separador de Drizzle y descarta lo que solo son comentarios. */
function sentenciasDe(sql) {
return (
sql
.split('--> statement-breakpoint')
.map((s) => s.trim())
// Línea a línea y sin expresión regular: `(--[^\n]*\n?)+` tarda catorce
// segundos en un comentario de quinientos caracteres, porque cuando no
// encaja puede repartir el mismo texto de exponencialmente muchas formas.
.filter((s) => s && s.split('\n').some((l) => l.trim() && !l.trim().startsWith('--')))
);
}
const archivos = fs
.readdirSync(CARPETA)
.filter((n) => n.endsWith('.sql'))
.sort();
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 120_000 });
await c.connect();
await c.query('CREATE SCHEMA IF NOT EXISTS drizzle');
await c.query(`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)`);
const { rows: hechas } = await c.query('SELECT hash FROM drizzle.__drizzle_migrations');
const yaEstan = new Set(hechas.map((f) => f.hash));
const pendientes = archivos
.map((nombre) => {
const sql = fs.readFileSync(path.join(CARPETA, nombre), 'utf8');
return { nombre, sql, hash: createHash('sha256').update(sql).digest('hex') };
})
.filter((m) => !yaEstan.has(m.hash));
if (pendientes.length === 0) {
console.log(`Al día: ${archivos.length} migraciones, ninguna pendiente.`);
await c.end();
process.exit(0);
}
console.log(`Pendientes ${pendientes.length} de ${archivos.length}:`);
for (const m of pendientes) console.log(` ${m.nombre}`);
if (SOLO_VER) {
console.log('\n(solo mirando: no se ha tocado nada)');
await c.end();
process.exit(0);
}
for (const m of pendientes) {
const sentencias = sentenciasDe(m.sql);
process.stdout.write(`\n${m.nombre} · ${sentencias.length} sentencias `);
await c.query('BEGIN');
try {
for (const [i, sentencia] of sentencias.entries()) {
try {
await c.query(sentencia);
} catch (fallo) {
const primera =
sentencia.split('\n').find((l) => l.trim() && !l.trim().startsWith('--')) ?? '';
console.log(`\n\n Falla la sentencia ${i + 1}:\n ${primera.trim().slice(0, 90)}`);
console.log(` → ${fallo.message}`);
throw fallo;
}
}
await c.query('INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)', [
m.hash,
String(Date.now())
]);
await c.query('COMMIT');
console.log('· aplicada');
} catch {
await c.query('ROLLBACK').catch(() => {});
console.log('\nSe deshizo. Las anteriores siguen aplicadas.');
await c.end();
process.exit(1);
}
}
console.log('\nAl día.');
await c.end();

@ -1,74 +0,0 @@
/**
* Ejecuta la migración en una transacción y la deshace.
*
* `drizzle-kit migrate` se queda colgado sin decir por qué, así que hay que
* preguntarle directamente a Postgres.
*
* El progreso se escribe a disco de forma SÍNCRONA. La salida normal de Node
* se acumula en un buffer cuando va a un pipe o a un archivo, y si el proceso
* se mata por tiempo ese buffer se pierde entero: es justo lo que hacía que
* pareciera que no llegaba a arrancar.
*/
import fs from 'node:fs';
import pg from 'pg';
const ARCHIVO = 'drizzle/0002_catalogo_en_la_base.sql';
const DIARIO = '.diario-migracion.txt';
const APLICAR = process.argv.includes('--aplicar');
fs.writeFileSync(DIARIO, '');
const apuntar = (linea) => fs.appendFileSync(DIARIO, linea + '\n');
const sql = fs.readFileSync(ARCHIVO, 'utf8');
const sentencias = sql
.split('--> statement-breakpoint')
.map((s) => s.trim())
// Sin expresión regular. `(--[^\n]*\n?)+` tarda catorce segundos en un
// comentario de quinientos caracteres: cuando no encaja, puede repartir el
// mismo texto de exponencialmente muchas formas antes de rendirse. Línea a
// línea es lineal, y además se lee.
.filter((s) => s && s.split('\n').some((l) => l.trim() && !l.trim().startsWith('--')));
apuntar(`${sentencias.length} sentencias`);
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
// Que no se quede esperando eternamente si el servidor no contesta.
connectionTimeoutMillis: 10_000,
query_timeout: 30_000
});
apuntar('conectando...');
await cliente.connect();
apuntar('conectado');
await cliente.query('BEGIN');
// Que falle rápido en vez de colgarse esperando un bloqueo que no llega.
await cliente.query("SET LOCAL lock_timeout = '5s'");
await cliente.query("SET LOCAL statement_timeout = '30s'");
apuntar('transacción abierta');
let hechas = 0;
let rota = null;
for (const [i, sentencia] of sentencias.entries()) {
const resumen = sentencia.split('\n').find((l) => l.trim() && !l.trim().startsWith('--')) ?? '';
apuntar(`[${i + 1}/${sentencias.length}] ${resumen.trim().slice(0, 80)}`);
try {
await cliente.query(sentencia);
hechas++;
} catch (fallo) {
rota = { i, sentencia, fallo };
apuntar(`\n FALLA: ${fallo.message}`);
if (fallo.hint) apuntar(` pista: ${fallo.hint}`);
if (fallo.position) apuntar(` posición: ${fallo.position}`);
apuntar('\n' + sentencia.slice(0, 600));
break;
}
}
const completa = !rota && hechas === sentencias.length;
await cliente.query(APLICAR && completa ? 'COMMIT' : 'ROLLBACK');
apuntar(completa ? `\nLas ${hechas} sentencias pasan.` : `\nParó en la ${hechas + 1}.`);
apuntar(APLICAR && completa ? 'APLICADA.' : '(deshecha)');
await cliente.end();

@ -9,7 +9,7 @@
*/ */
import { boolean, char, index, integer, pgTable, text } from 'drizzle-orm/pg-core'; import { boolean, char, index, integer, pgTable, text } from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante } from './comun'; import { ahora, estado, id, instante, procedencia } from './comun';
/** /**
* Países, por su código ISO 3166-1 de dos letras. * Países, por su código ISO 3166-1 de dos letras.
@ -111,6 +111,14 @@ export const persona = pgTable(
semblanza: text('semblanza'), semblanza: text('semblanza'),
estado: estado(), estado: estado(),
/*
* Procedencia también aquí: las figuras de un género —Agustín Lara, Sindo
* Garay— las escribe un modelo igual que las fichas, y con las mismas
* fechas de nacimiento que hay que verificar. Sin esto no se distingue a
* quien firma una canción del catálogo, que lo escribió una persona, de
* una semblanza importada sin revisar.
*/
...procedencia(),
creadoEn: ahora('creado_en'), creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en') actualizadoEn: instante('actualizado_en')
}, },

Loading…
Cancel
Save

Powered by TurnKey Linux.