db — Datenbankzugriff¶
Mit db führen Skripte parametrisierte SQL-Anweisungen aus — ohne
Verbindungen öffnen oder schließen zu müssen. db selbst arbeitet auf der
KCForms-Datenbank; mit db.on("name") sprichst du eine
externe Datenquelle an. Beide haben dieselben
Methoden.
Die Grundmethoden — query und update¶
| Methode | Liefert | Zweck |
|---|---|---|
db.query(sql, params) |
Array<Map> — ein Objekt pro Zeile |
SELECT |
db.update(sql, params) |
Number — Anzahl betroffener Zeilen |
INSERT/UPDATE/DELETE |
Dazu kommen Einzelwerte (scalar, queryOne, exists), zeilenweises
Lesen (forEach), Insert mit Schlüssel-Rückgabe (insert), Massen-
Schreiben (batch), Transaktionen (tx), Stored Procedures (call) und
Metadaten (meta, tables, columns, ping) — siehe
Kurzreferenz.
Ergebniszeilen sind Objekte mit den Spaltennamen in Kleinschreibung als Schlüssel — unabhängig davon, wie die Datenbank sie liefert (Oracle etwa meldet Großbuchstaben):
var rows = db.query("SELECT id_user, login_name FROM USERS WHERE login_name = ?", "admin");
log.info(rows.length + " Treffer");
log.info(rows[0].login_name); // "admin"
Vergib für berechnete Spalten einen Alias (COUNT(*) AS n), dann liest
du sie als rows[0].n.
Zeilen sind Java-Objekte
JSON.stringify(zeile) und Object.keys(zeile) liefern bei Ergebniszeilen
nichts Brauchbares ({} bzw. Java-Methodennamen). Werte liest du über den
Spaltennamen (zeile.login_name); alle Spalten durchläuft
for (var spalte in zeile) { … }.
Tabellennamen unterscheiden je nach Datenbank Groß- und Kleinschreibung —
auf MySQL unter Linux etwa heißt die Benutzertabelle USERS, nicht users.
Übungstabellen für diese Seite¶
Die Beispiele unten greifen auf ein paar kleine Tabellen zu. Einmal anlegen, dann sind sie der Reihe nach lauffähig; ganz unten steht das Aufräumen.
db.update("CREATE TABLE IF NOT EXISTS bsp_kunde (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, kundennr VARCHAR(20), name VARCHAR(200)," +
" email VARCHAR(200), plz VARCHAR(10), credit_limit DECIMAL(12,2))");
db.update("CREATE TABLE IF NOT EXISTS bsp_buchung (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, status VARCHAR(20), betrag DECIMAL(12,2), jahr INT)");
db.update("CREATE TABLE IF NOT EXISTS bsp_auftrag (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, kunde VARCHAR(20), angelegt TIMESTAMP NULL)");
db.update("CREATE TABLE IF NOT EXISTS bsp_position (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, auftrag BIGINT, artikel VARCHAR(40), menge INT)");
db.update("CREATE TABLE IF NOT EXISTS bsp_mahnung (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, kunde VARCHAR(20), betrag DECIMAL(12,2))");
if (db.scalar("SELECT COUNT(*) FROM bsp_kunde") == 0) {
db.update("INSERT INTO bsp_kunde (kundennr, name, email, plz, credit_limit) VALUES " +
"('K-101','Meyer GmbH','info@meyer.example','40699',5000.00)," +
"('K-102','Nilsson AB','kontakt@nilsson.example','40699',12000.00)");
db.update("INSERT INTO bsp_buchung (status, betrag, jahr) VALUES " +
"('OFFEN',120.50,2026),('GEBUCHT',80.00,2026)");
}
log.info("Übungstabellen bereit: " + db.scalar("SELECT COUNT(*) FROM bsp_kunde") + " Kunden");
Zwei Parameter-Stile¶
Positionale ?-Platzhalter oder benannte Parameter ${name} — beide
sind erlaubt. Ab mehr als zwei Parametern sind benannte übersichtlicher.
1. Positional — ?¶
Die Argumente werden in der Reihenfolge der ? eingesetzt — ein
Argument pro ?; fehlt eines oder ist eines zu viel, bricht der Aufruf
mit einem Fehler ab.
Einzelner null-Wert
Ein einzelner null-Parameter kann bei der positionalen Variante
verloren gehen. Dann auf benannte Parameter umstellen — dort gehen
null-Werte sauber durch.
2. Benannt — ${name} mit einem Objekt¶
db.query(
"SELECT COUNT(*) AS n FROM form_bestellung " +
"WHERE id_user = ${u} AND land = ${l}",
{ u: ctx.user.idUser, l: 'DE' }
);
- Kommt
${u}mehrfach vor, wird an jeder Stelle derselbe Wert eingesetzt. - Fehlt ein Schlüssel im Objekt, kommt eine klare Fehlermeldung („Missing named parameter 'foo'. Available keys: …").
- Erlaubte Namen:
[A-Za-z_][A-Za-z0-9_]*— keine Punkte oder Bindestriche.
${…} im SQL ist kein Makro
Im SQL von db.* steht ${name} für einen Parameter aus dem
übergebenen Objekt. Mit Makros
wie ${user:firstName} hat das nichts zu tun.
Typische Muster¶
Nachschlagen, dann entscheiden¶
// onValidate
var kunde = db.queryOne(
"SELECT credit_limit FROM bsp_kunde WHERE kundennr = ${k}",
{ k: ctx.form.kundennr }
);
if (!kunde) {
ctx.addError('kundennr', 'Unbekannte Kundennummer.');
} else if (kc.num(ctx.form.betrag) > kc.num(kunde.credit_limit)) {
ctx.addError('betrag', 'Über dem Kreditlimit (' + kunde.credit_limit + ').');
}
Update mit Rückmeldung¶
// Skript-Konsole — Buchung markieren
var n = db.update(
"UPDATE bsp_buchung SET status = 'GEBUCHT' WHERE id = ${id} AND status = 'OFFEN'",
{ id: 4711 }
);
log.info(n === 1 ? 'gebucht' : 'nichts geändert (war evtl. schon gebucht)');
db.update liefert die Anzahl betroffener Zeilen. Erwartest du genau 1
und bekommst 0, hatte der Satz nicht mehr den erwarteten Status.
Neuer Satz mit generierter ID¶
db.insert liefert den generierten Schlüssel direkt zurück — auf
allen Datenbanken, ohne herstellerspezifisches LAST_INSERT_ID():
var newId = db.insert(
"INSERT INTO bsp_mahnung (kunde, betrag) VALUES (${k}, ${b})",
{ k: ctx.form.kundennr, b: kc.num(ctx.form.summe) }
);
log.info('Mahnung #' + newId + ' angelegt');
Parameter-Objekt wiederverwenden¶
var p = { u: ctx.user.idUser };
var offen = db.scalar("SELECT COUNT(*) FROM form_bestellung WHERE id_user = ${u} AND land = 'DE'", p);
var gesamt = db.scalar("SELECT COUNT(*) FROM form_bestellung WHERE id_user = ${u}", p);
Was nicht geht¶
- Keine Mehrfach-Anweisungen —
db.query("SELECT 1; SELECT 2")läuft nicht. Pro Aufruf eine Anweisung. - Platzhalter nicht in Anführungszeichen —
LIKE '${q}'funktioniert nicht. Die Jokerzeichen gehören in den Wert:db.query("SELECT … WHERE name LIKE ${q}", { q: '%abc%' }). - Keine Tabellen von KCForms umbauen — DDL läuft technisch zwar, aber
die Formular-Tabellen (
form_<id>) gleicht KCForms beim Speichern des Formulars selbst ab, die System-Tabellen verwaltet die Installation. EigeneALTER TABLEdarauf können beim nächsten Abgleich oder Update kollidieren.
Performance¶
- Die Verbindungen kommen aus einem Pool; schließen musst du nichts.
- Eine einzelne Abfrage ist schnell, aber tausende kleine Abfragen in
einem Hook summieren sich — und das Zeitlimit
von 6 Sekunden gilt für das ganze Skript. Lieber eine Abfrage mit
INoderJOIN, zum Schreibendb.batch. db.querylädt das ganze Ergebnis in den Speicher. Für große Ergebnismengendb.forEachnehmen oder die Zeilen begrenzen (db.limit).
Sicherheitsgrenzen¶
Skripte arbeiten mit dem Datenbank-Konto von KCForms, nicht mit den
Rechten des angemeldeten Benutzers. Die Datensatz-Rechte, die KCForms in
Listen und Formularen durchsetzt, greifen hier nicht — ein
SELECT * FROM form_xxx sieht alle Datensätze, auch die anderer Benutzer.
Wer benutzerbezogen lesen will, filtert selbst:
Die Verwaltungs-Tabellen von KCForms (users, groups, permissions,
app_settings, audit_event, form_definition …) bitte nicht per
Skript ändern, sondern über die System-Administration — sonst passen
zwischengespeicherte Einstellungen nicht mehr zum Datenbankstand.
Alle Methoden¶
db und jedes Handle aus db.on(...) haben dieselben Methoden. Was
unten für db steht, gilt genauso für db.on("sap-erp"). Parameter
übergibst du wie oben positional (?) oder benannt (${name} + Objekt).
Kurzreferenz¶
| Methode | Liefert | Zweck |
|---|---|---|
db.on(nameOderId) |
db-Handle |
Auf externe Datenquelle umschalten |
db.query(sql, …) |
Array<Map> |
SELECT — alle Zeilen |
db.queryOne(sql, …) |
Map oder null |
Erste Zeile (oder null) |
db.scalar(sql, …) |
Einzelwert oder null |
Erste Spalte der ersten Zeile |
db.exists(sql, …) |
boolean |
Gibt es ≥ 1 Zeile? |
db.forEach(sql, fn, …) |
— | Callback je Zeile, ohne Gesamtliste |
db.update(sql, …) |
Number |
INSERT/UPDATE/DELETE — betroffene Zeilen |
db.insert(sql, …) |
generierter Schlüssel | INSERT + Auto-Increment-ID |
db.batch(sql, rows) |
Array<Number> |
Bulk — dasselbe SQL für viele Sätze |
db.tx(fn) |
Rückgabe von fn |
Alles-oder-nichts-Transaktion |
db.call(sql, …) |
Zeilen oder Number |
Stored Procedure / DB-Funktion |
db.meta() |
{product, version, dialect, source} |
DB-Steckbrief |
db.tables() |
Array<String> |
Tabellennamen |
db.columns(tabelle) |
Array<{name,type,size,nullable}> |
Spalten einer Tabelle |
db.ping() |
boolean |
Verbindungstest |
db.limit(coreSelect, n) |
String |
Dialektgerechtes Zeilenlimit anhängen |
Dazu zwei Eigenschaften (keine Methoden):
| Eigenschaft | Typ | Bedeutung |
|---|---|---|
db.dialect |
String |
"mysql" | "postgresql" | "sqlserver" | "oracle" |
db.source |
String |
Name der Quelle; "intern" bei der KCForms-DB |
Bei db selbst meldet db.dialect das System, auf dem KCForms tatsächlich
läuft (erkannt beim Start); db.limit(...) erzeugt entsprechend die passende
Form. Bei Handles aus db.on(...) gilt der Typ der Datenquelle.
Externe Datenquellen — db.on(...)¶
db.on(nameOderId) liefert ein Handle auf eine benannte externe
Datenquelle. Ob du den Namen oder die numerische ID übergibst,
ist egal:
// gegen eine externe Quelle, benannte Parameter
var kunden = db.on("demo-mysql").query(
"SELECT * FROM bsp_kunde WHERE plz = ${plz}",
{ plz: "40699" }
);
log.info(kunden.length + " Kunden im PLZ-Gebiet");
// dieselbe Quelle per numerischer ID
var h = db.on(5);
log.info("Quelle: " + h.source + " / Dialekt: " + h.dialect);
Datenquellen pflegst du unter System-Administration → Schnittstellen & Verteilung → Datenquellen (Verbindungs-URL, Treiber, Zugangsdaten, aktiv/inaktiv) — siehe Datenquellen.
Nicht gefunden = Exception
Existiert die Quelle nicht oder ist sie deaktiviert, wirft
db.on(...) sofort eine Exception. Kein stilles null. Fange das
ab, wenn ein Formular ohne die externe Quelle noch sinnvoll
weiterlaufen soll.
Das Handle trägt seinen Kontext mit sich: db.on("oracle-prod").dialect
ist "oracle", db.on("oracle-prod").source ist "oracle-prod". Das
brauchst du für dialektabhängiges SQL (siehe unten).
Beispiel — externe Oracle-Quelle mit benannten Parametern¶
// onLoad — Stammdaten aus dem führenden Oracle-System ziehen
var oracle = db.on("oracle-prod");
var row = oracle.queryOne(
"SELECT name, bonitaet FROM stammdaten.kunde WHERE kundennr = ${nr}",
{ nr: ctx.form.kundennr }
);
if (row) {
ctx.form.kundenname = row.name;
ctx.form.bonitaet = row.bonitaet;
} else {
ctx.addError('kundennr', 'Im Oracle-Stammsystem nicht gefunden.');
}
Lesen — queryOne, scalar, exists, forEach¶
Für die häufigen „ich will nur einen Wert / eine Zeile / ja-nein"-Fälle
gibt es schlanke Methoden, statt jedes Mal db.query(...)[0].x zu
schreiben.
db.queryOne — erste Zeile als Objekt, oder null, wenn nichts
gefunden wurde:
var kunde = db.queryOne(
"SELECT id, name, credit_limit FROM bsp_kunde WHERE kundennr = ${k}",
{ k: ctx.form.kundennr }
);
if (kunde === null) {
ctx.addError('kundennr', 'Unbekannte Kundennummer.');
} else {
log.info(kunde.name + " / Limit " + kunde.credit_limit);
}
db.scalar — genau ein Einzelwert (erste Spalte der ersten Zeile),
oder null. Ideal für COUNT, MAX, ein einzelnes Feld:
var anzahl = db.scalar(
"SELECT COUNT(*) FROM form_instance WHERE form_id = ${f}",
{ f: ctx.formId }
);
log.info("Instanzen: " + anzahl);
db.exists — boolean, true sobald mindestens eine Zeile
zurückkommt. exists liest das ganze Ergebnis — begrenze es deshalb im
SQL auf eine Zeile:
var schonDa = db.exists(
"SELECT 1 FROM bsp_kunde WHERE email = ${m} LIMIT 1",
{ m: ctx.form.email }
);
if (schonDa) {
ctx.addError('email', 'E-Mail bereits vergeben.');
}
db.forEach — ruft einen Callback je Zeile auf, ohne im Skript
die komplette Ergebnisliste im Speicher zu halten. Genau richtig für
große Resultsets (Streaming):
// Export-Hook — über tausende Zeilen laufen, ohne alles auf einmal zu laden
var summe = 0;
db.forEach(
"SELECT betrag FROM bsp_buchung WHERE jahr = ${j}",
function(row) {
summe += kc.num(row.betrag);
},
{ j: 2026 }
);
log.info("Jahressumme: " + summe);
Die params stehen bei forEach nach dem Callback — der Callback
ist das zweite Argument, die Parameter folgen als Varargs bzw.
Objekt danach.
Schreiben — insert und batch¶
db.insert führt das INSERT aus und liefert den generierten
Schlüssel (Auto-Increment bzw. Identity-Spalte) zurück.
// onSubmit — Auftrag anlegen, generierte ID gleich weiterverwenden
var auftragId = db.insert(
"INSERT INTO bsp_auftrag (kunde, angelegt) VALUES (${k}, CURRENT_TIMESTAMP)",
{ k: ctx.form.kundennr }
);
log.info("Neuer Auftrag #" + auftragId);
ctx.form.auftragsnummer = auftragId;
db.batch führt dasselbe SQL für viele Datensätze aus — der
klassische effiziente Bulk-Insert/-Update. rows ist entweder ein
Array von Arrays (positional ?) oder ein Array von Objekten
(${name}). Zurück kommt je Zeile die Anzahl betroffener Datensätze.
var auftragId = db.insert("INSERT INTO bsp_auftrag (kunde) VALUES (${k})",
{ k: "K-101" });
// Variante A — Array von Objekten, benannte Parameter
var counts = db.batch(
"INSERT INTO bsp_position (auftrag, artikel, menge) " +
"VALUES (${a}, ${art}, ${m})",
[
{ a: auftragId, art: "A-100", m: 3 },
{ a: auftragId, art: "B-200", m: 1 },
{ a: auftragId, art: "C-300", m: 7 }
]
);
log.info("Positionen eingefügt: " + counts.length);
var auftragId = db.insert("INSERT INTO bsp_auftrag (kunde) VALUES (?)", "K-101");
// Variante B — Array von Arrays, positional ?
db.batch(
"INSERT INTO bsp_position (auftrag, artikel, menge) VALUES (?, ?, ?)",
[
[auftragId, "A-100", 3],
[auftragId, "B-200", 1],
[auftragId, "C-300", 7]
]
);
Batch statt Schleife
Tausend einzelne db.update-Aufrufe in einer JS-Schleife sind
langsam (Roundtrip pro Satz). db.batch bündelt sie in einem
JDBC-Batch — oft um Größenordnungen schneller.
Transaktionen — db.tx¶
Sobald mehrere zusammengehörige Schreibvorgänge konsistent bleiben
müssen (Kopf + Positionen, Umbuchung von A nach B …), führt kein Weg an
db.tx vorbei. Der Callback läuft in einer Transaktion:
- Läuft er sauber durch → Commit.
- Wirft er (
throw, oder ein SQL-Fehler) → komplettes Rollback.
Das Callback-Argument t ist ein db-Handle auf derselben
Datenquelle. Alle Anweisungen über t sind atomar.
// onSubmit — Auftrag + Positionen atomar einfügen
db.tx(function(t) {
var auftragId = t.insert(
"INSERT INTO bsp_auftrag (kunde, angelegt) VALUES (${k}, CURRENT_TIMESTAMP)",
{ k: ctx.form.kundennr }
);
t.batch(
"INSERT INTO bsp_position (auftrag, artikel, menge) VALUES (${a}, ${art}, ${m})",
(ctx.form.positionen || []).map(function(p) {
return { a: auftragId, art: p.artikel, m: p.menge };
})
);
// Fachliche Prüfung MITTEN in der Transaktion:
if (t.scalar("SELECT SUM(menge) FROM bsp_position WHERE auftrag = ${a}",
{ a: auftragId }) > 1000) {
// Ein throw rollt ALLES zurück — auch der Auftrag verschwindet wieder.
throw new Error("Gesamtmenge über Limit — Auftrag verworfen.");
}
});
db.tx funktioniert genauso auf einem externen Handle:
db.on("oracle-prod").tx(function(t) {
t.update("UPDATE konto SET saldo = saldo - ${b} WHERE id = ${von}",
{ b: betrag, von: kontoVon });
t.update("UPDATE konto SET saldo = saldo + ${b} WHERE id = ${nach}",
{ b: betrag, nach: kontoNach });
// Wirft die zweite Anweisung, wird die erste zurückgerollt — kein
// halb ausgeführter Übertrag.
});
Transaktionen sind pro Datenquelle
Ein db.tx(...) klammert nur Anweisungen derselben Quelle. Es
gibt kein verteiltes Zwei-Phasen-Commit über mehrere Quellen
hinweg: Schreibst du im selben Hook in db und in
db.on("oracle-prod"), sind das zwei getrennte Transaktionen — die
eine kann committen, während die andere scheitert. Für echte
Konsistenz alle beteiligten Schreibvorgänge in eine Quelle
legen.
Stored Procedures — db.call¶
db.call führt eine gespeicherte Prozedur oder DB-Funktion über ein
CallableStatement aus. Gibt die Prozedur ein Resultset zurück,
bekommst du die Ergebniszeilen; andernfalls die Anzahl
betroffener Sätze.
// Prozedur ohne Resultset — Rückgabe ist die betroffene Zeilenzahl
var n = db.call("{call kunde_anlegen(?)}", ctx.form.name);
log.info("kunde_anlegen ausgeführt: " + n);
// Prozedur mit Resultset
var zeilen = db.call("{call top_kunden(?)}", 10);
zeilen.forEach(function(r) { log.info(r.name + " / " + r.umsatz); });
OUT-Parameter & Funktionen
Prozeduren mit vielen OUT-Parametern werden nur eingeschränkt unterstützt. Für PostgreSQL-/Oracle-Funktionen ist oft der direkte Weg einfacher und portabler:
Metadaten — meta, tables, columns, ping, limit¶
Rund um „was ist das eigentlich für eine DB, und was steckt drin" gibt es eine kleine Metadaten-Familie:
var m = db.meta();
// { product: "MySQL", version: "8.0.36", dialect: "mysql", source: "intern" }
log.info(m.product + " " + m.version + " (" + m.dialect + ")");
var alle = db.tables(); // ["users", "form_definition", "buchung", …]
log.info(alle.length + " Tabellen");
var spalten = db.columns("bsp_kunde");
// [ { name: "id", type: "BIGINT", size: 20, nullable: false },
// { name: "name", type: "VARCHAR", size: 200, nullable: true }, … ]
spalten.forEach(function(c) {
log.info(c.name + " : " + c.type + "(" + c.size + ")" +
(c.nullable ? " NULL" : " NOT NULL"));
});
if (!db.on("demo-mysql").ping()) {
ctx.addError(null, 'Stammsystem gerade nicht erreichbar.');
}
db.limit(coreSelect, n) hängt ein dialektgerechtes Zeilenlimit
an ein SELECT — praktisch, wenn dasselbe Skript gegen verschiedene
DB-Typen laufen soll:
| Dialekt | Ergebnis von db.limit("SELECT * FROM kunde", 50) |
|---|---|
| MySQL / PostgreSQL | SELECT * FROM bsp_kunde LIMIT 50 |
| SQL Server | SELECT TOP (50) * FROM kunde |
| Oracle | SELECT * FROM bsp_kunde WHERE ROWNUM <= 50 (sinngemäß) |
var q = db.on("mssql-dw");
var sql = q.limit("SELECT kundennr, name FROM bsp_kunde ORDER BY umsatz DESC", 20);
var top20 = q.query(sql);
Dialektportable Abfragen¶
Zwei Wege, dasselbe Skript über mehrere DB-Typen lauffähig zu halten:
1. Über db.limit — für den häufigsten Fall (Zeilenlimit) reicht
das schon, siehe oben.
2. Über db.dialect-Verzweigung — wenn sich das SQL wirklich
unterscheidet (RETURNING, Sequenzen, String-Funktionen …):
var q = db.on("demo-mysql");
var sql;
if (q.dialect === "oracle") {
sql = "SELECT * FROM bsp_kunde WHERE ROWNUM <= 100";
} else if (q.dialect === "sqlserver") {
sql = "SELECT TOP (100) * FROM kunde";
} else {
// mysql / postgresql
sql = "SELECT * FROM bsp_kunde LIMIT 100";
}
var rows = q.query(sql);
Dialekt-Unterschiede im Blick behalten
Neben LIMIT/TOP/ROWNUM unterscheiden sich die Dialekte u.a.
bei RETURNING, Auto-Increment vs. Sequenzen und String-/
Datumsfunktionen. db.dialect und db.limit decken die häufigsten
Fälle ab; für alles andere lohnt eine kurze Fallunterscheidung.
Demo zum Anfassen — Formular „DB-Client (Demo)"¶
Das Beispiel „DB-Client (Demo)" führt die Methoden live vor — gegen die
Spieltabelle demo_kv und die Beispiel-Datenquelle demo-mysql (die auf
die KCForms-Datenbank selbst zeigt). Die Schaltflächen rufen je einen Hook
auf: query/queryOne, scalar, insert, tx, batch, db.on und
meta/tables/columns.
Installiert wird es unter System-Administration → Formular-Bausteine → Beispiele; danach steht es im Formular-Katalog.
Übungstabellen wieder entfernen¶
Wenn du mit der Seite durch bist, räumt dieses Skript die oben angelegten Tabellen wieder weg:
["bsp_position", "bsp_auftrag", "bsp_mahnung", "bsp_buchung", "bsp_kunde"]
.forEach(function (t) { db.update("DROP TABLE IF EXISTS " + t); });
log.info("Übungstabellen entfernt");
Sicherheit¶
SQL-Injection — immer Parameter, nie String-Konkatenation
Für jede Methode gilt: Werte gehören in Parameter (? oder
${name}), niemals per String-Verkettung ins SQL. Auch bei
insert, batch, call und tx. Kein
"… WHERE name = '" + ctx.form.name + "'" — das ist die klassische
Injection-Lücke.
Interne DB vs. externe Quellen bewusst wählen
db ist die interne KCForms-DB — Schreibzugriffe dort treffen
ggf. System-Tabellen (siehe Sicherheitsgrenzen).
Externe Systeme sprichst du bewusst über db.on("…") an; so
verwechselst du interne und externe Ziele nicht versehentlich.