Source: config/migrations/tableIntegrity.js

import { readFile } from 'fs/promises';
import path from 'path';

/**
 * Tabellen-Integrität für den Migrationslauf.
 *
 * ## Das Problem
 *
 * `CREATE TABLE IF NOT EXISTS` prüft **nur den Namen** — nicht, ob unter dem
 * Namen auch eine benutzbare Tabelle liegt. Bleibt nach einem physischen Restore
 * (`mariabackup --copy-back` löscht keine Dateien, die nicht im Backup sind) eine
 * **verwaiste `.frm`** liegen — im Datenwörterbuch vorhanden, aber ohne nutzbaren
 * Tablespace — läuft der Befehl **erfolgreich durch**, ohne eine benutzbare
 * Tabelle zu erzeugen. Die Migration bucht sich als erledigt, und der Fehler
 * (`ERROR 1932 … doesn't exist in engine`) schlägt erst später zu, beim ersten
 * `SELECT`/`ALTER` — ohne erkennbaren Bezug zur Ursache (real passiert am
 * 2026-10-05: die App startete nicht mehr).
 *
 * ## Die Erkennung — Probe, nicht `ENGINE`
 *
 * Ein früherer Filter `information_schema.TABLES.ENGINE IS NULL` war **zu eng**:
 * nach dem Restore am 2026-10-05 meldete MariaDB für `AppRelease`,
 * `AppBackendBranch` und `OrgAppDeployment` `ENGINE = 'InnoDB'` — der Zustand war
 * von außen nicht von einer gesunden Tabelle zu unterscheiden. Verlässlich ist
 * nur der **Öffnungsversuch**: `SELECT 1 FROM t LIMIT 1`. `LIMIT 1` (nicht `0`)
 * ist Pflicht — mit `LIMIT 0` würde die Tabelle gar nicht geöffnet und eine
 * kaputte fälschlich als lesbar gelten.
 *
 * Deshalb werden **alle** migrations-eigenen Tabellen geprüft (nicht nur die mit
 * `ENGINE IS NULL`). Entfernt wird eine Tabelle nur, wenn der Öffnungsversuch
 * mit dem **eindeutigen** Defekt fehlschlägt (`errno 1932` /
 * `ER_NO_SUCH_TABLE_IN_ENGINE`). Jeder andere Fehler (Lock-Timeout, Rechte) ist
 * **kein** Grund, Daten zu verwerfen — die Tabelle bleibt liegen und wird nur
 * gemeldet.
 *
 * Views sind ausgenommen (`TABLE_TYPE = 'BASE TABLE'`): dort ist `ENGINE`
 * grundsätzlich `NULL` — die Kompatibilitäts-Aliase `Objects`, `HasTarget` und
 * `ObjectTargets` sind solche Views und völlig in Ordnung.
 *
 * ## Nach dem Drop: die erzeugende Migration entbuchen
 *
 * Ein Drop allein genügt **nicht**. Ist die `CREATE`-Migration bereits in
 * `dbVersion` gebucht (Regelfall: sie rann früher einmal), läuft sie nicht
 * erneut — die Tabelle bliebe für immer verschwunden. Deshalb hebt der Guard
 * beim Entfernen zugleich die `dbVersion`-Zeile(n) der Migration auf, die die
 * Tabelle anlegt. Der Runner liest `dbVersion` **nach** dem Guard
 * (`runMigrations`) und führt sie damit im selben Lauf wieder aus. Voraussetzung
 * ist, dass diese Migration idempotent ist (die `AppRelease`-Familie ist reines
 * `CREATE TABLE IF NOT EXISTS`).
 *
 * ## Der Geltungsbereich
 *
 * Entfernt werden **nur** Tabellen, die das Migrationssystem selbst besitzt
 * ({@link collectMigrationTables}) — also solche, die eine nachfolgende Migration
 * sauber neu anlegen kann. Alles andere (Alt-/Fremdtabellen) wird nie angefasst.
 * `dbVersion` selbst ist ausgenommen: sie gehört dem Runner
 * (`ensureVersionTable`), hat keine erzeugende Migration und darf nie fallen.
 */

/** Erkennt den eindeutigen Defekt „Tabelle im Engine nicht vorhanden". */
const isMissingInEngine = (error) =>
    error?.errno === 1932
    || error?.code === 'ER_NO_SUCH_TABLE_IN_ENGINE'
    || /doesn'?t exist in engine/i.test(String(error?.message ?? ''));

/**
 * Sammelt die Tabellennamen, die die Migrationen selbst anlegen — je Name die
 * Menge der Migrationen (`id`), die sie erzeugt.
 *
 * Gelesen wird der **Quelltext** der Migrationen (`CREATE TABLE [IF NOT EXISTS]
 * \`Name\``), nicht der Datenbankzustand: nur so ist die Menge unabhängig davon,
 * welche Migrationen bereits gebucht sind. Genau diese Menge darf der Guard
 * entfernen — sie ist per Definition wiederherstellbar. Die Zuordnung
 * Name → Migration(en) wird gebraucht, um nach einem Drop die erzeugende
 * Migration zu entbuchen.
 *
 * `dbVersion` gehört dazu, obwohl der Runner sie außerhalb der Migrationsdateien
 * anlegt (ohne erzeugende Migration).
 *
 * @param {string} dir - Verzeichnis der Migrationsmodule
 * @param {string[]} files - Dateinamen im Verzeichnis
 * @returns {Promise<Map<string, Set<string>>>} Tabellenname → erzeugende Migrations-Ids
 */
