orgTransfer.js — Export / Import / Netzwerk-Kopie genau EINER Organisation
aus der members-MariaDB.
Problem: Die members-Datenbank ist mandantenfähig, aber nicht über eine
OrgUID-Spalte getrennt, sondern als Objekt-Graph. Eine Organisation ist ein
`group`-Objekt mit `Data.root = true`, an dem alle ihre Objekte über
"Propagations-Links" hängen (member, memberA, memberS, memberG, memberSys,
member0, memberGA). Ein mysqldump der ganzen DB ist für einen Umzug einer
einzelnen Organisation also unbrauchbar.
Dieses Skript berechnet den Objekt-Scope einer Organisation rekursiv,
exportiert die Kern-Tabellen als einspielbares SQL und kann das Ergebnis
wahlweise in eine Datei schreiben, direkt in eine andere Datenbank
streamen (Netzwerk-Kopie) oder aus einer Datei wieder einspielen.
node src/scripts/orgTransfer.js inspect --org
node src/scripts/orgTransfer.js export --org [Optionen]
node src/scripts/orgTransfer.js import --in [Optionen]
node src/scripts/orgTransfer.js copy --org --to-db [Optionen]
Als `--org` darf jede Objekt-UID übergeben werden: ist es nicht selbst die
Organisation, wird die zugehörige Organisation über die Propagations-Links
aufgelöst (z.B. eine Person -> ihre Organisation).
UUIDs werden über die DB-Funktionen `UUID2BIN()` / `BIN2UUID()` konvertiert
(dieselben, die auch das Backend benutzt). Die Ziel-Datenbank muss diese
Funktionen besitzen — sie kommen aus `initFunctions.sql`.
Siehe `--help` für alle Optionen.
- Source:
Classes
Members
(inner, constant) EXTRA_SCOPES
Zusätzliche, explizit anzufordernde Scope-Erweiterungen.
Der Propagations-Scope enthält nur die "eigenen" Objekte der Organisation.
Bestimmte Objekte werden von ihnen nur *referenziert* und fehlen dadurch —
ohne sie funktioniert aber z.B. eine dynamische Liste nicht:
- `filters` : `include`/`exclude`/`intersect`-Objekte einer `dlist`.
Sie sind das ZIEL eines `dynamic`-Links, werden also nur
über eine Vorwärts-Verfolgung gefunden. Im Test fehlten
129 Filter-Objekte bei ~36k Referenzen.
- `templates` : Vorlagen/Kataloge (`achievementT`, `eventT`, `actionT`,
`function`, `eventJobT`, ...) — in beide Richtungen
verlinkt.
`outgoing` folgt den Links von Scope-Objekten weg (referenzierte Ziele),
`incoming` folgt Links in den Scope hinein. Beides läuft bis zum Fixpunkt,
Fremd-Organisationen werden dabei nicht hineingezogen.
- Source:
(inner, constant) NUMBER_RE
Nur echte Nutzdaten durchlassen (Schutz gegen kaputte Treiber-Werte).
- Source:
(inner, constant) NUMERIC_TYPES
Datentypen, die als nackte Zahl ausgegeben werden.
- Source:
(inner, constant) ORG_OWNED_MAX_ROUNDS
Zugehörigkeit über `UIDBelongsTo` wird **transitiv** verfolgt.
`UIDBelongsTo` ist laut Schema Inheritance/Ownership — die Aussage „gehört
diesem Objekt". Sie ist damit ein Graph-Rand wie ein Link, nur dass der
Export ihm bisher gar nicht folgte. Zwei Fälle fallen dadurch heraus:
1. **Eltern-Objekte statt Organisation.** `include`/`exclude`/`intersect`
hängen an ihrem `list`/`group`-Elternobjekt, nicht an der Organisation
(gemessen: 155 an `list`, 142 an `group`). `action`/`eventJobT` hängen an
`eventT`. Ein Prädikat `UIDBelongsTo = ` sieht davon nichts.
2. **Tiefer als eine Ebene.** Gemessen in der Dev-DB: 79 Objekte auf Tiefe 2
(67 `eventJobT` + 12 `action`, alle an einem `eventT`), verteilt auf eine
Organisation. `--include filters,templates` hilft dort nicht, weil der
`templates`-Scope *Links* folgt und diese Objekte gar keine tragen.
Deshalb wird bis zum Fixpunkt wiederholt, nicht einmalig. Ein einzelner
`INSERT ... JOIN __scope` pro Runde, Abbruch bei `affectedRows = 0`; die
Obergrenze ist nur ein Sicherheitsnetz gegen einen Zyklus, der durch
`INSERT IGNORE` ohnehin nicht entstehen kann.
**Guard:** Sub-Organisationen (`Data.root = true`) werden nicht hineingezogen —
die sind eigenständige Organisationen mit eigenem Export. Dieselbe Regel
benutzt die Link-Closure unten.
- Source:
(inner, constant) PROPAGATION_LINK_TYPES
Link-Typen, über die Zugehörigkeit zur Organisation propagiert wird.
Dies sind exakt die Typen aus `getOrganizationForObject()` im Backend
(`src/utils/organizationUtils.js`) — dort wird dieselbe Semantik genutzt,
um zu einem Objekt seine Organisation zu finden. Umgekehrt aufgezogen
ergeben sie den vollständigen Objekt-Bestand einer Organisation.
- Source:
(inner, constant) SCOPE_TABLES
Optionale Scope-Flags -> Tabellen, die sie mitbringen.
- Source:
(inner, constant) STRING_TYPES
Datentypen, die als String-Literal ausgegeben werden.
- Source:
(inner, constant) TABLE_SPECS
Tabellen, die exportiert werden können.
`scope`:
'org' — über die Objekt-UID (`__scope`)
'member' — Member-Zeilen, die von Objekten des Scopes benutzt werden
'accounting' — über `Transactions.UIDOrganization`
'events' — über `eventLog.UIDorga`
`always`: gehört zum Kern-Export (Default-Tabellensatz).
- Source:
Methods
(async, inner) applyDump(conn, readable, opts)
Dump-Statements auf eine Verbindung ausspielen.
Parameters:
| Name | Type | Description |
|---|---|---|
conn |
any | Ziel-Verbindung |
readable |
NodeJS.ReadableStream | Dump-Quelle |
opts |
object |
- Source:
(async, inner) assertTargetReady()
Voraussetzungen der Zieldatenbank prüfen.
- Source:
(inner) bufferToBitString()
Buffer (bit) -> Dezimalstring.
- Source:
(async, inner) buildContext()
Scope + Metadaten der Quelle aufbauen.
- Source:
(async, inner) buildScope()
Objekt-Scope einer Organisation berechnen und in `__scope` materialisieren.
Der Scope wird als TEMPORARY TABLE gehalten: er wird von jeder Tabellen-
Query gebraucht und ist mit ~250k Zeilen zu groß für IN-Listen.
Der rekursive Arm expandiert nur von Objekten aus, die entweder die
Start-Organisation selbst sind oder keine Organisation sind. Dadurch kann
die Traversierung nicht in eine andere Organisation "überlaufen", falls
deren Root über einen Propagations-Link erreichbar wäre.
- Source:
(inner) buildSelect()
SELECT-Ausdrücke bauen.
Die Konvertierung passiert in SQL, nicht in JS: `BIN2UUID()` liefert den
lesbaren String, `HEX()` die Binärdaten, `ST_AsWKB()`/`ST_SRID()` die
Geometrie. Damit gibt es keine Zeitzonen- oder Encoding-Überraschungen
durch den Node-Treiber.
- Source:
(async, inner) checkTargetSchema()
Prüfen, ob die Zieldatenbank die im Dump verwendeten Tabellen und Spalten
besitzt.
Der Export liest die Spalten aus `information_schema` der Quelle. Ist das
Ziel eine ältere Migrationsstufe, fehlen dort Spalten — ohne diesen Check
bricht das Einspielen mitten im Dump mit einem kryptischen
"Unknown column" ab.
- Source:
(inner) defaultOutName()
Dateiname für den Dump.
- Source:
(inner) describeConn()
Verbindungsdaten für's Log (ohne Passwort).
Wichtig als Sicherheitsnetz: zeigt immer die *tatsächlich* benutzte
Verbindung, nicht die aus der Umgebung — sonst könnte ein Export aus Prod
versehentlich wie ein Export aus der Test-DB aussehen (oder umgekehrt).
- Source:
(inner) encodeValue()
Einen Treiber-Wert in ein SQL-Literal umwandeln.
UUIDs gehen über `UUID2BIN()` — die Gegenrichtung zu `BIN2UUID()` im
SELECT und die vom Projekt vorgegebene Konvertierung (siehe
`backend-uuid-approach`-Regel).
- Source:
(inner) footerSql()
Dump-Fuß.
- Source:
(inner) formatDateLocal()
Notfall-Formatierung für einen `Date`, falls der Treiber doch castet.
Normalerweise kommt das nicht vor (`dateStrings: true` in
openConnection) — aber ein stillschweigend verschobener Zeitstempel
wäre schlimmer als eine lokale Formatierung.
- Source:
(inner) headerSql()
Dump-Kopf.
- Source:
(inner) human()
Bytes menschenlesbar.
- Source:
(inner) id()
Bezeichner quoten.
- Source:
(inner) isEffectivelyEmpty()
Ist das Statement nur ein Kommentar/Whitespace?
- Source:
(inner) isRoot()
`Data.root` kommt je nach Zugriffsweg als Zahl, String oder boolean.
- Source:
(async, inner) loadColumns()
Alle Spalten der gewünschten Tabellen aus `information_schema` holen und
klassifizieren.
Damit bleibt das Skript schema-agnostisch: neue Spalten werden automatisch
mitgenommen, generierte Spalten automatisch ausgelassen.
- Source:
(async, inner) loadSecretsPreservingEnv() → {Promise.<Array.<string>>}
Secrets laden — aber explizite ENV-Vorgaben gewinnen.
`loadSecretsFromVault()` schreibt seine Werte **bedingungslos** nach
`process.env`, würde ein vorgesetztes `DB_HOST` also überschreiben. Deshalb
wird die Umgebung vorher gesichert und danach wieder hergestellt:
Treiber-Default < Vault (`GIT_SECRET_PATH`) < ENV < `--from-*` / `--to-*`
So kann man eine abweichende Quelle bequem per `-e DB_HOST=…` mitgeben,
ohne die Passwort-Flags auf der Kommandozeile zu brauchen.
- Source:
Returns:
Namen der Schlüssel, die Vorrang behalten haben
- Type
- Promise.<Array.<string>>
(async, inner) loadTableTypes()
Prüfen, ob eine Tabelle existiert und welcher Art sie ist.
Nur BASE TABLE / SYSTEM VERSIONED werden exportiert — die Views `Objects`,
`ObjectTargets` und `HasTarget` leiten sich aus den Basistabellen ab und
dürfen nicht eingespielt werden.
- Source:
(inner) normalizeOptions()
CLI-Argumente in einen Options-Objekt normalisieren.
- Source:
(async, inner) openConnection(optsopt) → {Promise.<any>}
Verbindung öffnen.
`dateStrings: true` ist hier wichtig: ohne das castet der mariadb-Treiber
DATETIME/TIMESTAMP-Werte in JS-`Date`-Objekte und wendet dabei die lokale
Zeitzone an. Für einen verlustfreien Dump brauchen wir den Roh-String des
Servers ('2026-06-29 13:08:26.234419').
Parameters:
| Name | Type | Attributes | Description |
|---|---|---|---|
opts |
object |
<optional> |
host/user/password/database-Overrides |
- Source:
Returns:
mariadb-Verbindung (nicht gepoolt)
- Type
- Promise.<any>
(async, inner) openDumpReadable()
Quelldatei öffnen, gzip transparent entpacken.
- Source:
(async, inner) openFileSink()
Schreib-Sink auf eine Datei (optional gzip).
- Source:
(inner) parseArgs()
Minimaler Argument-Parser (`--key wert`, `--flag`, `--key=wert`).
- Source:
(inner) parseDumpManifest() → {Map.<string, Array.<string>>}
Spalten-Manifest aus dem Dump-Kopf lesen.
Format: `-- @manifest Member:UID,Display|ObjectBase:UID,Type,...`
- Source:
Returns:
- Type
- Map.<string, Array.<string>>
(inner) parseDumpOrg()
Org-UID aus dem Dump-Kopf lesen (für den Vorab-Check).
- Source:
(inner) pick()
CLI-Wert, falls gesetzt — sonst der ENV-Fallback.
- Source:
(inner) planTables()
Auflösen, welche Tabellen mit welchen Spalten tatsächlich geschrieben werden.
Das Ergebnis steht sowohl im Dump-Manifest als auch im Insert-Loop — beides
muss dieselbe Sicht haben.
- Source:
(inner) printPurgeReport()
`--mode replace`-Report aufs Terminal.
- Source:
(async, inner) purgeTargetOrg(conn, orgUid, opts, tablesopt) → {Promise.<Array.<{table: string, removed: number}>>}
Vorhandene Organisation im Ziel entfernen — der „tolerante" Import-Pfad.
**Warum das überhaupt nötig ist.** `checkOrgaExists()` (config/initOrga.js)
läuft beim Backend-Start für **jede** Organisation, die eine Config-Datei
(`config/admin/UUID-.yaml`) hat, und legt sie an, falls sie fehlt:
Org-Objekt, BOT-User (`extern`), `@@SuperAdmin`-Job und Superuser-Filter —
mit Defaults, also einem Platzhalter-Namen, nicht mit den echten Daten.
Ein Dump-Import trifft dieses Ziel also praktisch **nie leer** an. Und ein
„drüberbügeln" kann nicht funktionieren, weil der Stub seine System-Objekte
bei jeder Erzeugung mit **neuen Zufalls-UIDs** anlegt (`SELECT UIDV1() AS
UIDExtern, UIDV1() AS UIDJob, UIDV1() AS UIDFilter` in initOrga.js). Diese
UIDs können nie mit den gleichnamigen Objekten aus dem Dump zusammenfallen:
es blieben doppelte BOT-User, zwei `@@SuperAdmin`-Jobs und zwei
Superuser-Filter zurück. Vorher scheitert der Import schon am
Primärschlüssel — `ObjectBase` ist auf `(UID, ValidUntil)` geschlüsselt, und
die offene Zeile (`ValidUntil` = 2106-02-07) ist bereits belegt; `Member`
(`UID`) und `Visible` (`UID, UIDUser`) ebenso.
**Was gelöscht wird.** Die Closure wird im Ziel mit denselben Regeln
aufgebaut wie beim Export (`buildScope`), dann Zeile für Zeile entfernt —
und nur für die Tabellen, die der Dump auch wieder einspielt. Was der Dump
nicht mitbringt, wird nicht angefasst.
Bei system-versionierten Tabellen (`ObjectBase`, `Links`) beendet das
`DELETE` nur die offene Version (`ValidUntil` = jetzt); die Historie bleibt
erhalten. Damit ist der Primärschlüssel frei und der anschließende
`INSERT` des Dumps passt wieder.
Abhängige Tabellen werden **vor** ihren Eltern gelöscht (`order` absteigend),
sonst räumt z.B. `TransactionLines` über einen Subselect auf schon
gelöschte `Transactions` nichts mehr weg.
Parameters:
| Name | Type | Attributes | Default | Description |
|---|---|---|---|---|
conn |
any | Ziel-Verbindung | ||
orgUid |
string | Organisations-UID | ||
opts |
object | Optionen (nutzt `include`, `tables`, `dryRun`) | ||
tables |
Array.<string> | null |
<optional> |
null | Tabellen des Dumps; `null` = Default-Satz |
- Source:
Returns:
- Type
- Promise.<Array.<{table: string, removed: number}>>
(inner) quote()
SQL-String-Literal mit MySQL-Backslash-Escapes (wie mysqldump).
- Source:
(async, inner) readHead()
Erste Bytes einer Datei als Text (für den Kopf-Kommentar).
- Source:
(async, inner) resolveOrganization()
Organisation zu einer beliebigen Objekt-UID bestimmen.
Ist die UID selbst eine Organisation (`Data.root = true`), wird sie direkt
verwendet. Andernfalls wird über die Propagations-Links die zugehörige
Organisation gesucht — dieselbe Query wie `getOrganizationForObject()` im
Backend.
- Source:
Returns:
(async, inner) runDump()
Lesbaren Stream zeilenweise durch den Splitter schicken.
- Source:
(async, inner) scopeReport()
Kurzreport über den Inhalt des Scopes (für inspect und export).
- Source:
(inner) secs()
Sekunden mit einer Nachkommastelle.
- Source:
(inner) selectedTables()
Tabellen für diesen Lauf bestimmen.
- Source:
(inner) shortUuid()
Kurzform einer UUID für Dateinamen.
- Source:
(inner) sourceOptions()
Auflösung der Org-UID aus CLI/Env-Overrides.
- Source:
(async, generator, inner) streamRows()
Ergebnis einer Query als Stream durchreichen.
Wichtig für `Visible` (~800k Zeilen) und `Links`: die Treiber-Query würde
sonst alle Zeilen gleichzeitig im Speicher halten. `queryStream` respektiert
Backpressure.
- Source:
(inner) tableFilter()
WHERE-Klausel einer Tabelle.
- Source:
(async, inner) targetHasOrg()
Prüfen, ob die Organisation im Ziel schon existiert.
- Source:
(inner) wantProgress()
Fortschrittszeilen nur auf einem echten Terminal ausgeben (\r in Pipes ist Müll).
- Source: