noPLM Dokumentation

teilweise mit KI generiert

noPLM Server-Dokumentation

Die komplette deutschsprachige Endbenutzer-Dokumentation des noPLM-Servers — von der ersten Verbindung über Datenmodell, Automatisierung und Oberfläche bis zu Verwaltung, Kochbuch und Kommandoreferenz. Navigation links, oder einfach durchscrollen.

Teil I · Grundlagen

Kapitel 1

Einleitung

noPLM ist ein PLM-System (Product Lifecycle Management) — der Name ist dabei bewusst doppeldeutig gemeint: "no PLM", wie Du es vielleicht erwartest. Wer "PLM" hört, denkt oft sofort an CAD-Daten, Stücklisten und die Verwaltung mechanischer Bauteile im Maschinenbau. Genau darauf ist noPLM nicht festgelegt. In der Praxis nutzen viele Unternehmen PLM-ähnliche Systeme vor allem, um eigene Prozesse, Freigaben und Datenstrukturen zu organisieren — manche haben dabei gar keine "Teile" im klassischen Sinn. noPLM ist deshalb bewusst allgemein gehalten: Du definierst Deine eigenen Datenstrukturen, Workflows und Regeln, ganz gleich, ob es dabei um Bauteile, Rezepte, Verträge oder etwas ganz anderes geht.

Technisch kommt eine weitere Besonderheit dazu: Diese Dokumentation beschreibt den noPLM-Server, und der hat keine grafische Oberfläche im klassischen Sinne. Du sprichst mit ihm über eine textbasierte Konsolensprache — eine Art SQL für Deine Daten, Workflows und Automatisierung, die Du direkt eintippst oder aus einem Script heraus sendest. Für den praktischen Einsatz gibt es ein eigenständiges noPLM-GUI-Frontend, das diese Konsolensprache im Hintergrund spricht; für die Anbindung anderer Systeme gibt es außerdem eine HTTP-/REST-API, die dieselben Konsolenbefehle über einen JSON-Aufruf entgegennimmt. Trotzdem lohnt es sich gerade für Administratoren, die Konsolensprache zu verstehen — sie ist die gemeinsame Grundlage von allem, was noPLM tut, und nur wer sie kennt, kann das System wirklich effektiv und sinnvoll nutzen. Wer sich an eine SQL-Konsole oder ein gutes altes Terminal gewöhnt hat, wird sich hier ohnehin schnell zurechtfinden.

Diese Dokumentation richtet sich an Endbenutzer von noPLM: an Personen, die Datenstrukturen (sogenannte Spaces), Workflows, Scripte und Views definieren und benutzen — sei es direkt über die Konsole, über die mitgelieferte noplm-cli, oder über ein eigenes GUI, das die Konsolensprache im Hintergrund spricht. Du musst dafür keine Zeile Go-Code lesen; die interne Architektur des Servers interessiert hier nur so weit, wie sie das Verhalten nach außen erklärt.

1.1 Über dieses Dokument

Die Dokumentation ist in sieben Teile gegliedert, die aufeinander aufbauen:

  • Teil I – Grundlagen erklärt, wie man sich verbindet, wie Befehle ausgeführt werden und was bei einem Fehler passiert. Lies das zuerst, auch wenn es noch nicht sonderlich spannend klingt — es erspart später viel Kopfkratzen.
  • Teil II – Datenmodell dreht sich um das eigentliche Herzstück: Spaces, Felder, Beziehungen, Workflows und den kleinen Schlüssel-Wert-Speicher, der immer griffbereit ist.
  • Teil III – Automatisierung zeigt, wie Du mit Scripten Geschäftslogik in noPLM hineinbringst — von einfachen Validierungen bis zu automatischen E-Mails.
  • Teil IV – Oberfläche behandelt alles, was ein GUI-Client von noPLM bekommen kann: Views, Menüs und Dokumentenanhänge.
  • Teil V – Verwaltung ist für die Administration gedacht: Nutzer, Rollen, Rechte, Backups und Protokolle.
  • Teil VI – Kochbuch sammelt fortgeschrittene Lösungsmuster, die sich allein aus den in Teil I–V beschriebenen Bausteinen zusammensetzen — kein neues Feature, sondern clevere Kombination vorhandener.
  • Teil VII – Kommandoreferenz ist zum Nachschlagen da, wenn Du die genaue Syntax eines Befehls brauchst, den Du eigentlich schon kennst.

Jedes Kapitel enthält Beispiele mit echter Konsolensyntax — probier sie ruhig selbst aus. noPLM validiert die meisten Dinge sofort beim Eintippen (mehr dazu in Kapitel 3), sodass Tippfehler in der Regel schnell auffallen.

Das Beispielszenario

Damit die Beispiele nicht jedes Mal bei null anfangen, begleitet uns durch die ganze Dokumentation eine kleine Küche, die ihre Rezepte professionell verwaltet — von der ersten Idee bis zur Freigabe fürs Menü. Zwei Zutatenlieferanten tauchen dabei immer wieder auf:

  • Frischdienst Müller liefert das Grundhandwerkszeug jeder Küche: Mehl, Eier, Kartoffeln, Milch. Bodenständig, zuverlässig, ohne Überraschungen — perfekt für die meisten Beispiele.
  • Sternenstaub Zulieferer GmbH liefert alles, was ein Rezept "besonders" macht: Mondstaub-Safran, Einhorntränen-Essig, gelegentlich eine Phönixfeder zum Verfeinern. Dieser Lieferant taucht nicht in jedem Kapitel auf, aber immer dann, wenn ein Beispiel etwas mehr Anschaulichkeit gebrauchen kann — zum Beispiel bei Beziehungen mit Zusatzdaten ("3 Prisen Mondstaub-Safran pro Portion") oder einem Script, das warnt, wenn ein Rezept zu viele exotische Zutaten enthält.

Lieferanten, Zutaten und Rezepte sind rein fiktiv. Etwaige Ähnlichkeit mit echtem Mondstaub-Safran ist rein zufällig.

Ein kleiner Vorgeschmack, wie das später aussieht:

CREATE SPACE supplier (
    name STRING NOT NULL,
    reliable BOOL NOT NULL DEFAULT true
);

INSERT INTO supplier (name = 'Frischdienst Müller', reliable = true);
INSERT INTO supplier (name = 'Sternenstaub Zulieferer GmbH', reliable = false);

Die genaue Bedeutung jeder Zeile folgt in den nächsten Kapiteln — hier reicht es zu wissen, dass es ab jetzt konkret wird.

Kapitel 2

Erste Schritte

Dieses Kapitel bringt Dich von "noch nichts installiert" zu "erste eigene Zeile in der Konsole eingetippt". Viel Technik steckt nicht dahinter — ein laufender Server, eine Datenbank und ein Client reichen.

2.1 Installation & Konfiguration

noPLM besteht im Kern aus zwei Programmen: dem Server (noplm-server) und einem schlanken Konsolen-Client (noplm-cli, dazu gleich mehr). Beide sind eigenständige Programme ohne Installationsroutine — einfach entpacken und starten.

Voraussetzungen:

  • Eine laufende MySQL- oder MariaDB-Instanz, auf die der Server zugreifen kann.
  • Ein config.toml mit den Verbindungsdaten dieser Datenbank sowie ein paar Server-Einstellungen.

Ein minimales config.toml sieht so aus:

dbDatabase = "noplm"
dbUsername = "noplm"
dbPassword = "noplm"
dbHost = "127.0.0.1:3306"

bindAddress = "*"
bindPort = 1737

bindAddress = "*" bedeutet: auf allen Netzwerkschnittstellen lauschen. dbHost darf einen Port enthalten. Es gibt eine ganze Reihe weiterer Einstellungen — etwa für Zeitlimits, Mengenbegrenzungen oder den Datei-Anhang-Speicher — die tauchen in den passenden späteren Kapiteln auf, sobald das jeweilige Feature erklärt wird (z. B. beim Backup in Kapitel 14 oder bei Dokumentenanhängen in Kapitel 12); hier reicht das Minimum zum Starten.

Der Server sucht config.toml zuerst im aktuellen Arbeitsverzeichnis und weicht, falls dort keine liegt, auf /etc/noplm/config.toml aus — praktisch für eine systemweit installierte Instanz, die nicht aus einem bestimmten Verzeichnis heraus gestartet wird.

Server starten:

./noplm-server

Beim allerersten Start legt der Server automatisch einen Standard-Administrator namens admin an. Das führt direkt zu einer Besonderheit, die Du ab jetzt im Hinterkopf behalten solltest: noPLM kennt keine Passwort-Authentifizierung. Der Login-Befehl braucht nur einen Benutzernamen, kein Passwort — echte Authentifizierung wird als bereits vorgelagert vorausgesetzt (zum Beispiel durch ein Gateway oder einen Proxy vor dem Server). Stell den Server deshalb nie direkt ins offene Netz. Mehr dazu, sobald es um LOGIN geht — gleich im nächsten Abschnitt.

2.2 Die Konsole (noplm-cli)

noplm-cli ist ein schlanker Konsolen-Client: ein Terminal-Programm, das eine TCP-Verbindung zum Server aufbaut und die Konsolensprache spricht — mehr nicht. Es gibt keine Magie, keinen versteckten Login-Automatismus: Was Du eintippst, geht so an den Server, wie Du es eintippst.

./noplm-cli                       # verwendet bindAddress/bindPort aus config.toml
./noplm-cli -host example -port 1737

Läuft noplm-cli in einem echten Terminal, startet es einen interaktiven Modus mit Eingabezeilen-Editor: Der Prompt noplm> zeigt eine neue Eingabe an, ...> eine noch nicht abgeschlossene (weil ein Befehl erst mit ; endet — mehr dazu in Kapitel 3). Wird stattdessen Eingabe umgeleitet oder eine Datei hineingepiped, liest noplm-cli Befehl für Befehl ohne Prompts durch — praktisch, um z. B. eine ganze Datei mit Befehlen am Stück abzuarbeiten.

Eine erste Sitzung

$ ./noplm-cli
# NOPLM console 0.0
*
noplm> LOGIN admin;
# OK: logged in as admin
*
noplm> CREATE SPACE supplier (
...>     name STRING NOT NULL,
...>     reliable BOOL NOT NULL DEFAULT true
...> );
# OK: space 'supplier' created
*
noplm> INSERT INTO supplier (name = 'Frischdienst Müller', reliable = true);
% id: 1
*
noplm> QUERY supplier -> (name, reliable);
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|reliable|BOOL|false|
@ rows: 1
% 1 | 'Frischdienst Müller' | true
*

Die erste Ergebnisspalte ist die id des Datensatzes, obwohl wir sie gar nicht abgefragt haben — jede QUERY liefert sie automatisch mit, damit man mit jeder Zeile weiterarbeiten kann. Mehr dazu in Kapitel 5.

Auch ohne ein einziges Wort über die Konsolensprache selbst gelernt zu haben, lässt sich hier schon einiges ablesen: Jede Antwort endet mit einer Zeile, die nur * enthält — das einzige Signal, dass die Antwort vollständig ist (auch mehrzeilige Antworten enden so). Jede Zeile davor beginnt mit genau einem von fünf Zeichen, die verraten, was in ihr steht:

Zeichen Bedeutung
# Kommentar/Statuszeile (z. B. OK: ..., die Begrüßung, protokollierte Zwischenschritte)
@ Strukturinformation, die zum Verständnis der folgenden %-Zeilen nötig ist (Spaltenköpfe, Zeilenanzahl)
% eine Datenzeile (Ergebnis von QUERY/LIST/SHOW, oder die neue ID nach einem INSERT)
$ eine Nachricht für den Endbenutzer (von einem Script gesendet)
! eine Fehlermeldung — der Befehl ist fehlgeschlagen

Diese fünf Zeichen begegnen Dir in praktisch jedem Beispiel dieser Dokumentation wieder, deshalb lohnt es sich, sie früh zu verinnerlichen. Was genau bei einem Fehler passiert, welche Zeilen dann in welcher Reihenfolge erscheinen und wie sich das zu BEGIN/COMMIT verhält, ist Thema von Kapitel 3.

Ein letzter praktischer Hinweis für die interaktive Sitzung: Strg+C verwirft den gerade angefangenen, noch unvollständigen Befehl und bringt Dich zu einem frischen Prompt zurück, statt das Programm zu beenden. Ein komplett eingetippter, mehrzeiliger Befehl landet als ein einziger Eintrag in der History (mit den Pfeiltasten wieder aufrufbar), nicht als mehrere einzelne Zeilen.

2.3 Abmelden und Server beenden

LOGOUT; beendet nur die angemeldete Sitzung, nicht die TCP-Verbindung selbst — ein anschließendes LOGIN <anderer nutzer>; auf derselben Verbindung meldet Dich als jemand anderes neu an (siehe Kapitel 13).

Den ganzen Server kontrolliert herunterfahren kannst Du als Admin mit SHUTDOWN; — der Server nimmt keine neuen Verbindungen mehr an, lässt alle bereits offenen Sitzungen in Ruhe zu Ende laufen (bis zu einem festen Zeitlimit) und beendet sich dann. Das entspricht genau dem, was auch ein normales Betriebssystem-Signal (Strg+C auf dem Server-Prozess) auslöst — SHUTDOWN macht diesen Mechanismus nur auch über die Konsole erreichbar, für Deployments ohne direkten Systemzugriff.

Kapitel 3

Transaktionen und Befehlsausführung

In Kapitel 2 hast Du schon einen INSERT-Befehl abgeschickt und sofort eine Antwort bekommen. Das war bereits ein vollständiges Ausführungsmodell in Aktion — nur eben das einfachste der beiden, die noPLM kennt. Dieses Kapitel erklärt beide: wann ein Befehl sofort läuft, wann er erst einmal nur "vorgemerkt" wird, und was genau passiert, wenn dabei etwas schiefgeht. Das lohnt sich, bevor es an die eigentlichen Daten geht — vieles, was später sonst überraschend wirkt (vor allem sobald Scripte in Teil III ins Spiel kommen), ergibt sich direkt aus diesem Kapitel.

3.1 Autocommit vs. Warteschlange (BEGIN / COMMIT / ROLLBACK)

Ohne ein vorheriges BEGIN läuft jeder Befehl, den Du abschickst, sofort — das ist der Autocommit-Modus, der Normalfall, und genau das, was in Kapitel 2 passiert ist. Dabei steckt bereits mehr Absicherung drin, als man auf den ersten Blick vermutet: Auch ein einzelner Autocommit-Befehl läuft intern in einer eigenen, impliziten Transaktion. Löst er später (sobald Du Trigger-Scripte kennst) weitere Schreibvorgänge aus und einer davon schlägt fehl, wird alles, was dieser eine Befehl bewirkt hat, restlos zurückgenommen — nie ein halbfertiger Zustand.

Der zweite Modus ist eine explizite Warteschlange, eingeleitet mit BEGIN und abgeschlossen mit COMMIT (oder verworfen mit ROLLBACK). Jeder Befehl zwischen BEGIN und COMMIT wird nicht sofort ausgeführt — er landet zunächst nur in einer Warteschlange. Nichts davon fasst die Datenbank an, bis COMMIT läuft. Dann erst werden alle vorgemerkten Befehle der Reihe nach ausgeführt, alle zusammen in einer einzigen echten Datenbanktransaktion.

noplm> BEGIN;
# OK: transaction started
*
noplm> INSERT INTO supplier (name = 'Feinkost Vogel', reliable = true);
# OK: queued (#1)
*

An dieser Stelle existiert in der Datenbank noch kein neuer Lieferant — queued heißt wörtlich "vorgemerkt", nicht "erledigt". Ein ROLLBACK an dieser Stelle wirft die ganze Warteschlange weg, als wäre nie etwas eingetippt worden:

noplm> ROLLBACK;
# OK: rolled back
*

Läuft die Kette stattdessen bis COMMIT durch, werden alle vorgemerkten Befehle ausgeführt und das Ergebnis jedes einzelnen gemeldet:

noplm> BEGIN;
# OK: transaction started
*
noplm> INSERT INTO supplier (name = 'Feinkost Vogel', reliable = true);
# OK: queued (#1)
*
noplm> INSERT INTO supplier (name = 'Bio-Hof Wagner', reliable = true);
# OK: queued (#2)
*
noplm> COMMIT;
# [1] INSERT INTO supplier (name = 'Feinkost Vogel', reliable = true); -> % id: 2
# [2] INSERT INTO supplier (name = 'Bio-Hof Wagner', reliable = true); -> % id: 3
# COMMIT OK (2 commands)
*

Zwei Dinge, die Du kennen solltest, bevor Du das erste Mal BEGIN tippst:

  • Strukturelle Befehle sind von der Warteschlange ausgeschlossen. CREATE SPACE, ALTER SPACE, DROP SPACE und ähnliche strukturverändernde Befehle (Teil II) laufen ausschließlich im Autocommit-Modus. Innerhalb einer offenen Transaktion sind sie ein sofortiger Fehler: ! structural commands are not allowed inside a transaction (autocommit only) - COMMIT or ROLLBACK first.
  • Eine offene Transaktion verfällt von selbst, wenn sie zu lange untätig bleibt. Standardmäßig nach 60 Sekunden Inaktivität (per txIdleTimeoutSeconds konfigurierbar, 0 schaltet das ab) rollt der Server eine vergessene, offene Transaktion unaufgefordert selbst zurück — Du bekommst dann, ohne selbst etwas geschickt zu haben, eine Zeile wie ! ROLLBACK: transaction timed out after 1m0s of inactivity - send ROLLBACK; to continue. Ein BEGIN ist also kein Ort zum Nachdenken — Befehle vorbereiten, dann zügig COMMIT oder ROLLBACK.

Nach so einem Rollback verweigert die Sitzung jeden weiteren Befehl, bis Du selbst ROLLBACK; schickst: ! the transaction was rolled back after timing out - send ROLLBACK; to continue. Das ist Absicht. In diesem Moment glaubst Du noch, in einer Transaktion zu sein, und die folgenden Befehle einzeln im Autocommit auszuführen — jeder für sich committet — wäre das Gegenteil dessen, was Du wolltest. Das ausdrückliche ROLLBACK ist Deine Bestätigung, dass Du es bemerkt hast; danach läuft die Sitzung wieder normal. Ein COMMIT bringt Dich ebenfalls heraus, sagt aber klar, dass nichts committet wurde.

  • Auch ein einzelner Befehl hat eine Obergrenze. Standardmäßig bricht ein Befehl ab, der nach 30 Sekunden noch läuft (statementTimeoutSeconds; ein negativer Wert schaltet die Grenze ab — 0 kann das nicht, weil ein weggelassener Eintrag ebenfalls als 0 gelesen wird). Die Grenze umfasst den Befehl samt aller Trigger, die er auslöst, und deren gesamte Kaskade; bei einem COMMIT gilt sie für die ganze vorgemerkte Kette, denn ein COMMIT ist ein Befehl.

Befehle, die berechtigterweise Minuten brauchen, sind ausgenommen und laufen so lange sie müssen: EXPORT STRUCTURE, EXPORT DATA, IMPORT DATA, VERIFY DOCS OF, VERIFY ALL DOCS, RECOMPUTE LABELS OF, RECOMPUTE ALL LABELS sowie jedes strukturelle CREATE/ALTER/DROP SPACE — ein Feld an einen gut gefüllten Space anzuhängen schreibt die darunterliegende Tabelle um, und das auf halbem Weg abzubrechen wäre schlimmer als zu warten.

Wird Dir ein eigener Befehl abgeschnitten — etwa eine wirklich große QUERY — dann hebe lieber den Wert an, statt darum herumzubauen.

Und noch eine Kleinigkeit: COMMIT ohne offene Transaktion ist ein Fehler (! COMMIT without an open transaction - send BEGIN; first) und kein stilles Nichts.

Außerdem gilt: Solange eine Transaktion offen ist, lässt sich die Identität der Sitzung nicht per erneutem LOGIN wechseln (! cannot switch user inside an open transaction - COMMIT or ROLLBACK first) — naheliegend, denn alle bereits vorgemerkten Befehle wurden gegen die Rechte des aktuellen Nutzers geprüft.

3.2 Fail-Fast-Validierung und Ausführungszeitpunkt

"Vorgemerkt, nicht ausgeführt" heißt nicht "ungeprüft". Sobald Du innerhalb einer offenen Transaktion einen Befehl abschickst, prüft noPLM ihn sofort gegen das aktuelle Schema — bevor er überhaupt in die Warteschlange kommt. Ein falscher Feldname, ein falscher Typ, ein unbekannter Space: Der Befehl wird augenblicklich mit einer Fehlermeldung abgelehnt, erkennbar am Zusatz (not queued):

noplm> INSERT INTO supplier (name = 'Frisch & Fein', reliable = 'ja');
! field 'reliable' expects a bool value, got string (not queued)
*

Die Warteschlange selbst bleibt davon unberührt — der fehlerhafte Befehl wurde ja gar nicht erst aufgenommen. Du korrigierst ihn einfach und schickst ihn erneut.

Diese frühe Prüfung hat aber eine Grenze: Sie kennt nur das Schema (welche Spaces, Felder und Typen es gibt), nicht den tatsächlichen Zustand der Daten, und sie führt nichts wirklich aus. Ob ein Befehl am Ende tatsächlich erfolgreich läuft, entscheidet sich erst bei COMMIT, wenn die Warteschlange gegen die echte Datenbank ausgeführt wird — zum Beispiel, wenn ein Trigger-Script (Teil III) einen Schreibvorgang aus einem fachlichen Grund ablehnt. Das kann beim Eintippen niemand vorhersehen, denn ein Script kann beliebige Geschäftslogik enthalten. Merke Dir also zwei getrennte Zeitpunkte: "ergibt der Befehl syntaktisch und laut Schema Sinn" weißt Du sofort; "funktioniert er wirklich" weißt Du erst nach COMMIT.

3.3 Fehlerbehandlung und Rollback

Erinnerst Du Dich an die fünf Zeilenpräfixe aus Kapitel 2? Für Fehlerbehandlung ist vor allem eines davon entscheidend: Eine !-Zeile bedeutet immer "das ist schiefgegangen", und sie steht in der Antwort immer ganz am Ende — nach allen $-Nachrichten, aber vor der abschließenden *-Zeile.

Im Autocommit-Fall — schon durch die eigene implizite Transaktion aus 3.1 abgesichert — hinterlässt ein gescheiterter Befehl keine Spuren. Weder die eigentliche Änderung noch irgendetwas, das dabei an Nebenwirkungen ausgelöst wurde, bleibt bestehen.

Im Warteschlangen-Fall gilt dasselbe Prinzip, nur für die ganze Kette auf einmal: Scheitert bei COMMIT auch nur einer der vorgemerkten Befehle wirklich, wird die gesamte Kette zurückgerollt — nicht nur der eine fehlgeschlagene Befehl, sondern auch alle anderen, die zuvor erfolgreich vorgemerkt worden waren. Ein Vorgriff auf Teil III, wie das dann aussieht (angenommen, ein Trigger-Script namens check_stock lehnt eine Bestellung mit negativer Menge ab):

# space=order id=null script=check_stock: rejecting insert, qty was -1
$ Prüfe Bestellmenge...
! ROLLBACK: command #2 ('INSERT INTO order_item (product = 5, qty = -1);'): script 'check_stock' on space 'order_item' aborted: qty must not be negative
*

