noPLM Dokumentation

Vollständige Fassung · 7 Teile · 17 Kapitel

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; geplant ist außerdem eine REST-API, deren Protokoll sich weitgehend an den Konsolenbefehlen orientiert. 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|name|STRING|false|
@ column: 2|reliable|BOOL|false|
@ rows: 1
% 'Frischdienst Müller' | true
*

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. Ein BEGIN ist also kein Ort zum Nachdenken — Befehle vorbereiten, dann zügig COMMIT oder ROLLBACK.

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 REFERENCE 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/JSONUNIQUE 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.

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|REFERENCE|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, \' zum Escapen 'Mehl', 'Sie sagte \'Hallo\''
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

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.

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%'

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.

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|name|STRING|false|
@ column: 2|unit|STRING|false|
@ column: 3|supplier|RELATION|true|ingredient_supplier
@ rows: 1
% 'Safran' | 'g' | supplier:4
*

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|name|STRING|false|
@ column: 2|unit|STRING|false|
@ column: 3|supplier|RELATION|true|ingredient_supplier
@ rows: 1
@ truncated: true
% '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 REFERENCE 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 REFERENCE 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 ansprechbarSET 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.

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|name|STRING|false|
@ column: 2|supplier.name|STRING|false|
@ rows: 1
% 'Safran' | 'Feinkost Vogel'
*

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|2026-01-15T09:00:00Z|Kitchen Workflow
*
noplm> SHOW WORKFLOW kitchen;
% workflow: kitchen|Kitchen Workflow
% 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 (1 row(s))
*
GET kitchen, name;
% value: Küchenteam Nord
*
REMOVE kitchen, name;
# OK (1 row(s))
*

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 OK (0 row(s)).

Beachte das Antwortformat: PUT/REMOVE melden OK (N row(s)) als einfache Kommentarzeile, nicht das aus Kapitel 5 bekannte @ rows: N von UPDATE/DELETE — der Entry Store ist ein eigener, älterer Codepfad mit eigener Konvention.

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
Script-View-Script CREATE VIEW ... AS SINGLE SCRIPT berechnet Felder/speichert eine ganze View

Die letzten beiden gehören inhaltlich zu Views und werden 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) 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. SUSPEND SCRIPTS;/RESUME SCRIPTS; schalten diese Sperre für die aktuelle Sitzung ab bzw. wieder an — zurückgesetzt bei jedem LOGIN:

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
*
RESUME SCRIPTS;
# OK: scripts resumed for this session
*

SHOW SCRIPTS; (admin-only) verrät, ob die aktuelle Sitzung diesen Zustand gerade aktiv hat: % suspended: true|false. 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ückwirkendRECOMPUTE 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/RESUME SCRIPTS ausgenommen — es gibt schlicht nichts zu schützen.

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.

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.
  • 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 SUSPEND SCRIPTS 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 SUSPEND SCRIPTS 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 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 SUSPEND SCRIPTS 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][0], unit: rows[i][1], supplier: rows[i][2] });
}
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.

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
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.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.referenceChanges ✓ (auf der virtuellen Seite einer Beziehung)
ctx.allRunnables/ctx.allFlows

Jeder Record trägt außerdem sieben implizite "Pseudo-Felder" neben id/state/labelcreator/creationTime/owner/ responsibleRole/deputyOwner/lastUpdated/lastUpdatedBy (siehe Kapitel 4). Die vier 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.

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.

Es gibt zwei grundverschiedene Bauarten:

  • Simple View — deklarativ: eine QUERY, benannte FIELD-Slots, optionale Speicherschritte. Deckt den Normalfall ab, ganz ohne Script.
  • Script View — ein JavaScript, das die Felder und das Speichern selbst berechnet. Für alles, was eine statische Deklaration nicht ausdrücken kann (z. B. ein dynamisch berechneter Vorgabewert).

Beide teilen sich dieselbe Ausführung (RENDER VIEW/SAVE VIEW) und denselben Renderer-Mechanismus.

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(); }
;

Drei Funktionen, immer alle drei: renderView 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.

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 RECORD_ID = 3;        -- nur lesen
RENDER VIEW ingredient_edit EDIT WITH RECORD_ID = 3;   -- lesen + bearbeiten
SAVE VIEW ingredient_edit WITH RECORD_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 drei rein informative Hinweise tragen — SHOW WORKFLOW/SHOW DOCS/SHOW LOG —, die einem Client sagen, ob er zusätzlich Workflow-Status, Dokumentenanhänge (Kapitel 12) oder das Änderungsprotokoll (Kapitel 15) anzeigen soll. Der Server prüft dabei nicht nach, ob das überhaupt zutrifft (Workflow vorhanden, Rechte ausreichend) — das bleibt Aufgabe des Clients.

