Vollständige Fassung · 7 Teile · 17 Kapitel
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
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.
Die Dokumentation ist in sieben Teile gegliedert, die aufeinander aufbauen:
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.
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:
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
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.
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:
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.
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.
$ ./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.
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
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.
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:
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.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.
"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.
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
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.
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.
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 DESCRIPTIONZwei 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.
UNIQUEEin Feld kann außerdem als UNIQUE markiert werden — noPLM lehnt dann
jedes INSERT/UPDATE ab, das zwei Datensätzen denselben,
nicht-NULL-Wert in diesem Feld geben würde:
CREATE SPACE ingredient (
name STRING NOT NULL,
sku STRING UNIQUE
);
INSERT INTO ingredient (name = 'Mehl', sku = 'ZUT-001');
INSERT INTO ingredient (name = 'Zucker', sku = 'ZUT-001'); -- abgelehnt: sku schon vergeben
INSERT INTO ingredient (name = 'Salz'); -- ok: sku bleibt leer
INSERT INTO ingredient (name = 'Pfeffer'); -- auch ok: mehrere Datensätze
-- dürfen alle ein leeres sku haben
Das ist unabhängig von NOT NULL — ein nullable UNIQUE-Feld erlaubt
beliebig vielen Datensätzen, es leer zu lassen; nur die, die tatsächlich
einen Wert setzen, müssen sich alle unterscheiden. Es funktioniert nur
für Felder mit einem vergleichbaren, begrenzbaren Wert (also nicht
TEXT/JSON — UNIQUE wird für diese beiden Typen von noPLM
grundsätzlich abgelehnt).
Nachträglich setzen oder entfernen, ohne den Space neu anzulegen:
ALTER SPACE ingredient ALTER FIELD sku UNIQUE;
ALTER SPACE ingredient ALTER FIELD sku NOT UNIQUE;
Ein bestehendes Feld auf UNIQUE zu setzen schlägt mit einer klaren
Fehlermeldung fehl, wenn bereits doppelte Werte in den Daten stecken —
die müssen erst bereinigt werden, dann erneut versuchen.
AUTONUMBER: automatisch erzeugte CodesManchmal 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.AUTONUMBER-Feld ist immer automatisch NOT NULL und UNIQUE
— das explizit dazuzuschreiben ist sogar ein Fehler, ebenso ein
DEFAULT.INSERT noch UPDATE
dürfen ihm einen eigenen Wert geben (auch nicht als Admin) — der Wert
entsteht ausschließlich automatisch beim Anlegen des Datensatzes.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.text
ALTER SPACE ingredient ALTER FIELD code AUTONUMBER('ZUTAT-', 6);
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.id, created_at, modified_atJeder 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).
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.
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;.
LIST SPACES / SHOW SPACE / LIST FIELDS OFnoplm> 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
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).
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 |
INSERTINSERT 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.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;
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.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.
UPDATEUPDATE 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.
DELETEDELETE 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-BedingungenEine 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.
QUERYQUERY 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
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.
ONE/MANY und MANY:MANYEine 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:MANYManche 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.
"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:
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.SET RELATION DETAIL
ON ingredient.recipes WHERE id = 3 AND recipes = 2 TO $qty; trifft
genau dieselbe Verknüpfung.GET auf eine Verknüpfung ohne gesetztes Detail liefert %
detail: mit leerem Wert, keinen Fehler.PUT/GET im Entry Store
(Kapitel 8).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.WHEREEin 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:
reliable wird nachträglich auf false
gesetzt), werden bereits bestehende Verknüpfungen nicht
rückwirkend geprüft oder gar automatisch gelöst.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.WHERE-Klausel wie in
Kapitel 5 — inklusive
->-Hops (Abschnitt 6.4), falls die Bedingung über mehrere
Beziehungen hinweg prüfen soll.SUSPEND SCRIPTS
umgehen kann, gibt es hier keine Ausnahme.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
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.
STATEs und FLOWsEin 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.
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).
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).
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
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 / REMOVEPUT 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 ENTRIESnoplm> 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).
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.
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
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.
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.
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.
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
*
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.
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 CodeEine 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;
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
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.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.RECOMPUTE LABELS OF <space>;/RECOMPUTE ALL LABELS; sind die
manuellen Nachzieh-Befehle dafür.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.BEFORE (inkl. ctx.patch) und AFTEREin 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.
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.
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.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.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).SUSPEND SCRIPTS gilt dasselbe wie in 9.2
beschrieben.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;
BEFORE — ein globales AFTER gibt es nicht. Eine globale
Bindung ist als überall geltendes Validierungs-Gate gedacht, nicht
als Ort für systemweite Seiteneffekte.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.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.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.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;
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.
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).
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).
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.
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.
ctx-Objekt im ÜberblickNicht 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/label — creator/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
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:
QUERY, benannte FIELD-Slots,
optionale Speicherschritte. Deckt den Normalfall ab, ganz ohne
Script.Beide teilen sich dieselbe Ausführung (RENDER VIEW/SAVE VIEW) und
denselben Renderer-Mechanismus.
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.
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.
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.
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.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).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 VIEW — CREATE 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.
LIST VIEWS;
SHOW VIEW ingredient_edit;
LIST RENDERERS;
SHOW RENDERER text_short;
Alle vier brauchen — anders als LIST SCRIPTS/SHOW SCRIPT — keine
besondere Berechtigung: Eine View/ein Renderer ist zur Nutzung durch
normale Anwender gedacht, nicht nur für Admins interessant. Löschen und
Umbenennen funktionieren wie bei einem Script:
DROP VIEW ingredient_edit;
DROP RENDERER text_short;
ALTER RENDERER text_short RENAME TO text_short_v2;
Kapitel 11
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.
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.
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.
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.
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
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).
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.
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.
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.
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.
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).
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.
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
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".
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:
CREATE
USER ... ADMIN, ALTER USER ... SET ADMIN, ALTER USER ...
ENABLE sind uneingeschränkt, auch auf die eigene Sitzung angewendet.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) |
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.
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.
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
Drei admin-only, Autocommit-only Befehle sichern und stellen den kompletten Datenbestand wieder her — ohne externes Backup-Werkzeug, direkt über die Konsole.
EXPORT STRUCTUREEXPORT 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 DATAEXPORT 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 DATAIMPORT 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:
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.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.
Auf einem frischen, leeren System:
....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.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
Drei unabhängige, unveränderliche Protokolle zeichnen auf, was in noPLM passiert ist — jedes mit eigenem Zuständigkeitsbereich, eigener Berechtigung.
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.
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.
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.
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
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.
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.
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:
noplm, <rezept-id>, <space>noplm_<rezept-id>_<baustein>Für dieses Rezept ist <rezept-id> gleich simple_acls.
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.
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.Weiter im laufenden Küchen-Szenario: recipe trägt den Workflow
kitchen (draft → tested → approved, Flows test/revise/
approve — siehe Datenmodelloverview.md), die Rollen cook,
head_chef und eine dritte Rolle, sous_chef, haben alle GRANT
CREATE, READ, UPDATE, DELETE, RUN, FLOW ON recipe — das Rezept engt
das jetzt weiter ein, gemäß der Config oben. cook_bob hält cook,
head_chef_maria hält head_chef, sous_chef_erik hält sous_chef,
und intern_max nur eine vierte Rolle, intern (mit eigenständigem
GRANT CREATE ON recipe, aber ohne Nennung in der simple_acls-Config).
head_chef und sous_chef tragen beide den Tag approver (TAG ROLE
head_chef WITH approver;, TAG ROLE sous_chef WITH approver;) — zwei
ansonsten unabhängige Rollen mit demselben Tag, genau darum geht's.
Der Flow approve selbst trägt den Tag critical (TAG FLOW
kitchen.approve WITH critical;):
INSERT INTO recipe (name = 'Kartoffelsuppe'); -- als cook_bob
% id: 1
INSERT INTO recipe (name = 'Geheimrezept'); -- als intern_max
! ROLLBACK: command ('INSERT INTO recipe (name = 'Geheimrezept');'): script 'noplm_simple_acls_before' on space 'recipe' aborted: 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 Ausnahmeliste — test/revise tauchen
dort gar nicht auf und bleiben frei:
FLOW test ON recipe WITH ID = 2; -- als cook_bob
# OK: flow 'test' executed
approve ist sowohl über eine byName-Regel (Rolle head_chef) als
auch, weil der Flow selbst den Tag critical trägt, über eine
byTag-Regel (Tag approver) beschränkt — beide müssen gleichzeitig
zutreffen:
FLOW approve ON recipe WITH ID = 2; -- als sous_chef_erik
! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 2;'): flow 'approve' is not permitted for record 2 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 2; -- als head_chef_maria
# OK: flow 'approve' executed
sous_chef_erik scheitert trotz approver-Tag — die byName-Regel
verlangt konkret die Rolle head_chef, und da beide Regeln zutreffen
müssen (UND), reicht der Tag allein nicht. head_chef_maria hält
sowohl die Rolle (für byName) als auch, über head_chefs eigenen
Tag, den Tag (für byTag) — beide Regeln bestehen. Dasselbe Prinzip
bei RUN SCRIPT:
RUN SCRIPT print_shopping_list ON recipe WITH ID = 2; -- als cook_bob, nicht in der Config erwähnt
# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 2; -- als head_chef_maria
# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;
GRANT bereits erlaubt,
nie mehr freigeben. Ein Space ohne die passenden GRANTs bleibt
gesperrt, egal was in der simple_acls-Config steht.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.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.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.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.
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 |
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.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.
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
json-Regeln prüfen
nur die Rohtext-Länge, keine Schema-Konformität. Eine echte
JSON-Schema-Validierung wäre ein eigenständiges, deutlich größeres
Feature._pairs nur für einfache Zwei-Felder-Vergleiche — siehe oben,
alles Komplexere ist bewusst Sache eines selbstgeschriebenen
BEFORE-Skripts.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.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").
Wie in 16.1: Entry-Key noplm, owner_acls, <space>, Skriptnamen
noplm_owner_acls_<baustein>.
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.
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.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
Ausnahmeliste — test taucht dort nicht auf und bleibt frei, auch für
intern_max:
FLOW test ON recipe WITH ID = 1; -- als intern_max
# OK: flow 'test' executed
approve ist dagegen owner-gated:
FLOW approve ON recipe WITH ID = 1; -- als intern_max
! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 1;'): flow 'approve' is not permitted for record 1 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 1; -- als head_chef_maria (Rolle passt)
# OK: flow 'approve' executed
Dasselbe Prinzip bei RUN SCRIPT: print_shopping_list ist nicht
genannt und bleibt frei, reset_recipe ist owner-gated, aber die
Deputy-Owner-Zuordnung greift auch hier:
RUN SCRIPT print_shopping_list ON recipe WITH ID = 1; -- als intern_max, nicht in der Config erwähnt
# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 1; -- als sous_chef_lea (deputyOwner)
# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;
DELETE schließlich, wieder nur für Owner/Deputy/responsibleRole:
DELETE FROM recipe WHERE id = 1; -- als intern_max
! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_owner_acls_before' on space 'recipe' aborted: 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
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.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.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.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.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.
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.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.
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.
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>.picklists (eine SIMPLE VIEW/
SCRIPT VIEW) bleibt Sache des Betreibers, wie bei jedem anderen
Space auch.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.UNIQUE-Constraint — 'Offen' und 'offen' sind
zwei verschiedene Einträge innerhalb derselben Picklist, genau wie
bei jedem anderen STRING-Vergleich in noPLM.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).
Wie in 16.1: Entry-Key noplm, state_acls, <space>, Skriptnamen
noplm_state_acls_<baustein>.
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.
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.Weiter im Küchen-Szenario: recipe trägt wieder den Workflow kitchen
(Zustände INITIAL→tested→approved über die Flows
test/revise/approve, siehe Kapitel 7), und
cook, head_chef und sous_chef haben alle volles GRANT CREATE,
READ, UPDATE, DELETE, RUN, FLOW ON recipe. head_chef und sous_chef
tragen beide den Tag approver; Zustand tested trägt den Tag
pending_review, Zustand approved trägt den Tag locked. cook_bob
legt einen Datensatz an und bringt ihn nach tested — beides noch
unbeschränkt, da die state_acls-Config für recipe erst danach
gesetzt wird:
INSERT INTO recipe (name = 'Kartoffelsuppe'); -- als cook_bob
% id: 1
FLOW test ON recipe WITH ID = 1; -- als cook_bob
# OK: flow 'test' executed
PUT noplm, state_acls, recipe '{"byName":{"approved":{"update":{"role":["head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}}}},"byTag":{"pending_review":{"flow":{"byName":{"approve":{"role":["head_chef"]}}}}}}';
# OK (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.
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.ctx.state ist dort immer null.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.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.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.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).
Wie in 16.1: Entry-Key noplm, field_acls, <space>, Skriptname
noplm_field_acls_before.
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).
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.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
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.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".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.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.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.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
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).
ALTER LIBRARYALTER 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 ITEMALTER 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 RENDERERALTER 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 SCRIPTALTER 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 SPACEALTER 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 USERALTER 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 VIEWALTER 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 WORKFLOWALTER 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 ROLEASSIGN 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 DOCATTACH 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 SCRIPTATTACH 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.
BEGINBEGIN;
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.
COMMITCOMMIT;
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 LIBRARYCREATE LIBRARY <name>
<JS-Funktionen, kein ctx-Wrapper>
;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
CREATE MENU ITEMCREATE 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 RENDERERCREATE 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 ROLECREATE ROLE <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
CREATE SCRIPTCREATE SCRIPT <name> [PRIORITY <zahl>] [USES <library>, ...]
<JS-Code, automatisch in function script(ctx) { ... } eingebettet>
;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
CREATE SPACECREATE 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 TAGCREATE TAG <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
CREATE USERCREATE USER <name> [ADMIN];
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
CREATE VIEWCREATE 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 WORKFLOWCREATE WORKFLOW <name> [LABEL '<text>'];
Legt automatisch einen ersten Zustand INITIAL an. Berechtigung:
admin · Modus: Autocommit-only · Mehr: Kapitel
7.
DELETEDELETE 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 DOCDELETE 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 QUEUEDELETE MAIL QUEUE WITH ID = <id>;
Entfernt genau einen Eintrag, unabhängig vom Zustand. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
DETACH SCRIPTDETACH 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 SCRIPTDISABLE 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 LIBRARYDROP LIBRARY <name>;
Scheitert, solange ein Script sie noch per USES einbindet.
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel
9.
DROP MENU ITEMDROP MENU ITEM <name>;
Scheitert, solange der Eintrag noch Kinder hat. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.
DROP RENDERERDROP RENDERER <name>;
Scheitert, solange noch ein FIELD darauf verweist. Berechtigung:
admin · Modus: stapelbar · Mehr: Kapitel 10.
DROP ROLEDROP 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 SCRIPTDROP SCRIPT <name>;
Scheitert, solange noch irgendeine Bindung (Trigger, USES,
Label-/Condition-Script, ...) existiert. Berechtigung: admin ·
Modus: stapelbar · Mehr: Kapitel 9.
DROP SPACEDROP SPACE <name> [CASCADE];
CASCADE ist Pflicht, sobald noch Beziehungen auf/von diesem Space
existieren. Berechtigung: admin · Modus: Autocommit-only ·
Mehr: Kapitel 4.
DROP TAGDROP TAG <name>;
Löscht ein Tag endgültig, samt aller Zuweisungen — keine "wird noch verwendet"-Sperre. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
DROP USERDROP 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 VIEWDROP 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 WORKFLOWDROP WORKFLOW <name>;
Erst möglich, wenn an keinem Space mehr gebunden. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 7.
ENABLE SCRIPTENABLE 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 DATAEXPORT DATA;
Schreibt einen vollständigen Datenexport (alle Datensätze, Entry
Store) nach exportDirectory. Berechtigung: admin · Modus:
Autocommit-only · Mehr: Kapitel 14.
EXPORT STRUCTUREEXPORT 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.
FLOWFLOW <ü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 QUEUEFLUSH 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.
GETGET <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 ATTRGET DOC ATTR <schlüssel> ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];
Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.
GET RELATION DETAILGET 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.
GRANTGRANT 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.
IMPORT DATAIMPORT 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.
INSERTINSERT 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.
LIST ADMIN LOGLIST ADMIN LOG AT <start>-<ende>;
Serverweites Protokoll jedes struktur-/datenverändernden Admin-Befehls. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 15.
LIST ALL DOCSLIST ALL DOCS;
Jedes Doc der gesamten Datenbank, unabhängig von read-Rechten.
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel
12.
LIST ALL DOCS OFLIST ALL DOCS OF <space>;
Jedes Doc eines Spaces, über alle Datensätze hinweg. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 12.
LIST DOC ATTRS OFLIST DOC ATTRS OF <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];
Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.
LIST DOCS OFLIST DOCS OF <space> WITH ID = <id>;
Berechtigung: download · Modus: stapelbar · Mehr: Kapitel 12.
LIST ENTRIESLIST 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 OFLIST FIELD LOG OF <space> AT <start>-<ende> WITH ID = <id>;
Feldänderungsprotokoll eines einzelnen Datensatzes. Berechtigung: logs · Modus: stapelbar · Mehr: Kapitel 15.
LIST FIELDS OFLIST FIELDS OF <space>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.
LIST FLOW LOG OFLIST FLOW LOG OF <space> WITH ID = <id>;
Protokoll der Zustandsübergänge eines Datensatzes. Berechtigung: logs · Modus: stapelbar · Mehr: Kapitel 15.
LIST FLOWS OF WORKFLOWLIST 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 SCRIPTSLIST GLOBAL SCRIPTS;
Jede AS GLOBAL TRIGGER-Bindung, serverweit. Berechtigung: admin ·
Modus: stapelbar · Mehr: Kapitel 9.
LIST GLOBAL CONDITION SCRIPTSLIST GLOBAL CONDITION SCRIPTS;
Jede AS GLOBAL FLOW|RUN CONDITION-Bindung, serverweit.
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel
9.
LIST LIBRARIESLIST LIBRARIES;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
LIST MAIL QUEUELIST MAIL QUEUE AT <start>-<ende>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
LIST MENULIST 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 ITEMSLIST MENU ITEMS;
Der vollständige, ungefilterte Baum. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 11.
LIST MY ROLESLIST MY ROLES TOP|ALL;
Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.
LIST PERMISSIONS OFLIST PERMISSIONS OF <rolle> TOP|ALL;
ALL aggregiert über die gesamte Rollenhierarchie. Berechtigung:
admin · Modus: stapelbar · Mehr: Kapitel
13.
LIST RENDERERSLIST RENDERERS;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.
LIST ROLESLIST ROLES;
Alle Rollen des Systems. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
LIST ROLES OF ROLELIST ROLES OF ROLE <rolle> TOP|ALL;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
LIST ROLES OF USERLIST ROLES OF USER <benutzer> TOP|ALL;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
LIST RUNNABLE SCRIPTS OFLIST 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 SCRIPTSLIST SCRIPTS;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
LIST SCRIPTS OFLIST SCRIPTS OF <space>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
LIST SCRIPTS OF WORKFLOWLIST SCRIPTS OF WORKFLOW <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
LIST SPACESLIST SPACES;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.
LIST TAGSLIST 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 USERSLIST USERS;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
LIST VARSLIST VARS;
Alle aktuell in dieser Sitzung gesetzten $-Variablen.
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel
5.
LIST VIEWSLIST VIEWS;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.
LIST WALKABLE FLOWS OFLIST 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 WORKFLOWSLIST WORKFLOWS;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 7.
LOGINLOGIN <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.
LOGOUTLOGOUT;
Beendet nur die angemeldete Sitzung, nicht die TCP-Verbindung. Berechtigung: keine · Mehr: Kapitel 2.
PUTPUT <schlüsselteil>[,<schlüsselteil>, ...] = '<wert>';
Schreibt einen Wert in den Entry Store (überschreibt stillschweigend). Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 8.
QUERYQUERY <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.
RECOMPUTE ALL LABELSRECOMPUTE ALL LABELS;
Berechnet das label-Pseudofeld für jeden Space mit Label-Script neu.
Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel
9.
RECOMPUTE LABELS OFRECOMPUTE LABELS OF <space>;
Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 9.
REGISTER UPLOADREGISTER 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.
REMOVEREMOVE <schlüsselteil>[,<schlüsselteil>, ...];
Löscht einen Eintrag aus dem Entry Store. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 8.
REMOVE DOC ATTRREMOVE DOC ATTR <schlüssel> ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];
Berechtigung: upload · Modus: stapelbar · Mehr: Kapitel 12.
RENDER VIEWRENDER 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 OFREQUEST 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 SCRIPTSRESUME SCRIPTS;
Schaltet die Admin-Sperre für die aktuelle Sitzung wieder ein. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
REVOKEREVOKE READ, CREATE, UPDATE, DELETE, RUN, FLOW, UPLOAD, DOWNLOAD, LOGS ON <space> FROM <rolle>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
ROLLBACKROLLBACK;
Verwirft die aktuelle Kette vollständig, nichts davon wird ausgeführt. Berechtigung: keine · Mehr: Kapitel 3.
RUN SCRIPTRUN 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.
SAVE VIEWSAVE 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 ATTRSET DOC ATTR <schlüssel> = '<wert>' ON <space> WITH ID = <id>, FILENAME = '<name>' [, VERSION = <n>];
Berechtigung: upload · Modus: stapelbar · Mehr: Kapitel 12.
SET RELATION DETAILSET 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 LIBRARYSHOW LIBRARY <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
SHOW MESHOW ME;
Zeigt Benutzername, Admin-Status, Aktiviert-Status und Rollen der aufrufenden Sitzung. Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.
SHOW MY PERMISSIONS ONSHOW MY PERMISSIONS ON <space>;
Berechtigung: eigene Sitzung · Modus: stapelbar · Mehr: Kapitel 13.
SHOW RENDERERSHOW RENDERER <name>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.
SHOW ROLESHOW 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 SCRIPTSHOW SCRIPT <name>;
Quelltext und alle Bindungen eines Scripts. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
SHOW SCRIPTSSHOW 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 SPACESHOW SPACE <name>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 4.
SHOW TAGSHOW 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 USERSHOW USER <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
SHOW VARSHOW VAR $<name>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 5.
SHOW VIEWSHOW VIEW <name>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.
SHOW WORKFLOWSHOW WORKFLOW <name>;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 7.
SHUTDOWNSHUTDOWN;
Fährt den gesamten Server kontrolliert herunter. Berechtigung: admin · Modus: Autocommit-only · Mehr: Kapitel 2.
SUSPEND SCRIPTSSUSPEND SCRIPTS;
Schaltet für die aktuelle Sitzung die Admin-Sperre gegen implizite Script-Auslösung aus. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
TAGTAG 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.
UNASSIGN ROLEUNASSIGN 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.
UNTAGUNTAG 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.
UPDATEUPDATE <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.
VERIFY ALL DOCSVERIFY 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 OFVERIFY 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.