Beachte die Reihenfolge: Erst die protokollierten Zwischenschritte (#), dann eine eventuelle Nutzer-Nachricht ($), erst ganz zum Schluss die !-Fehlerzeile — obwohl der Fehler ja mitten in der Kette (bei Befehl #2) aufgetreten ist. Du musst also die komplette Antwort bis zur abschließenden *-Zeile einlesen, bevor Du weißt, ob alles geklappt hat — von oben nach unten lesen entspricht nicht zwangsläufig der Reihenfolge, in der die Befehle tatsächlich abgearbeitet wurden.

Ein ROLLBACK — ob von Dir selbst getippt, vom Server nach einem Fehler ausgelöst, oder nach Ablauf der Leerlaufzeit — muss danach nie manuell "aufgeräumt" werden. Die Warteschlange ist in dem Moment bereits leer; Du fängst mit einem neuen BEGIN einfach wieder von vorn an.

Teil II · Datenmodell

Kapitel 4

Spaces und Felder

Ab hier geht es an das eigentliche Datenmodell. Ein Space ist noPLMs Grundbaustein für Daten — am ehesten vergleichbar mit einer Tabelle in einer relationalen Datenbank, nur dass Du ihn komplett über die Konsole definierst, nicht über eine separate DDL-Sprache.

Einen Space anlegen

CREATE SPACE ingredient (
    name STRING NOT NULL,
    unit STRING NOT NULL DESCRIPTION 'measurement unit, e.g. g, ml, pcs',
    supplier RELATION supplier ONE AS ingredients MANY
);

Das erzeugt eine ingredient-Space mit drei Feldern — supplier ist dabei ein Beziehungsfeld (mehr dazu in Kapitel 6; für den Moment reicht: jede Zutat gehört zu genau einem Lieferanten). CREATE SPACE ist wie jeder andere strukturverändernde Befehl Autocommit-only — er lässt sich nicht in eine offene Transaktion einreihen (siehe Kapitel 3) — und admin-only.

Feldtypen

Ein einfaches (nicht-Beziehungs-)Feld hat einen von zehn Typen:

Typ Bedeutung
STRING kurzer Text
TEXT langer Text, ohne Längenbeschränkung
INT Ganzzahl
FLOAT Kommazahl
BOOL true/false
DATETIME Zeitstempel
JSON beliebiger, wohlgeformter JSON-Text
USER Login-Name eines noPLM-Benutzers
ROLE Name einer noPLM-Rolle
AUTONUMBER automatisch erzeugter, fortlaufender Code (siehe unten)

JSON ist dabei kein natives Datenbank-JSON — physisch einfach Text, aber bei jedem Schreibvorgang prüft noPLM, dass der Wert tatsächlich gültiges JSON ist. Ein Feld ist standardmäßig nullbar; NOT NULL macht es zur Pflichtangabe.

USER schreibst und liest Du wie ein STRING — als Login-Name in Anführungszeichen, z. B. owner = 'maria' — auch wenn intern nur die zugehörige Benutzer-ID gespeichert wird. noPLM prüft bei jedem Schreibvorgang, dass dieser Benutzer tatsächlich existiert (egal ob aktiviert oder deaktiviert — siehe Kapitel 13); ein unbekannter Name ist ein sofortiger Fehler. Wird der referenzierte Benutzer später per DROP USER gelöscht, springt ein nullbares USER-Feld automatisch auf NULL zurück — bei einem NOT NULL-Feld verhindert das den DROP USER stattdessen komplett, mit einer klaren Fehlermeldung.

ROLE funktioniert genauso, nur für einen Rollennamen statt einen Login-Namen, z. B. responsibleRole = 'reviewer' — bei jedem Schreibvorgang gegen die existierenden Rollen geprüft, und bei DROP ROLE auf NULL zurückgesetzt (bzw. den DROP ROLE blockierend, bei einem NOT NULL-Feld).

DEFAULT und DESCRIPTION

Zwei optionale, unabhängig voneinander nutzbare Zusätze pro Feld:

CREATE SPACE recipe (
    name STRING NOT NULL,
    description TEXT,
    servings INT NOT NULL DEFAULT 4 DESCRIPTION 'how many people this recipe feeds'
);
  • DEFAULT <wert> greift, wenn ein INSERT (Kapitel 5) das Feld einfach wegläßt — nicht bei einem expliziten = NULL, das gewinnt immer. Der Default ist dauerhaft gespeichert, nicht nur beim aktuellen Befehl aktiv: Jeder künftige INSERT, der servings wegläßt, bekommt automatisch 4.
  • DESCRIPTION '...' ist reiner Freitext (bis 255 Zeichen) für ein Feld — taucht in LIST FIELDS OF/SHOW SPACE auf (siehe unten), hat aber keine funktionale Auswirkung. Praktisch für eine GUI-Oberfläche, die daraus einen Tooltip bauen will.

Beide lassen sich nachträglich ändern, ohne den Space neu anzulegen — siehe ALTER FIELD unten.

UNIQUE

Ein Feld kann außerdem als UNIQUE markiert werden — noPLM lehnt dann jedes INSERT/UPDATE ab, das zwei Datensätzen denselben, nicht-NULL-Wert in diesem Feld geben würde:

CREATE SPACE ingredient (
    name STRING NOT NULL,
    sku STRING UNIQUE
);

INSERT INTO ingredient (name = 'Mehl', sku = 'ZUT-001');
INSERT INTO ingredient (name = 'Zucker', sku = 'ZUT-001');  -- abgelehnt: sku schon vergeben
INSERT INTO ingredient (name = 'Salz');                      -- ok: sku bleibt leer
INSERT INTO ingredient (name = 'Pfeffer');                   -- auch ok: mehrere Datensätze
                                                               -- dürfen alle ein leeres sku haben

Das ist unabhängig von NOT NULL — ein nullable UNIQUE-Feld erlaubt beliebig vielen Datensätzen, es leer zu lassen; nur die, die tatsächlich einen Wert setzen, müssen sich alle unterscheiden. Es funktioniert nur für Felder mit einem vergleichbaren, begrenzbaren Wert (also nicht TEXT/JSON — UNIQUE wird für diese beiden Typen von noPLM grundsätzlich abgelehnt).

Nachträglich setzen oder entfernen, ohne den Space neu anzulegen:

ALTER SPACE ingredient ALTER FIELD sku UNIQUE;
ALTER SPACE ingredient ALTER FIELD sku NOT UNIQUE;

Ein bestehendes Feld auf UNIQUE zu setzen schlägt mit einer klaren Fehlermeldung fehl, wenn bereits doppelte Werte in den Daten stecken — die müssen erst bereinigt werden, dann erneut versuchen.

AUTONUMBER: automatisch erzeugte Codes

Manchmal soll ein Feld nicht frei eingegeben, sondern von noPLM selbst fortlaufend vergeben werden — etwa eine Zutaten-Nummer wie ZUT-0001. Dafür gibt es AUTONUMBER('<präfix>', <länge>):

CREATE SPACE ingredient (
    name STRING NOT NULL,
    code AUTONUMBER('ZUT-', 4)
);

INSERT INTO ingredient (name = 'Mehl');    -- code wird automatisch 'ZUT-0001'
INSERT INTO ingredient (name = 'Zucker');  -- code wird automatisch 'ZUT-0002'
  • <präfix> ist ein fester Text (max. 16 Zeichen, nicht leer), <länge> (1–16) bestimmt nur, mit wie vielen Nullen die Zahl aufgefüllt wird — braucht der Zähler mehr Stellen, wächst die Zahl einfach über die angegebene Länge hinaus, statt abgeschnitten zu werden.
  • Ein AUTONUMBER-Feld ist immer automatisch NOT NULL und UNIQUE — das explizit dazuzuschreiben ist sogar ein Fehler, ebenso ein DEFAULT.
  • Das Feld ist immer schreibgeschützt: weder INSERT noch UPDATE dürfen ihm einen eigenen Wert geben (auch nicht als Admin) — der Wert entsteht ausschließlich automatisch beim Anlegen des Datensatzes.
  • Der Zähler ist global, nicht pro Feld — zwei AUTONUMBER-Felder mit demselben Präfix (egal auf welchem Space) teilen sich einen einzigen, spaceübergreifenden Zähler, damit ihre Werte niemals kollidieren können. Legt z. B. sowohl ingredient als auch supplier ein Feld mit dem Präfix 'REF-' an, laufen beide Nummernkreise gemeinsam hoch.
  • Nachträglich lässt sich Präfix/Länge ändern, ohne dass bereits vergebene Werte sich rückwirkend ändern:

text ALTER SPACE ingredient ALTER FIELD code AUTONUMBER('ZUTAT-', 6);

  • Bekommt ein bereits gefüllter Space nachträglich ein AUTONUMBER-Feld, erhält jeder vorhandene Datensatz sofort seinen eigenen, frisch vergebenen Wert (in id-Reihenfolge) — nicht wie bei einem gewöhnlichen NOT NULL DEFAULT ...-Feld denselben Wert für alle.

Virtuelle Felder: Werte, die beim Lesen berechnet werden

Manchmal hängt ein Wert von anderen Datensätzen ab: „Welche Revision dieser Reihe ist die neueste?“, „Was ist der letzte Messwert dieses Sensors?“. Ein Trigger kann so einen Wert nicht aktuell halten — die Änderung passiert in einem anderen Datensatz als dem, der ihn zeigen muss. Genau dafür gibt es virtuelle Felder: ein Feld ganz ohne gespeicherten Wert, das bei jedem Lesen aus einer QUERY berechnet wird.

CREATE SPACE rev (
    name STRING NOT NULL,
    revision INT NOT NULL,
    origin RELATION rev ONE AS revisions MANY   -- zeigt auf die erste Revision der Reihe
);

ALTER SPACE rev ADD VIRTUAL FIELD current_revision RELATION rev
    QUERY "QUERY rev -> (id) WHERE origin = $self.origin ORDER BY revision DESC LIMIT 1"
    DESCRIPTION 'die neueste Revision dieser Reihe';

ALTER SPACE rev ADD VIRTUAL FIELD latest_no INT
    QUERY "QUERY rev -> (revision) WHERE origin = $self.origin ORDER BY revision DESC LIMIT 1";

QUERY rev -> (name, revision, current_revision, latest_no);
QUERY rev -> (name, current_revision -> (revision, name));   -- Hop über das virtuelle Feld
  • Die Query liefert den Wert: genau eine Spalte (die erste Zeile zählt — das ORDER BY der Query entscheidet, die Datensatz-ID löst Gleichstände; keine Zeile heißt leer). Der deklarierte Typ (INT, STRING, … oder RELATION <space>) muss genau zu dieser Spalte passen. Ein virtuelles RELATION-Feld enthält die ID eines Datensatzes eines beliebigen Spaces und lässt sich wie ein echtes Relationsfeld als Hop nutzen.
  • $self.<feld> ist eine Spalte des Datensatzes, der gerade gelesen wird — so stellt jede Zeile ihre eigene Frage. Es darf nur als Wert in einer WHERE-Bedingung der Query des virtuellen Felds stehen und nennt id, ein einfaches Feld oder ein Relationsfeld dieses Spaces (keine USER-/ROLE-Felder).
  • Nur lesbar, und nur dort, wo es funktioniert: nicht in WHERE, ORDER BY oder UNIQUE, nicht beschreibbar, nicht von einem anderen virtuellen Feld nutzbar. Auch in QUERY <space>; und (*) ist es nicht enthalten — jedes virtuelle Feld ist eine Query pro Zeile, also nennt man es dort, wo man es haben will.
  • Es gelten Ihre eigenen Rechte: erreicht die Query des virtuellen Felds einen Space, den Sie nicht lesen dürfen, kommt das Feld für Sie einfach leer zurück — es scheitert nicht und zeigt nichts. (Leer kann also auch „kein Zugriff“ bedeuten, nicht nur „kein Treffer“.)
  • Ändern oder entfernen: ALTER SPACE rev ALTER FIELD latest_no QUERY "..." ersetzt die Query, DROP FIELD entfernt das Feld. Solange ein virtuelles Feld ein Feld oder einen Space noch liest, wird dessen Löschen mit einer Meldung verweigert, die das virtuelle Feld nennt — ändern Sie zuerst dessen Query.
  • In Skripten liest man den Wert als ctx.old.latest_no (im BEFORE-Trigger, da zeigt die Datenbank noch den alten Stand) oder ctx.new.latest_no (im AFTER-Trigger). Die jeweils andere Seite wirft einen Fehler, statt still den falschen Wert zu liefern. Der Wert wird erst beim Zugriff berechnet und taucht weder in Object.keys noch in JSON.stringify(ctx.new) auf. Schreiben (ctx.patch) geht nicht. Im Label-Skript sind beide Schreibweisen gleich. Achtung: Ein Label wird nur neu berechnet, wenn der Datensatz selbst geschrieben wird. Ändert sich ein virtueller Wert durch einen anderen Datensatz, bleibt das Label veraltet (bis RECOMPUTE LABELS). Für aktuelle Anzeigen den Wert besser per QUERY oder View lesen.
  • Nur Admins legen sie an, und nicht innerhalb einer Transaktion. Sie sind nicht Teil von EXPORT DATA (sie lassen sich neu berechnen), aber Teil von EXPORT STRUCTURE.

Hängt ein Wert nur vom Datensatz selbst ab (zwei Texte verbinden, ein if), braucht es kein virtuelles Feld: ein gewöhnliches Feld, das ein BEFORE-Trigger mit ctx.patch füllt, ist gespeichert, filterbar und sortierbar.

Automatische Felder: id, created_at, modified_at

Jeder Space hat automatisch ein id-Feld (fortlaufende Ganzzahl, niemals änderbar) sowie created_at/modified_at-Zeitstempel — Du deklarierst sie nicht selbst, sie tauchen auch nicht in LIST FIELDS OF auf, existieren aber auf jedem Datensatz. Zwei weitere solche "unsichtbaren" Pseudo-Felder lernst Du später kennen: state (Kapitel 7, nur relevant mit Workflow) und label (Kapitel 9, ein von einem Script berechneter Anzeigename).

Einen Space nachträglich ändern

ALTER SPACE ingredient ADD FIELD note TEXT AFTER name;
ALTER SPACE ingredient ADD FIELD origin STRING FIRST;
ALTER SPACE ingredient MOVE FIELD origin AFTER name;
ALTER SPACE ingredient DROP FIELD note;

ALTER SPACE ingredient ALTER FIELD unit DESCRIPTION 'updated description';
ALTER SPACE ingredient ALTER FIELD unit DESCRIPTION NULL;   -- löscht sie wieder

ALTER SPACE recipe ALTER FIELD servings DEFAULT 6;
ALTER SPACE recipe ALTER FIELD servings DEFAULT NULL;       -- löscht den Default

ALTER SPACE ingredient ALTER FIELD note NOT NULL DEFAULT 'n/a';  -- macht Pflichtfeld,
                                                                  -- befüllt bestehende Zeilen
ALTER SPACE ingredient ALTER FIELD note NULL;                    -- macht wieder optional

FIRST/AFTER <feld> bestimmen nur die Anzeigeposition — rein kosmetisch, keine funktionale Bedeutung. Ein Feld auf NOT NULL umzustellen, während bereits Datensätze existieren, verlangt einen DEFAULT (inline angegeben oder schon vorher gesetzt), mit dem bestehende NULL-Werte automatisch aufgefüllt werden — ohne Default wird der Befehl abgelehnt, nichts wird stillschweigend riskiert.

Ein Space selbst hat außerdem eine eigene, rein informative LABEL-Eigenschaft (nicht zu verwechseln mit dem später vorgestellten, script-berechneten Record-Label aus Kapitel 9) — Anzeigename, falls er sich vom technischen Space-Namen unterscheiden soll:

CREATE SPACE ingredient (...) LABEL 'Ingredients';
ALTER SPACE ingredient SET LABEL 'Zutaten';

Ohne LABEL entspricht er einfach dem Space-Namen.

Einen Space löschen

DROP SPACE ingredient CASCADE;

CASCADE ist Pflicht, sobald der Space in irgendeiner Beziehung steckt — dann verschwinden auch alle betroffenen Beziehungsfelder auf anderen Spaces mit. Was das im Detail bedeutet, gehört in Kapitel 6; für einen Space ganz ohne Beziehungen reicht ein einfaches DROP SPACE ingredient CASCADE;.

Nachschauen: LIST SPACES / SHOW SPACE / LIST FIELDS OF

noplm> LIST SPACES;
% space: ingredient|ingredient|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z|2
% space: supplier|supplier|2026-01-15T08:00:00Z|2026-01-15T08:00:00Z|1
*
noplm> LIST FIELDS OF ingredient;
% field: 1|name|STRING|false||||||false|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 2|unit|STRING|false|||measurement unit, e.g. g, ml, pcs|||false|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 3|supplier|RELATION|true|ingredient_supplier(ONE)|||||false|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 4|sku|STRING|true||||||true|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 5|code|AUTONUMBER|false||||||true|ZUT-|4|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
*

Jede % field:-Zeile hat vierzehn feste, |-getrennte Teile: position| name|type|nullable|relation|relation_where|description|default| detail_space|unique|autonumber_prefix|autonumber_length|created_at| modified_at — leere Teile bleiben als leere Stelle stehen, werden nie einfach weggelassen, damit sich die Zeile immer an fester Position aufteilen lässt. relation, relation_where und detail_space sind erst ab Kapitel 6 wirklich interessant; autonumber_prefix/autonumber_length sind nur bei einem AUTONUMBER-Feld gefüllt. SHOW SPACE ingredient; liefert dieselben field:-Zeilen, zusätzlich gerahmt von % name:/% label: und ein paar Zeilen, die erst in späteren Kapiteln Sinn ergeben (etwa % workflow: oder % label_script:, je nachdem, was auf dem Space schon eingerichtet ist).

LIST SPACES, LIST FIELDS OF und SHOW SPACE brauchen keine besondere Berechtigung — offen für jede angemeldete Sitzung, admin oder nicht.

Kapitel 5

Datensätze anlegen, ändern, abfragen

Mit Spaces und Feldern aus Kapitel 4 steht das Gerüst. Jetzt geht es um die eigentlichen Daten: INSERT, UPDATE, DELETE und QUERY — die vier Befehle, mit denen Du tagtäglich arbeitest. Alle vier sind, anders als die strukturellen Befehle aus Kapitel 4, ganz normal in einer BEGIN...COMMIT-Kette einreihbar (siehe Kapitel 3).

Literale

Bevor es losgeht, kurz die Werte-Schreibweisen, die Du überall brauchst:

Typ Schreibweise Beispiel
String einfache Anführungszeichen; escapebar sind \' \\ \n \r 'Mehl', 'Sie sagte \'Hallo\'', 'C:\\temp'
Int optionales Minus, Ziffern 42, -3
Float wie Int, mit Punkt 3.5, -0.25
Bool Schlüsselwort true, false
Datetime wie ein String, ISO-Format '2026-01-15', '2026-01-15T09:00:00'
Null Schlüsselwort null

Ein Backslash muss also immer verdoppelt werden: 'C:\\temp' ergibt den Wert C:\temp. Ein Backslash vor irgendetwas anderem ist ein Fehler — 'a\t' wird abgelehnt, nicht etwa stillschweigend als a, Backslash, t gelesen. Das ist Absicht: so gibt es für jeden Wert genau eine Schreibweise, und ein Client, der schlampig escapt, bekommt sofort eine Fehlermeldung statt eines leicht veränderten Werts in der Datenbank.

INSERT

INSERT INTO ingredient (name = 'Mehl', unit = 'g', supplier = 1);
% id: 3
*

Jedes angegebene Feld wird per = zugewiesen, mehrere durch Komma getrennt. Ein weggelassenes Feld mit DEFAULT bekommt automatisch diesen Wert (Kapitel 4); ein weggelassenes NOT NULL-Feld ohne DEFAULT ist ein Fehler. Die Antwort meldet die neu vergebene id — das ist alles, was ein einfacher INSERT zurückgibt.

AS $name hängt die neue id zusätzlich an eine Session-Variable:

INSERT INTO supplier (name = 'Feinkost Vogel', reliable = true) AS $sup;
% id: 4
*
INSERT INTO ingredient (name = 'Safran', unit = 'g', supplier = $sup);
% id: 5
*

Praktisch, wenn Du von Hand mehrere zusammenhängende Datensätze anlegst und die id des einen im nächsten Befehl brauchst — ohne Session-Variable müsstest Du sie Dir merken und von Hand eintippen. Ein paar Dinge, die Du dazu wissen solltest:

  • $name ist frei wählbar, muss nur mit $ beginnen. Ein erneutes AS $sup überschreibt einfach den alten Wert, kein Fehler.
  • $name funktioniert überall dort, wo sonst eine Zahl stehen dürfte — als zugewiesener Feldwert, in einem WHERE-Vergleich, in einer IN [...]-Liste.
  • Verweist Du auf eine nicht gesetzte Variable, ist das sofort ein Fehler. Innerhalb einer offenen Transaktion gilt dabei die aus Kapitel 3 bekannte Fail-Fast-Regel — mit einer Ausnahme: Ein noch nicht ausgeführter, aber schon vorgemerkter AS $name-INSERT weiter vorn in derselben Kette zählt bereits als Versprechen, dass die Variable existieren wird:

text BEGIN; INSERT INTO ingredient (name = 'Safran', supplier = $sup); ! session variable '$sup' is not set (not queued) *

text BEGIN; INSERT INTO supplier (name = 'Feinkost Vogel', reliable = true) AS $sup; # OK: queued (#1) * INSERT INTO ingredient (name = 'Safran', unit = 'g', supplier = $sup); # OK: queued (#2) * COMMIT;

  • Session-, nicht kettenweit gültig: Eine gesetzte Variable überlebt ein einzelnes BEGIN...COMMIT — sie bleibt für die ganze TCP-Sitzung gültig, bis Du Dich neu einloggst (auch ein Identitätswechsel per erneutem LOGIN, siehe Kapitel 2) oder die Verbindung endet.
  • Scheitert eine Kette, in der AS $name vorkam, bleibt die Variable unangetastet (alter Wert oder weiterhin ungesetzt) — sie zeigt nie auf einen durch ROLLBACK wieder verschwundenen Datensatz.

LIST VARS; zeigt alle aktuell gesetzten Variablen, SHOW VAR $sup; genau eine:

noplm> LIST VARS;
% var: sup|4
*
noplm> SHOW VAR $sup;
% name: sup
% value: 4
*

Beide brauchen keine besondere Berechtigung. Diese Variablen sind rein konsolenseitig — innerhalb eines Scripts (Teil III) hat JavaScript eigene, echte Variablen; $name dort zu verwenden ist ein Fehler.

Session-Makros: DEFINE

Eine $variable trägt eine Datensatz-id. Will man dagegen einen Namen wiederverwenden — etwa den Space, auf dem eine Befehlsdatei arbeiten soll —, gibt es DEFINE, ein schlichtes Textmakro:

noplm> DEFINE space AS ingredient;
# OK: {{space}} defined
*
noplm> QUERY {{space}} -> (name, unit);

Ab da wird {{space}} in jedem Befehl dieser Session durch ingredient ersetzt, bevor der Befehl gelesen wird; der Server sieht also QUERY ingredient -> (name, unit);. So wird eine Befehlsdatei für verschiedene Spaces nutzbar: {{space}} hineinschreiben, den Namen vorher mit DEFINE setzen, die Datei abspielen. Die Kochbuch-Rezepte, die einen vorhandenen Space verändern, arbeiten so; INCLUDE RECIPE <name> WITH space = ...; setzt das Makro für Dich, nur für diese eine Datei (Kapitel 16).

  • Ein Wert ist ein einzelnes Wort (DEFINE n AS 42;) oder ein String in Anführungszeichen (DEFINE label AS 'a b.c';). {{name}} wird einmal und so wie geschrieben ersetzt; der eingesetzte Text wird nicht noch einmal nach Makros durchsucht.
  • Angefasst werden nur Namen, die Du definiert hast. Ein nicht definiertes {{x}} bleibt stehen, der Befehl scheitert dann als Syntaxfehler.
  • Wie $name-Variablen gehören Makros zur Session und sind nach einem neuen LOGIN oder am Ende der Verbindung weg. LIST DEFINES; zeigt sie, UNDEFINE space; entfernt eines. Keiner dieser Befehle braucht eine Berechtigung.
  • Vor BEGIN definieren: DEFINE ist innerhalb einer Transaktion nicht erlaubt, aber vorher gesetzte Makros wirken in den Befehlen, die Du dort einreihst.
  • In Scripts gibt es keine Makros, und im Admin-Log steht der Text nach der Ersetzung.

Syntax: Kapitel 17, DEFINE.

UPDATE

UPDATE ingredient SET unit = 'kg' WHERE id = 3;
@ rows: 1
*

SET nimmt dieselbe feld = wert, ...-Liste wie INSERT, WHERE bestimmt die betroffenen Zeilen (Syntax siehe unten). Trifft die Bedingung auf mehrere Datensätze zu, werden alle in einem Rutsch geändert — @ rows: <n> meldet, wie viele es waren.

DELETE

DELETE FROM ingredient WHERE id = 3;
@ rows: 1
*

Genau wie UPDATE, nur ohne SET — ein WHERE ist Pflicht (ein DELETE FROM ingredient; ganz ohne Bedingung, das versehentlich alles löscht, ist kein gültiger Befehl).

WHERE-Bedingungen

Eine Bedingung vergleicht ein Feld mit einem Literal, mehrere lassen sich mit AND/OR verknüpfen:

WHERE qty > 10
WHERE name = 'Mehl' AND unit = 'g'
WHERE reliable = true OR name LIKE 'Feinkost%'

AND bindet dabei stärker als OR — genau wie in SQL. Die Bedingung

WHERE unit = 'g' OR unit = 'ml' AND qty > 10

liest sich also als "entweder unit = 'g', oder aber unit = 'ml' und qty > 10" — und nicht als das, was man beim Lesen von links nach rechts vielleicht erwartet hätte. Wenn du es andersherum meinst, setz Klammern:

WHERE (unit = 'g' OR unit = 'ml') AND qty > 10

Klammern dürfen ineinander verschachtelt werden und sind überall erlaubt, wo eine Bedingung stehen darf — auch im WHERE eines UPDATE oder DELETE. Leere Klammern (()) sind ein Fehler.

Verfügbare Vergleichsoperatoren: = != < <= > >= LIKE. LIKE funktioniert nur auf STRING/TEXT-Feldern (und dem später vorgestellten label-Pseudofeld), mit %/_ als SQL-typischen Platzhaltern — name LIKE '%Safran%' findet jeden Namen, der irgendwo "Safran" enthält.

Für "ist einer von mehreren Werten" gibt es eine einzige, einheitliche Listen-Syntax mit eckigen Klammern, egal welcher Typ:

WHERE unit IN ['g', 'ml', 'kg']
WHERE id IN [3, 5, 7]
WHERE id IN [$a, $b]

Eine leere Liste (WHERE id IN [];) ist gültig und trifft schlicht auf keine Zeile zu.

Um „kein Wert“ abzufragen, schreibt man = NULL, für „hat einen Wert“ != NULL:

WHERE supplier = NULL
WHERE note != NULL

IS NULL gibt es in dieser Sprache nicht (es ist ein Parse-Fehler); der Server macht aus = NULL selbst den richtigen Test. Die anderen Vergleiche (<, >, …) mit NULL werden abgelehnt.

QUERY

QUERY ingredient;                          -- alle Felder, alle Zeilen
QUERY ingredient -> name;                  -- nur ein Feld
QUERY ingredient -> (name, unit);          -- mehrere Felder, IMMER in Klammern
QUERY ingredient WHERE unit = 'g' -> (name, supplier);

Die Klammern um eine Mehrfachauswahl sind Pflicht, nicht Kosmetik — QUERY ingredient -> name, unit; ist ein Syntaxfehler. Grund: Ohne Klammern lässt sich bei tieferen Pfaden (Kapitel 6 zeigt, wie man über Beziehungen "hoppt") nicht mehr eindeutig entscheiden, ob ein Komma eine neue Verzweigung oder nur ein weiteres Feld am Ende derselben einleitet.

noplm> QUERY ingredient -> (name, unit, supplier);
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|unit|STRING|false|
@ column: 4|supplier|RELATION|true|ingredient_supplier
@ rows: 1
% 12 | 'Safran' | 'g' | supplier:4
*

Die erste Spalte ist immer die id des Datensatzes — auch wenn du sie gar nicht aufgezählt hast, und auch bei QUERY ingredient; ganz ohne Feldliste. So trägt jede Zeile schon die id, die du brauchst, um den Datensatz zu öffnen oder zu bearbeiten. Du darfst id weiterhin selbst mit hinschreiben; sie wird dann einfach in dieselbe erste Spalte zusammengezogen, nie doppelt.

Ein Beziehungsfeld wie supplier liefert nie eine nackte Zahl, immer <space>:<wert> — hier reicht das als Vorschau; wie man von da aus weiter über die Beziehung "hoppt" (ingredient -> supplier -> name) und was WITH LABELS daraus macht, ist Thema von Kapitel 6.

Ein optionales, clientseitig gewähltes LIMIT <n> am Ende schneidet das Ergebnis auf die ersten n Zeilen zu, ohne einen Fehler zu werfen, selbst wenn mehr existieren:

noplm> QUERY ingredient LIMIT 1;
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|unit|STRING|false|
@ column: 4|supplier|RELATION|true|ingredient_supplier
@ rows: 1
@ truncated: true
% 3 | 'Mehl' | 'g' | supplier:1
*

Die zusätzliche @ truncated:-Zeile taucht nur auf, wenn LIMIT tatsächlich angegeben wurde, und meldet, ob wirklich mehr Zeilen vorhanden waren als gezeigt. Das ist etwas anderes als die serverseitig konfigurierte Obergrenze maxQueryRows: Die lehnt eine zu große, LIMIT-lose Anfrage komplett ab, statt sie zu kürzen — Details dazu gehören eher zur Administration als in dieses Kapitel.

Kapitel 6

Beziehungen zwischen Spaces

Unsere ingredient-Space trägt seit Kapitel 4 schon ein Beziehungsfeld (supplier), ohne dass wir bisher erklärt haben, was dahintersteckt. Dieses Kapitel holt das nach — und zeigt, wie zwei Spaces sich gegenseitig referenzieren, wie man zusätzliche Daten an eine Beziehung selbst hängt, wie man Beziehungen einschränkt und wie man über mehrere davon hinweg abfragt.

6.1 Grundlagen: ONE/MANY und MANY:MANY

Eine Beziehung ist Teil eines Feldes, keine eigene Konstruktion. Schau Dir ingredient.supplier noch einmal genau an:

supplier RELATION supplier ONE AS ingredients MANY

Gelesen von links: das Feld heißt supplier, verweist auf die Space supplier, mit Multiplizität ONE (eine Zutat hat genau einen Lieferanten) — und legt gleichzeitig die Gegenseite fest: auf supplier entsteht automatisch ein Feld ingredients mit Multiplizität MANY (ein Lieferant beliefert viele Zutaten). Eine Beziehung wird also nur einmal deklariert, aber immer von beiden Seiten aus sichtbar.

Physisch steckt dahinter eine einzige Fremdschlüssel-Spalte, und zwar auf der ONE-Seite — hier: ingredient.supplier. Die MANY-Seite (supplier.ingredients) existiert nicht als eigene Spalte, sondern wird bei Bedarf berechnet. Trotzdem ist auch sie schreibbar — die ganze Mitgliedschaft auf einmal, als Liste:

UPDATE supplier SET ingredients = [3, 5, 7] WHERE id = 1;
@ rows: 1
*

Das ersetzt die komplette Zutatenliste dieses Lieferanten: Zutaten, die neu in der Liste stehen, bekommen supplier = 1; Zutaten, die vorher dabei waren und jetzt fehlen, werden auf supplier = NULL gesetzt (sofern das Feld nullbar ist). Eine leere Liste (= []) oder = NULL räumt die ganze Mitgliedschaft leer.

Von der physischen Seite aus schreibst Du dagegen ganz normal einen einzelnen Wert:

UPDATE ingredient SET supplier = 1 WHERE id = 3;
UPDATE ingredient SET supplier = NULL WHERE id = 3;   -- Beziehung lösen

MANY:MANY

Manche Beziehungen sind auf keiner Seite eindeutig — ein Rezept braucht mehrere Zutaten, und dieselbe Zutat taucht in mehreren Rezepten auf. Dafür gibt es MANY:MANY, deklariert genauso wie oben, nur mit MANY auf beiden Seiten:

ALTER SPACE recipe ADD FIELD ingredients RELATION ingredient MANY AS recipes MANY;
# OK: field 'ingredients' added to 'recipe'
*

Damit hat recipe jetzt ein Feld ingredients, und ingredient automatisch das Gegenstück recipes — beide MANY, beide gleichberechtigt schreibbar, keine Seite ist "die physische":

UPDATE recipe SET ingredients = [3, 5, 7] WHERE id = 2;   -- von der Rezept-Seite
UPDATE ingredient SET recipes = [2, 4] WHERE id = 3;      -- von der Zutaten-Seite

Intern liegt eine MANY:MANY-Beziehung in einer eigenen, unsichtbaren Zwischentabelle — für Dich als Endbenutzer aber komplett unsichtbar, Du siehst nur die beiden Listenfelder. Löschst Du einen Datensatz auf einer Seite, verschwinden seine Einträge in dieser Zwischentabelle automatisch mit.

6.2 Zusatzdaten an einer Beziehung: Relation Details

"Rezept braucht 3 Zutaten" reicht selten — es fehlt die Menge. Diese Information gehört weder zum Rezept noch zur Zutat allein, sondern zu genau dieser einen Kombination. Dafür kann eine Beziehung eine Detail-Space tragen: eine ganz normale, eigenständige Space, deren Datensätze eine einzelne Verknüpfung (hier: ein Rezept-Zutat-Paar) optional referenzieren darf.

CREATE SPACE amount (
    value FLOAT NOT NULL
);

ALTER SPACE recipe ALTER FIELD ingredients DETAIL SPACE amount;
# OK: DETAIL SPACE for field 'ingredients' on 'recipe' updated
*

Jetzt lässt sich pro Verknüpfung ein amount-Datensatz setzen — nicht über UPDATE, sondern über zwei eigene Befehle:

INSERT INTO amount (value = 250) AS $qty;
% id: 9
*
SET RELATION DETAIL ON recipe.ingredients WHERE id = 2 AND ingredients = 3 TO $qty;
# OK: relation detail for 'recipe.ingredients' updated
*
GET RELATION DETAIL ON recipe.ingredients WHERE id = 2 AND ingredients = 3;
% detail: 9
*

Ein paar Besonderheiten:

  • Die Ziel-ID ist immer Pflicht, auch wenn die eigene id die Verknüpfung theoretisch schon eindeutig machen würde — bewusste doppelte Bestätigung: WHERE id = 2 AND ingredients = 3 prüft, dass Rezept 2 tatsächlich gerade mit Zutat 3 verknüpft ist, nicht nur irgendeine.
  • Symmetrisch von beiden Seiten ansprechbar — SET RELATION DETAIL ON ingredient.recipes WHERE id = 3 AND recipes = 2 TO $qty; trifft genau dieselbe Verknüpfung.
  • GET auf eine Verknüpfung ohne gesetztes Detail liefert % detail: mit leerem Wert, keinen Fehler.
  • Kein Trigger feuert dabei, für keine Beziehungsart — ein reiner Datenzugriff, ähnlich einem PUT/GET im Entry Store (Kapitel 8).
  • Ein Detail-Feld lässt sich nicht entfernen oder auf eine andere Space umstellen, solange noch mindestens eine Verknüpfung einen Wert trägt — ALTER SPACE recipe ALTER FIELD ingredients DETAIL SPACE NULL; würde dann mit relation 'ingredients' has 1 instance(s) with a detail value set - clear them first (SET RELATION DETAIL ... TO NULL) before changing or clearing DETAIL SPACE abgelehnt.
  • OWNED: der Detail-Datensatz lebt und stirbt mit der Verknüpfung. Geschrieben als ... WITH DETAIL amount OWNED am Feld, oder nachträglich eingeschaltet mit ALTER SPACE recipe ALTER FIELD ingredients DETAIL SPACE amount OWNED; (und wieder aus, indem man OWNED weglässt). Der Detail-Datensatz gehört dann zur Verknüpfung wie ein Rad zu genau einem Fahrrad: Wird die Verknüpfung aufgelöst — Beziehung geleert, woanders hin gezeigt, die Zutat aus dem Rezept entfernt, einer der beiden Datensätze gelöscht —, dann wird auch der Mengen-Datensatz gelöscht, seine DELETE-Trigger feuern dabei ganz normal. Im Gegenzug wird SET RELATION DETAIL ... TO <id> abgelehnt: eine besessene Menge wird für ihre Verknüpfung erzeugt, nie einer zugewiesen. TO NULL funktioniert weiterhin und löscht nun.

Ohne OWNED (der Normalfall) wird nie etwas gelöscht — die Verknüpfung wird nur gekappt, der Mengen-Datensatz bleibt liegen und kann anderswo angehängt werden. Das Umschalten wirkt nie rückwirkend: es entscheidet, was die nächste aufgelöste Verknüpfung tut, und lässt alles Bestehende in Ruhe.

6.3 Bedingungen an Beziehungen: WHERE

Ein Beziehungsfeld kann zusätzlich verlangen, dass das referenzierte Objekt (oder umgekehrt: das referenzierende) eine Bedingung erfüllt — zum Beispiel: nur zuverlässige Lieferanten dürfen Zutaten liefern.

ALTER SPACE ingredient ALTER FIELD supplier WHERE reliable = true;
# OK: WHERE for field 'supplier' on 'ingredient' updated
*

Ab jetzt scheitert jeder Versuch, ingredient.supplier auf einen unzuverlässigen Lieferanten zu setzen:

UPDATE ingredient SET supplier = 5 WHERE id = 3;
! relation constraint violated: referenced supplier record (id 5) does not satisfy the condition on field 'supplier'
*

Wichtig zu wissen:

  • Geprüft wird nur im Moment des Schreibens — ändert sich später der Lieferant selbst (reliable wird nachträglich auf false gesetzt), werden bereits bestehende Verknüpfungen nicht rückwirkend geprüft oder gar automatisch gelöst.
  • Die Bedingung lässt sich auch von der anderen Seite setzen — auf supplier.ingredients würde sie einschränken, welche Zutaten überhaupt zu diesem Lieferanten gehören dürfen (z. B. WHERE unit = 'g').
  • ALTER SPACE ... ALTER FIELD ... WHERE NULL; löscht die Bedingung wieder.
  • Die Bedingung selbst ist eine ganz normale WHERE-Klausel wie in Kapitel 5 — inklusive ->-Hops (Abschnitt 6.4), falls die Bedingung über mehrere Beziehungen hinweg prüfen soll.
  • Diese Prüfung gilt für jede Sitzung, auch für Admins — anders als Scripte (Teil III), die ein Admin bei Bedarf per SUSPEND SCRIPTS umgehen kann, gibt es hier keine Ausnahme.

6.4 Abfragen über mehrere Beziehungen (Hops)

Ein -> in einer QUERY folgt einer Beziehung zur nächsten Space — beliebig tief verkettbar:

QUERY ingredient -> supplier -> name;
QUERY ingredient -> (name, supplier -> name);
QUERY ingredient WHERE supplier -> reliable = true -> name;

Auch in einer WHERE-Bedingung darf ein Feldverweis über -> hoppen (wie gerade eben bei supplier -> reliable), nicht nur in der Ergebnisauswahl.

noplm> QUERY ingredient -> (name, supplier -> name) WITH LABELS;
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|supplier->name|STRING|false|
@ rows: 1
% 12 | 'Safran' | 'Feinkost Vogel'
*

Die erste Spalte id gehört immer zum Wurzel-Datensatz (hier ingredient), nie zu einem gehoppten Ziel. Eine gehoppte Spalte wird nach ihrem vollen Pfad benannt, mit demselben -> geschrieben — supplier->name, nicht nur name — damit zwei Hops, die auf einem gleichnamigen Feld enden, unterscheidbar bleiben. (Denselben Pfad doppelt zu projizieren, wird abgelehnt.)

WITH LABELS wirkt sich nur auf Beziehungsfelder aus, die als solche im Ergebnis erscheinen (nicht auf einen gehoppten Zielwert wie hier supplier -> name, der ja längst ein einfacher String ist) — hängt z. B. supplier selbst mit ins Ergebnis, würde sein Wert als supplier:1('Feinkost Vogel') statt nur supplier:1 erscheinen.

Ein Hop über eine MANY-Beziehung ist ebenso erlaubt, mit einer Einschränkung: höchstens ein MANY-Pfad pro Abfrage, sonst müsste das Ergebnis ein Kreuzprodukt aus zwei unabhängigen Listen sein:

QUERY recipe -> (name, ingredients -> name);   -- ein MANY-Pfad: erlaubt, flach

Jede Kombination aus Rezept und Zutat wird dabei zu einer eigenen Ergebniszeile — klassisches JOIN-Verhalten, kein verschachteltes Format.

Wie tief sich hoppen lässt, begrenzt eine Server-Einstellung (maxQueryHops) — für den Alltag in der Regel kein Thema, kommt aber bei sehr verschachtelten Abfragen als klare Fehlermeldung zurück statt den Server unbegrenzt zu belasten.

Kapitel 7

Workflows

Ein Rezept durchläuft in unserer Küche typischerweise mehrere Stadien: Entwurf, getestet, freigegeben fürs Menü. Genau für solche Zustände-und-Übergänge-Modelle gibt es Workflows — unabhängig vom eigentlichen Datenmodell aus den Kapiteln 4–6, aber eng mit einem Space verknüpfbar.

Ein Workflow besteht aus STATEs und FLOWs

Ein WORKFLOW ist eine benannte Menge von Zuständen (STATE), verbunden durch benannte, gerichtete Übergänge (FLOW):

CREATE WORKFLOW kitchen LABEL 'Kitchen Workflow';
# OK: workflow 'kitchen' created
*

Das legt automatisch einen ersten, nicht löschbaren Zustand namens INITIAL an — jeder Workflow startet damit. Weitere Zustände und die Übergänge dazwischen kommen per ALTER WORKFLOW:

ALTER WORKFLOW kitchen ALTER STATE INITIAL SET LABEL 'Entwurf';
ALTER WORKFLOW kitchen ADD STATE TESTED LABEL 'Getestet';
ALTER WORKFLOW kitchen ADD STATE APPROVED LABEL 'Freigegeben';

ALTER WORKFLOW kitchen ADD FLOW test FROM INITIAL TO TESTED;
ALTER WORKFLOW kitchen ADD FLOW revise FROM TESTED TO INITIAL;
ALTER WORKFLOW kitchen ADD FLOW approve FROM TESTED TO APPROVED;

LABEL ist überall rein informativ (z. B. für eine GUI) und hat keine funktionale Wirkung — ohne Angabe entspricht das Label einfach dem Namen. revise FROM TESTED TO INITIAL zeigt: ein Übergang muss nicht "vorwärts" gehen, ein Rezept darf zur Überarbeitung zurück in den Entwurf. Selbst ein Übergang von einem Zustand auf sich selbst (FROM X TO X) ist erlaubt.

CREATE/ALTER/DROP WORKFLOW sind wie strukturelle Space-Befehle admin-only und Autocommit-only (siehe Kapitel 3/ Kapitel 4) — ein Workflow existiert unabhängig von jedem Space, bis Du ihn einem zuweist.

Einen Workflow an einen Space binden

ALTER SPACE recipe SET WORKFLOW kitchen;
# OK: workflow 'kitchen' attached to space 'recipe'
*

Ab jetzt trägt jeder recipe-Datensatz ein zusätzliches, unsichtbares Pseudofeld state — genau wie id nie in LIST FIELDS OF gelistet, aber überall abfragbar, wo ein normales Feld stehen dürfte:

QUERY recipe -> (name, state);
QUERY recipe -> (name, state) WHERE state = 'TESTED';

Ein neu angelegter Datensatz startet automatisch im INITIAL-Zustand des Workflows — sonst gäbe es kein state, das zu einem FROM-Zustand irgendeines Flows passt. state lässt sich nicht per INSERT/UPDATE setzen (das ist ausschließlich FLOWs Job) — ein Versuch scheitert mit einem klaren "read-only"-Fehler. Ein Space kann höchstens einen Workflow gleichzeitig tragen; ALTER SPACE recipe DROP WORKFLOW; löst ihn wieder, ohne den Workflow selbst zu löschen — jeder Datensatz verliert dabei seinen state (zurück auf null).

Einen Übergang ausführen

FLOW test ON recipe WITH ID = 2;
# OK: flow 'test' executed
*

Das setzt Rezept 2 vom aktuellen Zustand in den Zielzustand des test-Flows — vorausgesetzt, der aktuelle Zustand stimmt tatsächlich mit dessen FROM-Zustand überein:

FLOW approve ON recipe WITH ID = 2;
! flow 'approve' cannot run: record 2 is in state 'INITIAL', not 'TESTED'
*

FLOW braucht eine eigene Berechtigung, flow, unabhängig von read/update (mehr zu Rechten in Kapitel 13). Was beim Ausführen eines Flows sonst noch passieren kann — an einen Workflow gebundene Scripte, die dabei feuern — ist Thema von Kapitel 9; für den Moment reicht: ein FLOW-Befehl ändert den Zustand und trägt einen Eintrag ins Flow-Log ein (Kapitel 15).

Nachschauen

noplm> LIST WORKFLOWS;
% workflow: kitchen|Kitchen Workflow|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
*
noplm> SHOW WORKFLOW kitchen;
% workflow: kitchen|Kitchen Workflow|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% state: INITIAL|true|Entwurf
% state: TESTED|false|Getestet
% state: APPROVED|false|Freigegeben
% flow: test|INITIAL|TESTED|test
% flow: revise|TESTED|INITIAL|revise
% flow: approve|TESTED|APPROVED|approve
*
noplm> LIST FLOWS OF WORKFLOW kitchen FROM STATE TESTED;
% flow: revise|TESTED|INITIAL|revise
% flow: approve|TESTED|APPROVED|approve
*

LIST WORKFLOWS/SHOW WORKFLOW/LIST FLOWS OF WORKFLOW brauchen keine besondere Berechtigung, genau wie LIST SPACES/SHOW SPACE. LIST FLOWS OF WORKFLOW <name> ON <space> WITH ID = <id> — die zweite Variante mit einem konkreten Datensatz statt FROM STATE — zeigt die von diesem Datensatz aus tatsächlich ausführbaren Übergänge; nützlich für eine GUI, die nur die gerade sinnvollen Buttons anzeigen will.

Umbenennen und Aufräumen funktioniert wie bei einem Space — sowohl der Workflow selbst als auch jeder einzelne Zustand/Übergang lässt sich umbenennen und eigenständig beschriften:

ALTER WORKFLOW kitchen RENAME TO kitchen_v2;
ALTER WORKFLOW kitchen_v2 SET LABEL 'Küchen-Workflow';
ALTER WORKFLOW kitchen_v2 RENAME STATE APPROVED TO DONE;
ALTER WORKFLOW kitchen_v2 RENAME FLOW approve TO release;
ALTER WORKFLOW kitchen_v2 DROP FLOW revise;
ALTER WORKFLOW kitchen_v2 DROP STATE TESTED;   -- schlägt fehl, solange 'test'/'approve'
                                                -- noch darauf verweisen
DROP WORKFLOW kitchen_v2;                      -- erst möglich, wenn an keinem Space mehr gebunden

Jeder Workflow, Zustand und Übergang kann außerdem einen UI META genannten, rein clientseitigen JSON-Blob tragen — für Layout-Angaben eines grafischen Workflow-Editors (Position auf der Zeichenfläche, Linienführung, Farbe, ...), ohne jede Bedeutung für den Server selbst. Gesetzt über ALTER WORKFLOW kitchen_v2 SET UI META bzw. ... ALTER STATE <state> SET UI META/... ALTER FLOW <flow> SET UI META, gefolgt von einem mehrzeiligen JSON-Body und einer abschließenden %%;-Zeile — genau wie ein Script-Body (Kapitel 9); DROP UI META löscht ihn wieder. Sichtbar nur über SHOW WORKFLOW, nicht in LIST WORKFLOWS.

Kapitel 8

Entry Store

Neben dem strukturierten Datenmodell aus Teil II gibt es noch einen ganz simplen, eingebauten Schlüssel-Wert-Speicher — unabhängig von jedem Space, ohne eigenes Schema. Praktisch für globale Einstellungen, die zu keinem einzelnen Datensatz gehören: eine Standard-Portionsgröße, ein Küchenname, ein Konfigurationswert, den ein Script später ausliest.

PUT / GET / REMOVE

PUT kitchen, name 'Küchenteam Nord';
# OK
@ rows: 1
*
GET kitchen, name;
% value: Küchenteam Nord
*
REMOVE kitchen, name;
# OK
@ rows: 1
*

Ein Schlüssel ist eine kommagetrennte Liste von 1 bis maximal 8 Bezeichnern (server-konfigurierbar) — hier kitchen, name — und wird case-insensitiv behandelt: GET KITCHEN, NAME; trifft denselben Eintrag. PUT ist ein stiller Upsert: Ein zweites PUT auf denselben Schlüssel überschreibt einfach den alten Wert, ohne Fehler oder Rückfrage.

GET auf einen nicht existierenden Schlüssel ist — anders als eine QUERY ohne Treffer — ein Fehler, keine leere Antwort:

GET kitchen, name;
! entry 'kitchen,name' not found
*

REMOVE auf einen nicht existierenden Schlüssel dagegen nicht — meldet schlicht @ rows: 0.

Das Antwortformat ist dasselbe OK / @ rows: N wie bei UPDATE/DELETE aus Kapitel 5: PUT meldet immer 1, REMOVE die Anzahl tatsächlich entfernter Einträge (0 oder 1).

LIST ENTRIES

noplm> LIST ENTRIES;
% entry: default,servings|4
% entry: kitchen,name|Küchenteam Nord
*
noplm> LIST ENTRIES kitchen;
% entry: kitchen,name|Küchenteam Nord
*

Ohne Angabe listet LIST ENTRIES alles; mit einem (Teil-)Schlüssel filtert es als Präfix, nicht als exakte Übereinstimmung — LIST ENTRIES kitchen; findet kitchen,name genauso wie kitchen,address, aber nicht kitchenware,type (die Grenze zwischen Schlüsselteilen wird respektiert, ein Präfix kitchen matcht nie einen Teil, der zufällig mit denselben Buchstaben beginnt).

Berechtigungen

GET und LIST ENTRIES brauchen keine besondere Berechtigung — offen für jede angemeldete Sitzung. PUT und REMOVE sind dagegen admin-only. Das ist bewusst asymmetrisch: Da ohnehin jeder per GET jeden Wert lesen kann (es gibt keine Zugriffskontrolle pro Eintrag), würde ein Einschränken von LIST ENTRIES nur die Schlüsselnamen verstecken, nicht die Werte selbst — kein echter Schutz, den es sich zu erkaufen lohnt.

Zugriff aus Scripten

Ein Script (Teil III) kann einen Eintrag über ctx.get(...) lesen, oder alle zu einem Präfix passenden Einträge über ctx.getAll(...) auflisten — mehr zu beiden in Kapitel 9. Schon jetzt wichtig zu wissen: Nur lesend. Für PUT/REMOVE gibt es aus einem Script heraus keinen Zugriffsweg — ein Script kann Einträge lesen oder auflisten, aber nie schreiben oder entfernen.

Teil III · Automatisierung

Kapitel 9

Scripte

Bisher folgt noPLM nur den Regeln, die Du in Teil II direkt im Schema ausgedrückt hast — NOT NULL, DEFAULT, WHERE-Bedingungen an Beziehungen. Für alles, was echte Geschäftslogik braucht ("prüfe etwas Kompliziertes, bevor ein Datensatz gespeichert wird", "schick eine E-Mail, sobald ein Rezept freigegeben ist"), gibt es Scripte — JavaScript, das der Server zu bestimmten Anlässen ausführt.

Eine Sache vorab, weil sie überrascht, wenn man sie nicht kennt: Es gibt keinen eigenständigen Befehl CREATE TRIGGER. "Trigger" ist keine eigene Sache, die man anlegt oder löscht — es ist nur eine Rolle, die eine Bindung zwischen einem Script und einem Space einnehmen kann (AS TRIGGER ... gegenüber AS RUNNABLE). Ein Script existiert für sich, unabhängig davon, ob und wie oft es irgendwo angebunden ist.

9.1 Überblick & Scripttypen

Ein und dasselbe Script kann, je nachdem wie Du es anbindest, unterschiedliche Rollen einnehmen:

Scripttyp Wie gebunden Feuert wann
Trigger-Script ATTACH SCRIPT ... TO <space> AS TRIGGER BEFORE\|AFTER CREATE\|UPDATE\|DELETE (auch DOC UPLOAD/DOC DELETE, Kapitel 11) automatisch bei einem passenden Schreibvorgang
Globales Trigger-Script ATTACH SCRIPT ... AS GLOBAL TRIGGER BEFORE ... (kein Space — für alle Spaces auf einmal) automatisch, vor den lokalen BEFORE-Triggern jedes Space
Run-Script ATTACH SCRIPT ... TO <space> AS RUNNABLE auf Abruf per RUN SCRIPT ... WITH ID = ...
Flow-Script ATTACH SCRIPT ... TO WORKFLOW <name> bei jeder Ausführung eines FLOW-Übergangs
Label-Script ALTER SPACE ... SET LABEL SCRIPT ... berechnet das label-Pseudofeld eines Datensatzes
Flow-Condition-Script ATTACH SCRIPT ... TO <space> AS FLOW CONDITION (oder AS GLOBAL FLOW CONDITION) entscheidet, welche FLOW-Übergänge gerade erlaubt sind
Run-Condition-Script ATTACH SCRIPT ... TO <space> AS RUN CONDITION (oder AS GLOBAL RUN CONDITION) entscheidet, welche RUNNABLE-Scripte gerade erlaubt sind
Renderer-Script CREATE RENDERER ... FOR ... steuert Anzeige/Bearbeitung eines Feldtyps in einer View

Das letzte gehört inhaltlich zu Views und wird in Kapitel 10 behandelt (Abschnitt 9.10 verlinkt kurz dorthin); alle anderen sind Thema dieses Kapitels.

9.2 Verwaltung von Scripten

Ein Script anlegen

CREATE SCRIPT check_servings PRIORITY 100
if (ctx.new.servings <= 0) {
    ctx.abort("servings must be positive");
}
%%;
# OK: script 'check_servings' created
*

Der Rumpf ist freier JavaScript-Text, eingelesen bis zu einer Zeile, die nur %%; enthält, und automatisch in function script(ctx) { ... } eingebettet — Du schreibst nur den Inhalt, keine Funktionshülle. PRIORITY (Standard 0, Bereich -9999..9999) bestimmt später die Reihenfolge, falls mehrere Scripte am selben Anlass hängen (Abschnitt 9.4.2). CREATE SCRIPT/CREATE LIBRARY sind admin-only, aber — anders als Space-DDL — ganz normal in einer BEGIN...COMMIT-Kette einreihbar.

Ein Script ändern

ALTER SCRIPT check_servings RENAME TO validate_servings;
ALTER SCRIPT validate_servings PRIORITY 50;
ALTER SCRIPT validate_servings SET SOURCE
if (ctx.new.servings <= 0) {
    ctx.abort("servings must be positive (v2)");
}
%%;

SET SOURCE ersetzt den Rumpf an Ort und Stelle, ohne die Script-ID zu ändern — jede bestehende Bindung (Trigger, USES, Label-Script, ...) bleibt erhalten und läuft beim nächsten Aufruf mit dem neuen Code. Das ist der Grund, warum es dieses Kommando überhaupt gibt: Ein gebundenes Script lässt sich nicht einfach per DROP + CREATE unter demselben Namen neu schreiben — DROP SCRIPT weigert sich, solange noch irgendeine Bindung existiert:

DROP SCRIPT validate_servings;
! script 'validate_servings' still has 1 binding(s) - run DETACH SCRIPT/ALTER SPACE ... DROP LABEL SCRIPT first
*

Ein Script anbinden

ATTACH SCRIPT validate_servings TO recipe AS TRIGGER BEFORE CREATE;
# OK: script 'validate_servings' bound to 'recipe' (TRIGGER BEFORE CREATE)
*
DISABLE SCRIPT validate_servings ON recipe AS TRIGGER BEFORE CREATE;
ENABLE SCRIPT validate_servings ON recipe AS TRIGGER BEFORE CREATE;
DETACH SCRIPT validate_servings FROM recipe AS TRIGGER BEFORE CREATE;

Dasselbe Script lässt sich mehrfach anbinden — an verschiedene Spaces, oder am selben Space in verschiedenen Rollen (z. B. gleichzeitig als TRIGGER und als RUNNABLE). DISABLE/ENABLE schalten eine konkrete Bindung vorübergehend aus/ein, ohne sie zu entfernen.

Admin-Sitzungen und Scripte

Ein wichtiger Sicherheitsmechanismus, den Du kennen solltest, bevor Du als Admin mit Daten arbeitest, die Trigger-Scripte tragen: Eine Admin-Sitzung löst nie stillschweigend ein Script aus. Würde ein Befehl ein angebundenes Script feuern, bekommst Du stattdessen einen harten Fehler:

UPDATE recipe SET servings = 6 WHERE id = 2;
! script 'validate_servings' attached to 'recipe' was not executed: admin sessions may not trigger scripts implicitly - run SUSPEND SCRIPTS first (to skip it), ALLOW SCRIPTS first (to run it for real), or perform this action as a user with the appropriate space rights instead
*

Der Sinn dahinter: Ein Admin, der schnell mal Daten reparieren will, soll nie versehentlich Geschäftslogik anstoßen, die eigentlich für normale Nutzung gedacht ist. Drei sitzungsgebundene Kommandos — zurückgesetzt bei jedem LOGIN — schalten zwischen den drei möglichen Script-Modi um:

SUSPEND SCRIPTS;
# OK: scripts suspended for this session
*
UPDATE recipe SET servings = 6 WHERE id = 2;   -- läuft jetzt durch, ohne dass validate_servings feuert
@ rows: 1
*
GUARD SCRIPTS;
# OK: scripts guarded for this session
*
  • SUSPEND SCRIPTS; — die klassische Ausweichmöglichkeit: Das angebundene Script wird still übersprungen, der Rest des Befehls läuft normal. Gut für eine schnelle, ungehinderte Datenkorrektur.
  • ALLOW SCRIPTS; — das Gegenteil: Das angebundene Script feuert tatsächlich, genau wie bei einem normalen Nutzer (inklusive eines möglichen ctx.abort()). Sinnvoll, wenn die Korrektur die normale Benachrichtigungs-/Ableitungslogik weiterhin auslösen soll, statt sie zu überspringen.
  • GUARD SCRIPTS; — zurück zum Standardzustand (dem harten Fehler oben), aus beiden anderen Zuständen heraus.

SHOW SCRIPTS; (admin-only) verrät den aktuellen Script-Modus der Sitzung: % script_mode: NORMAL|SUSPENDED|ALLOWED. Nicht zu verwechseln mit SHOW SCRIPT <name>; (Quelltext + alle Bindungen eines Scripts), LIST SCRIPTS; (alle Scripte), LIST SCRIPTS OF <space>; (nur die an diesen Space gebundenen) — alle drei admin-only, genau wie das Pendant LIST LIBRARIES;/SHOW LIBRARY <name>; für Bibliotheken.

LIBRARY: wiederverwendbarer Code

Eine LIBRARY ist eine Sammlung normaler JS-Funktionen, ohne ctx-Hülle — praktisch, um Logik zu teilen, die mehrere Scripte brauchen:

CREATE LIBRARY kitchen_helpers
function formatServings(n) {
    return n + (n === 1 ? ' portion' : ' portions');
}
%%;
# OK: library 'kitchen_helpers' created
*

CREATE SCRIPT recipe_label USES kitchen_helpers
return ctx.new.name + ' (' + formatServings(ctx.new.servings) + ')';
;

Eine USES-Bibliothek wird bei jeder Ausführung frisch in die JS-Umgebung eingebunden, nicht beim Anlegen des Scripts fest eingebacken — änderst Du kitchen_helpers per ALTER LIBRARY ... SET SOURCE, wirkt sich das sofort auf jedes Script aus, das sie benutzt, ohne dass Du die Scripte selbst anfassen musst. Eine Library kann selbst keine weitere Library benutzen (keine verschachtelten Imports). DROP LIBRARY ist wie DROP SCRIPT blockiert, solange noch ein Script sie per USES einbindet: library 'kitchen_helpers' is still used by 1 script(s) - run ALTER SCRIPT ... DROP USES first.

Eine bereits bestehende USES-Kette lässt sich nachträglich ändern, und eine Library umbenennen:

ALTER SCRIPT recipe_label ADD USES other_helpers;
ALTER SCRIPT recipe_label DROP USES other_helpers;
ALTER LIBRARY kitchen_helpers RENAME TO recipe_helpers;

9.3 Label-Scripts

Jeder Datensatz trägt ein unsichtbares label-Pseudofeld (standardmäßig NULL) — einen von einem Label-Script berechneten Anzeigenamen, praktisch für eine GUI, die statt einer nackten ID etwas Lesbares zeigen will.

CREATE SCRIPT recipe_label
if (ctx.new.servings <= 0) {
    return null;
}
return ctx.new.name + ' (' + ctx.new.servings + ' portions)';
;
ALTER SPACE recipe SET LABEL SCRIPT recipe_label;
RECOMPUTE LABELS OF recipe;   -- berechnet das Label auch für schon vorhandene Datensätze
  • Der Rückgabewert setzt das Label, nicht ctx.log/ctx.message. Ein String (max. 255 Zeichen) setzt es; null oder gar kein return löscht es; alles andere (Zahl, Objekt, ...) ist ein Fehler, der die gesamte Befehlskette abbricht.
  • Läuft automatisch am Ende jeder Kaskade, nie synchron im Schreibvorgang selbst — jeder CREATE/UPDATE innerhalb einer Befehlskette markiert den betroffenen Datensatz "schmutzig", einmal am Ende wird das Label für alle so markierten Datensätze neu berechnet.
  • Ändern/Entfernen des Label-Scripts wirkt nie rückwirkend — RECOMPUTE LABELS OF <space>;/RECOMPUTE ALL LABELS; sind die manuellen Nachzieh-Befehle dafür.
  • Deutlich eingeschränktes ctx, komplett ohne Seiteneffekte: ctx.space/ctx.id/ctx.state/ctx.label (der alte Wert, vor dieser Neuberechnung)/ctx.user/ctx.new/ctx.old/ctx.docs/ ctx.now()/ctx.log() — plus, als einzige Ausnahme von "keine Seiteneffekte", die drei rein lesenden ctx.query()/ctx.get()/ ctx.getAll(). Kein ctx.exec/ctx.create/ctx.message/ctx.abort. Genau deswegen ist ein Label-Script auch von der Admin-Sperre aus 9.2 und von SUSPEND/ALLOW/GUARD SCRIPTS ausgenommen — es gibt schlicht nichts zu schützen.
  • Datumsangaben im Label: ctx.format.dateTime/date/time(value). Ein Label wird einmal gespeichert, aber von allen gelesen — ein als Klartext hineingeschriebenes Datum sähe also für jeden gleich aus. ctx.format.dateTime(ctx.new.due) schreibt stattdessen einen kleinen Marker, {{noplm:datetime|2026-10-03T14:30:00Z}}, den die GUI durch das Datum in Zeitzone und Format des jeweiligen Betrachters ersetzt. Das geht in jedem Scripttyp und in jedem String-Feld, nicht nur in Labels. In einer Mail löst der Server den Marker selbst auf, und zwar in UTC (2026-10-03 14:30:00 UTC). Zwei Dinge sind gut zu wissen: Der Marker zählt zur 255-Zeichen-Grenze des Labels mit, und eine Suche nach dem Label-Text trifft den Marker, nicht das lokalisierte Datum. Einen Marker kann man auch von Hand schreiben — er gilt nur, wenn Art (datetime, date, time) und UTC-Zeitpunkt mit dem abschließenden Z exakt stimmen; alles andere bleibt einfacher Text.
  • Texte in mehreren Sprachen: ctx.format.text({...}). Dieselbe Idee für Wörter statt Datumsangaben. ctx.format.text({de: "Neue Revision", en: "New revision"}) schreibt {{noplm:text|de=Neue Revision|en=New revision}}; die GUI zeigt den Eintrag zur Sprache des Betrachters und, fehlt die, den ersten Eintrag — die Sprache, die als Rückfall gelten soll, gehört also nach vorn. Ein Script braucht man dafür nicht: das LABEL eines Scripts, Menüeintrags oder Views ist einfacher Text, LABEL '{{noplm:text|en=Open|de=Öffnen}}' funktioniert direkt. Regeln für einen von Hand geschriebenen Marker: je Sprache zwei Kleinbuchstaben (de, en), jede höchstens einmal, kein leerer Text, und die Zeichen %, |, {, } im Text als %25, %7C, %7B, %7D (das macht ctx.format.text für Dich); alles andere bleibt einfacher Text. Dasselbe gilt für Ausgabetexte: ctx.message(ctx.format.text({en: "Saved.", de: "Gespeichert."})) zeigt die Sprache des Betrachters (auch bei ctx.abort). In einer Mail nimmt der Server den ersten Eintrag. Der Marker zählt zur 255-Zeichen-Grenze des Labels mit.

9.4 Trigger-Scripts

9.4.1 BEFORE (inkl. ctx.patch) und AFTER

Ein als TRIGGER angebundenes Script feuert automatisch bei CREATE/UPDATE/DELETE auf dem gebundenen Space, mit einer von zwei Zeitpunkten:

  • BEFORE läuft, bevor der eigentliche Schreibvorgang passiert, und kann per ctx.abort("...") die ganze Befehlskette verhindern.
  • AFTER läuft, nachdem geschrieben wurde, und sieht den endgültigen, gespeicherten Zustand.
CREATE SCRIPT validate_servings
if (ctx.new.servings <= 0) {
    ctx.abort("servings must be positive");
}
%%;
ATTACH SCRIPT validate_servings TO recipe AS TRIGGER BEFORE CREATE;

CREATE SCRIPT log_recipe_created
ctx.log("recipe created: " + ctx.new.name);
ctx.message("Recipe '" + ctx.new.name + "' was created.");
;
ATTACH SCRIPT log_recipe_created TO recipe AS TRIGGER AFTER CREATE;

ctx.new/ctx.old zeigen den aktuellen bzw. vorherigen Feldzustand (ctx.old ist null bei CREATE, ctx.new ist null bei DELETE). Bei UPDATE/DELETE mit einem WHERE, das mehrere Zeilen trifft, feuert das Script einmal pro betroffener Zeile.

Ein Relationsfeld steht in ctx.new/ctx.old nur als Id: eine Zahl (oder null) auf einer ONE-Seite, ein Array von Zahlen auf einer MANY-Seite — leer ist das [], nie null. Willst du den Relationen folgen, nimm ctx.newRecord/ctx.oldRecord: derselbe Zustand als Objektgraph, der sich beim Zugriff nachlädt.

ctx.newRecord.supplier.country.name      // ONE-Kette
ctx.newRecord.parts[2].label             // MANY → Array von Records
ctx.newRecord.parts.map(p => p.supplier.name)
ctx.newRecord.supplier?.country?.name    // unterbrochene Kette abfangen

Eine ONE-Relation ist ein Record oder null, eine MANY-Relation ein Array von Records (leer: []; ein Index außerhalb ist wie in JS undefined). Jeder Record hat id (ohne Datenbankzugriff), seine Felder, state und label. Wichtig zu wissen:

  • Nur der erste Schritt ist ein Schnappschuss: er geht von den Ids in ctx.old/ctx.new aus. Deshalb ist ctx.newRecord.supplier auch in einem BEFORE-Trigger der Lieferant, der gleich geschrieben wird, und ctx.oldRecord.supplier der bisherige. Alles dahinter wird beim ersten Zugriff gelesen und dann für den Rest dieses Aufrufs festgehalten — ein ctx.exec()-Schreibvorgang danach ändert einen schon gelesenen Record nicht mehr.
  • ctx.oldRecord ist null, wo ctx.old null ist (CREATE), und ctx.newRecord dort, wo ctx.new null ist (DELETE).
  • Die Records werden gebündelt geladen: parts.map(p => p.supplier.name) kostet zwei Abfragen, nicht eine pro Element. Mehr Records als maxScriptQueryRows sind ein Fehler.
  • Skalare Felder sind aufzählbar (JSON.stringify zeigt sie), Relationsfelder nicht — eine Serialisierung läuft also nie durch den ganzen Graphen.
  • Nur lesend, ohne eigene Rechteprüfung (wie ctx.query()), daher auch in BEFORE nutzbar. Virtuelle Felder gibt es an einem Record nicht.

BEFORE ist bewusst seiteneffektfrei — es gibt dort kein ctx.exec/ctx.create (ein Aufruf wirft sofort einen Fehler). Eine einzige, gezielte Ausnahme davon ist ctx.patch: ein anfangs leeres Objekt, ausschließlich während BEFORE CREATE/BEFORE UPDATE für den direkten Schreibziel-Datensatz vorhanden, mit dem Du den tatsächlich geschriebenen Wert überschreiben kannst, ohne dem Script sonst irgendeine Schreibfähigkeit zu geben:

CREATE SCRIPT clamp_servings
if (ctx.new.servings < 1) {
    ctx.patch.servings = 1;   -- klemmt den Wert fest, statt abzubrechen
}
%%;
ATTACH SCRIPT clamp_servings TO recipe AS TRIGGER BEFORE CREATE;

ctx.patch.<feld> = wert; setzt genau das, was am Ende tatsächlich geschrieben wird — für ein einfaches Feld oder die physische Seite einer Beziehung ein einzelner Wert, für ein MANY-seitiges/ MANY:MANY-Beziehungsfeld eine Liste von IDs (ctx.patch.ingredients = [3, 5];, gegen die aktuelle Mitgliedschaft verrechnet, genau wie ein normales field = [...]-Assign aus Kapitel 6). Feuern mehrere Scripte am selben Anlass, sieht ein später (niedriger priorisiertes) Script bereits den von einem früheren Script gepatchten Wert — Patches addieren sich über Scripte hinweg, statt dass jedes Script nur den ursprünglichen Befehl sieht. Ein per ctx.patch überschriebenes Beziehungsfeld wird nicht gegen dessen WHERE-Bedingung (Kapitel 6.3) geprüft — bewusste Ausnahme, ein Script darf hier gezielt übersteuern.

9.4.2 Reihenfolge

Hängen mehrere Scripte am selben Space+Ereignis+Zeitpunkt, feuern sie in PRIORITY-Reihenfolge, absteigend, bei Gleichstand alphabetisch nach Scriptname. Höhere Priorität zuerst — praktisch, um z. B. eine grundlegende Validierung vor einer aufwendigeren Prüfung laufen zu lassen.

9.4.3 Einschränkungen

  • Ein Script schreibt ausschließlich über ctx.exec(befehl) — keinen anderen Weg, auch keine feineren Rechteprüfungen pro einzelnem Befehl: Die RUN-/FLOW-Berechtigung, mit der das Script überhaupt angebunden wurde, gilt als ausreichende Autorisierung. Erlaubt sind QUERY/INSERT/UPDATE/DELETE/LIST/SHOW/RUN SCRIPT/FLOW/GET — alles, was Struktur, Nutzer/Rollen oder Scripte selbst verwaltet, ist blockiert. Ein ctx.exec("QUERY ...")-Ergebnis beginnt — wie eine Konsolen-QUERY — mit der id-Spalte des Wurzel-Datensatzes; seine .data-Zeilen starten also mit der id. Der typisierte ctx.query()-Wrapper fügt diese Spalte nicht hinzu und liefert genau die aufgezählten Felder.
  • Ein Script schreibt unter denselben Regeln wie die Konsole. Was ein getipptes INSERT/UPDATE/DELETE prüft, prüft ein ctx.exec() genauso: Beziehungs-WHERE-Bedingungen greifen, das Zuweisen einer MANY-Seite (SET teile = [1, 2]) wird als Mitgliedschafts-Änderung ausgeführt, und ein Pfad-Schreibzugriff (UPDATE rezept -> lieferant SET name = '...') landet wirklich beim Lieferanten. Was in der Konsole nicht geht, geht auch im Script nicht: INSERT ... AS $name wird abgelehnt, weil Session-Variablen zu einer Konsolensitzung gehören — die neue id bekommst du als Rückgabewert von ctx.create().
  • Kein synchrones Verschachteln. Schreibt ein Script per ctx.exec("INSERT/UPDATE/DELETE ...") weitere Daten, laufen dadurch ausgelöste Trigger nicht sofort, sondern werden in eine Warteschlange gestellt und erst nach dem aktuellen Befehl abgearbeitet ("Kaskade") — mit einer Tiefenbegrenzung gegen Endlosschleifen. Einzige Ausnahme: ctx.exec("RUN SCRIPT ...") ruft wirklich rekursiv auf, ebenfalls mit eigener Tiefenbegrenzung.
  • Jede (Script, Datensatz)-Kombination feuert höchstens einmal pro Befehlskette — aber nur für AFTER. BEFORE ist von dieser Entdopplung ausgenommen und feuert jedes Mal frisch, wenn es erreicht wird, egal wie oft innerhalb derselben Kette.
  • ctx.abort() an beliebiger Stelle der Kette, oder ein unbehandelter JS-Fehler, rollt die gesamte Kette zurück (siehe Kapitel 3).
  • Für die Admin-Sperre und den Script-Modus gilt dasselbe wie in 9.2 beschrieben.

9.4.4 Globale Trigger-Scripte

Ein Script lässt sich auch global statt an einen Space binden — es feuert dann für jeden passenden Vorgang in jedem Space, gegenwärtig und zukünftig, ganz ohne Konfiguration pro Space:

CREATE SCRIPT audit_all_creates
ctx.log("erstellt in " + ctx.space.name + " von " + ctx.user.name);
;
ATTACH SCRIPT audit_all_creates AS GLOBAL TRIGGER BEFORE CREATE;
  • Nur BEFORE — ein globales AFTER gibt es nicht. Eine globale Bindung ist als überall geltendes Validierungs-Gate gedacht, nicht als Ort für systemweite Seiteneffekte.
  • Feuert zuerst, als eigener Block — alle globalen Bindungen für das Ereignis laufen zuerst (in PRIORITY-Reihenfolge wie in 9.4.2), erst danach die lokalen BEFORE-Scripte des jeweiligen Space. Die Priorität eines globalen Scripts wird nie mit den lokalen in eine gemeinsame Reihenfolge gemischt — global gewinnt immer, unabhängig vom Zahlenwert.
  • Reines Validierungs-Gate: kein ctx.patch. Jede sonstige BEFORE-Fähigkeit (ctx.abort(), ctx.message(), ctx.log(), ctx.query(), ctx.get(), ...) funktioniert genau wie bei einem lokalen BEFORE-Trigger — aber ctx.patch ist nicht verfügbar, da ein globales Script automatisch in jedem Space läuft, auch in solchen, an die beim Schreiben nie gedacht wurde; würde es dort überall stillschweigend Feldwerte überschreiben können, wäre die Reichweite eines Bugs viel zu groß.
  • ctx.run.global ist nur bei dieser Art von Feuerung true (sonst false — bei einem lokalen Trigger, Flow-Script oder Run-Script) — nützlich, wenn dasselbe Script sowohl lokal als auch global gebunden ist und wissen muss, in welchem Aufruf es gerade steckt.
  • Die Admin-Sperre und der Script-Modus aus 9.2 gelten unverändert.
  • LIST GLOBAL SCRIPTS; (admin-only) listet jede globale Trigger-Bindung; SHOW SCRIPT <name>; zeigt jede als eigene GLOBAL_TRIGGER-Bindungszeile.

9.5 Flow-Scripts

Ein Script lässt sich statt an einen Space auch an einen ganzen Workflow binden — es feuert dann bei jeder Ausführung eines FLOW-Übergangs auf diesem Workflow, egal welcher:

CREATE SCRIPT notify_approval
if (ctx.flow.name === 'approve') {
    ctx.message("Recipe '" + ctx.new.name + "' was approved.");
}
%%;
ATTACH SCRIPT notify_approval TO WORKFLOW kitchen PRIORITY 5;

Zwei zusätzliche ctx-Felder, die in jedem Script verfügbar sind (nicht nur in Flow-Scripten), aber nur hier wirklich Substanz haben: ctx.state (aktueller Zustandsname, null ohne Workflow) und ctx.flow — {name, label} des gerade ausgeführten Übergangs, sonst immer null. Ein Flow-Script darf wie ein normaler Trigger ctx.exec()/ctx.create() benutzen (es läuft nicht in einer BEFORE-artigen Einschränkung) — Workflow-Definitionsbefehle (CREATE WORKFLOW, ALTER SPACE ... SET WORKFLOW, ...) bleiben aber auch hier blockiert, keine sich selbst verändernde Workflow-Logik zur Laufzeit.

SHOW WORKFLOW kitchen; zeigt gebundene Scripte als eigene % script:-Zeilen (nur für Admins) — separat von den space-gebundenen % attachment:-Zeilen aus 9.2, da es eine andere Beziehung ist; LIST SCRIPTS OF WORKFLOW kitchen; liefert dieselben Zeilen als eigener Befehl. DETACH/ENABLE/DISABLE SCRIPT funktionieren für eine Workflow-Bindung genauso wie für eine Space-Bindung, nur mit WORKFLOW <name> statt <space> AS TRIGGER ...:

DISABLE SCRIPT notify_approval ON WORKFLOW kitchen;
ENABLE SCRIPT notify_approval ON WORKFLOW kitchen;
DETACH SCRIPT notify_approval FROM WORKFLOW kitchen;

9.6 Condition-Scripts

Zwei mechanisch identische, aber unabhängige Wächter — einer für FLOW, einer für RUN SCRIPT — die strikt für jede Sitzung gelten, auch für Admins, ohne SUSPEND/ALLOW SCRIPTS-Ausnahme. Anders als bei einem normalen Trigger (9.2) gibt es hier keine Admin-Sperre zu umgehen — das ist Absicht: Eine Zugriffsbeschränkung wie diese soll sich nicht per Script-Modus-Umschalter aushebeln lassen.

An einem Space können mehrere Condition-Scripte derselben Art gleichzeitig hängen, dazu beliebig viele GLOBALe, die für jeden Space gelten — die Bindung läuft über dieselbe ATTACH/DETACH SCRIPT-Syntax wie bei einem Trigger- oder Run-Script (9.4/9.7), nur mit FLOW CONDITION/RUN CONDITION als Bindungsart. Sind mehrere gebunden, muss ein FLOW-/RUN SCRIPT-Wunsch von allen gebundenen Scripten gleichzeitig erlaubt sein — die wirksame erlaubte Menge ist ihre Schnittmenge, nicht ihre Vereinigung.

Flow-Condition-Script — die lokale Form braucht zwingend einen bereits am Space hängenden Workflow (die globale Form nicht, da sie an keinen einzelnen Space gebunden ist); entscheidet, welche FLOW-Namen gerade erlaubt sind:

CREATE SCRIPT approval_flows
if (ctx.user.name === 'head_chef_maria') {
    return ['test', 'revise', 'approve'];
}
return ['test', 'revise'];
;
ATTACH SCRIPT approval_flows TO recipe AS FLOW CONDITION;

LIST WALKABLE FLOWS OF recipe WITH ID = 2;   -- Vorschau, bevor Du es versuchst
FLOW approve ON recipe WITH ID = 2;          -- schlägt fehl, falls nicht erlaubt

DETACH SCRIPT approval_flows FROM recipe AS FLOW CONDITION;

Run-Condition-Script — dasselbe Prinzip für RUNNABLE-Scripte, in keiner der beiden Formen mit Workflow-Voraussetzung:

CREATE SCRIPT run_allowed
if (ctx.user.name === 'head_chef_maria') { return ['publish_menu']; }
return [];
;
ATTACH SCRIPT run_allowed TO recipe AS RUN CONDITION;
LIST RUNNABLE SCRIPTS OF recipe WITH ID = 2;

GLOBAL funktioniert genauso, nur ohne Space-Ziel — eine unternehmensweite Regel, die überall zusätzlich zu den lokalen Condition-Scripten eines Space gilt:

CREATE SCRIPT business_hours_only
var h = new Date().getUTCHours();
if (h < 6 || h > 20) { return []; }    -- außerhalb der Geschäftszeiten nichts erlaubt
return ['test', 'revise', 'approve', 'publish_menu'];
;
ATTACH SCRIPT business_hours_only AS GLOBAL FLOW CONDITION;
ATTACH SCRIPT business_hours_only AS GLOBAL RUN CONDITION;

Für beide Arten gilt eine strenge Regel ohne Ausnahme: Der Rückgabewert jedes Scripts muss ein JS-Array aus Strings sein — auch ein leeres Array ([], "gerade nichts erlaubt") ist gültig, aber alles andere (kein return, null, ein Nicht-Array, ein Element, das kein String ist) ist ein harter Fehler, der die ganze Kette abbricht. Das eingeschränkte ctx entspricht dem eines Label-Scripts (9.3): kein ctx.new/ctx.old/ctx.exec/ctx.abort — nur lesende Zugriffe. Ohne jegliches gebundenes Condition-Script (weder lokal noch global) ist FLOW/RUN SCRIPT uneingeschränkt — genau wie vor diesem Feature.

ctx.allRunnables/ctx.allFlows lösen ein Problem, das vor allem bei einem globalen Condition-Script auftritt: Ein Script muss immer die vollständige aktuell erlaubte Menge zurückgeben — es kennt aber nicht automatisch, welche RUNNABLE-Scripte oder Flows der jeweilige Space überhaupt hat. Ohne diese beiden Felder müsste ein global angehängtes Condition-Script entweder jeden Namen jedes Space von Hand kennen, oder würde versehentlich alles blockieren, sobald es an einem Space landet, an den beim Schreiben nie gedacht wurde. ctx.allRunnables liefert die rohe, ungefilterte Liste aller aktivierten RUNNABLE-Bindungen des aktuellen Space, ctx.allFlows die Liste aller vom aktuellen ctx.state aus strukturell erreichbaren Flow-Namen — in beiden Fällen genau das, was LIST RUNNABLE SCRIPTS OF/LIST WALKABLE FLOWS OF intern auch als Ausgangspunkt verwenden, bevor sie es mit einem Condition-Script schneiden. Ein Script, das nichts weiter weiß, kann damit einfach "alles, was strukturell da ist" zurückgeben:

CREATE SCRIPT default_allow
return ctx.allRunnables;   -- bzw. ctx.allFlows für eine Flow-Condition
%%;
ATTACH SCRIPT default_allow AS GLOBAL RUN CONDITION;

Wichtig: Beide Felder spiegeln immer die rohe Struktur wider, nie das bereits gefilterte Ergebnis eines anderen, mit höherer Priorität laufenden Condition-Scripts am selben Space — sonst würde das Endergebnis von der Ausführungsreihenfolge abhängen, was diesem Feature laut der Regel weiter oben ausdrücklich nicht passieren darf.

ctx.userRoles ist in jedem Scripttyp verfügbar, der auch ctx.user hat (siehe 9.11) — die vollständige, über die Rollenhierarchie (Kapitel 13) aufgelöste Rollenmenge des aufrufenden Users, als Array von Rollennamen, nie null (leeres Array, falls keine Rolle zutrifft). Nützlich überall dort, wo ein Script selbst zwischen Rollen unterscheiden will, ohne dafür eine eigene Hilfs-Space mit Mitgliederlisten pflegen zu müssen:

CREATE SCRIPT approval_flows_by_role
if (ctx.userRoles.indexOf('head_chef') !== -1) { return ctx.allFlows; }
return ['test', 'revise'];
;

Kapitel 16.1 zeigt, wie sich ctx.userRoles zusammen mit ctx.allRunnables/ctx.allFlows und dem Entry Store (Kapitel 8) zu einem eigenen, konfigurierbaren Rechtesystem kombinieren lässt.

9.7 Run-Scripts

Ein RUNNABLE-gebundenes Script wird nicht automatisch ausgelöst, sondern gezielt per RUN SCRIPT aufgerufen:

CREATE SCRIPT shopping_list
var rows = ctx.query("recipe -> ingredients -> (name, unit, supplier -> name)");
var result = [];
for (var i = 0; i < rows.length; i++) {
    result.push({
        ingredient: rows[i]['ingredients->name'],
        unit: rows[i]['ingredients->unit'],
        supplier: rows[i]['ingredients->supplier->name'],
    });
}
return result;
;
ATTACH SCRIPT shopping_list TO recipe AS RUNNABLE;

RUN SCRIPT shopping_list ON recipe WITH ID = 2;

Der Rückgabewert eines per RUN SCRIPT aufgerufenen Scripts wird — anders als bei einem Trigger- oder Flow-Script, wo er ignoriert wird — direkt in die Konsolenantwort übernommen, als eigener % return:-Quellblock, JSON-kodiert:

% return:
% [{"ingredient":"Saffron","unit":"g","supplier":"Feinkost Vogel"}]
% ;
# OK: script 'shopping_list' executed
*

Kein return bzw. return null; erzeugt gar keine % return:-Zeile. ctx.run verrät jedem Script, warum genau es gerade läuft: {event, timing, script, priority} — bei RUN SCRIPT z. B. event: "RUN_SCRIPT", timing: null (kein Vorher/Nachher-Konzept bei einem gezielten Aufruf).

9.8 Libraries

Bereits in 9.2 vorgestellt — hier noch einmal als eigener Punkt festgehalten, weil er in der Gliederung als eigenes Unterkapitel auftaucht: CREATE LIBRARY/ALTER LIBRARY ... SET SOURCE/DROP LIBRARY, eingebunden per USES in ein Script, ohne eigene USES-Kette (keine Bibliothek nutzt eine andere).

9.9 Mail & HTTP aus Scripten

Zwei Fähigkeiten, die tatsächlich nach draußen wirken — beide standardmäßig deaktiviert, bis ein Admin sie konfiguriert (smtpHost/ mailFrom fürs Mailen, httpQueryAllowedHosts als Positivliste erlaubter Ziele fürs HTTP). Verfügbar nur in AFTER-Triggern, RUNNABLE-Scripten und Flow-Scripten — nicht in BEFORE, einem Label- oder Condition-Script, die ja explizit seiteneffektfrei bleiben sollen.

CREATE SCRIPT notify_head_chef
if (ctx.flow.name === 'approve') {
    ctx.mail({
        to: ['maria@example.com'],
        subject: "Recipe approved: " + ctx.new.name,
        body: ctx.new.name + " has been approved for the menu."
    });
}
%%;
ATTACH SCRIPT notify_head_chef TO WORKFLOW kitchen;

ctx.mail() verschickt nie sofort — sie sammelt sich für die ganze Befehlskette und landet erst in der echten Warteschlange, nachdem diese Kette erfolgreich committet wurde. Scheitert die Kette, wird schlicht nichts verschickt. Eine Obergrenze (maxMailsPerChain, Standard 5) verhindert, dass eine Kette versehentlich eine Flut von Mails lostritt — wird sie überschritten, scheitert die ganze Kette, nichts geht raus. Datums- und Textmarker aus ctx.format.* (siehe 9.3) in Betreff oder Text werden vor dem Einreihen in schlichten Text umgewandelt (Daten in UTC, ein Textmarker in seine erste Sprache), denn eine Mail bekommt keine GUI je zu sehen.

var res = ctx.httpQuery({ method: 'GET', url: 'https://api.example.com/nutrition/saffron' });
if (res.status === 200) {
    ctx.log("nutrition data: " + res.body);
}

ctx.httpQuery() läuft synchron (blockiert das Script, bis die Antwort da ist oder das serverseitige Zeitlimit greift) und wirft nur bei einem echten Netzwerkfehler oder einem nicht erlaubten Ziel — ein 4xx/5xx-Status kommt ganz normal als Ergebnis zurück, kein Fehler.

Drei admin-only Befehle verwalten die Mail-Warteschlange:

LIST MAIL QUEUE AT 1-50;
% mail: 1|pending|maria@example.com|Recipe approved: Saffron Risotto|0|2026-01-15T10:00:00Z||2026-01-15T09:55:00Z
% total: 1
*
FLUSH MAIL QUEUE;              -- sofortiger Zustellversuch für alle wartenden Einträge
# OK: flush attempted for 1 mail queue entries
*
DELETE MAIL QUEUE WITH ID = 1;  -- entfernt genau einen Eintrag, jeden Zustands
# OK: mail queue entry 1 deleted
*

FLUSH MAIL QUEUE ist Autocommit-only (ein echter, sofortiger Zustellversuch); DELETE MAIL QUEUE dagegen ganz normal in einer Kette einreihbar.

9.10 Renderer-Scripte — kurz erwähnt

Ein RENDERER ist ein weiterer Scripttyp, der steuert, wie ein einzelnes Feld in einer View dargestellt und bearbeitet wird (renderView/renderEdit/readEdit). Er gehört inhaltlich zu Views, nicht zur Datenlogik dieses Kapitels — volle Erklärung in Kapitel 10.

9.11 Das ctx-Objekt im Überblick

Nicht jeder Scripttyp bekommt dasselbe ctx — je "gefährlicher" (seiteneffektreicher) ein Scripttyp ist, desto eingeschränkter sein ctx. Zur Orientierung:

ctx-Mitglied Trigger BEFORE Globaler Trigger (immer BEFORE) Trigger AFTER Flow-Script Run-Script Label-Script Condition-Script
ctx.space/ctx.id/ctx.user ✓ ✓ ✓ ✓ ✓ ✓ ✓
ctx.userRoles ✓ ✓ ✓ ✓ ✓ ✓ ✓
ctx.state/ctx.label ✓ ✓ ✓ ✓ ✓ ✓ ✓
ctx.creator/ctx.creationTime/ctx.lastUpdated/ctx.lastUpdatedBy ✓ ✓ ✓ ✓ ✓ ✓ ✓
ctx.new/ctx.old (inkl. .version) ✓ ✓ ✓ ✓ – ✓ –
ctx.newRecord/ctx.oldRecord ✓ ✓ ✓ ✓ – ✓ –
ctx.flow – – – ✓ – – (immer null) –
ctx.run (inkl. ctx.run.global) ✓ ✓ ✓ – ✓ – –
ctx.patch ✓ (nur CREATE/UPDATE) – – – – – –
ctx.query()/ctx.get()/ctx.getAll() ✓ ✓ ✓ ✓ ✓ ✓ (nur lesend) ✓ (nur lesend)
ctx.format.dateTime()/date()/time()/text() ✓ ✓ ✓ ✓ ✓ ✓ ✓
ctx.exec()/ctx.create() – – ✓ ✓ ✓ – –
ctx.mail()/ctx.httpQuery() – – ✓ ✓ ✓ – –
ctx.abort()/ctx.message()/ctx.log() ✓ (kein ctx.message sinnvoll ohne Schreibvorgang, aber technisch vorhanden) ✓ ✓ ✓ ✓ nur ctx.log() nur ctx.log()
ctx.relationChanges ✓ (auf der virtuellen Seite einer Beziehung) – ✓ – – – –
ctx.allRunnables/ctx.allFlows – – – – – – ✓

Jeder Record trägt außerdem acht implizite "Pseudo-Felder" neben id/state/label — creator/creationTime/owner/ responsibleRole/deputyOwner/lastUpdated/lastUpdatedBy/version (siehe Kapitel 4). Vier der fünf schreibgeschützten bekommen ihre eigene, dedizierte ctx.X-Property, genau wie ctx.state/ctx.label oben — nie Teil von ctx.new/ ctx.old. Die drei schreibbaren (owner/responsibleRole/ deputyOwner) sind dagegen ganz normale Mitglieder von ctx.new/ ctx.old, setzbar über ctx.patch.owner = 'alice' in einem BEFORE-Trigger wie jedes andere schreibbare Feld — keine eigene Zeile in der Tabelle oben nötig, sie folgen einfach dem, was dort schon steht. version ist die Ausnahme unter den fünf schreibgeschützten: ein reiner Änderungszähler (1 bei INSERT, +1 bei jeder Auffrischung von lastUpdated), und anders als ein Zeitstempel ist sein Vorher/Nachher-Wert selbst für ein Script interessant — deshalb bleibt es ein ganz normales, nicht per ctx.patch änderbares Mitglied von ctx.new/ctx.old statt einer eigenen ctx.version-Property.

Die Faustregel dahinter: BEFORE, ein globaler Trigger, und die drei "Wächter"-Scripte (Label, Flow-Condition, Run-Condition) dürfen nichts verändern — sie dürfen höchstens lesen (ctx.query()/ctx.get()/ ctx.getAll()) oder, bei einem lokalen BEFORE, über ctx.patch den anstehenden Schreibvorgang selbst anpassen (ein globaler Trigger bekommt kein ctx.patch, obwohl auch er immer BEFORE läuft — siehe 9.4.4). Jeder andere Scripttyp darf über ctx.exec() frei innerhalb seiner Berechtigung schreiben.

Teil IV · Oberfläche

Kapitel 10

Views

Alles bisher Gezeigte — QUERY, INSERT, UPDATE — ist Text, den Du selbst eintippst oder ein GUI-Client selbst zusammenbaut. Eine View kehrt das um: Ein Admin definiert einmal, wie ein Datensatz (oder eine Liste davon) angezeigt und bearbeitet werden soll, und der Server liefert auf Abruf ein fertiges HTML/JS-Fragment zurück, das ein GUI-Client nur noch einbetten muss.

Eine View ist deklarativ: eine QUERY, benannte FIELD-Slots, optionale Speicherschritte — ganz ohne Script. Gerendert wird sie mit RENDER VIEW, gespeichert mit SAVE VIEW, und ihre Felder zeigen Renderer an (10.1).

Früher gab es eine zweite, skriptbasierte Bauart (die „Script View“); sie wurde entfernt, weil sie niemand brauchte. Braucht ein Formular einen berechneten Vorgabewert, erledigt das ein BEFORE CREATE-Trigger mit ctx.patch (Kapitel 9) oder ein RUNNABLE.

10.1 Renderer

Ein RENDERER ist ein benannter JS-Baustein, gebunden an genau einen Feldtyp, der steuert, wie ein einzelnes Feld angezeigt und bearbeitet wird — unabhängig von der jeweiligen View, in der er verwendet wird:

CREATE RENDERER text_short FOR STRING
function renderView(value, ctx) { return value; }
function renderEdit(value, ctx) { return value; }
function readEdit(ctx) { return ctx.container.find('input').val(); }
%%;

Bis zu drei Funktionen: renderView (Pflicht) erzeugt die Nur-Lese-Darstellung, renderEdit die Bearbeitungsdarstellung (meist ein <input> mit dem aktuellen Wert vorbelegt), readEdit liest den vom Nutzer eingegebenen Wert wieder aus dem DOM zurück, wenn gespeichert wird. Diese drei Funktionen laufen im Browser des Clients, nicht auf dem Server — der Server liefert sie nur als Teil des HTML/JS-Fragments aus.

renderEdit und readEdit gehören zusammen: entweder beide oder keine. Ein Renderer mit nur renderView ist reine Anzeige — das Feld wird auch im Edit-Modus über renderView gezeigt und beim Speichern nie zurückgelesen (als wäre es READONLY). Der Server prüft das beim Anlegen und Ändern eines Renderers und lehnt fehlendes renderView, einen Syntaxfehler oder nur eine der beiden Funktionen ab.

Nennst Du in einem FIELD keinen expliziten Renderer, greift einer von acht eingebauten Standard-Renderern, passend zum Feldtyp (STRING, TEXT, INT, FLOAT, BOOL, DATETIME, JSON, sowie je einer für eine ONE- bzw. MANY-seitige Beziehung). Die beiden Beziehungs-Renderer bieten im Bearbeitungsmodus direkt eine Sucheingabe mit Server-Rückfrage, keinen nackten ID-Eingabefeld.

10.2 Anzeigemodi

Eine SINGLE VIEW hat einen von drei Modi:

  • MODE VIEW (Standard, falls weggelassen) — reine Nur-Lese-Ansicht eines vorhandenen Datensatzes.
  • MODE EDIT — Ansicht und Bearbeitung eines vorhandenen Datensatzes.
  • MODE CREATE — reine Neuanlage, kein vorhandener Datensatz; jedes FIELD bekommt statt einer Query-Bindung einen eigenen Typ.
RENDER VIEW ingredient_edit WITH id = 3;        -- nur lesen
RENDER VIEW ingredient_edit EDIT WITH id = 3;   -- lesen + bearbeiten
SAVE VIEW ingredient_edit WITH id = 3, name = 'Saffron', unit = 'g';

RENDER VIEW/SAVE VIEW sind Autocommit-only (nicht in einer BEGIN...COMMIT-Kette einreihbar) und brauchen keine eigene Berechtigung — autorisiert wird jeder einzelne zugrundeliegende Befehl ganz normal über read/create/update, als der aufrufende Nutzer, nicht als der Admin, der die View definiert hat.

Eine SINGLE VIEW kann außerdem vier rein informative Hinweise tragen — SHOW WORKFLOW/SHOW DOCS/SHOW LOG/SHOW RUNSCRIPTS —, die einem Client sagen, ob er zusätzlich Workflow-Status, Dokumentenanhänge (Kapitel 12), das Änderungsprotokoll (Kapitel 15) oder einen Starter für die ausführbaren Skripte des Datensatzes anzeigen soll. Der Server prüft dabei nicht nach, ob das überhaupt zutrifft (Workflow vorhanden, Rechte ausreichend) — das bleibt Aufgabe des Clients.

Jede View trägt zusätzlich ein eigenes LABEL — eine reine Anzeige-Beschriftung, die ein GUI als Überschrift der gerenderten Ansicht nutzt (nicht zu verwechseln mit dem nachgestellten LABEL an einem einzelnen FIELD oder dem label-Pseudo-Feld eines Datensatzes). Wie beim Space-/Workflow-Label gilt: Lässt man LABEL '<text>' weg, ist das Label der View-Name; es ist nie leer und hat keine DROP-Form. LABEL '<text>' ist eine Kopf-Klausel. Nachträglich ändern mit ALTER VIEW <name> SET LABEL '<text>'. RENDER VIEW gibt es als Zeile % view_label: zurück (DATA.viewLabel im Fragment), SHOW VIEW als % label:.

10.3 Eine View definieren

CREATE RENDERER supplier_picker FOR RELATION
function renderView(value, ctx) {
    return value === null ? '' : (value.label || ('#' + value.id));
}
function renderEdit(value, ctx) { return value === null ? '' : value.id; }
function readEdit(ctx) { return ctx.container.find('input').val(); }
%%;

CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id"
FIELD name RENDERER text_short
FIELD unit
FIELD supplier RENDERER supplier_picker
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit, supplier = $supplier WHERE id = $id"
;

Eine SINGLE VIEW in MODE VIEW/EDIT hat einen impliziten id-INT-Parameter — die id des Datensatzes. Er wird nie deklariert (PARAM id ist ein Fehler); RENDER VIEW/SAVE VIEW erwarten WITH id = <id>, und $id wird in QUERY/ON SAVE wie ein deklarierter Parameter eingesetzt. Der „Record-Space" der View ist die QUERY-Wurzel (hier ingredient) und wird zurückgemeldet, damit ein Client weiß, welchen Datensatz er anzeigt.

Davon unabhängig gilt: jede gerenderte View beginnt mit der Record-ID — eine LIST VIEW zeigt sie als erste Spalte, eine SINGLE VIEW als erstes Feld. Dafür ist nichts zu deklarieren, der Server stellt sie voran, immer nur lesend; gespeichert wird sie nie, SAVE VIEW schreibt sie also auch nicht zurück. Nur eine MODE CREATE-View hat keine — dort gibt es den Datensatz ja noch gar nicht.

Eine View ist ein einziges ;-Kommando — die Klauseln stehen ohne Trenner hintereinander (Zeilenumbruch zählt wie ein Leerzeichen), schlüsselwortgeführt, in zwei Stufen: erst die Kopf-Klauseln (MODE, LABEL, SHOW ..., CHECK VERSION, RECORD, PARAM), dann die Struktur-Klauseln (QUERY, FIELD, GROUP, ON SAVE). Innerhalb einer Stufe ist die Reihenfolge frei; bedeutsam bleiben nur die FIELD-Position und die ON SAVE-Reihenfolge. Eine Kopf-Klausel nach der ersten Struktur-Klausel ist ein harter Fehler — diese Grenze ist es, die LABEL (Kopf-Klausel und FIELD-Modifier) eindeutig macht. Jede Klausel höchstens einmal außer FIELD/PARAM/ON SAVE:

  • PARAM <name> AS <typ> — ein zusätzlicher typisierter Eingabewert (einer der sechs Skalartypen, nie eine Beziehung) neben dem impliziten id, später per $name in QUERY/ON SAVE einsetzbar.
  • QUERY "<konsolenbefehl>" — höchstens einmal, ein String-Literal ("..." oder '...'), dessen Inhalt roh gespeichert wird (inklusive $PARAM-Platzhaltern) und erst beim tatsächlichen RENDER VIEW mit echten Werten ersetzt und geparst wird. Ein inneres '...' braucht im "..."-Literal kein Escaping. In MODE CREATE komplett verboten — es gibt ja noch keinen Datensatz.
  • FIELD <spaltenpfad> [RENDERER <name> [WITH ...]] [READONLY] [NAVIGATE TO <view>] [LABEL '<text>'] — ein Feld ist eine Ausgabespalte der QUERY, benannt durch ihren vollständigen Pfad mit demselben ->/^ wie die QUERY selbst (name, supplier->name, supplier->^lieferstatus). Kein zusätzlicher Bezeichner, kein SOURCE — da jede Spalte einen eindeutigen kanonischen Namen hat und dieselbe Spalte nicht zweimal projiziert werden kann, ist das Feld strukturell 1:1 mit der Spalte. Nur in MODE VIEW/EDIT gültig.
  • FIELD <name> AS <typ> [DEFAULT <literal>] [READONLY] [LABEL '<text>'] — die Alternative für MODE CREATE: ein einfacher Feldname, kein Bezug zu einer Query, stattdessen ein eigener Typ und ein optionaler, statischer Vorgabewert.

Das optionale, nachgestellte LABEL '<text>' gibt dem Feld eine Anzeige-Beschriftung, die eine GUI statt des nackten Feldnamens darstellt (im gerenderten Fragment wird sie zum <label> jedes Feldes bzw. zur Spaltenüberschrift der Liste). Auf das Speichern hat es keine Wirkung. Fehlt es, fällt der Client auf den Feldnamen zurück. Nachträglich umschaltbar über ALTER VIEW ... ALTER FIELD <name> SET LABEL '<text>' / ... DROP LABEL.

Das optionale, nachgestellte READONLY macht ein Feld auch in MODE EDIT/CREATE nur anzeigbar, nie bearbeitbar: RENDER VIEW EDIT zeigt es immer über renderView, nie über renderEdit, und der Speichern-Knopf des mitgelieferten Fragments sammelt für dieses Feld gar keinen Wert ein. Das ist ein reiner Anzeige-Hinweis, keine neue serverseitige Durchsetzung — ein ON SAVE-Schritt, der $feld trotzdem referenziert, schreibt weiterhin, was ankommt. Nachträglich umschaltbar über ALTER VIEW ... ALTER FIELD <name> SET READONLY / ... UNSET READONLY.

  • ON SAVE "<UPDATE-oder-INSERT-Befehl>" — ein oder mehrere Speicherschritte (Payload als String-Literal), in Eintippreihenfolge ausgeführt, dürfen $PARAM und $FIELD referenzieren. Ein Feld mit ->/^ im Pfad wird als ${supplier->name} (geklammert) referenziert — $supplier->name würde sonst als $supplier gefolgt von ->name gelesen. Nur in MODE EDIT/CREATE; in MODE CREATE muss der erste Schritt ein INSERT sein.

ALTER VIEW ändert gezielt einzelne Teile, ohne die ganze View neu anzulegen:

ALTER VIEW ingredient_edit ADD PARAM MIN_UNIT AS STRING;
ALTER VIEW ingredient_edit DROP PARAM MIN_UNIT;
ALTER VIEW ingredient_edit SET QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id AND unit != ''";
ALTER VIEW ingredient_edit ADD FIELD note;
ALTER VIEW ingredient_edit DROP FIELD note;
ALTER VIEW ingredient_edit ALTER FIELD supplier SET RENDERER supplier_picker WITH variant = 'dropdown';
ALTER VIEW ingredient_edit ALTER FIELD supplier DROP RENDERER;
ALTER VIEW ingredient_edit ALTER FIELD supplier SET READONLY;
ALTER VIEW ingredient_edit ALTER FIELD supplier UNSET READONLY;
ALTER VIEW ingredient_edit ADD ON SAVE "UPDATE ingredient SET supplier = $supplier WHERE id = $id";
ALTER VIEW ingredient_edit DROP ON SAVE 2;
ALTER VIEW ingredient_edit RENAME TO ingredient_form;
ALTER VIEW ingredient_edit DROP QUERY;
ALTER VIEW ingredient_edit SET MODE EDIT;         -- oder MODE VIEW / MODE CREATE
ALTER VIEW ingredient_edit SET SHOW WORKFLOW;     -- oder SHOW DOCS / SHOW LOG / SHOW RUNSCRIPTS
ALTER VIEW ingredient_edit UNSET SHOW WORKFLOW;
ALTER VIEW ingredient_edit SET LABEL 'Zutat bearbeiten';  -- das eigene Anzeige-Label der View

Ein FIELD darf eine Hop-Spalte sein (QUERY ingredient -> (name, supplier -> (name)), FIELD supplier->name); um so eine Spalte zurückzuschreiben, folgt ein ON SAVE-Schritt demselben Hop mit UPDATE <wurzel> -> <hop> SET <spalte> = ${supplier->name} WHERE id = $id (siehe Kapitel 6).

Hat der Hop ein DETAIL SPACE (Kapitel 6, "Relationsdetails"), kann eine ^-Spalte (QUERY ingredient -> (name, supplier -> ^lieferstatus), FIELD supplier->^lieferstatus) ein Feld dieses Detail-Datensatzes anzeigen. Zurückgeschrieben wird sie mit einem abschließenden -> ^-Schritt: UPDATE <wurzel> -> <hop> -> ^ SET <detailfeld> = $field WHERE id = $id. Existiert für das Paar noch kein Detail-Datensatz, wird einer aus den SET-Zuweisungen angelegt und verknüpft. Erlaubt nur als letztes Segment, nur nach mindestens einem ->-Hop und nur wenn der letzte Hop eine ONE-seitige Relation mit DETAIL SPACE ist; für MANY:MANY nicht unterstützt. Rechte: read auf Wurzel und Zwischenstationen, update und create auf dem Detail-Space.

Im Referenz-PHP-Frontend wird eine View vollständig in einem Klick-Query-Builder gebaut und bearbeitet — er stellt QUERY, jedes FIELD (benannt nach seinem Spaltenpfad) und die ON SAVE-Schritte (Wurzel plus einer pro Hop) für dich zusammen. Die handgeschriebenen ALTER VIEW-Befehle oben bleiben der zugrundeliegende Mechanismus.

DROP PARAM/DROP QUERY/DROP FIELD werden abgelehnt, solange sie noch irgendwo referenziert werden (in QUERY- oder ON SAVE-Text) — dieselbe "noch referenziert"-Regel wie bei DROP SCRIPT/DROP LIBRARY (Kapitel 9). Ein Renderer-Script selbst lässt sich per ALTER RENDERER ... SET SCRIPT an Ort und Stelle austauschen, genau wie ALTER SCRIPT ... SET SOURCE (Kapitel 9.2) — jede bestehende FIELD-Bindung bleibt erhalten.

10.3a Gruppen: eine Zeile pro Verknüpfung

Eine View auf einem einzelnen Datensatz zeigt genau eine Zeile. Was aber, wenn zu diesem Datensatz mehrere gehören — alle Zutaten eines Rezepts, jede mit ihrer eigenen Menge? Die Menge gehört weder dem Rezept noch der Zutat, sondern der Verknüpfung zwischen beiden; genau dafür gibt es den Detail Space (Kapitel 6.2). Bis jetzt war er in einer View nur zu sehen, wenn die Beziehung ONE war.

Eine GROUP löst das: ein wiederholter Block, der eine Zeile pro Verknüpfung anzeigt.

Der Weg über die Abfrage funktioniert nicht. Man könnte auf die Idee kommen, die Spalte einfach in die QUERY der View zu schreiben: QUERY rezept -> (name, zutaten -> menge). Diese Abfrage liefert aber eine Zeile pro Zutat — eine Einzelsatz-View zeigt jedoch genau eine. Früher hat sie stillschweigend die erste angezeigt und den Rest weggeworfen; heute lehnt der Server das beim Rendern mit einer klaren Meldung ab und verweist auf die Gruppe. Eine ^-Spalte hinter einer ONE-Beziehung ist davon nicht betroffen — dort gibt es genau einen Detail-Datensatz, und das funktioniert wie immer.

CREATE VIEW rezept_bearbeiten AS SINGLE
MODE EDIT
QUERY "QUERY recipe -> (name) WHERE id = $id"
FIELD name
GROUP ingredients LABEL 'Zutaten' ORDER BY name (
    FIELD name     READONLY
    FIELD ^amount
    FIELD ^unit
)
ON SAVE "UPDATE recipe SET name = $name WHERE id = $id"
;

Die Gruppe ist ihr Beziehungsfeld — ingredients adressiert das gleichnamige MANY-Feld auf recipe, einen eigenen Namen braucht sie nicht. Innerhalb des Blocks sind Feldnamen relativ zur Verknüpfung:

  • name ist ein Feld der Zutat selbst,
  • ^amount ist ein Feld des Detail-Datensatzes der Verknüpfung — dasselbe ^, das Du aus QUERY kennst (Kapitel 6.2).

Ein paar Dinge, die die Gruppe von selbst tut:

  • Die erste Spalte heißt immer row und zeigt die Zutat selbst. Sie identifiziert die Zeile und ist deshalb nie bearbeitbar — eine Verknüpfung wird entfernt und neu angelegt, nicht umgebogen. Schreibst Du FIELD row LABEL 'Zutat', konfigurierst Du damit genau diese Spalte, Du bekommst keine zweite.
  • Die Reihenfolge ist immer eindeutig. ORDER BY name sortiert nach dem Zutatennamen; bei Gleichstand (und ganz ohne ORDER BY) entscheidet die Datensatz-ID. Ohne diesen Nachschlüssel würde die Tabelle bei jedem Neuladen anders herum stehen, was beim Bearbeiten aussieht, als wäre etwas verlorengegangen. Sortieren darfst Du auch nach einer Spalte, die Du gar nicht anzeigst.
  • Mehrere Gruppen pro View sind erlaubt, dieselbe Beziehung aber nur einmal. GROUP-Blöcke lassen sich nicht schachteln; der Rumpf steht in Klammern ( ... ) (die Kopf-Klauseln LABEL/ANCHOR/ORDER BY/NO ADD/NO REMOVE davor), ein fehlendes ) ist ein benannter Fehler.
  • Berechtigungen: Wer die View öffnet, braucht read auf den Space der Zeilen (hier ingredient) und — sobald eine ^-Spalte im Spiel ist — auch auf den Detail Space. Fehlt eines davon, gibt es eine klare Fehlermeldung; die Gruppe verschwindet nie stillschweigend.
  • Sehr lange Listen werden gekappt, nach derselben Obergrenze wie eine List View (maxListViewRows, Kapitel 15).

