fix(cli): Migrationen mit Prüfsumme und Sperre gegen Parallelausführung härten

Zweite Hälfte von #100 (P2, Nightbot-Befund): schema_migrations kannte
bisher nur den Dateinamen, keine Prüfsumme. Wurde eine bereits
angewendete Migrationsdatei nachträglich bearbeitet (versehentlich,
Merge-Konflikt o. Ä.), lief der nächste `pnpm migrate` klaglos durch –
die Änderung wurde nie angewendet, Umgebungen konnten unbemerkt
auseinanderdriften. Außerdem gab es keine Sperre: zwei gleichzeitige
Migrationsläufe (z. B. zwei parallele Deploys) hätten sich in die
Quere kommen können.

- Neue Spalte schema_migrations.checksum (SHA-256 des Dateiinhalts,
  ALTER TABLE ... ADD COLUMN IF NOT EXISTS für bestehende Installationen).
  Bereits angewendete Migrationen ohne gespeicherte Prüfsumme werden
  einmalig nachgetragen; weicht eine vorhandene Prüfsumme vom aktuellen
  Dateiinhalt ab, bricht die Migration mit klarer Fehlermeldung ab statt
  die Änderung stillschweigend zu ignorieren.
- GET_LOCK('kc_migrate', 10) um den gesamten Lauf (selbes Muster wie die
  Audit-Hash-Kette), verhindert parallele Migrationsläufe.
- Sauberer Fehlerausstieg (Meldung + Exit-Code) statt Stack-Trace.

Gegen die Testdatenbank durchexerziert: Prüfsummen-Nachtrag, No-Op-
Wiederholung, manipulierte Prüfsumme (bricht korrekt ab), danach wieder
sauberer Lauf. Gegen die echte Produktionsdatenbank angewendet: alle 31
bestehenden Migrationen jetzt mit Prüfsumme, kc-api läuft weiter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 11:36:52 +02:00
parent 881f19ed05
commit b64cec21a4

View file

@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
@ -5,19 +6,43 @@ import '../core/config.js';
import { pool } from '../core/db.js'; import { pool } from '../core/db.js';
const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations'); const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations');
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export async function migrate(): Promise<void> { export async function migrate(): Promise<void> {
await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))'); await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, checksum CHAR(64) NULL, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name)); await pool.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum CHAR(64) NULL'); // bestehende Installationen ohne die Spalte nachziehen
// Sperre gegen gleichzeitige Migrationsläufe (z. B. zwei parallele Deploys) – sonst könnten beide dieselbe neue
// Migration anwenden oder sich beim Anlegen der Zeile in schema_migrations in die Quere kommen.
const [[lockRow]] = await pool.query('SELECT GET_LOCK(?, 10) AS got', ['kc_migrate']) as any;
if (Number(lockRow?.got) !== 1) throw new Error('Migrations-Sperre konnte nicht erlangt werden (läuft bereits eine andere Migration? Zeitüberschreitung nach 10s)');
try {
const rows = (await pool.query('SELECT name, checksum FROM schema_migrations') as any)[0] as { name: string; checksum: string | null }[];
const applied = new Map(rows.map((r) => [r.name, r.checksum]));
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
if (done.has(f)) continue; const content = readFileSync(join(dir, f), 'utf8');
const sum = sha256(content);
if (applied.has(f)) {
const stored = applied.get(f);
// Bereits angewendete Migrationen dürfen nachträglich nicht mehr verändert werden (sonst driften Umgebungen
// unbemerkt auseinander, je nachdem wann sie migriert haben). Alte Zeilen ohne Prüfsumme werden einmalig nachgetragen.
if (stored == null) await pool.query('UPDATE schema_migrations SET checksum = ? WHERE name = ?', [sum, f]);
else if (stored !== sum) throw new Error(`Migration ${f} wurde bereits angewendet, ihr Inhalt hat sich seitdem aber geändert (Prüfsumme weicht ab). Bitte die Datei nicht nachträglich bearbeiten, sondern eine neue Migration anlegen.`);
continue;
}
const c = await pool.getConnection(); const c = await pool.getConnection();
try { try {
// DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch. // DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch.
const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean); const stmts = content.replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
for (const st of stmts) await c.query(st); for (const st of stmts) await c.query(st);
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); await c.query('INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)', [f, sum]);
console.log('migriert:', f); console.log('migriert:', f);
} finally { c.release(); } } finally { c.release(); }
} }
} finally {
await pool.query('SELECT RELEASE_LOCK(?)', ['kc_migrate']).catch(() => undefined);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
try { await migrate(); } catch (e) { console.error((e as Error).message); await pool.end(); process.exit(1); }
await pool.end();
} }
if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); }