From b64cec21a4c76c7bf7e358e06adcb86384a61951 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 11:36:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(cli):=20Migrationen=20mit=20Pr=C3=BCfsumme?= =?UTF-8?q?=20und=20Sperre=20gegen=20Parallelausf=C3=BChrung=20h=C3=A4rten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/cli/migrate.ts | 51 +++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/api/src/cli/migrate.ts b/apps/api/src/cli/migrate.ts index 59addb4..1d07980 100644 --- a/apps/api/src/cli/migrate.ts +++ b/apps/api/src/cli/migrate.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join, dirname } from 'node:path'; @@ -5,19 +6,43 @@ import '../core/config.js'; import { pool } from '../core/db.js'; const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations'); +const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); + export async function migrate(): Promise { - 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))'); - const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name)); - for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { - if (done.has(f)) continue; - const c = await pool.getConnection(); - try { - // 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); - for (const st of stmts) await c.query(st); - await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); - console.log('migriert:', f); - } finally { c.release(); } + 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))'); + 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()) { + 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(); + try { + // DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch. + const stmts = content.replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean); + for (const st of stmts) await c.query(st); + await c.query('INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)', [f, sum]); + console.log('migriert:', f); + } finally { c.release(); } + } + } finally { + await pool.query('SELECT RELEASE_LOCK(?)', ['kc_migrate']).catch(() => undefined); } } -if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); } +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(); +}