Eine ^-Spalte kann außerdem zum Detail-Datensatz navigieren:

    FIELD ^amount NAVIGATE TO mengen_detail VIA DETAIL

VIA DETAIL sagt: übergib der Ziel-View die ID des Detail-Datensatzes, nicht die der Zutat. Zeilen, für die es noch keinen Detail-Datensatz gibt, sind einfach nicht anklickbar.

Gruppen bearbeiten

Damit eine Spalte beschreibbar wird, braucht die Gruppe einen eigenen ON SAVE-Schritt — genau einen Schritt, der pro geänderter Zeile einmal läuft:

GROUP ingredients LABEL 'Zutaten' ORDER BY name (
    FIELD name     READONLY
    FIELD ^amount
    FIELD ^unit
    ON SAVE "UPDATE recipe -> ingredients -> ^ SET amount = ${^amount}, unit = ${^unit} WHERE id = $id AND ingredients = $row"
)

Zwei neue Platzhalter stehen darin zur Verfügung: $row ist die Zutat dieser Zeile, und ${^amount} ist der in dieser Zeile eingegebene Wert. Die geschweiften Klammern sind nötig, weil $^amount nicht als Platzhalter erkennbar wäre — dieselbe Schreibweise wie bei Pfad-Feldern (${teile->name}, Kapitel 10.2). Gibt es für die Verknüpfung noch keinen Detail-Datensatz, legt der Schritt ihn an (Kapitel 6.2).