10.3 Simple View

CREATE RENDERER supplier_picker FOR REFERENCE
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
PARAM RECORD_ID AS INT
QUERY QUERY ingredient -> (name, unit, supplier) WHERE id = $RECORD_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 = $RECORD_ID
;

Fünf Zeilenarten bauen eine Simple View auf, jede höchstens einmal außer FIELD/PARAM/ON SAVE:

  • PARAM <name> AS <typ> — ein typisierter Eingabewert (einer der sechs Skalartypen, nie eine Beziehung), später per $name in QUERY/ON SAVE einsetzbar.
  • QUERY <konsolenbefehl> — höchstens einmal, der Rest der Zeile wird roh gespeichert (inklusive $PARAM-Platzhaltern) und erst beim tatsächlichen RENDER VIEW mit echten Werten ersetzt und geparst. In MODE CREATE komplett verboten — es gibt ja noch keinen Datensatz.
  • FIELD <name> [SOURCE <spalte>] [RENDERER <name> [WITH ...]] [READONLY] — bindet einen Anzeige-Slot an eine Spalte der QUERY (Name oder 1-basierte Position); ohne SOURCE wird der Feldname selbst als Spaltenname angenommen. Nur in MODE VIEW/EDIT gültig.
  • FIELD <name> AS <typ> [DEFAULT <literal>] [READONLY] — die Alternative für MODE CREATE: kein Bezug zu einer Query, stattdessen ein eigener Typ und ein optionaler, statischer Vorgabewert.

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, in Eintippreihenfolge ausgeführt, dürfen $PARAM und $FIELD referenzieren. 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 = $RECORD_ID AND unit != '';
ALTER VIEW ingredient_edit ADD FIELD note SOURCE 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 = $RECORD_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
ALTER VIEW ingredient_edit UNSET SHOW WORKFLOW;

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.4 Script View

Eine Simple View stößt an ihre Grenzen, sobald ein Feldwert berechnet werden muss, statt einfach aus einer Spalte zu kommen — zum Beispiel, weil er von mehreren Abfragen abhängt oder ein MODE CREATE-Vorgabewert nicht statisch, sondern dynamisch sein soll. Dafür gibt es die Script View: statt QUERY/FIELD/ON SAVE definierst Du zwei JS-Funktionen.

