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;
};