Die Zeilen selbst verwaltet der Server, ohne dass Du dafür etwas schreibst. Das Frontend schickt beim Speichern die vollständige Liste der angezeigten Zeilen mit; alles Weitere ergibt sich daraus:

  • eine Zeile, die neu dazugekommen ist, wird verknüpft,
  • eine Zeile, die fehlt, wird gelöst (bei WITH DETAIL … OWNED samt ihrem Detail-Datensatz, Kapitel 6.2),
  • Trigger und WHERE-Bedingungen der Beziehung greifen dabei ganz normal.

Zwei Bremsen kannst Du im Kopf der Gruppe setzen:

GROUP ingredients LABEL 'Zutaten' NO ADD    ( ... )
GROUP ingredients LABEL 'Zutaten' NO REMOVE ( ... )

NO ADD weist eine neue Zeile mit einer Fehlermeldung ab; NO REMOVE ignoriert eine fehlende Zeile einfach. Der Unterschied ist Absicht: eine neue Zeile kann nur bewusst geschickt worden sein, eine fehlende Zeile dagegen kann genauso gut bedeuten „wurde gar nicht erst angezeigt“.

Genau aus diesem Grund ist eine gekappte Gruppe automatisch additiv: Wenn die Liste länger ist als maxListViewRows, werden Zeilen nur noch hinzugefügt, nie entfernt. Sonst würde ein einziger Klick auf „Speichern“ in einer Liste mit 5000 Verknüpfungen, von denen 1000 sichtbar sind, die übrigen 4000 löschen.

Ein paar Dinge lehnt der Server rundheraus ab, statt sie stillschweigend zu schlucken: dieselbe Zeile zweimal, eine Zeile ohne ausgewählte Zutat, eine Zutat, die es gar nicht gibt, und eine Zeile, die einen Wert weglässt, den ihr eigener ON SAVE-Schritt braucht.

Gruppen in einer Anlege-View

In MODE CREATE startet die Gruppe leer, und jede Zeile, die jemand hinzufügt, ist neu. Ein Problem gibt es dabei: die Verknüpfungen brauchen die ID des Datensatzes — und den legt dieselbe View ja erst an. Deshalb sagt die Gruppe mit ANCHOR, welche Variable ihn hält:

CREATE VIEW rezept_neu AS SINGLE
MODE CREATE
RECORD recipe
FIELD name AS STRING
GROUP ingredients ANCHOR $neu (
    FIELD name READONLY
    FIELD ^amount
    ON SAVE "UPDATE recipe -> ingredients -> ^ SET amount = ${^amount} WHERE id = $neu AND ingredients = $row"
)
ON SAVE "INSERT INTO recipe (name = $name) AS $neu"
;

$neu kommt aus dem AS $neu des INSERT-Schritts. Die Schritte der Gruppe laufen immer nach allen View-Schritten, die Variable steht also bereit. Und weil eine Anlege-View gar keinen impliziten id-Parameter hat, greift auch der Schritt der Gruppe über $neu auf den Datensatz zu.

ANCHOR ist in MODE CREATE Pflicht und überall sonst verboten: In einer Bearbeiten-View hängt die Gruppe ohnehin an dem Datensatz, den die View zeigt — und an genau dem wurden auch ihre Spalten aufgelöst.

Wer eine solche View benutzen soll, braucht auf dem Space der View nicht nur create, sondern auch update: das Setzen der Verknüpfungen ist ein ganz normaler Schreibvorgang auf dem Datensatz — daran ändert sich nichts dadurch, dass er einen Moment vorher angelegt wurde.

10.4 Create-Modus und List Views

Eine MODE CREATE-View braucht keine QUERY — sie legt einen neuen Datensatz an, statt einen vorhandenen zu zeigen. Sie hat kein implizites id, und eine MODE CREATE-View muss ihr INSERT-Ziel mit einer RECORD <space>-Klausel nennen:

CREATE VIEW ingredient_new AS SINGLE
MODE CREATE
RECORD ingredient
FIELD name AS STRING
FIELD unit AS STRING DEFAULT 'g'
ON SAVE "INSERT INTO ingredient (name = $name, unit = $unit)"
;

RENDER VIEW ingredient_new EDIT;   -- ohne EDIT abgelehnt, es gibt ja noch keinen Datensatz

Neben der bisher gezeigten SINGLE VIEW (genau ein Datensatz) gibt es auch eine LIST VIEW — CREATE VIEW ingredient_list AS LIST ... —, dieselbe Syntax, nur für eine ganze Ergebnisliste statt eines einzelnen Datensatzes; MODE/SHOW WORKFLOW/SHOW DOCS/SHOW LOG/SHOW RUNSCRIPTS gibt es dort nicht, da diese sich auf genau einen Datensatz beziehen.

10.5 Nachschauen und Aufräumen

LIST VIEWS;
SHOW VIEW ingredient_edit;
LIST RENDERERS;
SHOW RENDERER text_short;

Alle vier brauchen — anders als LIST SCRIPTS/SHOW SCRIPT — keine besondere Berechtigung: Eine View/ein Renderer ist zur Nutzung durch normale Anwender gedacht, nicht nur für Admins interessant. Löschen und Umbenennen funktionieren wie bei einem Script:

DROP VIEW ingredient_edit;
DROP RENDERER text_short;
ALTER RENDERER text_short RENAME TO text_short_v2;

10.6 Optimistischer Nebenläufigkeitsschutz (keine verlorenen Änderungen)

Zwei deiner Köche öffnen gleichzeitig dieselbe Zutat zum Bearbeiten, lassen die Ansicht eine Weile offen und speichern dann nacheinander. Ohne weiteres Zutun überschreibt der zweite Speichervorgang einfach still die Änderung des ersten, und niemand bemerkt es. CHECK VERSION schließt genau diese Lücke — mit einer einzigen zusätzlichen Klausel und sonst keinem weiteren Code:

CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
CHECK VERSION
QUERY "QUERY ingredient -> (name, unit) WHERE id = $id"
FIELD name
FIELD unit
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit WHERE id = $id"
;

Das knüpft an das version-Feld an, das jeder Record ohnehin automatisch mitträgt (Kapitel 4) — ein einfacher Zähler, der bei jeder Änderung um eins hochgezählt wird. RENDER VIEW merkt sich still die aktuelle version der Zutat mit dem Formular; wenn der zweite Koch seinen SAVE VIEW-Befehl schickt, bemerkt der Server, dass sich die Zutat seit dem Laden der Kopie weiterbewegt hat, und weist das Speichern mit einer klaren Fehlermeldung zurück, statt die Änderung des ersten Kochs stillschweigend zu verwerfen. Dessen eigenes Speichern (das den tatsächlich aktuellen Stand widerspiegelt) geht problemlos durch.

Du musst dafür kein version-FIELD hinzufügen, deine QUERY nicht anpassen und deinen ON SAVE-Schritt nicht anfassen — die Prüfung passiert komplett außerhalb der von dir deklarierten Felder. CHECK VERSION ist ein nacktes Flag (geprüft wird das implizite id gegen den Record-Space der View) und ergibt nur in MODE EDIT Sinn — nur ein bestehender Record kann sich "seit dem Laden geändert haben"; ein brandneuer, in MODE CREATE, kann das nicht:

ALTER VIEW ingredient_edit SET CHECK VERSION;
ALTER VIEW ingredient_edit UNSET CHECK VERSION;

Wer statt der vom Server mitgelieferten Bearbeitungsoberfläche eine eigene baut, sollte Folgendes wissen: Das generierte Fragment schickt die gemerkte Version beim Speichern automatisch als __version__-Wert mit — siehe GUI-INTEGRATION.md, falls du diesen Teil selbst schreibst.

10.7 Zwischen Views navigieren

Ein Feld kann auf eine andere View verweisen — so klickt man sich von der Zutatenansicht zum Lieferanten, von der Lieferantenliste zum einzelnen Lieferanten und so weiter. Das Suffix heißt NAVIGATE TO <view>, nachgestellt wie READONLY:

CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id"
FIELD name  NAVIGATE TO ingredient_history
FIELD unit
FIELD supplier NAVIGATE TO supplier_detail
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit WHERE id = $id"
;

Welche ID beim Klick mitwandert, ergibt sich aus der Feldart — genau so, wie man es erwartet:

  • Beziehungsfeld (supplier): die ID des verwiesenen Records — also der Lieferant, auf den das Feld zeigt. Bei einem Mehrfach-Feld ist jeder einzelne Eintrag für sich anklickbar.
  • jedes andere Feld (name): die ID des angezeigten Records — bei einer SINGLE VIEW ihr eigenes implizites id, bei einer LIST VIEW die der angeklickten Zeile (die steht dort immer zur Verfügung, siehe die implizite ID-Spalte oben).

Bei einer MODE VIEW/EDIT-View ist der Record-Space die QUERY-Wurzel, ein Nicht-Beziehungsfeld navigiert also ohne Zusatzklausel. Eine MODE CREATE-View nennt ihn mit ihrer RECORD <space>-Klausel — kein WITH ID mehr, die Record-ID ist immer das implizite id.

Das Ziel muss eine SINGLE VIEW in MODE VIEW/EDIT sein (sie empfängt die ID als ihr eigenes implizites id). Gibt es das Ziel nicht oder ist es keine solche View, wird die Navigation für dieses Feld still weggelassen — die Quell-View bricht nicht ab. NAVIGATE auf einer MODE CREATE-View ist nicht erlaubt.

Nachträglich umschaltbar:

ALTER VIEW ingredient_new SET RECORD ingredient;   -- nur MODE CREATE
ALTER VIEW ingredient_edit ALTER FIELD supplier SET NAVIGATE TO supplier_detail;
ALTER VIEW ingredient_edit ALTER FIELD supplier DROP NAVIGATE;

Die eigentliche Navigation macht die GUI, nicht der Server: das generierte Fragment ruft eine vom Host bereitgestellte Funktion window.noplmNavigate({ view, params }) auf — siehe GUI-INTEGRATION.md, falls du die Oberfläche selbst schreibst.

Kapitel 11

Menü

Ein GUI-Client braucht irgendeine Navigation — welche Einträge gibt es, in welcher Reihenfolge, wer darf welchen sehen. Statt das jedem Client selbst überlassen zu müssen, verwaltet noPLM einen einzigen, hierarchischen Menübaum direkt über die Konsole.

Einträge anlegen

CREATE MENU ITEM kitchen_root PARENT root LABEL 'Kitchen';
CREATE MENU ITEM recipes PARENT kitchen_root LABEL 'Recipes' SORT 10 VIEW recipe_edit WITH RECORD_ID = 1;
CREATE MENU ITEM ingredients PARENT kitchen_root LABEL 'Ingredients' SORT 20;

Jeder Eintrag ("Menu Item") hat einen global eindeutigen name, eine verpflichtende LABEL (anders als bei einem Space gibt es hier keinen Rückfall auf den Namen — ein technischer Bezeichner wie recipes ist nicht zwingend als Anzeigename geeignet), genau ein PARENT, einen SORT-Wert (Ganzzahl, Standard 0, bestimmt die Reihenfolge unter Geschwistern) und optional eine VIEW-Bindung mit festen Parametern — dieselbe WITH-Syntax wie bei RENDER VIEW (Kapitel 10), hier aber nicht verpflichtet, jeden von der View deklarierten Parameter abzudecken.

Es gibt genau eine Wurzel, root, beim ersten Start automatisch angelegt — sie hat keine eigene Anzeige (kein Label, taucht nie in LIST MENU auf) und lässt sich nie umbenennen, verschieben, umsortieren oder löschen. PARENT root ist einfach der Weg, einen Eintrag ganz oben einzuhängen.

Einen Eintrag ändern

ALTER MENU ITEM recipes RENAME TO all_recipes;
ALTER MENU ITEM all_recipes SET PARENT root;
ALTER MENU ITEM all_recipes SET LABEL 'All Recipes';
ALTER MENU ITEM all_recipes SET SORT 5;
ALTER MENU ITEM all_recipes SET VIEW recipe_edit WITH RECORD_ID = 2;
ALTER MENU ITEM all_recipes DROP VIEW;

SET VIEW überschreibt eine bestehende Bindung einfach — kein DROP VIEW vorher nötig. SET PARENT verweigert eine Verschiebung, die einen Zyklus erzeugen würde (auch den trivialen Fall "eigener Elternknoten"). Ein Eintrag mit Kindern lässt sich nicht löschen:

DROP MENU ITEM kitchen_root;
! menu item 'kitchen_root' still has children - move or remove them first
*

Kein CASCADE-Ausweg wie bei DROP SPACE — Kinder müssen erst verschoben oder entfernt werden.

Löscht Du eine View oder eine Rolle, auf die ein Menüeintrag verweist, scheitert das nicht: DROP VIEW räumt die Bindung (samt gespeicherten Parametern) einfach leer, DROP ROLE entfernt einfach die Rollenzuweisung — anders als bei DROP SCRIPT/DROP LIBRARY/DROP RENDERER gibt es hier keine "noch referenziert"-Sperre.

Sichtbarkeit nach Rolle

ASSIGN ROLE cook TO MENU ITEM all_recipes;
UNASSIGN ROLE cook FROM MENU ITEM all_recipes;

Ohne zugewiesene Rolle ist ein Eintrag für jeden sichtbar. Sobald mindestens eine Rolle zugewiesen ist, braucht der aufrufende Nutzer mindestens eine davon (über die volle Rollenhierarchie hinweg, siehe Kapitel 13) — sonst bleibt der Eintrag für ihn unsichtbar, und zwar mitsamt dem kompletten Teilbaum darunter, unabhängig davon, wie dessen Kinder selbst gegated sind. Unabhängig davon fällt ein Eintrag ganz ohne View und ganz ohne (verbleibende) Kinder — ein "totes Blatt" — aus LIST MENU automatisch heraus, selbst wenn er nie rollenbeschränkt war.

Nachschauen

noplm> LIST MENU;
% menu_item: kitchen_root|Kitchen|root|0|
% menu_item: all_recipes|All Recipes|kitchen_root|5|recipe_edit
% menu_item_param: all_recipes|RECORD_ID|2
% menu_item_role: all_recipes|cook
% menu_item: ingredients|Ingredients|kitchen_root|20|
% total: 3
*

LIST MENU; braucht keine Berechtigung — es liefert genau den Baum, den die aufrufende Sitzung nach den obigen Sichtbarkeitsregeln sehen würde (gefiltert, tote Blätter entfernt). LIST MENU ITEMS; dagegen ist admin-only und zeigt immer den vollständigen, ungefilterten Baum — das Werkzeug zum Verwalten/Prüfen, nicht zum Anzeigen für einen konkreten Nutzer.

Kapitel 12

Dokumente

Ein Rezept ohne Foto des fertigen Gerichts ist nur die halbe Miete — noPLM nennt einen an einen Datensatz angehängten Dateianhang Doc (kurz für "Dokument"). Die eigentlichen Dateibytes laufen dabei nie über die Konsole selbst — die ist zeilenbasierter Text, kein Ort für Binärdaten. Stattdessen tauscht die Konsole nur Metadaten aus; die eigentlichen Bytes bewegt ein externer Prozess direkt im Dateisystem, das er sich mit dem Server teilt (typischerweise: ein Upload-Handler Deiner eigenen Anwendung, der auf demselben Rechner oder Mount läuft).

Hochladen: zwei Schritte

REGISTER UPLOAD FILENAME = 'risotto.jpg', MIME = 'image/jpeg', CHECKSUM = 'd41d8cd98f00b204e9800998ecf8427e', SIZE = 204800;
% upload_id: 7f3a1c9e4b2d4f6a8c0e2b4d6f8a0c2e
% staging_path: /var/noplm/doc-exchange/7f3a1c9e4b2d4f6a8c0e2b4d6f8a0c2e
*

REGISTER UPLOAD reserviert nur einen Platz — Du (bzw. Dein externer Prozess) schreibst die eigentliche Datei anschließend selbst an staging_path. CHECKSUM ist ein MD5-Hash (nur zur Transportprüfung, keine kryptografische Absicherung), SIZE wird sofort gegen die serverseitige Obergrenze geprüft. Dieser erste Schritt braucht keine besondere Berechtigung — nur eine gültige Sitzung.

Erst wenn die Datei tatsächlich am angegebenen Pfad liegt, bestätigst Du den Upload und hängst ihn an einen Datensatz:

ATTACH DOC '7f3a1c9e4b2d4f6a8c0e2b4d6f8a0c2e' TO recipe WITH ID = 2 COMMENT 'finished dish, plated';
% id: 15
% version: 1
*

Bestätigen und Anhängen sind bewusst ein Befehl, nicht zwei — ein bestätigter, aber nicht angehängter Upload wäre nur eine verwaiste Datei ohne Besitzer. ATTACH DOC prüft dabei erneut Größe und Prüfsumme gegen die tatsächlich auf der Platte liegende Datei, bevor sie endgültig übernommen wird. Beide Schritte sind Autocommit-only — nicht in eine BEGIN...COMMIT-Kette einreihbar.

Herunterladen

REQUEST DOWNLOAD OF recipe WITH ID = 2, FILENAME = 'risotto.jpg';
% path: /var/noplm/doc-exchange/9c1e3a7b5d1f3a5c7e9b1d3f5a7c9e1b
% checksum: d41d8cd98f00b204e9800998ecf8427e
*

VERSION weggelassen liefert die neueste Version. Der zurückgegebene path liegt (per Hardlink, keine Kopie) im selben Austauschverzeichnis wie beim Upload — Dein externer Prozess liest die Datei von dort.

Versionen und Löschen

Ein erneuter Upload derselben Datei auf denselben Datensatz legt automatisch die nächste Version an — Versionen sind nie überschrieben, frühere bleiben abrufbar (VERSION = 1, VERSION = 2, ...).

DELETE DOC 'risotto.jpg' VERSION = 1 FROM recipe WITH ID = 2;   -- genau eine Version
DELETE DOC 'risotto.jpg' FROM recipe WITH ID = 2;                -- alle Versionen

Anders als die beiden Upload/Download-Befehle ist DELETE DOC ganz normal in einer Transaktionskette einreihbar. Gelöschte Versionsnummern werden nie wiederverwendet, solange noch eine andere Version derselben Datei existiert — erst wenn wirklich alle Versionen weg sind, fängt eine neue Datei mit diesem Namen wieder bei Version 1 an.

Attribute an einem Doc

Jede Doc-Version kann zusätzliche Schlüssel-Wert-Paare tragen — anders als der globale Entry Store aus Kapitel 8 hier gebunden an ein konkretes Dokument und über dieselben Rechte erreichbar wie Hoch-/Herunterladen selbst (nicht admin-only):

SET DOC ATTR reviewed = 'true' ON recipe WITH ID = 2, FILENAME = 'risotto.jpg';
GET DOC ATTR reviewed ON recipe WITH ID = 2, FILENAME = 'risotto.jpg';
% value: true
*
LIST DOC ATTRS OF recipe WITH ID = 2, FILENAME = 'risotto.jpg';
% doc_attr: reviewed|true
*
REMOVE DOC ATTR reviewed ON recipe WITH ID = 2, FILENAME = 'risotto.jpg';

Ein fehlendes Attribut bei GET ist ein Fehler, keine leere Antwort — dieselbe Konvention wie beim Entry Store. Kein Trigger feuert für einen Attribut-Schreibvorgang; es ist ein reiner Datenzugriff.

Nachschauen

noplm> LIST DOCS OF recipe WITH ID = 2;
% doc: risotto.jpg|1|image/jpeg|204800|d41d8cd98f00b204e9800998ecf8427e|finished dish, plated|maria|2026-01-15T10:00:00Z
% total: 1
*

LIST DOCS OF ist pro Datensatz und braucht die download-Berechtigung (getrennt von read — mehr zu Rechten in Kapitel 13). Zwei admin-only Befehle decken das ganze Lager ab, unabhängig von einzelnen Download-Berechtigungen: LIST ALL DOCS OF recipe; (jedes Doc dieser Space) und LIST ALL DOCS; (jedes Doc der gesamten Datenbank) — beide mit derselben % doc:-Zeile, nur mit zusätzlichen führenden Teilen (record_id, bei LIST ALL DOCS zusätzlich der Space-Name).

Berechtigungen

Vier eigenständige Rechte statt einem groben "darf mit Docs arbeiten": upload (für ATTACH DOC und SET/REMOVE DOC ATTR), download (für REQUEST DOWNLOAD OF, GET DOC ATTR, LIST DOC ATTRS OF, LIST DOCS OF) und das bestehende delete (für DELETE DOC). Bewusst getrennt von read: Wer die Felder eines Datensatzes lesen darf, muss nicht automatisch auch seine angehängten Dateien herunterladen dürfen, und umgekehrt.

Konsistenzprüfung

VERIFY DOCS OF recipe;   -- admin-only, Autocommit-only
VERIFY ALL DOCS;

Für den seltenen Fall, dass ein Dateisystem-Backup und ein Datenbank-Export zu unterschiedlichen Zeitpunkten gezogen wurden und danach auseinanderlaufen: Beide Befehle vergleichen jede _meta_docs-Zeile gegen die tatsächlich vorhandene Datei (Existenz und Prüfsumme) und entfernen inkonsistente Einträge automatisch — jede Zeile in ihrer eigenen Transaktion, damit ein einzelner fehlerhafter Eintrag nicht den ganzen Durchlauf abbricht — was sich nicht prüfen ließ, wird gemeldet, nicht verschwiegen. VERIFY ALL DOCS durchsucht zusätzlich das komplette Speicherverzeichnis nach Dateien ohne zugehörige Datenbankzeile.

Eine gerade erst angelegte Datei lässt dieser Rückwärtslauf bewusst in Ruhe: Beim Hochladen landet die Datei zuerst im Speicherverzeichnis und erst danach ihre Datenbankzeile, sodass sie für einen kurzen Moment tatsächlich wie eine verwaiste Datei aussieht. Wer sie in diesem Moment löschte, erzeugte genau den Schaden, den VERIFY reparieren soll. Zu junge Dateien werden deshalb übersprungen und in der Ausgabe als solche ausgewiesen; beim nächsten Durchlauf sind sie alt genug.

Teil V · Verwaltung

Kapitel 13

Benutzer, Rollen und Rechte

Kurze Erinnerung: kein Passwort

Wie in Kapitel 2 erwähnt, kennt noPLM keine Passwort-Authentifizierung — LOGIN <name>; prüft nur, ob dieser Nutzer existiert und aktiviert ist. Echte Authentifizierung wird als bereits vorgelagert vorausgesetzt (Gateway, Proxy, ...). Dieses Kapitel beschäftigt sich entsprechend nicht mit "wie meldet sich jemand an", sondern mit "was darf jemand, sobald er angemeldet ist".

Benutzer

CREATE USER maria;
# OK: user 'maria' created
*
CREATE USER admin_helper ADMIN;
ALTER USER maria DISABLE;
ALTER USER maria ENABLE;
ALTER USER admin_helper UNSET ADMIN;
ALTER USER admin_helper SET ADMIN;
DROP USER maria;

ALTER USER ... DISABLE ist die übliche Art, jemanden vorübergehend auszusperren, ohne Konto, Rollenzuweisungen oder Historie zu verlieren — ein deaktivierter Nutzer kann sich einfach nicht mehr einloggen (auch nicht per Identitätswechsel mitten in einer Sitzung). DROP USER dagegen ist endgültig: Konto weg, Rollenzuweisungen weg. Eine Ausnahme: Von diesem Nutzer hochgeladene Dokumente (Kapitel 12) bleiben erhalten, nur der Hochlader-Verweis wird leer — ein Doc ist echte Geschäftsdaten, kein Konto-Anhängsel. Ein USER-Feld (Kapitel 4) verhält sich ähnlich: nullbar wird es beim DROP USER automatisch auf NULL zurückgesetzt, NOT NULL blockiert den DROP USER stattdessen mit einer klaren Fehlermeldung.

Drei eingebaute Schutzmechanismen, identisch für DROP USER, ALTER USER ... UNSET ADMIN und ALTER USER ... DISABLE:

  • Niemand kann sich selbst die eigenen Admin-Rechte nehmen, sich selbst deaktivieren oder sich selbst löschen — auch dann nicht, wenn andere Admins existieren.
  • Keiner der drei Befehle darf den letzten aktivierten Admin übrig lassen — ein deaktivierter Admin zählt nicht als "noch da", weil er sich ja nicht mehr einloggen kann.
  • Beide Regeln gelten nur für die "wegnehmende" Richtung — CREATE USER ... ADMIN, ALTER USER ... SET ADMIN, ALTER USER ... ENABLE sind uneingeschränkt, auch auf die eigene Sitzung angewendet.

Rollen und Rechte

Rechte werden nie direkt an einen Nutzer vergeben, immer über eine Rolle:

CREATE ROLE cook;
CREATE ROLE head_chef;

GRANT READ, CREATE, UPDATE ON recipe TO cook;
GRANT READ ON ingredient TO cook;
GRANT READ, CREATE, UPDATE, DELETE, FLOW ON recipe TO head_chef;

ASSIGN ROLE cook TO USER maria;
UNASSIGN ROLE cook FROM USER maria;

REVOKE UPDATE ON recipe FROM cook;
DROP ROLE head_chef;

DROP ROLE ist ein permanentes, hartes Löschen — Rolle, ihre Rechtevergaben und ihre Nutzerzuweisungen sind komplett weg. Ein ROLE-Feld (Kapitel 4) verhält sich hier genau wie ein USER-Feld: ein nullbares springt bei DROP ROLE automatisch auf NULL zurück, ein NOT NULL-Feld verhindert den DROP ROLE stattdessen mit einer klaren Fehlermeldung. Anders als bei DROP USER gibt es hier keinen Selbstlösch- oder Letzter-Admin-Schutz zu beachten — eine Rolle kennt kein "gerade eingeloggt als".

Neun unabhängige Rechte pro Space-Rolle-Kombination:

Recht Steuert
READ QUERY, ->-Hops
CREATE INSERT
UPDATE UPDATE (auch schreibbare Beziehungsfelder, Kapitel 6)
DELETE DELETE, DELETE DOC
RUN RUN SCRIPT
FLOW FLOW-Übergänge (Kapitel 7)
UPLOAD ATTACH DOC, SET/REMOVE DOC ATTR
DOWNLOAD REQUEST DOWNLOAD OF, GET DOC ATTR, LIST DOC(S) ...
LOGS LIST FIELD LOG OF, LIST FLOW LOG OF (Kapitel 15)

Rollenhierarchie

Eine Rolle kann selbst weitere Rollen "enthalten" — die übergeordnete Rolle erbt dann transitiv alles, was die untergeordnete(n) mitbringen:

ASSIGN ROLE cook TO ROLE head_chef;

Ab jetzt hat jeder mit der Rolle head_chef automatisch auch alles, was cook mitbringt — ohne separate Zuweisung. Der Rollen-Graph darf beliebig viele Eltern-/Kind-Beziehungen haben (kein Baum, sondern ein DAG), solange er zyklenfrei bleibt — ein Versuch, der einen Zyklus erzeugen würde (auch der triviale Fall, eine Rolle sich selbst zuzuweisen), wird abgelehnt. Es gibt keine Tiefenbegrenzung. UNASSIGN ROLE cook FROM ROLE head_chef; entfernt genau diese eine Kante wieder.

Jede Rechteprüfung — egal ob ein echter Befehl oder eine der folgenden Introspektions-Abfragen — berücksichtigt automatisch die gesamte transitive Hülle, nicht nur direkt zugewiesene Rollen.

Nachschauen

noplm> LIST USERS;
% user: maria|false|true
% user: admin|true|true
*
noplm> SHOW USER maria;
% user: maria
% admin: false
% enabled: true
% roles: cook
*
noplm> SHOW ROLE head_chef;
% permission: recipe|true|true|true|true|false|true|false|false|false
*
noplm> LIST ROLES;
% role: cook
% role: head_chef
*
noplm> LIST ROLES OF USER maria TOP;
% role: cook
*
noplm> LIST ROLES OF USER maria ALL;
% role: cook
*
noplm> LIST ROLES OF ROLE head_chef TOP;
% role: cook
*
noplm> LIST ROLE HIERARCHY TOP;
% role_hierarchy: head_chef|cook
*
noplm> LIST PERMISSIONS OF head_chef TOP;
% permission: recipe|true|true|true|true|false|true|false|false|false
*
noplm> LIST PERMISSIONS OF head_chef ALL;
% permission: recipe|true|true|true|true|false|true|false|false|false
% permission: ingredient|true|false|false|false|false|false|false|false|false
*

TOP/ALL sind bei jedem dieser Befehle Pflicht, ohne Standardwert — ein Client soll nie überrascht werden, welche der beiden Sichten er gerade bekommt. SHOW ROLE <role>; ist die einzige Ausnahme: Sie zeigt immer nur die direkt an dieser einen Rolle vergebenen Rechte, ganz ohne TOP/ALL-Parameter — die aggregierte Sicht gibt es dort ausschließlich über LIST PERMISSIONS OF <role> ALL;.

LIST ROLE HIERARCHY TOP|ALL; ist die Stapelform von LIST ROLES OF ROLE — jede Eltern→Kind-Kante in einem Befehl (je eine Zeile % role_hierarchy: <rolle>|<unterrolle>), damit eine Admin-Ansicht, die jede Rolle neben ihren Unterrollen auflistet, nicht einen Aufruf pro Rolle braucht.

LIST USERS/SHOW USER/SHOW ROLE/LIST ROLES OF/LIST ROLE HIERARCHY/LIST PERMISSIONS OF sind admin-only. Für die eigene Sitzung gibt es drei Gegenstücke, die keine Berechtigung brauchen — praktisch für ein GUI, das ohne Admin-Rechte entscheiden will, was es anzeigt:

SHOW ME;
% user: maria
% admin: false
% enabled: true
% roles: cook
*
SHOW MY PERMISSIONS ON recipe;
% permission: recipe|true|true|true|false|false|false|false|false|false
*
LIST MY ROLES ALL;
% role: cook
*

Eine Admin-Sitzung bekommt bei SHOW MY PERMISSIONS ON immer alle neun Rechte als true zurück, ohne dass die Abfrage überhaupt gegen die Rollenzuweisungen läuft — Admins sind von jeder Rechteprüfung ohnehin ausgenommen.

Tags

GRANT/REVOKE und die Rollenhierarchie regeln, was eine Rolle insgesamt auf einem Space darf — grob genug für die meisten Fälle, aber manchmal braucht man feinere Unterscheidungen: nur bestimmte Felder, nur bestimmte Workflow-Zustände oder -Übergänge, oder eine Gruppe von Rollen, die sich nicht sauber über die Hierarchie ausdrücken lässt. Ein TAG ist ein einfacher, global eindeutiger Name, den man an einen SPACE, ein FIELD, einen FLOW, einen WORKFLOW, einen STATE oder eine ROLE heften kann — gedacht als Baustein für genau solche Fälle, vor allem in eigenen Rechte-Scripten (siehe Kapitel 9 und das Kochbuch, Kapitel 16).

noplm> CREATE TAG protected;
# OK: tag 'protected' created
*
noplm> TAG FIELD recipe.difficulty WITH protected;
# OK: field 'recipe.difficulty' tagged 'protected'
*
noplm> TAG ROLE head_chef WITH protected;
# OK: role 'head_chef' tagged 'protected'
*

Die Adressierung von FIELD, FLOW und STATE folgt demselben gepunkteten Schema wie bei SET RELATION DETAIL (Kapitel 6): <space>.<field>, <workflow>.<flow>, <workflow>.<state>. TAG und UNTAG sind beide idempotent — ein Tag doppelt zu vergeben oder ein nie vergebenes Tag zu entfernen ist jeweils ein folgenloser Erfolg, kein Fehler. DROP TAG löscht ein Tag unwiderruflich samt aller Zuweisungen, ganz ohne "wird noch verwendet"-Sperre — anders als z. B. DROP SCRIPT.

CREATE/DROP TAG sowie TAG/UNTAG sind admin-only. Ebenso die Tag-Registry selbst:

noplm> LIST TAGS;
% tag: protected|2026-08-23T10:00:00Z
*
noplm> SHOW TAG protected;
% tag: protected
% field: recipe.difficulty
% role: head_chef
*

SHOW TAG <name> ist die Rückwärtssuche: alles, woran ein bestimmtes Tag gerade hängt, über alle sechs Objektarten hinweg. Die Vorwärtsrichtung — welche Tags ein bestimmtes Objekt trägt — ist dagegen für jeden angemeldeten Nutzer sichtbar, an genau der Stelle, wo man sie erwarten würde: SHOW SPACE/LIST FIELDS OF zeigen die Tags eines Space bzw. seiner Felder, SHOW WORKFLOW die eines Workflows/seiner Zustände/Übergänge, SHOW ROLE die einer Rolle — siehe Kommandoreferenz (Kapitel 17).

Der eigentliche Nutzen zeigt sich in eigenen Scripten: praktisch jeder Script-Kontext, der schon ctx.space/ctx.state/ ctx.userRoles kennt, bekommt automatisch auch ctx.space.tags, ctx.fieldTags, ctx.stateTags, ctx.workflowTags, ctx.flowTags und ctx.userRoleTags — jeweils die Tags des betreffenden Objekts bzw., bei ctx.userRoleTags, die Vereinigung aller Tags über die eigenen (auch transitiv geerbten) Rollen. Ein Rechte-Script kann so z. B. prüfen, ob der Aufrufer irgendeine mit protected markierte Rolle hat, ohne Rollennamen fest zu verdrahten:

if (ctx.userRoleTags.indexOf('protected') !== -1) { /* ... */ }

Ein konkretes, lauffähiges Rezept, das ctx.fieldTags für feldbasierte Zugriffskontrolle nutzt, findet sich im Kochbuch (Kapitel 16).

Kapitel 14

Datensicherung

Drei admin-only, Autocommit-only Befehle sichern und stellen den kompletten Datenbestand wieder her — ohne externes Backup-Werkzeug, direkt über die Konsole.

EXPORT STRUCTURE

EXPORT STRUCTURE;
# OK: structure exported to '/var/lib/noplm/exports/2026-01-15_14-30-05.struct.noplm' (34 commands)
*

Schreibt eine Datei mit einer schlichten, geordneten Liste ganz normaler Konsolenbefehle — buchstäblich das, was Du selbst eintippen würdest, um Nutzer, Rollen, Rechte, Spaces (Felder, Beziehungen, Beschreibungen), Workflows, Scripte und deren Bindungen auf einem frischen, leeren System nachzubauen. Kein Sonderformat, keine spezielle Werkzeugkette zum Wiedereinspielen nötig — die Datei lässt sich einfach in die Konsole einfügen.

EXPORT DATA

EXPORT DATA;
# OK: data exported to '/var/lib/noplm/exports/2026-01-15_14-30-05.data.noplm' (2 space(s), 5 record(s), 1 entry/entries)
*

Ein eigenes, aber lesbares %-Zeilenformat für sämtliche Datensätze und den Entry Store (Kapitel 8):

% space: ingredient
% fields: name|unit|supplier
% record: 3|NULL|'Saffron'|'g'|1
% space: supplier
% fields: name|reliable
% record: 1|NULL|'Feinkost Vogel'|true
% entry: kitchen,name|Küchenteam Nord

Jede % record:-Zeile beginnt mit id, dann state (ein String, oder NULL ohne Workflow), dann die restlichen Werte in der Reihenfolge aus % fields:. Nur physische Felder werden exportiert — ein MANY-seitiges Beziehungsfeld (Kapitel 6) ist ja nur berechnet, nie selbst gespeichert, taucht also nicht auf; die referenzierende Seite schon, mit der ursprünglichen ID des Zieldatensatzes (die beim Import neu zugeordnet wird, siehe unten).

Beide Export-Befehle schreiben in das konfigurierte exportDirectory — ein Verzeichnis auf dem Server, keine herunterladbare Antwort über die Konsole selbst. Die Dateiendungen sind bewusst verschieden (.struct.noplm vs. .data.noplm), nicht beide schlicht .noplm — eine Struktur- und eine Datendatei sind zwei unvereinbare Formate, und das sollte man ihnen ansehen, ohne erst den Dateinamen genauer lesen zu müssen.

IMPORT DATA

IMPORT DATA '/var/lib/noplm/exports/2026-01-15_14-30-05.data.noplm';

Anders als bei EXPORT ist der Pfad hier ein beliebiger, serverseitiger Dateisystempfad, unabhängig vom konfigurierten exportDirectory. Zwei Dinge solltest Du kennen, bevor Du Dich darauf verlässt:

  • Jeder Space, in den die Datei schreibt, muss leer sein. Das hier ist ein Restore, kein Zusammenführen, und der Befehl sagt das deutlich, statt es halb zu tun: `! space 'part' already contains 3 record(s): IMPORT DATA restores into empty spaces, it does not merge
  • replay the structure file into a fresh installation first`. Die Prüfung läuft, bevor irgendetwas geschrieben wird — ein abgelehnter Import ändert also nichts.
  • Es feuert kein einziger Trigger. IMPORT DATA schreibt direkt, nicht über den normalen INSERT/UPDATE-Weg aus Kapitel 9 — ein Restore stellt einen früheren Zustand wieder her, er wiederholt keine Geschäftslogik.
  • state wird direkt gesetzt, ohne die sonst übliche FLOW-Regel zu prüfen, dass der aktuelle Zustand zum FROM-Zustand des Übergangs passt, und ohne an den Workflow gebundene Scripte auszulösen — der einzige Weg, wie ein Massen-Restore beliebige historische Zustände überhaupt erreichen kann. Auch kein Eintrag im Flow-Log dafür (Kapitel 15) — der Zustand wird reproduziert, nicht durchlaufen.
  • Die acht Pseudo-Felder pro Record (Kapitel 4) teilen sich in zwei Gruppen: creator/creationTime/owner/ responsibleRole/deputyOwner werden exakt so wiederhergestellt, wie sie im ursprünglichen Export standen — eine echte historische Tatsache, genau wie state. lastUpdated/lastUpdatedBy/version dagegen werden nie aus der Datei wiederhergestellt — IMPORT DATA selbst zählt als Änderung, lastUpdated/lastUpdatedBy kommen also immer frisch gestempelt mit dem Zeitpunkt des Imports und dem importierenden Admin zurück, genau als hätte gerade ein normaler Schreibvorgang stattgefunden, und version beginnt wieder bei 1, genau wie bei jedem frisch eingefügten Record.

Der ganze Import läuft als eine einzige, atomare Operation — jeder Fehler an irgendeiner Stelle rollt alles zurück, was der Befehl bis dahin geschrieben hat, genau wie bei jedem anderen einzelnen Autocommit-Befehl.

Typischer Restore-Ablauf

Auf einem frischen, leeren System:

  1. Den Inhalt der ....struct.noplm-Datei in die Konsole einfügen (z. B. über noplm-cli im Batch-Modus, Kapitel 2) — eine ganz normale Befehlsfolge, nichts Besonderes am Einspielen.
  2. IMPORT DATA '<pfad zur ....data.noplm-Datei>'; ausführen.

Beziehungs-IDs werden dabei nicht wörtlich übernommen, sondern neu zugeordnet — die Auto-Increment-Reihenfolge des Zielsystems muss ja nicht mit der des Quellsystems übereinstimmen.

AUTONUMBER-Zähler werden ebenfalls wiederhergestellt, und zwar ausschließlich vorwärts: steht der Zähler im Ziel bereits weiter als in der Datei, behält er den höheren Wert. Ihn zurückzudrehen hieße, dass der nächste INSERT eine bereits vergebene Nummer ein zweites Mal ausgibt — und wäre der zugehörige Datensatz zwischenzeitlich gelöscht worden, würde sich nicht einmal jemand beschweren.

Kapitel 15

Protokolle

Drei unabhängige, unveränderliche Protokolle zeichnen auf, was in noPLM passiert ist — jedes mit eigenem Zuständigkeitsbereich, eigener Berechtigung.

Field Log: Feldänderungen an einem Datensatz

noplm> LIST FIELD LOG OF ingredient AT 1-20 WITH ID = 3;
% field_log: 1|2026-01-15T09:00:00Z|maria|name|2|NULL|'Saffron'|NULL
% field_log: 1|2026-01-15T09:00:00Z|maria|unit|3|NULL|'g'|NULL
% field_log: 2|2026-01-15T10:00:00Z|maria|unit|3|'g'|'kg'|NULL
% total: 2
*

Jede Zeile: Änderungsnummer|Zeitstempel|Nutzer|Feldname|Feld-ID| Alter Wert|Neuer Wert|Angeforderter Wert. Ein INSERT erzeugt für jedes gesetzte Feld eine Zeile mit NULL als altem Wert, ein DELETE eine mit NULL als neuem Wert. Änderte ein einzelner Schreibvorgang mehrere Felder auf einmal, teilen sich alle betroffenen Zeilen dieselbe Änderungsnummer — genau das zeigt der erste Eintrag oben: name und unit wurden zusammen beim INSERT gesetzt, beide mit Änderungsnummer 1. Die Nummerierung startet bei jedem Datensatz neu bei 1. Ein Schreibvorgang, der am Ende gar nichts ändert (z. B. ein UPDATE, das denselben Wert erneut zuweist), erzeugt keinen Eintrag.

Der letzte Wert, Angeforderter Wert, ist normalerweise leer — er füllt sich nur, wenn ein BEFORE-Trigger-Script den tatsächlich geschriebenen Wert per ctx.patch überschrieben hat (Kapitel 9.4.1): Neuer Wert zeigt dann, was wirklich gespeichert wurde, Angeforderter Wert, was der Befehl ursprünglich verlangt hatte.

LIST FIELD LOG OF braucht die logs-Berechtigung auf diesem Space — eine eigene, von read getrennte Berechtigung (Kapitel 13): Wer die Felder eines Datensatzes lesen darf, darf nicht automatisch auch dessen Änderungshistorie einsehen.

Admin Log: serverweites Administrationsprotokoll

noplm> LIST ADMIN LOG AT 1-50;
% adminlog: 1|2026-01-15T08:00:00Z|admin|CREATE SPACE supplier (name STRING NOT NULL, reliable BOOL NOT NULL DEFAULT true);
% adminlog: 2|2026-01-15T08:05:00Z|admin|GRANT READ, CREATE, UPDATE ON recipe TO cook;
% total: 2
*

Ein einziges, serverweites Protokoll — nicht pro Space, sondern für jeden erfolgreich ausgeführten, struktur- oder datenverändernden Admin-Befehl: Space-DDL, Nutzer/Rollen/Rechte, Script-/Library- Verwaltung, PUT/REMOVE, IMPORT DATA. Nicht protokolliert: alles, was weder Struktur noch Daten ändert (SUSPEND/ALLOW/GUARD SCRIPTS, SHUTDOWN, EXPORT STRUCTURE/EXPORT DATA), und jeder Befehl, der fehlgeschlagen ist oder dessen umgebende Kette zurückgerollt wurde — nur, was tatsächlich durchging, taucht auf. Die Zeilennummer ist zugleich die fortlaufende Zeilen-ID über das ganze Protokoll hinweg, nicht pro Nutzer oder Space. LIST ADMIN LOG ist admin-only — anders als das Field Log, das pro Space über logs gesteuert wird.

Flow Log: Zustandsübergänge

noplm> LIST FLOW LOG OF recipe WITH ID = 2;
% flow_log: 2026-01-15T09:30:00Z|maria|kitchen|INITIAL|TESTED|test
% total: 1
*

Ein Eintrag pro erfolgreich ausgeführtem FLOW-Übergang (Kapitel 7): Zeitstempel, Nutzer, Workflow, Ausgangszustand, Zielzustand, Übergangsname. Workflow/Zustände/Übergang werden sowohl als interne ID als auch als Namens-Schnappschuss gespeichert — eine spätere Umbenennung oder Löschung macht ältere Log-Einträge nicht unleserlich. Wie das Field Log braucht auch dieses die logs-Berechtigung auf dem jeweiligen Space, nicht Admin-Rechte. IMPORT DATA (Kapitel 14) erzeugt hier bewusst keinen Eintrag — ein wiederhergestellter Zustand wurde nie wirklich durchlaufen.

Gemeinsame Konventionen

Alle drei enden mit einer abschließenden % total: <anzahl>-Zeile — die tatsächliche Gesamtzahl, unabhängig davon, wie viele Zeilen der angefragte Bereich wirklich zurückgibt (bei LIST FIELD LOG OF die Anzahl unterschiedlicher Änderungen, nicht die Anzahl einzelner % field_log:-Zeilen, die bei Mehrfachänderungen höher sein kann). Field Log und Admin Log teilen sich zusätzlich dieselbe AT <start>-<end> -Bereichsangabe — eng zusammengeschrieben, ohne Leerzeichen um den Bindestrich (AT 1-50, nicht AT 1 - 50), da der Bindestrich sonst als Minuszeichen der zweiten Zahl gelesen wird. Ein zu breiter Bereich wird komplett abgelehnt, nie stillschweigend gekürzt — die Server-Einstellung maxLogRangeRows (Standard 1000) begrenzt die maximale Breite.

Teil VI · Kochbuch

Kapitel 16

Kochbuch

Dieses Kapitel führt nichts Neues ein — es zeigt, wie sich vorhandene Bausteine aus den vorigen Kapiteln zu größeren Lösungen kombinieren lassen. Teil VI ist bewusst offen für Zuwachs angelegt: weitere Rezepte folgen mit der Zeit.

Jedes Rezept ist ein Verzeichnis mit einer Installationsdatei, einer README und einer kurzen recipe.json. Hat Dein Server ein recipeDirectory konfiguriert, kannst Du sie an der Konsole (als Admin) ansehen und anwenden: LIST RECIPES;, SHOW RECIPE <name>; und INCLUDE RECIPE <name> [WITH ...]; — die Installations-Abschnitte unten beschreiben auch das Abspielen der Installationsdatei von Hand.

16.1 Eigenes Rechtesystem mit Before-Scripten

Das eingebaute Rechtemodell (GRANT/REVOKE, Rollen, Rollenhierarchie — Kapitel 13) bleibt bewusst einfach: neun feste Rechte pro Space, sonst nichts. Viele PLM-Systeme versuchen, für jeden denkbaren Sonderfall (nur der Ersteller darf ändern, nur nach Freigabe gesperrt, nur bestimmte Felder für bestimmte Rollen, ...) ein eigenes, immer komplizierter werdendes eingebautes Rechtesystem mitzubringen — und stoßen irgendwann trotzdem an dessen Grenzen. noPLM geht den umgekehrten Weg: das eingebaute Modell bleibt schlank, und wer mehr braucht, baut es sich aus vorhandenen Bausteinen selbst — Entry Store (Kapitel 8) für die Konfiguration, global angehängte Scripte (Kapitel 9.4.4, 9.6) für die Durchsetzung.

Dieses Rezept — intern simple_acls genannt — ist ein zusätzliches, rollenbasiertes Zugriffsgate pro Space, das über dem GRANT-Modell liegt: es kann nur einschränken, nie mehr erlauben, als eine Rolle ohnehin schon über GRANT besitzt. Es ist eins von mehreren möglichen Rezepten dieser Art — Varianten wie eigentümerbasierte, zustandsabhängige oder feldweise Zugriffsrechte sind denkbar und folgen in eigenen Rezepten.

Namenskonvention

Alle Bausteine dieses Rezepts — und, falls Du weitere, ähnliche Rezepte selbst baust — folgen einem einheitlichen Schema, damit mehrere davon gleichzeitig nutzbar bleiben, ohne sich in die Quere zu kommen:

  • Entry-Key: noplm, <rezept-id>, <space>
  • Skriptnamen: noplm_<rezept-id>_<baustein>

Für dieses Rezept ist <rezept-id> gleich simple_acls.

Konfiguration

Pro Space, der von diesem Rezept eingeschränkt werden soll, ein JSON-Eintrag im Entry Store:

Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:

{
  "create": {"role": ["cook", "head_chef"]},
  "update": {"role": ["cook", "head_chef"]},
  "delete": {"tag": ["approver"]},
  "run": {"reset_recipe": {"role": ["head_chef"]}},
  "flow": {
    "byName": {"approve": {"role": ["head_chef"]}},
    "byTag": {"critical": {"tag": ["approver"]}}
  }
}
PUT noplm, simple_acls, recipe '{"create":{"role":["cook","head_chef"]},"update":{"role":["cook","head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}},"flow":{"byName":{"approve":{"role":["head_chef"]}},"byTag":{"critical":{"tag":["approver"]}}}}';

# OK
@ rows: 1

Fünf mögliche Schlüssel, alle optional — fehlt einer, ist die entsprechende Aktion für diesen Space uneingeschränkt:

Schlüssel Wert Bedeutung
create/update/delete eine Regel (siehe unten) die Regel muss den aufrufenden User erlauben
run Objekt {scriptName: Regel} reine Ausnahmeliste — ein hier genannter Name wird durch seine Regel beschränkt, jeder nicht genannte Name bleibt frei
flow {byName: {flowName: Regel}, byTag: {flowTag: Regel}} reine Ausnahmeliste, ausgewählt über den Namen des Flows und/oder seine Tags (TAG FLOW <workflow>.<flow> WITH ...;)

Ein Space ganz ohne simple_acls-Eintrag ist von diesem Rezept komplett unberührt — auch wenn die Scripte unten global angehängt sind.

Eine Regel ist ein Objekt mit bis zu zwei optionalen Schlüsseln, role und tag, jeweils ein Array — z. B. {"role": ["cook", "head_chef"], "tag": ["approver"]}. Sie erlaubt den aufrufenden User, wenn entweder eine der genannten Rollen direkt auf ihn zutrifft (ctx.userRoles), oder einer der genannten Rollen-Tags (TAG ROLE <role> WITH <tag>;) auf irgendeine Rolle in seinem effektiven Rollen-Set zutrifft (ctx.userRoleTags) — ein ODER zwischen role und tag, da beide nur zwei verschiedene Arten sind, dieselbe Personengruppe zu benennen. Eine Regel ohne beide Schlüssel erlaubt niemandem etwas. Tags sind die wartungsärmere Wahl, wann immer "wer darf" mit der Zeit wachsen soll, ohne diese Config anzufassen: jede Rolle taggen, die als approver gelten soll, den Tag einmal in der Regel referenzieren — und eine neu getaggte Rolle bekommt die Berechtigung automatisch, ganz ohne PUT.

