teilweise mit KI generiert
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; für die Anbindung anderer Systeme gibt es außerdem eine HTTP-/REST-API, die dieselben Konsolenbefehle über einen JSON-Aufruf entgegennimmt. Trotzdem lohnt es sich gerade für Administratoren, die Konsolensprache zu verstehen — sie ist die gemeinsame Grundlage von allem, was noPLM tut, und nur wer sie kennt, kann das System wirklich effektiv und sinnvoll nutzen. Wer sich an eine SQL-Konsole oder ein gutes altes Terminal gewöhnt hat, wird sich hier ohnehin schnell zurechtfinden.
Diese Dokumentation richtet sich an Endbenutzer von noPLM: an
Personen, die Datenstrukturen (sogenannte Spaces), Workflows,
Scripte und Views definieren und benutzen — sei es direkt über die
Konsole, über die mitgelieferte noplm-cli, oder über ein eigenes
GUI, das die Konsolensprache im Hintergrund spricht. Du musst dafür
keine Zeile Go-Code lesen; die interne Architektur des Servers
interessiert hier nur so weit, wie sie das Verhalten nach außen
erklärt.
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|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|reliable|BOOL|false|
@ rows: 1
% 1 | 'Frischdienst Müller' | true
*
Die erste Ergebnisspalte ist die id des Datensatzes, obwohl wir sie
gar nicht abgefragt haben — jede QUERY liefert sie automatisch mit,
damit man mit jeder Zeile weiterarbeiten kann. Mehr dazu in
Kapitel 5.
Auch ohne ein einziges Wort über die Konsolensprache selbst gelernt zu
haben, lässt sich hier schon einiges ablesen: Jede Antwort endet mit
einer Zeile, die nur * enthält — das einzige Signal, dass die Antwort
vollständig ist (auch mehrzeilige Antworten enden so). Jede Zeile davor
beginnt mit genau einem von fünf Zeichen, die verraten, was in ihr
steht:
| Zeichen | Bedeutung |
|---|---|
# |
Kommentar/Statuszeile (z. B. OK: ..., die Begrüßung, protokollierte Zwischenschritte) |
@ |
Strukturinformation, die zum Verständnis der folgenden %-Zeilen nötig ist (Spaltenköpfe, Zeilenanzahl) |
% |
eine Datenzeile (Ergebnis von QUERY/LIST/SHOW, oder die neue ID nach einem INSERT) |
$ |
eine Nachricht für den Endbenutzer (von einem Script gesendet) |
! |
eine Fehlermeldung — der Befehl ist fehlgeschlagen |
Diese fünf Zeichen begegnen Dir in praktisch jedem Beispiel dieser
Dokumentation wieder, deshalb lohnt es sich, sie früh zu verinnerlichen.
Was genau bei einem Fehler passiert, welche Zeilen dann in welcher
Reihenfolge erscheinen und wie sich das zu BEGIN/COMMIT verhält,
ist Thema von Kapitel 3.
Ein letzter praktischer Hinweis für die interaktive Sitzung: Strg+C
verwirft den gerade angefangenen, noch unvollständigen Befehl und
bringt Dich zu einem frischen Prompt zurück, statt das Programm zu
beenden. Ein komplett eingetippter, mehrzeiliger Befehl landet als ein
einziger Eintrag in der History (mit den Pfeiltasten wieder aufrufbar),
nicht als mehrere einzelne Zeilen.
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 - send ROLLBACK; to continue. Ein BEGIN ist also kein
Ort zum Nachdenken — Befehle vorbereiten, dann zügig COMMIT oder
ROLLBACK.Nach so einem Rollback verweigert die Sitzung jeden weiteren Befehl,
bis Du selbst ROLLBACK; schickst: ! the transaction was rolled
back after timing out - send ROLLBACK; to continue. Das ist
Absicht. In diesem Moment glaubst Du noch, in einer Transaktion zu
sein, und die folgenden Befehle einzeln im Autocommit auszuführen —
jeder für sich committet — wäre das Gegenteil dessen, was Du wolltest.
Das ausdrückliche ROLLBACK ist Deine Bestätigung, dass Du es
bemerkt hast; danach läuft die Sitzung wieder normal. Ein COMMIT
bringt Dich ebenfalls heraus, sagt aber klar, dass nichts committet
wurde.
statementTimeoutSeconds; ein negativer Wert schaltet die Grenze
ab — 0 kann das nicht, weil ein weggelassener Eintrag ebenfalls als
0 gelesen wird). Die Grenze umfasst den Befehl samt aller Trigger,
die er auslöst, und deren gesamte Kaskade; bei einem COMMIT gilt sie
für die ganze vorgemerkte Kette, denn ein COMMIT ist ein Befehl.Befehle, die berechtigterweise Minuten brauchen, sind ausgenommen und
laufen so lange sie müssen: EXPORT STRUCTURE, EXPORT DATA, IMPORT
DATA, VERIFY DOCS OF, VERIFY ALL DOCS, RECOMPUTE LABELS OF,
RECOMPUTE ALL LABELS sowie jedes strukturelle
CREATE/ALTER/DROP SPACE — ein Feld an einen gut gefüllten Space
anzuhängen schreibt die darunterliegende Tabelle um, und das auf
halbem Weg abzubrechen wäre schlimmer als zu warten.
Wird Dir ein eigener Befehl abgeschnitten — etwa eine wirklich große
QUERY — dann hebe lieber den Wert an, statt darum herumzubauen.
Und noch eine Kleinigkeit: COMMIT ohne offene Transaktion ist ein
Fehler (! COMMIT without an open transaction - send BEGIN; first)
und kein stilles Nichts.
Außerdem gilt: Solange eine Transaktion offen ist, lässt sich die
Identität der Sitzung nicht per erneutem LOGIN wechseln (!
cannot switch user inside an open transaction - COMMIT or ROLLBACK
first) — naheliegend, denn alle bereits vorgemerkten Befehle wurden
gegen die Rechte des aktuellen Nutzers geprüft.
"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 RELATION supplier ONE AS ingredients MANY
);
Das erzeugt eine ingredient-Space mit drei Feldern — supplier ist
dabei ein Beziehungsfeld (mehr dazu in
Kapitel 6; für den Moment reicht:
jede Zutat gehört zu genau einem Lieferanten). CREATE SPACE ist wie
jeder andere strukturverändernde Befehl Autocommit-only — er lässt
sich nicht in eine offene Transaktion einreihen (siehe
Kapitel 3) — und
admin-only.
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.Manchmal hängt ein Wert von anderen Datensätzen ab: „Welche
Revision dieser Reihe ist die neueste?“, „Was ist der letzte Messwert
dieses Sensors?“. Ein Trigger kann so einen Wert nicht aktuell halten —
die Änderung passiert in einem anderen Datensatz als dem, der ihn zeigen
muss. Genau dafür gibt es virtuelle Felder: ein Feld ganz ohne
gespeicherten Wert, das bei jedem Lesen aus einer QUERY berechnet
wird.
CREATE SPACE rev (
name STRING NOT NULL,
revision INT NOT NULL,
origin RELATION rev ONE AS revisions MANY -- zeigt auf die erste Revision der Reihe
);
ALTER SPACE rev ADD VIRTUAL FIELD current_revision RELATION rev
QUERY "QUERY rev -> (id) WHERE origin = $self.origin ORDER BY revision DESC LIMIT 1"
DESCRIPTION 'die neueste Revision dieser Reihe';
ALTER SPACE rev ADD VIRTUAL FIELD latest_no INT
QUERY "QUERY rev -> (revision) WHERE origin = $self.origin ORDER BY revision DESC LIMIT 1";
QUERY rev -> (name, revision, current_revision, latest_no);
QUERY rev -> (name, current_revision -> (revision, name)); -- Hop über das virtuelle Feld
ORDER BY der Query entscheidet, die Datensatz-ID löst
Gleichstände; keine Zeile heißt leer). Der deklarierte Typ (INT,
STRING, … oder RELATION <space>) muss genau zu dieser Spalte
passen. Ein virtuelles RELATION-Feld enthält die ID eines
Datensatzes eines beliebigen Spaces und lässt sich wie ein echtes
Relationsfeld als Hop nutzen.$self.<feld> ist eine Spalte des Datensatzes, der gerade gelesen
wird — so stellt jede Zeile ihre eigene Frage. Es darf nur als Wert in
einer WHERE-Bedingung der Query des virtuellen Felds stehen und
nennt id, ein einfaches Feld oder ein Relationsfeld dieses Spaces
(keine USER-/ROLE-Felder).WHERE,
ORDER BY oder UNIQUE, nicht beschreibbar, nicht von einem anderen
virtuellen Feld nutzbar. Auch in QUERY <space>; und (*) ist es
nicht enthalten — jedes virtuelle Feld ist eine Query pro Zeile, also
nennt man es dort, wo man es haben will.ALTER SPACE rev ALTER FIELD latest_no QUERY
"..." ersetzt die Query, DROP FIELD entfernt das Feld. Solange ein
virtuelles Feld ein Feld oder einen Space noch liest, wird dessen
Löschen mit einer Meldung verweigert, die das virtuelle Feld nennt —
ändern Sie zuerst dessen Query.ctx.old.latest_no (im
BEFORE-Trigger, da zeigt die Datenbank noch den alten Stand) oder
ctx.new.latest_no (im AFTER-Trigger). Die jeweils andere Seite wirft
einen Fehler, statt still den falschen Wert zu liefern. Der Wert wird
erst beim Zugriff berechnet und taucht weder in Object.keys noch in
JSON.stringify(ctx.new) auf. Schreiben (ctx.patch) geht nicht. Im Label-Skript sind beide Schreibweisen
gleich. Achtung: Ein Label wird nur neu berechnet, wenn der Datensatz
selbst geschrieben wird. Ändert sich ein virtueller Wert durch einen
anderen Datensatz, bleibt das Label veraltet (bis RECOMPUTE LABELS).
Für aktuelle Anzeigen den Wert besser per QUERY oder View lesen.EXPORT DATA (sie lassen sich neu berechnen),
aber Teil von EXPORT STRUCTURE.Hängt ein Wert nur vom Datensatz selbst ab (zwei Texte verbinden,
ein if), braucht es kein virtuelles Feld: ein gewöhnliches Feld, das
ein BEFORE-Trigger mit ctx.patch füllt, ist gespeichert, filterbar
und sortierbar.
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|RELATION|true|ingredient_supplier(ONE)|||||false|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 4|sku|STRING|true||||||true|||2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% field: 5|code|AUTONUMBER|false||||||true|ZUT-|4|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
*
Jede % field:-Zeile hat vierzehn feste, |-getrennte Teile: position|
name|type|nullable|relation|relation_where|description|default|
detail_space|unique|autonumber_prefix|autonumber_length|created_at|
modified_at — leere Teile bleiben als leere Stelle stehen, werden nie
einfach weggelassen, damit sich die Zeile immer an fester Position
aufteilen lässt. relation, relation_where und detail_space sind
erst ab Kapitel 6 wirklich
interessant; autonumber_prefix/autonumber_length sind nur bei einem
AUTONUMBER-Feld gefüllt. SHOW SPACE ingredient; liefert dieselben
field:-Zeilen, zusätzlich gerahmt von % name:/% label: und ein
paar Zeilen, die erst in späteren Kapiteln Sinn ergeben (etwa
% workflow: oder % label_script:, je nachdem, was auf dem Space
schon eingerichtet ist).
LIST SPACES, LIST FIELDS OF und SHOW SPACE brauchen keine
besondere Berechtigung — offen für jede angemeldete Sitzung, admin oder
nicht.
Kapitel 5
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; escapebar sind \' \\ \n \r |
'Mehl', 'Sie sagte \'Hallo\'', 'C:\\temp' |
| Int | optionales Minus, Ziffern | 42, -3 |
| Float | wie Int, mit Punkt | 3.5, -0.25 |
| Bool | Schlüsselwort | true, false |
| Datetime | wie ein String, ISO-Format | '2026-01-15', '2026-01-15T09:00:00' |
| Null | Schlüsselwort | null |
Ein Backslash muss also immer verdoppelt werden: 'C:\\temp' ergibt den
Wert C:\temp. Ein Backslash vor irgendetwas anderem ist ein Fehler
— 'a\t' wird abgelehnt, nicht etwa stillschweigend als a, Backslash, t
gelesen. Das ist Absicht: so gibt es für jeden Wert genau eine
Schreibweise, und ein Client, der schlampig escapt, bekommt sofort eine
Fehlermeldung statt eines leicht veränderten Werts in der Datenbank.
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.
DEFINEEine $variable trägt eine Datensatz-id. Will man dagegen einen Namen
wiederverwenden — etwa den Space, auf dem eine Befehlsdatei arbeiten soll —,
gibt es DEFINE, ein schlichtes Textmakro:
noplm> DEFINE space AS ingredient;
# OK: {{space}} defined
*
noplm> QUERY {{space}} -> (name, unit);
Ab da wird {{space}} in jedem Befehl dieser Session durch ingredient
ersetzt, bevor der Befehl gelesen wird; der Server sieht also QUERY
ingredient -> (name, unit);. So wird eine Befehlsdatei für verschiedene
Spaces nutzbar: {{space}} hineinschreiben, den Namen vorher mit DEFINE
setzen, die Datei abspielen. Die Kochbuch-Rezepte, die einen vorhandenen Space
verändern, arbeiten so; INCLUDE RECIPE <name> WITH space = ...; setzt das
Makro für Dich, nur für diese eine Datei (Kapitel 16).
DEFINE n AS 42;) oder ein String in
Anführungszeichen (DEFINE label AS 'a b.c';). {{name}} wird einmal und
so wie geschrieben ersetzt; der eingesetzte Text wird nicht noch einmal nach
Makros durchsucht.{{x}} bleibt stehen, der Befehl scheitert dann als Syntaxfehler.$name-Variablen gehören Makros zur Session und sind nach einem neuen
LOGIN oder am Ende der Verbindung weg. LIST DEFINES; zeigt sie,
UNDEFINE space; entfernt eines. Keiner dieser Befehle braucht eine
Berechtigung.BEGIN definieren: DEFINE ist innerhalb einer Transaktion nicht
erlaubt, aber vorher gesetzte Makros wirken in den Befehlen, die Du dort
einreihst.Syntax: Kapitel 17, DEFINE.
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%'
AND bindet dabei stärker als OR — genau wie in SQL. Die Bedingung
WHERE unit = 'g' OR unit = 'ml' AND qty > 10
liest sich also als "entweder unit = 'g', oder aber unit = 'ml'
und qty > 10" — und nicht als das, was man beim Lesen von links
nach rechts vielleicht erwartet hätte. Wenn du es andersherum meinst,
setz Klammern:
WHERE (unit = 'g' OR unit = 'ml') AND qty > 10
Klammern dürfen ineinander verschachtelt werden und sind überall
erlaubt, wo eine Bedingung stehen darf — auch im WHERE eines
UPDATE oder DELETE. Leere Klammern (()) sind ein Fehler.
Verfügbare Vergleichsoperatoren: = != < <= > >= LIKE. LIKE
funktioniert nur auf STRING/TEXT-Feldern (und dem später
vorgestellten label-Pseudofeld), mit %/_ als SQL-typischen
Platzhaltern — name LIKE '%Safran%' findet jeden Namen, der
irgendwo "Safran" enthält.
Für "ist einer von mehreren Werten" gibt es eine einzige, einheitliche Listen-Syntax mit eckigen Klammern, egal welcher Typ:
WHERE unit IN ['g', 'ml', 'kg']
WHERE id IN [3, 5, 7]
WHERE id IN [$a, $b]
Eine leere Liste (WHERE id IN [];) ist gültig und trifft schlicht auf
keine Zeile zu.
Um „kein Wert“ abzufragen, schreibt man = NULL, für „hat einen Wert“
!= NULL:
WHERE supplier = NULL
WHERE note != NULL
IS NULL gibt es in dieser Sprache nicht (es ist ein Parse-Fehler); der Server
macht aus = NULL selbst den richtigen Test. Die anderen Vergleiche (<, >,
…) mit NULL werden abgelehnt.
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|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|unit|STRING|false|
@ column: 4|supplier|RELATION|true|ingredient_supplier
@ rows: 1
% 12 | 'Safran' | 'g' | supplier:4
*
Die erste Spalte ist immer die id des Datensatzes — auch wenn du
sie gar nicht aufgezählt hast, und auch bei QUERY ingredient; ganz
ohne Feldliste. So trägt jede Zeile schon die id, die du brauchst, um
den Datensatz zu öffnen oder zu bearbeiten. Du darfst id weiterhin
selbst mit hinschreiben; sie wird dann einfach in dieselbe erste Spalte
zusammengezogen, nie doppelt.
Ein Beziehungsfeld wie supplier liefert nie eine nackte Zahl, immer
<space>:<wert> — hier reicht das als Vorschau; wie man von da aus
weiter über die Beziehung "hoppt" (ingredient -> supplier -> name)
und was WITH LABELS daraus macht, ist Thema von
Kapitel 6.
Ein optionales, clientseitig gewähltes LIMIT <n> am Ende schneidet
das Ergebnis auf die ersten n Zeilen zu, ohne einen Fehler zu
werfen, selbst wenn mehr existieren:
noplm> QUERY ingredient LIMIT 1;
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|unit|STRING|false|
@ column: 4|supplier|RELATION|true|ingredient_supplier
@ rows: 1
@ truncated: true
% 3 | 'Mehl' | 'g' | supplier:1
*
Die zusätzliche @ truncated:-Zeile taucht nur auf, wenn LIMIT
tatsächlich angegeben wurde, und meldet, ob wirklich mehr Zeilen
vorhanden waren als gezeigt. Das ist etwas anderes als die
serverseitig konfigurierte Obergrenze maxQueryRows: Die lehnt eine zu
große, LIMIT-lose Anfrage komplett ab, statt sie zu kürzen — Details
dazu gehören eher zur Administration als in dieses Kapitel.
Kapitel 6
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 RELATION supplier ONE AS ingredients MANY
Gelesen von links: das Feld heißt supplier, verweist auf die Space
supplier, mit Multiplizität ONE (eine Zutat hat genau einen
Lieferanten) — und legt gleichzeitig die Gegenseite fest: auf
supplier entsteht automatisch ein Feld ingredients mit
Multiplizität MANY (ein Lieferant beliefert viele Zutaten). Eine
Beziehung wird also nur einmal deklariert, aber immer von beiden Seiten
aus sichtbar.
Physisch steckt dahinter eine einzige Fremdschlüssel-Spalte, und zwar
auf der ONE-Seite — hier: ingredient.supplier. Die MANY-Seite
(supplier.ingredients) existiert nicht als eigene Spalte, sondern
wird bei Bedarf berechnet. Trotzdem ist auch sie schreibbar — die
ganze Mitgliedschaft auf einmal, als Liste:
UPDATE supplier SET ingredients = [3, 5, 7] WHERE id = 1;
@ rows: 1
*
Das ersetzt die komplette Zutatenliste dieses Lieferanten: Zutaten, die
neu in der Liste stehen, bekommen supplier = 1; Zutaten, die vorher
dabei waren und jetzt fehlen, werden auf supplier = NULL gesetzt
(sofern das Feld nullbar ist). Eine leere Liste (= []) oder = NULL
räumt die ganze Mitgliedschaft leer.
Von der physischen Seite aus schreibst Du dagegen ganz normal einen einzelnen Wert:
UPDATE ingredient SET supplier = 1 WHERE id = 3;
UPDATE ingredient SET supplier = NULL WHERE id = 3; -- Beziehung lösen
MANY: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 RELATION ingredient MANY AS recipes MANY;
# OK: field 'ingredients' added to 'recipe'
*
Damit hat recipe jetzt ein Feld ingredients, und ingredient
automatisch das Gegenstück recipes — beide MANY, beide
gleichberechtigt schreibbar, keine Seite ist "die physische":
UPDATE recipe SET ingredients = [3, 5, 7] WHERE id = 2; -- von der Rezept-Seite
UPDATE ingredient SET recipes = [2, 4] WHERE id = 3; -- von der Zutaten-Seite
Intern liegt eine MANY:MANY-Beziehung in einer eigenen, unsichtbaren
Zwischentabelle — für Dich als Endbenutzer aber komplett unsichtbar,
Du siehst nur die beiden Listenfelder. Löschst Du einen Datensatz auf
einer Seite, verschwinden seine Einträge in dieser Zwischentabelle
automatisch mit.
"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.OWNED: der Detail-Datensatz lebt und stirbt mit der
Verknüpfung. Geschrieben als ... WITH DETAIL amount OWNED am Feld,
oder nachträglich eingeschaltet mit ALTER SPACE recipe ALTER FIELD
ingredients DETAIL SPACE amount OWNED; (und wieder aus, indem man
OWNED weglässt). Der Detail-Datensatz gehört dann zur Verknüpfung
wie ein Rad zu genau einem Fahrrad: Wird die Verknüpfung aufgelöst —
Beziehung geleert, woanders hin gezeigt, die Zutat aus dem Rezept
entfernt, einer der beiden Datensätze gelöscht —, dann wird auch der
Mengen-Datensatz gelöscht, seine DELETE-Trigger feuern dabei ganz
normal. Im Gegenzug wird SET RELATION DETAIL ... TO <id> abgelehnt:
eine besessene Menge wird für ihre Verknüpfung erzeugt, nie einer
zugewiesen. TO NULL funktioniert weiterhin und löscht nun.Ohne OWNED (der Normalfall) wird nie etwas gelöscht — die
Verknüpfung wird nur gekappt, der Mengen-Datensatz bleibt liegen und
kann anderswo angehängt werden. Das Umschalten wirkt nie rückwirkend:
es entscheidet, was die nächste aufgelöste Verknüpfung tut, und
lässt alles Bestehende in Ruhe.
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|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|supplier->name|STRING|false|
@ rows: 1
% 12 | 'Safran' | 'Feinkost Vogel'
*
Die erste Spalte id gehört immer zum Wurzel-Datensatz (hier
ingredient), nie zu einem gehoppten Ziel. Eine gehoppte Spalte wird
nach ihrem vollen Pfad benannt, mit
demselben -> geschrieben — supplier->name, nicht nur name — damit
zwei Hops, die auf einem gleichnamigen Feld enden, unterscheidbar
bleiben. (Denselben Pfad doppelt zu projizieren, wird abgelehnt.)
WITH LABELS wirkt sich nur auf Beziehungsfelder aus, die als solche
im Ergebnis erscheinen (nicht auf einen gehoppten Zielwert wie hier
supplier -> name, der ja längst ein einfacher String ist) — hängt
z. B. supplier selbst mit ins Ergebnis, würde sein Wert als
supplier:1('Feinkost Vogel') statt nur supplier:1 erscheinen.
Ein Hop über eine MANY-Beziehung ist ebenso erlaubt, mit einer
Einschränkung: höchstens ein MANY-Pfad pro Abfrage, sonst müsste
das Ergebnis ein Kreuzprodukt aus zwei unabhängigen Listen sein:
QUERY recipe -> (name, ingredients -> name); -- ein MANY-Pfad: erlaubt, flach
Jede Kombination aus Rezept und Zutat wird dabei zu einer eigenen Ergebniszeile — klassisches JOIN-Verhalten, kein verschachteltes Format.
Wie tief sich hoppen lässt, begrenzt eine Server-Einstellung
(maxQueryHops) — für den Alltag in der Regel kein Thema, kommt aber
bei sehr verschachtelten Abfragen als klare Fehlermeldung zurück statt
den Server unbegrenzt zu belasten.
Kapitel 7
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|Kitchen Workflow|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
*
noplm> SHOW WORKFLOW kitchen;
% workflow: kitchen|Kitchen Workflow|2026-01-15T09:00:00Z|2026-01-15T09:00:00Z
% state: INITIAL|true|Entwurf
% state: TESTED|false|Getestet
% state: APPROVED|false|Freigegeben
% flow: test|INITIAL|TESTED|test
% flow: revise|TESTED|INITIAL|revise
% flow: approve|TESTED|APPROVED|approve
*
noplm> LIST FLOWS OF WORKFLOW kitchen FROM STATE TESTED;
% flow: revise|TESTED|INITIAL|revise
% flow: approve|TESTED|APPROVED|approve
*
LIST WORKFLOWS/SHOW WORKFLOW/LIST FLOWS OF WORKFLOW brauchen
keine besondere Berechtigung, genau wie LIST SPACES/SHOW SPACE.
LIST FLOWS OF WORKFLOW <name> ON <space> WITH ID = <id> — die zweite
Variante mit einem konkreten Datensatz statt FROM STATE — zeigt die
von diesem Datensatz aus tatsächlich ausführbaren Übergänge; nützlich
für eine GUI, die nur die gerade sinnvollen Buttons anzeigen will.
Umbenennen und Aufräumen funktioniert wie bei einem Space — sowohl der Workflow selbst als auch jeder einzelne Zustand/Übergang lässt sich umbenennen und eigenständig beschriften:
ALTER WORKFLOW kitchen RENAME TO kitchen_v2;
ALTER WORKFLOW kitchen_v2 SET LABEL 'Küchen-Workflow';
ALTER WORKFLOW kitchen_v2 RENAME STATE APPROVED TO DONE;
ALTER WORKFLOW kitchen_v2 RENAME FLOW approve TO release;
ALTER WORKFLOW kitchen_v2 DROP FLOW revise;
ALTER WORKFLOW kitchen_v2 DROP STATE TESTED; -- schlägt fehl, solange 'test'/'approve'
-- noch darauf verweisen
DROP WORKFLOW kitchen_v2; -- erst möglich, wenn an keinem Space mehr gebunden
Jeder Workflow, Zustand und Übergang kann außerdem einen UI META
genannten, rein clientseitigen JSON-Blob tragen — für Layout-Angaben
eines grafischen Workflow-Editors (Position auf der Zeichenfläche,
Linienführung, Farbe, ...), ohne jede Bedeutung für den Server selbst.
Gesetzt über ALTER WORKFLOW kitchen_v2 SET UI META bzw. ... ALTER
STATE <state> SET UI META/... ALTER FLOW <flow> SET UI META, gefolgt
von einem mehrzeiligen JSON-Body und einer abschließenden %%;-Zeile —
genau wie ein Script-Body (Kapitel 9); DROP UI META löscht ihn
wieder. Sichtbar nur über SHOW WORKFLOW, nicht in LIST WORKFLOWS.
Kapitel 8
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
@ rows: 1
*
GET kitchen, name;
% value: Küchenteam Nord
*
REMOVE kitchen, name;
# OK
@ rows: 1
*
Ein Schlüssel ist eine kommagetrennte Liste von 1 bis maximal 8
Bezeichnern (server-konfigurierbar) — hier kitchen, name — und wird
case-insensitiv behandelt: GET KITCHEN, NAME; trifft denselben
Eintrag. PUT ist ein stiller Upsert: Ein zweites PUT auf denselben
Schlüssel überschreibt einfach den alten Wert, ohne Fehler oder
Rückfrage.
GET auf einen nicht existierenden Schlüssel ist — anders als eine
QUERY ohne Treffer — ein Fehler, keine leere Antwort:
GET kitchen, name;
! entry 'kitchen,name' not found
*
REMOVE auf einen nicht existierenden Schlüssel dagegen nicht — meldet
schlicht @ rows: 0.
Das Antwortformat ist dasselbe OK / @ rows: N wie bei
UPDATE/DELETE aus Kapitel 5:
PUT meldet immer 1, REMOVE die Anzahl tatsächlich entfernter
Einträge (0 oder 1).
LIST 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 |
Das letzte gehört inhaltlich zu Views und wird in Kapitel 10 behandelt (Abschnitt 9.10 verlinkt kurz dorthin); alle anderen sind Thema dieses Kapitels.
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), ALLOW SCRIPTS first (to run it for real), or perform this action as a user with the appropriate space rights instead
*
Der Sinn dahinter: Ein Admin, der schnell mal Daten reparieren will,
soll nie versehentlich Geschäftslogik anstoßen, die eigentlich für
normale Nutzung gedacht ist. Drei sitzungsgebundene Kommandos —
zurückgesetzt bei jedem LOGIN — schalten zwischen den drei möglichen
Script-Modi um:
SUSPEND SCRIPTS;
# OK: scripts suspended for this session
*
UPDATE recipe SET servings = 6 WHERE id = 2; -- läuft jetzt durch, ohne dass validate_servings feuert
@ rows: 1
*
GUARD SCRIPTS;
# OK: scripts guarded for this session
*
SUSPEND SCRIPTS; — die klassische Ausweichmöglichkeit: Das
angebundene Script wird still übersprungen, der Rest des Befehls
läuft normal. Gut für eine schnelle, ungehinderte Datenkorrektur.ALLOW SCRIPTS; — das Gegenteil: Das angebundene Script feuert
tatsächlich, genau wie bei einem normalen Nutzer (inklusive eines
möglichen ctx.abort()). Sinnvoll, wenn die Korrektur die normale
Benachrichtigungs-/Ableitungslogik weiterhin auslösen soll, statt sie
zu überspringen.GUARD SCRIPTS; — zurück zum Standardzustand (dem harten Fehler
oben), aus beiden anderen Zuständen heraus.SHOW SCRIPTS; (admin-only) verrät den aktuellen Script-Modus der
Sitzung: % script_mode: NORMAL|SUSPENDED|ALLOWED. Nicht zu
verwechseln mit SHOW SCRIPT <name>; (Quelltext + alle Bindungen
eines Scripts), LIST SCRIPTS; (alle Scripte), LIST SCRIPTS OF
<space>; (nur die an diesen Space gebundenen) — alle drei admin-only,
genau wie das Pendant LIST LIBRARIES;/SHOW LIBRARY <name>; für
Bibliotheken.
LIBRARY: wiederverwendbarer 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/ALLOW/GUARD SCRIPTS ausgenommen — es gibt
schlicht nichts zu schützen.ctx.format.dateTime/date/time(value).
Ein Label wird einmal gespeichert, aber von allen gelesen — ein als
Klartext hineingeschriebenes Datum sähe also für jeden gleich aus.
ctx.format.dateTime(ctx.new.due) schreibt stattdessen einen kleinen
Marker, {{noplm:datetime|2026-10-03T14:30:00Z}}, den die GUI durch
das Datum in Zeitzone und Format des jeweiligen Betrachters ersetzt.
Das geht in jedem Scripttyp und in jedem String-Feld, nicht nur in
Labels. In einer Mail löst der Server den Marker selbst auf, und zwar
in UTC (2026-10-03 14:30:00 UTC). Zwei Dinge sind gut zu wissen: Der
Marker zählt zur 255-Zeichen-Grenze des Labels mit, und eine Suche
nach dem Label-Text trifft den Marker, nicht das lokalisierte Datum.
Einen Marker kann man auch von Hand schreiben — er gilt nur, wenn Art
(datetime, date, time) und UTC-Zeitpunkt mit dem abschließenden
Z exakt stimmen; alles andere bleibt einfacher Text.ctx.format.text({...}). Dieselbe Idee
für Wörter statt Datumsangaben. ctx.format.text({de: "Neue Revision",
en: "New revision"}) schreibt {{noplm:text|de=Neue Revision|en=New
revision}}; die GUI zeigt den Eintrag zur Sprache des Betrachters und,
fehlt die, den ersten Eintrag — die Sprache, die als Rückfall gelten
soll, gehört also nach vorn. Ein Script braucht man dafür nicht: das
LABEL eines Scripts, Menüeintrags oder Views ist einfacher Text,
LABEL '{{noplm:text|en=Open|de=Öffnen}}' funktioniert direkt. Regeln
für einen von Hand geschriebenen Marker: je Sprache zwei Kleinbuchstaben
(de, en), jede höchstens einmal, kein leerer Text, und die Zeichen
%, |, {, } im Text als %25, %7C, %7B, %7D (das macht
ctx.format.text für Dich); alles andere bleibt einfacher Text. Dasselbe
gilt für Ausgabetexte: ctx.message(ctx.format.text({en: "Saved.", de:
"Gespeichert."})) zeigt die Sprache des Betrachters (auch bei
ctx.abort). In einer Mail nimmt der Server den ersten Eintrag. Der Marker zählt zur
255-Zeichen-Grenze des Labels mit.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.
Ein Relationsfeld steht in ctx.new/ctx.old nur als Id: eine Zahl
(oder null) auf einer ONE-Seite, ein Array von Zahlen auf einer
MANY-Seite — leer ist das [], nie null. Willst du den Relationen
folgen, nimm ctx.newRecord/ctx.oldRecord: derselbe Zustand als
Objektgraph, der sich beim Zugriff nachlädt.
ctx.newRecord.supplier.country.name // ONE-Kette
ctx.newRecord.parts[2].label // MANY → Array von Records
ctx.newRecord.parts.map(p => p.supplier.name)
ctx.newRecord.supplier?.country?.name // unterbrochene Kette abfangen
Eine ONE-Relation ist ein Record oder null, eine MANY-Relation ein
Array von Records (leer: []; ein Index außerhalb ist wie in JS
undefined). Jeder Record hat id (ohne Datenbankzugriff), seine
Felder, state und label. Wichtig zu wissen:
ctx.old/ctx.new aus. Deshalb ist ctx.newRecord.supplier auch in
einem BEFORE-Trigger der Lieferant, der gleich geschrieben wird, und
ctx.oldRecord.supplier der bisherige. Alles dahinter wird beim ersten
Zugriff gelesen und dann für den Rest dieses Aufrufs festgehalten — ein
ctx.exec()-Schreibvorgang danach ändert einen schon gelesenen Record
nicht mehr.ctx.oldRecord ist null, wo ctx.old null ist (CREATE), und
ctx.newRecord dort, wo ctx.new null ist (DELETE).parts.map(p => p.supplier.name)
kostet zwei Abfragen, nicht eine pro Element. Mehr Records als
maxScriptQueryRows sind ein Fehler.JSON.stringify zeigt sie),
Relationsfelder nicht — eine Serialisierung läuft also nie durch den
ganzen Graphen.ctx.query()), daher auch in
BEFORE nutzbar. Virtuelle Felder gibt es an einem Record nicht.BEFORE ist bewusst seiteneffektfrei — es gibt dort kein
ctx.exec/ctx.create (ein Aufruf wirft sofort einen Fehler). Eine
einzige, gezielte Ausnahme davon ist ctx.patch: ein anfangs
leeres Objekt, ausschließlich während BEFORE CREATE/BEFORE UPDATE
für den direkten Schreibziel-Datensatz vorhanden, mit dem Du den
tatsächlich geschriebenen Wert überschreiben kannst, ohne dem Script
sonst irgendeine Schreibfähigkeit zu geben:
CREATE SCRIPT clamp_servings
if (ctx.new.servings < 1) {
ctx.patch.servings = 1; -- klemmt den Wert fest, statt abzubrechen
}
%%;
ATTACH SCRIPT clamp_servings TO recipe AS TRIGGER BEFORE CREATE;
ctx.patch.<feld> = wert; setzt genau das, was am Ende tatsächlich
geschrieben wird — für ein einfaches Feld oder die physische Seite
einer Beziehung ein einzelner Wert, für ein MANY-seitiges/
MANY:MANY-Beziehungsfeld eine Liste von IDs (ctx.patch.ingredients =
[3, 5];, gegen die aktuelle Mitgliedschaft verrechnet, genau wie ein
normales field = [...]-Assign aus Kapitel 6). Feuern mehrere Scripte
am selben Anlass, sieht ein später (niedriger priorisiertes) Script
bereits den von einem früheren Script gepatchten Wert — Patches
addieren sich über Scripte hinweg, statt dass jedes Script nur den
ursprünglichen Befehl sieht. Ein per ctx.patch überschriebenes
Beziehungsfeld wird nicht gegen dessen WHERE-Bedingung (Kapitel
6.3) geprüft — bewusste Ausnahme, ein Script darf hier gezielt
übersteuern.
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. Ein
ctx.exec("QUERY ...")-Ergebnis beginnt — wie eine Konsolen-QUERY —
mit der id-Spalte des Wurzel-Datensatzes; seine .data-Zeilen
starten also mit der id. Der typisierte ctx.query()-Wrapper fügt
diese Spalte nicht hinzu und liefert genau die aufgezählten
Felder.INSERT/UPDATE/DELETE prüft, prüft ein
ctx.exec() genauso: Beziehungs-WHERE-Bedingungen greifen, das
Zuweisen einer MANY-Seite (SET teile = [1, 2]) wird als
Mitgliedschafts-Änderung ausgeführt, und ein Pfad-Schreibzugriff
(UPDATE rezept -> lieferant SET name = '...') landet wirklich beim
Lieferanten. Was in der Konsole nicht geht, geht auch im Script
nicht: INSERT ... AS $name wird abgelehnt, weil
Session-Variablen zu einer Konsolensitzung gehören — die neue id
bekommst du als Rückgabewert von ctx.create().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).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.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/ALLOW SCRIPTS-Ausnahme. Anders als
bei einem normalen Trigger (9.2) gibt es hier keine Admin-Sperre zu
umgehen — das ist Absicht: Eine Zugriffsbeschränkung wie diese soll
sich nicht per Script-Modus-Umschalter aushebeln lassen.
An einem Space können mehrere Condition-Scripte derselben Art
gleichzeitig hängen, dazu beliebig viele GLOBALe, die für jeden
Space gelten — die Bindung läuft über dieselbe ATTACH/DETACH
SCRIPT-Syntax wie bei einem Trigger- oder Run-Script (9.4/9.7), nur
mit FLOW CONDITION/RUN CONDITION als Bindungsart. Sind mehrere
gebunden, muss ein FLOW-/RUN SCRIPT-Wunsch von allen gebundenen
Scripten gleichzeitig erlaubt sein — die wirksame erlaubte Menge ist
ihre Schnittmenge, nicht ihre Vereinigung.
Flow-Condition-Script — die lokale Form braucht zwingend einen
bereits am Space hängenden Workflow (die globale Form nicht, da sie an
keinen einzelnen Space gebunden ist); entscheidet, welche FLOW-Namen
gerade erlaubt sind:
CREATE SCRIPT approval_flows
if (ctx.user.name === 'head_chef_maria') {
return ['test', 'revise', 'approve'];
}
return ['test', 'revise'];
;
ATTACH SCRIPT approval_flows TO recipe AS FLOW CONDITION;
LIST WALKABLE FLOWS OF recipe WITH ID = 2; -- Vorschau, bevor Du es versuchst
FLOW approve ON recipe WITH ID = 2; -- schlägt fehl, falls nicht erlaubt
DETACH SCRIPT approval_flows FROM recipe AS FLOW CONDITION;
Run-Condition-Script — dasselbe Prinzip für RUNNABLE-Scripte,
in keiner der beiden Formen mit Workflow-Voraussetzung:
CREATE SCRIPT run_allowed
if (ctx.user.name === 'head_chef_maria') { return ['publish_menu']; }
return [];
;
ATTACH SCRIPT run_allowed TO recipe AS RUN CONDITION;
LIST RUNNABLE SCRIPTS OF recipe WITH ID = 2;
GLOBAL funktioniert genauso, nur ohne Space-Ziel — eine
unternehmensweite Regel, die überall zusätzlich zu den lokalen
Condition-Scripten eines Space gilt:
CREATE SCRIPT business_hours_only
var h = new Date().getUTCHours();
if (h < 6 || h > 20) { return []; } -- außerhalb der Geschäftszeiten nichts erlaubt
return ['test', 'revise', 'approve', 'publish_menu'];
;
ATTACH SCRIPT business_hours_only AS GLOBAL FLOW CONDITION;
ATTACH SCRIPT business_hours_only AS GLOBAL RUN CONDITION;
Für beide Arten gilt eine strenge Regel ohne Ausnahme: Der Rückgabewert
jedes Scripts muss ein JS-Array aus Strings sein — auch ein leeres
Array ([], "gerade nichts erlaubt") ist gültig, aber alles andere
(kein return, null, ein Nicht-Array, ein Element, das kein String
ist) ist ein harter Fehler, der die ganze Kette abbricht. Das
eingeschränkte ctx entspricht dem eines Label-Scripts (9.3): kein
ctx.new/ctx.old/ctx.exec/ctx.abort — nur lesende Zugriffe. Ohne
jegliches gebundenes Condition-Script (weder lokal noch global) ist
FLOW/RUN SCRIPT uneingeschränkt — genau wie vor diesem Feature.
ctx.allRunnables/ctx.allFlows lösen ein Problem, das vor allem
bei einem globalen Condition-Script auftritt: Ein Script muss immer
die vollständige aktuell erlaubte Menge zurückgeben — es kennt aber
nicht automatisch, welche RUNNABLE-Scripte oder Flows der jeweilige
Space überhaupt hat. Ohne diese beiden Felder müsste ein global
angehängtes Condition-Script entweder jeden Namen jedes Space von Hand
kennen, oder würde versehentlich alles blockieren, sobald es an einem
Space landet, an den beim Schreiben nie gedacht wurde. ctx.allRunnables
liefert die rohe, ungefilterte Liste aller aktivierten
RUNNABLE-Bindungen des aktuellen Space, ctx.allFlows die Liste aller
vom aktuellen ctx.state aus strukturell erreichbaren Flow-Namen — in
beiden Fällen genau das, was LIST RUNNABLE SCRIPTS OF/LIST WALKABLE
FLOWS OF intern auch als Ausgangspunkt verwenden, bevor sie es mit
einem Condition-Script schneiden. Ein Script, das nichts weiter weiß,
kann damit einfach "alles, was strukturell da ist" zurückgeben:
CREATE SCRIPT default_allow
return ctx.allRunnables; -- bzw. ctx.allFlows für eine Flow-Condition
%%;
ATTACH SCRIPT default_allow AS GLOBAL RUN CONDITION;
Wichtig: Beide Felder spiegeln immer die rohe Struktur wider, nie das bereits gefilterte Ergebnis eines anderen, mit höherer Priorität laufenden Condition-Scripts am selben Space — sonst würde das Endergebnis von der Ausführungsreihenfolge abhängen, was diesem Feature laut der Regel weiter oben ausdrücklich nicht passieren darf.
ctx.userRoles ist in jedem Scripttyp verfügbar, der auch
ctx.user hat (siehe 9.11) — die vollständige, über die Rollenhierarchie
(Kapitel 13) aufgelöste Rollenmenge des aufrufenden Users, als Array von
Rollennamen, nie null (leeres Array, falls keine Rolle zutrifft).
Nützlich überall dort, wo ein Script selbst zwischen Rollen
unterscheiden will, ohne dafür eine eigene Hilfs-Space mit
Mitgliederlisten pflegen zu müssen:
CREATE SCRIPT approval_flows_by_role
if (ctx.userRoles.indexOf('head_chef') !== -1) { return ctx.allFlows; }
return ['test', 'revise'];
;
Kapitel 16.1 zeigt, wie sich ctx.userRoles zusammen mit
ctx.allRunnables/ctx.allFlows und dem Entry Store (Kapitel 8)
zu einem eigenen, konfigurierbaren Rechtesystem kombinieren lässt.
Ein RUNNABLE-gebundenes Script wird nicht automatisch ausgelöst,
sondern gezielt per RUN SCRIPT aufgerufen:
CREATE SCRIPT shopping_list
var rows = ctx.query("recipe -> ingredients -> (name, unit, supplier -> name)");
var result = [];
for (var i = 0; i < rows.length; i++) {
result.push({
ingredient: rows[i]['ingredients->name'],
unit: rows[i]['ingredients->unit'],
supplier: rows[i]['ingredients->supplier->name'],
});
}
return result;
;
ATTACH SCRIPT shopping_list TO recipe AS RUNNABLE;
RUN SCRIPT shopping_list ON recipe WITH ID = 2;
Der Rückgabewert eines per RUN SCRIPT aufgerufenen Scripts wird —
anders als bei einem Trigger- oder Flow-Script, wo er ignoriert
wird — direkt in die Konsolenantwort übernommen, als eigener
% return:-Quellblock, JSON-kodiert:
% return:
% [{"ingredient":"Saffron","unit":"g","supplier":"Feinkost Vogel"}]
% ;
# OK: script 'shopping_list' executed
*
Kein return bzw. return null; erzeugt gar keine %
return:-Zeile. ctx.run verrät jedem Script, warum genau es gerade
läuft: {event, timing, script, priority} — bei RUN SCRIPT z. B.
event: "RUN_SCRIPT", timing: null (kein Vorher/Nachher-Konzept bei
einem gezielten Aufruf).
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.
Datums- und Textmarker aus ctx.format.* (siehe 9.3) in Betreff oder
Text werden vor dem Einreihen in schlichten Text umgewandelt (Daten in
UTC, ein Textmarker in seine erste Sprache), denn eine Mail bekommt keine
GUI je zu sehen.
var res = ctx.httpQuery({ method: 'GET', url: 'https://api.example.com/nutrition/saffron' });
if (res.status === 200) {
ctx.log("nutrition data: " + res.body);
}
ctx.httpQuery() läuft synchron (blockiert das Script, bis die
Antwort da ist oder das serverseitige Zeitlimit greift) und wirft nur
bei einem echten Netzwerkfehler oder einem nicht erlaubten Ziel — ein
4xx/5xx-Status kommt ganz normal als Ergebnis zurück, kein Fehler.
Drei admin-only Befehle verwalten die Mail-Warteschlange:
LIST MAIL QUEUE AT 1-50;
% mail: 1|pending|maria@example.com|Recipe approved: Saffron Risotto|0|2026-01-15T10:00:00Z||2026-01-15T09:55:00Z
% total: 1
*
FLUSH MAIL QUEUE; -- sofortiger Zustellversuch für alle wartenden Einträge
# OK: flush attempted for 1 mail queue entries
*
DELETE MAIL QUEUE WITH ID = 1; -- entfernt genau einen Eintrag, jeden Zustands
# OK: mail queue entry 1 deleted
*
FLUSH MAIL QUEUE ist Autocommit-only (ein echter, sofortiger
Zustellversuch); DELETE MAIL QUEUE dagegen ganz normal in einer
Kette einreihbar.
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 (inkl. .version) |
✓ | ✓ | ✓ | ✓ | – | ✓ | – |
ctx.newRecord/ctx.oldRecord |
✓ | ✓ | ✓ | ✓ | – | ✓ | – |
ctx.flow |
– | – | – | ✓ | – | – (immer null) |
– |
ctx.run (inkl. ctx.run.global) |
✓ | ✓ | ✓ | – | ✓ | – | – |
ctx.patch |
✓ (nur CREATE/UPDATE) | – | – | – | – | – | – |
ctx.query()/ctx.get()/ctx.getAll() |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ (nur lesend) | ✓ (nur lesend) |
ctx.format.dateTime()/date()/time()/text() |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
ctx.exec()/ctx.create() |
– | – | ✓ | ✓ | ✓ | – | – |
ctx.mail()/ctx.httpQuery() |
– | – | ✓ | ✓ | ✓ | – | – |
ctx.abort()/ctx.message()/ctx.log() |
✓ (kein ctx.message sinnvoll ohne Schreibvorgang, aber technisch vorhanden) |
✓ | ✓ | ✓ | ✓ | nur ctx.log() |
nur ctx.log() |
ctx.relationChanges |
✓ (auf der virtuellen Seite einer Beziehung) | – | ✓ | – | – | – | – |
ctx.allRunnables/ctx.allFlows |
– | – | – | – | – | – | ✓ |
Jeder Record trägt außerdem acht implizite "Pseudo-Felder" neben
id/state/label — creator/creationTime/owner/
responsibleRole/deputyOwner/lastUpdated/lastUpdatedBy/version
(siehe Kapitel 4). Vier der fünf
schreibgeschützten bekommen ihre eigene, dedizierte ctx.X-Property,
genau wie ctx.state/ctx.label oben — nie Teil von ctx.new/
ctx.old. Die drei schreibbaren (owner/responsibleRole/
deputyOwner) sind dagegen ganz normale Mitglieder von ctx.new/
ctx.old, setzbar über ctx.patch.owner = 'alice' in einem
BEFORE-Trigger wie jedes andere schreibbare Feld — keine eigene Zeile
in der Tabelle oben nötig, sie folgen einfach dem, was dort schon
steht. version ist die Ausnahme unter den fünf schreibgeschützten:
ein reiner Änderungszähler (1 bei INSERT, +1 bei jeder
Auffrischung von lastUpdated), und anders als ein Zeitstempel ist
sein Vorher/Nachher-Wert selbst für ein Script interessant — deshalb
bleibt es ein ganz normales, nicht per ctx.patch änderbares Mitglied
von ctx.new/ctx.old statt einer eigenen ctx.version-Property.
Die Faustregel dahinter: BEFORE, ein globaler Trigger, und die drei
"Wächter"-Scripte (Label, Flow-Condition, Run-Condition) dürfen nichts
verändern — sie dürfen höchstens lesen (ctx.query()/ctx.get()/
ctx.getAll()) oder, bei einem lokalen BEFORE, über ctx.patch
den anstehenden Schreibvorgang selbst anpassen (ein globaler Trigger
bekommt kein ctx.patch, obwohl auch er immer BEFORE läuft — siehe
9.4.4). Jeder andere Scripttyp darf über ctx.exec() frei innerhalb
seiner Berechtigung schreiben.
Teil IV · Oberfläche
Kapitel 10
Alles bisher Gezeigte — QUERY, INSERT, UPDATE — ist Text, den Du
selbst eintippst oder ein GUI-Client selbst zusammenbaut. Eine
View kehrt das um: Ein Admin definiert einmal, wie ein Datensatz
(oder eine Liste davon) angezeigt und bearbeitet werden soll, und der
Server liefert auf Abruf ein fertiges HTML/JS-Fragment zurück, das ein
GUI-Client nur noch einbetten muss.
Eine View ist deklarativ: eine QUERY, benannte FIELD-Slots,
optionale Speicherschritte — ganz ohne Script. Gerendert wird sie mit
RENDER VIEW, gespeichert mit SAVE VIEW, und ihre Felder zeigen
Renderer an (10.1).
Früher gab es eine zweite, skriptbasierte Bauart (die „Script View“); sie wurde entfernt, weil sie niemand brauchte. Braucht ein Formular einen berechneten Vorgabewert, erledigt das ein
BEFORE CREATE-Trigger mitctx.patch(Kapitel 9) oder einRUNNABLE.
Ein RENDERER ist ein benannter JS-Baustein, gebunden an genau einen
Feldtyp, der steuert, wie ein einzelnes Feld angezeigt und
bearbeitet wird — unabhängig von der jeweiligen View, in der er
verwendet wird:
CREATE RENDERER text_short FOR STRING
function renderView(value, ctx) { return value; }
function renderEdit(value, ctx) { return value; }
function readEdit(ctx) { return ctx.container.find('input').val(); }
%%;
Bis zu drei Funktionen: renderView (Pflicht) erzeugt die
Nur-Lese-Darstellung, renderEdit die Bearbeitungsdarstellung (meist
ein <input> mit dem aktuellen Wert vorbelegt), readEdit liest den
vom Nutzer eingegebenen Wert wieder aus dem DOM zurück, wenn
gespeichert wird. Diese drei Funktionen laufen im Browser des
Clients, nicht auf dem Server — der Server liefert sie nur als Teil
des HTML/JS-Fragments aus.
renderEdit und readEdit gehören zusammen: entweder beide oder keine.
Ein Renderer mit nur renderView ist reine Anzeige — das Feld wird auch
im Edit-Modus über renderView gezeigt und beim Speichern nie
zurückgelesen (als wäre es READONLY). Der Server prüft das beim Anlegen
und Ändern eines Renderers und lehnt fehlendes renderView, einen
Syntaxfehler oder nur eine der beiden Funktionen ab.
Nennst Du in einem FIELD keinen expliziten Renderer, greift einer von
acht eingebauten Standard-Renderern, passend zum Feldtyp (STRING,
TEXT, INT, FLOAT, BOOL, DATETIME, JSON, sowie je einer für
eine ONE- bzw. MANY-seitige Beziehung). Die beiden
Beziehungs-Renderer bieten im Bearbeitungsmodus direkt eine
Sucheingabe mit Server-Rückfrage, keinen nackten ID-Eingabefeld.
Eine SINGLE VIEW hat einen von drei Modi:
MODE VIEW (Standard, falls weggelassen) — reine Nur-Lese-Ansicht
eines vorhandenen Datensatzes.MODE EDIT — Ansicht und Bearbeitung eines vorhandenen
Datensatzes.MODE CREATE — reine Neuanlage, kein vorhandener Datensatz;
jedes FIELD bekommt statt einer Query-Bindung einen eigenen Typ.RENDER VIEW ingredient_edit WITH id = 3; -- nur lesen
RENDER VIEW ingredient_edit EDIT WITH id = 3; -- lesen + bearbeiten
SAVE VIEW ingredient_edit WITH id = 3, name = 'Saffron', unit = 'g';
RENDER VIEW/SAVE VIEW sind Autocommit-only (nicht in einer
BEGIN...COMMIT-Kette einreihbar) und brauchen keine eigene
Berechtigung — autorisiert wird jeder einzelne zugrundeliegende Befehl
ganz normal über read/create/update, als der aufrufende Nutzer,
nicht als der Admin, der die View definiert hat.
Eine SINGLE VIEW kann außerdem vier rein informative Hinweise tragen
— SHOW WORKFLOW/SHOW DOCS/SHOW LOG/SHOW RUNSCRIPTS —, die einem
Client sagen, ob er zusätzlich Workflow-Status, Dokumentenanhänge
(Kapitel 12), das Änderungsprotokoll (Kapitel
15) oder einen Starter für die ausführbaren Skripte
des Datensatzes anzeigen soll. Der Server prüft dabei nicht nach, ob
das überhaupt zutrifft (Workflow vorhanden, Rechte ausreichend) — das
bleibt Aufgabe des Clients.
Jede View trägt zusätzlich ein eigenes LABEL — eine reine
Anzeige-Beschriftung, die ein GUI als Überschrift der gerenderten
Ansicht nutzt (nicht zu verwechseln mit dem nachgestellten LABEL an
einem einzelnen FIELD oder dem label-Pseudo-Feld eines Datensatzes).
Wie beim Space-/Workflow-Label gilt: Lässt man LABEL '<text>'
weg, ist das Label der View-Name; es ist nie leer und hat keine
DROP-Form. LABEL '<text>' ist eine Kopf-Klausel.
Nachträglich ändern
mit ALTER VIEW <name> SET LABEL '<text>'. RENDER VIEW gibt es als
Zeile % view_label: zurück (DATA.viewLabel im Fragment), SHOW VIEW
als % label:.
CREATE RENDERER supplier_picker FOR RELATION
function renderView(value, ctx) {
return value === null ? '' : (value.label || ('#' + value.id));
}
function renderEdit(value, ctx) { return value === null ? '' : value.id; }
function readEdit(ctx) { return ctx.container.find('input').val(); }
%%;
CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id"
FIELD name RENDERER text_short
FIELD unit
FIELD supplier RENDERER supplier_picker
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit, supplier = $supplier WHERE id = $id"
;
Eine SINGLE VIEW in MODE VIEW/EDIT hat einen impliziten
id-INT-Parameter — die id des Datensatzes. Er wird nie deklariert
(PARAM id ist ein Fehler); RENDER VIEW/SAVE VIEW erwarten WITH id
= <id>, und $id wird in QUERY/ON SAVE wie ein deklarierter
Parameter eingesetzt. Der „Record-Space" der View ist die
QUERY-Wurzel (hier ingredient) und wird zurückgemeldet, damit ein
Client weiß, welchen Datensatz er anzeigt.
Davon unabhängig gilt: jede gerenderte View beginnt mit der
Record-ID — eine LIST VIEW zeigt sie als erste Spalte, eine SINGLE
VIEW als erstes Feld. Dafür ist nichts zu deklarieren, der Server
stellt sie voran, immer nur lesend; gespeichert wird sie nie, SAVE
VIEW schreibt sie also auch nicht zurück. Nur eine MODE CREATE-View
hat keine — dort gibt es den Datensatz ja noch gar nicht.
Eine View ist ein einziges ;-Kommando — die Klauseln
stehen ohne Trenner hintereinander (Zeilenumbruch zählt wie ein
Leerzeichen), schlüsselwortgeführt, in zwei Stufen: erst die
Kopf-Klauseln (MODE, LABEL, SHOW ..., CHECK VERSION, RECORD,
PARAM), dann die Struktur-Klauseln (QUERY, FIELD, GROUP, ON
SAVE). Innerhalb einer Stufe ist die Reihenfolge frei; bedeutsam
bleiben nur die FIELD-Position und die ON SAVE-Reihenfolge. Eine
Kopf-Klausel nach der ersten Struktur-Klausel ist ein harter Fehler —
diese Grenze ist es, die LABEL (Kopf-Klausel und FIELD-Modifier)
eindeutig macht. Jede Klausel höchstens einmal außer
FIELD/PARAM/ON SAVE:
PARAM <name> AS <typ> — ein zusätzlicher typisierter
Eingabewert (einer der sechs Skalartypen, nie eine Beziehung) neben
dem impliziten id, später per $name in QUERY/ON SAVE
einsetzbar.QUERY "<konsolenbefehl>" — höchstens einmal, ein
String-Literal ("..." oder '...'), dessen Inhalt roh
gespeichert wird (inklusive $PARAM-Platzhaltern) und erst beim
tatsächlichen RENDER VIEW mit echten Werten ersetzt und geparst
wird. Ein inneres '...' braucht im "..."-Literal kein Escaping.
In MODE CREATE komplett verboten — es gibt ja noch keinen
Datensatz.FIELD <spaltenpfad> [RENDERER <name> [WITH ...]] [READONLY] [NAVIGATE TO <view>] [LABEL '<text>'] —
ein Feld ist eine Ausgabespalte der QUERY, benannt durch ihren
vollständigen Pfad mit demselben ->/^ wie die QUERY selbst
(name, supplier->name, supplier->^lieferstatus). Kein
zusätzlicher Bezeichner, kein SOURCE — da jede Spalte einen
eindeutigen kanonischen Namen hat und dieselbe Spalte nicht zweimal
projiziert werden kann, ist das Feld strukturell 1:1 mit der Spalte.
Nur in MODE VIEW/EDIT gültig.FIELD <name> AS <typ> [DEFAULT <literal>] [READONLY] [LABEL '<text>'] — die
Alternative für MODE CREATE: ein einfacher Feldname, kein Bezug zu
einer Query, stattdessen ein eigener Typ und ein optionaler,
statischer Vorgabewert.Das optionale, nachgestellte LABEL '<text>' gibt dem Feld eine
Anzeige-Beschriftung, die eine GUI statt des nackten Feldnamens
darstellt (im gerenderten Fragment wird sie zum <label> jedes Feldes
bzw. zur Spaltenüberschrift der Liste). Auf das Speichern hat es keine
Wirkung. Fehlt es, fällt der Client auf den Feldnamen zurück.
Nachträglich umschaltbar über ALTER VIEW ... ALTER FIELD <name> SET
LABEL '<text>' / ... DROP LABEL.
Das optionale, nachgestellte READONLY macht ein Feld auch in
MODE EDIT/CREATE nur anzeigbar, nie bearbeitbar: RENDER VIEW EDIT
zeigt es immer über renderView, nie über renderEdit, und der
Speichern-Knopf des mitgelieferten Fragments sammelt für dieses Feld
gar keinen Wert ein. Das ist ein reiner Anzeige-Hinweis, keine neue
serverseitige Durchsetzung — ein ON SAVE-Schritt, der $feld
trotzdem referenziert, schreibt weiterhin, was ankommt. Nachträglich
umschaltbar über ALTER VIEW ... ALTER FIELD <name> SET READONLY /
... UNSET READONLY.
ON SAVE "<UPDATE-oder-INSERT-Befehl>" — ein oder mehrere
Speicherschritte (Payload als String-Literal), in Eintippreihenfolge
ausgeführt, dürfen $PARAM und $FIELD referenzieren. Ein Feld mit
->/^ im Pfad wird als ${supplier->name} (geklammert)
referenziert — $supplier->name würde sonst als $supplier gefolgt
von ->name gelesen. Nur in MODE EDIT/CREATE; in MODE CREATE
muss der erste Schritt ein INSERT sein.ALTER VIEW ändert gezielt einzelne Teile, ohne die ganze View neu
anzulegen:
ALTER VIEW ingredient_edit ADD PARAM MIN_UNIT AS STRING;
ALTER VIEW ingredient_edit DROP PARAM MIN_UNIT;
ALTER VIEW ingredient_edit SET QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id AND unit != ''";
ALTER VIEW ingredient_edit ADD FIELD note;
ALTER VIEW ingredient_edit DROP FIELD note;
ALTER VIEW ingredient_edit ALTER FIELD supplier SET RENDERER supplier_picker WITH variant = 'dropdown';
ALTER VIEW ingredient_edit ALTER FIELD supplier DROP RENDERER;
ALTER VIEW ingredient_edit ALTER FIELD supplier SET READONLY;
ALTER VIEW ingredient_edit ALTER FIELD supplier UNSET READONLY;
ALTER VIEW ingredient_edit ADD ON SAVE "UPDATE ingredient SET supplier = $supplier WHERE id = $id";
ALTER VIEW ingredient_edit DROP ON SAVE 2;
ALTER VIEW ingredient_edit RENAME TO ingredient_form;
ALTER VIEW ingredient_edit DROP QUERY;
ALTER VIEW ingredient_edit SET MODE EDIT; -- oder MODE VIEW / MODE CREATE
ALTER VIEW ingredient_edit SET SHOW WORKFLOW; -- oder SHOW DOCS / SHOW LOG / SHOW RUNSCRIPTS
ALTER VIEW ingredient_edit UNSET SHOW WORKFLOW;
ALTER VIEW ingredient_edit SET LABEL 'Zutat bearbeiten'; -- das eigene Anzeige-Label der View
Ein FIELD darf eine Hop-Spalte sein
(QUERY ingredient -> (name, supplier -> (name)), FIELD supplier->name);
um so eine Spalte zurückzuschreiben, folgt ein ON SAVE-Schritt
demselben Hop mit UPDATE <wurzel> -> <hop> SET <spalte> =
${supplier->name} WHERE id = $id (siehe
Kapitel 6).
Hat der Hop ein DETAIL SPACE (Kapitel 6, "Relationsdetails"), kann
eine ^-Spalte (QUERY ingredient -> (name, supplier -> ^lieferstatus),
FIELD supplier->^lieferstatus) ein Feld dieses Detail-Datensatzes
anzeigen. Zurückgeschrieben wird sie
mit einem abschließenden -> ^-Schritt:
UPDATE <wurzel> -> <hop> -> ^ SET <detailfeld> = $field WHERE id = $id.
Existiert für das Paar noch kein Detail-Datensatz, wird einer aus den
SET-Zuweisungen angelegt und verknüpft. Erlaubt nur als letztes
Segment, nur nach mindestens einem ->-Hop und nur wenn der letzte Hop
eine ONE-seitige Relation mit DETAIL SPACE ist; für MANY:MANY nicht
unterstützt. Rechte: read auf Wurzel und Zwischenstationen,
update und create auf dem Detail-Space.
Im Referenz-PHP-Frontend wird eine View vollständig in einem Klick-Query-Builder gebaut und bearbeitet — er stellt
QUERY, jedesFIELD(benannt nach seinem Spaltenpfad) und dieON SAVE-Schritte (Wurzel plus einer pro Hop) für dich zusammen. Die handgeschriebenenALTER VIEW-Befehle oben bleiben der zugrundeliegende Mechanismus.
DROP PARAM/DROP QUERY/DROP FIELD werden abgelehnt, solange sie
noch irgendwo referenziert werden (in QUERY- oder ON
SAVE-Text) — dieselbe "noch referenziert"-Regel wie bei DROP
SCRIPT/DROP LIBRARY (Kapitel 9). Ein Renderer-Script selbst lässt
sich per ALTER RENDERER ... SET SCRIPT an Ort und Stelle austauschen,
genau wie ALTER SCRIPT ... SET SOURCE (Kapitel 9.2) — jede
bestehende FIELD-Bindung bleibt erhalten.
Eine View auf einem einzelnen Datensatz zeigt genau eine
Zeile. Was aber, wenn zu diesem Datensatz mehrere gehören — alle Zutaten
eines Rezepts, jede mit ihrer eigenen Menge? Die Menge gehört weder dem
Rezept noch der Zutat, sondern der Verknüpfung zwischen beiden; genau
dafür gibt es den Detail Space (Kapitel 6.2). Bis jetzt war er in einer
View nur zu sehen, wenn die Beziehung ONE war.
Eine GROUP löst das: ein wiederholter Block, der eine Zeile pro
Verknüpfung anzeigt.
Der Weg über die Abfrage funktioniert nicht. Man könnte auf die Idee kommen, die Spalte einfach in die
QUERYder View zu schreiben:QUERY rezept -> (name, zutaten -> menge). Diese Abfrage liefert aber eine Zeile pro Zutat — eine Einzelsatz-View zeigt jedoch genau eine. Früher hat sie stillschweigend die erste angezeigt und den Rest weggeworfen; heute lehnt der Server das beim Rendern mit einer klaren Meldung ab und verweist auf die Gruppe. Eine^-Spalte hinter einerONE-Beziehung ist davon nicht betroffen — dort gibt es genau einen Detail-Datensatz, und das funktioniert wie immer.
CREATE VIEW rezept_bearbeiten AS SINGLE
MODE EDIT
QUERY "QUERY recipe -> (name) WHERE id = $id"
FIELD name
GROUP ingredients LABEL 'Zutaten' ORDER BY name (
FIELD name READONLY
FIELD ^amount
FIELD ^unit
)
ON SAVE "UPDATE recipe SET name = $name WHERE id = $id"
;
Die Gruppe ist ihr Beziehungsfeld — ingredients adressiert das
gleichnamige MANY-Feld auf recipe, einen eigenen Namen braucht sie
nicht. Innerhalb des Blocks sind Feldnamen relativ zur Verknüpfung:
name ist ein Feld der Zutat selbst,^amount ist ein Feld des Detail-Datensatzes der Verknüpfung —
dasselbe ^, das Du aus QUERY kennst (Kapitel 6.2).Ein paar Dinge, die die Gruppe von selbst tut:
row und zeigt die Zutat selbst. Sie
identifiziert die Zeile und ist deshalb nie bearbeitbar — eine
Verknüpfung wird entfernt und neu angelegt, nicht umgebogen. Schreibst
Du FIELD row LABEL 'Zutat', konfigurierst Du damit genau diese Spalte,
Du bekommst keine zweite.ORDER BY name sortiert nach
dem Zutatennamen; bei Gleichstand (und ganz ohne ORDER BY) entscheidet
die Datensatz-ID. Ohne diesen Nachschlüssel würde die Tabelle bei jedem
Neuladen anders herum stehen, was beim Bearbeiten aussieht, als wäre
etwas verlorengegangen. Sortieren darfst Du auch nach einer Spalte, die
Du gar nicht anzeigst.GROUP-Blöcke lassen sich nicht schachteln; der Rumpf steht
in Klammern ( ... ) (die Kopf-Klauseln LABEL/ANCHOR/ORDER
BY/NO ADD/NO REMOVE davor), ein fehlendes ) ist ein benannter
Fehler.read auf den Space
der Zeilen (hier ingredient) und — sobald eine ^-Spalte im Spiel ist
— auch auf den Detail Space. Fehlt eines davon, gibt es eine klare
Fehlermeldung; die Gruppe verschwindet nie stillschweigend.maxListViewRows, Kapitel 15).Eine ^-Spalte kann außerdem zum Detail-Datensatz navigieren:
FIELD ^amount NAVIGATE TO mengen_detail VIA DETAIL
VIA DETAIL sagt: übergib der Ziel-View die ID des Detail-Datensatzes,
nicht die der Zutat. Zeilen, für die es noch keinen Detail-Datensatz
gibt, sind einfach nicht anklickbar.
Damit eine Spalte beschreibbar wird, braucht die Gruppe einen eigenen
ON SAVE-Schritt — genau einen Schritt, der pro geänderter Zeile
einmal läuft:
GROUP ingredients LABEL 'Zutaten' ORDER BY name (
FIELD name READONLY
FIELD ^amount
FIELD ^unit
ON SAVE "UPDATE recipe -> ingredients -> ^ SET amount = ${^amount}, unit = ${^unit} WHERE id = $id AND ingredients = $row"
)
Zwei neue Platzhalter stehen darin zur Verfügung: $row ist die
Zutat dieser Zeile, und ${^amount} ist der in dieser Zeile
eingegebene Wert. Die geschweiften Klammern sind nötig, weil $^amount
nicht als Platzhalter erkennbar wäre — dieselbe Schreibweise wie bei
Pfad-Feldern (${teile->name}, Kapitel 10.2). Gibt es für die
Verknüpfung noch keinen Detail-Datensatz, legt der Schritt ihn an
(Kapitel 6.2).
Die Zeilen selbst verwaltet der Server, ohne dass Du dafür etwas schreibst. Das Frontend schickt beim Speichern die vollständige Liste der angezeigten Zeilen mit; alles Weitere ergibt sich daraus:
WITH DETAIL … OWNED samt
ihrem Detail-Datensatz, Kapitel 6.2),WHERE-Bedingungen der Beziehung greifen dabei ganz normal.Zwei Bremsen kannst Du im Kopf der Gruppe setzen:
GROUP ingredients LABEL 'Zutaten' NO ADD ( ... )
GROUP ingredients LABEL 'Zutaten' NO REMOVE ( ... )
NO ADD weist eine neue Zeile mit einer Fehlermeldung ab; NO REMOVE
ignoriert eine fehlende Zeile einfach. Der Unterschied ist Absicht: eine
neue Zeile kann nur bewusst geschickt worden sein, eine fehlende Zeile
dagegen kann genauso gut bedeuten „wurde gar nicht erst angezeigt“.
Genau aus diesem Grund ist eine gekappte Gruppe automatisch additiv:
Wenn die Liste länger ist als maxListViewRows, werden Zeilen nur noch
hinzugefügt, nie entfernt. Sonst würde ein einziger Klick auf „Speichern“
in einer Liste mit 5000 Verknüpfungen, von denen 1000 sichtbar sind, die
übrigen 4000 löschen.
Ein paar Dinge lehnt der Server rundheraus ab, statt sie stillschweigend
zu schlucken: dieselbe Zeile zweimal, eine Zeile ohne ausgewählte Zutat,
eine Zutat, die es gar nicht gibt, und eine Zeile, die einen Wert
weglässt, den ihr eigener ON SAVE-Schritt braucht.
In MODE CREATE startet die Gruppe leer, und jede Zeile, die jemand
hinzufügt, ist neu. Ein Problem gibt es dabei: die Verknüpfungen brauchen
die ID des Datensatzes — und den legt dieselbe View ja erst an. Deshalb
sagt die Gruppe mit ANCHOR, welche Variable ihn hält:
CREATE VIEW rezept_neu AS SINGLE
MODE CREATE
RECORD recipe
FIELD name AS STRING
GROUP ingredients ANCHOR $neu (
FIELD name READONLY
FIELD ^amount
ON SAVE "UPDATE recipe -> ingredients -> ^ SET amount = ${^amount} WHERE id = $neu AND ingredients = $row"
)
ON SAVE "INSERT INTO recipe (name = $name) AS $neu"
;
$neu kommt aus dem AS $neu des INSERT-Schritts. Die Schritte der
Gruppe laufen immer nach allen View-Schritten, die Variable steht also
bereit. Und weil eine Anlege-View gar keinen impliziten id-Parameter
hat, greift auch der Schritt der Gruppe über $neu auf den Datensatz zu.
ANCHOR ist in MODE CREATE Pflicht und überall sonst verboten:
In einer Bearbeiten-View hängt die Gruppe ohnehin an dem Datensatz, den
die View zeigt — und an genau dem wurden auch ihre Spalten aufgelöst.
Wer eine solche View benutzen soll, braucht auf dem Space der View nicht nur
create, sondern auchupdate: das Setzen der Verknüpfungen ist ein ganz normaler Schreibvorgang auf dem Datensatz — daran ändert sich nichts dadurch, dass er einen Moment vorher angelegt wurde.
Eine MODE CREATE-View braucht keine QUERY — sie legt einen neuen
Datensatz an, statt einen vorhandenen zu zeigen. Sie hat kein
implizites id, und eine MODE CREATE-View muss ihr
INSERT-Ziel mit einer RECORD <space>-Klausel nennen:
CREATE VIEW ingredient_new AS SINGLE
MODE CREATE
RECORD ingredient
FIELD name AS STRING
FIELD unit AS STRING DEFAULT 'g'
ON SAVE "INSERT INTO ingredient (name = $name, unit = $unit)"
;
RENDER VIEW ingredient_new EDIT; -- ohne EDIT abgelehnt, es gibt ja noch keinen Datensatz
Neben der bisher gezeigten SINGLE VIEW (genau ein Datensatz) gibt es
auch eine LIST VIEW — CREATE VIEW ingredient_list AS LIST ... —,
dieselbe Syntax, nur für eine ganze Ergebnisliste statt
eines einzelnen Datensatzes; MODE/SHOW WORKFLOW/SHOW DOCS/SHOW
LOG/SHOW RUNSCRIPTS gibt es dort nicht, da diese sich auf genau
einen Datensatz beziehen.
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;
Zwei deiner Köche öffnen gleichzeitig dieselbe Zutat zum Bearbeiten,
lassen die Ansicht eine Weile offen und speichern dann nacheinander.
Ohne weiteres Zutun überschreibt der zweite Speichervorgang einfach
still die Änderung des ersten, und niemand bemerkt es. CHECK VERSION
schließt genau diese Lücke — mit einer einzigen zusätzlichen Klausel
und sonst keinem weiteren Code:
CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
CHECK VERSION
QUERY "QUERY ingredient -> (name, unit) WHERE id = $id"
FIELD name
FIELD unit
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit WHERE id = $id"
;
Das knüpft an das version-Feld an, das jeder Record ohnehin
automatisch mitträgt (Kapitel 4) — ein einfacher Zähler, der bei jeder
Änderung um eins hochgezählt wird. RENDER VIEW merkt sich still die
aktuelle version der Zutat mit dem Formular; wenn der zweite Koch
seinen SAVE VIEW-Befehl schickt, bemerkt der Server, dass sich die
Zutat seit dem Laden der Kopie weiterbewegt hat, und weist das Speichern
mit einer klaren Fehlermeldung zurück, statt die Änderung des ersten
Kochs stillschweigend zu verwerfen. Dessen eigenes Speichern (das den
tatsächlich aktuellen Stand widerspiegelt) geht problemlos durch.
Du musst dafür kein version-FIELD hinzufügen, deine QUERY nicht
anpassen und deinen ON SAVE-Schritt nicht anfassen — die Prüfung
passiert komplett außerhalb der von dir deklarierten Felder. CHECK VERSION ist ein
nacktes Flag (geprüft wird das implizite id gegen den Record-Space
der View) und ergibt nur in MODE EDIT Sinn — nur ein bestehender
Record kann sich "seit dem Laden geändert haben"; ein brandneuer, in
MODE CREATE, kann das nicht:
ALTER VIEW ingredient_edit SET CHECK VERSION;
ALTER VIEW ingredient_edit UNSET CHECK VERSION;
Wer statt der vom Server mitgelieferten Bearbeitungsoberfläche eine
eigene baut, sollte Folgendes wissen: Das generierte Fragment schickt
die gemerkte Version beim Speichern automatisch als __version__-Wert
mit — siehe GUI-INTEGRATION.md, falls du diesen Teil selbst
schreibst.
Ein Feld kann auf eine andere View verweisen — so klickt man sich von
der Zutatenansicht zum Lieferanten, von der Lieferantenliste zum
einzelnen Lieferanten und so weiter. Das Suffix heißt NAVIGATE TO
<view>, nachgestellt wie READONLY:
CREATE VIEW ingredient_edit AS SINGLE
MODE EDIT
QUERY "QUERY ingredient -> (name, unit, supplier) WHERE id = $id"
FIELD name NAVIGATE TO ingredient_history
FIELD unit
FIELD supplier NAVIGATE TO supplier_detail
ON SAVE "UPDATE ingredient SET name = $name, unit = $unit WHERE id = $id"
;
Welche ID beim Klick mitwandert, ergibt sich aus der Feldart — genau so, wie man es erwartet:
supplier): die ID des verwiesenen Records —
also der Lieferant, auf den das Feld zeigt. Bei einem Mehrfach-Feld
ist jeder einzelne Eintrag für sich anklickbar.name): die ID des angezeigten Records — bei
einer SINGLE VIEW ihr eigenes implizites id, bei einer LIST VIEW
die der angeklickten Zeile (die steht dort immer zur Verfügung, siehe
die implizite ID-Spalte oben).Bei einer MODE VIEW/EDIT-View ist der Record-Space die
QUERY-Wurzel, ein Nicht-Beziehungsfeld navigiert also ohne
Zusatzklausel. Eine MODE CREATE-View nennt ihn mit ihrer
RECORD <space>-Klausel — kein WITH ID
mehr, die Record-ID ist immer das implizite id.
Das Ziel muss eine SINGLE VIEW in MODE VIEW/EDIT sein (sie
empfängt die ID als ihr eigenes implizites id). Gibt es das Ziel
nicht oder ist es keine solche View, wird die Navigation für dieses
Feld still weggelassen — die Quell-View bricht nicht ab. NAVIGATE auf
einer MODE CREATE-View ist nicht erlaubt.
Nachträglich umschaltbar:
ALTER VIEW ingredient_new SET RECORD ingredient; -- nur MODE CREATE
ALTER VIEW ingredient_edit ALTER FIELD supplier SET NAVIGATE TO supplier_detail;
ALTER VIEW ingredient_edit ALTER FIELD supplier DROP NAVIGATE;
Die eigentliche Navigation macht die GUI, nicht der Server: das
generierte Fragment ruft eine vom Host bereitgestellte Funktion
window.noplmNavigate({ view, params }) auf — siehe GUI-INTEGRATION.md,
falls du die Oberfläche selbst schreibst.
Kapitel 11
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 — was sich nicht prüfen
ließ, wird gemeldet, nicht verschwiegen. VERIFY ALL DOCS durchsucht
zusätzlich das komplette Speicherverzeichnis nach Dateien ohne
zugehörige Datenbankzeile.
Eine gerade erst angelegte Datei lässt dieser Rückwärtslauf bewusst in
Ruhe: Beim Hochladen landet die Datei zuerst im Speicherverzeichnis und
erst danach ihre Datenbankzeile, sodass sie für einen kurzen Moment
tatsächlich wie eine verwaiste Datei aussieht. Wer sie in diesem Moment
löschte, erzeugte genau den Schaden, den VERIFY reparieren soll. Zu
junge Dateien werden deshalb übersprungen und in der Ausgabe als solche
ausgewiesen; beim nächsten Durchlauf sind sie alt genug.
Teil V · Verwaltung
Kapitel 13
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 ROLE HIERARCHY TOP;
% role_hierarchy: head_chef|cook
*
noplm> LIST PERMISSIONS OF head_chef TOP;
% permission: recipe|true|true|true|true|false|true|false|false|false
*
noplm> LIST PERMISSIONS OF head_chef ALL;
% permission: recipe|true|true|true|true|false|true|false|false|false
% permission: ingredient|true|false|false|false|false|false|false|false|false
*
TOP/ALL sind bei jedem dieser Befehle Pflicht, ohne
Standardwert — ein Client soll nie überrascht werden, welche der
beiden Sichten er gerade bekommt. SHOW ROLE <role>; ist die einzige
Ausnahme: Sie zeigt immer nur die direkt an dieser einen Rolle
vergebenen Rechte, ganz ohne TOP/ALL-Parameter — die aggregierte
Sicht gibt es dort ausschließlich über LIST PERMISSIONS OF <role>
ALL;.
LIST ROLE HIERARCHY TOP|ALL; ist die Stapelform von LIST ROLES OF
ROLE — jede Eltern→Kind-Kante in einem Befehl (je eine Zeile
% role_hierarchy: <rolle>|<unterrolle>), damit eine Admin-Ansicht,
die jede Rolle neben ihren Unterrollen auflistet, nicht einen Aufruf
pro Rolle braucht.
LIST USERS/SHOW USER/SHOW ROLE/LIST ROLES OF/LIST ROLE
HIERARCHY/LIST PERMISSIONS OF sind admin-only. Für die eigene
Sitzung gibt es drei Gegenstücke,
die keine Berechtigung brauchen — praktisch für ein GUI, das ohne
Admin-Rechte entscheiden will, was es anzeigt:
SHOW ME;
% user: maria
% admin: false
% enabled: true
% roles: cook
*
SHOW MY PERMISSIONS ON recipe;
% permission: recipe|true|true|true|false|false|false|false|false|false
*
LIST MY ROLES ALL;
% role: cook
*
Eine Admin-Sitzung bekommt bei SHOW MY PERMISSIONS ON immer alle
neun Rechte als true zurück, ohne dass die Abfrage überhaupt
gegen die Rollenzuweisungen läuft — Admins sind von jeder
Rechteprüfung ohnehin ausgenommen.
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/version
dagegen werden nie aus der Datei wiederhergestellt — IMPORT
DATA selbst zählt als Änderung, lastUpdated/lastUpdatedBy kommen
also immer frisch gestempelt mit dem Zeitpunkt des Imports und dem
importierenden Admin zurück, genau als hätte gerade ein normaler
Schreibvorgang stattgefunden, und version beginnt wieder bei 1,
genau wie bei jedem frisch eingefügten Record.Der ganze Import läuft als eine einzige, atomare Operation — jeder Fehler an irgendeiner Stelle rollt alles zurück, was der Befehl bis dahin geschrieben hat, genau wie bei jedem anderen einzelnen Autocommit-Befehl.
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.
AUTONUMBER-Zähler werden ebenfalls wiederhergestellt, und zwar
ausschließlich vorwärts: steht der Zähler im Ziel bereits weiter als
in der Datei, behält er den höheren Wert. Ihn zurückzudrehen hieße, dass
der nächste INSERT eine bereits vergebene Nummer ein zweites Mal
ausgibt — und wäre der zugehörige Datensatz zwischenzeitlich gelöscht
worden, würde sich nicht einmal jemand beschweren.
Kapitel 15
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/ALLOW/GUARD
SCRIPTS, SHUTDOWN, EXPORT STRUCTURE/EXPORT DATA), und jeder
Befehl, der fehlgeschlagen ist oder dessen umgebende Kette
zurückgerollt wurde — nur, was tatsächlich durchging, taucht auf. Die
Zeilennummer ist zugleich die fortlaufende Zeilen-ID über das ganze
Protokoll hinweg, nicht pro Nutzer oder Space. LIST ADMIN LOG ist
admin-only — anders als das Field Log, das pro Space über logs
gesteuert wird.
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.
Jedes Rezept ist ein Verzeichnis mit einer Installationsdatei, einer README
und einer kurzen recipe.json. Hat Dein Server ein recipeDirectory
konfiguriert, kannst Du sie an der Konsole (als Admin) ansehen und anwenden:
LIST RECIPES;, SHOW RECIPE <name>; und INCLUDE RECIPE <name> [WITH ...];
— die Installations-Abschnitte unten beschreiben auch das Abspielen der
Installationsdatei von Hand.
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:
Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:
{
"create": {"role": ["cook", "head_chef"]},
"update": {"role": ["cook", "head_chef"]},
"delete": {"tag": ["approver"]},
"run": {"reset_recipe": {"role": ["head_chef"]}},
"flow": {
"byName": {"approve": {"role": ["head_chef"]}},
"byTag": {"critical": {"tag": ["approver"]}}
}
}
PUT noplm, simple_acls, recipe '{"create":{"role":["cook","head_chef"]},"update":{"role":["cook","head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}},"flow":{"byName":{"approve":{"role":["head_chef"]}},"byTag":{"critical":{"tag":["approver"]}}}}';
# OK
@ rows: 1
Fünf mögliche Schlüssel, alle optional — fehlt einer, ist die entsprechende Aktion für diesen Space uneingeschränkt:
| Schlüssel | Wert | Bedeutung |
|---|---|---|
create/update/delete |
eine Regel (siehe unten) | die Regel muss den aufrufenden User erlauben |
run |
Objekt {scriptName: Regel} |
reine Ausnahmeliste — ein hier genannter Name wird durch seine Regel beschränkt, jeder nicht genannte Name bleibt frei |
flow |
{byName: {flowName: Regel}, byTag: {flowTag: Regel}} |
reine Ausnahmeliste, ausgewählt über den Namen des Flows und/oder seine Tags (TAG FLOW <workflow>.<flow> WITH ...;) |
Ein Space ganz ohne simple_acls-Eintrag ist von diesem Rezept
komplett unberührt — auch wenn die Scripte unten global angehängt
sind.
Eine Regel ist ein Objekt mit bis zu zwei optionalen Schlüsseln,
role und tag, jeweils ein Array — z. B. {"role": ["cook",
"head_chef"], "tag": ["approver"]}. Sie erlaubt den aufrufenden User,
wenn entweder eine der genannten Rollen direkt auf ihn zutrifft
(ctx.userRoles), oder einer der genannten
Rollen-Tags (TAG ROLE <role>
WITH <tag>;) auf irgendeine Rolle in seinem effektiven Rollen-Set
zutrifft (ctx.userRoleTags) — ein ODER zwischen role und tag, da
beide nur zwei verschiedene Arten sind, dieselbe Personengruppe zu
benennen. Eine Regel ohne beide Schlüssel erlaubt niemandem etwas.
Tags sind die wartungsärmere Wahl, wann immer "wer darf" mit der Zeit
wachsen soll, ohne diese Config anzufassen: jede Rolle taggen, die als
approver gelten soll, den Tag einmal in der Regel referenzieren — und
eine neu getaggte Rolle bekommt die Berechtigung automatisch, ganz
ohne PUT.
flows byName/byTag-Aufteilung ist eine andere Achse als das
role/tag einer Regel — sie wählt aus, welcher Flow eine Regel
betrifft (über den Namen des Flows selbst, oder über einen Tag am Flow
selbst), nicht wer ihn ausführen darf. Trifft ein Flow sowohl auf
einen byName-Eintrag als auch auf einen oder mehrere
byTag-Einträge zu, müssen alle zutreffenden Regeln unabhängig
voneinander erlauben (UND, nicht ODER) — anders als role/tag
innerhalb einer Regel sind byName- und byTag-Einträge unabhängig
konfigurierte Einschränkungen, ein Tag auf einem Flow kann also nur
enger machen, was die eigene Namensregel bereits erlaubt, nie weiter
öffnen. run hat kein byTag-Gegenstück — ein RUNNABLE-Skript kann
gar kein TAG tragen.
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: {{noplm:text|en=None of your roles/tags permit "create" on space "recipe"|de=Keine Ihrer Rollen/Tags erlaubt "create" im Space "recipe"}}
intern_max scheitert trotz eigener GRANT CREATE-Berechtigung — die
Rolle intern steht schlicht nicht in der Config. DELETE ist rein
über den Tag approver beschränkt, nicht über einen bestimmten
Rollennamen:
DELETE FROM recipe WHERE id = 1; -- als cook_bob
! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_simple_acls_before' on space 'recipe' aborted: {{noplm:text|en=None of your roles/tags permit "delete" on space "recipe"|de=Keine Ihrer Rollen/Tags erlaubt "delete" im Space "recipe"}}
DELETE FROM recipe WHERE id = 1; -- als sous_chef_erik
# OK
@ rows: 1
sous_chef_erik gelingt es rein wegen des approver-Tags —
sous_chef steht an keiner Stelle in der Config. Bei flow/run ist
die Config dagegen eine reine Ausnahmeliste — test/revise tauchen
dort gar nicht auf und bleiben frei:
FLOW test ON recipe WITH ID = 2; -- als cook_bob
# OK: flow 'test' executed
approve ist sowohl über eine byName-Regel (Rolle head_chef) als
auch, weil der Flow selbst den Tag critical trägt, über eine
byTag-Regel (Tag approver) beschränkt — beide müssen gleichzeitig
zutreffen:
FLOW approve ON recipe WITH ID = 2; -- als sous_chef_erik
! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 2;'): flow 'approve' is not permitted for record 2 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 2; -- als head_chef_maria
# OK: flow 'approve' executed
sous_chef_erik scheitert trotz approver-Tag — die byName-Regel
verlangt konkret die Rolle head_chef, und da beide Regeln zutreffen
müssen (UND), reicht der Tag allein nicht. head_chef_maria hält
sowohl die Rolle (für byName) als auch, über head_chefs eigenen
Tag, den Tag (für byTag) — beide Regeln bestehen. Dasselbe Prinzip
bei RUN SCRIPT:
RUN SCRIPT print_shopping_list ON recipe WITH ID = 2; -- als cook_bob, nicht in der Config erwähnt
# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 2; -- als head_chef_maria
# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;
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
@ rows: 1
Jede Feldregel trägt ein type (bestimmt, welche Prüf-Funktion greift)
plus die passenden Constraint-Schlüssel; ein optionales message
überschreibt die generierte Standardmeldung — entweder ein einfacher String
oder ein Objekt pro Sprache wie {"en":"Too low","de":"Zu niedrig"}. Auch die
generierten Standardmeldungen sind zweisprachig (Englisch, Deutsch). Fehlt ein Feld in der
Config, bleibt es uneingeschränkt — genau wie bei simple_acls ist ein
Space ganz ohne Eintrag komplett unberührt.
| Typ | Constraints |
|---|---|
string/text |
length, minLength, maxLength, pattern, oneOf, notBlank |
int |
min, max, multipleOf, oneOf |
float |
min, max, oneOf |
bool |
equals |
datetime |
minDate, maxDate, notPast, notFuture |
json |
maxLength (nur Rohtext-Länge, keine Struktur-/Schema-Prüfung) |
relation |
minCount, maxCount — nur auf der MANY-Seite sinnvoll (ctx.new[feld] ist dort ein Array); für die ONE-Seite leisten die Relation-WHERE-Klauseln (Kapitel 6) das Äquivalent |
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: {{noplm:text|en=Field "unit" must be one of: g, ml, pcs (got "kg")|de=Feld "unit" muss einen von diesen Werten haben: g, ml, pcs (erhalten: "kg")}}
kg steht nicht in der konfigurierten oneOf-Liste — die Fehlermeldung
nennt den Feldnamen und den tatsächlich übergebenen Wert. Ähnlich bei
numerischen und Relation-Feldern:
INSERT INTO recipe (name = 'X', servings = 0, ingredients = [1]);
! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: {{noplm:text|en=Field "servings" must be >= 1 (got 0)|de=Feld "servings" muss >= 1 sein (erhalten: 0)}}
INSERT INTO recipe (name = 'Y', servings = 4, ingredients = []);
! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'recipe' aborted: {{noplm:text|en=Field "ingredients" must reference at least 1 record(s) (got 0)|de=Feld "ingredients" muss mindestens 1 Datensatz/Datensätze referenzieren (erhalten: 0)}}
Ein Rezept ganz ohne Zutat verstößt gegen minCount. Bei stock_level
greift zusätzlich der _pairs-Vergleich mit eigener Meldung:
INSERT INTO stock_level (ingredient = 1, min_stock = 50, max_stock = 10, checked_at = '2026-08-20 10:00:00');
! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'stock_level' aborted: field_constraints: min_stock must not exceed max_stock
INSERT INTO stock_level (ingredient = 1, min_stock = 5, max_stock = 50, checked_at = '2099-01-01 00:00:00');
! ROLLBACK: command (...): script 'noplm_field_constraints_before' on space 'stock_level' aborted: {{noplm:text|en=Field "checked_at" must not be in the future|de=Feld "checked_at" darf nicht in der Zukunft liegen}}
Ein gültiger Eintrag geht anstandslos durch:
INSERT INTO stock_level (ingredient = 1, min_stock = 10, max_stock = 50, checked_at = '2026-08-20 10:00:00', notes = '{"warehouse":"A1"}');
% id: 1
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
@ rows: 1
Bis zu vier mögliche Schlüssel, alle optional:
| Schlüssel | Wert | Bedeutung |
|---|---|---|
update/delete |
true |
Operation ist owner-gated: erlaubt nur dem aktuellen Owner, Deputy Owner oder einem Mitglied der responsibleRole des Records |
run/flow |
Array von Namen | reine Ausnahmeliste (wie bei simple_acls) — ein hier genannter Name ist owner-gated, jeder nicht genannte Name bleibt unabhängig von Ownership frei |
Kein create-Schlüssel — CREATE bleibt strukturell immer
unbeschränkt, da owner bei Anlage automatisch auf den Ersteller selbst
defaulted, es also nichts zu prüfen gibt. Anders als bei simple_acls
trägt update/delete auch keine Rollenliste — die Zugriffsregel ist
fix (Owner/Deputy/verantwortliche Rolle dieses konkreten Records), ein
bloßes true genügt. Ein Space ganz ohne owner_acls-Eintrag ist von
diesem Rezept komplett unberührt.
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
@ rows: 1
intern_max hat volles GRANT UPDATE, ist aber weder Owner noch
Deputy noch Mitglied der responsibleRole:
UPDATE recipe SET name = 'Hacked' WHERE id = 1; -- als intern_max
! ROLLBACK: command ('UPDATE recipe SET name = 'Hacked' WHERE id = 1;'): script 'noplm_owner_acls_before' on space 'recipe' aborted: {{noplm:text|en=You are not the owner, deputy, or responsible-role holder of this record on space "recipe"|de=Sie sind weder Eigentümer noch Stellvertreter noch Inhaber der verantwortlichen Rolle dieses Datensatzes im Space "recipe"}}
Owner, Deputy und ein Mitglied der zuständigen Rolle dürfen dagegen alle drei ändern:
UPDATE recipe SET name = 'Kartoffelsuppe (v2)' WHERE id = 1; -- als cook_bob (Owner)
# OK
@ rows: 1
UPDATE recipe SET name = 'Kartoffelsuppe (v3)' WHERE id = 1; -- als sous_chef_lea (deputyOwner)
# OK
@ rows: 1
UPDATE recipe SET name = 'Kartoffelsuppe (v4)' WHERE id = 1; -- als head_chef_maria (hält die Rolle 'head_chef')
# OK
@ rows: 1
Bei flow/run ist die Config wie bei simple_acls eine reine
Ausnahmeliste — test taucht dort nicht auf und bleibt frei, auch für
intern_max:
FLOW test ON recipe WITH ID = 1; -- als intern_max
# OK: flow 'test' executed
approve ist dagegen owner-gated:
FLOW approve ON recipe WITH ID = 1; -- als intern_max
! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 1;'): flow 'approve' is not permitted for record 1 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 1; -- als head_chef_maria (Rolle passt)
# OK: flow 'approve' executed
Dasselbe Prinzip bei RUN SCRIPT: print_shopping_list ist nicht
genannt und bleibt frei, reset_recipe ist owner-gated, aber die
Deputy-Owner-Zuordnung greift auch hier:
RUN SCRIPT print_shopping_list ON recipe WITH ID = 1; -- als intern_max, nicht in der Config erwähnt
# OK: script 'print_shopping_list' executed
% return:
% "ok"
% ;
RUN SCRIPT reset_recipe ON recipe WITH ID = 1; -- als sous_chef_lea (deputyOwner)
# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;
DELETE schließlich, wieder nur für Owner/Deputy/responsibleRole:
DELETE FROM recipe WHERE id = 1; -- als intern_max
! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_owner_acls_before' on space 'recipe' aborted: {{noplm:text|en=You are not the owner, deputy, or responsible-role holder of this record on space "recipe"|de=Sie sind weder Eigentümer noch Stellvertreter noch Inhaber der verantwortlichen Rolle dieses Datensatzes im Space "recipe"}}
DELETE FROM recipe WHERE id = 1; -- als cook_bob (Owner)
# OK
@ rows: 1
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, text STRING,
UNIQUE (picklist, value)) — picklist ist der Gruppenname (z. B.
'recipe_difficulty'), value ein Eintrag darin, der optionale text
das, was der Benutzer statt des Werts sieht. Die
feldübergreifende UNIQUE-Constraint verhindert, dass ein Wert
zweimal innerhalb derselben Gruppe auftaucht, ohne ihn über alle
Gruppen hinweg global eindeutig zu machen — 'g' kann ein
ingredient_unit-Eintrag sein und zufällig auch in einer anderen
Picklist auftauchen, ohne Konflikt.noplm_picklist_label — ein einzeiliges Label-Skript (return
ctx.new.text || ctx.new.value;), zugewiesen über ALTER SPACE picklists
SET LABEL SCRIPT .... Jedes Relationsfeld, das auf picklists zeigt,
zeigt damit den Anzeigetext des Eintrags (ohne Text den Wert) als sein
Label an statt einer nackten ID — die eingebauten Relation-Renderer
zeigen ohnehin schon das Label des verlinkten Records an, siehe 11.yes_no, yes_no_unknown, rating (1–5), priority, severity,
approval_status, lifecycle, gender_salutation (zweisprachig, Englisch
und Deutsch) sowie unit_length, unit_mass (sprachneutral). Ihr value
ist ein sprachneutraler Schlüssel (yes, in_review); ein zweisprachiger
Eintrag trägt seinen Anzeigetext als mehrsprachigen Text-Marker, z. B.
{{noplm:text|en=Yes|de=Ja}}: ein Client zeigt die Sprache des
Betrachters, die Konsole den Rohtext. Der Relation-Picker sortiert nach
Label, daher tragen Listen mit natürlicher Reihenfolge ein Zahlenpräfix im
text (1 - Low, 2 - Medium, …).value verwenden, nicht
die ID, die pro Installation verschieden ist: WHERE difficulty -> value =
'Einfach' funktioniert in einer Abfrage oder Relation-WHERE ohne ID und
ohne Skript.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
RELATION picklists ONE WHERE picklist = 'recipe_difficulty'
AS recipe_difficulties MANY;
Ab hier ist difficulty ein ganz gewöhnliches Relationsfeld — die eigene
WHERE-Durchsetzung der Relation (bei jedem Schreibzugriff geprüft,
siehe 4) hält es tatsächlich auf recipe_difficulty-Einträge beschränkt:
QUERY picklists -> (id, picklist, value) WHERE picklist = 'recipe_difficulty';
@ column: 1|id|INT|false|
@ column: 2|picklist|STRING|false|
@ column: 3|value|STRING|false|
@ rows: 3
% 1 | 'recipe_difficulty' | 'Einfach'
% 2 | 'recipe_difficulty' | 'Mittel'
% 3 | 'recipe_difficulty' | 'Schwer'
INSERT INTO recipe (name = 'Kartoffelsuppe', difficulty = 1);
QUERY recipe -> (name, difficulty) WITH LABELS;
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|difficulty|RELATION|true|recipe_difficulty
@ rows: 1
% 1 | 'Kartoffelsuppe' | picklists:1('Einfach')
Eine ID aus einer anderen Picklist — selbst wenn es sich um eine
vollkommen gültige picklists-Zeile handelt — wird genauso abgelehnt
wie jede andere ungültige Referenz:
UPDATE recipe SET difficulty = 4 WHERE id = 1; -- ID 4 ist ein 'ingredient_unit'-Eintrag, kein 'recipe_difficulty'
! ROLLBACK: command ('UPDATE recipe SET difficulty = 4 WHERE id = 1;'): relation constraint violated: referenced picklists record (id 4) does not satisfy the condition on field 'difficulty'
Ein doppelter Eintrag innerhalb derselben Picklist trifft auf die
feldübergreifende UNIQUE-Constraint, nicht auf rezeptspezifische
Logik:
INSERT INTO picklists (picklist = 'recipe_difficulty', value = 'Einfach');
! ROLLBACK: command (...): INSERT failed: Error 1062 (23000): Duplicate entry 'recipe_difficulty-Einfach' for key 'uq_constraint_1'
Der Editier-Picker des Standard-Relation-Renderers (11) respektiert die
eigene WHERE-Klausel eines Relationsfelds ebenfalls schon von selbst
— eine VIEW über difficulty bietet dadurch
automatisch nur recipe_difficulty-Einträge an, ohne zusätzlichen,
picklist-spezifischen Renderer-Aufwand.
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 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:
Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:
{
"byName": {
"approved": {
"update": {"role": ["head_chef"]},
"delete": {"tag": ["approver"]},
"run": {"reset_recipe": {"role": ["head_chef"]}}
}
},
"byTag": {
"pending_review": {
"flow": {"byName": {"approve": {"role": ["head_chef"]}}}
}
}
}
PUT noplm, state_acls, recipe '{"byName":{"approved":{"update":{"role":["head_chef"]},"delete":{"tag":["approver"]},"run":{"reset_recipe":{"role":["head_chef"]}}}},"byTag":{"pending_review":{"flow":{"byName":{"approve":{"role":["head_chef"]}}}}}}';
# OK
@ rows: 1
byName-Schlüssel sind literale Zustandsnamen; byTag-Schlüssel sind
Tags an einem Zustand (TAG STATE
<workflow>.<state> WITH <tag>;). Jede ausgewählte Zustands-Regel
hat dann bis zu vier optionale Schlüssel, identisch zur Form von
simple_aclss eigenen Top-Level-Schlüsseln:
| Schlüssel (innerhalb einer Zustands-Regel) | Wert | Bedeutung |
|---|---|---|
update/delete |
eine Regel ({role, tag}, siehe 16.1) |
solange der Record in diesem Zustand ist, ist die Operation für alle gesperrt außer für einen Aufrufer, den die Regel erlaubt |
run |
Objekt {scriptName: Regel} |
reine Ausnahmeliste, dieselbe Form wie simple_aclss eigenes run |
flow |
{byName: {flowName: Regel}, byTag: {flowTag: Regel}} |
reine Ausnahmeliste, dieselbe Form wie simple_aclss eigenes flow |
Trifft ein Zustand auf mehr als einen Selektor gleichzeitig — seinen
eigenen literalen Namen in byName, plus einen oder mehrere seiner
Tags in byTag — werden die zutreffenden Zustands-Regeln mit UND
kombiniert: jede zutreffende Regel muss den Aufrufer unabhängig
erlauben, für update/delete/den run-Eintrag zu diesem Namen/den
flow-Eintrag zu diesem Namen. Das spiegelt simple_aclss eigene
byName/byTag-Kombination bei flow, aus demselben Grund: byName
und byTag sind unabhängig konfigurierte Einschränkungen, keine zwei
Schreibweisen derselben Gruppe — ein Tag auf einem Zustand kann also
nur enger machen, was die eigene Namensregel bereits erlaubt, nie
weiter öffnen (role/tag innerhalb einer Regel ist dagegen ein
ODER — siehe 16.1).
Ein Zustand, den keiner der beiden Selektoren trifft, ist völlig
unbeschränkt — auch ein Zustand, den der Record noch nicht erreicht hat
oder nie erreichen wird. Bewusst gibt es keinen create-Schlüssel auf
oberster Ebene: Der Zustand eines Records ist während BEFORE CREATE
immer null (siehe unten), Zustand kann Anlage also gar nicht
einschränken — dafür simple_acls kombinieren, falls zusätzlich
gebraucht. Ein Space ganz ohne state_acls-Eintrag, oder ein Space ganz
ohne angehängten Workflow (ctx.state ist dann immer null), ist von
diesem Rezept komplett unberührt.
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
@ rows: 1
Der Record steht jetzt im Zustand tested, ausgewählt rein über dessen
pending_review-Tag (der literale Name taucht in der Config gar nicht
auf), in dem approve auf head_chef beschränkt ist.
sous_chef_erik hält den Tag approver, aber diese eine Regel prüft
role/tag nur innerhalb ihrer selbst — sie wird von keiner anderen
Regel beeinflusst, der Tag allein hilft hier also nicht:
FLOW approve ON recipe WITH ID = 1; -- als sous_chef_erik
! ROLLBACK: command ('FLOW approve ON recipe WITH ID = 1;'): flow 'approve' is not permitted for record 1 of space 'recipe' by its flow condition script(s)
FLOW approve ON recipe WITH ID = 1; -- als head_chef_maria (hält die Rolle 'head_chef')
# OK: flow 'approve' executed
Der Record steht jetzt im Zustand approved, der sowohl über
byName (Rolle head_chef) als auch, über seinen locked-Tag,
über byTag (Tag approver) zutrifft — update verlangt also beide
Regeln gleichzeitig (UND). sous_chef_erik hält den Tag, aber nicht die
Rolle, und scheitert trotzdem — der Beweis, dass die beiden Selektoren
wirklich mit UND kombinieren, statt dass einer allein reichen würde:
UPDATE recipe SET name = 'Hacked' WHERE id = 1; -- als sous_chef_erik
! ROLLBACK: command ('UPDATE recipe SET name = 'Hacked' WHERE id = 1;'): script 'noplm_state_acls_before' on space 'recipe' aborted: {{noplm:text|en=State "approved" on space "recipe" does not permit "update" for your roles/tags|de=Der State "approved" im Space "recipe" erlaubt "update" für Ihre Rollen/Tags nicht}}
UPDATE recipe SET name = 'Kartoffelsuppe (final)' WHERE id = 1; -- als head_chef_maria
# OK
@ rows: 1
run steht nur in byNames "approved"-Regel, beschränkt auf
head_chef:
RUN SCRIPT reset_recipe ON recipe WITH ID = 1; -- als cook_bob
! ROLLBACK: command ('RUN SCRIPT reset_recipe ON recipe WITH ID = 1;'): script 'reset_recipe' is not permitted to run on record 1 of space 'recipe' by its run condition script(s)
RUN SCRIPT reset_recipe ON recipe WITH ID = 1; -- als head_chef_maria
# OK: script 'reset_recipe' executed
% return:
% "ok"
% ;
delete steht ebenfalls nur in byNames "approved"-Regel, und rein
über den Tag beschränkt — byTags "locked"-Regel hat gar keinen
delete-Schlüssel, hier greift also nur eine einzige Regel, und der
Tag allein reicht:
DELETE FROM recipe WHERE id = 1; -- als cook_bob
! ROLLBACK: command ('DELETE FROM recipe WHERE id = 1;'): script 'noplm_state_acls_before' on space 'recipe' aborted: {{noplm:text|en=State "approved" on space "recipe" does not permit "delete" for your roles/tags|de=Der State "approved" im Space "recipe" erlaubt "delete" für Ihre Rollen/Tags nicht}}
DELETE FROM recipe WHERE id = 1; -- als sous_chef_erik
# OK
@ rows: 1
sous_chef_erik gelingt es rein über den approver-Tag —
sous_chef steht an keiner Stelle in der Config.
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:
Die Konfiguration ist ein JSON-Objekt in einer Zeile (ein String-Literal darf keinen echten Zeilenumbruch enthalten) — hier zur Übersicht eingerückt:
{
"byName": {"difficulty": {"role": ["head_chef"]}},
"byTag": {"sensitive": {"tag": ["approver"]}}
}
PUT noplm, field_acls, recipe '{"byName":{"difficulty":{"role":["head_chef"]}},"byTag":{"sensitive":{"tag":["approver"]}}}';
# OK
@ rows: 1
byName-Schlüssel sind literale Feldnamen; byTag-Schlüssel sind
Tags an einem Feld (TAG FIELD
<space>.<field> WITH <tag>;). Jeder Wert ist eine Regel ({role,
tag}, dieselbe Form wie simple_aclss eigene Regeln — siehe 16.1):
Ändern des Felds ist für alle gesperrt außer für einen Aufrufer,
den die Regel erlaubt — eine leere Regel ({}) sperrt für alle, ohne
Ausnahme.
Trifft ein Feld sowohl auf einen byName-Eintrag als auch auf einen
oder mehrere byTag-Einträge zu, müssen alle zutreffenden Regeln
unabhängig voneinander erlauben (UND) — dieselbe Kombinationsregel, die
state_acls für seine eigene Zustandsauswahl nutzt, aus demselben
Grund: ein Tag auf einem Feld kann nur enger machen, was die eigene
Namensregel bereits erlaubt, nie weiter öffnen.
Ein Feld, das keiner der beiden Selektoren trifft, ist völlig
unbeschränkt (weiterhin nur an das normale GRANT UPDATE des Space
gebunden, wie immer). Das funktioniert für jedes Feld, das
ctx.old/ctx.new zeigen — nicht nur ein normales, selbst deklariertes
Space-Feld, sondern auch die drei schreibbaren Pseudo-Felder
owner/responsibleRole/deputyOwner (siehe CLAUDE.md §
"Per-record pseudo-fields" im Server-Repo) — z. B. um zwar jedem das
Ändern der eigentlichen Rezeptdaten zu erlauben, die Neuzuweisung des
owner aber einem head_chef vorzubehalten. Ein Pseudo-Feld kann kein
eigenes TAG FIELD tragen — es ist von vornherein keine Zeile in
_meta_fields — und lässt sich deshalb nur über byName auswählen.
Bewusst gibt es keinen Vergleich gegen den ursprünglichen Wert bei
Anlage — ein Space ganz ohne field_acls-Eintrag ist von diesem
Rezept komplett unberührt, und CREATE ist nie eingeschränkt (siehe
"Was installiert wird" unten).
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
@ rows: 1
name bleibt weiterhin völlig offen — es kommt in der Config gar nicht
vor und trägt auch keinen Tag:
UPDATE recipe SET name = 'Kartoffelsuppe (deluxe)' WHERE id = 1; -- als cook_bob
# OK
@ rows: 1
difficulty trifft sowohl über byName (Rolle head_chef) als
auch, über seinen sensitive-Tag, über byTag (Tag approver) zu —
beide müssen gleichzeitig gelten. sous_chef_erik hält den Tag, aber
nicht die Rolle, und scheitert trotzdem:
UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1; -- als sous_chef_erik
! ROLLBACK: command ('UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "difficulty" on space "recipe" may not be changed by your roles/tags|de=Das Feld "difficulty" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}
Das erneute Zuweisen des bereits aktuellen Werts ist keine "Änderung" und wird deshalb nie geprüft, auch nicht bei fehlender Rolle/Tag:
UPDATE recipe SET difficulty = 'Mittel', name = 'Kartoffelsuppe (unverändert)' WHERE id = 1; -- als cook_bob
# OK
@ rows: 1
Ein einzelner Befehl, der ein offenes und ein gesperrtes Feld
gleichzeitig ändert, ist alles-oder-nichts — die verbotene
Feldänderung blockiert den gesamten Befehl, einschließlich der für
sich genommen unproblematischen name-Änderung:
UPDATE recipe SET name = 'Sneaky', difficulty = 'Schwer' WHERE id = 1; -- als cook_bob
! ROLLBACK: command ('UPDATE recipe SET name = 'Sneaky', difficulty = 'Schwer' WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "difficulty" on space "recipe" may not be changed by your roles/tags|de=Das Feld "difficulty" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}
head_chef_maria hält sowohl die Rolle als auch den Tag:
UPDATE recipe SET difficulty = 'Schwer' WHERE id = 1; -- als head_chef_maria
# OK
@ rows: 1
price dagegen ist rein über den Tag beschränkt — kein
byName-Eintrag nennt es überhaupt. sous_chef_erik gelingt es rein
über den approver-Tag, obwohl sous_chef an keiner Stelle in der
Config steht:
UPDATE recipe SET price = 4.50 WHERE id = 1; -- als cook_bob
! ROLLBACK: command ('UPDATE recipe SET price = 4.50 WHERE id = 1;'): script 'noplm_field_acls_before' on space 'recipe' aborted: {{noplm:text|en=Field "price" on space "recipe" may not be changed by your roles/tags|de=Das Feld "price" im Space "recipe" darf mit Ihren Rollen/Tags nicht geändert werden}}
UPDATE recipe SET price = 4.50 WHERE id = 1; -- als sous_chef_erik
# OK
@ rows: 1
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.Kein Rechte-Rezept: dieses — intern display_renderers — besteht aus
fünf read-only Renderern für INT-, FLOAT- und BOOL-Felder. Sie
zeigen nur an; im Edit-Modus verhalten sie sich wie ein READONLY-Feld.
Die Installation legt ausschließlich die Renderer an, Konfiguration
gibt es keine. Skriptquelltext und Installationsdatei:
recipes/display_renderers/../../recipes/display_renderers/README.md.
Namensschema wie in 16.1: noplm_display_renderers_<komponente>. Ein
Renderer ist an genau einen Feldtyp gebunden, daher gibt es die ersten
beiden je als INT- und FLOAT-Variante.
| Renderer | Gebunden an | Zeigt |
|---|---|---|
…_si_int / …_si_float |
INT / FLOAT |
gerundet, mit SI-Präfix |
…_light_int / …_light_float |
INT / FLOAT |
Ampel nach Vorzeichen |
…_light_bool |
BOOL |
Rot/Grün-Ampel |
SI-Anzeige. Rundet auf eine Anzahl gültiger Stellen und hängt k,
M, G, T (Werte ≥ 1000) bzw. m, µ, n, p (Werte < 1) an.
Parameter digits (1-15, Standard 3): 1234 wird 1.23k, 0.00123
wird 1.23m, 1.5e-6 wird 1.5µ. Eine UNIT des Felds folgt dem
Präfix (1.23 kg); der exakte Wert steht als Tooltip daran.
Ampel. Eine gekippte Ampel: grün links (positiv), gelb in der Mitte
(0), rot rechts (negativ). Mit dem Parameter lights=2 entfällt die
gelbe Lampe und 0 zeigt grün. Die Bool-Variante kennt nur rot und
grün: true ist grün, false rot; invert = true vertauscht das (für
"Fehler"-Flags). Bei NULL bleibt jede Lampe dunkel.
ALTER VIEW stock_view ALTER FIELD qty SET RENDERER noplm_display_renderers_si_int WITH digits = 3;
ALTER VIEW stock_view ALTER FIELD balance SET RENDERER noplm_display_renderers_light_int WITH lights = 2;
ALTER VIEW stock_view ALTER FIELD active SET RENDERER noplm_display_renderers_light_bool;
USES
einbinden, daher tragen INT- und FLOAT-Variante identischen Code.5e15 zeigt 5000T).Auch kein Rechte-Rezept, und anders als picklist (16.4) bringt dieses —
intern revisions — keinen eigenen Space mit, sondern verändert einen,
den Du schon hast. Das Muster: Jede Revision ist ein ganz gewöhnlicher
Datensatz desselben Spaces, eine Selbstrelation bindet alle Revisionen einer
Reihe zusammen, und zwei virtuelle Felder beantworten die Fragen, die
Anwender wirklich stellen: Welche Revision ist die neueste? (als Link zum
Hinspringen) und Stehe ich gerade auf ihr? Einen eingebauten
Revisionsmechanismus im Server gibt es bewusst nicht — dafür sind zu viele
Fragen (feste oder mitwandernde Verweise von anderen Spaces, Kopiersemantik
der Teile, Rechte) Sache des jeweiligen Datenmodells. Skriptquelltext und
Installationsdatei: recipes/revisions/../../recipes/revisions/README.md.
Virtuelle Felder sind der neue Baustein: ein Feld, dessen Wert bei jedem
Lesen aus einer gespeicherten QUERY berechnet wird — nicht gespeichert,
nicht beschreibbar, und genau richtig für einen Wert, der von anderen
Datensätzen abhängt (die neueste Revision ändert sich dort, wo gar kein
Trigger des gelesenen Datensatzes feuert). Details in
Kapitel 4.
Alle Namen tragen den Präfix noplm_revisions_, damit nichts mit Deinen
eigenen Feldern kollidiert. Auf dem Space, den Du angibst (hier: recipe):
…_revision — INT NOT NULL DEFAULT 1, die Nummer innerhalb der Reihe.
Vorhandene Datensätze werden Revision 1.…_origin — die Selbstrelation. Jede Revision zeigt auf die erste
Revision ihrer Reihe, die erste zeigt auf sich selbst. Die Rückseite
…_series listet die ganze Reihe.…_current — virtuelles Relationsfeld: die neueste Revision der Reihe.…_latest_no — virtuelles INT-Feld: deren Nummer. Vergleicht man sie mit
der eigenen, lässt sich ein Hinweis "Es gibt eine neuere Revision" bauen.Und fünf Skripte, geteilt von allen Spaces, auf die Du das Rezept anwendest:
noplm_revisions_set_origin — AFTER CREATE: ein Datensatz ohne Ursprung
wird zur ersten Revision seiner eigenen Reihe.noplm_revisions_new_revision — RUNNABLE: kopiert einen Datensatz als
nächste Revision und liefert die neue ID.noplm_revisions_guard — der Wächter, siehe unten.noplm_revisions_backfill — RUNNABLE für einen Bestandsdatensatz, siehe unten.noplm_revisions_run_condition — bietet backfill nur an einem Datensatz
ohne Ursprung an.Das Label-Skript des Spaces bleibt Deine Sache: Es sollte Revisionen
unterscheidbar benennen (Kartoffelsuppe (rev 3)), sonst zeigt ein Link auf
die neueste Revision nur den nackten Namen.
revisions)Dieses Rezept ändert einen Space, den Du schon hast; deshalb nennst Du ihn beim Einspielen (als Admin):
INCLUDE RECIPE revisions WITH space = recipe;
Der Server spielt die Installationsdatei des Rezepts ab, wobei das
Session-Makro {{space}} (Kapitel 5)
nur für diese Datei auf recipe gesetzt ist; jeder Befehl, der den Space
verändert, trägt es. SHOW RECIPE revisions; zeigt die Parameter, und ohne
WITH space = … wird der Befehl abgelehnt, bevor etwas geändert wurde. (Ohne
konfiguriertes Rezeptverzeichnis geht dasselbe von Hand: DEFINE space AS
recipe; und dann die Datei abspielen, z. B. mit noplm-cli.) Für einen
zweiten Space nur den Namen ändern und den Befehl noch einmal ausführen: Die Skripte gibt es dann schon
(CREATE SCRIPT IF NOT EXISTS überspringt sie mit einer Warnung und
überschreibt nie eine Kopie, die Du angepasst hast), nur der Teil für den
Space läuft. Zweimal auf denselben Space scheitert dagegen absichtlich
(ADD FIELD, ATTACH) — und weil DDL keine Transaktion kennt, hinterlässt
ein Fehler mitten drin einen halb vorbereiteten Space; die README listet die
Schritte zum Zurückbauen.
Eine Kartoffelsuppe, jetzt mit Revisionen. new_revision kopiert den
Datensatz und gibt die neue ID zurück:
RUN SCRIPT noplm_revisions_new_revision ON recipe WITH ID = 1;
# OK: script 'noplm_revisions_new_revision' executed
% return:
% 2
% ;
$ Revision 2 created.
Die Kartoffelsuppe in der Fassung "diesmal mit mehr Kartoffeln" als Revision 3 — auch von einer alten Revision aus bekommt die Kopie die nächste freie Nummer:
RUN SCRIPT noplm_revisions_new_revision ON recipe WITH ID = 2;
QUERY recipe -> (name, noplm_revisions_revision, noplm_revisions_current, noplm_revisions_latest_no);
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|noplm_revisions_revision|INT|false|
@ column: 4|noplm_revisions_current|RELATION|true|
@ column: 5|noplm_revisions_latest_no|INT|true|
@ rows: 3
% 1 | 'Kartoffelsuppe' | 1 | recipe:3 | 3
% 2 | 'Kartoffelsuppe' | 2 | recipe:3 | 3
% 3 | 'Kartoffelsuppe' | 3 | recipe:3 | 3
Alle drei Zeilen kennen die neueste Revision, ohne dass jemand beim Anlegen
der dritten die ersten beiden angefasst hätte. Weil …_current ein
Relationsfeld ist, lässt sich darüber auch hüpfen:
QUERY recipe -> (name, noplm_revisions_current -> (noplm_revisions_revision, servings)) WHERE id = 1;
@ column: 1|id|INT|false|
@ column: 2|name|STRING|false|
@ column: 3|noplm_revisions_current->noplm_revisions_revision|INT|false|
@ column: 4|noplm_revisions_current->servings|INT|false|
@ rows: 1
% 1 | 'Kartoffelsuppe' | 3 | 6
In einer VIEW zeigt FIELD noplm_revisions_current NAVIGATE TO <view> einen
Link auf die neueste Revision. Virtuelle Felder sind nicht schreibbar und
nicht in WHERE, ORDER BY oder UNIQUE verwendbar — als Spalte und als Hop
aber überall, auch im Query-Builder (Kapitel 10).
noplm_revisions_guard ist Pflicht und wird beim Anwenden angehängt (ein
Admin kann ihn mit DETACH wieder lösen). Er tut zwei Dinge:
new_revision verweist.UPDATE recipe SET servings = 8 WHERE id = 1;
! ROLLBACK: command ('UPDATE recipe SET servings = 8 WHERE id = 1;'): script 'noplm_revisions_guard' on space 'recipe' aborted: Revision 1 is not the newest revision of its series and cannot be changed.
UPDATE recipe SET servings = 8 WHERE id = 3;
# OK
@ rows: 1
Eine Revision, die nur "angefasst" wird, weil eine neue Revision in ihre Reihe eintritt (ihre Rückseite ändert sich), gilt nicht als geändert. Ein Datensatz ohne Ursprung gehört zu keiner Reihe und wird nicht geschützt.
Datensätze, die auf eine Revision zeigen, zählen als Änderung an ihr: Ein
Teil (oder ein Einsatz, ein Auftrag, alles mit einer Relation auf den Space)
lässt sich weder an eine alte Revision hängen noch von ihr wegnehmen oder dort
löschen. Wegnehmen und Löschen sieht der Wächter vor dem Schreiben, das Anlegen
wird der Revision erst danach gemeldet — deshalb hängt der Wächter auch an
AFTER UPDATE, und die Ablehnung macht dann den ganzen Befehl rückgängig. Muss
die Historie trotzdem geändert werden, ist das Sache des Admins: SUSPEND
SCRIPTS; überspringt den Wächter (und jedes andere angehängte Skript), ein
neues Login holt ihn zurück.
Wer es strenger will: Anwendern nur run, aber nicht create/update auf
dem Space geben — dann ist new_revision der einzige Weg hinein.
Wendest Du das Rezept auf einen Space an, der schon Datensätze enthält, werden sie Revision 1 ohne Ursprung: eine Reihe der Länge eins, die noch niemand begonnen hat. Zwei Dinge helfen:
RUN SCRIPT noplm_revisions_new_revision auf so einem Datensatz macht ihn
zuerst zur ersten Revision seiner eigenen Reihe und legt dann Revision 2
an. Vorbereiten muss man nichts.RUN SCRIPT noplm_revisions_backfill ON recipe WITH ID = <id>; erledigt
das nur für diesen Datensatz, damit current und latest_no etwas
anzeigen. Angeboten (und erlaubt) wird es nur an einem Datensatz ohne
Ursprung; an jedem anderen blendet die Run-Condition es aus,
new_revision bleibt angeboten.RUN SCRIPT noplm_revisions_backfill ON recipe WITH ID = 1;
$ Started as revision 1 of its own series.
Beides verbindet keine bereits vorhandenen Datensätze zu Reihen. Liegen Revisionen bei Dir schon als getrennte Datensätze vor (gleicher Name, die Nummer in einem eigenen Feld), ist der Zusammenhang ein Geschäftsschlüssel, den nur Du kennst — ein Einmal-Skript, das je Gruppe den Ursprung aller Mitglieder auf das erste setzt, ist dafür das richtige Werkzeug; die README beschreibt, worauf es zu achten hat.
new_revision
übernimmt jedes Feld, das Du selbst deklariert hast, außer den
Revisionsfeldern, einem AUTONUMBER (die Kopie zieht eine eigene Nummer)
und einem UNIQUE-Feld (die Kopie würde mit ihrem Original kollidieren und
beginnt leer). Relationen: die einfache Seite einer 1:n und n:m
(dieselben Zuordnungen, je mit einem neuen Detail-Datensatz, wenn die
Relation einen DETAIL SPACE hat) werden kopiert; 1:1 und die
Viele-Seite einer 1:n (Teile, die auf den Datensatz zeigen) nicht — sie
bleiben bei der alten Revision. Alles andere, was zum Datensatz gehört
(Dokumente, …), kopiert es nicht — das ist eine Entscheidung über Deine
Daten, also Dein Skript.UNIQUE-Feld, das nicht leer sein darf (NOT NULL, ohne
DEFAULT), lässt die Kopie scheitern. Das Feld nicht eindeutig machen,
einen Standardwert vergeben oder ein eigenes Kopier-Skript schreiben.read auf den Space, um …_current zu sehen: ohne
Leserecht ist ein virtuelles Feld leer, nicht fehlerhaft — der Wächter
lässt dann durch, statt jede Änderung zu blockieren.SUSPEND SCRIPTS;/ALLOW
SCRIPTS; freigegeben sind (Kapitel 9).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 ;. Ein Zeilenumbruch
zählt innerhalb eines Befehls wie ein Leerzeichen (Ausnahme: der opake
Script-/JSON-Body, der bis zu einer %%;-Zeile läuft), und mehrere
;-getrennte Befehle dürfen auf einer Zeile stehen — jeder wird
einzeln ausgeführt und einzeln beantwortet.
Berechtigung ist eine von: keine (jede angemeldete Sitzung),
eigene Sitzung (bezieht sich nur auf den Aufrufer selbst, kein Recht
nötig), einem der neun Space-Rechte aus Kapitel
13 (read/create/update/delete/
run/flow/upload/download/logs), oder admin.
Modus ist entweder Autocommit-only (nicht in einer
BEGIN...COMMIT-Kette einreihbar, siehe Kapitel
3) oder stapelbar (kann
sowohl einzeln als auch innerhalb einer Kette laufen).
ALLOW SCRIPTSALLOW SCRIPTS;
Lässt ein angehängtes Script für die aktuelle Admin-Sitzung tatsächlich
feuern — genau wie bei einem normalen Nutzer (volle Abbruchmacht,
Kaskaden, ctx.mail()/ctx.httpQuery() eingeschlossen) — statt des
standardmäßigen harten Fehlers oder des stillen Überspringens durch
SUSPEND SCRIPTS. Berechtigung: admin · Modus:
Autocommit-only · Mehr: Kapitel 9.
ALTER 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> [OWNED] | NULL;
ALTER SPACE <space> ADD VIRTUAL FIELD <feld> (<typ> | RELATION <space>) QUERY "<query>" [DESCRIPTION '<text>'] [UNIT '<text>'];
ALTER SPACE <space> ALTER FIELD <feld> QUERY "<query>"; -- nur virtuelle Felder
ALTER SPACE <space> SET LABEL '<text>';
ALTER SPACE <space> SET LABEL SCRIPT <script>;
ALTER SPACE <space> DROP LABEL SCRIPT;
ALTER SPACE <space> SET WORKFLOW <workflow>;
ALTER SPACE <space> DROP WORKFLOW;
Strukturelle Änderungen an einem bestehenden Space — Felder
hinzufügen/entfernen/verschieben/ändern, Beschriftung, Label-Script,
Workflow-Bindung. feld_definition folgt derselben Syntax wie bei
CREATE SPACE. (Flow-/Run-Condition-Scripte werden nicht mehr hier
gesetzt — siehe ATTACH SCRIPT ... AS FLOW|RUN CONDITION weiter
unten.) Berechtigung: admin · Modus: Autocommit-only
(strukturelle DDL) · Mehr: Kapitel
4, Kapitel
6 (WHERE/DETAIL SPACE),
Kapitel 7 (WORKFLOW), Kapitel
9 (Scripte).
ALTER 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 LABEL '<text>'; -- das eigene Anzeige-Label der View (keine DROP-Form)
ALTER VIEW <name> SET SHOW WORKFLOW | DOCS | LOG;
ALTER VIEW <name> UNSET SHOW WORKFLOW | DOCS | LOG;
ALTER VIEW <name> ADD PARAM <name> AS <typ>;
ALTER VIEW <name> DROP PARAM <name>;
ALTER VIEW <name> SET QUERY "<konsolenbefehl>";
ALTER VIEW <name> DROP QUERY;
ALTER VIEW <name> ADD FIELD <spaltenpfad> [RENDERER <name> [WITH ...]]; -- oder <name> AS <typ> (MODE CREATE)
ALTER VIEW <name> DROP FIELD <feld>;
ALTER VIEW <name> SET CHECK VERSION | UNSET CHECK VERSION;
ALTER VIEW <name> SET RECORD <space>; -- nur MODE CREATE
ALTER VIEW <name> ALTER FIELD <feld> SET RENDERER <name> [WITH <param>=<literal>, ...];
ALTER VIEW <name> ALTER FIELD <feld> DROP RENDERER;
ALTER VIEW <name> ALTER FIELD <feld> SET NAVIGATE TO <view>;
ALTER VIEW <name> ALTER FIELD <feld> DROP NAVIGATE;
ALTER VIEW <name> ALTER FIELD <feld> SET LABEL '<text>';
ALTER VIEW <name> ALTER FIELD <feld> DROP LABEL;
ALTER VIEW <name> ALTER FIELD <feld> DEFAULT <literal> | NULL;
ALTER VIEW <name> ADD ON SAVE "<UPDATE- oder INSERT-Befehl>";
ALTER VIEW <name> DROP ON SAVE <laufende_nummer>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.
ALTER 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 [IF NOT EXISTS] <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 [IF NOT EXISTS] <name> FOR STRING|TEXT|INT|FLOAT|BOOL|DATETIME|JSON|USER|ROLE|RELATION|RELATION LIST
function renderView(value, ctx) { ... }
function renderEdit(value, ctx) { ... }
function readEdit(ctx) { ... }
%%;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 10.
CREATE ROLECREATE ROLE <name>;
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
CREATE SCRIPTCREATE SCRIPT [IF NOT EXISTS] <name> [PRIORITY <zahl>] [USES <library>, ...]
<JS-Code, automatisch in function script(ctx) { ... } eingebettet>
%%;
IF NOT EXISTS (auch bei CREATE LIBRARY und CREATE RENDERER): Ist der
Name schon vergeben, wird nichts geändert — das vorhandene Objekt wird
nicht überschrieben — und der Befehl endet erfolgreich mit einer
% warning:-Zeile statt mit einem Fehler; ein übersprungenes Anlegen kommt
nicht ins Admin-Log. Gedacht für Installationsdateien, die mehr als einmal
angewendet werden, etwa die Kochbuch-Rezepte.
Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 9.
CREATE SPACECREATE SPACE <name> [LABEL '<text>'] (
<feld> STRING|TEXT|INT|FLOAT|BOOL|DATETIME|JSON|USER|ROLE [NOT NULL] [UNIQUE] [DEFAULT <literal>] [DESCRIPTION '<text>'],
<feld> RELATION <space> ONE|MANY [WHERE <where_clause>]
AS <inverses_feld> ONE|MANY [WHERE <where_clause>]
[WITH DETAIL <space> [OWNED]] [DESCRIPTION '<text>'],
...
);
Legt einen neuen Space mit Skalar- und/oder Beziehungsfeldern an;
id/created_at/modified_at entstehen automatisch. Berechtigung:
admin · Modus: Autocommit-only · Mehr: Kapitel
4, Kapitel
6.
CREATE 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 -- ein einziges ;-Kommando, Klauseln ohne Trenner
-- Kopf-Klauseln (reihenfolge-frei, vor den Struktur-Klauseln):
[MODE VIEW|EDIT|CREATE] [LABEL '<text>'] [SHOW WORKFLOW] [SHOW DOCS] [SHOW LOG] [SHOW RUNSCRIPTS] [CHECK VERSION]
[RECORD <space>] -- Pflicht bei MODE CREATE
[PARAM <name> AS <typ>] -- Zusatz-Parameter; die Record-id ist das implizite "id"
-- Struktur-Klauseln:
[QUERY "<konsolenbefehl>"] -- String-Literal; MODE VIEW/EDIT; Wurzel-Space = Record-Space
FIELD <spaltenpfad> [RENDERER <name> [WITH ...]] [READONLY] [NAVIGATE TO <view> [VIA DETAIL]] [LABEL '<text>']
| FIELD <name> AS <typ> [DEFAULT <literal>] [READONLY] [LABEL '<text>']
[ON SAVE "<UPDATE- (optional mit -> Hops) oder INSERT-Befehl>"]
[GROUP <beziehungsfeld> [LABEL '<text>'] [ANCHOR $<var>] [ORDER BY <spalte> [ASC|DESC], ...] [NO ADD] [NO REMOVE] (
FIELD ... -- relativ zur Verknüpfung: <feld> oder ^<detailfeld>
[ON SAVE "..."]
)]
...
;
SINGLE (ein Datensatz) oder LIST. Eine View ist ein ;-Kommando mit
schlüsselwortgeführten Klauseln ohne Trenner, in zwei Stufen
(Kopf-Klauseln vor Struktur-Klauseln); QUERY/ON SAVE-Payload ist ein
String-Literal, ein GROUP-Rumpf steht in ( … ). Eine SINGLE VIEW in MODE VIEW/EDIT hat einen
impliziten id-INT-Parameter; CHECK VERSION ist ein nacktes Flag. Ein
GROUP-Block (nur SINGLE Views) zeigt eine Zeile pro
Verknüpfung einer MANY-Beziehung und ist in MODE EDIT wie in
MODE CREATE bearbeitbar (dort braucht er ein ANCHOR $<var>).
Berechtigung: admin · Modus: stapelbar · Mehr:
Kapitel 10.
CREATE WORKFLOWCREATE WORKFLOW <name> [LABEL '<text>'];
Legt automatisch einen ersten Zustand INITIAL an. Berechtigung:
admin · Modus: Autocommit-only · Mehr: Kapitel
7.
DEFINEDEFINE <name> AS <wort>|'<text>';
Setzt ein Session-Makro: Ab jetzt wird {{name}} im Text jedes Befehls dieser
Session durch den Wert ersetzt, bevor der Befehl geparst wird. Ein einzelnes
Wort ist ein Name oder eine Zahl, alles andere gehört in einen String. Der
Wert wird einmal und unverändert eingesetzt — er wird nicht noch einmal nach
Makros durchsucht, und unbekannte {{namen}} bleiben stehen. Ein Wert darf
keine Zeile enthalten, die nur aus %%; besteht. Die Makros gelten bis
UNDEFINE, LOGIN oder LOGOUT und werden nirgends gespeichert. So lässt
sich eine Installationsdatei auf einen Space nach Wahl anwenden: DEFINE space
AS part;, dann die Datei abspielen, in der ALTER SPACE {{space}} ...
steht. Innerhalb einer BEGIN...COMMIT-Kette nicht erlaubt (vorher
definieren); in der Kette eingereihte Befehle werden aber ersetzt. Das
Admin-Log enthält den ersetzten Text. Berechtigung: keine · Modus:
Autocommit-only.
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.
GUARD SCRIPTSGUARD SCRIPTS;
Schaltet die Admin-Sperre gegen implizites Script-Feuern für die
aktuelle Sitzung wieder ein — egal ob vorher SUSPEND SCRIPTS oder
ALLOW SCRIPTS aktiv war. Berechtigung: admin · Modus:
Autocommit-only · Mehr: Kapitel 9.
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.
INCLUDE RECIPEINCLUDE RECIPE <name> [WITH <param> = <wort>|'<text>' [, ...]];
Wendet ein Kochbuch-Rezept an: Der Server spielt die Installationsdatei des
Rezepts (install.struct.noplm im Verzeichnis des Rezepts unter
recipeDirectory) Befehl für Befehl ab, als hättest Du sie getippt. Die
WITH-Werte sind die # @param-Parameter des Rezepts; sie werden als
Session-Makros ({{name}}, siehe DEFINE) nur für die Dauer dieser Datei
gesetzt, Deine eigenen Makros kommen danach zurück und sind für die Datei
nicht sichtbar. Jeder deklarierte Parameter muss angegeben werden, ein
anderer Name wird abgelehnt. Ein Rezept kann kein weiteres Rezept einbinden.
Der Vorgang ist nicht atomar: Strukturbefehle lassen sich nicht
zurückrollen, ein Fehler (gemeldet mit Rezept, Nummer und Text des Befehls)
lässt das stehen, was davor lief; die gemeinsamen CREATE ... IF NOT
EXISTS-Befehle machen eine Wiederholung ungefährlich. Vorhandene Objekte,
die IF NOT EXISTS unberührt ließ, erscheinen als % warning:-Zeilen. Jeder
Befehl steht wie üblich im Admin-Log. Über die REST-API nicht verfügbar.
Berechtigung: admin · Modus: Autocommit-only · Mehr:
Kapitel 16.
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 DEFINESLIST DEFINES;
Alle Makros dieser Session, nach Namen sortiert (% define: <name>|<wert>).
Berechtigung: keine · Modus: stapelbar.
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 RECIPESLIST RECIPES;
Die Rezepte in recipeDirectory, nach Name sortiert
(% recipe: <name>|<titel>|<beschreibung>). Ein Verzeichnis, dem eine der
drei Pflichtdateien (install.struct.noplm, README.md, recipe.json)
fehlt, erscheint stattdessen als % warning:-Zeile. Berechtigung: admin ·
Modus: einreihbar · Über die REST-API nicht verfügbar.
LIST RENDERERSLIST RENDERERS;
Berechtigung: keine · Modus: stapelbar · Mehr: Kapitel 10.
LIST ROLESLIST ROLES;
Alle Rollen des Systems. Berechtigung: admin · Modus: stapelbar · Mehr: Kapitel 13.
LIST ROLE HIERARCHYLIST ROLE HIERARCHY TOP|ALL;
Alle Eltern→Kind-Rollenkanten des Systems in einem Befehl (TOP =
direkte Kanten, ALL = jede Rolle mit ihrer vollständigen transitiven
Unterrollen-Hülle). Eine Zeile % role_hierarchy: <rolle>|<unterrolle>
je Paar, sortiert; eine Rolle ohne Unterrollen erzeugt keine Zeile. Die
Stapelform von LIST ROLES OF ROLE. Berechtigung: admin ·
Modus: stapelbar · Mehr: Kapitel 13.
LIST ROLES OF 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. Das
Ergebnis beginnt immer mit der id-Spalte des Wurzel-Datensatzes,
egal ob <pfad> sie aufzählt. Berechtigung: read · Modus:
stapelbar · Mehr: Kapitel
5, Kapitel
6.
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.
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>, ...,
<gruppe>=[<id>{<spalte>=<literal>, ...}, <id>, ...];
Führt die ON SAVE-Schritte der View aus. Ein GROUP-Block wird als ein Argument mit dem Namen
seines Beziehungsfeldes übergeben: die Liste enthält alle angezeigten
Zeilen (daraus ergibt sich, was verknüpft und was gelöst wird), die
geschweiften Klammern die Werte der geänderten und neuen Zeilen. Berechtigung: keine eigene (aus den
zugrundeliegenden Befehlen abgeleitet) · Modus: Autocommit-only ·
Mehr: Kapitel 10.
SET DOC 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 RECIPESHOW RECIPE <name> [SOURCE];
Zeigt ein Rezept, bevor Du es anwendest: % name:, % title:,
% description:, je ein % param: <name>|<text> pro Parameter und die README
als Quellblock % readme:. Mit SOURCE erscheint nur die Installationsdatei
als Quellblock % source:. Berechtigung: admin · Modus: einreihbar ·
Über die REST-API nicht verfügbar.
SHOW 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 den Script-Modus der aktuellen Sitzung (NORMAL/
SUSPENDED/ALLOWED) — 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 — das Script wird übersprungen, nicht ausgeführt. Berechtigung: admin · Modus: Autocommit-only · 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.
UNDEFINEUNDEFINE <name>;
Entfernt ein Session-Makro (siehe DEFINE); Fehler, wenn es nicht gesetzt ist.
Berechtigung: keine · Modus: Autocommit-only.
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> [-> <hop> ...] [-> ^] SET <feld> = <literal> | [<id>, ...] | NULL, ... WHERE <where_clause>;
Ändert jeden zum WHERE passenden Datensatz; die Klammerform setzt die
gesamte Mitgliedschaft einer virtuellen Beziehungsseite. Eine optionale
-> <hop>-Kette folgt ONE-seitigen Beziehungen von der Wurzel (das
WHERE verankert weiter auf der Wurzel) und wendet SET auf den am
Ende erreichten Datensatz an — eine leere Relation unterwegs ist ein
No-Op, ein MANY-Hop wird abgelehnt; Berechtigung dann update auf dem
Endpunkt, read auf dem Pfad. Ein abschließendes -> ^ pivotiert in
den DETAIL SPACE des letzten Hops und schreibt den Detail-Datensatz
des Paars (legt einen aus den SET-Zuweisungen an, falls noch keiner
existiert) — der letzte Hop muss eine ONE-seitige Relation mit DETAIL
SPACE sein; braucht update und create auf dem Detail-Space. Berechtigung: update · Modus:
stapelbar · Mehr: Kapitel
5, Kapitel
6.
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.