// @ts-check
/**
* Invariante: **genau ein aktiver `memberA`-Link pro Objekt.**
*
* Diese Migration ist der kanonische Ort der Regel. Sie stand bisher weder in
* der Doku (`DB-Docu > Backend > Datenstruktur > Link-Legende` beschreibt
* `memberA` als "Administrative (erste Ebene) Mitgliedschaft", sagt aber nichts
* zur Eindeutigkeit) noch strukturell in der Datenbank. Durchgesetzt war sie
* nur als App-Guard in **einem** Pfad
* (`personHelpers.js` → `handleGroupMembershipMigration`), und dort dreifach
* eingeschraenkt: nur beim Uebertritt, nur fuer Quelle `person`/`extern`, nur
* fuer Ziel `group`/`ggroup`.
*
* Die uebrigen ~20 Insert-Stellen (`entry`, `group`, `location`, `job`,
* `email`, `list`, `dlist`, `guest`, `event`, `project`, `runner`, `tailnet`)
* umgehen diesen Guard vollstaendig. Deshalb ein DB-Trigger: er greift
* zentral, auch fuer `INSERT IGNORE`.
*
* ## Datenlage bei Einfuehrung (Prod, 2026-09-25)
*
* - 261.696 Objekte mit aktivem `memberA`, davon **95 mit mehreren**
* (92x zwei, 3x drei) = 98 ueberschuessige Links → 99,96 % konform
* - Aufschluesselung der Verstoesse: `person` 71, `extern` 9, `entry` 9,
* `group` 1, 5 verwaist (Link ohne `ObjectBase`-Zeile)
* - Ziele ausschliesslich `group` (158 Links) und `dlist` (20 Links)
* - Kein Objekt hat `memberA` auf **verschiedene Ziel-Typen** — die
* Mehrfach-Links liegen immer auf demselben Ziel-Typ
* - `memberS` (106 Objekte): 0 Duplikate, aber alle 106 haben **zusaetzlich**
* einen `memberA` → die Regel ist typ-spezifisch, nie "eine
* Mitgliedschaft insgesamt"
*
* ## Verifiziertes Verhalten (Wegwerf-`mariadb:11.7`, prod-identische Struktur
* `WITH SYSTEM VERSIONING` + `PARTITION BY SYSTEM_TIME`)
*
* 1. `CREATE TRIGGER` auf partitionierter **und** system-versionierter
* Tabelle funktioniert (MySQL verbietet das, MariaDB nicht).
* 2. `INSERT IGNORE` schluckt das `SIGNAL` **nicht** (Fehler 1644 kommt
* trotzdem) — Voraussetzung dafuer, dass die vielen `INSERT IGNORE`
* Stellen die Regel ueberhaupt spueren.
* 3. **Kein `BEFORE UPDATE` noetig.** Die internen Versionierungs-Inserts
* feuern diesen `BEFORE INSERT`-Trigger nicht: ein Objekt mit zwei
* aktiven `memberA`-Links liess sich nach Trigger-Einbau weiterhin
* `UPDATE`n. Haette die Versionierungs-Buchhaltung den Trigger gefeuert,
* haette die Pruefung den anderen Duplikat-Link gefunden und blockiert.
* 4. Altbestand bleibt **editier- und loeschbar**. Daraus folgt: die
* Migration ist nicht-brechend und benoetigt **kein** Daten-Pre-Cleanup.
* (Die 95 Verstoesse raeumt separat `GET /maintenance/personConsistency?fix=true`.)
* 5. `UIDTarget <> NEW.UIDTarget` ist **Pflicht**, nicht Kosmetik: der
* Trigger feuert *vor* der Duplikatspruefung, ein zweiter `INSERT IGNORE`
* **desselben** Links (heute ein stiller No-Op) wuerde sonst 1644 werfen.
* Ohne diese Bedingung brechen idempotente Re-Inserts an ~20 Stellen.
* 6. Pruefung laeuft ueber den Index (`PRIMARY`, `rows: 1`), kein Scan.
* 7. `SIGNAL ... SET MESSAGE_TEXT = CONCAT(...)` ist **nicht** erlaubt
* ("Undeclared variable: CONCAT") — die SET-Klausel akzeptiert nur
* Literale oder Variablen. Ausweg: `DECLARE msg VARCHAR(128)` +
* `SET msg = CONCAT(...)` + `SIGNAL ... = msg`. Nur so laesst sich die
* Objekt-UID in die Fehlermeldung aufnehmen.
*
* ## Reihenfolge-Anforderung an den Aufrufer
*
* Der alte Link muss **vor** dem neuen `INSERT` geschlossen sein, sonst
* blockiert der Trigger. Die lebenden Pfade halten das ein:
* `migratePerson.js` loescht `deltaOldPlus` (enthaelt immer das alte
* `memberA`-Ziel) vor dem `INSERT` des neuen Ziels.
*
* ## Folge fuer die Anwendung
*
* Ein Verstoss ist ab jetzt `errno 1644` / `SQLState 45000`. Die API-Schicht
* sollte das auf **409** (oder 400) mappen — sonst wird aus einem
* Datenproblem ein 500er.
*
* Rueckbau: `DROP TRIGGER IF EXISTS trg_links_single_membera_bi;`
*/
export const id = '20260925-links-single-membera-trigger';
export const name = 'Invariante: genau ein aktiver memberA-Link pro Objekt (Trigger)';
/**
* Name des Triggers — auch fuer die Idempotenz-Pruefung.
*
* Exportiert, damit Test-Fixtures die Invariante **befristet** ausschalten
* koennen, wenn sie Altbestand simulieren muessen. Siehe
* `src/__tests__/.helpers/memberAInvariant.js`.
*/
export const TRIGGER_NAME = 'trg_links_single_membera_bi';
/**
* Die Trigger-DDL selbst — **eine** Quelle fuer Migration und Test-Fixtures,
* damit beide nie auseinanderlaufen.
*
* Zwei Fallstricke stecken hier drin:
*
* - `SIGNAL ... SET MESSAGE_TEXT = CONCAT(...)` ist **nicht** erlaubt
* ("Undeclared variable: CONCAT"): die SET-Klausel von SIGNAL akzeptiert nur
* Literale oder Variablen, keine Ausdruecke. Deshalb erst die Meldung per
* `SET` (dort sind Funktionen erlaubt) in eine Variable schreiben und diese
* an SIGNAL uebergeben. So bleibt die Objekt-UID in der Fehlermeldung und
* der Verursacher ist ohne Nachfrage im Log identifizierbar.
* Die Meldung ist 85 Zeichen — unter dem Limit von 128 fuer MESSAGE_TEXT.
* - `UIDTarget <> NEW.UIDTarget` ist Pflicht. Der Trigger feuert *vor* der
* Duplikatspruefung, ein zweiter `INSERT IGNORE` **desselben** Links (heute
* ein stiller No-Op) wuerde sonst 1644 werfen und idempotente Re-Inserts an
* ~20 Stellen brechen.
*/
export const TRIGGER_DDL = `
CREATE TRIGGER ${TRIGGER_NAME} BEFORE INSERT ON Links FOR EACH ROW
BEGIN
DECLARE msg VARCHAR(128);
IF NEW.Type = 'memberA' AND EXISTS (
SELECT 1 FROM Links
WHERE UID = NEW.UID
AND Type = 'memberA'
AND UIDTarget <> NEW.UIDTarget
AND ValidUntil > NOW()
) THEN
SET msg = CONCAT(
'invariant: object ', HEX(NEW.UID),
' already has an active memberA link'
);
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = msg;
END IF;
END
`;
/**
* Erzwingt die Eindeutigkeit von `memberA`-Links auf DB-Ebene.
*
* Legt einen `BEFORE INSERT`-Trigger an. Nur `INSERT` — siehe Migrationkopf,
* Punkt 3: `UPDATE` braucht keine Pruefung, weil weder die legitimen Updates
* noch die internen Versionierungs-Inserts den Trigger faelschlich ausloesen.
*
* Idempotent: der Trigger wird verworfen und neu angelegt, damit eine
* spaetere Aenderung der Definition beim erneuten Lauf greift.
*
* @param {{ query: Function }} api
* @returns {Promise<void>}
*/
export const migrate = async ({ query }) => {
// DDL auf `Links` — die Tabelle ist system-versioned. `CREATE TRIGGER`
// braucht dafuer kein `system_versioning_alter_history` (nur echte
// ALTERs brauchen das, siehe 20260619-objectbase-fulltext-index).
await query(`DROP TRIGGER IF EXISTS ${TRIGGER_NAME}`, []);
await query(TRIGGER_DDL, []);
// Verifizieren, dass der Trigger wirklich am Server haengt. Ohne diese
// Pruefung waere ein stiller Fehlschlag (falsche Rechte, anderes Schema)
// erst am ersten Duplikat in Produktion sichtbar.
const [{ anzahl }] = await query(
`SELECT COUNT(*) AS anzahl FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = DATABASE()
AND TRIGGER_NAME = ?`,
[TRIGGER_NAME],
);
if (!anzahl) {
throw new Error(`Trigger ${TRIGGER_NAME} wurde nicht angelegt`);
}
// Bestandsverstoesse werden NICHT angefasst — der Trigger bewacht nur neue
// Inserts. Zur Transparenz im Migrationslog mitzaehlen, damit beim Rollout
// sichtbar ist, wie viele Objekte die Bereinigung noch erwartet.
const [{ objekte }] = await query(
`SELECT COUNT(*) AS objekte FROM (
SELECT UID FROM Links
WHERE Type = 'memberA' AND ValidUntil > NOW()
GROUP BY UID HAVING COUNT(*) > 1
) x`,
[],
);
console.log(
` Invariante aktiv. Bestandsverstoesse (separat zu bereinigen): ${objekte} Objekte`,
);
};