flows byName/byTag-Aufteilung ist eine andere Achse als das role/tag einer Regel — sie wählt aus, welcher Flow eine Regel betrifft (über den Namen des Flows selbst, oder über einen Tag am Flow selbst), nicht wer ihn ausführen darf. Trifft ein Flow sowohl auf einen byName-Eintrag als auch auf einen oder mehrere byTag-Einträge zu, müssen alle zutreffenden Regeln unabhängig voneinander erlauben (UND, nicht ODER) — anders als role/tag innerhalb einer Regel sind byName- und byTag-Einträge unabhängig konfigurierte Einschränkungen, ein Tag auf einem Flow kann also nur enger machen, was die eigene Namensregel bereits erlaubt, nie weiter öffnen. run hat kein byTag-Gegenstück — ein RUNNABLE-Skript kann gar kein TAG tragen.

Installation (simple_acls)

Der Quellcode dieses Rezepts liegt nicht hier, sondern als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/simple_acls/../../recipes/simple_acls/README.md. install.struct.noplm einfach in die Konsole einspielen (z. B. ./noplm-cli < install.struct.noplm) — danach ist das Rezept aktiv, aber für jeden Space ohne passenden simple_acls-Eintrag im Entry Store weiterhin ein reiner No-op.

Drei Bausteine, alle global angehängt (kein Space-Ziel, siehe 9.4.4):

  • noplm_simple_acls_before — ein GLOBAL TRIGGER BEFORE CREATE/UPDATE/DELETE-Skript, das ctx.userRoles gegen die konfigurierte Regel für Space und Operation prüft und bei fehlender Übereinstimmung mit ctx.abort() abbricht. Kein Konfigurationseintrag für den betroffenen Space → sofortiger No-op. Ein globales BEFORE-Skript hat kein ctx.patch (siehe 9.4.4) — es kann also nur prüfen, nichts automatisch setzen.
  • noplm_simple_acls_run_condition/noplm_simple_acls_flow_condition — je ein GLOBAL RUN CONDITION/GLOBAL FLOW CONDITION-Skript für RUN SCRIPT bzw. FLOW. Beide folgen demselben Muster: ohne Konfiguration einfach die vollständige strukturelle Liste zurückgeben (ctx.allRunnables/ctx.allFlows, siehe 9.6) — echtes Default-Allow, ganz ohne dass das Script wissen muss, welche Namen ein fremder Space überhaupt hat.

Beispiel

Weiter im laufenden Küchen-Szenario: recipe trägt den Workflow kitchen (draft → tested → approved, Flows test/revise/ approve — siehe Datenmodelloverview.md), die Rollen cook, head_chef und eine dritte Rolle, sous_chef, haben alle GRANT CREATE, READ, UPDATE, DELETE, RUN, FLOW ON recipe — das Rezept engt das jetzt weiter ein, gemäß der Config oben. cook_bob hält cook, head_chef_maria hält head_chef, sous_chef_erik hält sous_chef, und intern_max nur eine vierte Rolle, intern (mit eigenständigem GRANT CREATE ON recipe, aber ohne Nennung in der simple_acls-Config). head_chef und sous_chef tragen beide den Tag approver (TAG ROLE head_chef WITH approver;, TAG ROLE sous_chef WITH approver;) — zwei ansonsten unabhängige Rollen mit demselben Tag, genau darum geht's. Der Flow approve selbst trägt den Tag critical (TAG FLOW kitchen.approve WITH critical;):

INSERT INTO recipe (name = 'Kartoffelsuppe');    -- als cook_bob

% id: 1
INSERT INTO recipe (name = 'Geheimrezept');    -- als intern_max

! ROLLBACK: command ('INSERT INTO recipe (name = 'Geheimrezept');'): script 'noplm_simple_acls_before' on space 'recipe' aborted: {{noplm:text|en=None of your roles/tags permit "create" on space "recipe"|de=Keine Ihrer Rollen/Tags erlaubt "create" im Space "recipe"}}

intern_max scheitert trotz eigener GRANT CREATE-Berechtigung — die Rolle intern steht schlicht nicht in der Config. DELETE ist rein über den Tag approver beschränkt, nicht über einen bestimmten Rollennamen:

DELETE FROM recipe WHERE id = 1;    -- als cook_bob

! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_simple_acls_before' on space 'recipe' aborted: {{noplm:text|en=None of your roles/tags permit "delete" on space "recipe"|de=Keine Ihrer Rollen/Tags erlaubt "delete" im Space "recipe"}}
DELETE FROM recipe WHERE id = 1;    -- als sous_chef_erik

# OK
@ rows: 1

sous_chef_erik gelingt es rein wegen des approver-Tags — sous_chef steht an keiner Stelle in der Config. Bei flow/run ist die Config dagegen eine reine Ausnahmeliste — test/revise tauchen dort gar nicht auf und bleiben frei:

FLOW test ON recipe WITH ID = 2;    -- als cook_bob

# OK: flow 'test' executed

approve ist sowohl über eine byName-Regel (Rolle head_chef) als auch, weil der Flow selbst den Tag critical trägt, über eine byTag-Regel (Tag approver) beschränkt — beide müssen gleichzeitig zutreffen:

FLOW approve ON recipe WITH ID = 2;    -- als sous_chef_erik

! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 2;'): flow 'approve' is not permitted for record 2 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 2;    -- als head_chef_maria

# OK: flow 'approve' executed

sous_chef_erik scheitert trotz approver-Tag — die byName-Regel verlangt konkret die Rolle head_chef, und da beide Regeln zutreffen müssen (UND), reicht der Tag allein nicht. head_chef_maria hält sowohl die Rolle (für byName) als auch, über head_chefs eigenen Tag, den Tag (für byTag) — beide Regeln bestehen. Dasselbe Prinzip bei RUN SCRIPT:

RUN SCRIPT print_shopping_list ON recipe WITH ID = 2;    -- als cook_bob, nicht in der Config erwähnt

# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 2;    -- als head_chef_maria

# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;

Grenzen

  • Rein additiv — kann nur einschränken, was GRANT bereits erlaubt, nie mehr freigeben. Ein Space ohne die passenden GRANTs bleibt gesperrt, egal was in der simple_acls-Config steht.
  • Kein Feld-, Eigentümer- oder Zustandsbezug — die Regeln hängen nur von Space, Rolle und (über das role/tag/byName/byTag-Schema oben) Tags ab. owner_acls (16.3), state_acls (16.5) und field_acls (16.6) sind die Geschwister-Rezepte für diese Fälle.
  • Admin-Sperre gilt normal für die BEFORE-Seite (9.2): ein Admin, der z. B. Daten korrigiert, braucht SUSPEND SCRIPTS, sonst feuert noplm_simple_acls_before gar nicht erst — und ist dann, solange die Suspendierung aktiv ist, auch von diesem Rezept komplett ausgenommen. Die Condition-Scripte kennen dagegen keine Admin-Ausnahme (9.6) — sie gelten uneingeschränkt für jede Sitzung, auch für Admins.
  • Kaputte Config bricht fail-closed ab — ungültiges JSON im simple_acls-Eintrag lässt noplm_simple_acls_before mit ctx.abort() scheitern (die beiden Condition-Scripte geben in diesem Fall [] zurück, also "nichts erlaubt") statt die Prüfung stillschweigend zu überspringen.
  • byName/byTag kombinieren mit UND, role/tag kombinieren mit ODER — nicht verwechseln. Ein Flow, der mehrere Selektoren gleichzeitig trifft (eigener Name plus ein oder mehrere Tags), muss alle zutreffenden Regeln erfüllen; innerhalb einer zutreffenden Regel reicht die genannte Rolle oder der genannte Tag gleichermaßen.

16.2 Deklarative Feld-Constraints

simple_acls (16.1) regelt wer schreiben darf. Dieses Rezept regelt etwas anderes: welche Werte ein Feld überhaupt annehmen darf — unabhängig davon, wer schreibt. Beide Fragen sind absichtlich getrennt und lassen sich unabhängig voneinander einsetzen (auch kombiniert, auf demselben Space): ein zukünftiges field_acls-Rezept würde die erste Frage feldweise beantworten, dieses hier die zweite.

Über NOT NULL/DEFAULT hinaus kennt das Schema selbst keine Wertregeln — kein "muss zwischen 0 und 10 liegen", kein "muss genau 4 Zeichen lang sein". Dieses Rezept — intern field_constraints genannt — schließt die Lücke deklarativ: eine JSON-Konfiguration pro Space im Entry Store, durchgesetzt von einem einzigen global angehängten BEFORE-Skript.

Konfiguration

Namenskonvention wie in 16.1: Entry-Key noplm, field_constraints, <space>, <rezept-id> ist hier field_constraints. Ein JSON-Objekt, ein Eintrag pro zu prüfendem Feldnamen:

PUT noplm, field_constraints, stock_level '{"min_stock":{"type":"int","min":0},"max_stock":{"type":"int","min":0},"checked_at":{"type":"datetime","notFuture":true},"notes":{"type":"json","maxLength":2000},"_pairs":[{"fields":["min_stock","max_stock"],"op":"<=","message":"min_stock must not exceed max_stock"}]}';

# OK
@ rows: 1

Jede Feldregel trägt ein type (bestimmt, welche Prüf-Funktion greift) plus die passenden Constraint-Schlüssel; ein optionales message überschreibt die generierte Standardmeldung — entweder ein einfacher String oder ein Objekt pro Sprache wie {"en":"Too low","de":"Zu niedrig"}. Auch die generierten Standardmeldungen sind zweisprachig (Englisch, Deutsch). Fehlt ein Feld in der Config, bleibt es uneingeschränkt — genau wie bei simple_acls ist ein Space ganz ohne Eintrag komplett unberührt.

Typ Constraints
string/text length, minLength, maxLength, pattern, oneOf, notBlank
int min, max, multipleOf, oneOf
float min, max, oneOf
bool equals
datetime minDate, maxDate, notPast, notFuture
json maxLength (nur Rohtext-Länge, keine Struktur-/Schema-Prüfung)
relation minCount, maxCount — nur auf der MANY-Seite sinnvoll (ctx.new[feld] ist dort ein Array); für die ONE-Seite leisten die Relation-WHERE-Klauseln (Kapitel 6) das Äquivalent

Installation (field_constraints)

Der Quellcode dieses Rezepts liegt nicht hier, sondern als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/field_constraints/../../recipes/field_constraints/README.md. install.struct.noplm einfach in die Konsole einspielen (z. B. ./noplm-cli < install.struct.noplm) — danach ist das Rezept aktiv, aber für jeden Space ohne passenden field_constraints-Eintrag im Entry Store weiterhin ein reiner No-op.

Zwei Bausteine:

  • noplm_field_constraints_lib — eine Library mit einer Prüf-Funktion pro Typ (nfc_checkString/nfc_checkInt/ nfc_checkFloat/nfc_checkBool/nfc_checkDatetime/nfc_checkJson/ nfc_checkReference), plus nfc_checkField (Typ-Dispatch) und nfc_checkPair (Feldübergreifender Vergleich, siehe unten) — eine Funktion pro Typ, damit die Liste wachsen kann, ohne das Trigger-Skript selbst anzufassen. Bemerkenswerter Stolperstein darin: die nfc_checkDatetime/_pairs-Vergleiche vergleichen Date-Objekte konsequent gegen Date-Objekte, nie gegen den rohen RFC3339-String — ein Date per >/< gegen einen String zu vergleichen wandelt den String nicht wie erwartet über Date.parse um (JS wandelt dabei über ToNumber, nicht über die Datums-Parsing-Regeln), sondern liefert NaN und damit einen Vergleich, der immer false ist.
  • noplm_field_constraints_before — ein GLOBAL TRIGGER BEFORE CREATE/UPDATE-Skript (USES noplm_field_constraints_lib), das jedes konfigurierte Feld gegen seine Regel prüft und beim ersten Verstoß mit ctx.abort() abbricht. Ein NULL/nicht gesetztes Feld wird übersprungen — NOT NULL bleibt Sache des Schemas, nicht dieses Rezepts. DELETE ist bewusst nicht abgedeckt, da es nichts zu validieren gibt.

Feldübergreifende Constraints — bewusst nur Paare

Für Regeln über ein Feld hinaus (z. B. "Mindestbestand darf den Höchstbestand nicht übersteigen") gibt's den optionalen _pairs-Block oben: ein Array aus {fields: [a, b], op: "<="} mit Vergleichsoperator <, <=, >, >=, ==, !=, ausgewertet nur, wenn beide Felder gesetzt sind.

Bewusst nicht generalisiert wird alles darüber hinaus — drei oder mehr Felder, bedingte Regeln ("wenn Feld A = X, dann Feld B Pflicht") o. ä. Eine deklarative Lösung dafür würde auf eine kleine Ausdruckssprache in JSON hinauslaufen, bei der man am Ende ohnehin JavaScript schreibt, nur schlechter lesbar. Für diese Fälle: ein eigenes, lokales oder globales BEFORE-Skript direkt schreiben — die Bausteine dafür (ctx.new, ctx.abort()) sind dieselben, die noplm_field_constraints_before selbst benutzt.

Beispiel

Weiter im Küchen-Szenario, jetzt mit der neuen Space stock_level (siehe Datenmodelloverview.md) für Mindest-/Höchstbestand pro Zutat:

INSERT INTO ingredient (name = 'Mehl', unit = 'g');

% id: 1
INSERT INTO ingredient (name = 'Mondstaub', unit = 'kg');

! ROLLBACK: command ('INSERT INTO ingredient (name = 'Mondstaub', unit = 'kg');'): script 'noplm_field_constraints_before' on space 'ingredient' aborted: {{noplm:text|en=Field "unit" must be one of: g, ml, pcs (got "kg")|de=Feld "unit" muss einen von diesen Werten haben: g, ml, pcs (erhalten: "kg")}}

kg steht nicht in der konfigurierten oneOf-Liste — die Fehlermeldung nennt den Feldnamen und den tatsächlich übergebenen Wert. Ähnlich bei numerischen und Relation-Feldern:

INSERT INTO recipe (name = 'X', servings = 0, ingredients = [1]);

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: {{noplm:text|en=Field "servings" must be >= 1 (got 0)|de=Feld "servings" muss >= 1 sein (erhalten: 0)}}
INSERT INTO recipe (name = 'Y', servings = 4, ingredients = []);

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: {{noplm:text|en=Field "ingredients" must reference at least 1 record(s) (got 0)|de=Feld "ingredients" muss mindestens 1 Datensatz/Datensätze referenzieren (erhalten: 0)}}

Ein Rezept ganz ohne Zutat verstößt gegen minCount. Bei stock_level greift zusätzlich der _pairs-Vergleich mit eigener Meldung:

INSERT INTO stock_level (ingredient = 1, min_stock = 50, max_stock = 10, checked_at = '2026-08-20 10:00:00');

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'stock_level' aborted: field_constraints: min_stock must not exceed max_stock
INSERT INTO stock_level (ingredient = 1, min_stock = 5, max_stock = 50, checked_at = '2099-01-01 00:00:00');

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'stock_level' aborted: {{noplm:text|en=Field "checked_at" must not be in the future|de=Feld "checked_at" darf nicht in der Zukunft liegen}}

Ein gültiger Eintrag geht anstandslos durch:

INSERT INTO stock_level (ingredient = 1, min_stock = 10, max_stock = 50, checked_at = '2026-08-20 10:00:00', notes = '{"warehouse":"A1"}');

% id: 1

Grenzen

  • Nur Wertebene, keine Struktur-Constraints — json-Regeln prüfen nur die Rohtext-Länge, keine Schema-Konformität. Eine echte JSON-Schema-Validierung wäre ein eigenständiges, deutlich größeres Feature.
  • _pairs nur für einfache Zwei-Felder-Vergleiche — siehe oben, alles Komplexere ist bewusst Sache eines selbstgeschriebenen BEFORE-Skripts.
  • Kaputte Config bricht fail-closed ab — ungültiges JSON im field_constraints-Eintrag lässt das Skript mit ctx.abort() scheitern, statt die Prüfung stillschweigend zu überspringen.
  • DELETE bleibt unberührt — es gibt nichts zu validieren.

16.3 Eigentümerbasiertes Rechtemanagement

simple_acls (16.1) engt Zugriff über feste, space-weite Rollenlisten ein. Dieses Rezept — intern owner_acls genannt — engt stattdessen über die Zugehörigkeit zum einzelnen Record ein: erlaubt ist eine Operation nur dem aktuellen owner, dem deputyOwner oder jemandem mit einer Rolle, die dem Record über responsibleRole zugewiesen ist. Diese drei Pseudo-Felder trägt seit Kurzem jeder Record automatisch (Kapitel 4) — eine frühere Fassung dieser Idee brauchte noch eine eigene, pro Space konfigurierbare Angabe, welches Feld als "Owner-Feld" gilt; das entfällt jetzt vollständig, das Rezept greift einfach direkt auf die eingebauten Felder zu. Wie simple_acls liegt es über dem GRANT-Modell und kann nur einschränken, nie mehr erlauben; die beiden Rezepte sind kombinierbar (dann müssen beide Prüfungen bestehen, kein "oder").

Namenskonvention

Wie in 16.1: Entry-Key noplm, owner_acls, <space>, Skriptnamen noplm_owner_acls_<baustein>.

Konfiguration

Pro Space, der von diesem Rezept eingeschränkt werden soll, ein JSON-Eintrag im Entry Store:

PUT noplm, owner_acls, recipe '{"update":true,"delete":true,"run":["reset_recipe"],"flow":["approve"]}';

# OK
@ rows: 1

Bis zu vier mögliche Schlüssel, alle optional:

Schlüssel Wert Bedeutung
update/delete true Operation ist owner-gated: erlaubt nur dem aktuellen Owner, Deputy Owner oder einem Mitglied der responsibleRole des Records
run/flow Array von Namen reine Ausnahmeliste (wie bei simple_acls) — ein hier genannter Name ist owner-gated, jeder nicht genannte Name bleibt unabhängig von Ownership frei

Kein create-Schlüssel — CREATE bleibt strukturell immer unbeschränkt, da owner bei Anlage automatisch auf den Ersteller selbst defaulted, es also nichts zu prüfen gibt. Anders als bei simple_acls trägt update/delete auch keine Rollenliste — die Zugriffsregel ist fix (Owner/Deputy/verantwortliche Rolle dieses konkreten Records), ein bloßes true genügt. Ein Space ganz ohne owner_acls-Eintrag ist von diesem Rezept komplett unberührt.

Installation (owner_acls)

Der Quellcode dieses Rezepts liegt nicht hier, sondern als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/owner_acls/../../recipes/owner_acls/README.md. install.struct.noplm einfach in die Konsole einspielen — danach ist das Rezept aktiv, aber für jeden Space ohne passenden owner_acls-Eintrag im Entry Store weiterhin ein reiner No-op.

Drei Bausteine, alle global angehängt:

  • noplm_owner_acls_before — ein GLOBAL TRIGGER BEFORE UPDATE/DELETE-Skript, das prüft, ob der aufrufende User ctx.old.owner, ctx.old.deputyOwner ist, oder eine seiner Rollen ctx.old.responsibleRole entspricht, und sonst mit ctx.abort() abbricht. Nicht an CREATE angehängt — bei Anlage ist der anlegende User immer schon sein eigener Owner, die Prüfung wäre dort immer trivial erfüllt.
  • noplm_owner_acls_run_condition/noplm_owner_acls_flow_condition — je ein GLOBAL RUN CONDITION/GLOBAL FLOW CONDITION-Skript. Beide laden Owner/Deputy/responsibleRole des betroffenen Records per ctx.query() nach (Condition-Skripte haben kein ctx.old/ctx.new, siehe 9.6) und filtern ctx.allRunnables/ctx.allFlows entsprechend — jeder in der Config genannte Name bleibt owner-gated, jeder ungenannte frei.

Beispiel

Weiter im Küchen-Szenario: recipe trägt wieder den Workflow kitchen, die Rollen cook/head_chef/intern haben alle volles GRANT CREATE, READ, UPDATE, DELETE, RUN, FLOW ON recipe. cook_bob legt einen Datensatz an (wird damit automatisch dessen owner) und setzt anschließend deputyOwner = 'sous_chef_lea' sowie responsibleRole = 'head_chef' — beides noch unbeschränkt, da die owner_acls-Config für recipe erst danach gesetzt wird:

INSERT INTO recipe (name = 'Kartoffelsuppe');    -- als cook_bob

% id: 1
PUT noplm, owner_acls, recipe '{"update":true,"delete":true,"run":["reset_recipe"],"flow":["approve"]}';

# OK
@ rows: 1

intern_max hat volles GRANT UPDATE, ist aber weder Owner noch Deputy noch Mitglied der responsibleRole:

UPDATE recipe SET name = 'Hacked' WHERE id = 1;    -- als intern_max

! ROLLBACK: command ('UPDATE recipe SET name = 'Hacked' WHERE id = 1;'): script 'noplm_owner_acls_before' on space 'recipe' aborted: {{noplm:text|en=You are not the owner, deputy, or responsible-role holder of this record on space "recipe"|de=Sie sind weder Eigentümer noch Stellvertreter noch Inhaber der verantwortlichen Rolle dieses Datensatzes im Space "recipe"}}

Owner, Deputy und ein Mitglied der zuständigen Rolle dürfen dagegen alle drei ändern:

UPDATE recipe SET name = 'Kartoffelsuppe (v2)' WHERE id = 1;    -- als cook_bob (Owner)

# OK
@ rows: 1
UPDATE recipe SET name = 'Kartoffelsuppe (v3)' WHERE id = 1;    -- als sous_chef_lea (deputyOwner)

# OK
@ rows: 1
UPDATE recipe SET name = 'Kartoffelsuppe (v4)' WHERE id = 1;    -- als head_chef_maria (hält die Rolle 'head_chef')

# OK
@ rows: 1

Bei flow/run ist die Config wie bei simple_acls eine reine Ausnahmeliste — test taucht dort nicht auf und bleibt frei, auch für intern_max:

FLOW test ON recipe WITH ID = 1;    -- als intern_max

# OK: flow 'test' executed

approve ist dagegen owner-gated:

FLOW approve ON recipe WITH ID = 1;    -- als intern_max

! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 1;'): flow 'approve' is not permitted for record 1 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 1;    -- als head_chef_maria (Rolle passt)

# OK: flow 'approve' executed

Dasselbe Prinzip bei RUN SCRIPT: print_shopping_list ist nicht genannt und bleibt frei, reset_recipe ist owner-gated, aber die Deputy-Owner-Zuordnung greift auch hier:

RUN SCRIPT print_shopping_list ON recipe WITH ID = 1;    -- als intern_max, nicht in der Config erwähnt

# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 1;    -- als sous_chef_lea (deputyOwner)

# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;

DELETE schließlich, wieder nur für Owner/Deputy/responsibleRole:

DELETE FROM recipe WHERE id = 1;    -- als intern_max

! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_owner_acls_before' on space 'recipe' aborted: {{noplm:text|en=You are not the owner, deputy, or responsible-role holder of this record on space "recipe"|de=Sie sind weder Eigentümer noch Stellvertreter noch Inhaber der verantwortlichen Rolle dieses Datensatzes im Space "recipe"}}
DELETE FROM recipe WHERE id = 1;    -- als cook_bob (Owner)

# OK
@ rows: 1

Grenzen

  • Rein additiv — kann nur einschränken, was GRANT bereits erlaubt, nie mehr freigeben. Ein Space ohne die passenden GRANTs bleibt gesperrt, egal was in der owner_acls-Config steht.
  • CREATE ist nie owner-gated — siehe "Konfiguration" oben.
  • Admin-Sperre gilt normal für die BEFORE-Seite: ein Admin, der z. B. Daten korrigiert, braucht SUSPEND SCRIPTS, sonst feuert noplm_owner_acls_before gar nicht erst — und ist dann, solange die Suspendierung aktiv ist, auch von diesem Rezept komplett ausgenommen. Die Condition-Scripte kennen dagegen keine Admin-Ausnahme — sie gelten uneingeschränkt für jede Sitzung, auch für Admins.
  • Kaputte Config bricht fail-closed ab — ungültiges JSON im owner_acls-Eintrag lässt noplm_owner_acls_before mit ctx.abort() scheitern (die beiden Condition-Scripte geben in diesem Fall [] zurück, also "nichts erlaubt") statt die Prüfung stillschweigend zu überspringen.
  • responsibleRole wird direkt geprüft, ohne eigene Rollenhierarchie-Auflösung — da ctx.userRoles bereits das vollständige effektive Rollenset des Users ist (direkte Zuweisungen plus alles über die Rollenhierarchie Erreichbare, siehe 13), zählt ein User mit einer Unterrolle der konfigurierten responsibleRole automatisch mit dazu.
  • Kombinierbar mit simple_acls — beide Rezepte gleichzeitig angehängt schneiden ihre Einschränkungen (beide müssen zustimmen), dieselbe "rein additiv"-Logik wie überall in dieser Rezeptfamilie; ein eingebautes "Owner ODER Rolle" zwischen den beiden Rezepten gibt es nicht.

16.4 Picklist: Gemeinsame Wertelisten

Kein Rechtesystem-Rezept wie 16.1/16.3, und keine Wertvalidierung wie 16.2 — dieses Rezept ist ein Modellierungs-Helfer. Ein difficulty-Feld hier, ein unit-Feld dort: jede dieser kleinen, benannten Wertelisten würde sonst einen eigenen Space (oder eine field_constraints-artige oneOf-Regel) pro Feld bedeuten. Dieses Rezept — intern picklist genannt — ersetzt das alles durch einen gemeinsamen Space, picklists, plus zwei ganz gewöhnliche, bereits vorhandene noPLM-Funktionen, die die eigentliche Arbeit übernehmen: eine feldübergreifende UNIQUE-Constraint (Kapitel 4) und die eigene WHERE-Klausel eines Relationsfelds (Kapitel 4). Keine eigene Skriptlogik.

Was installiert wird

  • picklists (picklist STRING NOT NULL, value STRING NOT NULL, text STRING, UNIQUE (picklist, value)) — picklist ist der Gruppenname (z. B. 'recipe_difficulty'), value ein Eintrag darin, der optionale text das, was der Benutzer statt des Werts sieht. Die feldübergreifende UNIQUE-Constraint verhindert, dass ein Wert zweimal innerhalb derselben Gruppe auftaucht, ohne ihn über alle Gruppen hinweg global eindeutig zu machen — 'g' kann ein ingredient_unit-Eintrag sein und zufällig auch in einer anderen Picklist auftauchen, ohne Konflikt.
  • noplm_picklist_label — ein einzeiliges Label-Skript (return ctx.new.text || ctx.new.value;), zugewiesen über ALTER SPACE picklists SET LABEL SCRIPT .... Jedes Relationsfeld, das auf picklists zeigt, zeigt damit den Anzeigetext des Eintrags (ohne Text den Wert) als sein Label an statt einer nackten ID — die eingebauten Relation-Renderer zeigen ohnehin schon das Label des verlinkten Records an, siehe 11.
  • Vorgefertigte Picklisten, am Ende der Installationsdatei eingefügt: yes_no, yes_no_unknown, rating (1–5), priority, severity, approval_status, lifecycle, gender_salutation (zweisprachig, Englisch und Deutsch) sowie unit_length, unit_mass (sprachneutral). Ihr value ist ein sprachneutraler Schlüssel (yes, in_review); ein zweisprachiger Eintrag trägt seinen Anzeigetext als mehrsprachigen Text-Marker, z. B. {{noplm:text|en=Yes|de=Ja}}: ein Client zeigt die Sprache des Betrachters, die Konsole den Rohtext. Der Relation-Picker sortiert nach Label, daher tragen Listen mit natürlicher Reihenfolge ein Zahlenpräfix im text (1 - Low, 2 - Medium, …).
  • Zum Vergleichen gegen einen Eintrag den Schlüssel value verwenden, nicht die ID, die pro Installation verschieden ist: WHERE difficulty -> value = 'Einfach' funktioniert in einer Abfrage oder Relation-WHERE ohne ID und ohne Skript.

Installation (picklist)

Liegt als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/picklist/../../recipes/picklist/README.md. install.struct.noplm einfach in die Konsole einspielen.

Verwendung

Eine benannte Picklist mit ihren erlaubten Einträgen befüllen — picklist wird nirgends vorab deklariert, eine Gruppe entsteht einfach in dem Moment, in dem ihr erster Eintrag eingefügt wird:

INSERT INTO picklists (picklist = 'recipe_difficulty', value = 'Einfach');
INSERT INTO picklists (picklist = 'recipe_difficulty', value = 'Mittel');
INSERT INTO picklists (picklist = 'recipe_difficulty', value = 'Schwer');

Dann, auf welchem Space auch immer ein Feld auf genau diese Gruppe eingeschränkt werden soll, ein gewöhnliches Relationsfeld mit passender WHERE-Klausel anlegen:

ALTER SPACE recipe ADD FIELD difficulty
    RELATION picklists ONE WHERE picklist = 'recipe_difficulty'
    AS recipe_difficulties MANY;

Ab hier ist difficulty ein ganz gewöhnliches Relationsfeld — die eigene WHERE-Durchsetzung der Relation (bei jedem Schreibzugriff geprüft, siehe 4) hält es tatsächlich auf recipe_difficulty-Einträge beschränkt:

QUERY picklists -> (id, picklist, value) WHERE picklist = 'recipe_difficulty';

@ column: 1|id|INT|false|
@ column: 2|picklist|STRING|false|
@ column: 3|value|STRING|false|
@ rows: 3
% 1 | 'recipe_difficulty' | 'Einfach'
% 2 | 'recipe_difficulty' | 'Mittel'
% 3 | 'recipe_difficulty' | 'Schwer'
INSERT INTO recipe (name = 'Kartoffelsuppe', difficulty = 1);
QUERY recipe -> (name, difficulty) WITH LABELS;

@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|difficulty|RELATION|true|recipe_difficulty
@ rows: 1
% 1 | 'Kartoffelsuppe' | picklists:1('Einfach')

Eine ID aus einer anderen Picklist — selbst wenn es sich um eine vollkommen gültige picklists-Zeile handelt — wird genauso abgelehnt wie jede andere ungültige Referenz:

UPDATE recipe SET difficulty = 4 WHERE id = 1;    -- ID 4 ist ein 'ingredient_unit'-Eintrag, kein 'recipe_difficulty'

! ROLLBACK: command ('UPDATE recipe SET difficulty = 4 WHERE id = 1;'): relation constraint violated: referenced picklists record (id 4) does not satisfy the condition on field 'difficulty'

Ein doppelter Eintrag innerhalb derselben Picklist trifft auf die feldübergreifende UNIQUE-Constraint, nicht auf rezeptspezifische Logik:

INSERT INTO picklists (picklist = 'recipe_difficulty', value = 'Einfach');

! ROLLBACK: command (...): INSERT failed: Error 1062 (23000): Duplicate entry 'recipe_difficulty-Einfach' for key 'uq_constraint_1'

Der Editier-Picker des Standard-Relation-Renderers (11) respektiert die eigene WHERE-Klausel eines Relationsfelds ebenfalls schon von selbst — eine VIEW über difficulty bietet dadurch automatisch nur recipe_difficulty-Einträge an, ohne zusätzlichen, picklist-spezifischen Renderer-Aufwand.

Grenzen

  • GRANT/REVOKE auf picklists sind Sache des Betreibers — dieses Rezept installiert kein eigenes Rechtemodell; ein Space, der seine Picklist-Einträge direkt über die Konsole verwaltet, braucht weiterhin das übliche GRANT CREATE/READ/UPDATE/DELETE ON picklists TO <rolle>.
  • Keine Admin-Oberfläche zum Pflegen der Einträge — bewusst nur das Datenmodell; eine Pflegemaske für picklists (eine VIEW) bleibt Sache des Betreibers, wie bei jedem anderen Space auch.
  • Die WHERE-Klausel einer Relation wirkt nie rückwirkend (siehe 4) — eine Picklist-Gruppe umzubenennen, oder den Eintrag zu entfernen, auf den das Relationsfeld eines bestehenden Records bereits zeigt, macht diese schon geschriebene Referenz nicht ungültig. Ganz normales noPLM-Verhalten für jedes WHERE-eingeschränkte Relationsfeld, nichts Rezeptspezifisches.
  • Keine Groß-/Kleinschreibungs-Normalisierung bei der feldübergreifenden UNIQUE-Constraint — 'Offen' und 'offen' sind zwei verschiedene Einträge innerhalb derselben Picklist, genau wie bei jedem anderen STRING-Vergleich in noPLM.

16.5 Zustandsbasiertes Rechtemanagement

simple_acls (16.1) und owner_acls (16.3) schränken Zugriff auf eine Art ein, die nie davon abhängt, wo im Workflow der Record gerade steht. Dieses Rezept — intern state_acls genannt — schränkt stattdessen über den aktuellen Workflow-Zustand des Records (ctx.state, siehe Kapitel 7) ein: das klassische "nach Freigabe gesperrt"-Muster, bei dem eine Operation, die eben noch ging, in dem Moment verboten wird, in dem ein FLOW den Record in einen späteren Zustand verschiebt. Wie seine beiden Geschwister liegt es über dem GRANT-Modell und kann nur einschränken, nie mehr erlauben; alle drei sind frei kombinierbar (jede angehängte Rezept-Prüfung muss bestehen).

Namenskonvention

Wie in 16.1: Entry-Key noplm, state_acls, <space>, Skriptnamen noplm_state_acls_<baustein>.

Konfiguration

Pro Space, der von diesem Rezept eingeschränkt werden soll, ein JSON-Eintrag im Entry Store. Die äußere Ebene wählt aus, welcher Zustand eine Zustands-Regel betrifft, über eine {byName, byTag}-Aufteilung — dieselbe Aufteilung, die simple_acls schon für flow nutzt, nur eine Ebene höher, für den Zustand selbst statt für den Flow:

Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:

{
  "byName": {
    "approved": {
      "update": {"role": ["head_chef"]},
      "delete": {"tag": ["approver"]},
      "run": {"reset_recipe": {"role": ["head_chef"]}}
    }
  },
  "byTag": {
    "pending_review": {
      "flow": {"byName": {"approve": {"role": ["head_chef"]}}}
    }
  }
}
PUT noplm, state_acls, recipe '{"byName":{"approved":{"update":{"role":["head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}}}},"byTag":{"pending_review":{"flow":{"byName":{"approve":{"role":["head_chef"]}}}}}}';

# OK
@ rows: 1

byName-Schlüssel sind literale Zustandsnamen; byTag-Schlüssel sind Tags an einem Zustand (TAG STATE <workflow>.<state> WITH <tag>;). Jede ausgewählte Zustands-Regel hat dann bis zu vier optionale Schlüssel, identisch zur Form von simple_aclss eigenen Top-Level-Schlüsseln:

Schlüssel (innerhalb einer Zustands-Regel) Wert Bedeutung
update/delete eine Regel ({role, tag}, siehe 16.1) solange der Record in diesem Zustand ist, ist die Operation für alle gesperrt außer für einen Aufrufer, den die Regel erlaubt
run Objekt {scriptName: Regel} reine Ausnahmeliste, dieselbe Form wie simple_aclss eigenes run
flow {byName: {flowName: Regel}, byTag: {flowTag: Regel}} reine Ausnahmeliste, dieselbe Form wie simple_aclss eigenes flow

Trifft ein Zustand auf mehr als einen Selektor gleichzeitig — seinen eigenen literalen Namen in byName, plus einen oder mehrere seiner Tags in byTag — werden die zutreffenden Zustands-Regeln mit UND kombiniert: jede zutreffende Regel muss den Aufrufer unabhängig erlauben, für update/delete/den run-Eintrag zu diesem Namen/den flow-Eintrag zu diesem Namen. Das spiegelt simple_aclss eigene byName/byTag-Kombination bei flow, aus demselben Grund: byName und byTag sind unabhängig konfigurierte Einschränkungen, keine zwei Schreibweisen derselben Gruppe — ein Tag auf einem Zustand kann also nur enger machen, was die eigene Namensregel bereits erlaubt, nie weiter öffnen (role/tag innerhalb einer Regel ist dagegen ein ODER — siehe 16.1).

Ein Zustand, den keiner der beiden Selektoren trifft, ist völlig unbeschränkt — auch ein Zustand, den der Record noch nicht erreicht hat oder nie erreichen wird. Bewusst gibt es keinen create-Schlüssel auf oberster Ebene: Der Zustand eines Records ist während BEFORE CREATE immer null (siehe unten), Zustand kann Anlage also gar nicht einschränken — dafür simple_acls kombinieren, falls zusätzlich gebraucht. Ein Space ganz ohne state_acls-Eintrag, oder ein Space ganz ohne angehängten Workflow (ctx.state ist dann immer null), ist von diesem Rezept komplett unberührt.

Installation (state_acls)

Liegt als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/state_acls/../../recipes/state_acls/README.md. install.struct.noplm einfach in die Konsole einspielen — danach ist das Rezept aktiv, aber für jeden Space ohne passenden state_acls-Eintrag weiterhin ein reiner No-op.

Drei Bausteine, alle global angehängt:

  • noplm_state_acls_before — ein GLOBAL TRIGGER BEFORE UPDATE/DELETE-Skript, das ctx.state (und dessen Tags) nachschlägt, jede über byName/byTag ausgewählte Zustands-Regel einsammelt und, falls eine davon diese Operation einschränkt, mit ctx.abort() abbricht, außer der Aufrufer erfüllt alle zutreffenden Regeln. Nicht an CREATE angehängt — ctx.state ist während BEFORE CREATE immer null.
  • noplm_state_acls_run_condition/noplm_state_acls_flow_condition — je ein GLOBAL RUN CONDITION/GLOBAL FLOW CONDITION-Skript. Beide schlagen ctx.state nach (bei einer Flow-Prüfung ist das der Ausgangszustand des Übergangs) und filtern ctx.allRunnables/ctx.allFlows gegen die run/flow-Regeln dieses Zustands.

