Source: Router/registry/releaseImport.js

// @ts-check
/**
 * Release-Import — die Versionen aus dem Artefakt-Bucket.
 *
 * Liest den S3-Lageplan `<produkt>/<version>/…` aus dem Artefakt-Bucket
 * (`commtool-apps`, siehe `ARTIFACT_BUCKET`) und schreibt je gefundener Version
 * eine Zeile nach `AppRelease`. Damit steht die Liste der wählbaren Versionen
 * **ohne** einen zweiten Pflegeort: was im Bucket liegt, ist wählbar.
 *
 * ## Warum der Schlüssel das Produkt ist
 *
 * Der Lageplan ist **produktweit**: `member/5.30.1/` liegt genau einmal und
 * bedient `member.app`, `member.test`, `member.dev`. Deshalb ist
 * `AppRelease.AppKey` das **Produkt** (`member`) — nicht die App-ID. Die
 * Umgebung sitzt in der Zuordnung des Admins (`OrgAppDeployment`), nicht im
 * Ablageort. Dieselbe Naht wie beim Vertrag (`productOfAppId`).
 *
 * ## Kanäle entstehen hier nicht
 *
 * `latest` und `stable` sind **Auflösungsregeln**, keine Zeilen (siehe
 * `registryService.resolveRelease`): fehlt eine Zeile mit dem Namen, greift die
 * neueste Version. Der Import legt deshalb nur **konkrete** Versionszeilen an
 * (`5.30.1`). Eine Kanal-Zeile, die der Release-Job setzt (Promotion), bleibt
 * unangetastet — das Pruning löscht nur Zeilen, die wie eine Version aussehen.
 *
 * ## Die Richtung ist einseitig
 *
 * Der Bucket beschreibt nur, **was es gibt**. Was eine Organisation gewählt hat,
 * steht in `OrgAppDeployment` — dieses Modul kennt die Tabelle nicht und kann
 * eine Admin-Entscheidung nicht zurücksetzen.
 *
 * ## Ohne Berechtigung ist es kein Defekt
 *
 * Das App-Konto darf den Artefakt-Bucket in der Regel **nicht** lesen (er gehört
 * dem Broker). Der Import meldet das als Warnung und lässt die vorhandenen
 * Zeilen stehen — die Auslieferung läuft mit dem letzten Stand weiter.
 *
 * `parseArtifactKeys` ist **rein** — die Standardwege (S3, DB) werden erst in
 * `importReleases` geladen. So bleiben Form und Fehlerfälle ohne Bucket und ohne
 * Datenbank prüfbar.
 *
 * @see src/Router/registry/contractImport.js — dasselbe Muster für den Vertrag
 * @see app-broker/PLAN.md §6.9 — der S3-Lageplan und die Kanäle
 */

const logPrefix = '[releases]';

/**
 * Zerlegt Objekt-Schlüssel in `{ produkt: [version, …] }`.
 *
 * Erwartet den Lageplan `<produkt>/<version>/<datei…>`. Nur **konkrete**
 * Versionsordner zählen: das zweite Segment muss mit einer Ziffer beginnen
 * (`5.30.1`). Damit fallen Hilfsordner (`languages/…`) und ein flacher Müllpfad
 * heraus, ohne eine Liste zu pflegen.
 *
 * @param {Iterable<string>} keys
 * @returns {Record<string, string[]>} Produkt → sortierte, eindeutige Versionen
 */
export function parseArtifactKeys(keys) {
    /** @type {Map<string, Set<string>>} */
    const byProduct = new Map();

    for (const key of keys ?? []) {
        if (typeof key !== 'string') continue;
        const parts = key.split('/').filter((segment) => segment.length > 0);
        if (parts.length < 3) continue;
        const [product, version] = parts;
        if (!product || !version || !/^\d/.test(version)) continue;
        if (!byProduct.has(product)) byProduct.set(product, new Set());
        byProduct.get(product).add(version);
    }

    /** @type {Record<string, string[]>} */
    const result = {};
    for (const [product, versions] of byProduct) {
        result[product] = [...versions].sort();
    }
    return result;
}

/** Der S3-Präfix eines Releases — abgeleitet, nicht eingetippt (§6.9). */
export const releasePrefix = (product, version) => `${product}/${version}/`;

/**
 * Listet die Objekt-Schlüssel des Artefakt-Buckets über den S3-Client.
 *
 * Rekursiv: das Ergebnis ist die flache Schlüsselliste, aus der
 * {@link parseArtifactKeys} den Lageplan ableitet. Ein zweiter Weg (Ordner für
 * Ordner) wäre mehr Aufrufe für dasselbe Ergebnis — der Bucket ist klein.
 *
 * @returns {Promise<string[]>}
 */
export async function listArtifactKeys() {
    const { artifactMinioClient, ARTIFACT_BUCKET } = await import('../../utils/s3Client.js');
    if (!artifactMinioClient) throw new Error('S3 is not initialised (initS3)');

    /** @type {string[]} */
    const keys = [];
    await new Promise((resolve, reject) => {
        const stream = artifactMinioClient.listObjectsV2(ARTIFACT_BUCKET, '', true);
        stream.on('data', (obj) => keys.push(obj.name));
        stream.on('end', () => resolve(undefined));
        stream.on('error', reject);
    });
    return keys;
}

/**
 * Ein Durchlauf: Artefakte listen, Versionen schreiben, verschwundene entfernen.
 *
 * Die Abhängigkeiten sind injizierbar (`listKeys`, `listKnown`, `saveRelease`,
 * `deleteRelease`) — so ist die Funktion ohne Bucket und ohne Datenbank prüfbar.
 *
 * Pruning: für **gescannte** Produkte werden Zeilen entfernt, die wie eine
 * Version aussehen (`/^\d/`) und nicht mehr gefunden wurden. Kanal-Zeilen
 * (`latest`, `stable`) und die Produkte, die der Scan **nicht** kennt, bleiben
 * stehen — ein abgebrochener Scan darf nichts löschen.
 *
 * @param {{
 *   listKeys?: () => Promise<string[]>,
 *   listKnown?: () => Promise<Array<{AppKey: string, Version: string}>>,
 *   saveRelease?: (release: {appKey: string, version: string, prefix: string}) => Promise<unknown>,
 *   deleteRelease?: (appKey: string, version: string) => Promise<unknown>,
 *   logPrefix?: string
 * }} [options]
 * @returns {Promise<{ok: boolean, products: number, upserted: number, removed: number, reason?: string}>}
 */
export async function importReleases({
    listKeys,
    listKnown,
    saveRelease,
    deleteRelease,
    logPrefix: prefix = logPrefix,
} = {}) {
    if (!listKeys || !listKnown || !saveRelease || !deleteRelease) {
        const registryService = await import('../orgaSettings/registryService.js');
        listKeys = listKeys ?? (() => listArtifactKeys());
        listKnown = listKnown ?? registryService.listReleaseRows;
        saveRelease = saveRelease ?? registryService.saveAppRelease;
        deleteRelease = deleteRelease ?? registryService.deleteAppRelease;
    }

    /** @type {string[]} */
    let keys;
    try {
        keys = await listKeys();
    } catch (error) {
        // Kein Zugang ist kein Defekt: die vorhandenen Zeilen bleiben stehen,
        // die Auslieferung läuft mit dem letzten Stand weiter.
        return { ok: false, products: 0, upserted: 0, removed: 0, reason: `unreadable: ${error?.message || error}` };
    }

    const discovered = parseArtifactKeys(keys);
    const products = Object.keys(discovered);
    const known = await listKnown();

    let upserted = 0;
    let removed = 0;

    for (const product of products) {
        const versions = discovered[product];

        for (const version of versions) {
            await saveRelease({ appKey: product, version, prefix: releasePrefix(product, version) });
            upserted += 1;
        }

        const gone = known.filter(
            (row) => row.AppKey === product && /^\d/.test(row.Version) && !versions.includes(row.Version),
        );
        for (const row of gone) {
            await deleteRelease(product, row.Version);
            removed += 1;
        }
    }

    return { ok: true, products: products.length, upserted, removed };
}

/**
 * Import-Durchlauf für den Start / den Reload-Endpunkt, mit Logging und ohne Wurf.
 *
 * Absichtlich **nicht** werfend: ein fehlender Zugang zum Artefakt-Bucket darf
 * den Serverstart (oder einen Reload) nicht scheitern lassen.
 *
 * @returns {Promise<Awaited<ReturnType<typeof importReleases>>>}
 */
export async function importReleasesSafely() {
    try {
        const result = await importReleases();
        if (!result.ok) {
            console.warn(`${logPrefix} kein Import: ${result.reason}`);
        } else {
            console.log(
                `${logPrefix} importiert: ${result.upserted} Version(en) in ${result.products} Produkt(en)` +
                (result.removed ? `, ${result.removed} entfernt` : ''),
            );
        }
        return result;
    } catch (error) {
        console.error(`${logPrefix} Import fehlgeschlagen:`, error?.message || error);
        return { ok: false, products: 0, upserted: 0, removed: 0, reason: error?.message || String(error) };
    }
}