Zum Inhalt

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 — ?

db.query(
  "SELECT * FROM form_bestellung WHERE id_user = ? AND land = ?",
  ctx.user.idUser, 'DE'
);

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. Eigene ALTER TABLE darauf 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 IN oder JOIN, zum Schreiben db.batch.
  • db.query lädt das ganze Ergebnis in den Speicher. Für große Ergebnismengen db.forEach nehmen 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:

db.query(
  "SELECT * FROM form_bestellung WHERE id_user = ${u}",
  { u: ctx.user.idUser }
);

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:

var r = db.query("SELECT * FROM meine_funktion(?)", 42);

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.