Beispiel

Weiter im Küchen-Szenario: recipe trägt wieder den Workflow kitchen (Zustände INITIAL→tested→approved über die Flows test/revise/approve, siehe Kapitel 7), und cook, head_chef und sous_chef haben alle volles GRANT CREATE, READ, UPDATE, DELETE, RUN, FLOW ON recipe. head_chef und sous_chef tragen beide den Tag approver; Zustand tested trägt den Tag pending_review, Zustand approved trägt den Tag locked. cook_bob legt einen Datensatz an und bringt ihn nach tested — beides noch unbeschränkt, da die state_acls-Config für recipe erst danach gesetzt wird:

INSERT INTO recipe (name = 'Kartoffelsuppe');    -- als cook_bob

% id: 1
FLOW test ON recipe WITH ID = 1;    -- als cook_bob

# OK: flow 'test' executed
PUT noplm, state_acls, recipe '{"byName":{"approved":{"update":{"role":["head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}}}},"byTag":{"pending_review":{"flow":{"byName":{"approve":{"role":["head_chef"]}}}}}}';

# OK
@ rows: 1

Der Record steht jetzt im Zustand tested, ausgewählt rein über dessen pending_review-Tag (der literale Name taucht in der Config gar nicht auf), in dem approve auf head_chef beschränkt ist. sous_chef_erik hält den Tag approver, aber diese eine Regel prüft role/tag nur innerhalb ihrer selbst — sie wird von keiner anderen Regel beeinflusst, der Tag allein hilft hier also nicht:

FLOW approve ON recipe WITH ID = 1;    -- als sous_chef_erik

! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 1;'): flow 'approve' is not permitted for record 1 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 1;    -- als head_chef_maria (hält die Rolle 'head_chef')

# OK: flow 'approve' executed

Der Record steht jetzt im Zustand approved, der sowohl über byName (Rolle head_chef) als auch, über seinen locked-Tag, über byTag (Tag approver) zutrifft — update verlangt also beide Regeln gleichzeitig (UND). sous_chef_erik hält den Tag, aber nicht die Rolle, und scheitert trotzdem — der Beweis, dass die beiden Selektoren wirklich mit UND kombinieren, statt dass einer allein reichen würde:

UPDATE recipe SET name = 'Hacked' WHERE id = 1;    -- als sous_chef_erik

! ROLLBACK: command ('UPDATE recipe SET name = 'Hacked' WHERE id = 1;'): script 'noplm_state_acls_before' on space 'recipe' aborted: {{noplm:text|en=State "approved" on space "recipe" does not permit "update" for your roles/tags|de=Der State "approved" im Space "recipe" erlaubt "update" für Ihre Rollen/Tags nicht}}
UPDATE recipe SET name = 'Kartoffelsuppe (final)' WHERE id = 1;    -- als head_chef_maria

# OK
@ rows: 1

run steht nur in byNames "approved"-Regel, beschränkt auf head_chef:

RUN SCRIPT reset_recipe ON recipe WITH ID = 1;    -- als cook_bob

! ROLLBACK: command ('RUN SCRIPT reset_recipe ON recipe WITH ID = 1;'): script 'reset_recipe' is not permitted to run on record 1 of space 'recipe' by its run condition script(s)
RUN SCRIPT reset_recipe ON recipe WITH ID = 1;    -- als head_chef_maria

# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;

delete steht ebenfalls nur in byNames "approved"-Regel, und rein über den Tag beschränkt — byTags "locked"-Regel hat gar keinen delete-Schlüssel, hier greift also nur eine einzige Regel, und der Tag allein reicht:

DELETE FROM recipe WHERE id = 1;    -- als cook_bob

! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_state_acls_before' on space 'recipe' aborted: {{noplm:text|en=State "approved" on space "recipe" does not permit "delete" for your roles/tags|de=Der State "approved" im Space "recipe" erlaubt "delete" für Ihre Rollen/Tags nicht}}
DELETE FROM recipe WHERE id = 1;    -- als sous_chef_erik

# OK
@ rows: 1

sous_chef_erik gelingt es rein über den approver-Tag — sous_chef steht an keiner Stelle in der Config.

Grenzen

  • Rein additiv — kann nur einschränken, was GRANT bereits erlaubt, nie mehr freigeben. Ein Space ohne die passenden GRANTs bleibt gesperrt, egal was in der state_acls-Config steht.
  • CREATE ist nie eingeschränkt — siehe "Konfiguration" oben.
  • Kein Effekt auf einen Space ohne angehängten Workflow — ctx.state ist dort immer null.
  • Admin-Sperre gilt normal für die BEFORE-Seite: ein Admin, der z. B. Daten korrigiert, braucht SUSPEND SCRIPTS, sonst feuert noplm_state_acls_before gar nicht erst — und ist dann, solange die Suspendierung aktiv ist, auch von diesem Rezept komplett ausgenommen. Die Condition-Scripte kennen dagegen keine Admin-Ausnahme — sie gelten uneingeschränkt für jede Sitzung, auch für Admins.
  • Kaputte Config bricht fail-closed ab — ungültiges JSON im state_acls-Eintrag lässt noplm_state_acls_before mit ctx.abort() scheitern (die beiden Condition-Scripte geben in diesem Fall [] zurück, also "nichts erlaubt") statt die Prüfung stillschweigend zu überspringen.
  • Nie rückwirkend, und nie mitten im Flow neu ausgewertet — eine Flow-Prüfung wird gegen den Zustand geprüft, den der Record vor dem Übergang hat, also gegen den, den er tatsächlich verlässt; nichts hier reagiert auf einen Zustandswechsel, der erst nach der bestandenen Prüfung passiert.
  • Kombinierbar mit simple_acls/owner_acls — mehrere dieser Rezept-Skripte gleichzeitig angehängt schneiden ihre Einschränkungen (alle angehängten müssen zustimmen), dieselbe "rein additiv"-Logik wie überall in dieser Rezeptfamilie; ein eingebautes "ODER" zwischen ihnen gibt es nicht.
  • byName/byTag kombinieren mit UND, role/tag kombinieren mit ODER — nicht verwechseln. Ein Zustand, der mehrere Selektoren gleichzeitig trifft (eigener Name plus ein oder mehrere Tags), muss alle zutreffenden Regeln erfüllen; innerhalb einer zutreffenden Regel reicht die genannte Rolle oder der genannte Tag gleichermaßen.

16.6 Feldbasiertes Rechtemanagement

simple_acls (16.1), owner_acls (16.3) und state_acls (16.5) schränken alle auf Ebene einer ganzen Operation ein (UPDATE/DELETE/ RUN/FLOW als Ganzes). Dieses Rezept — intern field_acls genannt — schränkt stattdessen auf Ebene einzelner Felder ein: manche Felder eines Records bleiben für jeden offen, der bereits GRANT UPDATE hält, andere dürfen nur von einem Aufrufer mit einer bestimmten Rolle geändert werden — der Rest desselben UPDATE-Befehls läuft ganz normal durch. Wie seine drei Geschwister liegt es über dem GRANT-Modell und kann nur einschränken, nie mehr erlauben; alle vier sind frei kombinierbar (jede angehängte Rezept-Prüfung muss bestehen).

Namenskonvention

Wie in 16.1: Entry-Key noplm, field_acls, <space>, Skriptname noplm_field_acls_before.

Konfiguration

Pro Space, der von diesem Rezept eingeschränkt werden soll, ein JSON-Eintrag im Entry Store. Die äußere Ebene wählt aus, welches Feld eine Regel betrifft, über eine {byName, byTag}-Aufteilung — dieselbe Aufteilung, die state_acls (16.5) für die Zustandsauswahl und simple_acls (16.1) für die Flow-Auswahl nutzt:

Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:

{
  "byName": {"difficulty": {"role": ["head_chef"]}},
  "byTag": {"sensitive": {"tag": ["approver"]}}
}
PUT noplm, field_acls, recipe '{"byName":{"difficulty":{"role":["head_chef"]}},"byTag":{"sensitive":{"tag":["approver"]}}}';

# OK
@ rows: 1

byName-Schlüssel sind literale Feldnamen; byTag-Schlüssel sind Tags an einem Feld (TAG FIELD <space>.<field> WITH <tag>;). Jeder Wert ist eine Regel ({role, tag}, dieselbe Form wie simple_aclss eigene Regeln — siehe 16.1): Ändern des Felds ist für alle gesperrt außer für einen Aufrufer, den die Regel erlaubt — eine leere Regel ({}) sperrt für alle, ohne Ausnahme.

Trifft ein Feld sowohl auf einen byName-Eintrag als auch auf einen oder mehrere byTag-Einträge zu, müssen alle zutreffenden Regeln unabhängig voneinander erlauben (UND) — dieselbe Kombinationsregel, die state_acls für seine eigene Zustandsauswahl nutzt, aus demselben Grund: ein Tag auf einem Feld kann nur enger machen, was die eigene Namensregel bereits erlaubt, nie weiter öffnen.

Ein Feld, das keiner der beiden Selektoren trifft, ist völlig unbeschränkt (weiterhin nur an das normale GRANT UPDATE des Space gebunden, wie immer). Das funktioniert für jedes Feld, das ctx.old/ctx.new zeigen — nicht nur ein normales, selbst deklariertes Space-Feld, sondern auch die drei schreibbaren Pseudo-Felder owner/responsibleRole/deputyOwner (siehe CLAUDE.md § "Per-record pseudo-fields" im Server-Repo) — z. B. um zwar jedem das Ändern der eigentlichen Rezeptdaten zu erlauben, die Neuzuweisung des owner aber einem head_chef vorzubehalten. Ein Pseudo-Feld kann kein eigenes TAG FIELD tragen — es ist von vornherein keine Zeile in _meta_fields — und lässt sich deshalb nur über byName auswählen.

Bewusst gibt es keinen Vergleich gegen den ursprünglichen Wert bei Anlage — ein Space ganz ohne field_acls-Eintrag ist von diesem Rezept komplett unberührt, und CREATE ist nie eingeschränkt (siehe "Was installiert wird" unten).

Installation (field_acls)

Liegt als fertig lauffähiges install.struct.noplm neben einer eigenen README.md im Rezepte-Verzeichnis: recipes/field_acls/../../recipes/field_acls/README.md. install.struct.noplm einfach in die Konsole einspielen — danach ist das Rezept aktiv, aber für jeden Space ohne passenden field_acls-Eintrag weiterhin ein reiner No-op.

Ein Baustein, global angehängt:

  • noplm_field_acls_before — ein GLOBAL TRIGGER BEFORE UPDATE-Skript, das für jedes vom Befehl berührte Feld den aktuellen Wert (ctx.old[feld]) mit dem Wert vergleicht, den der Befehl gerade schreiben will (ctx.new[feld]). Ein Feld, dessen Wert sich gar nicht ändert, wird nie geprüft, egal was die Config sagt — nur eine echte Änderung wird geprüft. Ändert sich ein Feld tatsächlich, schlägt das Skript nach, welche Regel(n) dafür gelten (über Feldname und/oder Feld-Tag), und der Aufrufer muss alle davon erfüllen, sonst bricht der gesamte Befehl ab (nicht nur die eine Feldzuweisung — ein UPDATE, das mehrere Felder auf einmal ändert, geht entweder als Ganzes durch oder scheitert als Ganzes, dieselbe Alles-oder-nichts-Form, die jeder BEFORE-Abbruch in noPLM ohnehin schon hat). Nicht an CREATE angehängt — die Startwerte eines Records sind keine "Änderung" gegenüber irgendetwas, es gibt also nichts, womit ein feldbasiertes Änderungs-Gate vergleichen könnte. Wer auch die Erstbelegung eines Felds einschränken will, kombiniert dieses Rezept mit dem gröberen, spaceweiten create-Gate von simple_acls../simple_acls/README.md.

Beispiel

Weiter im Küchen-Szenario: recipe trägt hier zur Vereinfachung zwei einfache Skalarfelder, difficulty und price (statt der vollen Picklist-Relation aus dem Datenmodell), und cook, head_chef und sous_chef haben alle volles GRANT CREATE, READ, UPDATE, DELETE ON recipe. head_chef und sous_chef tragen beide den Tag approver; sowohl difficulty als auch price tragen den Tag sensitive. cook_bob legt einen Datensatz an und ändert difficulty — beides noch unbeschränkt, da die field_acls-Config für recipe erst danach gesetzt wird:

INSERT INTO recipe (name = 'Kartoffelsuppe');    -- als cook_bob

% id: 1
UPDATE recipe SET difficulty = 'Mittel' WHERE id = 1;    -- als cook_bob

# OK
@ rows: 1
PUT noplm, field_acls, recipe '{"byName":{"difficulty":{"role":["head_chef"]}},"byTag":{"sensitive":{"tag":["approver"]}}}';

# OK
@ rows: 1

name bleibt weiterhin völlig offen — es kommt in der Config gar nicht vor und trägt auch keinen Tag:

UPDATE recipe SET name = 'Kartoffelsuppe (deluxe)' WHERE id = 1;    -- als cook_bob

# OK
@ rows: 1

difficulty trifft sowohl über byName (Rolle head_chef) als auch, über seinen sensitive-Tag, über byTag (Tag approver) zu — beide müssen gleichzeitig gelten. sous_chef_erik hält den Tag, aber nicht die Rolle, und scheitert trotzdem:

UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1;    -- als sous_chef_erik

