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:
parent
881f19ed05
commit
b64cec21a4
1 changed files with 38 additions and 13 deletions
|
|
@ -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<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))');
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue