/**
* Vertrags-Import — der Backend-Katalog aus dem Konfig-Bucket.
*
* Liest `config/contract/backends.yaml` aus dem Konfig-Bucket und schreibt ihn in
* die Registry-Tabellen: die App-Ebene nach `AppCatalog`, die Zweige nach
* `AppBackendBranch`. Beides über `registryService` — **direkt**, nicht über die
* HTTP-Registry und nicht über den Broker. Der Vertrag ist members-back-Datenhoheit
* (er füllt seine Tabellen), also importiert members-back ihn auch.
*
* ## Woher der Vertrag kommt
*
* Der Vertrag ist **global** (ein File für alle Organisationen, geschlüsselt über
* `appKey`). Er liegt deshalb nicht im Orga-Baum `config/{app}/{orgUID}.yaml` —
* jener Baum ist pro Organisation, verlangt eine `UID` im YAML und wird von
* `readAllConfigs` ausschließlich unter `config/admin/UUID-<uuid>.yaml` gelesen.
* Der Vertrag hat seinen eigenen, globalen Schlüssel:
*
* <bucket>/config/contract/backends.yaml
*
* Im Repo liegt nur das **Beispiel** (`contract/backends.example.yaml`) als Vorlage.
*
* ## Flach machen, was zwei Ebenen hat
*
* Der Vertrag beschreibt App- und Zweig-Ebene je als **zwei Töpfe**: `environment`
* (env.js-Keys) und die App-Felder daneben (`title`, `description`, `icon`, `roles`,
* `domain`). Gespeichert wird je Ebene **ein flaches Objekt** — beide Töpfe
* verschmolzen. Der Broker mischt später `{...AppEbene, ...Zweig}`; weil beide
* Ebenen dieselben Schlüssel tragen können, überschreibt der Zweig je Schlüssel.
*
* ## Die Richtung ist einseitig
*
* Der Vertrag beschreibt nur das **Angebot** (welche Apps und Zweige es gibt). Was
* eine Organisation gewählt hat, steht in `OrgAppDeployment` — dieses Modul kennt
* die Tabelle nicht. Ein Re-Import kann deshalb keine Admin-Entscheidung
* zurücksetzen. Innerhalb einer aufgeführten App werden entfernte Zweige nur
* gelöscht, wenn sie keine Organisation mehr wählt; die anderen meldet
* `saveBackendBranches` als `kept` (Warnung, kein Fehler).
*
* `parseContract` / `flattenEntry` sind **rein** — die Standardwege (S3, DB)
* werden erst in `importContract` geladen. So bleiben Form und Fehlerfälle ohne
* S3 und ohne Datenbank prüfbar.
*
* @see contract/backends.example.yaml — die Form
* @see PLAN-app-registry.md — das Registry-Modell
*/
import YAML from 'yaml';
const logPrefix = '[contract]';
/** Der globale Vertrags-Schlüssel im Konfig-Bucket. */
export const CONTRACT_KEY = 'config/contract/backends.yaml';
/**
* Verschmilzt die zwei Töpfe einer Ebene zu **einem** flachen Objekt.
*
* `environment` (env.js-Keys) und die App-Felder daneben werden zusammengelegt;
* `branches` gehört nicht dazu (es ist die nächste Ebene, nicht ein Feld).
* Reihenfolge ist belanglos, weil sich die beiden Töpfe per Definition nicht
* überschneiden: `environment` trägt env.js-Keys, der Rest Anzeige/Routing.
*
* @param {Record<string, unknown>} entry
* @param {string} label - für die Fehlermeldung
* @returns {Record<string, unknown>}
*/
export function flattenEntry(entry, label) {
const { environment, branches: _branches, ...meta } = entry;
if (environment !== undefined && (typeof environment !== 'object' || environment === null || Array.isArray(environment))) {
throw new Error(`"${label}": "environment" is not an object`);
}
return { ...(environment ?? {}), ...meta };
}
/**
* Zerlegt den Vertragstext in `[appKey, { environment, branches }]`-Paare.
*
* Geprüft wird **nur die Form** (`branches` da, Objekte). Die Prüfung der
* Schlüssel und Werte liegt bei `saveAppCatalog` / `saveBackendBranches` — die
* Instanz, die schreibt. Ein zweiter Prüfer hier wäre eine zweite Wahrheit.
*
* @param {string} text
* @returns {Array<[string, {environment: object, branches: Record<string, object>}]>}
*/
export function parseContract(text) {
const parsed = YAML.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Contract is not an object of app entries');
}
const apps = [];
for (const [appKey, entry] of Object.entries(parsed)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`"${appKey}": entry is not an object`);
}
const { branches } = entry;
if (!branches || typeof branches !== 'object' || Array.isArray(branches)) {
throw new Error(`"${appKey}": "branches" is missing or not an object`);
}
const branchMap = {};
for (const [branch, branchEntry] of Object.entries(branches)) {
if (!branchEntry || typeof branchEntry !== 'object' || Array.isArray(branchEntry)) {
throw new Error(`"${appKey}.${branch}": branch is not an object`);
}
branchMap[branch] = flattenEntry(branchEntry, `${appKey}.${branch}`);
}
apps.push([appKey, { environment: flattenEntry(entry, appKey), branches: branchMap }]);
}
return apps;
}
/**
* Ein Durchlauf: Vertrag lesen, je App importieren, Ergebnis melden.
*
* Die Abhängigkeiten sind injizierbar (`readFile`, `saveAppCatalog`,
* `saveBackendBranches`) — so ist die Funktion ohne S3 und ohne Datenbank
* prüfbar, und der Aufrufer entscheidet, ob er den echten Konfig-Bucket nimmt.
* Ohne Injektion werden die Standardwege erst hier geladen (siehe Dateikopf).
*
* Ein leeres/fehlendes App-`environment` überspringt den App-Schritt: ein
* Vertrag, der alles in die Zweige legt, ist gültig und soll die vorhandene
* App-Ebene nicht löschen.
*
* @param {{
* readFile?: (key: string) => Promise<string>,
* saveAppCatalog?: (appKey: string, environment: object) => Promise<unknown>,
* saveBackendBranches?: (appKey: string, branches: Record<string, object>) => Promise<{upserted?: string[], removed?: string[], kept?: string[]}>,
* logPrefix?: string
* }} [options]
* @returns {Promise<{ok: boolean, apps: number, branches: number, kept: string[], removed: string[], reason?: string}>}
*/
export async function importContract({
readFile,
saveAppCatalog,
saveBackendBranches,
logPrefix: prefix = logPrefix,
} = {}) {
if (!readFile || !saveAppCatalog || !saveBackendBranches) {
const [{ getConfigFile }, registryService] = await Promise.all([
import('../../config/configFile.js'),
import('../orgaSettings/registryService.js'),
]);
readFile = readFile ?? getConfigFile;
saveAppCatalog = saveAppCatalog ?? registryService.saveAppCatalog;
saveBackendBranches = saveBackendBranches ?? registryService.saveBackendBranches;
}
/** @type {string} */
let text;
try {
text = await readFile(CONTRACT_KEY);
} catch (error) {
// Kein Vertrag ist kein Defekt: die Auslieferung läuft mit dem zuletzt
// gültigen Katalog weiter (§6.9). Der Aufrufer loggt die Ursache.
return { ok: false, apps: 0, branches: 0, kept: [], removed: [], reason: `unreadable: ${error?.message || error}` };
}
// Erst vollständig parsen, dann schreiben: ein Formfehler soll keinen halb
// importierten Katalog hinterlassen.
const apps = parseContract(text);
/** @type {string[]} */
const kept = [];
/** @type {string[]} */
const removed = [];
let branches = 0;
for (const [appKey, { environment, branches: branchMap }] of apps) {
if (environment && Object.keys(environment).length > 0) {
await saveAppCatalog(appKey, environment);
}
const result = await saveBackendBranches(appKey, branchMap);
branches += result?.upserted?.length ?? 0;
for (const branch of result?.kept ?? []) kept.push(`${appKey}:${branch}`);
for (const branch of result?.removed ?? []) removed.push(`${appKey}:${branch}`);
console.log(
`${prefix} ${appKey}: ${result?.upserted?.length ?? 0} Zweig(e) geschrieben` +
(result?.removed?.length ? `, ${result.removed.length} entfernt` : '') +
(result?.kept?.length ? `, ${result.kept.length} behalten` : ''),
);
}
if (kept.length > 0) {
// Kein Fehler, aber ein Widerspruch: der Vertrag nennt diese Zweige nicht
// mehr, eine Organisation wählt sie aber noch. Gelöscht wurde **nicht** —
// sonst wechselte eine laufende Organisation still ihr Backend.
console.warn(
`${prefix} WARNUNG: ${kept.length} Zweig(e) stehen nicht mehr im Vertrag, ` +
`sind aber noch zugeordnet und wurden NICHT entfernt: ${kept.join(', ')}`,
);
}
return { ok: true, apps: apps.length, branches, kept, removed };
}
/**
* Import-Durchlauf für den Start / den Reload-Endpunkt, mit Logging und ohne Wurf.
*
* Absichtlich **nicht** werfend: ein fehlender oder kaputter Vertrag darf den
* Serverstart (oder einen Reload-Aufruf) nicht scheitern lassen — die Auslieferung
* läuft mit dem zuletzt gültigen Katalog weiter.
*
* @returns {Promise<Awaited<ReturnType<typeof importContract>>>}
*/
export async function importContractSafely() {
try {
const result = await importContract();
if (!result.ok) {
console.warn(`${logPrefix} kein Import: ${result.reason}`);
} else {
console.log(`${logPrefix} importiert: ${result.apps} App(s), ${result.branches} Zweig(e)`);
}
return result;
} catch (error) {
console.error(`${logPrefix} Import fehlgeschlagen:`, error?.message || error);
return { ok: false, apps: 0, branches: 0, kept: [], removed: [], reason: error?.message || String(error) };
}
}