! ROLLBACK: command ('UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "difficulty" on space "recipe" may not be changed by your roles/tags|de=Das Feld "difficulty" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}

Das erneute Zuweisen des bereits aktuellen Werts ist keine "Änderung" und wird deshalb nie geprüft, auch nicht bei fehlender Rolle/Tag:

UPDATE recipe SET difficulty = 'Mittel', name = 'Kartoffelsuppe (unverändert)' WHERE id = 1;    -- als cook_bob

# OK
@ rows: 1

Ein einzelner Befehl, der ein offenes und ein gesperrtes Feld gleichzeitig ändert, ist alles-oder-nichts — die verbotene Feldänderung blockiert den gesamten Befehl, einschließlich der für sich genommen unproblematischen name-Änderung:

UPDATE recipe SET name = 'Sneaky', difficulty = 'Schwer' WHERE id = 1;    -- als cook_bob

! ROLLBACK: command ('UPDATE recipe SET name = 'Sneaky', difficulty = 'Schwer' WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "difficulty" on space "recipe" may not be changed by your roles/tags|de=Das Feld "difficulty" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}

head_chef_maria hält sowohl die Rolle als auch den Tag:

UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1;    -- als head_chef_maria

# OK
@ rows: 1

price dagegen ist rein über den Tag beschränkt — kein byName-Eintrag nennt es überhaupt. sous_chef_erik gelingt es rein über den approver-Tag, obwohl sous_chef an keiner Stelle in der Config steht:

UPDATE recipe SET price = 4.50 WHERE id = 1;    -- als cook_bob

! ROLLBACK: command ('UPDATE recipe SET price = 4.50 WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "price" on space "recipe" may not be changed by your roles/tags|de=Das Feld "price" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}
UPDATE recipe SET price = 4.50 WHERE id = 1;    -- als sous_chef_erik

# OK
@ rows: 1

Grenzen

  • Rein additiv — kann nur einschränken, was GRANT bereits erlaubt, nie mehr freigeben. Ein Space ohne passendes GRANT UPDATE bleibt gesperrt, egal was in der field_acls-Config steht.
  • CREATE ist nie eingeschränkt — siehe "Was installiert wird" oben.
  • Alles-oder-nichts pro Befehl, nicht pro Feld — ein verbotenes Feld in einem UPDATE, das mehrere Felder auf einmal setzt, bricht den gesamten Befehl ab, nicht nur diese eine Zuweisung; es gibt kein "erlaubte Felder anwenden, Rest überspringen".
  • Änderungserkennung ist ein einfacher JSON.stringify-Vergleich von ctx.old[feld] und ctx.new[feld] — korrekt für jeden Skalartyp (STRING/TEXT/INT/FLOAT/BOOL/DATETIME/JSON/USER/ ROLE/AUTONUMBER) und ein ONE-seitiges Relationsfeld (eine reine Id). Bei einem MANY-seitigen/MANY:MANY-Feld (ein Array von Ids) ist dieser Vergleich reihenfolgeabhängig — dieselbe Mitgliedschaft in anderer Reihenfolge neu zuzuweisen liest sich als "geändert", obwohl sie es nicht ist. Dieses Rezept ist für einzelne Skalar-/ Einzelwertfelder gedacht; ein Mitgliedschaftsfeld sperrt man besser komplett über simple_acls../simple_acls/README.md oder lässt es hier ungegatet.
  • Admin-Sperre gilt normal: ein Admin, der z. B. Daten korrigiert, braucht SUSPEND SCRIPTS, sonst feuert noplm_field_acls_before gar nicht erst — und ist dann, solange die Suspendierung aktiv ist, auch von diesem Rezept komplett ausgenommen.
  • Kaputte Config bricht fail-closed ab — ungültiges JSON im field_acls-Eintrag lässt noplm_field_acls_before mit ctx.abort() scheitern, statt die Prüfung stillschweigend zu überspringen.
  • byName/byTag kombinieren mit UND, role/tag kombinieren mit ODER — nicht verwechseln. Ein Feld, das beide Selektoren gleichzeitig trifft, muss alle zutreffenden Regeln erfüllen; innerhalb einer zutreffenden Regel reicht die genannte Rolle oder der genannte Tag gleichermaßen.
  • Kombinierbar mit simple_acls/owner_acls/state_acls — mehrere dieser Rezept-Skripte gleichzeitig angehängt schneiden ihre Einschränkungen (alle angehängten müssen zustimmen), dieselbe "rein additiv"-Logik wie überall in dieser Rezeptfamilie; ein eingebautes "ODER" zwischen ihnen gibt es nicht.

16.7 Anzeige-Renderer: gerundete Zahlen und Ampeln

Kein Rechte-Rezept: dieses — intern display_renderers — besteht aus fünf read-only Renderern für INT-, FLOAT- und BOOL-Felder. Sie zeigen nur an; im Edit-Modus verhalten sie sich wie ein READONLY-Feld. Die Installation legt ausschließlich die Renderer an, Konfiguration gibt es keine. Skriptquelltext und Installationsdatei: recipes/display_renderers/../../recipes/display_renderers/README.md.

Namensschema wie in 16.1: noplm_display_renderers_<komponente>. Ein Renderer ist an genau einen Feldtyp gebunden, daher gibt es die ersten beiden je als INT- und FLOAT-Variante.

Die Renderer

Renderer Gebunden an Zeigt
…_si_int / …_si_float INT / FLOAT gerundet, mit SI-Präfix
…_light_int / …_light_float INT / FLOAT Ampel nach Vorzeichen
…_light_bool BOOL Rot/Grün-Ampel

SI-Anzeige. Rundet auf eine Anzahl gültiger Stellen und hängt k, M, G, T (Werte ≥ 1000) bzw. m, µ, n, p (Werte < 1) an. Parameter digits (1-15, Standard 3): 1234 wird 1.23k, 0.00123 wird 1.23m, 1.5e-6 wird 1.5µ. Eine UNIT des Felds folgt dem Präfix (1.23 kg); der exakte Wert steht als Tooltip daran.

Ampel. Eine gekippte Ampel: grün links (positiv), gelb in der Mitte (0), rot rechts (negativ). Mit dem Parameter lights=2 entfällt die gelbe Lampe und 0 zeigt grün. Die Bool-Variante kennt nur rot und grün: true ist grün, false rot; invert = true vertauscht das (für "Fehler"-Flags). Bei NULL bleibt jede Lampe dunkel.

Benutzung

ALTER VIEW stock_view ALTER FIELD qty SET RENDERER noplm_display_renderers_si_int WITH digits = 3;
ALTER VIEW stock_view ALTER FIELD balance SET RENDERER noplm_display_renderers_light_int WITH lights = 2;
ALTER VIEW stock_view ALTER FIELD active SET RENDERER noplm_display_renderers_light_bool;

Grenzen

  • Nur Anzeige — beim Speichern wird nie etwas zurückgelesen.
  • Kein gemeinsamer Code — Renderer können keine Library per USES einbinden, daher tragen INT- und FLOAT-Variante identischen Code.
  • SI-Bereich p…T — größere/kleinere Werte behalten das äußerste Präfix (5e15 zeigt 5000T).
  • Feste Farben — die Ampeln sind Inline-SVG; für andere Farben das Skript anpassen.

16.8 Revisionen: eine Reihe pro Datensatz

Auch kein Rechte-Rezept, und anders als picklist (16.4) bringt dieses — intern revisions — keinen eigenen Space mit, sondern verändert einen, den Du schon hast. Das Muster: Jede Revision ist ein ganz gewöhnlicher Datensatz desselben Spaces, eine Selbstrelation bindet alle Revisionen einer Reihe zusammen, und zwei virtuelle Felder beantworten die Fragen, die Anwender wirklich stellen: Welche Revision ist die neueste? (als Link zum Hinspringen) und Stehe ich gerade auf ihr? Einen eingebauten Revisionsmechanismus im Server gibt es bewusst nicht — dafür sind zu viele Fragen (feste oder mitwandernde Verweise von anderen Spaces, Kopiersemantik der Teile, Rechte) Sache des jeweiligen Datenmodells. Skriptquelltext und Installationsdatei: recipes/revisions/../../recipes/revisions/README.md.

Virtuelle Felder sind der neue Baustein: ein Feld, dessen Wert bei jedem Lesen aus einer gespeicherten QUERY berechnet wird — nicht gespeichert, nicht beschreibbar, und genau richtig für einen Wert, der von anderen Datensätzen abhängt (die neueste Revision ändert sich dort, wo gar kein Trigger des gelesenen Datensatzes feuert). Details in Kapitel 4.

Was installiert wird

Alle Namen tragen den Präfix noplm_revisions_, damit nichts mit Deinen eigenen Feldern kollidiert. Auf dem Space, den Du angibst (hier: recipe):

  • …_revision — INT NOT NULL DEFAULT 1, die Nummer innerhalb der Reihe. Vorhandene Datensätze werden Revision 1.
  • …_origin — die Selbstrelation. Jede Revision zeigt auf die erste Revision ihrer Reihe, die erste zeigt auf sich selbst. Die Rückseite …_series listet die ganze Reihe.
  • …_current — virtuelles Relationsfeld: die neueste Revision der Reihe.
  • …_latest_no — virtuelles INT-Feld: deren Nummer. Vergleicht man sie mit der eigenen, lässt sich ein Hinweis "Es gibt eine neuere Revision" bauen.

Und fünf Skripte, geteilt von allen Spaces, auf die Du das Rezept anwendest:

  • noplm_revisions_set_origin — AFTER CREATE: ein Datensatz ohne Ursprung wird zur ersten Revision seiner eigenen Reihe.
  • noplm_revisions_new_revision — RUNNABLE: kopiert einen Datensatz als nächste Revision und liefert die neue ID.
  • noplm_revisions_guard — der Wächter, siehe unten.
  • noplm_revisions_backfill — RUNNABLE für einen Bestandsdatensatz, siehe unten.
  • noplm_revisions_run_condition — bietet backfill nur an einem Datensatz ohne Ursprung an.

Das Label-Skript des Spaces bleibt Deine Sache: Es sollte Revisionen unterscheidbar benennen (Kartoffelsuppe (rev 3)), sonst zeigt ein Link auf die neueste Revision nur den nackten Namen.

Installation (revisions)

Dieses Rezept ändert einen Space, den Du schon hast; deshalb nennst Du ihn beim Einspielen (als Admin):

INCLUDE RECIPE revisions WITH space = recipe;

Der Server spielt die Installationsdatei des Rezepts ab, wobei das Session-Makro {{space}} (Kapitel 5) nur für diese Datei auf recipe gesetzt ist; jeder Befehl, der den Space verändert, trägt es. SHOW RECIPE revisions; zeigt die Parameter, und ohne WITH space = … wird der Befehl abgelehnt, bevor etwas geändert wurde. (Ohne konfiguriertes Rezeptverzeichnis geht dasselbe von Hand: DEFINE space AS recipe; und dann die Datei abspielen, z. B. mit noplm-cli.) Für einen zweiten Space nur den Namen ändern und den Befehl noch einmal ausführen: Die Skripte gibt es dann schon (CREATE SCRIPT IF NOT EXISTS überspringt sie mit einer Warnung und überschreibt nie eine Kopie, die Du angepasst hast), nur der Teil für den Space läuft. Zweimal auf denselben Space scheitert dagegen absichtlich (ADD FIELD, ATTACH) — und weil DDL keine Transaktion kennt, hinterlässt ein Fehler mitten drin einen halb vorbereiteten Space; die README listet die Schritte zum Zurückbauen.

Verwendung

Eine Kartoffelsuppe, jetzt mit Revisionen. new_revision kopiert den Datensatz und gibt die neue ID zurück:

RUN SCRIPT noplm_revisions_new_revision ON recipe WITH ID = 1;

# OK: script 'noplm_revisions_new_revision' executed
% return:
% 2
% ;
$ Revision 2 created.

Die Kartoffelsuppe in der Fassung "diesmal mit mehr Kartoffeln" als Revision 3 — auch von einer alten Revision aus bekommt die Kopie die nächste freie Nummer:

RUN SCRIPT noplm_revisions_new_revision ON recipe WITH ID = 2;
QUERY recipe -> (name, noplm_revisions_revision, noplm_revisions_current, noplm_revisions_latest_no);

@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|noplm_revisions_revision|INT|false|
@ column: 4|noplm_revisions_current|RELATION|true|
@ column: 5|noplm_revisions_latest_no|INT|true|
@ rows: 3
% 1 | 'Kartoffelsuppe' | 1 | recipe:3 | 3
% 2 | 'Kartoffelsuppe' | 2 | recipe:3 | 3
% 3 | 'Kartoffelsuppe' | 3 | recipe:3 | 3

Alle drei Zeilen kennen die neueste Revision, ohne dass jemand beim Anlegen der dritten die ersten beiden angefasst hätte. Weil …_current ein Relationsfeld ist, lässt sich darüber auch hüpfen:

QUERY recipe -> (name, noplm_revisions_current -> (noplm_revisions_revision, servings)) WHERE id = 1;

@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|noplm_revisions_current->noplm_revisions_revision|INT|false|
@ column: 4|noplm_revisions_current->servings|INT|false|
@ rows: 1
% 1 | 'Kartoffelsuppe' | 3 | 6

In einer VIEW zeigt FIELD noplm_revisions_current NAVIGATE TO <view> einen Link auf die neueste Revision. Virtuelle Felder sind nicht schreibbar und nicht in WHERE, ORDER BY oder UNIQUE verwendbar — als Spalte und als Hop aber überall, auch im Query-Builder (Kapitel 10).

Der Wächter

noplm_revisions_guard ist Pflicht und wird beim Anwenden angehängt (ein Admin kann ihn mit DETACH wieder lösen). Er tut zwei Dinge:

  • Beim Anlegen prüft er Zusammenhang, statt zu raten, was Du meintest. Kein Ursprung angegeben: erste Revision, die Nummer muss 1 sein (oder weg bleiben). Ursprung angegeben: er muss auf die erste Revision einer vorhandenen Reihe zeigen, und die Nummer muss genau die nächste sein. Alles andere bricht ab, mit einer Meldung, die auf new_revision verweist.
  • Beim Ändern verweigert er Änderungen an jeder Revision, die nicht die neueste ist:
UPDATE recipe SET servings = 8 WHERE id = 1;

! ROLLBACK: command ('UPDATE recipe SET servings = 8 WHERE id = 1;'): script 'noplm_revisions_guard' on space 'recipe' aborted: Revision 1 is not the newest revision of its series and cannot be changed.
UPDATE recipe SET servings = 8 WHERE id = 3;

# OK
@ rows: 1

Eine Revision, die nur "angefasst" wird, weil eine neue Revision in ihre Reihe eintritt (ihre Rückseite ändert sich), gilt nicht als geändert. Ein Datensatz ohne Ursprung gehört zu keiner Reihe und wird nicht geschützt.

Datensätze, die auf eine Revision zeigen, zählen als Änderung an ihr: Ein Teil (oder ein Einsatz, ein Auftrag, alles mit einer Relation auf den Space) lässt sich weder an eine alte Revision hängen noch von ihr wegnehmen oder dort löschen. Wegnehmen und Löschen sieht der Wächter vor dem Schreiben, das Anlegen wird der Revision erst danach gemeldet — deshalb hängt der Wächter auch an AFTER UPDATE, und die Ablehnung macht dann den ganzen Befehl rückgängig. Muss die Historie trotzdem geändert werden, ist das Sache des Admins: SUSPEND SCRIPTS; überspringt den Wächter (und jedes andere angehängte Skript), ein neues Login holt ihn zurück.

Wer es strenger will: Anwendern nur run, aber nicht create/update auf dem Space geben — dann ist new_revision der einzige Weg hinein.

Bestandsdaten

Wendest Du das Rezept auf einen Space an, der schon Datensätze enthält, werden sie Revision 1 ohne Ursprung: eine Reihe der Länge eins, die noch niemand begonnen hat. Zwei Dinge helfen:

  • RUN SCRIPT noplm_revisions_new_revision auf so einem Datensatz macht ihn zuerst zur ersten Revision seiner eigenen Reihe und legt dann Revision 2 an. Vorbereiten muss man nichts.
  • RUN SCRIPT noplm_revisions_backfill ON recipe WITH ID = <id>; erledigt das nur für diesen Datensatz, damit current und latest_no etwas anzeigen. Angeboten (und erlaubt) wird es nur an einem Datensatz ohne Ursprung; an jedem anderen blendet die Run-Condition es aus, new_revision bleibt angeboten.
RUN SCRIPT noplm_revisions_backfill ON recipe WITH ID = 1;

$ Started as revision 1 of its own series.

Beides verbindet keine bereits vorhandenen Datensätze zu Reihen. Liegen Revisionen bei Dir schon als getrennte Datensätze vor (gleicher Name, die Nummer in einem eigenen Feld), ist der Zusammenhang ein Geschäftsschlüssel, den nur Du kennst — ein Einmal-Skript, das je Gruppe den Ursprung aller Mitglieder auf das erste setzt, ist dafür das richtige Werkzeug; die README beschreibt, worauf es zu achten hat.

Grenzen

  • Skalarfelder und einige Relationen werden kopiert. new_revision übernimmt jedes Feld, das Du selbst deklariert hast, außer den Revisionsfeldern, einem AUTONUMBER (die Kopie zieht eine eigene Nummer) und einem UNIQUE-Feld (die Kopie würde mit ihrem Original kollidieren und beginnt leer). Relationen: die einfache Seite einer 1:n und n:m (dieselben Zuordnungen, je mit einem neuen Detail-Datensatz, wenn die Relation einen DETAIL SPACE hat) werden kopiert; 1:1 und die Viele-Seite einer 1:n (Teile, die auf den Datensatz zeigen) nicht — sie bleiben bei der alten Revision. Alles andere, was zum Datensatz gehört (Dokumente, …), kopiert es nicht — das ist eine Entscheidung über Deine Daten, also Dein Skript.
  • Ein UNIQUE-Feld, das nicht leer sein darf (NOT NULL, ohne DEFAULT), lässt die Kopie scheitern. Das Feld nicht eindeutig machen, einen Standardwert vergeben oder ein eigenes Kopier-Skript schreiben.
  • Der Wächter braucht read auf den Space, um …_current zu sehen: ohne Leserecht ist ein virtuelles Feld leer, nicht fehlerhaft — der Wächter lässt dann durch, statt jede Änderung zu blockieren.
  • Admin-Sitzungen laufen bei jedem angehängten Skript in den üblichen harten Fehler, solange Skripte nicht per SUSPEND SCRIPTS;/ALLOW SCRIPTS; freigegeben sind (Kapitel 9).
  • Den Ursprung von Hand umzuhängen prüft nichts. Besser lassen.

Wird fortgesetzt.

Teil VII · Kommandoreferenz

Kapitel 17

Kommandoreferenz

Dieses Kapitel erklärt nichts Neues — es ist ein alphabetisches Nachschlagewerk für die exakte Syntax jedes Befehls, den die Konsolensprache kennt. Das Warum und Wie steht in Teil I–VI; hier findest Du nur noch: Syntax, eine Kurzbeschreibung, die nötige Berechtigung und ob der Befehl Autocommit-only oder in einer BEGIN...COMMIT-Kette stapelbar ist.

Notation: <eckige Klammern> sind Platzhalter, die Du durch echte Namen/Werte ersetzt; [...] markiert optionale Teile; A|B bedeutet "eines von beidem"; ... steht für Wiederholung. Wörter in GROSSBUCHSTABEN sind Schlüsselwörter der Konsolensprache — bis auf wenige Ausnahmen (siehe Kapitel 1) alle kontextuell, d. h. an jeder anderen Stelle ganz normal als Bezeichner verwendbar. Jeder Befehl endet mit ;. Ein Zeilenumbruch zählt innerhalb eines Befehls wie ein Leerzeichen (Ausnahme: der opake Script-/JSON-Body, der bis zu einer %%;-Zeile läuft), und mehrere ;-getrennte Befehle dürfen auf einer Zeile stehen — jeder wird einzeln ausgeführt und einzeln beantwortet.

Berechtigung ist eine von: keine (jede angemeldete Sitzung), eigene Sitzung (bezieht sich nur auf den Aufrufer selbst, kein Recht nötig), einem der neun Space-Rechte aus Kapitel 13 (read/create/update/delete/ run/flow/upload/download/logs), oder admin.

Modus ist entweder Autocommit-only (nicht in einer BEGIN...COMMIT-Kette einreihbar, siehe Kapitel 3) oder stapelbar (kann sowohl einzeln als auch innerhalb einer Kette laufen).


A

ALLOW SCRIPTS

ALLOW SCRIPTS;

Lässt ein angehängtes Script für die aktuelle Admin-Sitzung tatsächlich feuern — genau wie bei einem normalen Nutzer (volle Abbruchmacht, Kaskaden, ctx.mail()/ctx.httpQuery() eingeschlossen) — statt des standardmäßigen harten Fehlers oder des stillen Überspringens durch SUSPEND SCRIPTS. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.

ALTER LIBRARY

ALTER LIBRARY <name> RENAME TO <neuer_name>;
ALTER LIBRARY <name> SET SOURCE
<JS-Funktionen>
%%;

Benennt eine Library um oder ersetzt ihren Code an Ort und Stelle — jedes bindende USES bleibt erhalten. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

ALTER MENU ITEM

ALTER MENU ITEM <name> RENAME TO <neuer_name>;
ALTER MENU ITEM <name> SET PARENT <anderer_name>;
ALTER MENU ITEM <name> SET LABEL '<text>';
ALTER MENU ITEM <name> SET SORT <zahl>;
ALTER MENU ITEM <name> SET VIEW <view> [WITH <param>=<literal>, ...];
ALTER MENU ITEM <name> DROP VIEW;

Ändert einen einzelnen Menüeintrag. SET PARENT verweigert Zyklen. SET VIEW überschreibt eine bestehende Bindung, kein DROP VIEW vorher nötig. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.

ALTER RENDERER

ALTER RENDERER <name> RENAME TO <neuer_name>;
ALTER RENDERER <name> SET SCRIPT
function renderView(value, ctx) { ... }
function renderEdit(value, ctx) { ... }
function readEdit(ctx) { ... }
%%;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

ALTER SCRIPT

ALTER SCRIPT <name> RENAME TO <neuer_name>;
ALTER SCRIPT <name> PRIORITY <zahl>;
ALTER SCRIPT <name> ADD USES <library>;
ALTER SCRIPT <name> DROP USES <library>;
ALTER SCRIPT <name> SET SOURCE
<JS-Code>
%%;

SET SOURCE ersetzt den Rumpf an Ort und Stelle, jede Bindung bleibt erhalten. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

ALTER SPACE

ALTER SPACE <space> ADD FIELD <feld_definition> [FIRST | AFTER <feld>];
ALTER SPACE <space> DROP FIELD <feld>;
ALTER SPACE <space> MOVE FIELD <feld> FIRST | AFTER <anderes_feld>;
ALTER SPACE <space> ALTER FIELD <feld> DESCRIPTION '<text>' | NULL;
ALTER SPACE <space> ALTER FIELD <feld> DEFAULT <literal> | NULL;
ALTER SPACE <space> ALTER FIELD <feld> NOT NULL [DEFAULT <literal>];
ALTER SPACE <space> ALTER FIELD <feld> NULL;
ALTER SPACE <space> ALTER FIELD <feld> UNIQUE;
ALTER SPACE <space> ALTER FIELD <feld> NOT UNIQUE;
ALTER SPACE <space> ALTER FIELD <feld> WHERE <where_clause> | NULL;
ALTER SPACE <space> ALTER FIELD <feld> DETAIL SPACE <space> [OWNED] | NULL;
ALTER SPACE <space> ADD VIRTUAL FIELD <feld> (<typ> | RELATION <space>) QUERY "<query>" [DESCRIPTION '<text>'] [UNIT '<text>'];
ALTER SPACE <space> ALTER FIELD <feld> QUERY "<query>";   -- nur virtuelle Felder
ALTER SPACE <space> SET LABEL '<text>';
ALTER SPACE <space> SET LABEL SCRIPT <script>;
ALTER SPACE <space> DROP LABEL SCRIPT;
ALTER SPACE <space> SET WORKFLOW <workflow>;
ALTER SPACE <space> DROP WORKFLOW;

Strukturelle Änderungen an einem bestehenden Space — Felder hinzufügen/entfernen/verschieben/ändern, Beschriftung, Label-Script, Workflow-Bindung. feld_definition folgt derselben Syntax wie bei CREATE SPACE. (Flow-/Run-Condition-Scripte werden nicht mehr hier gesetzt — siehe ATTACH SCRIPT ... AS FLOW|RUN CONDITION weiter unten.) Berechtigung: admin · Modus: Autocommit-only (strukturelle DDL) · Mehr: Kapitel 4, Kapitel 6 (WHERE/DETAIL SPACE), Kapitel 7 (WORKFLOW), Kapitel 9 (Scripte).

ALTER USER

ALTER USER <name> ENABLE;
ALTER USER <name> DISABLE;
ALTER USER <name> SET ADMIN;
ALTER USER <name> UNSET ADMIN;

DISABLE/UNSET ADMIN verweigern das eigene Konto und den letzten verbleibenden aktivierten Admin. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

ALTER VIEW

ALTER VIEW <name> RENAME TO <neuer_name>;
ALTER VIEW <name> SET MODE VIEW | EDIT | CREATE;
ALTER VIEW <name> SET LABEL '<text>';   -- das eigene Anzeige-Label der View (keine DROP-Form)
ALTER VIEW <name> SET SHOW WORKFLOW | DOCS | LOG;
ALTER VIEW <name> UNSET SHOW WORKFLOW | DOCS | LOG;
ALTER VIEW <name> ADD PARAM <name> AS <typ>;
ALTER VIEW <name> DROP PARAM <name>;
ALTER VIEW <name> SET QUERY "<konsolenbefehl>";
ALTER VIEW <name> DROP QUERY;

ALTER VIEW <name> ADD FIELD <spaltenpfad> [RENDERER <name> [WITH ...]];   -- oder <name> AS <typ> (MODE CREATE)
ALTER VIEW <name> DROP FIELD <feld>;
ALTER VIEW <name> SET CHECK VERSION | UNSET CHECK VERSION;
ALTER VIEW <name> SET RECORD <space>;   -- nur MODE CREATE
ALTER VIEW <name> ALTER FIELD <feld> SET RENDERER <name> [WITH <param>=<literal>, ...];
ALTER VIEW <name> ALTER FIELD <feld> DROP RENDERER;
ALTER VIEW <name> ALTER FIELD <feld> SET NAVIGATE TO <view>;
ALTER VIEW <name> ALTER FIELD <feld> DROP NAVIGATE;
ALTER VIEW <name> ALTER FIELD <feld> SET LABEL '<text>';
ALTER VIEW <name> ALTER FIELD <feld> DROP LABEL;
ALTER VIEW <name> ALTER FIELD <feld> DEFAULT <literal> | NULL;
ALTER VIEW <name> ADD ON SAVE "<UPDATE- oder INSERT-Befehl>";
ALTER VIEW <name> DROP ON SAVE <laufende_nummer>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

ALTER WORKFLOW

ALTER WORKFLOW <name> RENAME TO <neuer_name>;
ALTER WORKFLOW <name> SET LABEL '<text>';
ALTER WORKFLOW <name> SET UI META
<JSON>
%%;
ALTER WORKFLOW <name> DROP UI META;
ALTER WORKFLOW <name> ADD STATE <name> [LABEL '<text>'];
ALTER WORKFLOW <name> DROP STATE <name>;
ALTER WORKFLOW <name> RENAME STATE <name> TO <neuer_name>;
ALTER WORKFLOW <name> ALTER STATE <name> SET LABEL '<text>';
ALTER WORKFLOW <name> ALTER STATE <name> SET UI META ... ;
ALTER WORKFLOW <name> ALTER STATE <name> DROP UI META;
ALTER WORKFLOW <name> ADD FLOW <name> FROM <state> TO <state> [LABEL '<text>'];
ALTER WORKFLOW <name> DROP FLOW <name>;
ALTER WORKFLOW <name> RENAME FLOW <name> TO <neuer_name>;
ALTER WORKFLOW <name> ALTER FLOW <name> SET LABEL '<text>';
ALTER WORKFLOW <name> ALTER FLOW <name> SET UI META ... ;
ALTER WORKFLOW <name> ALTER FLOW <name> DROP UI META;

DROP STATE/DROP FLOW scheitern, solange noch etwas darauf verweist. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 7.

ASSIGN ROLE

ASSIGN ROLE <rolle> TO USER <benutzer>;
ASSIGN ROLE <kind> TO ROLE <eltern>;
ASSIGN ROLE <rolle> TO MENU ITEM <name>;

Weist eine Rolle einem Benutzer, einer anderen Rolle (Hierarchie, zyklenfrei) oder einem Menüeintrag (Sichtbarkeitsgate) zu. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13, Kapitel 11.

ATTACH DOC

ATTACH DOC '<upload_id>' TO <space> WITH ID = <id> [COMMENT '<text>'];

Übernimmt eine per REGISTER UPLOAD zwischengelagerte Datei endgültig als Doc an diesem Datensatz. Berechtigung: upload · Modus: Autocommit-only · Mehr: Kapitel 12.

ATTACH SCRIPT

ATTACH SCRIPT <name> TO <space> AS TRIGGER BEFORE|AFTER CREATE|UPDATE|DELETE;
ATTACH SCRIPT <name> TO <space> AS TRIGGER BEFORE|AFTER DOC UPLOAD|DOC DELETE;
ATTACH SCRIPT <name> TO <space> AS RUNNABLE;
ATTACH SCRIPT <name> TO <space> AS FLOW CONDITION;
ATTACH SCRIPT <name> TO <space> AS RUN CONDITION;
ATTACH SCRIPT <name> TO WORKFLOW <workflow> [PRIORITY <zahl>];
ATTACH SCRIPT <name> AS GLOBAL TRIGGER BEFORE CREATE|UPDATE|DELETE|DOC UPLOAD|DOC DELETE;
ATTACH SCRIPT <name> AS GLOBAL FLOW CONDITION;
ATTACH SCRIPT <name> AS GLOBAL RUN CONDITION;

Bindet ein bestehendes Script als Trigger, Run-Script, Flow-Script, Flow-/Run-Condition-Script, oder — die AS GLOBAL ...-Formen, ohne Space-Ziel — als Bindung, die für jeden Space auf einmal gilt (kein GLOBAL RUNNABLE; kein GLOBAL TRIGGER AFTER). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.


B

BEGIN

BEGIN;

Startet eine explizite Befehlskette — jeder folgende Befehl wird nur noch fail-fast validiert, ausgeführt erst bei COMMIT. Berechtigung: keine · Modus: — (Session-Steuerung selbst). Mehr: Kapitel 3.


C

COMMIT

COMMIT;

Führt jeden Befehl der aktuellen Kette wirklich aus, in einer echten Transaktion; jeder Fehler rollt die gesamte Kette zurück. Berechtigung: keine · Mehr: Kapitel 3.

CREATE LIBRARY

CREATE LIBRARY [IF NOT EXISTS] <name>
<JS-Funktionen, kein ctx-Wrapper>
%%;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

CREATE MENU ITEM

CREATE MENU ITEM <name> PARENT <eltern_name> LABEL '<text>'
    [SORT <zahl>] [VIEW <view> [WITH <param>=<literal>, ...]];

LABEL ist hier verpflichtend, kein Rückfall auf den Namen. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.

CREATE RENDERER

CREATE RENDERER [IF NOT EXISTS] <name> FOR STRING|TEXT|INT|FLOAT|BOOL|DATETIME|JSON|USER|ROLE|RELATION|RELATION LIST
function renderView(value, ctx) { ... }
function renderEdit(value, ctx) { ... }
function readEdit(ctx) { ... }
%%;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

CREATE ROLE

CREATE ROLE <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

CREATE SCRIPT

CREATE SCRIPT [IF NOT EXISTS] <name> [PRIORITY <zahl>] [USES <library>, ...]
<JS-Code, automatisch in function script(ctx) { ... } eingebettet>
%%;

IF NOT EXISTS (auch bei CREATE LIBRARY und CREATE RENDERER): Ist der Name schon vergeben, wird nichts geändert — das vorhandene Objekt wird nicht überschrieben — und der Befehl endet erfolgreich mit einer % warning:-Zeile statt mit einem Fehler; ein übersprungenes Anlegen kommt nicht ins Admin-Log. Gedacht für Installationsdateien, die mehr als einmal angewendet werden, etwa die Kochbuch-Rezepte.

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

CREATE SPACE

CREATE SPACE <name> [LABEL '<text>'] (
    <feld> STRING|TEXT|INT|FLOAT|BOOL|DATETIME|JSON|USER|ROLE [NOT NULL] [UNIQUE] [DEFAULT <literal>] [DESCRIPTION '<text>'],
    <feld> RELATION <space> ONE|MANY [WHERE <where_clause>]
           AS <inverses_feld> ONE|MANY [WHERE <where_clause>]
           [WITH DETAIL <space> [OWNED]] [DESCRIPTION '<text>'],
    ...
);

Legt einen neuen Space mit Skalar- und/oder Beziehungsfeldern an; id/created_at/modified_at entstehen automatisch. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 4, Kapitel 6.

CREATE TAG

CREATE TAG <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

CREATE USER

CREATE USER <name> [ADMIN];

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

CREATE VIEW

CREATE VIEW <name> AS SINGLE|LIST                        -- ein einziges ;-Kommando, Klauseln ohne Trenner
    -- Kopf-Klauseln (reihenfolge-frei, vor den Struktur-Klauseln):
    [MODE VIEW|EDIT|CREATE] [LABEL '<text>'] [SHOW WORKFLOW] [SHOW DOCS] [SHOW LOG] [SHOW RUNSCRIPTS] [CHECK VERSION]
    [RECORD <space>]                          -- Pflicht bei MODE CREATE
    [PARAM <name> AS <typ>]                   -- Zusatz-Parameter; die Record-id ist das implizite "id"
    -- Struktur-Klauseln:
    [QUERY "<konsolenbefehl>"]                -- String-Literal; MODE VIEW/EDIT; Wurzel-Space = Record-Space
    FIELD <spaltenpfad> [RENDERER <name> [WITH ...]] [READONLY] [NAVIGATE TO <view> [VIA DETAIL]] [LABEL '<text>']
    | FIELD <name> AS <typ> [DEFAULT <literal>] [READONLY] [LABEL '<text>']
    [ON SAVE "<UPDATE- (optional mit -> Hops) oder INSERT-Befehl>"]
    [GROUP <beziehungsfeld> [LABEL '<text>'] [ANCHOR $<var>] [ORDER BY <spalte> [ASC|DESC], ...] [NO ADD] [NO REMOVE] (
        FIELD ...                            -- relativ zur Verknüpfung: <feld> oder ^<detailfeld>
        [ON SAVE "..."]
     )]
    ...
;


SINGLE (ein Datensatz) oder LIST. Eine View ist ein ;-Kommando mit schlüsselwortgeführten Klauseln ohne Trenner, in zwei Stufen (Kopf-Klauseln vor Struktur-Klauseln); QUERY/ON SAVE-Payload ist ein String-Literal, ein GROUP-Rumpf steht in ( … ). Eine SINGLE VIEW in MODE VIEW/EDIT hat einen impliziten id-INT-Parameter; CHECK VERSION ist ein nacktes Flag. Ein GROUP-Block (nur SINGLE Views) zeigt eine Zeile pro Verknüpfung einer MANY-Beziehung und ist in MODE EDIT wie in MODE CREATE bearbeitbar (dort braucht er ein ANCHOR $<var>). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

CREATE WORKFLOW

CREATE WORKFLOW <name> [LABEL '<text>'];

Legt automatisch einen ersten Zustand INITIAL an. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 7.


D

DEFINE

DEFINE <name> AS <wort>|'<text>';

Setzt ein Session-Makro: Ab jetzt wird {{name}} im Text jedes Befehls dieser Session durch den Wert ersetzt, bevor der Befehl geparst wird. Ein einzelnes Wort ist ein Name oder eine Zahl, alles andere gehört in einen String. Der Wert wird einmal und unverändert eingesetzt — er wird nicht noch einmal nach Makros durchsucht, und unbekannte {{namen}} bleiben stehen. Ein Wert darf keine Zeile enthalten, die nur aus %%; besteht. Die Makros gelten bis UNDEFINE, LOGIN oder LOGOUT und werden nirgends gespeichert. So lässt sich eine Installationsdatei auf einen Space nach Wahl anwenden: DEFINE space AS part;, dann die Datei abspielen, in der ALTER SPACE {{space}} ... steht. Innerhalb einer BEGIN...COMMIT-Kette nicht erlaubt (vorher definieren); in der Kette eingereihte Befehle werden aber ersetzt. Das Admin-Log enthält den ersetzten Text. Berechtigung: keine · Modus: Autocommit-only.

DELETE

DELETE FROM <space> WHERE <where_clause>;

Löscht jeden zum WHERE passenden Datensatz; feuert DELETE-Trigger einmal pro Zeile. Berechtigung: delete · Modus: stapelbar · Mehr: Kapitel 5.

DELETE DOC

DELETE DOC '<dateiname>' [VERSION = <n>] FROM <space> WITH ID = <id>;

Ohne VERSION werden alle Versionen dieses Dateinamens gelöscht. Berechtigung: delete · Modus: stapelbar · Mehr: Kapitel 12.

DELETE MAIL QUEUE

DELETE MAIL QUEUE WITH ID = <id>;

Entfernt genau einen Eintrag, unabhängig vom Zustand. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

DETACH SCRIPT

DETACH SCRIPT <name> FROM <space> AS TRIGGER BEFORE|AFTER CREATE|UPDATE|DELETE;
DETACH SCRIPT <name> FROM <space> AS TRIGGER BEFORE|AFTER DOC UPLOAD|DOC DELETE;
DETACH SCRIPT <name> FROM <space> AS RUNNABLE;
DETACH SCRIPT <name> FROM <space> AS FLOW CONDITION;
DETACH SCRIPT <name> FROM <space> AS RUN CONDITION;
DETACH SCRIPT <name> FROM WORKFLOW <workflow>;
DETACH SCRIPT <name> AS GLOBAL TRIGGER BEFORE CREATE|UPDATE|DELETE|DOC UPLOAD|DOC DELETE;
DETACH SCRIPT <name> AS GLOBAL FLOW CONDITION;
DETACH SCRIPT <name> AS GLOBAL RUN CONDITION;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

DISABLE SCRIPT

DISABLE SCRIPT <name> ON <space> AS TRIGGER BEFORE|AFTER CREATE|UPDATE|DELETE;
DISABLE SCRIPT <name> ON <space> AS RUNNABLE;
DISABLE SCRIPT <name> ON <space> AS FLOW CONDITION;
DISABLE SCRIPT <name> ON <space> AS RUN CONDITION;
DISABLE SCRIPT <name> ON WORKFLOW <workflow>;
DISABLE SCRIPT <name> AS GLOBAL TRIGGER BEFORE CREATE|UPDATE|DELETE|DOC UPLOAD|DOC DELETE;
DISABLE SCRIPT <name> AS GLOBAL FLOW CONDITION;
DISABLE SCRIPT <name> AS GLOBAL RUN CONDITION;

Schaltet eine Bindung vorübergehend aus, ohne sie zu entfernen. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

DROP LIBRARY

DROP LIBRARY <name>;

Scheitert, solange ein Script sie noch per USES einbindet. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

DROP MENU ITEM

DROP MENU ITEM <name>;

Scheitert, solange der Eintrag noch Kinder hat. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.

DROP RENDERER

DROP RENDERER <name>;

Scheitert, solange noch ein FIELD darauf verweist. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

DROP ROLE

DROP ROLE <name>;

Entfernt auch alle Zuweisungen dieser Rolle. Wird blockiert, solange ein NOT NULL-ROLE-Feld noch darauf verweist; ein nullbares springt stattdessen auf NULL. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

DROP SCRIPT

DROP SCRIPT <name>;

Scheitert, solange noch irgendeine Bindung (Trigger, USES, Label-/Condition-Script, ...) existiert. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

DROP SPACE

DROP SPACE <name> [CASCADE];

CASCADE ist Pflicht, sobald noch Beziehungen auf/von diesem Space existieren. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 4.

DROP TAG

DROP TAG <name>;

Löscht ein Tag endgültig, samt aller Zuweisungen — keine "wird noch verwendet"-Sperre. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

DROP USER

DROP USER <name>;

Endgültig, anders als ALTER USER ... DISABLE — Rollenzuweisungen gehen verloren, hochgeladene Docs bleiben (Uploader-Referenz wird leer). Verweigert das eigene Konto und den letzten aktivierten Admin. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

DROP VIEW

DROP VIEW <name>;

Ein Menüeintrag, der darauf verweist, verliert nur seine Bindung (view_id wird NULL), scheitert nicht. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.

DROP WORKFLOW

DROP WORKFLOW <name>;

Erst möglich, wenn an keinem Space mehr gebunden. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 7.


E

ENABLE SCRIPT

ENABLE SCRIPT <name> ON <space> AS TRIGGER BEFORE|AFTER CREATE|UPDATE|DELETE;
ENABLE SCRIPT <name> ON <space> AS RUNNABLE;
ENABLE SCRIPT <name> ON <space> AS FLOW CONDITION;
ENABLE SCRIPT <name> ON <space> AS RUN CONDITION;
ENABLE SCRIPT <name> ON WORKFLOW <workflow>;
ENABLE SCRIPT <name> AS GLOBAL TRIGGER BEFORE CREATE|UPDATE|DELETE|DOC UPLOAD|DOC DELETE;
ENABLE SCRIPT <name> AS GLOBAL FLOW CONDITION;
ENABLE SCRIPT <name> AS GLOBAL RUN CONDITION;

Gegenstück zu DISABLE SCRIPT. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

EXPORT DATA

EXPORT DATA;

Schreibt einen vollständigen Datenexport (alle Datensätze, Entry Store) nach exportDirectory. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 14.

EXPORT STRUCTURE

EXPORT STRUCTURE;

Schreibt eine Liste normaler Konsolenbefehle, die die aktuelle Struktur (Spaces, Rollen, Scripte, Workflows, ...) auf einem leeren System nachbauen. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 14.


F

FLOW

FLOW <übergang> ON <space> WITH ID = <id>;

Führt einen Workflow-Übergang aus, sofern der aktuelle Zustand zu dessen FROM-Zustand passt. Berechtigung: flow · Modus: stapelbar · Mehr: Kapitel 7.

FLUSH MAIL QUEUE

FLUSH MAIL QUEUE [WITH ID = <id>];

Erzwingt einen sofortigen Zustellversuch, ohne auf die reguläre Wiederholungsplanung zu warten. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.


G

GET

GET <schlüsselteil>[,<schlüsselteil>, ...];

Liest einen Wert aus dem Entry Store; ein fehlender Schlüssel ist ein Fehler, kein leeres Ergebnis. Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 8.

GET DOC ATTR

GET DOC ATTR <schlüssel> ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];

Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.

GET RELATION DETAIL

GET RELATION DETAIL ON <space>.<feld> WHERE id = <eigene_id> AND <feld> = <ziel_id>;

Liest den an genau dieser Beziehungsinstanz hängenden Detail-Datensatz (NULL, falls keiner gesetzt ist). Berechtigung: read · Modus: stapelbar · Mehr: Kapitel 6.

GRANT

GRANT READ, CREATE, UPDATE, DELETE, RUN, FLOW, UPLOAD, DOWNLOAD, LOGS ON <space> TO <rolle>;

Vergibt eine oder mehrere der neun Space-Rechte an eine Rolle (mehrere kommagetrennt). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

GUARD SCRIPTS

GUARD SCRIPTS;

Schaltet die Admin-Sperre gegen implizites Script-Feuern für die aktuelle Sitzung wieder ein — egal ob vorher SUSPEND SCRIPTS oder ALLOW SCRIPTS aktiv war. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.


I

IMPORT DATA

IMPORT DATA '<serverseitiger pfad>';

Spielt einen EXPORT DATA-Export wieder ein — als eine einzige, atomare Operation, ohne Trigger auszulösen, state wird direkt gesetzt. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 14.

INCLUDE RECIPE

INCLUDE RECIPE <name> [WITH <param> = <wort>|'<text>' [, ...]];

Wendet ein Kochbuch-Rezept an: Der Server spielt die Installationsdatei des Rezepts (install.struct.noplm im Verzeichnis des Rezepts unter recipeDirectory) Befehl für Befehl ab, als hättest Du sie getippt. Die WITH-Werte sind die # @param-Parameter des Rezepts; sie werden als Session-Makros ({{name}}, siehe DEFINE) nur für die Dauer dieser Datei gesetzt, Deine eigenen Makros kommen danach zurück und sind für die Datei nicht sichtbar. Jeder deklarierte Parameter muss angegeben werden, ein anderer Name wird abgelehnt. Ein Rezept kann kein weiteres Rezept einbinden. Der Vorgang ist nicht atomar: Strukturbefehle lassen sich nicht zurückrollen, ein Fehler (gemeldet mit Rezept, Nummer und Text des Befehls) lässt das stehen, was davor lief; die gemeinsamen CREATE ... IF NOT EXISTS-Befehle machen eine Wiederholung ungefährlich. Vorhandene Objekte, die IF NOT EXISTS unberührt ließ, erscheinen als % warning:-Zeilen. Jeder Befehl steht wie üblich im Admin-Log. Über die REST-API nicht verfügbar. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 16.

INSERT

INSERT INTO <space> (<feld> = <literal>, ...) [AS $<name>];

Legt einen neuen Datensatz an. AS $name merkt sich die neue ID als Session-Variable. Berechtigung: create · Modus: stapelbar · Mehr: Kapitel 5.


L

LIST ADMIN LOG

LIST ADMIN LOG AT <start>-<ende>;

Serverweites Protokoll jedes struktur-/datenverändernden Admin-Befehls. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 15.

LIST ALL DOCS

LIST ALL DOCS;

Jedes Doc der gesamten Datenbank, unabhängig von read-Rechten. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 12.

LIST ALL DOCS OF

LIST ALL DOCS OF <space>;

Jedes Doc eines Spaces, über alle Datensätze hinweg. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 12.

LIST DEFINES

LIST DEFINES;

Alle Makros dieser Session, nach Namen sortiert (% define: <name>|<wert>). Berechtigung: keine · Modus: stapelbar.

LIST DOC ATTRS OF

LIST DOC ATTRS OF <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];

Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.

LIST DOCS OF

LIST DOCS OF <space> WITH ID = <id>;

Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.

LIST ENTRIES

LIST ENTRIES [<präfix_teil>[,<präfix_teil>, ...]];

Listet Entry-Store-Schlüssel, optional gefiltert auf ein Präfix. Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 8.

LIST FIELD LOG OF

LIST FIELD LOG OF <space> AT <start>-<ende> WITH ID = <id>;

Feldänderungsprotokoll eines einzelnen Datensatzes. Berechtigung: logs · Modus: stapelbar · Mehr: Kapitel 15.

LIST FIELDS OF

LIST FIELDS OF <space>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.

LIST FLOW LOG OF

LIST FLOW LOG OF <space> WITH ID = <id>;

Protokoll der Zustandsübergänge eines Datensatzes. Berechtigung: logs · Modus: stapelbar · Mehr: Kapitel 15.

LIST FLOWS OF WORKFLOW

LIST FLOWS OF WORKFLOW <name> FROM STATE <state>;
LIST FLOWS OF WORKFLOW <name> ON <space> WITH ID = <id>;

Zweite Form zeigt die von einem konkreten Datensatz aus tatsächlich ausführbaren Übergänge. Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 7.

LIST GLOBAL SCRIPTS

LIST GLOBAL SCRIPTS;

Jede AS GLOBAL TRIGGER-Bindung, serverweit. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST GLOBAL CONDITION SCRIPTS

LIST GLOBAL CONDITION SCRIPTS;

Jede AS GLOBAL FLOW|RUN CONDITION-Bindung, serverweit. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST LIBRARIES

LIST LIBRARIES;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST MAIL QUEUE

LIST MAIL QUEUE AT <start>-<ende>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST MENU

LIST MENU;

Der Menübaum, so wie die aufrufende Sitzung ihn nach den Sichtbarkeitsregeln sieht (gefiltert, tote Blätter entfernt). Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 11.

LIST MENU ITEMS

LIST MENU ITEMS;

Der vollständige, ungefilterte Baum. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.

LIST MY ROLES

LIST MY ROLES TOP|ALL;

Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.

LIST PERMISSIONS OF

LIST PERMISSIONS OF <rolle> TOP|ALL;

ALL aggregiert über die gesamte Rollenhierarchie. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST RECIPES

LIST RECIPES;

Die Rezepte in recipeDirectory, nach Name sortiert (% recipe: <name>|<titel>|<beschreibung>). Ein Verzeichnis, dem eine der drei Pflichtdateien (install.struct.noplm, README.md, recipe.json) fehlt, erscheint stattdessen als % warning:-Zeile. Berechtigung: admin · Modus: einreihbar · Über die REST-API nicht verfügbar.

LIST RENDERERS

LIST RENDERERS;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.

LIST ROLES

LIST ROLES;

Alle Rollen des Systems. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST ROLE HIERARCHY

LIST ROLE HIERARCHY TOP|ALL;

Alle Eltern→Kind-Rollenkanten des Systems in einem Befehl (TOP = direkte Kanten, ALL = jede Rolle mit ihrer vollständigen transitiven Unterrollen-Hülle). Eine Zeile % role_hierarchy: <rolle>|<unterrolle> je Paar, sortiert; eine Rolle ohne Unterrollen erzeugt keine Zeile. Die Stapelform von LIST ROLES OF ROLE. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST ROLES OF ROLE

LIST ROLES OF ROLE <rolle> TOP|ALL;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST ROLES OF USER

LIST ROLES OF USER <benutzer> TOP|ALL;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST RUNNABLE SCRIPTS OF

LIST RUNNABLE SCRIPTS OF <space> WITH ID = <id>;

Jedes aktivierte RUNNABLE-Script, geschnitten mit dem Ergebnis des Run-Condition-Scripts, falls eines gesetzt ist. Berechtigung: run · Modus: stapelbar · Mehr: Kapitel 9.

LIST SCRIPTS

LIST SCRIPTS;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST SCRIPTS OF

LIST SCRIPTS OF <space>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST SCRIPTS OF WORKFLOW

LIST SCRIPTS OF WORKFLOW <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

LIST SPACES

LIST SPACES;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.

LIST TAGS

LIST TAGS;

Die komplette Tag-Registry (% tag: <name>|<created_at>), unabhängig davon, woran ein Tag hängt — für die Rückwärtssuche siehe SHOW TAG. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST USERS

LIST USERS;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

LIST VARS

LIST VARS;

Alle aktuell in dieser Sitzung gesetzten $-Variablen. Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 5.

LIST VIEWS

LIST VIEWS;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.

LIST WALKABLE FLOWS OF

LIST WALKABLE FLOWS OF <space> WITH ID = <id>;

Vom aktuellen Zustand aus strukturell mögliche Übergänge, geschnitten mit dem Ergebnis des Flow-Condition-Scripts, falls eines gesetzt ist. Berechtigung: flow · Modus: stapelbar · Mehr: Kapitel 9.

LIST WORKFLOWS

LIST WORKFLOWS;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 7.

LOGIN

LOGIN <benutzername>;

Meldet an — ohne Passwort (siehe Kapitel 2). Funktioniert auch mitten in einer Sitzung zum Identitätswechsel, außer während eine Kette offen ist. Berechtigung: keine · Mehr: Kapitel 2.

LOGOUT

LOGOUT;

Beendet nur die angemeldete Sitzung, nicht die TCP-Verbindung. Berechtigung: keine · Mehr: Kapitel 2.


P

PUT

PUT <schlüsselteil>[,<schlüsselteil>, ...] = '<wert>';

Schreibt einen Wert in den Entry Store (überschreibt stillschweigend). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 8.


Q

QUERY

QUERY <space> [-> <pfad>] [WHERE <where_clause>] [WITH LABELS] [LIMIT <n>];

Liest Datensätze, optional über -> durch Beziehungen hinweg. Das Ergebnis beginnt immer mit der id-Spalte des Wurzel-Datensatzes, egal ob <pfad> sie aufzählt. Berechtigung: read · Modus: stapelbar · Mehr: Kapitel 5, Kapitel 6.


R

RECOMPUTE ALL LABELS

RECOMPUTE ALL LABELS;

Berechnet das label-Pseudofeld für jeden Space mit Label-Script neu. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.

RECOMPUTE LABELS OF

RECOMPUTE LABELS OF <space>;

Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.

REGISTER UPLOAD

REGISTER UPLOAD FILENAME = '<name>', MIME = '<typ>', CHECKSUM = '<md5>', SIZE = <bytes>;

Registriert eine bevorstehende Datei-Übertragung, liefert eine upload_id + einen Staging-Pfad zurück. Berechtigung: keine · Modus: Autocommit-only · Mehr: Kapitel 12.

REMOVE

REMOVE <schlüsselteil>[,<schlüsselteil>, ...];

Löscht einen Eintrag aus dem Entry Store. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 8.

REMOVE DOC ATTR

REMOVE DOC ATTR <schlüssel> ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];

Berechtigung: upload · Modus: stapelbar · Mehr: Kapitel 12.

RENDER VIEW

RENDER VIEW <name> [EDIT] [WITH <param>=<literal>, ...];

Liefert ein fertiges HTML/JS-Fragment; EDIT für MODE EDIT/CREATE, bei MODE CREATE verpflichtend. Berechtigung: keine eigene (aus den zugrundeliegenden Befehlen abgeleitet) · Modus: Autocommit-only · Mehr: Kapitel 10.

REQUEST DOWNLOAD OF

REQUEST DOWNLOAD OF <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];

Verlinkt eine gespeicherte Datei zum Abholen in den Austauschordner. Berechtigung: download · Modus: Autocommit-only · Mehr: Kapitel 12.

REVOKE

REVOKE READ, CREATE, UPDATE, DELETE, RUN, FLOW, UPLOAD, DOWNLOAD, LOGS ON <space> FROM <rolle>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

ROLLBACK

ROLLBACK;

Verwirft die aktuelle Kette vollständig, nichts davon wird ausgeführt. Berechtigung: keine · Mehr: Kapitel 3.

RUN SCRIPT

RUN SCRIPT <name> ON <space> WITH ID = <id>;

Ruft ein RUNNABLE-gebundenes Script gezielt auf; ein Rückgabewert erscheint als % return:-Block. Berechtigung: run · Modus: stapelbar · Mehr: Kapitel 9.


S

SAVE VIEW

SAVE VIEW <name> WITH <param>=<literal>, ..., <feld>=<literal>, ...,
    <gruppe>=[<id>{<spalte>=<literal>, ...}, <id>, ...];

Führt die ON SAVE-Schritte der View aus. Ein GROUP-Block wird als ein Argument mit dem Namen seines Beziehungsfeldes übergeben: die Liste enthält alle angezeigten Zeilen (daraus ergibt sich, was verknüpft und was gelöst wird), die geschweiften Klammern die Werte der geänderten und neuen Zeilen. Berechtigung: keine eigene (aus den zugrundeliegenden Befehlen abgeleitet) · Modus: Autocommit-only · Mehr: Kapitel 10.

SET DOC ATTR

SET DOC ATTR <schlüssel> = '<wert>' ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];

Berechtigung: upload · Modus: stapelbar · Mehr: Kapitel 12.

SET RELATION DETAIL

SET RELATION DETAIL ON <space>.<feld> WHERE id = <eigene_id> AND <feld> = <ziel_id> TO <literal> | NULL;

Setzt oder löscht den Detail-Datensatz einer einzelnen Beziehungsinstanz, symmetrisch von beiden Seiten aus adressierbar. Berechtigung: update · Modus: stapelbar · Mehr: Kapitel 6.

SHOW LIBRARY

SHOW LIBRARY <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

SHOW ME

SHOW ME;

Zeigt Benutzername, Admin-Status, Aktiviert-Status und Rollen der aufrufenden Sitzung. Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.

SHOW MY PERMISSIONS ON

SHOW MY PERMISSIONS ON <space>;

Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.

SHOW RECIPE

SHOW RECIPE <name> [SOURCE];

Zeigt ein Rezept, bevor Du es anwendest: % name:, % title:, % description:, je ein % param: <name>|<text> pro Parameter und die README als Quellblock % readme:. Mit SOURCE erscheint nur die Installationsdatei als Quellblock % source:. Berechtigung: admin · Modus: einreihbar · Über die REST-API nicht verfügbar.

SHOW RENDERER

SHOW RENDERER <name>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.

SHOW ROLE

SHOW ROLE <rolle>;

Nur die direkt an dieser Rolle vergebenen Rechte, ohne Hierarchie-Aggregation (dafür LIST PERMISSIONS OF ... ALL). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

SHOW SCRIPT

SHOW SCRIPT <name>;

Quelltext und alle Bindungen eines Scripts. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

SHOW SCRIPTS

SHOW SCRIPTS;

Zeigt nur den Script-Modus der aktuellen Sitzung (NORMAL/ SUSPENDED/ALLOWED) — nicht zu verwechseln mit SHOW SCRIPT <name>. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

SHOW SPACE

SHOW SPACE <name>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.

SHOW TAG

SHOW TAG <name>;

Rückwärtssuche: alles, woran dieses Tag hängt (% space:/% field:/% flow:/% workflow:/% state:/% role:). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

SHOW USER

SHOW USER <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

SHOW VAR

SHOW VAR $<name>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 5.

SHOW VIEW

SHOW VIEW <name>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.

SHOW WORKFLOW

SHOW WORKFLOW <name>;

Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 7.

SHUTDOWN

SHUTDOWN;

Fährt den gesamten Server kontrolliert herunter. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 2.

SUSPEND SCRIPTS

SUSPEND SCRIPTS;

Schaltet für die aktuelle Sitzung die Admin-Sperre gegen implizite Script-Auslösung aus — das Script wird übersprungen, nicht ausgeführt. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.


T

TAG

TAG SPACE <space> WITH <tag>;
TAG FIELD <space>.<feld> WITH <tag>;
TAG FLOW <workflow>.<flow> WITH <tag>;
TAG WORKFLOW <workflow> WITH <tag>;
TAG STATE <workflow>.<state> WITH <tag>;
TAG ROLE <rolle> WITH <tag>;

Vergibt ein bestehendes Tag an eines der sechs Objektarten (gepunktete Adressierung wie bei SET RELATION DETAIL, Kapitel 6). Idempotent — ein bereits vergebenes Tag erneut zu vergeben ist ein folgenloser Erfolg. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.


U

UNASSIGN ROLE

UNASSIGN ROLE <rolle> FROM USER <benutzer>;
UNASSIGN ROLE <kind> FROM ROLE <eltern>;
UNASSIGN ROLE <rolle> FROM MENU ITEM <name>;

Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13, Kapitel 11.

UNDEFINE

UNDEFINE <name>;

Entfernt ein Session-Makro (siehe DEFINE); Fehler, wenn es nicht gesetzt ist. Berechtigung: keine · Modus: Autocommit-only.

UNTAG

UNTAG SPACE <space> WITH <tag>;
UNTAG FIELD <space>.<feld> WITH <tag>;
UNTAG FLOW <workflow>.<flow> WITH <tag>;
UNTAG WORKFLOW <workflow> WITH <tag>;
UNTAG STATE <workflow>.<state> WITH <tag>;
UNTAG ROLE <rolle> WITH <tag>;

Entfernt eine Tag-Zuweisung wieder — idempotent, ein nie vergebenes Tag zu entfernen ist ebenfalls ein folgenloser Erfolg. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.

UPDATE

UPDATE <space> [-> <hop> ...] [-> ^] SET <feld> = <literal> | [<id>, ...] | NULL, ... WHERE <where_clause>;

Ändert jeden zum WHERE passenden Datensatz; die Klammerform setzt die gesamte Mitgliedschaft einer virtuellen Beziehungsseite. Eine optionale -> <hop>-Kette folgt ONE-seitigen Beziehungen von der Wurzel (das WHERE verankert weiter auf der Wurzel) und wendet SET auf den am Ende erreichten Datensatz an — eine leere Relation unterwegs ist ein No-Op, ein MANY-Hop wird abgelehnt; Berechtigung dann update auf dem Endpunkt, read auf dem Pfad. Ein abschließendes -> ^ pivotiert in den DETAIL SPACE des letzten Hops und schreibt den Detail-Datensatz des Paars (legt einen aus den SET-Zuweisungen an, falls noch keiner existiert) — der letzte Hop muss eine ONE-seitige Relation mit DETAIL SPACE sein; braucht update und create auf dem Detail-Space. Berechtigung: update · Modus: stapelbar · Mehr: Kapitel 5, Kapitel 6.


V

VERIFY ALL DOCS

VERIFY ALL DOCS;

Wie VERIFY DOCS OF, zusätzlich mit umgekehrtem Abgleich: Dateien ohne passende Zeile werden ebenfalls entfernt. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 12.

VERIFY DOCS OF

VERIFY DOCS OF <space>;

Gleicht _meta_docs-Zeilen gegen tatsächlich vorhandene Dateien ab und räumt Inkonsistenzen auf. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 12.