export const collectMigrationTables = async (dir, files) => {
    const owned = new Map([['dbVersion', new Set()]]);
    for (const file of files) {
        const source = await readFile(path.join(dir, file), 'utf8');
        const idMatch = /export\s+const\s+id\s*=\s*['"`]([^'"`]+)['"`]/.exec(source);
        const migrationId = idMatch ? idMatch[1] : null;
        // Die Namen stehen in Template-Strings, die Backticks dort sind also
        // escaped (`\`AppRelease\``) — das Muster muss den optionalen Backslash
        // mitnehmen, sonst findet es nur nicht-escapte Vorkommen.
        for (const match of source.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?\\?`([A-Za-z0-9_$]+)\\?`/gi)) {
            const name = match[1];
            if (!owned.has(name)) owned.set(name, new Set());
            if (migrationId) owned.get(name).add(migrationId);
        }
    }
    return owned;
};

/**
 * Erkennt inkonsistente, migrations-eigene Tabellen und räumt sie weg, damit
 * sie sauber neu angelegt werden können.
 *
 * @param {Function} query - `query(sql, params)` der Migrations-API
 * @param {{ onProgress?: Function, ownedTables?: Map<string, Set<string>>|Set<string> }} [options]
 *   `ownedTables` ist die Erlaubnisliste aus {@link collectMigrationTables}
 *   (Map, bevorzugt) oder eine reine Namensmenge (dann ohne Entbuchung).
 *   Fehlt sie, wird **nichts** entfernt.
 * @returns {Promise<string[]>} Namen der entfernten Tabellen
 */
export const healInconsistentTables = async (query, { onProgress, ownedTables } = {}) => {
    // Map bevorzugt (trägt die erzeugenden Migrationen); eine Set-Fassung wird
    // toleriert, kann aber nicht entbuchen.
    const owned = ownedTables instanceof Map
        ? ownedTables
        : new Map([...(ownedTables ?? [])].map((name) => [name, new Set()]));

    if (owned.size === 0) return [];

    // Kandidaten: jede migrations-eigene Tabelle, die im Wörterbuch steht —
    // **ohne** `ENGINE`-Filter. Der verwaiste `.frm` erscheint als
    // `ENGINE = 'InnoDB'` und fällt nur beim Öffnen auf (siehe Modulkopf).
    const names = [...owned.keys()].filter((name) => name !== 'dbVersion');
    if (names.length === 0) return [];
    const candidates = await query(
        `SELECT TABLE_NAME AS tableName
           FROM information_schema.TABLES
          WHERE TABLE_SCHEMA = DATABASE()
            AND TABLE_TYPE = 'BASE TABLE'
            AND TABLE_NAME IN (${names.map(() => '?').join(',')})`,
        names,
    );

    const removed = [];
    for (const { tableName } of candidates) {
        if (tableName === 'dbVersion') continue;

        let error = null;
        try {
            await query(`SELECT 1 FROM \`${tableName}\` LIMIT 1`, []);
        } catch (e) {
            error = e;
        }
        if (!error) continue;

        // Nur der eindeutige Defekt wird behandelt. Ein Lock-Timeout oder ein
        // Rechte-Fehler ist KEIN Grund, Daten zu verwerfen.
        if (!isMissingInEngine(error)) {
            onProgress?.({
                action: 'warn',
                text: `Tabelle ${tableName} nicht lesbar (${error.code ?? error.errno ?? error.message}) — bleibt liegen`,
            });
            continue;
        }

        // Sicherheitsnetz: nur eine migrations-eigene Tabelle darf fallen. Die
        // Kandidaten-Abfrage filtert zwar schon darauf, aber der Drop soll sich
        // nie allein auf eine Abfrage verlassen.
        if (!owned.has(tableName)) {
            onProgress?.({
                action: 'warn',
                text: `Inkonsistente Tabelle gefunden, aber nicht migrations-eigen — bleibt liegen: ${tableName}`,
            });
            continue;
        }

        onProgress?.({
            action: 'warn',
            text: `Inkonsistente Tabelle entfernt, wird neu angelegt: ${tableName}`,
        });
        await query(`DROP TABLE \`${tableName}\``, []);

        // Erzeugende Migration entbuchen, sonst legt die Tabelle niemand wieder
        // an (der Runner liest `dbVersion` erst nach dem Guard).
        for (const migrationId of owned.get(tableName) ?? []) {
            await query('DELETE FROM dbVersion WHERE migrationId = ?', [migrationId]);
            onProgress?.({
                action: 'warn',
                text: `Migration erneut eingeplant (erzeugt ${tableName}): ${migrationId}`,
            });
        }
        removed.push(tableName);
    }

    return removed;
};