Source: config/migrations/20260928-app-registry-types.js

export const id = '20260928-app-registry-types';
export const name = 'App-Registry: app/appDomain/appAsset in ObjectBase.Type und Links.Type';

/**
 * Das App-Registry legt Apps, Domains und Assets als Objekte in `ObjectBase`
 * ab und verbindet sie über `Links`. Beide Tabellen sind `WITH SYSTEM
 * VERSIONING` und führen `Type` als `enum` — **ohne die Literale schlägt jeder
 * INSERT fehl** (strikt in MariaDB, kein stiller Fallback).
 *
 * ## Warum die Werte gelesen und nicht hingeschrieben werden
 *
 * Die naheliegende Variante wäre, die vollständige Enum-Liste in die Migration
 * zu schreiben (so machen es die Accounting- und Runner-Migrationen). Das ist
 * hier bewusst **nicht** gewählt: die Ist-Enum hängt davon ab, welche
 * Migrationskette eine Datenbank durchlaufen hat — gemessen unterscheiden sich
 * Test- und Produktionsstand bereits sichtbar (`runner` fehlt dem einen,
 * `project` dem anderen). Eine hart kodierte Liste würde beim Anwenden auf einen
 * abweichenden Stand **still Literale entfernen** und damit bestehende Zeilen
 * unlesbar machen.
 *
 * Diese Migration liest deshalb die tatsächliche Enum-Definition aus
 * `information_schema` und **hängt nur die fehlenden Werte an**. Sie ist damit
 * idempotent und auf jedem Startzustand korrekt — auch auf einem frischen Boot,
 * der `initTables.sql` schon kennt (dort endet sie als No-Op).
 *
 * ## Das Session-Flag ist Pflicht
 *
 * MariaDB verweigert `ALTER TABLE` auf system-versioned Tabellen mit
 * `ERROR 4119` („Change @@system_versioning_alter_history to proceed"). Das Flag
 * gilt **pro Verbindung** — hier ist das unkritisch, weil `query()` ohne
 * Transaktion auf der geteilten `mainConnection` läuft. Würde die Migration auf
 * einen Pool umgestellt, müssten SET und ALTER auf dieselbe Verbindung.
 */

/** Objekttypen für das App-Registry (ObjectBase.Type) */
const OBJECT_TYPES = ['app', 'appDomain', 'appAsset'];

/**
 * Link-Typen für das App-Registry (Links.Type).
 * `app` selbst ist kein Link-Typ — eine App wird nicht verlinkt, sie ist das
 * Ziel. Domains und Assets hängen über Links an der App.
 */
const LINK_TYPES = ['appDomain', 'appAsset'];

/**
 * Liest die Enum-Literale einer Spalte aus `information_schema`.
 * @param {Function} query
 * @param {string} table
 * @param {string} column
 * @returns {Promise<string[]|null>} Literale, oder `null` wenn keine enum-Spalte
 */
const getEnumValues = async (query, table, column) => {
    const rows = await query(
        `SELECT COLUMN_TYPE AS columnType
           FROM information_schema.COLUMNS
          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
        [table, column],
    );
    const columnType = rows[0]?.columnType;
    if (!columnType) return null;

    const match = /^enum\((.*)\)$/is.exec(columnType);
    if (!match) return null;

    // Über die Anführungszeichen parsen statt über Kommas zu splitten — ein
    // Literal darf theoretisch ein Komma enthalten, ein Quote-Token nicht.
    const literals = match[1].match(/'((?:[^']|'')*)'/g);
    if (!literals) return null;

    return literals.map((token) => token.slice(1, -1).replace(/''/g, "'"));
};

/**
 * Hängt fehlende Literale an ein `enum` an — in der Reihenfolge, in der sie
 * übergeben werden. Vorhandene Werte behalten ihre Position, damit bestehende
 * Zeilen ihren Index nicht verlieren.
 * @param {string[]} current
 * @param {string[]} wanted
 * @returns {string[]|null} neue Liste, oder `null` wenn nichts fehlt
 */
const appendMissing = (current, wanted) => {
    const missing = wanted.filter((value) => !current.includes(value));
    return missing.length > 0 ? [...current, ...missing] : null;
};

/**
 * Führt ein `ALTER TABLE … MODIFY COLUMN Type enum(…)` auf einer
 * system-versioned Tabelle aus. Setzt und entfernt das Session-Flag selbst,
 * damit kein Aufrufer es vergessen kann.
 * @param {Function} query
 * @param {string} table
 * @param {string[]} values
 */
const alterEnum = async (query, table, values) => {
    const list = values.map((value) => `'${value.replace(/'/g, "''")}'`).join(',');
    await query('SET @@system_versioning_alter_history = 1', []);
    try {
        await query(
            `ALTER TABLE \`${table}\`
                MODIFY COLUMN \`Type\` enum(${list})
                CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL`,
            [],
        );
    } finally {
        // Auch im Fehlerfall zurücksetzen — sonst bliebe das Flag für alle
        // folgenden Statements dieser Verbindung gesetzt (mainConnection).
        await query('SET @@system_versioning_alter_history = 0', []);
    }
};

export const migrate = async ({ query }) => {
    const targets = [
        { table: 'ObjectBase', wanted: OBJECT_TYPES },
        { table: 'Links', wanted: LINK_TYPES },
    ];

    for (const { table, wanted } of targets) {
        const current = await getEnumValues(query, table, 'Type');
        if (!current) {
            // Kein enum gefunden: Tabelle fehlt oder Spalte ist anders typisiert.
            // Nicht raten — die Migration würde sonst gegen ein unbekanntes
            // Schema schreiben. initTables.sql-Pfad ist hier bewusst kein Fehler.
            continue;
        }

        const next = appendMissing(current, wanted);
        if (!next) continue;

        await alterEnum(query, table, next);
    }
};