CREATE VIEW recipe_edit AS SINGLE SCRIPT
MODE EDIT
PARAM RECORD_ID AS INT
function fields(ctx) {
    var rows = ctx.exec("QUERY recipe -> (name, servings, ingredients) WHERE id = " + ctx.params.RECORD_ID + " WITH LABELS").data;
    var parts = rows[0].split(' | ');
    var name = parts[0].replace(/^'|'$/g, '');
    var servings = parseInt(parts[1], 10);

    // parts[2] sieht z.B. so aus: ingredient:[3,7('Saffron')]
    // -- jede referenzierte Zutat, mit Label, falls vorhanden.
    var ingredientList = [];
    var matches = parts[2].match(/(\d+)(\('([^']*)'\))?/g) || [];
    matches.forEach(function(m) {
        var mm = m.match(/(\d+)(\('([^']*)'\))?/);
        ingredientList.push({ id: parseInt(mm[1], 10), label: mm[3] || null });
    });

    return [
        { name: 'name', type: 'STRING', value: name },
        { name: 'servings', type: 'INT', value: servings },
        { name: 'ingredients', type: 'REFERENCE LIST', value: ingredientList, renderer: '__default_reference_list__' }
    ];
}
function save(ctx, values) {
    ctx.update('recipe', ctx.params.RECORD_ID, { name: values.name, servings: values.servings });
}
;
  • fields(ctx) ist immer Pflicht, liefert ein Array aus {name, type, value, renderer, rendererParams, readOnly}-Beschreibungen — genau hier liegt der Mehrwert gegenüber einer Simple View: value kann aus einer beliebigen Berechnung stammen, nicht nur aus einer Spalte. Das Beispiel oben zeigt genau das aus der Gliederung angekündigte Muster "Labels bei MANY-Relationen": Der rohe QUERY-Text liefert die Beziehung bereits als <space>:[id,id('Label'), ...] (siehe Kapitel 6.4), und das Script zerlegt das per Hand in eine Liste aus {id, label} — exakt die Form, die der eingebaute MANY-Beziehungs-Renderer erwartet, um in der Bearbeitungsansicht die Namen statt nackter IDs zu zeigen. readOnly ist das Skript-View-Gegenstück zum FIELD-Suffix READONLY einer Simple View — bei jedem Aufruf neu berechenbar (z. B. abhängig von ctx.userRoles), standardmäßig false.
  • save(ctx, values) ist Pflicht außer in MODE VIEW. values sind die per SAVE VIEW ... WITH ... übergebenen, nativen JS-Werte — anders als bei einer Simple View gibt es keine statische FIELD-Liste, gegen die geprüft wird; jeder übergebene Name landet einfach in values. ctx.create()/ctx.update() sind typisierte Hilfsfunktionen über denselben INSERT/UPDATE-Weg, den auch eine Simple Views ON SAVE benutzt — normale Trigger, normales Field Log.
  • Kleineres ctx als bei einem Trigger-Script: ctx.params, ctx.mode, ctx.editing, ctx.user.name, ctx.userRoles, ctx.now(), ctx.query(), ctx.get(), ctx.getAll() — immer vorhanden. ctx.create()/ ctx.update() existieren nur innerhalb von save(ctx, values), in fields(ctx) sind sie schlicht nicht gesetzt (ein Aufruf dort scheitert wie ein Aufruf einer nicht existierenden Funktion).
  • Rechteprüfung pro Aufruf, nicht vorab — jeder ctx.query()/ ctx.create()/ctx.update()-Aufruf prüft die tatsächliche Berechtigung des aufrufenden Nutzers für sich, genau wie ein handgetippter Befehl. Anders als bei einer Simple View gibt es daher keine vorab laufende Berechtigungsprüfung beim reinen RENDER VIEW EDIT — die Sicherheit sitzt dort, wo tatsächlich geschrieben wird.

ALTER VIEW recipe_edit SET SCRIPT ...; tauscht den Code wie gehabt an Ort und Stelle aus, MODE/PARAM/SHOW stehen bei einer Script View direkt auf der Kopfzeile (nicht als eigene Zeilen im Rumpf) — der Rumpf ist reines JavaScript, das sonst versehentlich mit Zeilen wie mode ... kollidieren könnte.

Eine MODE CREATE-View braucht keine QUERY — sie legt einen neuen Datensatz an, statt einen vorhandenen zu zeigen:

CREATE VIEW ingredient_new AS SINGLE
MODE CREATE
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 VIEWCREATE VIEW ingredient_list AS LIST ... —, dieselbe Simple-View-Syntax, nur für eine ganze Ergebnisliste statt eines einzelnen Datensatzes; MODE/SHOW WORKFLOW/SHOW DOCS/SHOW LOG gibt es dort nicht, da diese sich auf genau einen Datensatz beziehen. Eine Script View kennt dagegen nur die SINGLE-Form.

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 SCRIPTkeine 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;

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. VERIFY ALL DOCS durchsucht zusätzlich das komplette Speicherverzeichnis nach Dateien ohne zugehörige Datenbankzeile.

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 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 USERS/SHOW USER/SHOW ROLE/LIST ROLES OF/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: Export & Import

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:

  • 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 sieben 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 dagegen werden nie aus der Datei wiederhergestellt — IMPORT DATA selbst zählt als Änderung, beide 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.

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.

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/RESUME 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.

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:

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 (1 row(s))

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 (drafttestedapproved, 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: simple_acls: none of your roles/tags permit "create" on 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: simple_acls: none of your roles/tags permit "delete" on 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 Ausnahmelistetest/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 (1 row(s))

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. 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)
reference 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: field_constraints: field "unit" must be one of: g, ml, pcs (got "kg")

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

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

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: field_constraints: field "servings" must be >= 1 (got 0)
INSERT INTO recipe (name = 'Y', servings = 4, ingredients = []);

! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: field_constraints: field "ingredients" must reference at least 1 record(s) (got 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: field_constraints: field "checked_at" must not be in the future

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-Constraintsjson-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 (1 row(s))

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 (1 row(s))

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: owner_acls: you are not the owner, deputy, or responsible-role holder of this record on 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 Ausnahmelistetest 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: owner_acls: you are not the owner, deputy, or responsible-role holder of this record on 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, UNIQUE (picklist, value))picklist ist der Gruppenname (z. B. 'recipe_difficulty'), value ein Eintrag darin. 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.value;), zugewiesen über ALTER SPACE picklists SET LABEL SCRIPT .... Jedes Relationsfeld, das auf picklists zeigt, zeigt damit den Wertetext des Eintrags als sein Label an statt einer nackten ID — die eingebauten Referenz-Renderer zeigen ohnehin schon das Label des verlinkten Records an, siehe 11.

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
    REFERENCE 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|name|STRING|false|
@ column: 2|difficulty|RELATION|true|recipe_difficulty
@ rows: 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-Referenz-Renderers (11) respektiert die eigene WHERE-Klausel eines Relationsfelds ebenfalls schon von selbst — eine SIMPLE VIEW/SCRIPT 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 SIMPLE VIEW/ SCRIPT 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:

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 (1 row(s))

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 INITIALtestedapproved ü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 (1 row(s))

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: state_acls: state "approved" on space "recipe" does not permit "update" for your roles/tags
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: state_acls: state "approved" on space "recipe" does not permit "delete" for your roles/tags
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 Workflowctx.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:

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

# OK (1 row(s))

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 (1 row(s))

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: field_acls: field "difficulty" on space "recipe" may not be changed by your roles/tags

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: field_acls: field "difficulty" on space "recipe" may not be changed by your roles/tags

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: field_acls: field "price" on space "recipe" may not be changed by your roles/tags
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.

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 ;.

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

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> | NULL;
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 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> SET SCRIPT
function fields(ctx) { ... }
function save(ctx, values) { ... }
;
ALTER VIEW <name> ADD FIELD <feld> [SOURCE <spalte>] [RENDERER <name> [WITH ...]];
ALTER VIEW <name> DROP FIELD <feld>;
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> DEFAULT <literal> | NULL;
ALTER VIEW <name> ADD ON SAVE <UPDATE- oder INSERT-Befehl>;
ALTER VIEW <name> DROP ON SAVE <laufende_nummer>;

SET SCRIPT nur für eine Script View, SET/DROP QUERY/ADD/DROP FIELD/ALTER FIELD ... DEFAULT nur für eine Simple View. 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 <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 <name> FOR STRING|TEXT|INT|FLOAT|BOOL|DATETIME|JSON|USER|ROLE|REFERENCE|REFERENCE 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 <name> [PRIORITY <zahl>] [USES <library>, ...]
<JS-Code, automatisch in function script(ctx) { ... } eingebettet>
;

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> REFERENCE <space> ONE|MANY [WHERE <where_clause>]
           AS <inverses_feld> ONE|MANY [WHERE <where_clause>]
           [WITH DETAIL <space>] [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
    [MODE VIEW|EDIT|CREATE] [SHOW WORKFLOW] [SHOW DOCS] [SHOW LOG]
    [PARAM <name> AS <typ>]
    [QUERY <konsolenbefehl>]
    FIELD <name> [SOURCE <spalte>] [RENDERER <name> [WITH ...]]
    | FIELD <name> AS <typ> [DEFAULT <literal>]
    [ON SAVE <UPDATE- oder INSERT-Befehl>]
    ...
;

CREATE VIEW <name> AS SINGLE SCRIPT
    [MODE VIEW|EDIT|CREATE] [PARAM <name> AS <typ>] [SHOW WORKFLOW] [SHOW DOCS] [SHOW LOG]
function fields(ctx) { ... }
function save(ctx, values) { ... }
;

Simple View (deklarativ) oder Script View (fields(ctx)/save(ctx, values)), SINGLE (ein Datensatz) oder LIST (nur Simple View, keine Script View). 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

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.


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.

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 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 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 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. 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.

RESUME SCRIPTS

RESUME SCRIPTS;

Schaltet die Admin-Sperre für die aktuelle Sitzung wieder ein. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.

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>, ...;

Führt die ON SAVE-Schritte (Simple View) bzw. save(ctx, values) (Script View) aus. 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 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, ob die aktuelle Sitzung gerade SUSPEND SCRIPTS aktiv hat — 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. Berechtigung: admin · Modus: stapelbar · 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.

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> SET <feld> = <literal> | [<id>, ...] | NULL, ... WHERE <where_clause>;

Ändert jeden zum WHERE passenden Datensatz; die Klammerform setzt die gesamte Mitgliedschaft einer virtuellen Beziehungsseite. 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.