Manifest spec
A manifest is the manifest.json at the root of an app or add-on package. It tells Adminium
what the package is, which tables it needs in the operator’s database, and what it adds on top of
them: pages in the sidebar, roles, settings, frontends, the add-ons it needs, documents, emails,
public endpoints and sample data. Adminium validates it before anything is installed, and builds
the install plan from it.
In a project folder the same manifest may be written as a manifest/ folder of
part files, one per table, per page and per block. Adminium composes them into this one document,
so everything below applies unchanged; only where each field is written differs.
This page describes version 1 of the format, which is what Adminium 0.3 reads. For what an operator sees when they install a package, see Installing apps and Installing add-ons.
There are two kinds of manifest:
- An app (
"kind": "app") is a whole product: its own tables, its own pages and its own staff and customer screens, served at/apps/<key>/<side>/. Most of this page is about apps. - An add-on (
"kind": "add-on") extends an app or the dashboard with code that runs inside Adminium. It shares the identity and table blocks and adds anaddOnblock. See Add-on manifests.
A small app manifest
Section titled “A small app manifest”{ "manifestVersion": 1, "kind": "app", "key": "visits", "name": "Visits", "version": "1.0.0", "publisher": { "id": "adminium", "name": "Adminium" }, "license": "AGPL-3.0-only", "description": { "key": "mft.visits.desc", "fallback": "Book and track client visits." }, "categories": ["operations"], "compatibility": { "minAdminiumVersion": "0.3.27" }, "requiredSchema": { "prefixed": true, "tables": [ { "ref": "visits", "label": { "en-US": "Visit", "de-DE": "Besuch" }, "labelPlural": { "en-US": "Visits", "de-DE": "Besuche" }, "keyField": "client", "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "client", "type": "text", "maxLength": 80 }, { "ref": "starts_at", "type": "timestamptz" }, { "ref": "status", "type": "enum", "enum": ["booked", "done", "cancelled"], "default": "booked" } ] } ] }, "pages": [ { "ref": "visits-calendar", "template": "page-calendar", "title": { "key": "mft.visits.page.calendar", "fallback": "Calendar" }, "nav": { "group": "records", "icon": "calendar-days", "order": 1 }, "bindings": { "rows": "visits" } } ], "frontends": [{ "side": "staff", "kind": "spa", "entry": "index.html" }]}Conventions
Section titled “Conventions”Several shapes recur throughout the format.
| Shape | Rule |
|---|---|
| i18n message | { "key": "...", "fallback": "..." }. key is a catalogue key (1–120 characters); fallback is the English text shown when the key is not in the catalogue (1–400 characters). Both are required and nothing else is allowed. |
| Label | Either a plain string (1–256 characters), or an object keyed by BCP 47 tag, such as { "en-US": "Category", "de-DE": "Kategorie" }. A keyed label must include en-US, which every reader falls back to. Each value is 1–120 characters. |
| snake_case ref | ^[a-z][a-z0-9_]*$. Used for table refs, column refs and setting keys. |
| kebab-case key | ^[a-z][a-z0-9-]*$. Used for page refs, role keys, nav group keys and option list names. |
| Version | Strict semver: major.minor.patch, with an optional pre-release and build part (1.2.0, 0.3.0-rc.4). |
Every object in a manifest is strict: a field this page does not list is an error, not something Adminium ignores. See Validation.
Identity
Section titled “Identity”| Field | Required | Rule |
|---|---|---|
manifestVersion |
yes | Always 1. |
kind |
no | "app" or "add-on". A manifest with no kind is read as an app. |
key |
yes | ^[a-z][a-z0-9-]{1,79}$, so 2–80 characters. Apps and add-ons share one key namespace. apps, add-on, add-ons, dashboard, demo, index and search are reserved. |
name |
yes | Display name, 1–80 characters. |
version |
yes | Strict semver. |
publisher |
yes | { id, name, url? }. id matches ^[a-z][a-z0-9-]{1,39}$, name is 1–80 characters, url is an optional URL of up to 200 characters. |
license |
yes | An SPDX identifier for the package’s own code, 1–80 characters. It is informational. |
description |
yes | An i18n message, shown on the app’s card. |
categories |
yes | At least one. For an app: commerce, hospitality, operations, crm, internal-tools. Add-ons have their own list; see Add-on manifests. |
capabilities |
no | What the package uses; see Capabilities. |
Compatibility
Section titled “Compatibility”"compatibility": { "minAdminiumVersion": "0.3.27", "engines": ["postgres", "mysql", "sqlite"], "requires": ["realtime"]}| Field | Required | Rule |
|---|---|---|
minAdminiumVersion |
yes | The oldest Adminium the package runs on. An install on an older server is refused. |
maxAdminiumVersion |
no | An exclusive upper bound. It must be greater than minAdminiumVersion. The installer does not enforce it in this release. |
engines |
no | The databases the package’s tables work on: postgres, mysql, sqlite. At least one when present. Informational in this release. |
requires |
no | Capabilities the package cannot run without, from the same list as capabilities. Informational in this release. |
updatesFrom |
no | The installed versions this release can update in place, as a semver range (>=0.2.0, ^0.2.0, >=0.2.0 <1.0.0, ^1.0.0 || >=2.0.0), up to 120 characters. Absent means any older version. |
Use updatesFrom when a release changes its tables in a way an update cannot carry. An install
outside the range is not offered the update, and an update or upload of it is refused with a
message telling the operator to uninstall the old version first. That is better than an update
that fails half-way.
Versions are compared on major.minor.patch only: a pre-release tag is ignored, so
"minAdminiumVersion": "0.3.0-rc.4" is met by any 0.3.0 build.
When a package starts using a field that an older Adminium does not know, raise
minAdminiumVersion to the release that reads it. An older server then tells the operator to
upgrade instead of reporting the manifest as invalid.
Capabilities
Section titled “Capabilities”capabilities (and compatibility.requires) take values from one closed list:
| Value | Meaning |
|---|---|
hosted-only |
Only runs on a hosted Adminium. Cannot be combined with offline-required. |
offline-required |
Must keep working with no network. Cannot be combined with hosted-only. |
receipt-printer |
Prints receipts. |
barcode-scanner |
Reads barcodes. |
payments |
Takes payments. |
file-storage |
Stores files. |
email-delivery |
Sends email. |
realtime |
Uses live updates. |
outbound-http |
An add-on’s server code calls a third-party API. Needs addOn.network.allow. |
oauth-connect |
Adminium runs an OAuth 2.0 flow for an add-on. Needed by addOn.connect.kind: "oauth2". |
The storefront shows an app’s capabilities on its card.
requiredSchema
Section titled “requiredSchema”requiredSchema lists the tables the package needs. Apps must declare it; for add-ons it is
optional. At install Adminium compares it with the operator’s database and shows a plan: which
tables it will create, which existing tables it will reuse, and what it needs from the operator
first. Nothing is created until the operator confirms.
"requiredSchema": { "prefixed": true, "tables": [ … ]}| Field | Required | Rule |
|---|---|---|
tables |
yes | At least one table. Table refs must be unique. |
prefixed |
no | true, or leave it out. Apps only. |
Table names and prefixed
Section titled “Table names and prefixed”Without prefixed, each table is created under its ref exactly (visits).
With "prefixed": true, Adminium names each table <key>_<ref>, with any - in the key written
as _: the visits table of an app keyed visits becomes visits_visits, and the tickets
table of pos becomes pos_tickets. The full name must fit in 63 characters. The app reads the
real names at runtime from its surface-config.json (a tables map from each ref to its real
name), so its code never hard-codes the prefix. Pages, rules, roles and public endpoints in the
manifest always use the short refs; Adminium maps them. The staff side’s config also carries
sharedTables: each of the app’s tables another installed app uses too (a menu shared with the
till), with those apps’ keys, so a screen can say that a change there reaches the other app.
Prefixing is opt-in because an app built before it existed hard-codes its table names. New apps
should use it. No table may end up in Adminium’s own adminium_ namespace.
What the plan does with each table
Section titled “What the plan does with each table”Every table in requiredSchema falls into one of four cases:
| Case | When | Default action |
|---|---|---|
| New | Nothing with that name exists. | Create it. |
| This app’s own | An earlier install of the same app recorded it. | Reuse it. |
| Shared | Another installed app recorded it under the same shape. |
Share it. |
| Taken | It exists and no install records it as this app’s. | The operator chooses before installing. |
For a taken table the operator can reuse it (offered only when the table has no required
column that the app would never fill), rename the existing table out of the way (to
<name>_old unless they choose a name), or give the whole app a different prefix, which
applies to every table at once and is meaningful for a prefixed app.
A reused or shared table may need changes first. The plan offers only changes that cannot lose
data: adding a missing column (created nullable), widening a type (a longer varchar, varchar to
text, int to bigint), making an integer key number itself, adding enum values, and giving a
column declared fk the foreign key it lacks (a column first made a plain int: the check names
rows that point at nothing, LINK_ORPHANS, and nothing changes until they are put right). A missing
id, fk or blob column cannot be added to an existing table, and a column whose type cannot
hold the app’s values is refused by name.
A column an update adds starts empty on the rows already there, or holds the manifest’s fixed
default, except a column Adminium works out: a copy of the linked row, a
formula, a rollup and its balance. The update works
each of those out for every row already there, as the next save of the row would: a quote’s copy
of its deal’s state, a line’s amount, a deal’s total. So does a column the new version derives
another way than the one before (a changed formula), and every derived column that reads one of
them, each after what it reads (the total after the line amounts it adds up). It reads the rule as
it stands on the connection (the operator’s own, where they keep one); a copy that only fills a
blank (mode default) fills only the rows still blank. It runs in one transaction of the
database, before the new version is recorded: a failure stops the update (APP_UPDATE_INCOMPLETE,
stage derived-columns), the running version stays, and updating again finishes it. Applying an
app from its project folder fills them the same way. Needs Adminium 0.3.35.
A foreign key’s target must be one of the package’s own tables or a table that already exists in the database.
Tables
Section titled “Tables”| Field | Required | Rule |
|---|---|---|
ref |
yes | snake_case. The table’s short name. |
columns |
yes | At least one. Column refs must be unique within the table. |
label |
no | A label for one row (“Category”): form titles, buttons, empty states, link fields. Without it Adminium names the table from its real name. |
labelPlural |
no | A label for the table (“Categories”). Needs label. |
keyField |
no | The column that names a row wherever another table links to it (a category’s name). Must be one of the table’s columns. |
shape |
no | <name>@<version>, such as menu@1. Two apps that declare the same shape on a table can use one table between them. See Shared tables. |
builtOn |
no | <add-on key>/<shape name>@<version>, such as invoices/invoice@1: the table is built on a shape an add-on defines. Needs part. See Tables built on an add-on’s shape. A table has shape or builtOn, not both. |
part |
with builtOn |
snake_case: which part of the shape the table is (document, lines, payments). Only a table with builtOn has one. |
capacity |
no | A limit on how much of a pool the table’s rows may take (a time slot, a ticket type, a room type’s nights), or a list of up to three; see Capacity. |
booking |
no | Rows that book a person’s time, never overlapping; see Booking. A table has capacity or booking, not both. |
occupies |
no | Rows that take a resource for a time (a room for an hour, a van for a day), never overlapping; see A resource and a time. A table has one of capacity, booking and occupies. Needs Adminium 0.3.28. |
states |
no | The states a row moves through, what is locked in each, and the child tables tied to them; see States. |
unique |
no | 1–8 sets of 2–4 columns no two rows may hold the same values in together; see Columns unique together. |
module |
no | The part of the app the table belongs to. Needs Adminium 0.3.28. |
removal |
no | { "reason"?: true | "required", "history"?: true }: a removal of a row takes a reason, and the removed row stays readable through a row it linked to; see Removed rows. Needs Adminium 0.3.35. |
label, labelPlural, keyField and every column label are installed as the operator’s own
labels would be. The operator can rename anything; a name they changed is theirs, and a later
version of the app does not overwrite it.
Columns
Section titled “Columns”{ "ref": "status", "type": "enum", "enum": ["open", "paid", "void"], "default": "open", "label": { "en-US": "Status", "de-DE": "Status" } }| Field | Required | Rule |
|---|---|---|
ref |
yes | snake_case. |
type |
yes | One of the column types. |
nullable |
no | true makes the column nullable. Columns are NOT NULL unless you say otherwise. A primary key is never nullable. |
role |
no | pk (primary key), created_at or updated_at. |
semantic |
no | A hint for widgets: name, money, image, email, avatar, geo-lat, geo-lng. money (like a money column, or "scale": "currency") makes the column money wherever Adminium draws it, whatever it is called: a board’s totals and card, a chart’s Show data table, a list. Read by Adminium 0.3.28 and later; an older release takes it as a hint only. |
enum |
for enum |
The allowed values, at least one. |
references |
for fk |
The ref of the table this foreign key points at. That table must declare exactly one pk column; the foreign key takes its type. A name the manifest does not declare is a table the database already has (a shop’s own customers, with a one-column primary key): the app then installs only on a database with that table, the check warns about it, and anywhere else the install is refused (UNRESOLVED_REFERENCE). |
maxLength |
no | text only: creates varchar(n) instead of unbounded text. 1–1000. Leave it out for long text (notes, a description): text with no maxLength has no limit. |
scale |
no | decimal and money only: the places kept after the point. See Decimal places. |
default |
no | The value the database fills when an insert leaves the column out. See Defaults. |
unique |
no | true: no two rows may hold the same value. Empty values do not count, so many rows may leave it empty. Not on the primary key, a json or a blob column; a text column needs maxLength, because MySQL cannot index unbounded text. |
index |
no | true: a plain index on a foreign key a limit or a total counts by, so the count reads the rows it needs and not the whole table. Only on such a foreign key, and never on one that is unique already. |
label |
no | A label for the column: a form field, a list heading. |
rules |
no | Rules Adminium keeps on the column; see Column rules. |
A NOT NULL column with no default refuses every insert that leaves it out, which includes every
record added from a form that does not show it. Give such columns a default, or make them
nullable.
unique becomes a unique constraint named uq_<table>_<column>, where <table> is the real
table name (uq_clinic_patients_email). It is made when Adminium creates the table, and by an
update that adds the column or finds it without one (on SQLite, a unique index of the same name).
Before adding it to a column that already has rows, the update’s check makes sure no two rows hold
the same value, and names the column if they do; nothing changes until they differ. A code column
and a number without gaps are unique the same way, a number counted per parent row together with
its parent. On MySQL a unique text column holds at most 768 characters (maxLength): MySQL
indexes no longer key, so a longer one is refused on the check, at install and on update.
Columns unique together
Section titled “Columns unique together”A table’s unique lists sets of columns that are unique together: one waitlist entry per show per
address, one check-in per ticket per day.
{ "ref": "check_ins", "columns": [ … ], "unique": [["ticket_id", "day"]] }Each set names 2–4 of the table’s columns, each once; a table has up to 8 sets, and no set twice.
A row with any of the set’s columns empty never collides. The columns must be ones an index can
hold: not json or blob, and a text column with maxLength (a code column counts). A set
that a number counted per parent already keeps unique (a gapless sequence with a scope, and
its scope) is refused as said twice, and so is a set on a table built on an add-on’s
shape, whose own writes could break it.
A set becomes a unique constraint when Adminium creates the table (a unique index on SQLite), and
an update that adds a set to a table that already has rows first checks that no two rows repeat
it: the check names the table and columns (UNIQUE_DUPLICATES), and nothing changes until they
differ. A write that would repeat a set is refused 409 UNIQUE_VIOLATION, with
details.columns naming the set. A plain index is added the same way, at install or
by an update.
Column types
Section titled “Column types”| Type | Created as |
|---|---|
id |
A string key (varchar). |
int, bigint |
Integer. With role: "pk", the key numbers itself (an identity or auto-increment column); an explicit value is still accepted. |
text |
text, or varchar(maxLength). A column with a code rule is exactly as wide as its codes. |
decimal, money |
Decimal. |
float |
Floating point. |
bool |
Boolean. |
enum |
A varchar(32) (or varchar(64) when a value is longer than 32 characters) with a check that limits it to the enum values. |
json |
JSON. |
date |
Date. |
timestamptz |
Timestamp with time zone. |
uuid |
UUID. |
fk |
A foreign key, typed like its target’s primary key. |
blob |
Binary. |
Defaults
Section titled “Defaults”A default must mean the same thing on Postgres, MySQL and SQLite, so only these are accepted:
timestamptz: only the string"now".text: a string, and the column needsmaxLength(MySQL gives unbounded text no default). The default must fit in it.enum: one of its values.int,bigint: a whole number.decimal,money,float: a number.bool:trueorfalse.json,blob,date,id,uuid,fkand primary keys take no default.
A value that comes from somewhere else when the row is made (the connection’s currency, a
setting) is not a database default: use the default rule.
"now" is filled by Adminium on every create, through every door, so what the create works out can
read it (a visit’s hours are there from the start). On MySQL the
database is given no default for it at all: an app’s timestamptz is a DATETIME there, kept on
the Adminium server’s clock, and the database — whose sessions Adminium keeps in UTC — could only
fill UTC’s. A row written to that table outside Adminium, leaving the column out, gets nothing: it
stays empty, or is refused when the column may not be empty. Adminium also fills a MySQL DATETIME
whose database default is CURRENT_TIMESTAMP (a table made before, or one of your own) on its own
creates, with the server’s own time: before, the database filled it with UTC’s time, so on a server
not in UTC a row Adminium makes now differs from one another program stamps. On Postgres and SQLite
the database’s own default is kept as well.
Decimal places
Section titled “Decimal places”scale says how many places a decimal or money column keeps after the point:
| Value | Places |
|---|---|
0–4 |
That many. |
"currency" |
The decimals of the row’s own currency: 0 for JPY, 2 for EUR, 3 for KWD. The currency is read from the table’s own column named currency when the table has one and the row fills it, else from the connection’s currency, else 2 places. A value that is not a currency code Adminium knows counts as 2 places. |
{ "ref": "total", "type": "decimal", "scale": "currency", "nullable": true }Every value a write sends to the column is rounded to its scale, half away from zero, without ever
passing through a floating-point number. Rollup totals and
formulas round to it too. With "currency", a document keeps the places of the
currency it was written in, even after the connection’s currency changes. A decimal with no
scale is made with four places.
Column rules
Section titled “Column rules”rules asks Adminium to keep rules on a column. They are written at install as the operator’s own
column rules would be. An operator’s existing rule on the same column wins, and a rule they later
change or delete is theirs from then on.
| Rule | Shape | What it does |
|---|---|---|
options |
{ "list": "<name>" } or { "values": [{ "value", "label"?, "tone"? }] } |
The allowed values. list names one of the app’s option lists, or a built-in list: builtin:countries, builtin:us-states, builtin:gender. Inline values take 1–500 entries. An inline value’s label is a label: a keyed one follows the reader’s language in the form’s choices, the filters, the list and dashboard cards, falling back to en-US. An app’s option list keeps its en-US words. |
enumLabels |
{ "labels": { "<value>": label }, "tones"?: { "<value>": "<tone>" } } |
Display labels (and badge tones) for an enum’s values. Each label is a label: a keyed one follows the reader’s language on pages (the list, the record and the form), and on dashboard cards, falling back to en-US. A page that sets its own labels or tones for the column keeps them. |
required |
true |
The server requires a value on every write. |
requiredWhen |
{ "column", "in" } |
The server requires a value only while another column of the same row holds one of the values in in (1–32): an away event names who is away, an event in the office names nobody. See Required for some values. |
validation |
{ "format"?, "min"?, "max"?, "minLength"?, "maxLength"? } |
format is email, url or phone. |
copy |
{ "via", "from", "mode"?, "follow"? } |
Copies a value from a linked row. via is a foreign-key column of this table, from a column of the table it points at. With mode: "default" (the default) the copy fills only a value the write leaves out; with "always" it always wins. With "follow": true it keeps in step when that row changes later; see Copies that follow. via may itself be a copied column (a ticket’s show, copied from its type): that copy runs first, in the same write. A column kept from readers is copied only into one kept the same way: a secret into a secret, a personal column into a personal one (or a secret), and a shared link’s code never. The same holds for a stamp that copies a column of its row, and for a formula’s inputs. |
default |
{ "from" } |
A value filled on a create that leaves the column empty, read when the row is made. See Values from elsewhere. |
sequence |
{ "start"?, "gapless"?, "startSetting"?, "scope"? } |
The next number in a running series. Without gapless, the column’s own counter; start is at least 1. With "gapless": true, a number with no gaps and none repeated. See Numbers without gaps. |
format |
{ "from", "prefix"?, "prefixSetting"?, "pad"? } |
A text column written from a running number of the same row, gapless or not: the prefix, then the digits padded with zeros (INV-0042). See Numbers without gaps. |
code |
{ "length", "prefix"?, "renew"?, "hiddenFromStaff"?, "givenByLedger"? } |
A short random code, unique in the column. renew makes a new one when the row changes hands; see Codes that renew. length is 4–16; prefix is upper case, up to 6 characters plus an optional - (MR-). A create that leaves it out or empty gets one, a sample row or an import too; a code they bring is kept, but a sample’s code for a shared link’s column is always made anew. A code is no secret by its rule alone: a name that reads like one (share_token) keeps it one, unless secret: false says otherwise or it is the column a shared link opens a row by, on a table the install made, which staff who read the table see. The public sees a code only where an entry’s select names it, and a shared link’s never. The audit log and the automation logs say [code] in its place, and the assistant never reads it. hiddenFromStaff: true: a code no desk hands out (an online order’s own link) is left out of every staff read, for every role and Super Admin too: rows, exports, live updates and the history. The link still opens its row, and the app’s emails still carry it to the row’s holder. Such a code is kept as a shared link’s code is, whether or not an entry claims by it yet: no column copies it (a copy, a stamp’s copy, a formula), no total keeps it as the newest child row’s value (rollup.latest), no codeLast4 keeps its last four, no posting copies it into a ledger (its map, from the row or its parent), and no public entry shows, filters or orders by it. The manifest check refuses each one, and so does the server when somebody saves such a rule on the schema or makes such an API endpoint (ENDPOINT_SHARE_CODE_READ). A rule never carries it: a rule’s step or e-mail template that names it ({{record.x}}, {{x}}, {{was.x}}, a linked row’s {{link.x}}) is refused by the validator and when a rule is saved, the column named. givenByLedger: true, on a table of an add-on: a row the add-on’s own ledger adds may bring its code (an older gift card, under the code its holder has always had). It is kept when it is the prefix, then 4 to 16 capital letters and digits, fits the column and no other row has it, and refused otherwise; with none given Adminium makes one. No person’s save gains anything by it, and a change never writes a code. |
formula |
an expression | A number worked out from the row’s other columns on every write. See Formulas. |
normalize |
"trim", "email" or "code" |
How a text value is kept: trim without spaces at either end, email trimmed and in lower case, code compared as a code (upper case, spaces and dashes left out) when a person types one; see Typed codes. |
lookup |
{ "from", "table", "column", "where"?, "scope"? } |
A foreign key filled from a code a person types into another column: a discount code, a presale code. See Typed codes. |
addOnLink |
{ "addOn", "table" } |
A link to a row of a table an add-on keeps: an order line’s stock item, a payment’s gift card. No foreign key is made, so the app installs whether or not the add-on is there. The add-on is one the manifest names under addOns. While it is not installed, or not connected to this app, the link is inert: a value sent for it is refused (POSTING_REFUSED, reason add-on-unavailable, with the column) and the rows keep what they named. While it is there, a value must be the key of a row of that table, or the column is refused not-found. An empty link is always taken, and an import keeps whatever it names. A lookup on the same column may name the same table as { "addOn", "table" }: a typed code then fills the link. A default may fill it from a column of the app’s settings row that carries the same addOnLink (same add-on, same table, same type), and from nowhere else: "default": { "from": { "table": "settings", "column": "supplies_place_id" } }. Such a default never refuses a save: it is left empty while the add-on is not there for the app, and when the row the setting names is gone. It needs minAdminiumVersion 0.3.19 or later. |
plainText |
true or { "digits"?, "max"? } |
On a text column shown to people who did not write it: a name, the message on a card, a note to the kitchen. Letters, spaces and sentence punctuation only: no web address, no email address, no @ handle, no path. true is a name (no digits, up to 80 characters); an object is a note with up to digits digits and max characters. It is the column’s own rule, so it holds every way of writing the row: a staff save, an import, a guest’s entry (which may be stricter still). Refused as plain-text on the column. |
mentions |
true or { "about": link } |
On a text column people write to each other in (a note, a comment). Its box offers the people who sign in as the writer types @, and the text keeps each one named as @[Name](usr_…). A save that names somebody who does not sign in here is refused by the column, with nothing written. After the save each person newly named is told who mentioned them and on which record, never what was written; one who cannot open that record gets a notice that names the table only (never the record) and has no link. The record is named only to somebody who can open it, by a column their role shows, and only when the writer can read it too. A mention in a row saved under another record’s form tells the people a new row names; a change to such a row tells nobody. A text that already names somebody who has since left can still be edited. about is a link of the table: a mention in a comment is about the deal the comment is on, and that is the record the notice names and opens. Not on a private table. Needs minAdminiumVersion 0.3.28. |
place |
true |
On a json column: a position on the map, kept as { "lat", "lng", "by" } where by is "town" or "exact". Every write is read and made alike; a value that is not a position is refused by the column. See A place and an area. Needs Adminium 0.3.28. |
area |
true |
On a json column: an area on the map, kept as a GeoJSON Polygon or MultiPolygon. See A place and an area. Needs Adminium 0.3.28. |
customerKey |
{ "of": "<address column>" } |
On a text column of 64 characters that may be empty: a keyed hash of the address in of, made by Adminium whenever that address is written and by nobody else (a value sent for it is dropped; a guest’s entry may not list it). The same address gives the same key however it is spelled, and another key in another database. It says “the same customer” to whatever counts per customer, without keeping who. An empty address has no key. |
codeLast4 |
{ "of": "<code column>" } |
On a text column that may be empty: the last four characters of the code in of (a column with code), cut by Adminium whenever that code is made — on a new row, when a collision makes it again, when a change of hands renews it. No writer’s value is kept. It is what a list and a receipt show of a gift card; a code another column keeps the last four of is a money code, never shown in a list or a form. Never of a shared link’s code, nor of a code no staff member reads (hiddenFromStaff). From Adminium 0.3.19. |
perNight |
{ "from", "to", "rate", "adjust"? } |
A price worked out night by night: a stay’s room total. See Prices by the night. |
notAfter |
"today" or { "column", "via"?, "when"?, "strict"?, "days"? } |
A date column is never later than today, in the venue’s time zone; or the column is never later than another column, read as notBefore reads it (a credit’s nights end by its stay’s depart; warm below stale). days, on a date: the other date moved by so many days (1–36600), so an end is at most so many days after a start ("ends_on": { "notAfter": { "column": "starts_on", "days": 365 } }: a cover lasts a year at most; an empty end stays allowed). A later value is refused (out-of-range). |
notBefore |
"now" or { "column", "via"?, "when"?, "strict"?, "days"? } |
The column is never earlier than another column of the same kind: of the same row, or, with via, of the row its foreign key via points at. A date or timestamptz against a date (a payment never before its invoice’s issued_on), a number (int, bigint, decimal, money, float) against a number, a time of day kept as text (HH:MM, at least 5 characters) against another (work_from before work_to, compared as times: 9:30 is before 17:00). when: 1–8 conditions on the row as the write leaves it; the bound holds only while they are met. strict: the two may not be the same — true, or 1–8 conditions it is out under (a guest who left early is credited from the day after the arrival, one who never came from the arrival itself). "now", on a timestamptz: a new row’s moment is not in the past, to the minute (a visit is never booked to start before now); judged on a create only, never on one that brings history (an import, sample data), so a later change may correct a visit that happened. A bound on a column that is no date, days and "now" need Adminium 0.3.35. |
linkable |
{ "where"?, "same"? } |
On a foreign key: it names only a row of the table it points at that meets 1–8 conditions (where, the shape a move’s linked takes) — a price-list line still offered — and, with same, whose columns hold what this row holds (a visit’s contact works for the company of the visit’s deal). Judged when a new row names one and when a change moves the link, for every writer. See Links to some rows. Needs Adminium 0.3.28. |
file |
{ "accept", "maxBytes", "maxCount"? } |
Files a signed-in person uploads for the column: one in a text column, a list of up to maxCount (1–20) in a json column. It holds Adminium’s own file ids, never a link. accept names the kinds the column takes, judged by the file’s bytes; maxBytes (1 KiB–25 MiB) the size of each. A picture loses what a camera writes beside it as it is uploaded. A write may name only a file its writer uploaded and no record holds yet. See Files in a column. Needs Adminium 0.3.28. |
changes |
[{ "to"?, "from"?, "while"?, "by"?, "notBy"? }] |
When, and by whom, the column may change: to some values only from others, only while another column holds a value, only by the person a staff column of the row (or of a linked row) names, never by the people others name. Judged by the server on every way a row is written but history. See Who may change a column. Needs Adminium 0.3.35. |
retryKey |
true |
The column a staff create keeps its retry key in, as a 43-letter hash, on a table no public entry creates rows of: a desk’s payment sent again after a lost reply answers the payment the first one made (see the REST API’s clientKey). A text column, unique, nullable, at least 43 long; one a table. |
history |
"audit" |
The column’s earlier values are kept for the audit log only, which the install’s owner reads (a removed message’s text). A row’s history (GET …/{record}/history), a removed row’s history and row, a rule’s {{was.<column>}} and the row a removal’s live frame carries leave the column out, for every reader, the owner included; the audit page keeps it. The value a row holds now is read as any other column is. A rule or a template of the manifest that reads {{was.<column>}} of such a column is refused. Not on the key. Needs Adminium 0.3.35. |
rollup |
{ "from", "via", "sum" or "count", "times"?, "unlessSet"?, "where"?, "balance"?, "cap"? } |
A total over child rows, kept up to date as they change. from is the child table, via its foreign key back to this table, sum the column to add up, or "count": true to count the rows instead; see Totals that count and climb. times multiplies each row (a quantity); a child row with a value in unlessSet is left out (a voided line). See Totals and balances for where, balance and cap. |
stamp |
{ "set", "on", "clearOnBack"?, "strict"? } |
A value Adminium writes when something happens: the moment, who did it, or a deadline. See Stamps. |
venueLocal |
true |
A wall time given with no zone is read in the venue’s time zone. |
personal |
true or false |
Whether the column is personal data, overriding the guess Adminium makes from the column’s name. |
secret |
true or false |
Whether the column is a secret no response carries, to anyone, overriding the guess Adminium makes from the column’s name (api_token, password_hash). An entry in public access that names no select leaves a secret column out, and a code column too. false is written only on a table the app’s install made: on a table it reuses, a rule that would show a secret or take a personal column’s mask off is skipped, the check step says so, and only an operator can show the column, in Studio, as Super Admin. A secret the operator set in Studio wins over the app’s. On a column of an add-on’s shape, only true. |
Tones are the dashboard’s badge colours: neutral, accent, info, pos, warn and danger.
copy, default, sequence, format, code, rollup, formula, stamp, lookup and
perNight are values Adminium decides: they are filled on the server, so a browser never
picks a price, a number, a code or a time. So are a rollup’s balance column, a booking’s
late-cancellation flag and a late move’s flag. None of them can be listed as writable in public access, and
a primary key cannot take sequence or code.
One rule decides a column. The one pair allowed is a copy with a default behind it: the copy
comes first, and the default fills the column when the copy comes back empty (a client’s own tax
rate, else the business’s). A stamped column takes none of the others.
The outbox’s own columns that Adminium writes (status, sentAt, error,
skipReason, approvedBy, effectAt and effectError) take none of these rules, nor options,
validation, required, requiredWhen, notAfter or notBefore: a stamp of who approved a
message would race Adminium for the column, and a rule that refuses a value would refuse what
Adminium writes, so every message the desk makes would be refused, or stuck. Its to and
language, which Adminium writes when it looks the address up, take none that decide a value, nor
options, required or requiredWhen (a desk leaves to empty to have it looked up); a
validation of the address a person types is fine. Nor may another column’s rule read one of them
where the read could refuse Adminium’s write: a note requiredWhen the status is sent, a formula
worked out from effectAt, a notBefore bound on sentAt. The install names the column, and a
Studio save refuses the same rules on an installed outbox’s columns.
Every name a rule uses is checked against the manifest: copy.via must be a foreign key of the
table, rollup.via must point back at this table, and so on. normalize is for text columns
only.
The rules are kept on every door a row is written through: a form, a bulk edit, an import, an
automation, the public API and an outbox’s onSent change. normalize, formula and the
rounding to a scale apply on each of them. History keeps what it brings: an import and sample
data are not stamped, not capped, and not held to a date’s
notAfter or notBefore — today, another date, now (they are held to linkable); an undo puts a row back exactly as it was, with no rule at all.
What is no history is judged on an import and on sample data too: a number against a number, a
time of day against another, and an end at most days after a start (a file cannot bring in warm
above stale, or a cover of two years). A value refused by notAfter or notBefore answers 422
VALIDATION_FAILED, the field’s code out-of-range, on the column and, when the other column is of
the same row, on that one too, so a form shows the pair. A bound is judged when the column is
written, and when the other column, its via link or a column its conditions read changes (warm
changed alone is judged against the stale the row holds). required and
requiredWhen hold on an import and on sample data too; a value they refuse answers 422
VALIDATION_FAILED, the field’s code required. On every table, with a rule or without one, text
holding the character U+0000 (anywhere in a JSON value too) is refused the same way, the field’s
code invalid-character: Postgres cannot store it, and MySQL and SQLite would keep what Postgres
refuses.
The public API answers a refused value with its one 400 PUBLIC_WRITE_REFUSED. When the value
was refused for itself, in a column the entry lets the caller write, params names the column and
why: { "column": "name", "reason": "too-long" }, the reason too-long, format,
invalid-character, or on a create required. A batch adds the row’s index. Anything else
names no column: a value already taken or pointing at a row that is not there, a value outside
options or a link its linkable leaves out, a date out of bounds, and a column only a change leaves empty, since whether it may be
empty can turn on what the stored row holds.
Required for some values
Section titled “Required for some values”requiredWhen asks for a column only while another column of the same row holds one of some
values:
{ "ref": "person_id", "type": "fk", "references": "people", "nullable": true, "rules": { "requiredWhen": { "column": "kind", "in": ["away", "sick"] } } }The row is judged as the write leaves it, whenever the write changes either column: a create or an
update that leaves person_id empty while kind is away or sick is refused, and so is moving
an event whose person_id is empty to away. A write that changes neither column is not judged —
a column sent back as it is stored, as the record form sends every field, is no change — so a row
stored before the rule can still be edited. Empty means no value, or only spaces. On a create that
leaves kind out, kind is its database default: a trip that is away unless it says otherwise
asks for a person. The record form marks the field required as soon as the other column holds one
of the values, and asks nothing of an edit that changes neither.
- As the database compares. A
boolcolumn’s yes is a yes in any spelling (true,on,y,1,TRUE), and it is stored as the answer it names on every engine; a word that is no yes and no no is refused. On MySQL a plain text column is compared as MySQL compares it, soAWAYisawaythere, and asks for a person; Postgres and SQLite keepAwayapart fromaway. - Two writers at once. An update that moves
kindtoawayover a person it did not send, or emptiesperson_idover akindit did not send, asks the database, in the same statement, that what it read is still so. Two people changing one event at the same moment cannot together leave it away with nobody named: the second is refused as the first would have been.
column is another column of the same table, and every value in in must fit it (a value of an
enum, a number for a number column, true or false for a bool). The rule’s own column must be
nullable: one that is never empty is simply required. A column cannot take both required and
requiredWhen, and a column Adminium fills (copy, default, sequence, format, code,
rollup, formula) takes no requiredWhen, because nobody is asked for it. One exception is a
copy that only fills what a write leaves out (mode: "default", without follow): the copy runs
first, and the column is asked for only when there was nothing to copy either (an order sent by
email needs an address: its supplier’s, or one typed on the order). The other is a stamp: it fills
a person’s own save, and never a write that brings history. A line of a brought file is judged as it
was sent, before any stamp: a deal brought in as won with no closed_at (closed_at stamped when
state becomes won or lost, and requiredWhen state is won or lost) is named in the import’s
preview, closed_at required, and not brought in, never dated by the day of the import; one that
brings its date keeps it. Sample data is never stamped, so a sample’s won deal carries its date too.
A stamp beside requiredWhen needs Adminium 0.3.35.
{ "ref": "subtotal", "type": "money", "default": 0, "rules": { "rollup": { "from": "ticket_items", "via": "ticket_id", "sum": "unit_price", "times": "qty", "unlessSet": "voided_at" } } }Links to some rows
Section titled “Links to some rows”linkable says that a foreign key names only a row meeting some conditions: a quote line names a
price-list line that is still offered, so a rep cannot price a quote from a retired line the list
no longer gives.
{ "ref": "price_id", "type": "fk", "references": "price_list", "nullable": true, "rules": { "linkable": { "where": [{ "column": "active", "eq": true }] } } },{ "ref": "list_rate", "type": "decimal", "scale": "currency", "nullable": true, "rules": { "copy": { "via": "price_id", "from": "list_rate", "mode": "always" } } }where is 1–8 conditions on the row the link names, in the shape a move’s
linked takes: { "column", <one test> }, the test eq or in
(values that fit the column), isNull, or gt, gte, lt or lte on a number column. Every
condition must hold. The rule’s column is a foreign key, and each condition names a column of the
table it points at that is not a secret.
- When it is judged. On a new row that names a row, and on a change that moves the link to another row. A change that leaves the link as the row holds it, or sends it back as it stands (as the record form sends every field), is no move: a line naming a price retired since keeps it while its quantity changes. An empty link names nothing, and is taken.
- Who it holds. Everybody, an admin and the owner too: it is the data’s own rule, not a permission. It holds on every door a row is written through: a form, a bulk edit, a record’s action, an automation, the public API, an import (a new line, and a matched line that moves the link) and sample data. An undo puts a row back as it was. A row a rule or an effect moves keeps the link it holds.
- The refusal.
422VALIDATION_FAILED, the field’s codenot-allowed(the code a value outsideoptionsgets), naming nothing of the row the link named. Through the public API it isPUBLIC_WRITE_REFUSEDwith no column. A key that names no row at all is the foreign key’s to refuse, as it always was; and somebody who does not reach the named row is refused as for a row that is not there, before this rule is asked, so the rule never tells them whether a row out of their reach meets it. - Read as it stands. The named row is read when the write is judged. A line retired at the same moment as a quote names it may still be named, as if the quote had come first.
- A rule that can no longer be read. When the link’s relation is gone, or a column a condition reads is no longer on the table, no row can be shown to meet the rule: every new link is refused, and rows that already hold one keep it. Put the column back, or change the rule in Studio.
A row that must match the row written
Section titled “A row that must match the row written”same compares the named row with the row being written instead of with a fixed value: a site
visit names only a contact who works for the company of the visit’s deal.
{ "ref": "contact_id", "type": "fk", "references": "contacts", "nullable": true, "rules": { "linkable": { "same": [{ "column": "company_id", "as": "deal_id.company_id" }] } } }same is 1–4 pairs. column is a column of the row the link names (here a contact’s
company_id); as is a column of the row being written, or one link further, <link column>.<column>: the column of the row a foreign key of it names (here the visit’s deal’s
company_id). Each pair must hold: both sides hold the same value. An empty side holds nothing,
so a deal with no company yet matches no contact; an empty link is still taken. where and
same may be given together, and a rule gives at least one of them. Needs Adminium 0.3.28
(linkable.same).
- When it is judged. As
whereis, and also when a change moves the columnasreads on the row (a visit moved to another deal), the contact staying: the visit would no longer be the deal’s company’s. The far row is read as it stands when the write is judged: a deal moved to another company, or a contact who changes company, keeps the visits that name them. - What the refusal says. The same
422not-allowedon the link’s field, naming nothing of either row. The far row ofasis read through the write’s own handle; a writer who names a deal out of their reach is refused for that deal before this rule is asked, whichever contact the visit names. - The validator refuses a column the linked table does not have, a secret on either side, a hop through a column that is not a foreign key, the link compared with itself, and two columns that can never hold one value (a number and a text). Studio checks that each column and link is there.
An operator may set the same rule on a column in Studio; Studio refuses one whose column is not a foreign key, or whose conditions name a column the linked table does not have. The screens are still the app’s to filter: a picker that offers only active lines is kinder than a refusal.
Who may change a column
Section titled “Who may change a column”changes holds a column’s changes to 1–8 guards. A quote’s approval becomes approved or refused
only from asked, and never by the person who asked for it nor by the deal’s owner; an enquiry’s
deal is set only while the enquiry is claimed, and only by the person who claimed it:
{ "ref": "approval", "type": "enum", "enum": ["none", "asked", "approved", "refused"], "rules": { "changes": [ { "to": ["approved", "refused"], "from": ["asked"], "notBy": ["asked_by_id", "deal_id.owner_id"] } ] } }
{ "ref": "deal_id", "type": "fk", "references": "deals", "nullable": true, "rules": { "changes": [ { "while": { "column": "state", "in": ["claimed"] }, "by": "claimed_by_id" } ] } }| Key | What it says |
|---|---|
to |
1–32 values of the column: the guard holds a change to one of them. Without it, every change of the column (a new row’s value is a change from nothing). |
from |
1–32 values the row holds before the change. A new row holds none, so it is never made with a value the guard holds. |
while |
{ "column", "in" } or { "column", "isNull" }: another column of the row holds one of the values (or is empty, or not), before the change and as the change leaves it. Never a secret. |
by |
The only person who makes the change: the one a staff column of the row names (claimed_by_id), or <link>.<staff column>, the staff column of the row one of its foreign keys names (deal_id.owner_id), as the row stands before the change (a new row: as made). |
notBy |
1–4 people who never make the change, named as by names one, before the change and as it leaves the row: naming somebody else in the same write changes nothing. |
A guard says at least one of from, while, by and notBy, and every guard that holds a change
must be met. A value sent back as the row holds it is no change and is not judged.
- On every door. The server judges it where it writes the row: a change on a page, a record’s
button (a
setor a move is saved as a change), a bulk edit, a file brought in, the public API, a rule’s step. History is not judged: an undo puts back what was, and an app’s sample and seed rows say what happened then. A Super Admin is held to it like anybody. - Who is judged.
byandnotByjudge the signed-in person who writes; an API key or a visitor is somebody no column names (never theby, never anotBy). A write Adminium makes by itself (a rule’s step, a move when a moment passes) has nobody to judge: it is held tofromandwhileonly. A person read through a link the database no longer has holdsbyfor nobody andnotByfor everybody. - The refusal.
403COLUMN_FORBIDDEN, with the column and the reason:change-from,change-while,change-byorchange-not-by; a file’s line is refused by the same code, naming the column. What an update was judged on in the stored row is asked again by its statement: a row changed meanwhile is not written. - On the record page. A button whose change a guard leaves to others is not shown to the person
the row names in a
notBystaff column of its own, and is shown only to the person abynames there (stateActions[].notBy,.by); one read through a link is left to the save. That is presentation only: the save refuses the change whatever a page shows. - The validator refuses a value the column cannot hold, a change from the very values it goes to,
a
whileon the column itself or on a column the table has not, a person who is not a staff column (rules.staff) of the row or of the table a foreign key of it names, a person named twice or bothbyandnotBy, and a guard on a column Adminium decides (copy,sequence,code,rollup,stamp,formula,format,lookup,perNight), which no writer changes. Studio checks that each column and link is there. An app may add it to a column of a part of an add-on’s shape.
Files in a column
Section titled “Files in a column”file makes a column hold files a signed-in person uploads for it: a crew’s sign-off keeps the
customer’s signature and the job’s photos.
{ "ref": "signature", "type": "text", "maxLength": 64, "nullable": true, "rules": { "file": { "accept": ["png"], "maxBytes": 524288 } } },{ "ref": "photos", "type": "json", "nullable": true, "rules": { "file": { "accept": ["jpeg", "png", "webp"], "maxBytes": 8388608, "maxCount": 6 } } }A text column holds one file’s id (file_…, 31 characters, so a maxLength is at least 31); a
json column holds a list of ids, of at most maxCount, read back as a list on every database.
accept takes png, jpeg, webp, gif, pdf, office, zip, csv, text, markdown,
json, mp4, webm, mp3, wav and ogg: never svg, a document that can carry script, and
never heic, a picture whose camera notes cannot be taken out of it.
-
The upload.
POST /api/v1/files?connectionId=…&table=…&column=…&filename=…, the body the file itself, from a person who may create or change rows of the table. The column’s kinds and size narrow the workspace’s own (files.allowedTypes,files.maxBytes), never widen them: a file of another kind is refused415, a larger one413. A PNG, JPEG, WebP or GIF is kept without what a camera writes beside it: the place it was taken, the device, comments, XMP. A picture that cannot be read as one is refused415. The reply’sdata.idis what the column holds. Until a record names it, the file is its uploader’s alone: nobody else reads it, and the uploader may throw it away (DELETE /api/v1/files/{id}). One no record names is thrown away afterfiles.unattachedHours. -
The write. A create or a change may name, beside what the column already held, only a file its writer uploaded and no record holds yet (or one uploaded for this very record and in none of its other columns), of this database, of a kind the column takes, no larger than it allows, and, a picture, one cleaned as it was uploaded for a file column. A file named twice in one row is refused, and so is a list longer than
maxCount. Only a person names a new file, through a form, a bulk edit or a record’s button: an import, a rule, project code, sample data and the public API name none (they may keep or empty what the column holds), but for a picture column’s sample pictures (below). An undo puts back what was. A refusal is422VALIDATION_FAILED, the field’s codefile-not-here,file-type,file-too-largeortoo-many-files; whose a file is, whether it exists and which record holds it are all said alike, asfile-not-here. -
A picture column. A
textcolumn with"semantic": "image"and afilerule whoseacceptnames onlypng,jpeg,webpandgifholds one picture: a public entry may show it to every visitor (pictures), and a sample row may give it one of the bundle’s pictures,{ "@asset": "<label>" }, which the sample adds as that row’s own file. An image column whose file rule takes any other kind is refused. An image column with nofilerule holds links, as before.{ "ref": "photo", "type": "text", "nullable": true, "semantic": "image","rules": { "file": { "accept": ["png", "jpeg", "webp", "gif"], "maxBytes": 5242880 } } } -
The record holds it. Once named, the file is read with its record, by whoever reads the record and reaches its row, through every column their role shows. It is changed only through the record: taking it off, renaming it, throwing it away or hanging it on another record by the file routes is refused
409FILE_HELD, for its uploader and the owner alike. A change that names another file throws the old one away; removing the record throws away its files (an undo brings them back). -
What a manifest cannot do with one. A public entry neither writes nor defaults a file column, a sample row leaves it empty, and a file column takes no
default, nouniqueand no rule that decides its value (personal,requiredandrequiredWhenare fine). The kit’s signature pad and photo field upload for such a column from an app’s own screen.
Totals and balances
Section titled “Totals and balances”A rollup can also filter its child rows, keep a balance beside the total, and refuse a change that would take the balance below zero: what a visit’s fee, its payments and its write-offs need.
| Field | Rule |
|---|---|
where |
{ "column", "eq" }: only child rows whose column equals the value are added up (voided is false). The value must fit the column, and the column must not be nullable: a row left empty would drop out of the total unseen. |
balance |
{ "column", "of", "minus"? }: a second column of this row, kept as of − minus… − total (balance = fee − waived − paid). minus lists up to 4 columns. Every column named is a number column of this table, and the balance is a column of its own, with no rules of its own. |
cap |
true: a child write that would take the balance below zero is refused. It needs a balance on the same rollup, or a balance elsewhere on the row whose minus lists this total (a write-off is capped by the balance it lowers). |
capUnless |
{ "column" }, with cap: a yes/no column of the same row that lifts the cap while it is on (a stock level a shop sells from whether or not the count is right). The column is never empty. A balance guarded by two capped totals is lifted only when both name the same column. |
latest |
{ "of", "by", "row"? }, in place of sum and count: the column holds the value of of in the newest child row, the one whose by (a date, a time or a number) is the greatest (a deal’s amount is the total of its last sent quote: { "from": "quotes", "via": "deal_id", "latest": { "of": "total", "by": "sent_at", "row": "quote_id" } }). A row whose by is empty is left out; of two rows with the same by, the one with the greater key is taken; where and unlessSet leave rows out as they do of a total. The column is nullable (it is empty while no row qualifies) and of the type of of. row names a nullable column of this table that Adminium fills with the key of the row it took: a plain column of the key’s type, not a link (a link would stop that row being deleted), with no rules of its own. No times, balance or cap. of is never a shared link’s code, nor a code no staff member reads (hiddenFromStaff): the parent would show it. Kept in the statement that writes the child row, like a total, and it climbs like one: a line added to the quote that counts moves the deal. Needs Adminium 0.3.28. |
{ "ref": "paid", "type": "money", "default": 0, "rules": { "rollup": { "from": "payments", "via": "visit_id", "sum": "amount", "where": { "column": "voided", "eq": false }, "balance": { "column": "balance", "of": "fee", "minus": ["waived"] }, "cap": true } } }of may be another total of the row, so money given back is capped by money taken: two totals
over the same payments, taken (where kind taken) and given_back (where kind
given_back, balance: { "column": "refundable", "of": "taken" }, cap: true). A refund past what
was taken is refused, and so is lowering a payment taken below what already went back.
A capped write is refused with BALANCE_EXCEEDED and the balance it would have gone below. So is a
change to the parent that lowers of under what is already paid. Only a write that takes the
balance below zero, or further below it, is refused: a row already negative from older data can
still be edited or voided. Two payments at once are judged one after the other, so they cannot
both pass.
A write that touches several rows of a table feeding a capped total is refused with
BALANCE_ONE_AT_A_TIME, because it cannot be judged row by row. Imports and sample data are
settled but not capped: they record what already happened.
A total or a balance a writer sends is dropped, not refused, so a form that sends the whole row still saves.
Totals that count and climb
Section titled “Totals that count and climb”A total may count its child rows instead of adding a column up: how many tickets an order holds, how many lines a kitchen ticket has.
{ "ref": "ticket_count", "type": "int", "default": 0, "rules": { "rollup": { "from": "tickets", "via": "order_id", "count": true, "unlessSet": "refunded_at" } } }A rollup names sum or "count": true, never both. A count is kept in an int or bigint
column, and takes no times, balance or cap; where and unlessSet leave rows out as they do
for a sum.
A total may also add up another table’s totals: an option’s price into its line, the line into
its order, the order into the customer’s lifetime total. Such totals climb, at most three
tables high, and never in a circle (a table adding up its own rows, or two tables adding up each
other). A write that moves a total at the bottom settles every total above it in the same
transaction: each level adds up what the level below has just written, then works out its
formulas and balances. Every door that moves a total does this: a create, a change, a delete, a
create with child rows, a bulk edit, an import, an undo, a parent form and sample data. Two
writers take the rows in one order (the highest parent first), so they never wait on each other
crosswise; a line moved to another order while a write was reading it is refused 409
WRITE_CONFLICT with details.retry: true, and the same write a moment later goes through.
Sums are exact on every engine, SQLite included: a total is added up from each row’s decimal text, never through a floating-point number.
A capped balance whose of is a formula (a total of subtotal and tax) is judged
against the cap one row at a time; a bulk edit or an import settles it afterwards, without the
cap. So validateManifest warns when the formula reads a column that stays
writable while the capped rows can exist: lock those columns with the table’s
states (and the lines they add up with lock: true) in every state a capped row can be
written in or reached from.
Formulas
Section titled “Formulas”A formula works a number out from the other columns of the same row: a line’s amount, a
document’s tax and total.
{ "ref": "amount", "type": "decimal", "scale": "currency", "nullable": true, "rules": { "formula": { "max": [0, { "sub": [{ "mul": ["qty", "rate"] }, { "coalesce": ["discount", 0] }] }] } } }An expression is a number, a column of the same row by its ref ("qty"), or one of these objects:
| Expression | Value |
|---|---|
{ "add": [a, b, …] } |
The sum of 2–8 expressions. |
{ "sub": [a, b] } |
a − b. |
{ "mul": [a, b, …] } |
The product of 2–8 expressions. |
{ "div": [a, b] } |
a ÷ b. Empty when b is zero. |
{ "min": [a, b, …] }, { "max": [a, b, …] } |
The smallest or largest of 2–8 expressions. |
{ "round": a } or { "round": [a, places] } |
a rounded half away from zero, to the column’s scale or to places (0–4). |
{ "coalesce": [a, b] } |
a, or b when a is empty. |
{ "if": [condition, a, b] } |
a when the condition holds, else b. |
{ "hoursBetween": [start, stop] } |
The hours from the start column to the stop column, both timestamptz columns of the row. See Hours between two moments. |
{ "daysBetween": [from, to] } |
The whole calendar days from the from date to the to date, two date columns of the row: a stay’s nights. |
{ "join": [part, part, …] } |
Text: 2–8 parts, each a column of the row or a piece of text, joined in order. The whole formula, of a text column; see Joined text. |
A condition is one of:
| Condition | Holds when |
|---|---|
{ "eq": [column, value] }, { "neq": [column, value] } |
The column equals, or does not equal, a string, number or boolean. The column may be of any type (an enum, a bool). neq does not hold for an empty column. |
{ "gt": [a, b] }, { "gte": … }, { "lt": … }, { "lte": … } |
a is greater, greater or equal, less, or less or equal than b. A comparison with an empty side does not hold. |
{ "isNull": column } |
The column is empty. |
{ "and": [c, c, …] }, { "or": [c, c, …] } |
All, or any, of 2–8 conditions. |
{ "if": [{ "eq": ["discount_kind", "percent"] }, { "mul": ["qty", "rate", { "sub": [1, { "div": [{ "coalesce": ["discount", 0] }, 100] }] }] }, { "sub": [{ "mul": ["qty", "rate"] }, { "coalesce": ["discount", 0] }] }] }How a formula is worked out:
- Exactly. Every value is read from its decimal text as an exact fraction, and nothing goes
through a floating-point number.
1 ÷ 3 × 3is exactly 1, and a total is the same on Postgres, MySQL and SQLite to the last minor unit. - Rounded once, half away from zero, to the column’s scale. A formula
column with no
scalerounds to 0 places when it is anintorbigint, and to 4 when it is a decimal.roundinside a formula rounds that part early, where the arithmetic calls for it (a tax rounded before it is added). - Empty in, empty out. An empty column makes the result empty unless
coalescesays what to read instead: a draft line with no rate yet has no amount, rather than an amount of 0 that looks like a price. - On every write. A create works out every formula; an update works out the ones whose inputs it changed, reading the stored row with the new values over it. A formula that reads another formula column is worked out after it. A formula that reads a rollup total is worked out again whenever the total moves.
A formula fills a decimal, money, int or bigint column, never a float (a
join fills a text column). It reads only columns of its own table, and every
column it counts with holds a number; eq, neq and isNull may name any column,
hoursBetween names two different timestamptz columns and daysBetween two different date
columns. It
may not read itself, formulas may not read each other in a circle, and an expression nests at most
8 deep. A value a writer sends to a formula column is dropped. Anything that reads another row is a
copy or a rollup, which already keep in step when that other row changes.
Hours between two moments
Section titled “Hours between two moments”hoursBetween works a time entry’s hours out from its start and its stop:
{ "ref": "hours", "type": "decimal", "scale": 2, "nullable": true, "rules": { "formula": { "hoursBetween": ["started_at", "stopped_at"] } } }09:15 → 11:45 is 2.50; 22:30 → 01:15 the next day is 2.75. The hours are exact and rounded
once to the column’s scale, like any formula, and they can be counted with further:
{ "mul": [{ "hoursBetween": ["started_at", "stopped_at"] }, "rate"] } is the pay at a rate. An
update that moves only the stop works the hours out again from the start as stored.
- The time that passed. Hours are real elapsed time. A column that keeps a zone (Postgres
timestamptz, MySQLTIMESTAMP) holds the moment itself. One that keeps none (MySQLDATETIME, which is what an app’stimestamptzcolumn becomes on MySQL, Postgrestimestamp, and SQLite text) is read on the Adminium server’s clock, the clock Adminium writes such times on. So on a server in Europe/London, 00:30 → 03:30 on the night the clocks go forward is2.00, and 00:30 → 02:30 on the night they go back is3.00, on every engine. Run the server in the zone the times are kept in. - A time written without a zone. Sent for a column that keeps a zone,
2026-09-25 11:45is 11:45 on the Adminium server’s clock — the moment the hours are counted to and the moment that is stored, whatever zone the database’s session is in. A stop stampednowis that moment too. - What SQLite keeps. A start SQLite fills with
unixepoch()(seconds since 1970) is read as that moment, and so is a text with its zone after a space (2026-09-25 09:15:00 +02:00). - Empty for a missing, impossible or backwards span. An empty start or stop leaves the hours
empty, and so does a day or an hour the calendar does not have (30 February stays no time, not 2
March). So does a stop before its start: a negative number of hours would quietly take pay off a
total, so the entry shows no hours until it is corrected. A stop equal to the start is
0.00. - Too many to keep. Hours the column cannot hold (centuries in a
numeric(6, 2)) are refused,422VALIDATION_FAILEDwith the codeout-of-rangeon the moments they are counted from, on every engine — never left for the database to refuse or, on SQLite, to keep. Any formula whose result its column cannot hold is refused the same way.
daysBetween counts calendar days the same way: two date columns of the row, the nights of a
stay from its arrival to its departure. A night the clocks change is still one night. An empty
date, or a to before its from, leaves the result empty.
Joined text
Section titled “Joined text”A join makes a text column from other columns and pieces of text: a guest’s full name, a
line’s description.
{ "ref": "full_name", "type": "text", "maxLength": 160, "nullable": true, "rules": { "formula": { "join": ["first_name", " ", "last_name"] } } }A part that reads as a snake_case name is a column of the row; any other part (a space, " · ")
is text written as it is. A join reads text, int and bigint columns only: a decimal, a yes or
no, or a time would be spelled differently by each database. An empty column is left out, and so
is the text between it and its neighbour, so a guest with no last name is “Mia”, not “Mia “. The
result is trimmed, and empty when every column is. A join is the whole formula of its column,
never a part of a sum.
A join that reads personal data lands it only in a personal column. A guest’s first and last
names beside their email read as personal by their names, so the joined name needs
"personal": true too; without it the check refuses the manifest. The same holds for a copy
and a stamp’s copy.
Numbers without gaps
Section titled “Numbers without gaps”A tax office expects an unbroken series of invoice numbers: none twice, none skipped. A
sequence with "gapless": true numbers rows that way.
{ "ref": "number_seq", "type": "int", "nullable": true, "rules": { "sequence": { "gapless": true, "startSetting": { "addOn": "invoices", "setting": "number_start_invoice" } } } },{ "ref": "number", "type": "text", "maxLength": 24, "nullable": true, "unique": true, "rules": { "format": { "from": "number_seq", "prefixSetting": { "addOn": "invoices", "setting": "prefix_invoice" }, "pad": 4 } } }| Field | Rule |
|---|---|
gapless |
true. The column is an int or bigint, and nullable. |
start |
The first number, at least 1. |
startSetting |
The first number, read from a setting when the row is made. Not with start. |
scope |
A foreign key of the table: the series restarts for each row it points at (a deliverable’s versions, v1, v2 …). |
sample |
true: the app’s sample rows may take numbers in the series, and the next real row follows them. For a series of the app’s own (a job’s J-01); never on a table built on an add-on’s document. Needs Adminium 0.3.28. |
scope and startSetting need gapless.
The number is the largest the table holds (for scope, the largest for that parent row) plus one,
and never less than the start. It is taken inside the transaction that inserts the row, so an
insert that fails takes its number back with it and the next create takes the same one. Two
creates at once never read the same largest number. When one is still taking the next number, the
other may be answered 409 NUMBER_BUSY, to try again. A number a writer sends is dropped, from
every writer but an import. A numbered row’s create is never undone: undo is refused 409, since a
number once taken stays taken; void the row instead.
format writes the number as text in the same statement:
| Field | Rule |
|---|---|
from |
The row’s numbered column (one with a gapless sequence). |
prefix |
Up to 12 letters, digits and - _ / . (INV-). |
prefixSetting |
The prefix, read from a setting when the row is made. Not with prefix. A change applies to the next number. |
pad |
The digits are padded with zeros to this many (0–12). |
The format column is text and nullable, and its maxLength must hold the prefix and the
padding. Give the text a unique constraint, as the example does: a second guard against a
number twice, which MySQL’s numbering also leans on.
An import brings its own history: it keeps the numbers its rows carry, and a row that carries only
the text, written as the table’s prefix followed by digits, gets the number from those digits.
The series then carries on after the largest. Sample rows spell a gapless number null, so they
stay off the real series, unless the series is marked sample; see Sample data.
Values from elsewhere
Section titled “Values from elsewhere”A default rule fills a column on a create that leaves it empty, with a value read when the row is
made:
{ "ref": "currency", "type": "text", "maxLength": 3, "nullable": true, "rules": { "default": { "from": "connection.currency" } } },{ "ref": "tax_rate", "type": "decimal", "scale": 3, "nullable": true, "rules": { "default": { "from": { "addOn": "invoices", "setting": "default_tax_rate" } } } }from is one of:
from |
Reads |
|---|---|
"connection.currency" |
The connection’s currency, a three-letter code. The column is text of at least 3 characters. |
{ "table", "column" } |
A column of the app’s one-row settings table. |
{ "addOn", "setting" } |
A setting of an add-on. The app must require that add-on in addOns.requires, so the setting is always there. |
The same three settings (the last two) are what sequence.startSetting and format.prefixSetting
read. A column with a default rule is nullable: when there is nothing to read, it stays empty
rather than taking a made-up value. An update never refills it.
A { "table", "column" } setting, wherever a rule reads one (a default, a limit’s size, a move’s
condition, a moment’s time), names a table that holds one row: the outbox’s
settings.table, or a table that stands alone, with no foreign key of its own, none
pointing at it, and no states, limits or booking. Adminium reads that row when the rule runs. A
moment, a state condition or a stamp’s amount that finds two rows there reads nothing rather than
guess which one is meant.
Copies that follow
Section titled “Copies that follow”A plain copy is taken when the row is written. With "follow": true it keeps in step: when the
row it copies from changes, every row that copies it takes the new value in the same write. A
hotel stay’s extras copy the stay’s nights and guests, so breakfast for two over three nights
becomes breakfast for three over four the moment the stay changes.
{ "ref": "nights", "type": "int", "nullable": true, "rules": { "copy": { "via": "stay_id", "from": "nights", "mode": "always", "follow": true } } }The copy is written, then every formula over it, then the totals it moves are settled into the
row it follows (the stay’s total, tax and balance), before the change commits; if any of that is
refused, nothing is kept. A paid stay whose balance would fall below zero is refused
409 BALANCE_EXCEEDED.
The rules that keep a follow sound:
- it always wins (
mode: "always"), and follows one level: the column it copies does not itself follow another row; - it never follows a total, a balance or a stamp, nor a formula over one: those change without a change of the row, so the copy would keep an old value;
- the row it follows is not worked out from this table’s own totals (a loop);
- a total another table keeps of these rows reads none of what the follow writes, including the link it groups by: a follow that moved rows between another table’s totals would leave those totals behind.
More than 500 rows following one changed row refuse the whole write, 409 FOLLOW_TOO_MANY
(details.table, details.count), rather than leave some behind. A bulk edit, an import or a
public batch that would move a paid balance below zero through its followers, or could not write
them under the caller’s role, or would move more than 500, is refused before anything is written:
409 BALANCE_ONE_AT_A_TIME. Make such changes one row at a time.
A row put back by an undo of its removal takes each copy that always wins ("mode": "always")
again from the row it copies, as that row is now, with the formulas that read it: a deal’s member
removed and brought back after the deal changed hands names the deal’s owner now, not the one it
was removed under. A copy that only fills a blank comes back as it was. Needs Adminium 0.3.35.
Prices by the night
Section titled “Prices by the night”perNight works a price out night by night: for each night from from up to the day before to,
the base rate read from the row rate.via points at, plus every adjustment row that matches that
night (a weekend, a season). Each night is rounded to the column’s scale, and
the column holds their sum.
{ "ref": "room_total", "type": "money", "scale": "currency", "nullable": true, "rules": { "perNight": { "from": "arrive", "to": "depart", "rate": { "via": "room_type_id", "column": "base_rate" }, "adjust": { "table": "rate_rules", "match": { "via": "room_type_id", "weekdays": "weekdays", "from": "from_date", "to": "to_date" }, "add": "amount", "name": "name", "where": { "column": "active", "eq": true } } } } }| Field | Rule |
|---|---|
from, to |
Two different date columns of the row: the first night, and the day after the last. |
rate |
{ "via", "column" }: a foreign key of the row, and the number column of the row it points at that holds the base rate. Never a secret or personal column. |
adjust |
Optional. table holds the adjustments; add is the number added to a night (negative for a discount) and name the text a night’s line is tagged with. |
of |
Optional. { "via", "column" }: this row’s nights are a part of another row’s price by the night (a credit for the nights a stay did not use): via a foreign key of this row, column that row’s priced column. While that row’s rates are the ones it was priced at, the part is today’s price exactly; after they changed, it is scaled to what the row was charged, and never more than it. That column may not itself be a part. |
adjust.match |
Which adjustments apply to a night. via: a foreign key to what rate.via points at (empty on a row: every type). weekdays: a text column of at least 27 characters listing nights like fri,sat (empty: every night). from, to: date columns, the first and last night it applies on, both included (empty: open). |
adjust.where |
{ "column", "eq" }: only adjustments whose column holds the value (active is true). The column may not be nullable. |
The column is a decimal, money, int or bigint, and a table has one such price. A create
always works it out. A change works it out again only when it writes the dates or the rate’s link
to something new, so rates edited later never re-price a stay already booked, and a form that
sends the whole row back keeps the booked price. An import keeps a figure it brings and works out
one it leaves out. Formulas read the price (a subtotal, the tax, the total), so they run after it.
The nights themselves are worked out, never stored: a dry run answers them (date, rate,
base, tags), staff read them at GET /api/v1/data/<connection>/<table>/<id>/nightly, and a
document can list them. When the rates changed after the stay was priced, the lines
come back as one line equal to the stored figure, so a folio never prints lines that disagree with
its total. A rate rule that cannot be read refuses the write 409 NIGHTLY_RATE_UNREADABLE.
Typed codes
Section titled “Typed codes”A lookup fills a foreign key from a code a person types: a discount code on an order, a presale
code on a ticket. The browser never names the codes row itself; Adminium finds it.
{ "ref": "promo_code", "type": "text", "maxLength": 32, "nullable": true },{ "ref": "promo_id", "type": "fk", "references": "promo_codes", "nullable": true, "rules": { "lookup": { "from": "promo_code", "table": "promo_codes", "column": "code", "where": [{ "column": "active", "eq": true }, { "column": "valid_until", "notBefore": "today", "orEmpty": true }], "scope": [{ "column": "event_id", "equals": "event_id", "orEmpty": true }] } } }| Field | Rule |
|---|---|
from |
The nullable text column of this row the code is typed into, up to 64 characters, with no rule of its own that decides it. |
table, column |
The codes table, and its text column the code is found by. The rule’s own column is a nullable foreign key to table. column finds one row: it is unique, a code column, or unique together with the scope columns in one of the table’s sets. It is compared as a code, so it has normalize: "code" unless it is a code column. Never the code a shared link opens its row with. |
where |
Up to 4 conditions on the codes row: { "column", "eq" }; { "column", "notBefore": "now" or "today", "orEmpty"? }, a date or time not yet past (valid_until); { "column", "notAfter": "now" or "today", "orEmpty"? }, one already reached (valid_from). orEmpty lets an empty column pass. |
scope |
1–2 { "column", "equals", "orEmpty"? }: the codes row’s column equals this row’s equals column (this show’s codes). With orEmpty, a codes row whose column is empty matches any (a code good for every show). |
A typed code is read the way codes are kept: upper case, spaces and dashes left out. A column
Adminium makes codes in reads it as a claim does, its prefix put back, O as 0,
I and L as 1. Two stored codes that fold alike are told apart by the exact spelling.
Every miss is one answer: no such code, a code switched off, expired, another show’s, or two that
fold alike are all refused 422 VALIDATION_FAILED on the typed column with the code unknown
(through the public API, PUBLIC_WRITE_REFUSED with reason: "unknown"), so a guesser learns no
more from one miss than from another. A code whose uses are all taken, counted by a
parent limit through the link, is refused on the typed column as used-up.
Emptying the typed column empties the link, and every copy made through it: a code taken off
takes its discount with it. A bulk edit, a form’s child rows and an import find each row’s code
the same way, and an unknown code refuses just that row. A table resolves at most two typed codes.
A code typed to read rows rather than write one (a presale code that shows its ticket type) is
a public entry’s unlockBy.
Codes that renew
Section titled “Codes that renew”A ticket’s code is the door’s proof. When the ticket goes to somebody else, the old code must
stop working at once, and the new holder gets one the old holder never saw. code.renew says
what makes a new code:
{ "ref": "code", "type": "text", "nullable": true, "rules": { "code": { "length": 8, "renew": { "on": { "column": "holder_customer_id", "changed": true } } } } }on is one trigger or a list of 2–3: { "column", "changed": true }, any change of another
column of the row, or { "column", "values" }, that column moving to one of 1–16 values (a
transfer accepted). The watched column is not a code itself, not json or blob, and a changed
trigger watches a column a person changes or a stamp writes (a holder stamped in as an offer is
taken renews too).
The new code is written in the same statement as the change: the old one stops as the write
commits, and every session a token link on that column opened stops with
it. What never renews: a create (it makes a code anyway), an import or other history, a change that
sends back the value the row already holds, and a server action that writes the code itself (a
new link). A timed move’s set renews like any other change.
Undoing a change of hands renews once more, so neither the old code nor the one handed on works
after it; see Undo of a move. A renewed code is never shown to a public caller
in the change’s reply, and only to staff who may read the table.
Stamps
Section titled “Stamps”A stamp writes a value when a row is created, or when another column changes to one of a list of values: the time a patient checked in, who took a payment.
{ "ref": "checked_in_at", "type": "timestamptz", "nullable": true, "rules": { "stamp": { "set": "now", "on": { "column": "status", "values": ["checked_in"] } } } }| Field | Rule |
|---|---|
set |
What is written; see the table below. |
on |
When: "create"; { "column", "values" }, another column of the table and 1–16 values it must change to; { "column", "filled": true }, the moment another column, a nullable one, is first filled; { "columns": [...] }, whenever one of 1–8 other columns changes (a create sets them all); or a list of 2–3 of these, any of which writes the stamp. |
clearOnBack |
true: emptied again when a move marked undo takes the row back out of a state the stamp watches (the time an order was marked ready, when the kitchen undoes the Ready). The column is nullable, the stamp watches the table’s state column, and some undo move leaves one of the states it watches. |
strict |
true: the value is always Adminium’s, on every door and for every writer, Super Admin included: a value a create, a change, a bulk edit, a rule, the public API or an add-on sends for the column is dropped (the stamp writes it, or the row keeps what it holds), and a line of a brought file that carries one is refused, the column named, in the preview and in the run (a line that leaves it empty is stamped as its bringer). Who ticked “may be e-mailed” and when is never typed. Only history keeps its own: an app’s sample data and an undo. Not with a byOrigin that leaves staff their own word. Needs Adminium 0.3.35. |
What a stamp writes:
set |
Writes | Column |
|---|---|---|
"now" |
The moment. | timestamptz |
"today" |
Today’s date on the venue’s calendar. | date |
"user-name", "user-id" |
Who made the write. | text |
{ "byOrigin": { "public", "staff"? } } |
One value for a write through the public API and another for everyone else. With no staff, a staff write keeps the value its writer chose (a desk records how a client approved; the portal always says “portal”). Each value must fit the column. |
text or enum |
{ "copy": column } |
Another column of the same row, as it stands at that moment: a client’s first answer, kept when they edit it later. Both columns have the same type. | any |
{ "claim": column, "staff"? } |
A column of the signed-in person’s own row (their email, their name), on a public write. column is a column of a table the app’s people sign in as (an entry with a claim). staff is "user-name" or "user-id": what a staff write stamps instead. |
text |
{ "addDays": { "date", "days", "map"? } } |
A date so many days after date, a date or timestamptz column of the row: a due date from the issue date and the terms. days is a number (0–3650) or a column: an int, or an enum or text column with map giving each of its values its days. |
date |
{ "hashOf": { "columns", "children"?, "linked"? } } |
A fingerprint: SHA-256 over the named columns, child rows and linked rows, in a canonical form anyone can recompute. | text of at least 64 characters |
{ "addMinutes": { "minutes" or "hours", "notAfter"? } } |
The moment so many minutes or hours from now: a hold for ten minutes, an offer open for a day. The amount is a number or a whole-number setting. notAfter is a moment it never passes (an offer ends at the doors at the latest); a missing moment caps nothing. |
timestamptz |
{ "deadline": { "days", "time", "notAfter"? } } |
days after today on the venue’s calendar, at time ("HH:MM" or a text setting), but never later than notAfter: a transfer due in five days at 18:00, or three days before the show. |
timestamptz |
{ "moment": <moment> } |
A moment of the row or a linked row, worked out whenever the stamp fires: a stay’s cancel-by, from its arrival. Read from other columns, never its own. A moment that cannot be found writes nothing. | timestamptz |
hashOf takes 1–24 of the row’s own columns; up to 4 children, each
{ "table", "via", "columns", "orderBy"? }, a child table whose foreign key via points at this
table, read in orderBy order and then by key; and up to 4 linked, each
{ "via", "table", "columns", "children"? }, the row this table’s foreign key via points at, with
its own children.
{ "ref": "due_on", "type": "date", "nullable": true, "rules": { "stamp": { "set": { "addDays": { "date": "issued_on", "days": "terms", "map": { "net7": 7, "net14": 14, "net30": 30, "on-receipt": 0 } } }, "on": { "column": "status", "values": ["sent"] } } } }A change is judged against the stored row, so sending a status the row already holds stamps
nothing again. A create that already holds one of the values (a walk-in written as checked in)
is stamped too. A stamp wins over a value the writer sent. Between its moments a stamped column
is Adminium’s, and a value a writer sends there is dropped, with two exceptions for staff: an
addDays date (the desk may still move a due date) and a byOrigin column with no staff
value. A byOrigin value that is itself now, today, user-name or user-id writes what that
stamp would.
A public write stamps like any other write, except user-name and user-id: a browser key is
nobody, so they write nothing. What it knows of the person is their own signed-in row, which is
what claim reads. For an automation, user-name is the rule’s name. Sample data, an app’s seed
rows and undo stamp nothing, since a stamp of today’s time over history would be false.
A file somebody brings in is different: each line is their own create or their own change. A
stamp is written into a column the line leaves out or leaves empty, as it would be for a save
(now is the moment of the import, user-id and user-name are the person who brought the
file, a byOrigin column gets its staff value). So a role that may not write received_at
still brings a list of enquiries in, and each row says when it arrived. A value the line does
carry is kept, where a save would lose it to the stamp: the day an old enquiry really came is
the file’s to say. Whether the person may say it is their role’s create limit, which refuses a
file that carries a column they may not give a new row. A line that changes a stored row (an
import that updates the rows it matches) fires the stamps a change by hand would, against the
row as stored: moving a status stamps its moment once, and the same file brought in again
stamps nothing. A stamped column takes
no copy, default, sequence, format, code, rollup, formula, lookup or perNight as
well, and a stamp watches a column other than its own.
A stamp that works out a moment again whenever what it reads changes: a stay may be cancelled free until 48 hours before 15:00 on its arrival day, and moving the arrival moves the deadline.
{ "ref": "cancel_by", "type": "timestamptz", "nullable": true, "rules": { "stamp": { "set": { "moment": { "column": "arrive", "time": { "table": "settings", "column": "arrive_from" }, "minus": { "hours": { "table": "settings", "column": "cancel_hours" } } } }, "on": { "columns": ["arrive"] } } } }An offer that lasts as many hours as the settings row says, but never past the show’s doors:
{ "ref": "offer_until", "type": "timestamptz", "nullable": true, "rules": { "stamp": { "set": { "addMinutes": { "hours": { "table": "settings", "column": "offer_hours" }, "notAfter": { "column": "doors_at", "via": "event_id" } } }, "on": { "column": "status", "values": ["offered"] } } } }Moments
Section titled “Moments”Every rule that reads a point in time reads it as a moment: a move allowed only after or before a time, a move Adminium makes when a time passes, a deadline a stamp writes, a public change open only inside a window, a hold that lasts until a time. A moment is a date or time column of the row, or of the row one of its links points at, at a wall time of the venue’s day, shifted by an amount, with fallbacks when its column is empty.
{ "column": "starts_at", "via": "event_id", "minus": { "minutes": 30 } }| Field | Rule |
|---|---|
column |
A date or timestamptz column: this row’s own, or the linked row’s with via. |
via |
A foreign key of this row: the moment is read from the row it points at. One link, never two. |
time |
The wall time on the column’s day, on the venue’s clock; a date column needs one. "HH:MM"; a text setting { "table", "column" } holding one; the venue’s opening or closing hour that weekday, { "hours": { "table", "weekday", "open"?, "opens"?, "closes" }, "edge": "opens" or "closes" }; or a time kept on the row itself, { "column" } (a guest’s arrival time on their stay). |
plus, minus |
A shift forward or back, not both: exactly one of minutes (up to 1,000,000), hours (up to 16,666) or days (up to 36,600), each a number or a whole-number setting. |
or |
1–3 fallback moments of the same shape (without their own or), read in turn when this one’s column is empty: an event’s own refund deadline, else seven days before it starts. |
Minutes and hours are elapsed time: 48 hours before 15:00 is 48 real hours, whatever the clocks did. Days are calendar days at the same wall time: 7 days before 20:00 is 20:00. A day the hours table marks closed, or has no row for, ends at midnight. A moment whose column is empty, whose linked row is missing, or whose setting cannot be read, is no moment at all, and the rule reading it says what that means: a move waiting for it is refused, a deadline capped by it is not capped.
A time kept on the row ({ "column" }) is a text column of at least 5 characters holding
HH:MM (H:MM and the database’s HH:MM:SS read too). Every column a moment reads that way is
checked when it is written: a value that does not read as a time of day (“9pm”) is refused 422
VALIDATION_FAILED with the code format, on every door. The moment is compared with the clock
the write reads under its locks, or with the time a staff device says a scan was made (see
occurredAt in the REST API).
Capacity
Section titled “Capacity”capacity limits how much of a pool a table’s rows may take: the guard behind a booking form, a
ticket shop and a hotel’s rooms. A rule names its pool one of three ways:
| Kind | The pool | Example |
|---|---|---|
slot (the default) |
Rows add up per start time. | Tables in a restaurant, orders in a pickup slot. |
parent |
Rows take from a limit held on the row they point at. | Tickets of a type, today’s portions of a dish, the uses of a code. |
night |
A row takes one unit on every night of its stay, from a pool counted in another table. | Rooms of a type, a hotel’s parking spaces. |
capacity is one rule, or a list of up to three (a pool per room type and a pool per room on the
same stays). A table has one slot rule at most. Numbers can be literal, or read from the app’s
one-row settings table as { "table": "<ref>", "column": "<ref>" }, so a
venue can change them without a new release. Every table a rule names is one of the app’s own, by
its short ref; columns of a row reached through a foreign key (via) are named plainly.
Which rows count is said by countWhere: { "column", "values", "via"? }, only rows whose column
holds one of the values (a cancelled order holds nothing), or a list of two, one on the row and one
on the row it belongs to (a ticket’s status and its order’s), via being the foreign key to that
owner. A rule reads through one owner only: every via of its conditions, hold and day names the
same foreign key.
Give the foreign keys a limit counts by index: true, so the count under the limit’s
lock reads the rows it needs rather than the whole table.
Slot limits
Section titled “Slot limits”"capacity": { "slot": "pickup_at", "amount": 1, "perSlot": { "table": "settings", "column": "orders_per_slot" }, "slotMinutes": 15, "countWhere": { "column": "status", "values": ["placed", "confirmed", "preparing", "ready"] }, "hours": { "table": "opening_hours", "weekday": "weekday", "open": "open", "opens": "opens", "closes": "closes" }, "closures": { "table": "closures", "from": "from_date", "to": "to_date", "active": "active" }, "pauses": { "table": "slot_pauses", "slot": "slot_at", "active": "active" }, "windowDays": 7, "noticeMinutes": 20}| Field | Required | Rule |
|---|---|---|
kind |
no | "slot", or left out. A rule with no kind is a slot rule, and reads exactly as it always has. |
slot |
yes | The timestamptz column holding each row’s time. |
amount |
yes | How much a row takes: an int column (a party size), or a number from 1 to 1000. A column a guest asks availability about needs a largest value, validation.max. |
perSlot |
yes | How much one slot holds. A non-negative integer, or a settings reference. |
slotMinutes |
yes | Slot length in minutes, or a settings reference; at least 1. |
countWhere |
no | Which rows count (above). |
windowDays |
no | How many days ahead bookings are open. |
opens, closes |
no | "HH:MM", or a settings reference. Not with hours. |
hours |
no | Weekly hours in place of opens and closes: { "table", "weekday", "open"?, "opens", "closes" }, one row per weekday. weekday is an enum of exactly mon … sun, opens and closes are text columns holding HH:MM, open a bool. |
closures |
no | Days the venue is closed, { "table", "from", "to", "active"? }, from to to included (date columns). |
pauses |
no | Slots the venue has paused (a kitchen that is full): { "table", "slot", "active"? }, slot a timestamptz. |
noticeMinutes |
no | How many minutes ahead a guest’s slot must be. Staff are never held to it. |
resource |
no | A column (a table, a room): the limit applies per value of it too. |
cancelHours |
no | Until how many hours before its time a guest may still cancel through the public API. Staff are never held to it. |
hold |
no | Rows count only while their hold lasts; see Holds. |
The slots of a day run on the grid from opening. With an hours table the day is
[opens, closes): the last slot starts before closing. With plain opens and closes, a slot
also ends by closing.
Parent limits
Section titled “Parent limits”"capacity": { "kind": "parent", "via": "ticket_type_id", "size": { "column": "quantity" }, "countWhere": [{ "column": "status", "values": ["valid", "offered", "checked_in"] }, { "column": "status", "values": ["held", "paid"], "via": "order_id" }], "window": { "opens": "sales_open_at", "closes": "sales_close_at" }, "perWrite": { "max": 6, "within": "order_id" }, "also": [{ "via": "event_id", "size": { "column": "sell_limit" } }], "hold": { "column": "held_until", "states": ["held"], "via": "order_id" }}| Field | Required | Rule |
|---|---|---|
kind |
yes | "parent". |
via |
yes | The foreign key to the row holding the limit. A row with it empty is left out of this rule. |
size |
yes | How many the pool holds: a number, a settings reference, or { "column" }, a number column of the row via points at (empty there: no limit). With { "column", "onDay" }, onDay a date column of that row, the number holds only on that venue day and any other day has no limit (today’s portions); it needs day. |
amount |
no | How much a row takes: an int column of the row, or a number. Absent: one. |
countWhere |
no | Which rows count (above). |
window |
no | { "opens"?, "closes"? }: timestamptz columns of the parent a sale must fall between (empty: no bound). |
perWrite |
no | { "max", "within" }: at most max (a size, as above) per row of within, a foreign key of this row (up to six tickets an order). |
also |
no | 1–2 wider pools the same rows also take from: { "via", "size" }, where via is another foreign key of the row and size a size of the row it points at, or { "via", "column" } one more hop away (a room’s cap across its ticket types). |
day |
no | Count only the rows of the same venue day as this time: a timestamptz column, or { "column", "via"? } on the owner. |
lockBy |
no | The column whose value names the lock: via, or a wider pool’s via that is itself a copy through via. Absent: one lock for the whole table. |
hold |
no | See Holds. |
reserved |
no | { "states", "via"? }: counted states whose places are kept back from the public while staff decide what to do with them (a refunded ticket held for the waitlist). Not a held state. |
Night limits
Section titled “Night limits”"capacity": [{ "kind": "night", "from": "arrive", "to": "depart", "countWhere": { "column": "status", "values": ["booked", "in_house"] }, "pool": { "via": "room_type_id", "count": { "table": "rooms", "column": "room_type_id", "outOfService": { "table": "room_blocks", "room": "room_id", "from": "from_date", "to": "to_date", "active": "active" } }, "fits": { "column": "sleeps" }, "given": { "via": "room_id", "column": "room_type_id" } }, "nights": { "min": 1, "max": 28, "aheadDays": 365 }, "arrived": { "states": ["in_house"] }}]A stay takes one unit on every night from its arrival to the day before it leaves.
| Field | Required | Rule |
|---|---|---|
kind |
yes | "night". |
from, to |
yes | The arrival and the departure: date columns of the row, or { "via", "column" } of the row it belongs to (an extra’s nights are its stay’s). |
countWhere |
no | Which rows count (above). |
pool |
yes | Either the rows of another table: { "via", "count": { "table", "column", "outOfService"? }, "fits"?, "given"? }, the rows of count.table whose column points where the row’s via points (the rooms of a type); or one unit per row pointed at, or a number of that row: { "via", "size": 1 or { "column" }, "outOfService"? } (a room holds one stay a night; an extra’s parking spaces). |
pool.count.outOfService, pool.outOfService |
no | { "table", "room", "from", "to", "active"? }: rooms out of service between two dates, room a foreign key to the counted rows. They are taken off the pool on those nights. |
pool.fits |
no | { "column" }: a number column of the pool’s row a stay’s guests must fit (how many a type sleeps). It filters what availability offers; to refuse a stay with too many guests, use the entry’s agrees. |
pool.given |
no | { "via", "column" }: when the row’s via link is set (a room given), it counts against that row’s column instead (the room’s type, not the one booked). |
nights |
no | { "min"?, "max"?, "minByArrival"?, "aheadDays"? }: the shortest and longest stay (a stay is at least one night), a shortest stay per arrival weekday ({ "fri": 2 }, 1–60), and how many days ahead a stay may start. |
hold |
no | See Holds. |
arrived |
no | { "states", "via"? }: the counted states of a stay whose guest has arrived. A change of such a stay (another room, another type, other dates) is judged from the venue’s today on; the nights already slept are never judged again, so a room closed last night takes nothing from a guest moved today. Extras whose nights come from the stay are judged the same way. |
Parent and night availability for guests read the pool’s rows, so the key also needs a plain read entry on the pool’s table (the ticket types, the room types).
A hold makes a row count only for a while: an order a guest is paying for keeps its tickets for ten minutes, and gives them back when it lapses.
"hold": { "column": "held_until", "states": ["held"] }| Field | Rule |
|---|---|
column |
When the hold ends: a timestamptz column of the row (or of its owner, with via) that a stamp writes, never one a guest writes. Or { "column", "via", "or"? }: a moment of a linked row, via a foreign key of the row the hold reads, and or 1–2 fallbacks { "column", "via"? } read when the link is empty (a waitlist offer’s end, else the order’s own). |
states |
1–8 counted states in which the row is held. A row in one of them counts only until its hold ends; a row in any other counted state counts whatever its old hold says (a paid order). |
via |
The owner the states and the column are read on. |
One live hold per buyer. A public create sends replaces, the page’s own-link session for the
hold it takes the place of (a checkout changed before it was confirmed): that hold is let go in the
same write, and the session moves to the new hold. A verified signed-in person’s other holds are
let go the same way. A dry run takes replaces too, and
judges the places as if the old hold were gone, letting nothing go. Letting a hold go writes its
end directly, with no rule run on that write, so nothing may watch a hold’s end column: no stamp,
code renewal or followed copy is set off by it.
A row that leaves a hold state (a waitlist claim) counts the places kept back by reserved as
staff do, so a returned place offered on is not counted twice.
How a limit is judged
Section titled “How a limit is judged”A write that adds to what a pool counts is judged inside its transaction, under a named lock (a
slot limit’s per venue day, a parent limit’s per lockBy value, a night limit’s per table), so two
writers never both take the last place. A writer waits at most 10 seconds for another’s lock, then
is answered 409 CAPACITY_BUSY (or NUMBER_BUSY) to try again. A bulk edit cannot move a row
whose limit needs a lock (409 CONFLICT, details.reason: "CAPACITY_ONE_AT_A_TIME"), nor can a
public batch (400 PUBLIC_WRITE_REFUSED); an import records history, and is not judged.
Staff are refused 409 CAPACITY_FULL, with details naming the rule’s kind, the column, the
pool (key, at) and left, the places that were left before the write; a place the venue does
not offer is refused with a reason (out of range, out of hours, closed, paused, not on sale, too
many). A guest is told PUBLIC_SLOT_FULL, PUBLIC_SOLD_OUT or
PUBLIC_NO_ROOM (with the night), never how full anything is; see
Error codes. Sample rows count against real availability like any other.
For the whole picture, see Booking rules and limits.
Booking
Section titled “Booking”booking books a person’s time rather than seats: each row takes a resource (a clinician) for its
own length, and two counted rows of one resource may never overlap. The time must also fall
inside that resource’s hours, off their break, on the booking grid and outside any closure.
Capacity adds up a party per start time; booking forbids overlap per resource. They answer
different questions, so a table has one or the other, never both.
Every table the rule names is one of the app’s own, by its short ref. A number can be literal or
read from the settings table as { "table", "column" }, as in Capacity. For how the
pieces fit together, see Booking rules.
"booking": { "start": "starts_at", "minutes": "minutes", "resource": "clinician_id", "kind": "visit_type_id", "countWhere": { "column": "status", "values": ["booked", "checked_in", "seen"] }, "eligible": { "table": "clinician_visit_types", "resource": "clinician_id", "kind": "visit_type_id", "order": { "table": "clinicians", "column": "position", "active": "active", "public": "online" } }, "hours": { "practice": { "table": "opening_hours", "weekday": "weekday", "opens": "opens", "closes": "closes", "breakStart": "break_start", "breakEnd": "break_end", "open": "open" }, "own": { "table": "clinician_hours", "resource": "clinician_id", "weekday": "weekday", "opens": "opens", "closes": "closes" } }, "closures": { "table": "closures", "from": "from_date", "to": "to_date", "resource": "clinician_id" }, "grid": { "table": "settings", "column": "grid_minutes" }, "windowDays": 60, "noticeMinutes": 120, "cancel": { "hours": 24, "mode": "flag", "flag": "late_cancel", "when": { "column": "status", "to": "cancelled" } }}| Field | Required | Rule |
|---|---|---|
start |
yes | A timestamptz column: when the row starts, read in the venue’s time zone. |
minutes |
yes | An int column: how long the row lasts. Usually a copy from the kind. |
resource |
yes | A foreign key: whose time the row takes. Left empty on a create, it means anyone: Adminium picks the first free person in eligible.order. |
kind |
yes | A foreign key: what the row is, which decides who may be booked for it. |
countWhere |
yes | { "column", "values" }: only rows whose column holds one of these values take time (a cancelled visit takes none). Each value must fit the column. |
eligible |
yes | Who does what: { "table", "resource", "kind", "order"? }, a link table whose two foreign keys point where the row’s resource and kind point. A person with no link row for a kind is never booked for it. |
eligible.order |
no | { "table", "column", "active"?, "public"? }, kept on the table resource points at. column is a number: the order in which “anyone” picks. A person whose active bool is false is never booked; one whose public bool is false is never booked through the public API. |
hours.practice |
yes | Weekly hours: { "table", "weekday", "opens", "closes", "breakStart"?, "breakEnd"?, "open"? }, one row per weekday. weekday is an enum of exactly mon, tue, wed, thu, fri, sat, sun, in that order. The times are text columns holding HH:MM. A break names both its start and its end. open is a bool. |
hours.own |
no | A person’s own weekly hours, the same shape plus resource, a foreign key to the person. A person with rows here follows them every day, and a weekday with no row is a day off. A person with none follows the practice’s hours. |
closures |
no | Dated closures: { "table", "from", "to", "resource"?, "active"? }. from and to are date columns. resource must be nullable: an empty one closes for everyone. active is a bool. |
grid |
yes | The minutes between bookable starts, counted from the opening time. At least 1. |
windowDays |
no | How many working days ahead a booking may be made. |
noticeMinutes |
no | How far ahead a public booking must be. Staff are never held to it. |
cancel |
no | Late cancellation: { "hours", "mode", "flag"?, "when" }. See below. |
cancel.when is { "column", "to" }: a cancellation is a change of the countWhere column to a
value that does not count. Inside hours of the start, mode: "refuse" turns a guest’s
cancellation away, and mode: "flag" lets it through and sets flag, a bool column of the table,
whoever cancels. flag is required with "flag" and not allowed with "refuse". A guest can never
move a booking inside the window, in either mode. Staff are never refused.
Nothing may start in the past, with one exception: staff may create a walk-in in the slot that
holds the current time, when its status is a counted value other than the first in countWhere
(checked_in rather than booked).
The public API answers a clash with PUBLIC_SLOT_FULL; a time outside hours, on a closure,
beyond the window or with nobody offered for the kind with PUBLIC_WRITE_REFUSED; and a guest’s
late move, or a late cancellation in refuse mode, with PUBLIC_TOO_LATE. An
availability entry on a booking table lists the free times of a day, or a
strip of days, for a kind.
A resource and a time
Section titled “A resource and a time”occupies is the plain rule beside Booking: a row takes a resource for a time, and no
two counted rows of one resource overlap. A showroom bay booked from two to three is not booked
again at half past two; a demo van is out once a day. It has no kinds of visit, nobody to pick, no
grid and no window. Use booking when people are booked for visits, and occupies for everything
else that can be taken twice by mistake. Needs Adminium 0.3.28.
"occupies": { "resource": "space_id", "start": "starts_at", "minutes": "minutes", "countWhere": { "column": "state", "values": ["tentative", "confirmed"] }, "hours": { "table": "space_days", "resource": "space_id", "weekday": "weekday", "opens": "opens", "closes": "closes" }}| Field | Required | Rule |
|---|---|---|
resource |
yes | What is taken: a column of the table, usually a foreign key. |
start |
one of start, day |
A timestamptz column: when the row starts, read in the venue’s time zone. Comes with minutes or end, one of the two. |
minutes |
with start |
An int column: how long the row lasts. |
end |
with start |
A timestamptz column: when the row ends. It must be after the start. |
day |
one of start, day |
A date column: the row takes the whole day. Takes no minutes and no end. |
until |
no, with day |
A date column, usually nullable: the last day the row takes, so the row is a span of whole days from day to until. Empty in a row means the span has no end. Takes no hours and no shared. |
countWhere |
no | { "column", "values" }: only rows whose column holds one of these values take the resource (a cancelled booking takes nothing). Without it every row counts. |
hours |
no | The resource’s week: { "table", "resource", "weekday", "opens"?, "closes"? }, a table of the app with one row per weekday the resource works. resource names what the row’s resource names; weekday is an enum of exactly mon, tue, wed, thu, fri, sat, sun, in that order. opens and closes are text columns holding HH:MM, both or neither. |
shared |
no | true: rows may share the resource’s time, and only its hours are judged (a crew does several jobs on a day it works, and none on its day off). Needs hours. |
A row with no resource, or no time yet, takes nothing and is not judged: a visit can wait for its day. A row that ends as another starts does not overlap it. A timed row may run past midnight and lasts 30 days at most.
With hours, a weekday the resource has no row for is a day it does not work. A timed row must
also lie inside the weekday’s opens and closes, when the rule names them. A resource with no
rows in the table at all keeps no week and is held to none, so a crew made a minute ago can be
booked before its days are written down.
With until, a row takes every day from its day to its until, and both ends count: a cover
that ends on the 14th and another of the same person that starts on the 14th overlap, and the
second is refused. Start it on the 15th instead. A row whose until is empty runs “until further
notice”: it overlaps every later row of its resource, and a second open-ended row. An until
before its day is refused with BOOKING_OUT_OF_RANGE. A span can be as long as you like.
"occupies": { "resource": "owner_id", "day": "starts_on", "until": "ends_on" }The refusal names the column and nothing of the row in the way: not its key and not its days, because the person writing may not be allowed to read that row.
A write that overlaps is refused with BOOKING_TAKEN (409), one on a day the resource does not
work with BOOKING_CLOSED (409), one outside the opening times with BOOKING_OUT_OF_HOURS and one
whose time cannot be read or is too long with BOOKING_OUT_OF_RANGE (both 422). The public API
answers as it does for a booking. Two writers of one resource take turns, so two requests for the
same hour cannot both win. As on a booking table, rows are written one at a time: a batch of
new rows, or a batch change that could move a row’s time or resource, is refused. A create of
one row may carry a retry key (clientKey), on this table and on a booking table: it is placed
under the same lock and judged by the same rule, the same key again answers the row the first
made, and another key for a time that is taken is refused BOOKING_TAKEN. A create with rows
below it is still refused (CAPACITY_ONE_AT_A_TIME).
States
Section titled “States”states describes the life of a row: the states it moves through, what may happen in each, and
the child tables tied to them. An invoice is a draft, then sent, then perhaps void; once sent, its
lines are locked and payments may be recorded against it.
"states": { "column": "status", "initial": "draft", "moves": { "draft": [{ "to": "sent", "requires": { "children": { "invoice_lines": 1 }, "where": [{ "column": "total", "gt": 0 }] } }, "void"], "sent": [{ "to": "void", "requires": { "where": [{ "column": "paid", "eq": 0 }] }, "roles": ["manager"] }] }, "lock": { "when": ["sent", "void"], "except": ["due_on", "ladder", "void_reason"] }, "children": { "invoice_lines": { "via": "document_id", "lock": true }, "payments": { "via": "document_id", "parentIn": ["sent"], "clearOnCreate": ["client_paid_at"] } }, "noDelete": { "when": "numbered" }}| Field | Required | Rule |
|---|---|---|
column |
yes | The enum column that holds the state. Every state named below is one of its values. |
initial |
yes | The state a new row starts in. |
moves |
yes | From each state, up to 16 states a row may move to. A move is a state ("void"), or { "to", "requires"?, "roles"?, "undo"?, "clears"? }. A move goes to another state. undo: true marks a move that takes back the listed move the other way; clears (1–8 columns, on an undo only) names further columns it empties. See Undo of a move. |
lock |
no | { "when", "except"? }. While a row is in one of when (1–16 states), only the columns in except (up to 32) may change, and the state itself through a move. The state column is never in except. |
children |
no | Child tables tied to the row’s state, keyed by table ref. See below. |
lockedWhenReferencedBy |
no | 1–4 { "table", "via", "in" }: the row is locked once a row of table, whose foreign key via points at it, is in one of the states in (a terms version, once a proposal naming it is sent). Needs lock, which says what stays open. |
noDelete |
no | { "when" }: rows that are never deleted, only voided. when is 1–16 states, or "numbered": any row that holds a number from a gapless sequence. "numbered" needs such a column on the table. |
onlyLater |
no | 1–8 date or timestamptz columns that may move later, never earlier (a quote’s valid_until). An entry { "column": "depart", "in": ["in_house"] } holds only while the row is in one of in (a stay in the house leaves no earlier by a change of its date). |
strict |
no | true, or { "show": [1–4 columns] }: a write naming the state the row already holds is refused rather than passing silently (a ticket let in once). See Once means once. |
late |
no | 1–4 moves judged late when made close to a moment; see Late moves. |
timed |
no | 1–8 moves Adminium makes by itself once a moment has passed; see Timed moves. |
effects |
no | 1–8 moves of the row a link points at, set off by this row’s move or by a change of the link; see Effects. |
create |
no | { "requires" }: what a new row must meet to be created; see Conditions on a new row. |
A move’s requires says what must be true first:
| Field | Rule |
|---|---|
children |
{ "<table>": n }: at least n (1–1000) rows of a child table. The table must be one of children. |
where |
1–8 conditions on the row itself: { "column", <one test> }, where the test is eq or in (values that fit the column), isNull (true or false), or gt, gte, lt or lte (a number, on a number column). |
linked |
1–4 { "via", "where" }: conditions (as where) on the row this row’s foreign key via points at (the order a ticket belongs to is paid). |
time |
{ "after"?, "before"? }: a window on the clock, each end a moment (from half an hour before the doors, until the ticket’s day ends). |
setting |
1–4 { "table", "column", "eq" }: a value of the settings row (door sales switched on). |
notPast |
A date column of the row: the move is refused once that day has passed. See A move’s last day. |
A move’s roles (1–8 of the app’s role keys) keeps it for the people holding one of
them: any role may void a draft, only a manager a sent invoice.
Each entry of children names a child table whose foreign key via points at this table, with at
least one of:
| Field | Rule |
|---|---|
lock |
true: the child’s rows are locked while this row is. Needs a lock on this table. |
parentIn |
1–16 states: the child’s rows may be written only while this row is in one of them (payments on a sent invoice). Not with lock. It is createIn and changeIn at once. |
createIn |
1–16 states: a child row may be added only while this row is in one of them (a payment taken on a stay still booked or in house). Not with lock or parentIn. |
changeIn |
1–16 states: a child row may be changed or deleted only while this row is in one of them (a payment voided on a cancelled stay too). Not with lock or parentIn. |
clearOnCreate |
1–8 nullable columns of this row, emptied when a child row is created (a recorded payment clears the client’s “I’ve sent it”). |
moveOnCreate |
A state of this row: a child row created moves it there, by one of this table’s listed moves (a sign-off made on site signs its job off). A row already there is left as it is. Needs Adminium 0.3.30. See A new child moves its parent. |
lockLinked |
{ "<link>": ["<column>", …] }: for 1–8 of the child’s foreign keys to other tables, 1–16 columns of the row the link points at that do not change while a child row points at it (the hours of time an invoice line bills). The key is never one. |
and, with lock:
| Field | Rule |
|---|---|
release |
{ "when", "columns" }: while this row is in one of when (states the lock holds, and that no move leaves), a child row may still empty the listed columns (1–8 nullable columns of the child, never via or the key), and change nothing else. A released state must be final: a released link stops locking the row it points at, so a document that could move on would bring its line back billing a row that changed meanwhile. |
Time and purchases billed on invoice lines, so that nothing is billed twice (each link unique),
and billed again once the invoice is void:
"children": { "invoice_lines": { "via": "invoice_id", "lock": true, "release": { "when": ["void"], "columns": ["time_entry_id", "expense_id"] }, "lockLinked": { "time_entry_id": ["hours", "logged_hours", "project_id", "date"], "expense_id": ["amount", "currency", "project_id", "client_id"] } }}A void invoice’s line may set time_entry_id to null; setting it to another entry, changing
anything else on the line, or deleting it stays RECORD_LOCKED, and so does emptying it while the
invoice is sent. While a line points at an entry, a change to one of the entry’s listed columns is
refused 409 RECORD_LOCKED, details.column naming it, and the entry’s record page draws those
fields read-only. A line on an invoice in a state that releases its link (release.when, with the
link among release.columns) keeps nothing: once the invoice is void the hours may change, and
once the line lets go the time may go on another invoice. A line whose invoice is gone, or has no
state, keeps them. A value the line copies from the entry (a copy through the link) is read
again when the line is written, holding the entry: an entry changed in between refuses the line
409 WRITE_CONFLICT, to be written again, so a line never bills hours its entry no longer has.
A listed column Adminium works out (a formula) is kept too: a change to what it is worked out from
is refused naming it. A total over the entry’s own child rows (a rollup) is Adminium’s, and moves.
A lockLinked link is followed through its foreign key. Studio refuses to save states whose link
has none, or names a column the linked table does not have. If the link later stops leading
anywhere Adminium can read (its relation removed in Studio, its foreign key or a kept column
dropped), the rule fails closed: a line may no longer be linked through it, and creating one or
changing its link is refused 409 RECORD_LOCKED with details.unresolved: true and the link as
details.column. Emptying the link, and every other change to the line, still goes through, and
the columns that can still be followed stay kept. Put the relation back, or change the states in
Studio, to bill through it again. A refusal names tables by their own names (linkedFrom,
parent), never with a schema in front.
A move that is not listed is not one the row may make. Columns Adminium keeps (totals, balances,
formulas, stamps) are Adminium’s to write whatever the state. A new row starts in initial. A
locked row cannot be deleted either, with or without noDelete.
The states hold on every write to the table: a form, a bulk edit, an automation, the public API,
and an outbox’s onSent change. A refusal is 409: STATE_MOVE_REFUSED for a move the row may
not make (or a new row that does not start in initial), RECORD_LOCKED for a change to a locked
row or to a child row its parent’s state does not allow (details.on says create or change,
beside the parent’s state and the states that allow it), and DELETE_REFUSED for a delete. An
onlyLater column moved earlier, or emptied, is refused 422 with the code out-of-range.
Through the public API each of these is PUBLIC_WRITE_REFUSED.
Adding sample data and importing past records are history. A new row they write may start in
any state, and a child row may follow a parent the same import or sample brought in; a child
row under a parent that was already there is judged as any other write. An import that updates
a row already there is judged in full, and a history write empties no clearOnCreate column.
An undo is never given for a write to a table with states, or to its child tables: a mistake is
moved on (voided, sent back), never unwritten. The one exception is a status move the app lists an
undo move for: its Undo is that move back, judged like any move. A table whose
columns a lockLinked keeps keeps its undo, and an undo is judged like any other change: an edit
of the hours made before the time was billed is not taken back after.
Conditions a move waits for
Section titled “Conditions a move waits for”Beyond children and where, a move may wait for the row one of its links points at, for a window
on the clock, and for the settings row:
"moves": { "valid": [{ "to": "checked_in", "requires": { "linked": [{ "via": "order_id", "where": [{ "column": "status", "eq": "paid" }] }], "time": { "after": { "column": "doors_at", "via": "event_id", "minus": { "minutes": 30 } } }, "setting": [{ "table": "settings", "column": "door_open", "eq": true }] } }]}Everything is read inside the write’s transaction, holding the linked row, so a ticket scanned
while its order is being paid sees the order as it committed. A condition that cannot be read (an
empty link, a linked row that is gone, a moment with no value) refuses: a move waiting for
something is never let through on nothing. A link that another writer moved while the write was
waiting refuses 409 WRITE_CONFLICT (details.retry: true). The window is judged by the
write’s own clock, or by the time a staff device says a scan was made (occurredAt). A refused
move is 409 STATE_MOVE_REFUSED, its details naming what failed: requires (linked,
time or setting), and via, column, bound (after or before) and at where they
apply, so the door can say “Not paid yet”, “Not today” or “Too early”.
A move’s last day
Section titled “A move’s last day”requires.notPast names a date column of the row. The move is made up to and on that day, and
refused from the day after: a quote is accepted no later than its valid_until.
"moves": { "sent": [{ "to": "accepted", "requires": { "notPast": "valid_until" } }, { "to": "declined", "requires": { "notPast": "valid_until" } }, "withdrawn"]}- The day is the business’s: today in the install’s time zone, never UTC’s and never the caller’s.
- It holds every writer alike: a person on the dashboard, a bulk edit, a record’s button, a rule, an import that moves a row already there, another row’s effect, and a customer through the public API.
- The date read is the one the row held before the write. A later date sent in the same write does not open the move; a person who may change the date changes it first, and then makes the move.
- A row whose date is empty is held to none.
- No public entry on the table may list the date column as
writable: a guest would move their own deadline. - An undo and a planned move take no last day.
A refused move is 409 STATE_DATE_PASSED, never STATE_MOVE_REFUSED, so a screen can tell “its
day has passed” from “it is not in a state that allows this”. details carry column (the state
column), from, to, date (the date column) and until (the day it held). Through the public
API it is 409 PUBLIC_TOO_LATE with no params: the entry may not show the date. The row is
still read as before, so a page opened by its link can say the quote ran out. Needs Adminium
0.3.28.
A public entry can also close a change by a date with writableWhen
("from-today"), which refuses a row whose date is empty and holds only that entry’s writes. The
move’s last day holds the staff too.
Conditions on a new row
Section titled “Conditions on a new row”create holds a new row to the same conditions before it is created at all: a check-in recorded
only for a paid ticket, on its day, from half an hour before the doors.
"create": { "requires": { "linked": [{ "via": "ticket_id", "where": [{ "column": "status", "in": ["valid", "offered"] }] }], "time": { "after": { "column": "doors_at", "via": "event_id", "minus": { "minutes": 30 } } } } }requires takes where, linked, time and setting, at least one, in the shapes above (no
children). They are judged inside the create’s transaction on every door but an import, which
is history; a staff device’s occurredAt stands in for now. A refusal is 409
STATE_MOVE_REFUSED with details.create: true and from: null, beside to, requires and
the parts above. The row’s formulas, and its copies of a linked row’s columns, are worked out
before the conditions are judged. On a table built on an add-on’s shape
the app may add only where conditions on columns it added itself (Adminium 0.3.35).
Once means once
Section titled “Once means once”strict refuses a write that names the state the row already holds, rather than letting it pass:
a ticket let in once is not let in again. The refusal is 409 STATE_UNCHANGED, with details.at
and details.by, when and by whom the row got there. { "show": [...] } repeats up to 4 more
columns of the row in the refusal (the door it came in by); never a secret or personal one.
Through the public API it is PUBLIC_WRITE_REFUSED with reason: "unchanged". A parent form that
sends a child row’s unchanged state back is not refused.
Late moves
Section titled “Late moves”late judges a move made close to a moment: a cancellation inside the last 48 hours before a
stay’s arrival.
"late": [{ "to": "cancelled", "from": ["booked"], "moment": { "column": "arrive", "time": "15:00" }, "within": { "hours": 48 }, "mode": "flag", "flag": "late_cancel" }]| Field | Rule |
|---|---|
to |
The state moved to. Each late rule judges its own move; a move the booking rule’s cancel already judges takes no second one. |
from |
1–16 states the move comes from, each a listed move to to. Absent: any. |
moment |
The moment it is close to, read from the row as stored: a guest who types a later arrival in the same change does not move the window. |
within |
How close: one of minutes, hours or days, a number or a setting. A moment already past is inside the window too. |
mode |
"flag": the move goes through and sets flag, a bool column of the table no other rule writes, whoever writes. "refuse": the move is turned away, for a public writer, or with "refuse": "everyone" for every writer. |
where |
1–8 conditions on the row as the write leaves it. Only a move that meets them is judged: [{ "column": "cancel_code", "in": ["guest", "no_card"] }] leaves a cancellation by the house out, even inside the window. A flag sent for a move the where leaves out is dropped. |
A refused late move is 409 STATE_TOO_LATE for staff, and PUBLIC_TOO_LATE through the public
API.
Timed moves
Section titled “Timed moves”timed lists moves Adminium makes by itself once a moment of the row has passed: an order still
held when its hold ends is released, an order still placed at the kitchen’s closing hour is
cancelled.
"timed": [{ "from": "placed", "to": "cancelled", "at": { "column": "pickup_at", "time": { "hours": { "table": "opening_hours", "weekday": "weekday", "open": "open", "opens": "opens", "closes": "closes" }, "edge": "closes" } }, "set": { "cancel_code": "closed" } }]| Field | Rule |
|---|---|
from, to |
A listed move, never one marked undo. One timed move leaves each state. |
at |
A moment of the row’s own columns (no via). A move whose requires.time.after is certainly later than at could never be made in time, and is refused by the validator. |
set |
1–8 other columns of the row and the fixed value (or null on a nullable column) the move writes with it: why an order still open at closing was cancelled. Never the state column, the key, or a column another rule writes. |
A timed move is a write by Adminium like any other: it is judged as the declared move (its conditions, limits and totals), and sets off stamps, code renewals, effects and emails. See Timed moves on the venue’s clock.
Effects
Section titled “Effects”An effect moves the row one of this row’s links points at, in the same write: a guest checked out turns the room to cleaning.
"effects": [ { "on": { "to": "in_house" }, "via": "room_id", "set": { "status": "occupied" } }, { "on": { "to": "departed" }, "via": "room_id", "set": { "status": "cleaning" } }, { "on": { "change": "room_id", "in": ["in_house"] }, "old": { "set": { "status": "cleaning" } }, "new": { "set": { "status": "occupied" } } }]There are two kinds, up to 8 effects a table:
- On a move (
on.to): when this row moves to the state, the rowviapoints at moves to the statesetnames. At most one such effect per state and link. - On a changed link (
on.change, a foreign key): when the link really changes while the row is in one ofon.in(before and after the write; absent: any state), the row it pointed at moves byoldand the row it now points at bynew; either may be left out. A link first set or emptied moves only the side there is. One such effect per link. Withoutin, a booked guest’s room assignment would flip rooms too; a hotel saysin: ["in_house"].
set names one column, the linked table’s state column, and a state one of its listed moves goes
to. The linked row is moved by that declared move and judged as it: its conditions, the limits and
totals it moves, its own states. A row already in the state an effect’s old side names is left as
it is; a new row already in the state new names refuses the whole write (409
STATE_MOVE_REFUSED with details.effect: "new": the new room is not ready). Any refusal refuses
the whole write.
The linked row is moved by its own table’s declared move, with that move’s conditions, its lock and its totals, and not by what the person who made the first move may do to it: whoever may make the move that sets an effect off thereby moves the linked row too, whatever their role writes of that table and whichever of its rows they reach. Give an effect only a link whose row belongs with the row that moves. A link that points at the moving row itself moves nothing.
An effect moves one row, one link away, never a chain. The linked table may not be one whose rows are lines of another table’s states, one that books people by the day, or the app’s outbox; the state it sets may not be one the linked table’s own effects start from (a table with effects may be moved to any other state, its own rows included: a quote that is sent withdraws the quote it replaces); and the move it makes may not wait for another row, be an undo, or be judged late by another row’s time. A table a project hook watches is refused at run time. History (an import, sample data) sets off no effect. A move that set off an effect offers no Undo.
A new child moves its parent
Section titled “A new child moves its parent”A child row may move the row it belongs to, in the same write: the crew’s sign-off, made on a
phone by somebody who reads no job, signs the job off, and the office’s counts and the rules that
run on a job signed off follow. Say it on the child, under the parent’s children, with
moveOnCreate (Adminium 0.3.30):
"states": { "column": "state", "initial": "open", "moves": { "open": ["installed"], "installed": [{ "to": "signed-off", "requires": { "children": { "sign_offs": 1 }, "where": [{ "column": "blocking_snags", "eq": 0 }] } }] }, "children": { "sign_offs": { "via": "job_id", "createIn": ["open", "installed", "signed-off"], "moveOnCreate": "signed-off" } }}When a sign_offs row is created, its job moves to signed-off by the job’s own listed move,
once the new row is in: that move’s conditions are judged on the job as held, with the new row
already counted (children: { "sign_offs": 1 } passes on the first sign-off), and its stamps,
totals and limits are written as for any move of the job. It is announced as a change of the job
by the same person: the job’s screens refresh, its audit names the sign-off’s maker, and a rule
that runs on the job’s move to signed-off runs.
- A job already
signed-offis left as it is, and the row is made: a correction, a new sign-off that names the one it corrects, moves nothing. - A job in a state with no listed move to
signed-off(stillopenabove), or whose move’s conditions do not hold (a snag still blocking), refuses the whole write:409STATE_MOVE_REFUSED, and no row is made. List"open": ["installed", "signed-off"]and a sign-off made on an open job signs it off too. - Whoever may create the child moves the parent. The move is the app’s own declared move, as an
effect’s is: the roles a move is kept for do not stop it, and the person needs no
right to read or change the parent. A refusal about the parent is told as that person reads the
parent’s table. Give
moveOnCreateonly to a child whose rows belong with the parent they move. - Only a create moves the parent. A change of the child, its move to another parent, or its delete moves nothing. Importing rows and loading sample data are history: they move nothing either. A save that moved its parent offers no Undo.
The move is made as an effect’s is, so the same tables are refused: the state must be one a listed
move goes to, from a state the child is added in (createIn or parentIn, when given); not only
by an undo, nor only by an add-on’s ledger (planned); not one that waits for another row or is
judged late by another row’s time; not one that sets off this table’s own effects (an
effect moves one row, never a chain). The parent may not be a line of another table’s states, book
people by the day, keep a limit of its own, be private, be the app’s outbox, or be
watched by a project hook (refused when the row is made). An add-on’s shape keeps its children’s
moveOnCreate as it wrote it.
Undo of a move
Section titled “Undo of a move”A move marked "undo": true takes back the listed move the other way: the kitchen marked an
order ready by mistake, and moves it back to preparing.
"moves": { "preparing": ["ready"], "ready": [{ "to": "preparing", "undo": true, "requires": { "time": { "before": { "column": "ready_at", "plus": { "minutes": 1 } } } } }]}An undo move is made only by a write that names the state it saw the row in (from on a staff
change): a stale screen’s tap never takes back another screen’s move, and is refused 409
STATE_MOVE_REFUSED naming both states. What it waits for is judged on the row as it stands. The
stamps written when the row entered the state it returns to keep what they had; the stamps marked
clearOnBack that watch the state it leaves are emptied. A state reached only by undo
moves is never written by a door that names no state it saw: a timed move, an effect, an email’s
onSent, or a value a guest may write. An outbox producer with holdSeconds waits long
enough for an undo to drop its message.
An undo may empty further columns that the move it takes back filled, with clears: a hand-over
taken back is unpaid again.
"moves": { "ready": [{ "to": "picked_up", "requires": { "where": [{ "column": "paid_method", "isNull": false }] } }], "picked_up": [{ "to": "ready", "roles": ["manager"], "undo": true, "clears": ["paid_method"] }]},"lock": { "when": ["picked_up"], "except": ["link_stopped"] }- The move empties each column it
clears, whether or not the writer sends it. A writer may leave it out or send it asnull; a value is refused409STATE_MOVE_REFUSED, withdetails.clearsnaming the column, and nothing is written. - What an undo empties, its
clearOnBackstamps and itsclears, is open to the table’slockfor that move only, and only to be emptied. The same column changed by any other write stays locked (409RECORD_LOCKED). The lock’s other columns and the rows ofchildrentied to it stay as they are. clearsnames 1–8 columns of the table, each once, that may be empty. It never names the state column, the key, or a column another rule writes (a stamp is emptied byclearOnBackinstead). It is refused on a move not markedundo:only a move marked undo empties columns as it goes. A states rule saved in Studio is held to the same checks.- A move is taken by its target: the first listed from a state to another is the one made. A
second move between the same two states is refused when either is marked
undoor namesclears:another move from "picked_up" to "ready" comes first, so this one is never made. - The dashboard’s Undo of the forward move makes this move back when the forward change filled
a column the undo
clearsfrom empty. When the change overwrote a value already there, the move back would lose it, so no Undo is offered. Nor is one offered to a person who holds none of the move back’sroles. - A take-back whose row was moved back and on again by someone else while it was made is refused
409WRITE_CONFLICTwithdetails.retry: true: make it again.
A code nothing renews is put back by an undo as it was. A code a change of hands renewed is never put back: undoing the hand-over makes a code neither holder had. See Undo a status move.
Buttons on a record
Section titled “Buttons on a record”states.actions lists the buttons a generated record page offers on a row, in the app’s own
words: up to 12, each with a kebab-case id and a label (one text, or one per language). A
manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.
"states": { "column": "status", "initial": "draft", "moves": { "draft": ["sent"], "sent": ["received"] }, "actions": [ { "id": "send", "label": "Send", "move": { "to": "sent" }, "tone": "primary", "confirm": "Send this order to the supplier?", "set": { "sent_at": { "now": true } } }, { "id": "send-again", "label": "Send again", "set": { "resent_at": { "now": true } }, "in": ["sent"] }, { "id": "receive", "label": "Receive", "link": { "page": "shop-receipts", "param": "order" }, "in": ["sent"] }, { "id": "note", "label": "Add a note", "in": ["sent", "received"], "child": { "table": "order_notes", "via": "order_id", "form": ["text"] }, "set": { "kind": "desk" } } ]}An action is one of four forms:
| Form | Keys | What the button does |
|---|---|---|
| A move | move: { "to" }, set?, ask?, confirm? |
Moves the row to the state. It is offered in every state that lists a move there. |
| A write | set, in, ask?, confirm? |
Writes columns and moves nothing. |
| A link | link: { "page" | "addOnPage", "param" }, in |
Opens another page with the row’s key as param. page is a page of the manifest; addOnPage is <ref> (a page of the same add-on) or <add-on key>:<ref>. |
| A child row | child: { "table", "via", "form" }, set?, in, confirm? |
Opens a small form (up to 6 columns) and adds a row of the child table, linked through via. |
in lists the states the button is shown in. tone is primary, neutral (the default) or
danger. ask names up to 4 columns typed in the confirm before the action is made. set is
always a key of the action itself: on a move or a write it names columns of the row, on a child
row it names columns of the new row. A value is a text, a number or a yes/no the column takes, or
{ "now": true } on a timestamptz column of the row: the moment the action is made.
Checked against the manifest:
- A move goes to a state some listed move reaches by hand. A state only a
plannedmove reaches has no button. - A column the action sets or asks for is the row’s to write: not the state column, not the key, not a column Adminium fills. A column kept from staff is never asked for.
- Where the table is locked in a state the button is shown in, or moves to, each column
it writes is listed in
lock.except. - A child row’s table has a foreign key
viato this table.viais neither typed nor set. - A column is set or typed, not both.
Postings
Section titled “Postings”A table’s postings hand its rows to a ledger an add-on keeps, in the same save. How
they behave, with every way of writing a row, is in
Rows that post into an add-on’s ledger.
"postings": [ { "id": "stock", "into": { "addOn": "inventory", "ledger": "stock", "action": "use" }, "via": "order_id", "reserve": { "on": { "create": true } }, "post": { "on": { "to": ["picked_up"] } }, "reverse": { "on": { "to": ["cancelled"], "from": ["placed", "ready"] } }, "map": { "what": "item_id", "quantity": "quantity" }, "heldUntil": { "parent": "hold_until" }, "unlessSet": "voided_at" }]| Field | Required | Rule |
|---|---|---|
id |
yes | A lowercase name, unique on the table. Up to six postings a table. Two tables of lines under one parent may not share an id. |
into |
yes | { "addOn", "ledger", "action" }. The add-on is one the manifest names under addOns.requires or addOns.suggests (or the add-on itself, in its own manifest). |
needs |
no | An app’s addOns.features id: the posting runs only while that feature is on. |
via |
no | A foreign key of this table: its rows are lines of the row the key names, and every point but a create is judged on that parent row. |
reserve, post, reverse |
one of the first two | { "on": <point> }. reverse is required for an action that holds, and for an action that decides an amount when the row is created. |
map |
yes | An input of the action → where it is read: a column, { "row": true } (the row itself), { "parent": "<column>" } (under via), { "setting": "<column>" } (the add-on’s settings row), { "value": … }. Every input the action needs is mapped, and nothing else. |
multipliers |
no | The same, for night, guest and guest_night (which is night × guest when not mapped). |
heldUntil |
with a reserve of an action that holds |
A date-and-time column of the row, or { "parent": "<column>" }. Nothing else: a hold ends at a moment a row keeps. |
unlessSet |
no | A column of the line: while it is filled, the line is left out of every call. |
only |
no | { "column", "eq" }, { "column", "in": [...] } or { "column", "set": true }: only matching lines are handed over. |
refuses |
no | Up to four { "column", "set": true } (a line of the same table) or { "table", "via", "column", "set": true } (a line of another table under the same parent): the save is refused while such a line has the column filled. Under via only. |
A point is one of:
| Point | Fires |
|---|---|
{ "create": true } |
when the row is created (under via: when the line is). |
{ "to": [...], "from"?: [...] } |
when the row’s state moves into one of to — from one of from, when given — or it is created in one. Needs states on the judged table. |
{ "column", "in": [...], "from"?: [...], "own"?: true } |
when a column takes one of the values. |
{ "column", "set": true, "own"?: true } |
when a nullable column is filled. |
own is said under via: the point is the line’s own column, not the parent’s.
A mapped column is the row’s own to say. It may be a default, a formula, a stamp or a copy made once; it may not be a total, a balance or a copy that follows another row, since Adminium settles those in the same save. A column mapped to an input the action decides is written by Adminium and by nobody else: it is not the key, the state column, a formula, a running number or a total.
A price an add-on lowers
Section titled “A price an add-on lowers”A table’s adjust has an add-on that keeps offers lower the price of its rows, inside the save. How
it behaves is in Discounts, codes and refunds. From Adminium
0.3.19.
| Field | Rule |
|---|---|
by |
{ "addOn" }: the add-on that answers. It must declare addOn.adjuster. |
needs |
Optional. A feature of the app (addOns.features[].id): the rule runs only while it is on. |
lines |
1–3 parts. A child table: table, via (its link to this table), price, quantity? (absent: one), discount (written by Adminium), what (1–4 of { "column", "as": "item" | "category" | "type" | "tag" }: what the line sells), excludes? ({ "column", "set": true }: nothing reduces a row whose column is filled), paidBy?, only? ({ "column", "eq" } or { "column", "in" }), unlessSet? (a row whose column is filled is no line). Or the row itself: { "self": true, "price", "quantity?", "discount", … }, once at most, with nights? for a price by the night. A child table’s part must add up into this table through the same via. |
order.discount |
The order’s reduction: a decimal column, written by Adminium. |
order.customer |
link (the foreign key to the customer), address (that table’s address column), proved (a yes/no Adminium writes: whether the customer’s identity was proved), counts? ({ "column", "in" }: which of the customer’s other rows count as earlier orders). |
order.staff |
A reduction by hand: kind (an enum with percent and amount, and comp where offered), value, reason, by (written by Adminium: who gave it). |
order.currency |
A column, { "value" } or { "setting" }. |
codes |
Where codes are typed: table, via, typed (text, up to 64), code and voucher (columns carrying addOnLink into the add-on’s codes and vouchers tables, filled by Adminium), removed? (a row with it set is no code any more). |
uses |
The id of a posting on this table into the same add-on: where what the order used is recorded, once. |
frozen |
From when the price stands: { "to": [states] }, { "column", "in" } or { "column", "set": true }. |
expect |
The money column a price check compares. |
refunds |
Money given back: table, via, amount and tax? (decided by Adminium), of and taxOf? (this table’s columns the order’s cost is read from), against? (the refund’s link to the payment it gives back), lines? ({ "table", "via", "line", "quantity" }: the lines a refund returns). |
Every column Adminium writes here — a reduction, proved, staff.by, a code’s links, a refund’s
amount and tax — is read-only to every writer and in no public entry’s writable.
Removed rows
Section titled “Removed rows”removal, on a table, says what a removal of one of its rows takes and keeps:
{ "ref": "deal_members", "columns": [ … ], "removal": { "reason": "required", "history": true } }reason: every door a person removes a row through takes a reason: the delete route (DELETE /api/v1/data/{connection}/{table}/{record}with the body{ "reason": "…" }), a bulk delete (POST …/{table}/bulkwith"reason", one for the batch) and the add-on kit’suseWrite(table).remove(key, { reason }). It is trimmed, at most 500 characters, and written into the removal’s audit entry (changes.reason).trueasks for one;"required"refuses a removal without one (422,fields.reason.coderequired). Every other removal (an undo, a row taken off under its parent’s save, a rule’s or a move’s delete, a public entry’s) is written with"reason": null, no reason. A table withoutreasonrefuses one (not-allowed): nothing would keep it. The reason travels in the body, never in the address, which logs keep. The console’s delete dialogs, for one row and for the rows ticked in a list, ask “Why is it removed?” on such a table (marked optional where it istrue) and hold the Delete button until something is written where it is"required". The page assistant is told which tables ask: a delete it proposes carries the person’s reason, shown on the card before it is confirmed, and one without a reason on a"required"table is refused at the check.history: true: a removed row stays readable, through a row it linked to that the reader reads now (a deal’s removed member, a job’s cancelled visit), until an undo brings it back. Every removal keeps it, by whichever door: who removed it, when, why, and the row as it was, masked as the audit log keeps it (no personal data, no code). Never on a private table, and only on a table with a link (fk) to the row it is read through.
The removed rows are read by two routes:
GET /api/v1/data/{connection}/{table}/{record}/removed/{childTable}GET /api/v1/data/{connection}/{table}/{record}/removed/{childTable}/{childRecord}/historyThe first lists the rows of childTable that linked to the record and were removed, newest first,
paged by cursor like a row’s history: each with its key, removedAt, who removed it
(actorKind, actorLabel), its reason (null for none), the link column that named the record
(via) and the row as it was. The second is one of those rows’ own history, as a live row’s
history route answers it. Both answer 404 for a record the reader does not read now (as for a
record that is not there), and for a child table that keeps no removed rows (This table keeps no removed rows.) or does not link to the record; 403 where the reader’s role may not read the
child table. A removed row is shown only to a reader whose read of the child table would reach it:
their row limit there is judged on the row as it was, and the columns their role does not read
are left out, as a live read leaves them out. A table that never says history keeps nothing: a
rep cannot read a contact the admin removed, through anything.
Shared tables
Section titled “Shared tables”Two apps may use one table between them: a restaurant’s point of sale and its online ordering
read and write the same menu. Each app declares the table with the same shape, a name and a
version ("shape": "menu@1"); the second app’s install plan finds the first app’s table and
offers to share it (the Shared case in What the plan does with each
table), with the same safe changes it would make to a table
it reuses.
{ "ref": "menu_items", "shape": "menu@1", "columns": [ … ] }While a table is shared:
- Rules both apps keep on it (labels, choices, column rules) are kept once: the install skips a rule the other app already keeps there, naming it.
- Uninstalling either app names the other on the uninstall preview, keeps the table, and hands the rules it kept there to the other app. Those rules survive the other app’s updates, unless a version of it declares its own value for the same column.
- An app reinstalled beside a menu it used to share is offered it again. A table another app still uses is never offered for renaming out of the way.
- An app update that stops declaring a table’s
shapestops sharing it, but only while no other app shares the table; while one does, the update is refused409SHAPE_IN_USE, naming it.
A shared table’s real rows are the venue’s: the second app’s sample data can leave
its demo rows for it out (skipWhenShared). A table has shape or builtOn,
never both. See A menu two apps share.
Tables built on an add-on’s shape
Section titled “Tables built on an add-on’s shape”An add-on can define a shape: a set of named parts, each with its columns, the rules Adminium
keeps on them and its states. The Invoices & Receipts add-on defines invoice@1, whose parts are a
document, its lines and its payments. An app builds its own tables on it:
{ "ref": "invoices", "builtOn": "invoices/invoice@1", "part": "document", "columns": [ … every column of the part, then the app's own … ], "states": { … the part's states … } }builtOn is <add-on key>/<shape name>@<version>. The app must require that add-on in
addOns.requires, or put the table in a part whose feature needs the add-on:
the part is then left out of an install without it. builtOn is not the table’s shape field: that lets two
apps share one table, while two apps built on one add-on’s shape each get tables of their own.
The table spells out every column of its part, with the part’s type, nullability, enum values,
maxLength, scale, unique, default and rules, and the part’s states. So every check of the
app reads its own manifest alone. Where a part’s column refers to another part ("references": "document"), the app’s column refers to its own table built on that part ("references": "invoices"), and a rule that names a part (a rollup’s from: "lines") names the app’s table
instead.
What the app may add to a part, and nothing else:
- columns of its own, with any rules;
- a label, and the rules that label or narrow a part’s column:
enumLabels,personal,validation,required,requiredWhen,options,notAfterandnotBefore; - a
copyin front of a column the part fills with adefault(a client’s own tax rate before the add-on’s default rate): the part’s default still answers when the copy comes back empty; - in the states: more
lock.exceptcolumns (its own columns that stay writable),roleson a move, a condition added to a move the part has (requires.whereon a column the app itself added: a quote is sent only while its ownmay_sendis 1; the part’s own conditions stay as written, a condition of the app’s never reads a column of the part’s, and the app adds no move), a condition on a new row (create.requires.where, on a column the app itself added: no new quote on a closed deal, read from the deal’s state the app keeps on the quote; acreatethe part has stays as written, a condition of the app’s never reads a column of the part’s, and alinked,settingortimeis the part’s alone; history is not held to it; needs Adminium 0.3.35), a last day on a move the part gives none (requires.notPast, read from anydatecolumn of the table, the part’s included: a last day only ever refuses), an effect that runs through a link the app itself added (effectswithvia, oron.change, naming the app’s own link: sending a quote withdraws the one itsreplaces_idnames, in the same write; the part’s own effects stay as written and in their order), more tables inchildren, more columns in a child’sclearOnCreate, and on a child the part ties to the state areleaseandlockLinkedentries naming only columns the app added to that child (a line’s owntime_entry_id), never the part’s. Areleasethe part has is kept as it is, and alockLinkedentry it has keeps at least its columns.
Everything else, from a column’s type to a rule that decides a value or a move, is the part’s own.
The install checks each table against the shape of the add-on it will really run on: the installed
add-on, or the version the install brings with the app. A column dropped or retyped, or a rule
changed, refuses the install or update 409 SHAPE_MISMATCH, naming the column, before anything
is written. The app pins the shape’s version: invoice@1 is checked against the add-on’s
invoice@1, whatever else a newer add-on ships beside it. An add-on update that no longer has a
shape version an installed app is built on is refused 409 ADD_ON_SHAPE_IN_USE; update the app
first. The tables themselves are the app’s: two apps built on one shape get two sets of tables.
When the shape sends email, the app sends it too: its outbox has a kind and a
producer for every kind the shape’s outbox sends. The shape’s document profiles (an invoice, a
receipt) are made for the app’s tables when it is installed; see Documents. For the
whole story, see Building on an add-on.
An app can be cut into parts its owner switches on and off: a business that sends no crews out
switches “Jobs on site” off and never sees its pages, its roles or its rules. The screens say
“parts”; the manifest word is modules. A manifest that uses it sets
compatibility.minAdminiumVersion to 0.3.28 or later. An add-on declares none.
"modules": [ { "id": "selling", "label": { "en-US": "Selling" }, "default": "always" }, { "id": "jobs", "label": { "en-US": "Jobs on site" }, "default": "off" }, { "id": "quotes", "label": { "en-US": "Quotes" }, "default": "on", "feature": "quotes" }]| Field | Required | Rule |
|---|---|---|
id |
yes | kebab-case, up to 40 characters, unique in the manifest. |
label |
yes | A keyed label, en-US among them. |
default |
yes | on or off: how the part starts; the install check offers it as a tick, and the owner switches it afterwards. always: the part has no switch. |
feature |
no | One of the app’s addOns.features: the part is also off while that feature’s add-ons are not all installed and connected to the app, whatever its switch says. |
1–16 parts. What belongs to a part says so with "module": "<id>": a table, a
page, a dashboard card (an entry of a page’s layout.items), a rule in
automations, an entry in documents, an outbox
producer and a publicAccess entry each name one part. A
role names one or several with "modules": [...]. Anything that names no part is always
there.
What “off” means. While a part is off:
- its pages are out of the sidebar, and their addresses answer
409PART_OFF; - its cards are not drawn on a dashboard of another part: the page is served without them, in its shared layout and in each person’s own. A layout saved meanwhile keeps them where they were, so they are back in their place when the part is on;
- its rules start no run. A run that was already waiting is passed over, with a line in its log.
A scheduled rule that visits each row
oncedoes not use a row up while the part is off; - its documents are not drawn, its outbox producers queue nothing, its public entries answer as entries that are not there, and a scheduled report over one of its pages waits;
- a role whose parts are all off gives nothing and is offered to nobody new. Somebody whose every role is like that is told “nothing is switched on for you”;
- a role that belongs to the part holds nothing on the part’s tables while it is off.
What it does not mean. The tables and their rows stay. A role of another part that is granted one of those tables goes on reading it: an account manager still sees a won deal’s job while “Jobs on site” has no page. Nobody writes it: a part that is off is stopped, so a grant to create, change, delete or import its rows holds for no role until it is on again. So a table’s read grant lapses with the table’s part only for a role that is itself of that part.
Switching a part on needs no update and no restart. Nothing that happened while it was off is
caught up: its rules did not run for those changes and do not run for them later. The app’s own
screens are told which parts are on (modules in their config, session.modules from
connectStaff), and hide a screen of a part that is off; what such a screen may read is still
decided by the roles.
A part built on an add-on the app only suggests. A table
built on an add-on’s shape cannot be made without the add-on.
Put it in a part whose feature requires that add-on and the app need not require the add-on at
all. Installed without it, the whole part is left out: its tables are not made, and what names
them elsewhere goes with them —
- the part’s pages, cards, rules, documents, producers and public entries;
- a dashboard card over one of its tables, whether or not it names the part, so the dashboard is built without it;
- a key of
publicKeysevery entry of which went with the part: a shared link’s key is made only with the part it opens, and it is made when the part is put in and the app’s public access allowed; - a link column into one of its tables (and an index or a
belongsway that reads the column); - a kept total over one of its tables: the column stays, empty, with no rule;
- a page bound to one of its tables, a role’s grants and limits on them, a starting row and a sample row for them;
- a role left with no part.
When the add-on is connected to the app later, the part is put in: the app is applied again from
its own package, the tables and the link columns are made, and nothing that was there is touched.
A part that is in stays in; switching its add-on off for the app only turns the part off. The
manifest must still be valid with the part left out, and the manifest check tries it: a formula of
another table that reads the link column, for instance, is refused with “installed without
<add-on>”.
"maps": true at the top of an app’s manifest says the app shows maps. A map’s pictures are
fetched by each person’s browser from a map server, so whether maps are shown is one switch for
the whole install. The install check of the first app that says this asks, with the tick on;
once somebody has answered, no later install asks again. Needs Adminium 0.3.28.
Until somebody answers, maps are shown: the core’s own map widget works on a fresh install. The
answer is changed in Settings → Maps, and the operator can switch maps off for the install
whatever it says (ADMINIUM_MAP_SERVER=off).
Maps, places and routes says what a map asks of which server.
A place and an area
Section titled “A place and an area”Two column rules, each on a json column. A column takes one of them, not
both. They need Adminium 0.3.28.
{ "ref": "spot", "type": "json", "nullable": true, "rules": { "place": true } },{ "ref": "zone", "type": "json", "nullable": true, "rules": { "area": true } }A place is kept as one object, or null:
| Field | |
|---|---|
lat, lng |
The position, in degrees. |
by |
"town" or "exact": how the position is known. |
placeId |
The town’s id in Adminium’s list of places. Always there for by: "town"; on an exact point it names the town the point is in. |
name |
The town’s name as the list has it; or, on an exact point with no placeId, a label the writer gave (up to 200 characters). |
country |
Two letters, upper case. |
What a pin by town means. by: "town" is the middle of a town, not an address: “somewhere
in Cairo”. It is what an app has when all it was given is a town’s name. Adminium fills the
position in itself from its list (placeId), whatever position the writer sent beside it, so a
point nobody measured can never be stored as a town’s or the other way round. A map draws such
a pin differently from an exact one, a distance from it is an estimate, and the way between two
stops known only as the same town is answered as unknown, never as zero, with why
(why: "same-town": both stops are pinned to the town; place them to measure). If your app needs the
door, ask for an exact point.
Every way of writing the row takes the same things for a place (a staff save, an import, a rule’s step, a public entry that may write the column), and stores them alike:
| Given | Kept as |
|---|---|
{ "placeId": 360630 } |
{ "lat": 30.06263, "lng": 31.24967, "by": "town", "placeId": 360630, "name": "Cairo", "country": "EG" } |
{ "lat": 30.0444, "lng": 31.2357 } |
{ "lat": 30.0444, "lng": 31.2357, "by": "exact" } |
"30.0444,31.2357" or "geo:30.0444,31.2357" |
The same exact point. |
| A map link with the position written in it | The same exact point. Read from the link’s text: Google Maps (@lat,lng, ?q=, ll=, a pin’s !3d…!4d…), OpenStreetMap (#map=zoom/lat/lng, mlat/mlon), Apple Maps (ll=). |
Adminium never opens a pasted link. A short link (maps.app.goo.gl/…), which says nothing
until it is followed, is refused.
A value that is not one of these is refused with 422, the column named in details.fields
with one of:
| Code | |
|---|---|
place-unreadable |
Not a position: free text, a link with no position in it, by: "town" without a placeId. |
place-short-link |
A link that would have to be followed to say where it points. |
place-out-of-range |
A latitude beyond ±90 or a longitude beyond ±180. |
place-unknown |
A placeId the list of places does not have. |
An area is kept as a GeoJSON geometry, or null: a Polygon or a MultiPolygon, with
positions east first ([lng, lat]). A Feature wrapping one is taken and kept as the bare
geometry; a height after a position is dropped. Every ring is closed (its last position is its
first) and has at least four positions; the whole shape has at most 2,000 positions and 64 KB.
Refused with area-unreadable (not such a shape, an open ring, a position off the map) or
area-too-large.
Both are read back as objects on every database, in a list, on a record and through the
dashboard’s own routes. An export to CSV writes a place as lat,lng, which an import reads
back as an exact point (JSON Lines keeps the whole object), and an area as its JSON.
A map widget takes its points from a place column: a geo-points query that selects a name,
then the place column, then any numbers to show ("select": ["name", "spot", "visits"]). Each
point says how it is known (by), and a row with no place is left off the map.
Option lists
Section titled “Option lists”optionLists ships lists of answers a column can name with options: { "list": "<name>" }. It is
an object keyed by kebab-case list name.
"optionLists": { "zones": { "label": { "en-US": "Zones", "de-DE": "Bereiche" }, "values": [ { "value": "Window", "label": { "en-US": "Window", "de-DE": "Fenster" } }, { "value": "Patio", "tone": "info" } ] }}Each list has a label and 1–500 values, each with a value (1–256 characters) and an optional
label and tone. A list is installed once as <key>-<name> (pos-zones). The operator can edit
it like any other list, so a later version never overwrites it, and a list of that key someone
already made is left alone.
pages declares the dashboard pages an app adds. An app needs at least one. Each page is built at
install over the app’s real table, with the same generator the dashboard’s own create screen uses,
and appears in the app’s own sidebar section.
{ "ref": "pos-menu", "template": "page-crud", "title": { "key": "mft.pos.page.menu", "fallback": "Menu" }, "titles": { "de-DE": "Speisekarte", "fr-FR": "Carte" }, "nav": { "group": "manage", "icon": "utensils", "order": 1 }, "bindings": { "rows": "menu_items" }}| Field | Required | Rule |
|---|---|---|
ref |
yes | kebab-case. It becomes the page’s address, /p/<ref>. Every app installed on the same database shares these addresses, so start it with the app key (pos-menu). The plan refuses an install whose page ref another app’s page already uses there. |
template |
yes | The page template; see below. |
title |
yes | An i18n message. fallback is the English title. |
titles |
no | The title in other languages, keyed by BCP 47 tag, each 1–120 characters. The sidebar shows the operator’s language until the operator renames the page. |
nav |
yes | { "group", "icon", "order", "offRail"? }. group is 1–80 characters, icon a Lucide icon name (1–60 characters), order an integer. "offRail": true keeps the page off the sidebar; see A page kept off the sidebar. |
bindings |
no | Which of the app’s tables the page reads: an object from a page-local name to a requiredSchema table ref. |
config |
no | Page configuration: a form for a record page, a layout for page-dashboard, a calendar for page-calendar, a board for page-board. |
feature |
no | The id of one of the app’s addOns.features. Until every add-on the feature requires is installed, attached to the app and switched on there, the page leaves the sidebar and is listed apart, with the add-ons it needs. |
module |
no | The part of the app the page belongs to. While that part is off the page leaves the sidebar and its address answers 409 PART_OFF. Needs Adminium 0.3.28. |
Templates. A table-bound template reads one table: page-crud, page-board,
page-calendar, page-scheduler, page-directory, page-master-detail, page-queue-inbox,
page-log-viewer, page-files and page-chat. Give it bindings. A single entry names its table
whatever its key ({ "items": "menu_items" }); with several entries, the one keyed rows is the
page’s own table. page-dashboard reads from several tables and takes its widgets from
config.layout.
Config. config.form and config.layout name the app’s tables and columns by their short
refs; Adminium binds them to the real tables at install. The plan refuses a form or layout that is
not well formed or that names a column or table the manifest does not declare. Other problems do
not block the install: a page with no bindings, an unknown template, or a table that cannot back
its template gives a page that is created empty and says so, and the install report lists it.
A form’s chips field ("control": "reference-chips") may name the table it picks from or the
link table between the two ("relation": "clinician_visit_types"). A link table is one that
holds the two foreign keys and nothing else to fill, with or without its own id. A field the
install cannot bind is listed in the install report, and the page gets the form Adminium makes. A
designed field the form cannot show says so in its place.
config.calendar names the columns a page-calendar plots by:
"config": { "calendar": { "start": "starts_at", "title": "patient_id.name", "category": "visit_type_id" } }| Field | Required | Rule |
|---|---|---|
start |
yes | A date or timestamptz column of the page’s table: where each row is plotted. |
end |
no | A date or timestamptz column: where a row that spans time ends. |
title |
no | A column of the page’s table, or <fk column>.<column>: a column of the table that foreign key points at (the patient’s name). |
category |
no | A column of the page’s table: what the rows are coloured and filtered by. |
The manifest is refused when a name is not a column of the right table and type. Without
calendar, a table with a booking rule is plotted by the booking’s start; any other table by
the first date Adminium finds. On a page with a form, Add event and a click on an empty day
open that form, with the day filled in.
config.board says what the columns of a page-board are. It needs
"minAdminiumVersion": "0.3.28".
"config": { "board": { "column": "stage_id", "columnsFrom": { "table": "stages", "label": "name", "order": "position", "badge": { "column": "odds", "as": "percent" } }, "title": "name", "totals": [{ "column": "amount", "fn": "sum" }, { "column": "weighted_value", "fn": "sum" }], "only": { "state": "open" }, "card": { "link": "company_id.name", "person": "owner_id", "amount": "amount", "secondAmount": { "column": "weighted_value", "label": "w", "labels": { "de-DE": "gew." } }, "daysSince": { "column": "entered_stage_at", "warnFrom": 14, "alertFrom": 30, "label": "in stage", "labels": { "de-DE": "in der Phase" } }, "date": "expected_close" }, "toolbar": { "total": true, "person": "owner_id" }, "note": { "text": "Columns come from Stages", "texts": { "de-DE": "Die Spalten kommen aus den Phasen" }, "href": "/p/crm-stages" }} }| Field | Required | Rule |
|---|---|---|
column |
yes | The column of the page’s table a card is placed by. With columnsFrom it is a link ("type": "fk") to that table. Without columnsFrom it is an enum column, and the board’s columns are its values. |
columnsFrom |
no | { "table", "label", "order", "badge" }: the table of the app whose rows are the board’s columns, the column of it a board column is headed with, and the column the board’s columns are ordered by. order may be left out; the columns are then in the order of their key. badge is { "column", "as" }: one more column of that table, shown small beside each heading. as is "percent" (a share kept as a fraction: 0.1 reads “10%”), "number", or "text" (the default: as kept, up to 24 characters). |
title |
no | The column of the page’s table a card is titled with. Left out: the table’s own title column. |
totals |
no | One or two { "column", "fn" }: a number or money column of the page’s table, and sum or avg. Each column’s header shows the figure beside its count. |
only |
no | Which rows of the page’s table are cards on the board: one to three "column": value or "column": [values] (up to 20 values each). A row is a card when every column named holds its value, or one of its values. Left out: every row. |
card |
no | What a card shows under its title, each from a column of the page’s table; at least one of: link ("<link column>.<column>": a column of the row the link points at, such as a company’s name), person (a staff column, rules.staff, shown as the person’s name), amount (a number or money column), secondAmount ({ "column", "label", "labels" }: a second number or money column, drawn at the end of the amount’s line after its short label, such as a deal’s weighted value “w €1,240”; labels gives the label in other languages by their tag; needs minAdminiumVersion 0.3.35), daysSince ({ "column", "warnFrom", "alertFrom", "label", "labels" }: whole days from a date or time column to today; from warnFrom days the figure is amber and from alertFrom red, both optional, 1 to 3650, warnFrom the smaller; label says what the days are, after the figure: “12 days in stage”, with labels by language; the label needs minAdminiumVersion 0.3.35) and date (a date or time column, shown as a day). A label is 1 to 40 characters, and labels needs label. |
toolbar |
no | { "total", "person" }, at least one (0.3.35): a line drawn above the board. "total": true shows how many cards the board holds and each of its totals over all of them. person (a staff column, rules.staff) is a filter: “Everyone” and each person who holds a card on the board. The board’s note is drawn at the line’s end. |
note |
no | { "text", "texts", "href" }: one sentence of up to 200 characters drawn above the board; texts gives it in other languages by their tag, as a page’s titles does; with href, a path of the workspace starting with one / (a page is /p/<its ref>), the sentence is a link. |
With columnsFrom the board draws one column for each row of that table, a row with no card
too, and reads them when the page opens. A row added, renamed, moved or removed there changes the
board for everybody looking at it who may read that table, about a second later, with no update of
the app; so does a card a colleague saves. A card whose link is empty is on no column. Dropping a card on a column saves that row’s key in the card’s link, as any save of the
record would: where a role’s limit or a state rule refuses the save, the card goes back and the
board shows the reason.
The count and the totals in a column’s header are worked out on the server, over the rows the
person may read: a rep held to their own deals sees the count and the worth of their own. Money
is shown in the column’s own currency when it has one, else in the install’s. A board draws the
first 200 cards of its table; the count is of all of them. It draws the first 100 rows of
columnsFrom.table as columns, and says so when that table has more.
Everything card draws is read by the server as the person looking, like the headers. The linked
row’s column is empty when their roles do not read that table, or do not reach that row. A person
is shown by name and never by id, on the rule of the people list: a signed-in reader is told the
name of somebody who is here or is a sample person; anybody else (suspended, only invited,
removed) is drawn as nobody. The days are counted on the server’s clock, on the calendar of where
the business is, so a browser with the wrong date draws the same card; a row with no date has no
figure, and a date still to come counts 0. With daysSince the cards are listed longest-waiting
first. The two totals of a header are each named by their column’s label (“Amount €12,400 ·
Weighted value €1,240”); a total takes a line of its own where the heading has no room. A role
that may not read the badge column sees the board without the badge.
The toolbar’s total is the server’s, worked out over every card the person may read (their row
limits, and only), never a sum of the cards drawn: a board of 340 deals draws 200 cards and its
toolbar counts 340. The person filter offers the people who hold a card the person reads, named as
the people list names them, the most cards first (30 at most); a card with nobody in the column is
among “Everyone” only. Choosing a person narrows the cards, each column’s count and totals, and
the toolbar’s total to that person’s cards, still within what the person looking may read: a rep
held to their own deals who chooses a colleague sees no card. The choice holds while the page is
open.
only makes the board a board of some of its table’s rows: "only": { "state": "open" } is the
open deals, "only": { "kind": ["new", "renewal"], "hot": true } the hot deals of two kinds. It
holds for the cards and for the headers alike, so a column’s count and totals are of the cards
only keeps. A column of only is an enum, a bool, a link (fk, by the key it holds),
text or a whole number (int, bigint) of the page’s table, and not the board’s own column;
a value of an enum is one of its values, and a value of a link is of the kind the table it links
to is keyed by: a whole number for an int or bigint key, text for any other. A card added on
the board is given each value only names alone ("state": "open", not a list), so it is on the
board; where the table’s own rules refuse that value for a new row, the add is refused and the
board says why. A role that may not read a column only names (it is
not among the role’s readable columns, or it is personal data the role may not read) gets
neither the cards nor the figures: the headers show no count and no total, never those of every
row. So name a column every role that opens the board may read.
The manifest is refused when column is not a link to columnsFrom.table (or, with no
columnsFrom, not an enum), when a name is not a column of the right table, when a total is not
a number or money column, when only names the board’s own column, a column of another type
or a value its column cannot hold, when a card names a link that is no link, a person that is no
staff column, an amount that is no number or a day that is no date, when a note opens anything
but a path of the workspace, and on a page that is not a page-board. The install checks the
same against the tables as they really are: a board that cannot be built as named is not built as
another one. Its page is created empty and the install reports why (PAGE_BOARD_INVALID).
Without board, a board’s columns are the values of the workflow column Adminium finds, as
described under
A board with its own columns.
Links that open a list filtered. A link in a layout (a metric card’s href, the toolbar’s
link) may open a records page on some of its rows: /p/<page ref>?f.<column>=<op>:<value>, with
one f. piece per column, up to 8. “Overdue” leads to
/p/studio-invoices?f.status=eq:sent&f.due_on=before:today, and the page shows a chip for each
piece.
| Piece | Rows it keeps |
|---|---|
eq:<v>, neq:<v>, in:<a,b> |
The value, not the value, or one of up to 50 values. |
gt:, gte:, lt:, lte: |
A number, or a day. |
before:<day>, after:<day> |
A date or time before or after that day. |
before:now, after:now |
A time before or after this moment. |
month:this, month:last |
A date or time in this or last month. |
set, unset |
Has a value, or has none. |
A day is YYYY-MM-DD, today, or a whole number of days from it: today-30, today+7 (at most
3660 either way). Days are the venue’s: on a time column a day is the whole day where the venue
is. now is for time columns only. The pieces are worked out on the server when the page opens,
so today is always the day it is read. A piece the column cannot take (an unknown column, a
personal column the reader may not see, gt on a yes/no, a value that is not a number) is left
out, and its chip says so; the page never fails.
Updates. A page nobody has edited is rebuilt when the app is updated. A page the operator edited is left as they left it. Uninstalling an app does not delete its pages.
Tab words, filters and bulk actions
Section titled “Tab words, filters and bulk actions”A page-crud page bound to a table may say three more things in its config. A manifest that
uses tabs or bulk sets compatibility.minAdminiumVersion to 0.3.18 or later.
"config": { "tabs": { "receipts": { "empty": "Nothing received yet", "emptyBody": "A receipt shows here.", "noNew": true } }, "filters": [{ "column": "status", "control": "any-of" }, { "column": "ordered_on" }], "bulk": [{ "id": "reorder", "label": "Reorder", "child": { "table": "reorder_requests", "via": "point_id", "form": ["note"] }, "set": { "source": "list" }, "where": { "column": "state", "eq": "low" }, "confirm": { "title": "Reorder {count} items?", "body": "One request is made for each.", "columns": ["name", "on_hand"] }, "done": "{count} requested" }]}| Key | Rule |
|---|---|
tabs |
Keyed by a child table: one with a foreign key to the page’s table. empty and emptyBody are what the tab says while it holds no row; noNew: true takes its “New” away, for rows made elsewhere. It words a tab the page already has: it adds none and renames none. |
filters |
Up to 6 columns of the page’s table, each once. control is one-of or any-of for a choice, yes-no, record for a link, date-range or number-range; left out, the column’s type decides. A free text column and the key cannot be filtered. |
bulk |
Up to 2 actions on the rows ticked in the list. Each adds one row of child.table per ticked row, linked through via, with form (up to 4 columns) typed once for all of them and set written on each. where keeps only the ticked rows that hold a value. confirm.columns (up to 8) are shown for each row before anything is made. |
A bulk action’s confirm.title, confirm.body and done may name {count}, the number of rows,
and nothing else. Its where and confirm.columns never name a secret column or a code kept from
staff. A dashboard page’s toolbar takes layout.toolbar.links in place of link: one or two
links of the same shape, at most one of them with "tone": "primary".
A link to the app’s own staff side. A toolbar link, or a card’s href, may name "@staff":
the app’s staff screens, wherever the app placed them (inside the dashboard, or on their own
address in a new tab), as the sidebar’s “Open the staff screens” opens them. "@staff/<path>"
opens one screen of them: "@staff/approvals" is /a/<app key>/approvals inside the dashboard,
or approvals under the side’s own address. The path is a path under the side, as a
navLinks entry’s is: plain segments of letters, digits, ., _, ~ and -,
with no query, no # and no ..; the manifest is refused otherwise, and when the app has no
staff frontend. A link is drawn only when the side can be opened. @staff/<path> needs
Adminium 0.3.28 (an older release draws no such link).
"layout": { "version": 1, "toolbar": { "links": [ { "label": "Approvals", "labels": { "de-DE": "Freigaben" }, "href": "@staff/approvals", "icon": "clipboard-check", "tone": "primary" }] }, "items": [] }A dashboard card of a part, and its column headings. A card of an app’s page-dashboard
(an entry of layout.items) may say which part of the app it belongs to with
"module": "<id>": while that part is off the card is not drawn, for everybody, and the page
stays whole. A table card’s columns (config.columns, as a data-grid heads them and a
detail-key-value names its rows) may give their heading in other languages with labels, keyed by tag as a page’s titles
are: each reader sees the heading in their language, else label. Both need Adminium 0.3.28.
{ "i": "unclaimed", "widget": "kpi-stat-card", "x": 3, "y": 3, "w": 3, "h": 3, "module": "inbox", "config": { "title": "Unclaimed enquiries", "binding": { "kind": "table-query", "source": { "name": "enquiries", "type": "table" }, "shape": "metric+delta", "aggregations": [{ "fn": "count", "alias": "n" }] } } }A card’s figures (aggregations[].fn) are the query descriptor’s: count, count_distinct,
sum, avg, min, max, median and percentile. median (from Adminium 0.3.35) is the middle
value: the mean of the two middle numbers of an even count, the earlier of the two middle days of a
date column. min, max and median of a date or a time column answer the day itself (0.3.35),
in a single figure, a figure and its prior window, or beside a number in a ranking: see
a figure is a number, or a day.
"columns": [{ "name": "name", "label": "Rep", "labels": { "de-DE": "Verkäufer", "ar-EG": "المندوب" } }]navGroups
Section titled “navGroups”navGroups names the headings inside the app’s sidebar section. Up to 12.
"navGroups": [ { "key": "manage", "label": { "en-US": "Manage", "de-DE": "Verwalten" }, "order": 1 }, { "key": "records", "label": { "en-US": "Records", "de-DE": "Aufzeichnungen" }, "order": 2 }]Each group has a kebab-case key, a keyed label (must include en-US) and an
integer order. A page whose nav.group is one of these keys is listed under that heading, in
nav.order. A page whose group is not declared (an Overview, say) is listed first, with no
heading.
A page kept off the sidebar
Section titled “A page kept off the sidebar”An app with many tables has many pages, and its sidebar section need not list them all. A page
whose nav says "offRail": true is left out of the sidebar and is a page in every other way:
{ "ref": "crm-stages", "template": "page-crud", "title": { "key": "mft.crm.page.stages", "fallback": "Stages" }, "nav": { "group": "selling", "icon": "milestone", "order": 1, "offRail": true }, "bindings": { "rows": "stages" } }It opens at its address (/p/crm-stages), a record of its table opens on it, and search, the
command palette and the assistant’s “where is” find it, for whoever may view it
(page:@crm-stages:view). Somebody who may not view it finds it nowhere, as with any page: the
key hides a page from the menu and from nobody’s reach. A heading of the section whose every page
is kept off the sidebar is not drawn. The only value is true; leave the key out for a page on
the sidebar. An owner who edits the page keeps their edit and this key stays the app’s to say.
Needs Adminium 0.3.28.
navLinks
Section titled “navLinks”navLinks adds entries to the app’s sidebar section that are not pages. Up to 24. An entry does
not write an address: it names one of a closed list of places, and Adminium writes the address.
"navLinks": [ { "ref": "approvals", "label": { "en-US": "Approvals", "de-DE": "Freigaben" }, "nav": { "group": "selling", "icon": "badge-check", "order": 7 }, "to": { "side": "staff", "path": "/approvals" } }, { "ref": "rules", "label": { "en-US": "Rules" }, "nav": { "group": "setup", "icon": "workflow", "order": 1 }, "to": { "screen": "automations" } }]| Field | Required | Rule |
|---|---|---|
ref |
yes | kebab-case, up to 60 characters, used once. |
label |
yes | A keyed label (must include en-US). Shown in the reader’s language. |
nav |
yes | { "group", "icon", "order" }, as a page’s: the entry sits among the pages of its group, in order. |
to |
yes | Where it opens: { "screen": … } or { "side": "staff", "path"? }, below. |
module |
no | The part of the app the entry belongs to. It leaves the sidebar while the part is off. |
to |
Opens | Shown to |
|---|---|---|
{ "screen": "automations" } |
The dashboard’s Automations, showing only the rules this app brought (/automations?app=<key>). |
Whoever may manage automations. |
{ "screen": "email-templates" } |
The dashboard’s Emails, showing only the documents this app brought (/email-templates?app=<key>). |
Whoever may manage settings. |
{ "screen": "settings" } |
The app’s own page in the console: its parts, its sample data, its add-ons. | Whoever may manage apps. |
{ "side": "staff" } |
The app’s staff side: its first screen, or the screen at path. Inside the dashboard for a side shown there, else on its own address in a new tab. |
Whoever may open the staff side (app:@:staff), while the side is served. |
path is a path under the side: / then plain segments (letters, digits, ., _, ~, -),
with no query, no # and no ... An entry is listed only to somebody who can open what it opens;
nobody else is told it exists. The two narrowed screens show a line saying what is shown, with a
way back to everything, and they only narrow: nobody sees a rule or an email there that the whole
screen would not list for them. An entry to the staff side needs a staff frontend. Needs
Adminium 0.3.28.
Frontends
Section titled “Frontends”frontends declares the app’s own screens: a staff side, a customer side, a mobile
side (a phone app), or any of them together. At least one is required, and each side may appear
once. The Adminium dashboard itself is always
there and is not declared.
"frontends": [ { "side": "staff", "kind": "spa", "entry": "index.html", "routes": { "pos": "/" }, "placement": "external" }, { "side": "customer", "kind": "spa", "entry": "index.html", "routes": { "book": "/", "manage": "/manage" } }]| Field | Required | Rule |
|---|---|---|
side |
yes | staff, customer or mobile. |
kind |
yes | spa, electron or none; expo for the mobile side, and for it alone. |
entry |
no | The frontend’s entry file (index.html). |
env |
no | The environment variables the frontend reads, each { "required": boolean, "example"?: string }. |
routes |
no | The views this side owns, from a view name to a path. |
placement |
no | Staff side only: internal opens the screens inside the dashboard, external on their own address (a till). The operator can change it. Absent means internal. |
enabled |
no | Whether the side starts switched on. Absent means on. |
records |
no | Staff side only: where a record opens on this side, from a table ref to a path with one {id}; see Where a record opens. Needs Adminium 0.3.28. |
Where a record opens
Section titled “Where a record opens”Adminium gives people links to records: a notice in the bell, the button of the notice’s email, a search hit. Each opens the dashboard’s record page. An app whose staff side has a screen of its own for a record says where, per table:
{ "side": "staff", "kind": "spa", "entry": "index.html", "placement": "external", "records": { "deals": "/deals/{id}", "quotes": "/quotes/{id}", "companies": "/companies/{id}" } }Each key is one of the app’s table refs. Each value is a path under the side: /, then plain
segments (letters, digits, ., _, ~, -), and {id} as one whole segment, exactly once. It
has no query, no #, no .. and no host: the address can only be a screen of this side. {id}
is the record’s key as the dashboard’s record page takes it (a key of several columns is their
values as a JSON list), written as one segment whatever it holds.
Who gets the app’s address is decided for each person. Somebody whose roles open the staff side
(app:@:staff) gets it, while the app is on, the side is switched on and the table’s part is on.
Everybody else keeps the dashboard’s record page. Then:
- A notice (
GET /api/v1/me/notifications) carries the address inactionUrl(/apps/<key>/staff/deals/12;/a/<key>/deals/12for a side shown inside the dashboard) andopens: { "appKey", "path" }with the path under the side (/deals/12), which the app’s own screens route on. It is asked again each time the list is read, so a role given or taken later moves a notice already written. - The notice’s email carries the same address in its button: on the host mapped to the staff side when there is one, else under the server’s public address.
- A search hit (
GET /api/v1/search) carriesopens: { "appKey", "path", "url" }. A table with an address is searched for somebody who reads it and opens the staff side, even with no page of the dashboard to land on;pageSlugis then empty.
Somebody who is signed out and follows the address is sent to sign in and brought back to it. The
way back is followed only to a path of the same address, and on the dashboard’s own address only
into an app’s screens. Only the staff side takes records. A table left out with its part takes
its address with it.
A mobile side is { "side": "mobile", "kind": "expo" } and nothing more: it is a phone app in
apps/<key>/mobile/, which Adminium serves to nobody and no package carries, so it has no entry,
routes, placement or switch. What it has is a key of its own kind for the public API, made at
install beside the customer key: see A phone app for an app. A
manifest with a mobile side needs the Adminium that knows it or a newer one
(compatibility.minAdminiumVersion); an older one refuses the whole manifest.
Adminium serves each web side at /apps/<key>/<side>/. The staff side needs a signed-in user; the
customer side is public and calls the public API with the
app’s customer browser key, which the install creates. A staff side can be handed a second key
for a screen of its own; see publicKeys. Each side reads its surface-config.json at boot: the real table
names, the app’s settings and, for the staff side, the connection, the venue’s time zone and
currency, who is signed in, the language they chose in Adminium (locale, as a tag such as
ar-EG, with its dir), and access: which of read, create, update and delete they
hold on each of the app’s tables, and the app’s roles they hold. A staff screen uses it to leave
out a button whose write would be refused; the data API still checks every write. The staff
side’s config also carries kit, the address of Adminium’s own larger parts for a screen that
draws one itself (see Adminium’s own parts on an app’s screen), and
maps: whether maps are on, the map server’s address, and whether routes along roads are on.
Whose a row is
Section titled “Whose a row is”A role can be held to some rows of a table (see Whose a row is). Five words say how. They need Adminium 0.3.28.
A staff column holds a staff member’s id. It is a text column, and the only kind of column a person is read from:
{ "ref": "owner_id", "type": "text", "maxLength": 36, "nullable": true, "rules": { "staff": true } }belongs, on a table, lists the ways a person belongs to one of its rows. Any one is enough:
"belongs": [ { "column": "owner_id", "set": "team" }, { "through": { "table": "deal_members", "link": "deal_id", "user": "user_id" } }, { "through": { "table": "covers", "link": "owner_id", "on": "owner_id", "user": "cover_id", "from": "starts_on", "to": "ends_on" } }, { "via": "company_id" }]| Way | Fields |
|---|---|
| a column | column: a staff column of this table. set says whom a writer limited on the table may put there: self (the default: only themselves), team (somebody in a team they lead) or anyone. With person, column is instead a link to a row of the app, and person names the staff column on that row (a ticket’s assignee_id to an agent’s user_id). |
| a listing | through: table lists people against rows; link is the listing’s column that equals this row’s key, or this row’s on column when it is not the key; user is the listing’s staff column (or, with person, a link to a row that holds one). from and to are date columns of the listing: the listing counts from the first to the second, by the business’s day, and either may be empty. while makes a listing row count only while another column of it holds one of some values ({ "column": "kind", "in": ["helper"] }) or is empty, or not ({ "column": "ended_on", "isNull": true }, for a nullable column): a member row whose end is set lists nobody, from that moment, on every read. Not the user or the link column. while needs Adminium 0.3.28. |
| a parent | via: a link of this table. Whoever reaches the row it points at reaches this one. |
A row is reached through at most two links (via of a table that is itself reached via
another). The listing a way goes through cannot itself be reached through the table it lists
for. Each staff column a way names, and each link a via follows, should be first in one of the
table’s indexes: a limited list is as fast as an unlimited one with the index, and
several times slower without it.
private, on a table, with a belongs of plain staff columns only (no via, through or
person): its rows are read by their own people and by nothing else. No other table’s rows are
reached through a private table but by those same people (below), nothing is copied out of it,
no total of its rows is kept elsewhere, and it is not exported, imported, reported on a schedule,
drawn into a document, read by a rule or handed to an owner’s code. An update of the app is the
only thing that sets or removes the mark.
A row hung on a private row, reached by its own people. Another table may say its rows are
reached through a link to a private table, with person naming one of that private table’s own
belongs columns: exactly the people who already read the private row. A step of a coaching
area is the rep’s and the manager’s of that area:
{ "ref": "coaching_steps", "belongs": [{ "column": "area_id", "person": "rep_id" }, { "column": "area_id", "person": "manager_id" }], "columns": [ ... ] }Any other staff column of the private row as person is refused, as are a through listing or a
via through it. Such a way is never widened by a team: a team reach of the steps reaches,
through the private row, only the rows whose private row names the reader, never one that names
somebody they lead (another manager’s one-to-one with her rep stays out). A writer held to some
rows may set the link only to a private row that names them; with several such ways on one link,
any one that names them is enough. Needs Adminium 0.3.35.
rows, in a role’s limits for a table, says which rows its read, update and
delete reach, each on its own:
"limits": { "deals": { "rows": { "read": { "reach": "team", "unowned": true }, "update": { "reach": "own" } } } }reach is own, team or all; unowned: true adds the rows nobody belongs to; while
({ "column", "in": [...] }) holds the reach to rows whose column has one of those values now,
and all is written only with a while. An action left out reaches every row. The table must
declare belongs. A manifest is refused when a role holds a limit on a table of which another
table keeps a stored total (a rollup), unless that table is reached via the
very link the total adds up by.
by narrows a reach of own or team to the rows a staff column of the table names the person
in (with team, somebody they lead). It is for a table reached through a parent, where everybody
on the parent reads every row and each changes only their own:
"limits": { "notes": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "by": "author_id" } } } }A rep reads every note of the deals they reach, and changes the ones author_id names them in.
The column must be a staff column (rules.staff).
notBy, on an update or a delete reach, takes out the rows a staff column names the person in: of
the row itself, or <link>.<staff column> of the row one of its foreign keys names. A sales
manager changes her team’s targets and never her own, and any starter’s goal but her own:
"limits": { "targets": { "rows": { "read": { "reach": "team" }, "update": { "reach": "team", "notBy": "rep_id" } } }, "starter_goals": { "rows": { "update": { "reach": "all", "notBy": "starter_id.user_id" } } } }It judges the person alone, never the people they lead, and all takes it in place of a while.
It holds on every door the reach holds on (a change on a page, a bulk edit, a file brought in, a
record’s button): a row it leaves out is a row that is not there to them (404, and a file’s line
is not matched). It also judges the row as a write leaves it: a target made for herself, or a
colleague’s moved onto her, is refused (403 COLUMN_FORBIDDEN, reason not-by, naming the
column). A record page does not show her the buttons of a row whose own staff column names her,
where every reach of hers says so. Never on a read, a private table, or a grant on an add-on’s
table. Needs Adminium 0.3.35 (rows.notBy).
via beside by judges a row of a table that hangs off a parent (its belongs has
{ "via": "<link>" }) by a staff column of THAT parent, and by nothing else: by then names a
column of the row the link points at. A sales manager reaches the visits of the deals somebody
she leads owns, and not a visit on another team’s deal, even where her rep helps on that deal or
the visit itself names her rep:
"limits": { "deals": { "rows": { "read": { "reach": "team", "by": "owner_id" }, "update": { "reach": "team", "by": "owner_id" } } }, "site_visits": { "rows": { "read": { "reach": "team", "via": "deal_id", "by": "owner_id" }, "update": { "reach": "team", "via": "deal_id", "by": "owner_id" } } } }reach is own (the parent names the person) or team (the person, or somebody they lead);
unowned is not taken. via must be one of the table’s own belongs links and by a staff
column (rules.staff) of the table it points at. It holds on every door a reach holds on: the
list and the record (another team’s visit is a row that is not there, 404), a change, a new
row (hung only under a parent whose by names them or somebody they lead, else 403 reason
parent-not-theirs, whoever the new row names), a dashboard figure (worked out again as soon as
the parent changes hands), a file out, the live frames (a bare “this table changed”, never a
row) and the role editor’s count. It is opt-in: a reach without via reads the table’s every
way as before (a helper listed on the deal, the visit’s own rep). Needs Adminium 0.3.35
(rows.via).
notThrough, on an update or a delete reach, names a listing the reach does not count: a
through way whose table is that listing makes no row the person’s for this reach, on the
table itself or on a row it hangs off (via, up to two links up). Every other way counts as
before: a staff column, another listing, the rows nobody owns. A helper listed on a deal
(deal_members) reads the deal, its quotes, their lines and its visits, and comments on them,
and changes none of them; whoever covers for the deal’s owner (covers), and the owner, change
them as they always did:
"limits": { "deals": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "notThrough": "deal_members" } } }, "quotes": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "notThrough": "deal_members" } } }, "quote_lines": { "rows": { "read": { "reach": "own" }, "delete": { "reach": "own", "notThrough": "deal_members" } } }, "follow_ups": { "rows": { "read": { "reach": "own" }, "update": { "reach": "own", "by": "owner_id", "notThrough": "deal_members" } } } }It is one listing’s ref, or a list of one to four. Each must be the table of a through way of
this table, or of a table it hangs off by via (two links at most); it is refused on a read (a
person listed on a row still reads it), beside via (that reach counts no listing already), with
reach: all, on a private table, and on a grant on an add-on’s table. It holds on every door an
update or a delete reach holds on (a change on a page, a child row saved with it, a bulk edit, a
file brought in, a record’s button, a document): a row it leaves out is a row that is not there to
them (404). A new row hung under a parent (a quote of the deal, a follow-up or a visit on it) is
judged by the parent without the listing too, 403 reason parent-not-theirs: where every reach
of the writer on the new row’s table leaves a listing out, a staff column of the new row that
names the writer does not make it theirs while it is hung under a parent. A row hung under no
parent is theirs by its own column as before, and a row of theirs made before is changed and
removed as before. The role editor keeps it through a save and says it in words (“but not as
someone listed in Deal members”). Needs Adminium 0.3.36 (rows.notThrough).
An action may name several reaches, as a list of one to four: any one of them is enough, and
each is its own whole test (its while, unowned and by hold for it alone), as two roles’
reaches are. An account manager reads every won deal, and an open one only while he is listed on
it:
"limits": { "deals": { "rows": { "read": [ { "reach": "all", "while": { "column": "state", "in": ["won"] } }, { "reach": "own" } ] } } }No reach of the list is written twice, and the checks above hold for each. A private table’s
reach stays { "reach": "own" } alone. A list needs Adminium 0.3.28.
A row of a table reached through its parent (via) is hung, by a writer held to some rows of it,
only under a parent their reach of THAT table reaches: a parent that is theirs (or somebody’s
they lead, with team), or nobody’s when the reach takes unowned rows. Reading the parent is
not enough: the account manager above reads every won deal and comments only on a deal he is on
(403, reason parent-not-theirs). A row a staff column of its own names them in (a stamp of
its maker, say) is theirs whatever its parent, unless every reach of theirs on the table leaves a
listing out (notThrough, above); a table that also lists people through, or reads a person
from a linked row, is not judged this way.
Apart from any limit, the columns that decide whose a row is (the staff columns the table’s
own belongs names) are changed only by somebody the stored row names in one of them, or who
leads that person where the role’s reach is team. Somebody who reaches the row another way (a
helper listed on it, a cover, the owner of its parent) works on the row and cannot give it away
or take it. A record’s handOver button frees whom the row may go to, not who may hand it over.
Where such a column is kept as a copy of the row’s parent (copy with mode: "always"), the
person is decided by the link: a writer held to some rows sets that link only to a parent that
names them there (or somebody they lead, with set: "team"), and is refused 403 with reason
parent-not-theirs otherwise. And the person column of a table that another table’s belongs
reads through takes only somebody who reads at least one of those tables (422, the field
marked cannot-open), whoever writes: a person on the dashboard, a bulk edit, an import (by
somebody nothing limits too), a rule’s write step, the row an effect moves, a timed move, and
the owner’s code. Every one of them is judged on the row as its rules leave it, a person’s own
change and new row included (from 0.3.35), so a stamp that puts a person into such a column (its
writer, or a person copied from another column) is held to the same. See
Whose a row is.
handOver, on one of a table’s states.actions, marks the one write in which a
person limited to their own rows may name a colleague: the button asks for a staff column and
sets it to anybody.
{ "id": "hand-over", "label": { "en-US": "Hand over" }, "set": {}, "in": ["open"], "ask": ["owner_id"], "handOver": true }Anybody, that is, who can work the row: an active person whose roles read and change the table,
with a reach that holds the row in the state it is in (an account manager who reaches a deal
once it is won is not given an open one). Anyone else is refused 422, the field marked
cannot-open. A writer held to some rows who names somebody by a plain change, in a column
whose belongs way says set: "anyone", is held to the same: the button is not the only door
to the column.
carries, beside handOver, moves the old person’s child rows to the new one in the
hand-over’s own write: a deal handed over takes its owner’s booked visits and open follow-ups
along, and leaves a visit already made, and a colleague’s rows on the deal, where they are.
{ "id": "hand-over", "label": { "en-US": "Hand over" }, "set": {}, "in": ["open"], "ask": ["owner_id"], "handOver": true, "carries": [ { "table": "site_visits", "via": "deal_id", "column": "rep_id", "where": [{ "column": "state", "in": ["booked"] }] }, { "table": "follow_ups", "via": "deal_id", "column": "owner_id", "where": [{ "column": "done", "eq": false }] } ] }| Key | Meaning |
|---|---|
table |
A table of the app with a foreign key to this one. |
via |
That foreign key. |
column |
The child’s staff column (rules.staff) that moves: only rows where it names the row’s OLD person move, and they are given the new one. |
from |
The staff column of this row the person is read from: one of the columns the button asks for. Left out when the button asks for one staff column. |
where |
1–8 conditions on the child row, as a move’s requires.where writes them (eq, in, isNull, gt…): every one must hold. |
One to eight carries, each table and column once, on a button with handOver: true. Each carried
row is changed as its own update, inside the hand-over’s transaction: its rules and hooks run,
it is written to the audit log, its live frame is sent and the rules and the outbox that watch
its table are told. The person handing over must hold the child table’s update and reach each
row moved (else 403, and nothing moves); the column itself is the button’s to write, as the
columns it sets are, whatever the role’s writable says. Each row is judged as a hand-over
judges its own row: the new person works the child table, and a child that is a listing lists
them only against rows they can open (422, the field marked cannot-open). A child table that
takes a resource for a time (occupies or booking) has
each carried row placed as a single change places it, under its day’s lock: a visit the new person
is already busy for refuses the hand-over (409 BOOKING_TAKEN). One refusal refuses
the whole hand-over. A hand-over that carried rows offers no Undo. Needs Adminium 0.3.35
(states.carries).
roles declares roles the app brings, such as a cashier and a manager. Each is installed as
<key>-<role key> (pos-cashier) and belongs to the app.
{ "key": "cashier", "name": "POS cashier", "screensOnly": true, "permissions": ["table:@tickets:read", "table:@tickets:create", "page:@pos-menu:view", "app:@:staff"]}| Field | Required | Rule |
|---|---|---|
key |
yes | kebab-case. The full <key>-<role key> must fit in 40 characters. |
name |
yes | 1–80 characters. The English name. |
names |
no | The name in other languages, keyed by BCP 47 tag ({ "de-DE": "Kassierer" }), each 1–80 characters. A reader sees their own language until the owner renames the role; a renamed role is shown as the owner wrote it, in every language. Needs Adminium 0.3.28. |
modules |
no | 1–16 ids of the parts of the app the role belongs to. The role is offered while any of them is on. Needs Adminium 0.3.28. |
permissions |
no | Grants, in the forms below. |
cloneFrom |
no | The key of another of this app’s roles, whose grants this role also gets, and its limits. |
screensOnly |
no | true: people with this role open the app’s own screens and never the dashboard. |
limits |
no | Per table ref, what the role’s update there may change, what its read there shows, and which rows its read, update and delete reach. See below. |
A manifest cannot know the real table names or page ids, so it grants through placeholders:
| Grant | Actions |
|---|---|
table:@<table ref>:<action> |
read, create, update, delete, export, import, read_pii |
page:@<page ref>:<action> |
view, edit |
app:@:staff |
Open the app’s staff screens. |
addOn:<add-on key>:settings |
Edit that add-on’s non-secret settings (the letterhead of an invoicing add-on). Only an add-on the app requires or suggests in addOns. |
read_pii shows the table’s personal columns in clear. Without it they read as null, listed in
the row’s _masked. A lookup into another table needs read_pii on that other table. See
Personal data.
The plan refuses a system: grant, a wildcard, and a reference to a table or page the manifest
does not declare. Grants are given once: an update adds what a new version asks for, and an
operator’s narrowing of an app role survives it.
limits narrows the role’s update on a table to some columns and, for some of those, to some
values. The names are the ones public access uses:
{ "key": "clinician", "name": "Clinician", "permissions": ["table:@appointments:read", "table:@appointments:update", "app:@:staff"], "limits": { "appointments": { "writable": ["status"], "writableValues": { "status": ["roomed", "with_clinician", "ready"] }, "writableFrom": { "status": ["checked_in", "roomed", "with_clinician"] } } }}| Field | Required | Rule |
|---|---|---|
writable |
no | The columns the update may change, at least one. |
writableValues |
no | For a column in writable, the only values it may set (1–32, each a value of the column). Needs writable. |
writableFrom |
no | { "<column>": [1–32 values] }: the update reaches a row only while that column of the row holds one of these values. Any column of the table, in writable or not. Needs writable, and minAdminiumVersion 0.3.19 or later. |
readable |
no | The only columns the role’s read of the table shows, 1–200, each once. The key is always read; a link to another row is read only when it is listed, as any column (0.3.35; before, every link was read). Needs the role’s table:@<ref>:read. |
creatable |
no | The only columns a new row the role creates may be given; the rest take their defaults or what Adminium decides. Needs the role’s table:@<ref>:create. |
creatableValues |
no | For a column in creatable, the only values a new row may be given (1–32). Needs creatable. |
A limit names writable, readable, creatable, or any of them.
The table must be one the app declares, and the role must grant table:@<ref>:update itself or
through cloneFrom. Someone who also holds a role with an unlimited update on the table is not
limited. Creating rows is not limited by writable; creatable limits it the same way, on the
create itself, a row added from a parent’s form, and an import (which may not bring in a column
outside creatable, nor one whose values are limited). A value left empty, and the state column at
its first state, are no choice and always pass. Every install and update writes the
manifest’s current limits.
writableValues says what a column may be moved to; writableFrom says what a row may be
moved from. It is judged on the row as it is stored, never on what the request says of it, and
whatever the update changes: a clinician who may move a visit along from checked_in, roomed or
with_clinician cannot change a visit that is already seen at all — not its status, not another
column, not a line under it sent through the visit’s form. The refusal is 403 COLUMN_FORBIDDEN
with reason: "update-from", the column and the values. It holds on a single change, a bulk
change (one row out of reach refuses the whole batch), a row-by-row change (that row only) and a
record’s own action. Roles add up here too: a role with an unlimited update lifts it; of two
limited roles, a column only one of them judges by is not judged, and where both do, a value either
lists is enough. A create is not held by it: a new row under a parent is the child table’s create. See Edits limited to some columns.
readable limits what the role reads the same way: housekeeping reads a stay’s room and dates,
and none of its guest or its money.
"limits": { "stays": { "readable": ["room_id", "arrive", "depart", "late_checkout", "status"] } }Every staff read of the table holds to it: lists and records, links and lookups, search, exports,
imports, dashboard cards and their live updates, documents, the audit log, files kept in a hidden
column, and the page assistant. Selecting, filtering or sorting by a hidden column is refused 403
COLUMN_FORBIDDEN with details.reason: "read-limit"; a lookup into one reads as masked. A
refusal never repeats a hidden value, and the desk’s limit counts and nightly lines are refused
when the rule reads a hidden column. A hidden column is not written through the role either,
creates included, unless writable names it. Roles add up: two limited roles read the union of
their columns, one unlimited read of the table reads it all, and a Super Admin is never limited.
A role that signs in a staff-bound browser key (a check-in tablet; see publicKeys)
must be screensOnly, with no cloneFrom and no grant but app:@:staff.
A role on an add-on’s tables
Section titled “A role on an add-on’s tables”An app’s role may reach tables of an add-on the app names in addOns, with tables.
A manifest that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.
"tables": [ { "addOn": "inventory", "table": "transfers", "actions": ["read", "create", "update"], "limit": { "creatable": ["from_place_id", "to_place_id", "note"], "writable": ["status"] } }, { "addOn": "inventory", "table": "stock_points", "actions": ["read"], "limit": { "readable": ["id", "item_id", "place_id", "on_hand"] } }]Up to 12 entries, one per table. addOn is the add-on’s key and table its own name for the
table. actions are read, create and update, never a delete, an export or an import.
limit is the object a role’s limits hold for one table, and narrows only an action
the entry gives: readable needs read, writable needs update, creatable needs create.
The grant is live while the add-on is connected to the app; the add-on’s table and column names
are checked then. An add-on’s own roles grant its tables through permissions, not tables.
The add-on may say how far an app’s role goes on each of its tables
(appGrants). An entry is then cut to it: an
action or a column past it is left out, and an entry that asks for every column is given the
add-on’s columns, never all of them. Nothing past it is ever written. The install check lists each
role’s hold with its columns, and says what was asked for and not given. An update whose roles
would hold more than the running version’s (a new action, a new column, a column list that becomes
every column) is refused with ROLE_TABLES_WIDER until the update is sent with
"roleTables": true. The update’s check in Studio shows what is new, and pressing Update sends it.
Settings
Section titled “Settings”settings declares values the operator sets for the app, such as a business type or a currency.
They appear on the app’s settings page, and the app reads them, with their defaults, from
surface-config.json.
{ "key": "business_type", "type": "enum", "enum": ["restaurant", "retail"], "default": "restaurant", "label": { "key": "mft.pos.setting.businessType", "fallback": "Business type" } }Every setting has a snake_case key, a type, and optionally required, secret, a label and
a help sentence shown under the field (both i18n messages). By type:
type |
Extra fields |
|---|---|
string |
default (string) |
number |
default, min, max (numbers), unit (up to 20 characters) |
boolean |
default (boolean) |
enum |
enum (at least one value, required), default (string) |
file |
accept (a list of accepted types) |
json |
default (any JSON) |
A setting marked secret is never sent to an app’s screens. In this release an app’s settings page
does not show or store secret settings; they are used by add-ons.
Add-ons
Section titled “Add-ons”addOns names the add-ons an app needs, the ones it works better with, and the features that stop
without one.
"addOns": { "requires": [{ "key": "invoices", "range": ">=1.1.0", "reason": { "en-US": "Invoices, quotes and receipts are made by this add-on." } }], "suggests": [{ "key": "holiday-calendars", "range": ">=1.1.0", "checked": true, "reason": { "en-US": "Marks public holidays as days off." } }], "features": [{ "id": "capacity-holidays", "label": { "en-US": "Holidays in Capacity" }, "requires": ["holiday-calendars"] }]}| Field | Required | Rule |
|---|---|---|
requires |
no | 1–8 add-ons the app cannot run without: { "key", "range", "reason" }. |
suggests |
no | 1–8 add-ons offered with the app: the same, plus checked, whether the offer starts ticked. |
features |
no | 1–16 { "id", "label", "requires" }: a part of the app that works only with some add-ons. id is kebab-case, up to 40 characters; requires lists 1–4 add-on keys, each one the app requires or suggests. |
key is an add-on’s key. range is a semver range over its version (>=1.1.0, ^1.1.0), up to
120 characters. reason and label are keyed labels, with en-US among them. An
add-on is named once, in requires or in suggests, and never the app itself.
What each list does:
- Required. The install check shows each required add-on, where its version comes from and its
own install plan. Installing the app installs, updates or connects every required add-on first,
before the app’s own tables, so a table built on its shape
and a foreign key into its tables resolve. One that cannot be had, or is out of range and not
ticked to update, refuses the install before anything is written. An add-on an installed app
requires cannot be removed or switched off: that is refused
409ADD_ON_REQUIRED_BY, naming the apps. Nor can it be updated to a version outside the app’srange: that is refused409ADD_ON_RANGE, and the app is updated first. - Suggested. Offered on the install check, ticked when
checkedsays so. The operator may leave it out. - Features. A page that names a feature in
featureleaves the sidebar while the feature’s add-ons are not all installed and switched on for the app. A document may name one too, and so may a part of the app.
If a later step of the app’s install fails, the add-ons it installed stay: another app may already rely on them. Uninstalling the app keeps them too; only their link to the app goes, and the uninstall preview lists them. Only a required add-on’s settings may be read by the app’s rules (see Values from elsewhere); a role may be given any named add-on’s settings (see Roles).
Documents
Section titled “Documents”documents lists document profiles the app ships for its own tables: which columns make a printed
invoice, receipt or statement, drawn by an add-on that renders documents. Up to 16.
"documents": [{ "kind": "receipt", "addOn": "invoices", "table": "sales", "name": { "en-US": "Till receipt" }, "mapping": { "number": { "column": "number" }, "customerName": { "via": "customer_id", "column": "name" }, "lines": { "collection": { "table": "sale_lines", "via": "sale_id", "orderBy": "position", "columns": { "description": "name", "qty": "qty", "amount": "amount" } } } }}]| Field | Required | Rule |
|---|---|---|
kind |
yes | kebab-case: the kind of document (invoice, receipt, statement). One of each kind per table. |
addOn |
yes | The add-on that draws it. The app requires or suggests it. |
table |
yes | The app’s table a document is drawn for, one per row. |
name |
yes | A plain string or a keyed label. |
mapping |
yes | From each of the add-on’s slots (letters, digits and _, starting with a letter) to where its value comes from. See below. |
statement |
no | Makes the document a statement. See below. |
feature |
no | One of the app’s addOns.features. |
module |
no | The part of the app the document belongs to: it is not drawn while that part is off. Needs Adminium 0.3.28. |
requestValues |
no | 1–8 slots the app’s own screen may fill when it asks for the document (a label sheet’s count). Each must be a slot the entry does not map; when the add-on is attached, its outline must have the slot, with no default, holding a number or a text. See below. |
where |
no | { "column", "in": [...] }: only rows whose column holds one of 1–16 values have this document (a receipt for payments.kind taken, not for money given back). Asked for another row, the render answers 409 DOCUMENT_NOT_FOR_ROW; an email that would carry it goes without it. |
The slots are the add-on’s words (number, issuedAt, items): a shape’s own profiles, in the
add-on’s manifest, show the ones it draws. Each slot reads one of:
| Source | Reads |
|---|---|
{ "column", "form"? } |
A column of the row. |
{ "via", "column", "form"? } |
A column of the row a foreign key via of this table points at: the client’s name on an invoice. |
{ "collection": { "table", "via", "orderBy"?, "columns", "where"?, "unless"? } } |
A list of child rows, whose foreign key via points at this table, in orderBy order. columns maps the add-on’s names for a line’s values to the child’s columns. where ({ "column", "in": [1–16 values] }) keeps only the rows whose column holds one of the values; a row whose unless column is true or set is left out (a voided line). |
{ "collection": { "nightly", "columns" } } |
The nights a price by the night of the row is made of, one line each, worked out when the document is drawn. nightly names the priced column; columns maps a line’s values to a night’s own date, rate, base, qty and tags, or to <rate via>.<column>, a column of the row the rate comes from (room_type_id.name). |
{ "collection": { "addOn", "table", "match": { "table", "row" }, "orderBy"?, "columns", "takenOff"?, "where"?, "unless"? } } |
Rows an add-on keeps for this row, found by the pair each stores: this table’s stored name in match.table and the row’s key in match.row (what Offers & gift cards applied to an order). addOn is one the manifest names; table and the columns are the add-on’s own names. With the add-on away the list is empty. Rows of what an add-on took off an order print one row for each offer, code or voucher, its amounts added, named in the document’s language. takenOff lists the line values printed as taken off: an amount kept as 4.85 reads −4.85, so a reduction can sit among charges. |
{ "collections": [1–6 sources] } |
One list from several sources, in order, each a table source, a nightly source or an add-on’s rows as above: a folio’s nights, then its extras, its charges, and what was taken off. |
A line’s value in a table source may also be a list of names one level below the line, printed one
after another (a dish’s options, “Farro · Grilled chicken · Avocado”):
{ "list": { "table", "via", "column", "orderBy"? } }, where column is a text or enum column
of the rows whose via points at the line. Such a list never names a secret, personal, code or
withheld column. "form": "grouped" prints a code column in groups of four
(K7QX-M2PD), as a person reads it out; only on a code column.
Some slots have a default in the add-on’s outline, which fills the slot when nothing else does: when it is not mapped, and when the column it is mapped to is empty on this row (a draft with no issue date yet).
| Default | Fills the slot with |
|---|---|
now |
The moment the document is made; a date slot takes that day on the venue’s clock. A document drawn again (after an edit, in another language) is the same document and keeps the day it was first made. |
connection |
The document’s currency: the row’s own currency column when it holds one, else the connection’s. |
sequence |
The number the document prints. |
setting |
Nothing on the server: it is one of the add-on’s own settings, which the add-on reads itself. A required slot with this default still needs a mapping. |
A required slot that nothing fills makes the document fail, naming the slot (“unmapped or empty”).
A statement is drawn for one row (a client) over a period. It lists the documents and the payments that point at that row, with a running balance, so it names two sources rather than one list:
"statement": { "documents": { "table": "invoices", "via": "client_id", "date": "issued_on", "amount": "total", "number": "number", "where": { "column": "status", "in": ["sent", "void"] } }, "payments": { "table": "payments", "via": "client_id", "date": "paid_on", "amount": "amount", "unless": "voided" }}Each source is { "table", "via", "date", "amount", "number"?, "where"?, "unless"? }: a table whose
foreign key via points at the document’s table, its date and amount columns, and optionally its
number. where is { "column", "in": [1–16 values] }, only rows whose column holds one of them
(sent invoices, never drafts); a row whose unless column is set is left out (a voided payment).
The period is chosen when the statement is drawn, never as dates: all (the default), year
(from 1 January) or 12m (the last twelve months). What came before the period is carried in as
the opening balance, and rows dated after today are left out. A statement is issued on the day it
is drawn, on the venue’s clock: that fills its issue date (issuedAt) unless the profile maps a
column to it.
A profile is the app’s. It is made with the real table names when the app is installed, changed in place by an update so documents already issued keep pointing at it, and removed when the app is uninstalled. A profile whose add-on is not installed is skipped, with a reason on the install reply. An operator’s own profile is never changed. A document of a kind the attached add-on does not draw yet waits for the add-on’s update; see below.
When a table is built on an add-on’s shape, the shape’s own
profiles are made for it at install. An app entry of the same kind on that table does not make a
second profile: its slots are added to the shape’s mapping (the app’s slot wins on the same name,
so an invoice can print the client’s address from the app’s own clients table), and its name
replaces the shape’s.
The app’s staff screens ask for a document by the app’s own names:
POST /api/v1/apps/<key>/documents/render with { "kind", "ref", "pk", "period"?, "locale"?, "values"? },
where ref is the table’s short ref and pk the row’s key. values fills the slots the entry
lists in requestValues, by slot id, typed by the add-on’s outline ({ "count": 12 }); a value for
any other slot, for one a value typed into the profile fills, or one its type cannot hold is 400
DOCUMENT_VALUE_REFUSED, naming the slot, and nothing is drawn. A document drawn with values
lists the slots they filled under requestValues in its subject, is never emailed on its own
(pending-review, as a stranger’s is), and a later draw of the same row takes nothing from it. The document is drawn now, or the one
already drawn is handed back while the row is unchanged, with contentUrl for its bytes and
printUrl for a copy to print. The caller must be signed in and able to read every table the
document reads, a statement’s sources included. An app, kind, row or table they cannot reach is
the one 404; an add-on that is detached, or a feature switched off, is 409 FEATURE_OFF; a
document that cannot be drawn (a required slot left empty) is 422 DOCUMENT_NOT_DRAWN.
A picture in a document
Section titled “A picture in a document”An add-on’s outline may have a picture slot (Adminium 0.3.35): the document then shows a picture
the row keeps, such as a customer’s drawn signature on a signed copy. Map it to a column of the
document’s own row that holds one file, a text column with
"rules": { "file" } and no maxCount, whose accept lists png or jpeg:
"mapping": { "signer": { "column": "signed_by" }, "signature": { "column": "signature" } }The add-on never reads the file store: Adminium reads the file each time the document is drawn
and hands the add-on the picture, or nothing. It hands it only when the file is the row’s own
(attached to that very record, alive, not a file of another row written into the column by hand),
a PNG or a JPEG by its own bytes, no larger than the column’s maxBytes and 5 MiB, and at most
8192 pixels a side; what a camera writes beside a picture (the place, the device) is taken out. A
file that is none of that leaves the slot empty, and a required picture slot is then a slot left
empty (“unmapped or empty”). The picture is read as the column is: a staff member whose role does
not read the column, or the row, is drawn nothing at all, as for any column a document prints; a
column withheld from a guest is empty for them, and so is the picture. A secret column gives no
picture. A value typed into the profile, or sent by a request, never fills a picture slot.
A picture slot mapped through a link (via), to a column that holds no file, a list of files, or a
file column that takes no PNG or JPEG is refused when the app is installed, naming the slot. A
picture is never a column of a collection.
The add-on receives the slot’s value in subject.fields as { "mediaType", "dataUrl", "width", "height" }: mediaType is image/png or image/jpeg, dataUrl is
data:<mediaType>;base64,<bytes> (an HTML page puts it in an <img>; a PDF writer decodes the
base64 after the comma and embeds the PNG or JPEG as it is), and width and height are the
picture’s pixels. @adminium/add-on-contracts exports the slot type (OUTLINE_SLOT_TYPES), the
value’s type and schema (DocumentPictureValue, documentPictureValueSchema) and, for an add-on’s
own tests, SAMPLE_DOCUMENT_PICTURE. The documents register keeps what was printed without the
bytes: a picture is stored as { "mediaType", "width", "height", "sha256" }, and the document’s own
file holds it as drawn.
A kind the add-on does not draw yet
Section titled “A kind the add-on does not draw yet”An app may name a document kind that the add-on attached to it does not draw yet: the app was
released for the add-on’s next version, and the installed one is older. The install and the update
go through all the same (Adminium 0.3.35; an older release refused them). That document is left
off, and so is the attachment a message makes of it: a template whose attach is optional goes
without it, and one whose attach is not fails as for any document that cannot be drawn. The reply
of POST /api/v1/apps/install and the app of POST /api/v1/apps/<key>/update say which documents
wait, in documents.waiting ([{ "kind", "table", "addOn", "addOnName", "name", "reason" }]), and
the console shows it on the install’s last screen and under the update’s notice. When the add-on is
updated to a version that draws the kind, the document is made then, with nothing to do in the
app. A profile already made for it is kept meanwhile.
A list of an add-on’s rows
Section titled “A list of an add-on’s rows”A collection may list rows of an add-on’s table that belong to the document’s row. The add-on’s
table has no foreign key into the app, so the rows are found by the pair the add-on keeps: the
stored name of a table and a row’s key. A manifest that uses it sets
compatibility.minAdminiumVersion to 0.3.18 or later.
"applied": { "collection": { "addOn": "offers", "table": "applications", "match": { "table": "source_table", "row": "source_row" }, "orderBy": "id", "columns": { "label": "label", "amount": "amount" } } }addOn is an add-on the manifest names in addOns. table, match, orderBy, where,
unless and the columns are the add-on’s own short names; match.table is a column that carries
"rules": { "tableRef": true } and match.row a text column. Only plain columns are listed, none
read through a link. While the add-on is absent, detached or switched off the list is empty and
the document is drawn without it. The rows are the row’s own, so whoever may draw the document
needs no grant on the add-on’s table. Keep a money code out
of a table listed this way.
Documents that print a money code
Section titled “Documents that print a money code”A money code is a code worth something to whoever holds it, a gift card’s: a code column of
which another column of the same table keeps the last four. A document that prints one, from its
own row, a row it links to or the rows listed under it, is drawn when it is asked for and kept
nowhere: no row in the documents register, no stored file, and it cannot be drawn again from the
register. Whoever made the code prints it from the page that made it, once, with the ticket the
create answered (see the once block in the REST API).
A signed-in person reaches the documents of their own rows through a public entry’s
documents.
Emails
Section titled “Emails”An app declares the emails it sends with two blocks: outbox, which names one of its tables as
the outbox and says what queues rows in it, and emailTemplates, the templates those rows are
sent with. For a walk-through, see Emails.
The outbox table is the log. Every email is a row in it, queued by a producer below, by the app’s
own screens or by the operator. Adminium sends queued rows and records the outcome on each:
sent once the message is handed to the mail queue, skipped when there is nothing to send to
(“No email on file”, or a reserved example address), and failed with a reason. A row already
there for the same kind and source is what stops a second send.
outbox
Section titled “outbox”"outbox": { "table": "messages", "columns": { "kind": "kind", "status": "status", "to": "to_address", "language": "language", "due": "due_at", "sentAt": "sent_at", "error": "error" }, "links": { "appointment": "appointment_id", "patient": "patient_id" }, "recipient": { "via": "patient_id", "table": "patients", "email": "email", "name": "name", "language": "language", "optIn": "reminders", "fallback": { "via": "appointment_id", "email": "new_email", "name": "new_name" } }, "settings": { "table": "settings", "enabled": "emails_on", "name": "practice_name", "phone": "phone" }, "pages": { "manage": "/my-visits", "booking": "/" }, "kinds": { "confirmation": "clinic-confirmation", "reminder": "clinic-reminder" }, "producers": [ { "kind": "confirmation", "link": "appointment_id", "gate": "enabled", "onCreate": { "table": "appointments" } }, { "kind": "reminder", "link": "appointment_id", "gate": "enabled", "optIn": true, "before": { "table": "appointments", "at": "starts_at", "lead": { "via": "patient_id", "table": "patients", "column": "reminder_hours", "fallback": { "table": "settings", "column": "reminder_hours" }, "max": 72 }, "where": { "column": "status", "eq": "booked" } } } ]}| Field | Required | Rule |
|---|---|---|
table |
yes | One of the app’s tables: the outbox. |
columns |
yes | The outbox’s columns. kind is an enum of the kinds. status is an enum holding at least queued, sent, failed and skipped, and held when a producer holds. to is text, the address. Optional: language (text), due (timestamptz, required by a before producer and by one with hold or due), sentAt (timestamptz) and error (text), and the columns of held messages: skipReason (text or enum), subjectOverride, bodyOverride, approvedBy and effectError (text), and effectAt (timestamptz). repeatKey (text of at least 43 characters) keeps which value a repeatBy producer sent for; was (text, unbounded or at least 1000 characters) keeps a was producer’s values from before the change. |
links |
no | The outbox’s foreign keys, by the name a template reads them under: { "appointment": "appointment_id" } gives a template appointment.*. Names are snake_case. Every foreign key of the outbox table that the outbox names must be nullable: not every email is about one. |
recipient |
yes | Who the email goes to. See below. |
settings |
no | The app’s one-row settings table: { "table", "enabled"?, "name"?, "phone"? }. Templates read it as practice.*. enabled is a bool that pauses producers with gate: "enabled". name is a text column the app’s emails are signed with, the emailed code included; without it, the workspace’s name is used. phone is a text column: when it holds a number, the notice sent to a person’s old address after a change of email tells them to ring it; without one, the notice says to contact you. replyTo is a text column: when it holds one plain address, every message the app sends carries it as its Reply-To, so a guest’s reply reaches the house rather than the no-reply sender; empty, or anything but one address, adds none. |
pages |
no | { "manage"?, "booking"? }: paths on the app’s customer side (/my-visits) that a template’s manage_url and booking_url lead to. Up to 120 characters. Default: the side’s front page. |
kinds |
yes | Each value of the kind column, and the key of the template it is sent with. Every value must be one of the enum’s, and every template one of emailTemplates. |
producers |
no | Up to 24 rules that queue rows by themselves. See below. |
recipient is { "via", "table", "email", "name"?, "language"?, "optIn"?, "fallback"? }.
via is the outbox’s foreign key to the person, table the person’s table, and the rest are its
columns: email, name and language are text, and optIn is a bool the person sets (false
means nothing from a producer that asks optIn). language may instead be { "column" }, a text
column of the row the message is about: an order placed in German is written to in German, whatever
the person’s own row says. The row every producer’s message is about must have that column. It is
used only when it holds one usable language tag that fits the outbox’s language column (which
must be named); otherwise the person’s language is. fallback is { "via", "email", "name"?, "language"? }: where the address
comes from when via is empty. Its via is another foreign key of the outbox, and its columns
belong to the table that key points at; a first visit by someone not yet on file carries their
details on the visit itself.
Producers. Each has a kind (a key of kinds) and a link, the outbox’s foreign-key column
that points at the row the message is about and must be one of links. Optionally module, the
part of the app the email belongs to (nothing is queued while it is off), gate and
optIn: true (needs recipient.optIn: a person who opted out gets nothing). gate: "enabled"
pauses the producer while the settings row’s settings.enabled bool is false;
gate: { "setting": { "table", "column" } } pauses it while that bool of the settings row is false,
so each notice can have its own switch; gate: { "feature": "<id>" } sends only while one of the
app’s addOns.features is on (a receipt, while Invoices & Receipts is attached);
gate: { "feature": "<id>", "setting": { "table", "column" } } queues only while both hold: the
feature is on AND that bool of the settings row is true (a receipt, while Invoices & Receipts is
attached and the manager’s switch is on). Each half is checked as it is alone. Every gate is judged
when the message is queued: a message already waiting (held, or due later) still goes when the
gate closes after it was queued, unless its dropWhen drops it. A message of the kind sent from a
new link or a “Send it again” is judged by the same gate, and a kind held for
approval (hold: true) cannot be sent from a link at all. Then exactly one of:
| Producer | Shape | Queues a row |
|---|---|---|
onCreate |
{ "table", "via"?, "where"? } |
When a row of the table is created. |
onChange |
{ "table", "via"?, "column", "to", "where"? } |
When the column changes to to, a value or a list of 1–16 values. |
onChange |
{ "table", "via"?, "columns", "changed": true, "where"? } |
When any of 1–8 columns changes, compared with the row as it was stored (a stay’s dates, whatever they became). Numbers compare as numbers and dates by day. |
onDelete |
{ "table", "where"? } |
When a row of the table is removed, by anyone: one message for each removal, written once the row is gone. See A row that is removed. Needs Adminium 0.3.28. |
before |
{ "table", "at", "lead", "where"? } |
A lead time before at, a timestamptz of the row: a reminder. lead.at ("HH:MM") sends it at that wall time on the venue’s day the lead reaches: 24 hours before a 20:00 show, at 09:00, is 09:00 the day before. |
With via, the source is a child row and the message is about the row its foreign key via
points at: a version posted to a deliverable makes a message linked to the deliverable, and
addressed through it. link then points at that parent’s table.
A row that is removed
Section titled “A row that is removed”onDelete writes a message when a row of table is removed: from the dashboard, by a bulk
removal (one message a row), from an app’s own screen, or through a public entry that may delete.
where is judged on the row as it was. The row is gone when the message is written, so:
- the message’s
link(the outbox’s link to that table) is empty, and nothing is read from the row again. What the template says of it comes fromwas:{{was.starts_at.date}}. The outbox’s other links are filled from the removed row as on any message (the contact and the deal a visit named), so{{contact.name}}and{{deal.name}}read as usual; - it goes to the outbox’s recipient, found through those links, or to
recipient.column, an address the removed row held. That address is read once, as the row goes, and kept in the message’s own address column and nowhere else; the name beside it is not kept, so{{recipient.first_name}}is empty for such a message; - it takes none of
hold,due,batchMinutes,holdSeconds,dropWhen,supersede,repeatBy,onSentandreplyTo: each reads the row again after the message is written. A reply goes to the outbox’s ownsettings.replyTo, when it has one; - each removal is its own message. A sample row, a row taken back by an undo of its creation, and a child row removed inside its parent’s own save write none.
Naming a table in onDelete also means its rows are never held by their messages. Without it,
a row that any message links (sent last year, or still waiting) cannot be removed: the database
refuses it (409). With it, as a row of that table goes, every message that named it through the
producer’s link loses that link and stays in the log with what it kept, and one that had not
gone yet (queued, or held) is skipped with the reason no-longer-needed. A skipReason column
that is an enum therefore offers no-longer-needed. This holds for every removal made through
Adminium, also while the app is switched off; a row removed by SQL of your own is still refused by
the database while a message names it. Nothing is altered in the database for it, so an install
made before this word gets it with the update that adds the producer.
{ "kind": "visit-cancelled", "link": "visit_id", "onDelete": { "table": "visits" }, "was": ["starts_at", "place"] }A producer may also say:
| Field | Rule |
|---|---|
hold |
true: its rows are written held and sent only once a person approves one. See Held messages. |
due |
{ "date", "days", "at"? }: when the message comes due, days after date (a date or timestamptz of the row it is about), at at ("HH:MM", 09:00 by default) on the venue’s clock. Not on a before producer, which is due by its lead. |
supersede |
A group name (kebab-case, up to 40 characters). When a message of the group comes due for a row, the earlier ones not yet sent are skipped as overtaken, so an invoice never has two reminders ready at once. Needs due. |
dropWhen |
1–4 conditions on the row the message is about: { "column", <one test>, "reason" }, the test being eq, in, isNull, lte or gte. While one holds, its waiting messages are skipped with the reason: paid, void or no-longer-needed. Only a message that waits (hold, or due) can be dropped. |
recipient |
{ "setting" }: send to the address a setting holds (a studio’s own reply_to), never to the person the row links. The setting is { "table", "column" } of the settings row, or { "addOn", "setting" } of a required add-on. Or { "column", "name"?, "language"? }: send to the address a text column of the producing row holds (the friend a ticket is offered to), with that row’s name column as the name and its language column as the message’s language. |
replyTo |
{ "staff": column }: a reply to the message goes to the person a staff column names, at the address they sign in with. The column is one of the row the message is about (owner_id), or of the row one of its links names (deal_id.owner_id: the owner of a quote’s deal). See A reply to the rep. Needs Adminium 0.3.28. |
holdSeconds |
1–3600: the message waits this many seconds before it may go, so a move taken back at once (an order marked ready by mistake) drops it by dropWhen before anyone is told. A message dropped so does not stop the next one of its kind. Needs the outbox’s due column; not on a before producer, nor with hold, due or batchMinutes. |
repeatBy |
A column of the row the message is about: one message for each value it holds, not one for the row for ever (a ticket offered again, its link made afresh, is emailed again). A message for an earlier value not yet sent is skipped as overtaken. Needs the outbox’s repeatKey column; not on a before producer, nor with batchMinutes. |
repeat |
true: one message for each change it hears of, not one for the row for ever (“Resend tickets” twice is two messages). Only on an onChange producer, and not with repeatBy or batchMinutes. |
was |
1–8 columns of the changed row kept as they were before the change (a stay’s old dates, its old total), read by the template as {{was.<column>}} in every form the column has. On an onChange producer, and on an onDelete producer, where they are the removed row’s own values; needs the outbox’s was column. Never a secret, personal, code, share-code, withheld or typed-code column. |
batchMinutes |
1–240: one message per linked row in each window of this many minutes. The first event opens a message due at the window’s end; every event while it still waits is taken in by it (five versions posted in ten minutes make one email). The window’s end is its due, so it takes no due; it may still be dropped (dropWhen) or overtaken (supersede) while it waits. Not on a before producer. |
onSent |
{ "table", "via"?, "set" }: a change made once the message has gone. Without via, table is the row the message is about; with it, via is that row’s foreign key to table. set maps columns to values (null empties a nullable column). |
due.days is one of:
days |
Days after the date |
|---|---|
| a number, 0–3650 | That many. |
{ "byColumn", "values" } |
One number per value of a column of the row (ladder: gentle 7, firm 1). Every value of the column needs its days. |
{ "setting", "byColumn"?, "index"? } |
Read from a setting when the message is made, and again whenever its inputs move: a number, a list (index, 0–9, picks one), or, with byColumn, lists per value of that column ({ "gentle": [7, 21, 45], … }). |
A due that cannot be worked out (no date yet, a value with no days, a setting that is not there) is left empty. A held message with no due waits for a person: it is never ready and overtakes nothing. A queued message with no due goes at once, so give a producer that queues for later a date it always has.
where is one condition on the source row: { "column", "eq" }, { "column", "in": [values] }
or { "column", "isNull": true|false }, exactly one of the three. before.lead is
{ "via", "table", "column", "fallback"?, "max" }: the number of hours before, read from column
(an int) of the row that via points at, else from the settings fallback, and never more
than max hours (1–336). max is also how far ahead Adminium looks. A reminder is queued with
its moment in columns.due.
A source row produces each kind once, unless its producer says repeat or repeatBy. A reminder
is produced again only when its moment moves; a batched message, once its window has closed. An
undo of a change is heard by the producers of "changed": true alone, so the dates it puts back are
mailed; a { "column", "to" } producer never fires on an undo. Sample data and imports fire no
producer when they are written. A sample row never has a message at all. An imported row is a real one, so the
minute’s look-over still makes its before reminder when the moment comes, and a held producer’s
messages for it (an imported sent invoice gets its reminders).
A message row an import or an undo brings back never goes by itself: nothing here made it and
nobody approved it. One that arrives queued comes in held for a person to approve, or failed
with a sentence saying why where the outbox has no held (queue it again to send it); one that
says it went, with a sent time and no error, comes in sent.
A reply to the rep
Section titled “A reply to the rep”A message about a quote is sent by the business, and the customer’s reply should reach the person
who owns the quote. A producer says so with replyTo:
{ "kind": "quote-sent", "link": "quote_id", "replyTo": { "staff": "deal_id.owner_id" }, "onChange": { "table": "quotes", "column": "status", "to": "sent" } }staff is a staff column of the row the message is about, the one the
producer’s link names: "owner_id". One link further is allowed: "deal_id.owner_id" on a
quote is the owner of the quote’s deal. The validator refuses any other column: a text column that
holds an address is not one, and there is no way to type an address here.
When the message goes, Adminium reads who the column names and looks up the address that person signs in with. The address is read then, not when the message was queued, and it is kept nowhere in the app’s tables. The message carries it as its Reply-To.
Sometimes there is nobody to reply to: the column or the link is empty, the person has been
removed or suspended, has not accepted their invitation yet, or is a sample person. Then the
message carries the house’s own address (settings.replyTo) if the outbox has one, and no
Reply-To otherwise. It is sent either way: a missing rep never holds a message back.
The customer sees the rep’s sign-in address in the reply field of their mail program. Use
replyTo only where that is what you want.
Held messages
Section titled “Held messages”A producer with hold writes its messages held, each with its own due moment, and nothing is
sent until a person approves one. An invoice’s three reminders are made when it is sent, each due
some days after its due date; “ready” means held and due.
{ "kind": "invoice-rung-1", "link": "invoice_id", "hold": true, "onChange": { "table": "invoices", "column": "status", "to": "sent" }, "due": { "date": "due_on", "at": "09:00", "days": { "setting": { "addOn": "invoices", "setting": "ladders" }, "byColumn": "ladder", "index": 0 } }, "supersede": "rungs", "dropWhen": [{ "column": "balance", "lte": 0, "reason": "paid" }, { "column": "status", "eq": "void", "reason": "void" }], "onSent": { "table": "projects", "via": "project_id", "set": { "status": "paused" } } }What a person may do to a message, through any screen or the data API:
- Approve a held one: it becomes
queued. They may rewrite it first:bodyOverrideis sent in place of the template’s blocks, as plain paragraphs, andsubjectOverridein place of its subject.approvedByrecords who approved it. One approved before its day, or with no day worked out, goes at once. - Skip a held or queued one: it becomes
skipped, with the reasonby-hand. - Queue again a failed one.
Only Adminium marks a message sent or failed, and a sent message stays as it was sent. A new
row a person makes starts queued or held.
Once a minute Adminium looks over waiting messages: it re-dates any whose date inputs moved (a new
due date, a changed ladder), skips those dropWhen no longer needs, and skips as overtaken the
earlier messages of a supersede group once a later one has come due. It also makes a held
producer’s message for a watched row in the state that has none of that kind yet (after a crash
between a write and its producer, or for an imported sent invoice). It judges all of this again
just before it sends. skipReason records why a message was skipped: overtaken, paid,
void, no-longer-needed or by-hand.
A held message is made even with no address on file (never for a person who opted out of a
producer that asks optIn): the address is looked up again when it is approved and when it is
sent.
An onSent change is made after the message’s status is saved, as an ordinary write of that
row, and is never a reason to send the message again. It is held to the row’s rules and
states like any write, made by Adminium itself, which holds no role: a move kept for
some roles is refused to it. effectAt records when it was made, or effectError why it was
refused.
emailTemplates
Section titled “emailTemplates”Up to 32 templates. Each is stored as the app’s: an operator can edit it, and an edited template is kept as they left it across updates and uninstalls. A template a new version no longer ships is removed if nobody edited it.
{ "key": "clinic-reminder", "name": { "en-US": "Visit reminder", "de-DE": "Terminerinnerung" }, "vars": ["recipient.first_name", "appointment.starts_at.relative_day", "manage_url"], "locales": { "en-US": { "subject": "Your visit {{appointment.starts_at.relative_day}}", "blocks": [ { "block": "email.text", "data": { "text": "Hello {{recipient.first_name}}, see you {{appointment.starts_at.relative_day}} at {{appointment.starts_at.time}}." } }, { "block": "email.button", "data": { "label": "Manage your visit", "url": "{{manage_url}}" } } ] } }}| Field | Required | Rule |
|---|---|---|
key |
yes | kebab-case, 2–80 characters, starting with the app’s key and - (clinic-reminder). Unique in the manifest. |
name |
yes | A plain string or a keyed label. |
vars |
no | Up to 60 variable names the template reads, for the editor’s list. |
locales |
yes | The template in each language it ships, keyed by BCP 47 tag. en-US is required. |
attach |
no | { "kind", "link", "optional"? }: a document the email carries, drawn by an add-on for the row the outbox link link names (a receipt for a sale). kind is a document kind (see Documents); link is one of the outbox’s links. A message whose document cannot be drawn fails rather than going without it. With "optional": true, a message goes without the document while the add-on that draws its kind is not attached and switched on for the app, and every block marked "data": { "withAttachment": true } is left out with it (“Your receipt is attached”). Any other failure to draw it still fails the message. |
Each language is { "subject", "preheader"?, "blocks", "footer"? }: a subject and a preheader of
up to 200 characters, 1–40 blocks, and a footer of up to 1000. A block is
{ "block", "id"?, "label"?, "data"? }, where block is an email block kind such as email.text,
email.heading or email.button. email.html is refused: its variables are not escaped, and an
app’s emails show values a stranger typed. The install’s check step refuses a block kind the
renderer does not know, or data of the wrong shape.
A template reads variables as {{name}}: each link by its name (appointment.*, and one foreign
key further, such as appointment.clinician.*), recipient.name and recipient.first_name,
practice.*, appName, manage_url and booking_url. A time has the forms .date, .time,
.day_month and .relative_day (“tomorrow”), in the recipient’s language and the venue’s zone. A
number has .number, .percent and .money, written exactly as stored in the message’s language
({{order.tax_rate.percent}} is “8.25%” on every engine, never “8.250”); a choice has .label,
its label in the message’s language ({{order.paid_method.label}} is “Card”, not “card”); a text
column of up to 8 characters holding a time of day has .time, in the reader’s clock (“3:00 PM” in
the US, “15:00” in Britain); a yes/no (bool) column reads “Yes” or “No” in the message’s language
(“Ja”, “Oui”, “Ano”, “نعم”…), in its text and in a list’s rows, never true or 1 (Adminium 0.3.35;
a rule’s e-mail reads it the same way); a code column has .grouped (K7QX-M2PD) and .qr
(see Emails that list rows). A {{was.<column>}} reads a
was column as it was before a change, or as a removed row held it, in the same forms. A
staff column (rules.staff) has .name: the person it names, by the name the people list gives
them ({{visit.rep_id.name}}, {{was.rep_id.name}}, {{row.rep_id.name}} in a list), and nothing
else of them, never their address. Somebody suspended, only invited or removed, and an id that is
nobody’s, read empty, so a block sent onlyWith the name is left out; such a template needs
Adminium 0.3.28 (an older release fails the message). A
date has .day_month and .days_since only: a template asking a column for a form its type does
not have ({{invoice.due_on.date}} on a date column) is refused at install and on update with
EMAIL_TEMPLATE_INVALID, naming the template and the variable.
The guide lists every variable. A message whose email names a variable
nothing fills (a secret column, a personal column of a linked row, a link the row does not have, a
misspelt name) is not sent: it is failed, and its error names the variable. recipient.name and
recipient.first_name are always filled, empty when the recipient has no name on file.
The code a shared link opens a row with (a claim: { "by": "token" } column, a project’s
share_token) opens a page to whoever holds it, so an email carries it only to the person it belongs
to: the message goes to the address the recipient row keeps, and the row with the code is that
person’s row or links to it by the recipient’s via column where it has one (project.client_id),
else by every link it has to the recipient’s table, all naming them. Any other code column (a
booking’s reference) is printed like any other value.
A message addressed by hand to another address, or one linking one client and another client’s
project, is failed with a sentence naming the code. A person or an API key making a message (by
hand, or by an import they started), or queueing a held or failed one to go, may link it only to
rows they can read.
Templates are sent through an outbox or by a rule’s own email or digest step, so a manifest
with no outbox is refused when it has a template no rule sends. The install’s check step warns when the server cannot send email.
Emails that list rows
Section titled “Emails that list rows”An email.rows block lists the rows of a child table that link to the row an outbox link names,
one line each: an order’s dishes, an order’s tickets with a QR code each.
{ "block": "email.rows", "data": { "from": { "link": "order", "table": "order_items", "via": "order_id", "orderBy": "position", "unless": "voided", "limit": 50 }, "joins": { "options": { "table": "order_item_options", "via": "order_item_id", "column": "name", "orderBy": "position", "separator": " · " } }, "row": { "title": "{{row.qty}} × {{row.name}}", "meta": "{{row.options}}", "amount": "{{row.line_total.money}}" }, "empty": "Nothing on this order." } }| Field | Rule |
|---|---|
from |
link, one of the outbox’s links; table, the child table, and via, its foreign key back to the linked row; then orderBy, where ({ "column", "in" }), unless (a bool column that leaves a row out) and limit, at most 50 rows. |
joins |
1–2 lists one level further down, gathered into one text per row under their name (a dish’s options): { "table", "via", "column", "orderBy"?, "separator"? }. column is a text column, never a secret, personal, code, share-code or withheld one. |
row |
The text of each line: title, meta, amount, note, reading {{row.<column>}} in every form the column has, {{row.<link>.<column>}} through one of the row’s own links, and a join by its name. image is a QR code of a code column, {{row.<column>.qr}}, and nothing else; without it the line has no image cell. |
empty |
Said when there are no rows. Without it, the block is left out. |
{{row.…}} is read only inside an email.rows block, and every language of a template lists the
same rows (from and joins are the same in each). A message that lists rows is never sent
without its list: when its table or link can no longer be read (after a rename), the message is
failed (“Not sent: the email lists rows from a table or link that is not there”). An empty list
still sends.
A QR code is the whole value of an image: an email.image block’s qr, written
{{<link>.<column>.qr}} (80–200 pixels across, size), or a row’s image. It is drawn when the
message is delivered, only of a code column, and only for a code of at most 64 bytes. A code the
withhold or share-code rules keep from the message’s recipient is printed
empty, in every form, its QR code included.
Blocks sent only with a value
Section titled “Blocks sent only with a value”A block’s data may carry onlyWith or onlyWithout, the name of one variable the template
reads ("onlyWith": "card.balance"). With onlyWith the block is sent when the variable holds
something; with onlyWithout, when it does not. Empty means missing, blank, or held back from
this reader. The block is dropped before the message is checked, so a variable read only inside a
dropped block may stay empty, and the HTML and the plain text drop the same blocks. A manifest
that uses either sets compatibility.minAdminiumVersion to 0.3.18 or later. A template with
such a block is sent by the outbox only, never by a rule the manifest ships.
A value that is missing
Section titled “A value that is missing”A variable may carry a backup after a bar: {{recipient.first_name|guest}} writes “guest” when
the value is missing or blank, and {{recipient.first_name|}} writes nothing. The backup is plain
text with no braces. A name written with a backup everywhere need not be filled.
A block may be tied to a value beside its block and data: "showWhen": { "var": "stay.note" }
sends it only when that variable holds something, and on an email.text or email.heading block
"otherwise" is the text sent in its place when it does not. Unlike onlyWith, both are read by
the renderer itself, so a template that uses them may be sent by a rule too. A manifest that uses
either sets compatibility.minAdminiumVersion to 0.3.22 or later.
An add-on’s links into an app
Section titled “An add-on’s links into an app”An add-on has no customer side of its own. Its outbox may name routes of whichever app it serves:
"pages": { "app": { "balance": "giftCard" } }app maps a kebab-case name of the add-on’s own to the key of a route an app declares on its
customer side. A template reads it as {{app_url.balance}}: the address of that route in the
first app, by key, that names the add-on, has it connected and switched on, declares the route and
has a customer address. With no such app the variable is empty, so put the button that uses it in
a block marked onlyWith.
Rows of an add-on’s table in an email
Section titled “Rows of an add-on’s table in an email”An email.rows block may list an add-on’s rows for the row a link names, found by the same
pair a document uses:
"from": { "link": "sale", "addOn": "offers", "table": "applications", "match": { "table": "source_table", "row": "source_row" }, "orderBy": "id", "limit": 20 }While the add-on is absent, detached or switched off the list is empty and the message still goes
(the block’s empty text is said, or the block is left out). A match column the add-on’s table
no longer has fails the message with a reason.
Public access
Section titled “Public access”publicAccess says what the app’s public screens may do. Each entry becomes an endpoint of the
public API on the real table, served through one of the
app’s browser keys and marked as the app’s: switching the app off stops it and uninstalling
removes it. Up to 64 entries.
An entry is served through the app’s customer key unless it names another in key. The install
creates one key for customer and one for each name in publicKeys. A key the
operator revoked is not made again by an update.
Every update takes back what its version no longer declares: each of the app’s keys loses the
entries dropped (the customer key is kept, holding nothing if nothing is left), and a key whose
name the version no longer lists in publicKeys is revoked. What a version adds — an entry, a key,
or a staff screen’s key turned into a shared link’s — is given only when the operator allows it on
the update’s check, which sends "publicAccess": true to POST /api/v1/apps/{key}/update.
"publicAccess": [ { "table": "booking_rules", "methods": ["GET"], "select": ["opens", "closes", "slot_minutes"] }, { "table": "reservations", "kind": "availability", "methods": ["GET"] }, { "table": "reservations", "methods": ["POST"], "select": ["id", "code", "starts_at", "status"], "writable": ["party_size", "starts_at", "name", "mobile", "email"], "defaults": { "status": "confirmed", "channel": "online" }, "confirm": { "template": "booking-confirmation", "to": "email", "code": "code", "when": "starts_at", "link": "manage?code={code}" } }, { "table": "reservations", "methods": ["GET", "PATCH"], "claim": { "match": ["code", "mobile"] }, "writable": ["starts_at", "party_size", "status"] }]| Field | Required | Rule |
|---|---|---|
table |
yes | One of the app’s table refs. |
methods |
yes | At least one of GET, POST, PATCH. PATCH needs a claim, a claimedBy that is not optional, or a visibleWith. |
kind |
no | records (the default) or availability. |
key |
no | The browser key that serves the entry: customer (the default) or a name in publicKeys. |
module |
no | The part of the app the entry belongs to: while that part is off the entry answers as one that is not there. Needs Adminium 0.3.28. |
select |
no | The columns a response carries. Default: every column the app declares for the table. An entry with claimedBy must list them. |
writable |
no | The columns a create or change may set. Never a column whose value Adminium decides. |
writableValues |
no | { "<column>": [1–32 values] }: the only values a browser may write into a writable column, on a create or a change. Each value must fit the column. |
writableWhen |
no | The state a row must be in to be changed, per column: [1–32 values], where null stands for “still empty” (on a nullable column); "from-now", a timestamptz still ahead; { "within": <minutes> }, a timestamptz no more than that many minutes ahead (1–1440; a past time always passes; at most one per entry); "from-today", a date of today or later on the venue’s calendar; "before-today", a date already past; or a window on a moment, { "after"?, "before"?, "where"? }, see Windows on a moment. Needs PATCH, except a window keyed by a visibleWith link on a create. |
requires |
no | 1–8 writable columns every write through the entry must fill: accepting a proposal carries the name typed as its signature. |
requiresWhen |
no | { "<column>": { "<other column>": [values] } }, 1–8 columns: a writable column a write must fill when it sets the other column to one of the values, and may write at no other time. See A value asked for with a choice. |
filters |
no | Rows the endpoint can reach at all. See Filters. |
defaults |
no | Values the server writes whatever the browser sends. |
claim |
no | How a person proves who they are: by a row’s details, by a link emailed to them, or by a token. Makes the entry its key’s identity. See A person’s own rows. |
claimedBy |
no | { "table", "column", "optional"? }. The entry reaches only the rows of the person its key’s identity claimed. |
visibleWith |
no | { "table", "via" }. The entry reaches a row only where the entry on table (on the same key) reaches the row it belongs to: an invoice’s lines are exactly as visible as the invoice. See Rows visible with their parent. |
level |
no | lookup (the default) or verified: the session the entry needs. Only with claimedBy or visibleWith. |
files |
no | 1–8 text columns holding a file, which a signed-in person may download: the file the row names, never one by its id. Each is in select. Needs a claim, claimedBy or visibleWith. |
documents |
no | 1–8 document kinds (see Documents) a signed-in person may list and open for the rows the entry reaches. Needs a claim, claimedBy or visibleWith. |
sensitive |
no | Whether the rows need a verified session to see. See A person’s own rows. |
reason |
with sensitive: false |
Why the entry is not sensitive, 1–200 characters. Only with sensitive: false. |
onClaim |
no | { "clear": [1–12 columns] }: on an entry whose claimedBy is optional, the nullable columns a signed-in create empties (the name and number a first visit would type). |
maxOpen |
no | { "column", "values", "n", "upcoming"? }: a claimed person may hold at most n (1–50) rows whose column holds one of values. With upcoming, a timestamptz, only rows still ahead count. Needs claimedBy and POST. |
rank |
no | { "orderBy", "where"? }: a create also answers where the new row stands, as the number of rows ordered by orderBy at or before it. where is { "column", "eq" }. Needs POST. |
humanCheck |
no | true: the browser solves a small proof of work before a create or a claim is taken. Only on an entry that creates or claims. |
anonymous |
no | Limits on a create nobody signed in for. See Limits on a stranger’s create. |
requireSetting |
no | Up to 4 { "table", "column", "when"? }, each a bool of the settings table. While one is false, every write through the entry is refused. |
confirm |
no | An email Adminium sends when a guest creates a row; needs POST. See below. |
children |
no | The rows a create carries with it, by child table, two levels at most. See A create with its child rows. |
agrees |
no | 1–8 checks the created row’s own values must pass (guests no more than a room sleeps). See A create with its child rows. |
dryRun |
no | true: the create or change may be tried without writing, to see every figure Adminium would work out. See Dry runs, price checks and retries. |
expect |
no | A money column Adminium works out, which the write may send its expected value for; a different figure writes nothing. |
clientKey |
no | A column holding a key the browser mints, so a retried create lands on the same row. |
identity |
no | On a create, the person it is made for, found or made by the address typed. See A person found by address. |
shareLink |
no | A share-code column: a create answers, once, the new row’s own link. See A row’s own link. |
newLink |
no | { "column", "kind", "when"? }: “Make a new link” for a signed-in person’s row, or “Send it again” through a row’s own link. See A row’s own link. |
forget |
no | On an identity entry: what “delete my details” empties. See Delete my details. |
withhold |
no | Columns left out of a row for some readers. See Withheld columns. |
limits |
no | Limits on a change a guest makes. See Limits on a guest’s change. |
unlockBy |
no | Rows readable only with a code that unlocks them. See Codes that unlock rows. |
pictures |
no | 1–4 image columns any visitor may see. See Pictures. |
rule, showLeft, under |
no | On an availability entry: which limit it answers, whether it says what is left, and the column a page asks by. See Availability. |
writableValues and writableWhen pin both ends of a change: status may become cancelled,
and only while it is booked. writableWhen is part of the change itself, never of a read, so a
finished visit still lists but cannot be moved; a change to a row in any other state answers as if
the row were not there. An entry with claimedBy that changes an enum column must list its
writableValues: permission to cancel is not permission to mark a visit seen.
A value asked for with a choice
Section titled “A value asked for with a choice”One entry often carries two answers: a customer accepts a quote, typing a name, or declines it,
typing none. requires would ask for the name on both. requiresWhen asks for it only with the
choice it belongs to:
{ "table": "quotes", "key": "quote", "methods": ["GET", "PATCH"], "claim": { "by": "token", "column": "share_code", "own": true, "address": "sent_to_email" }, "select": ["number", "status", "valid_until", "total"], "writable": ["status", "accepted_name"], "writableValues": { "status": ["accepted", "declined"] }, "writableWhen": { "status": ["sent"] }, "requiresWhen": { "accepted_name": { "status": ["accepted"] } } }- A write that sets
statustoacceptedmust fillaccepted_name; without it the write is refused400PUBLIC_WRITE_REFUSEDwith{ column }, as forrequires. - A write that does not set
statustoacceptedmay not writeaccepted_nameat all: a name sent with a decline, or on its own, refuses the whole write. A declined quote never holds a name a stranger typed. An empty value sent along is left out, and empties nothing. - Both columns are
writable, the other column lists its values inwritableValues, and each value named is one of them. A column is inrequiresor inrequiresWhen, not both.
Hold the typed column to plain text as well: a column’s own
plainText rule, or the entry’s limits.plainText, keeps a name to a name (no
link, no markup, no digits) and to its length. Needs Adminium 0.3.28.
A null in writableWhen pins a value that may be written once: "signed_name": [null] takes a
signature while the column is still empty, and never changes it after. The calendar words pin a
date: a proposal still in date may be accepted ("valid_until": "from-today"), and one past it may
ask for a new price ("before-today"). "before-today" takes a date column only, never a time,
and the same entry may not write that date: a caller who could move it forward would put the row
back in date on its old terms.
A window lets a change wait for its time: "starts_at": { "within": 60 } takes a check-in up to an
hour before the visit, or any time after it. A change asked for earlier is refused 409
PUBLIC_TOO_EARLY, and the reply’s params carry at, the row’s time, and from, when the window
opens, both as instants — even when select leaves the column out; naming the window agrees to
that. The refusal is said only for a row the caller’s own read reaches, with every other state in
writableWhen met; any other miss answers as if the row were not there.
confirm takes template (only booking-confirmation today), to (the column holding the
guest’s address) and optionally code, when, party and name (columns the email shows),
venue ({ "table", "name"?, "address"?, "phone"? }, the app’s one-row venue table) and link
(a path under the customer side, up to 200 characters).
A file in files stays private: a browser downloads it only at
GET /api/v1/public/files/<ref>/<row>/<column> (<ref> is the entry’s endpoint), which reads the
row exactly as the entry’s list would (its filters, its claim, its parent), then serves the file
that column names, from the key’s own database. A row the session cannot reach, an empty column
or a link to somewhere else serves nothing (404), and no other public route serves a file.
An availability entry answers free or full and never returns a row. It is GET only, and the
table must declare a capacity or a booking. On a capacity table it
answers by the limit’s kind; see Availability. On a booking table it answers the
times of a day, or a strip of up to 31 days, for a kind and optionally a person; a guest is offered
only people bookable online, and is never told who. An availability entry reads no rows, so it is
never the parent another entry is visible with.
The install’s check step lists every endpoint it will create, and warns about anything that would stop the key working, such as the public API being off or no time zone set on the database.
Filters
Section titled “Filters”A filter limits every read and every write of the entry.
| Filter | Rows it keeps |
|---|---|
{ "column", "op", "value" } |
op is eq, neq, in, gte or lte. |
{ "column", "op": "today" } |
Rows whose date or time falls on today. |
{ "column", "op": "from-today", "days"? } |
Rows from today onwards; days (1–366) limits how far, today included. |
today and from-today need a date or timestamptz column, and are worked out on every
request in the venue’s time zone. A filtered column can be writable only when both
writableWhen and writableValues pin it; otherwise a write could move the row out of the
endpoint half-way.
Windows on a moment
Section titled “Windows on a moment”A writableWhen entry may open a change only inside a window read from moments: a
refund until seven days before the show, a guest’s own change of their stay until the cancel-by
time.
"writableWhen": { "status": ["booked"], "cancel_by": { "before": {} }, "event_id": { "before": { "column": "starts_at", "minus": { "days": 7 } }, "where": [{ "column": "refunds_on", "eq": true }] }}The key says what the window is read from:
- A date or time column of the row (
cancel_by): the window’s moments are that column’s, so each end names no othercolumn; it may add atime, a shift andorfallbacks. - A foreign key of the row (
event_id): each end names acolumnof the linked row, andwhere(1–8 conditions) must hold on that row too (refunds switched on for the event).
after and before are the window’s ends, either or both. The window is judged on the row as
it is stored before the write, so a change that moves the time cannot reopen its own window, and a
guest may never write a column that opens their own window, a time kept on the row included (an
arrival_time a moment reads). A change asked for too early is refused 409 PUBLIC_TOO_EARLY
with params.at (the row’s time) and from (when the window opens); one too late, 409
PUBLIC_TOO_LATE with at. As with within, the refusal is said only for a row the caller’s own
read reaches.
A create entry that is visible with its parent (POST, no
PATCH) may carry a window keyed by its visibleWith link, with the parent’s moments at each end:
an extra may be added to a stay only until its cancel-by time. It is judged in the create’s
transaction, on the parent.
A person’s own rows
Section titled “A person’s own rows”An entry with claim is its key’s identity. A caller proves who they are and gets a session on
that row. Each key has at most one identity. A claim takes one of three forms.
By the row’s details: { "match", "verify"?, "email"? }. The caller proves they know a row’s
details (a mobile number and a date of birth).
| Field | Rule |
|---|---|
match |
1–3 columns the caller must match. |
verify |
"email-code": the session starts at lookup, and a code emailed to the row’s own address raises it to verified. |
email |
With verify only: the text column holding the address. |
By a link emailed to the person: { "verify": "email-link", "email" }. The person types only
their address, and Adminium emails a one-use link (and a code, for another device). The session
opens at verified from the link; a typed address alone opens nothing. email is the text
column holding the address. The entry asks the human check ("humanCheck": true), because anyone
can type an address, and every entry its sessions read says level: "verified".
The link opens a page that asks the person to continue and greets them by first name, before
anything is proved. It shows the first word of one column and nothing else: the name the
outbox’s recipient.name declares, when the recipient lives in the same table; with no
outbox recipient there, the first text column in the entry’s select that is not email. It
never reads a number, a date, the address, a masked or secret column, or a column the entry does
not select. A declared name the entry does not select greets nobody by name, so list it in
select (a client portal that shows ["id", "company", "contact_name"] greets by
contact_name, not by company).
By a token: { "by": "token", "column", "expires"?, "stopped"? }. An unguessable code in a
column opens that one row, with no email at all: a handover page shared by link. column is a
text column with a code rule of length 16, so Adminium fills it. expires
is a date or timestamptz after which the link opens nothing; stopped a bool that switches
it off. A token opens its row to whoever holds the link, so it is served on a
key of its own that no staff signs in, and every entry on that key only reads
(GET) — unless the token is the row’s own link ("own": true, with address), which opens a
verified session that may change the row; see A row’s own link.
A session opened by a token is checked against the row on every request: once the row is
stopped, past expires, or given a new code, the link and every session it opened reach nothing,
at once. Nobody types a code, not staff and not an import. Staff who read the table see the code
and can copy the link: the install says the column is no secret, on a table it made (on a table
it reuses, it stays whatever it was). No entry of its table shows it, filters or orders by it — the
one the link opens or any other, under any key — so a select, a filter or a rank that names the
column is refused, and an endpoint or a generated one added later is held to the same. To make a
new link, staff with
update on the table call POST /api/v1/data/<connection>/<table>/<record>/regenerate-code with
{ "column": "share_token" }: a fresh code is written, and the old one never opens anything
again. The answer carries the new code only to a caller who may also read the table.
An entry with claimedBy reaches only the claimed person’s rows. table is the table its key’s
identity claims. column is a foreign key of this entry’s table pointing at it, or that table’s
own primary key when the entry is on the identity’s table. The column is filled from the session,
so it cannot be writable. With optional: true, a create goes through with no session at all:
a first visit by someone not yet on file. Only an entry whose methods is exactly ["POST"] may
be optional. An identity takes no claimedBy.
"publicAccess": [ { "table": "patients", "methods": ["GET"], "select": ["name"], "claim": { "match": ["mobile", "date_of_birth"], "verify": "email-code", "email": "email" }, "sensitive": true, "humanCheck": true }, { "table": "appointments", "methods": ["GET", "PATCH"], "claimedBy": { "table": "patients", "column": "patient_id" }, "level": "verified", "sensitive": false, "reason": "Times and visit types only, no clinical notes.", "select": ["id", "starts_at", "visit_type_id", "clinician_id", "status"], "writable": ["status"], "writableValues": { "status": ["cancelled"] }, "writableWhen": { "status": ["booked"], "starts_at": "from-now" } }, { "table": "appointments", "methods": ["POST"], "claimedBy": { "table": "patients", "column": "patient_id", "optional": true }, "sensitive": false, "reason": "A new booking answers with its own time only.", "select": ["id", "starts_at"], "writable": ["starts_at", "visit_type_id", "clinician_id", "new_name", "new_mobile", "new_email"], "defaults": { "status": "booked" }, "humanCheck": true, "maxOpen": { "column": "status", "values": ["booked"], "n": 2, "upcoming": "starts_at" }, "onClaim": { "clear": ["new_name", "new_mobile", "new_email"] }, "anonymous": { "perValue": { "columns": ["new_mobile", "new_email"], "n": 2 }, "perKeyHour": 30, "plainText": ["new_name"] }, "requireSetting": [{ "table": "settings", "column": "online_booking" }, { "table": "settings", "column": "new_patients_online", "when": "anonymous" }] }]The rules that tie these together:
- Levels. An entry with
level: "verified"refuses alookupsession withPUBLIC_CLAIM_LEVEL, and needs an identity that sends a code or a link. A code is six digits, lasts 10 minutes and allows 5 tries; a verified session lasts 30 minutes. See The emailed code. - Sensitive rows. An identity marked
sensitive: trueshows alookupsession only its ownselect, and must send a code. EveryclaimedByentry of its key must then saysensitiveeither way:trueneedslevel: "verified", andfalseneeds areason. - The address. Where a claim sends a code, no entry may write the identity’s
emailormatchcolumns or create rows on its table: a session could otherwise send the next code to itself. The install refuses such an endpoint. - The human check. A
claimedByentry withhumanCheckneeds an identity withhumanChecktoo, or claiming first would skip the proof. A found person’s own create on an entry withmaxOpenasks no proof. See The human check. maxOpencounts only for a signed-in create, and refuses the next one withPUBLIC_LIMIT_REACHED.
For the whole flow, see A person’s own rows.
Rows visible with their parent
Section titled “Rows visible with their parent”An invoice’s lines and payments are not the person’s own rows by a column of their own: they are
the invoice’s. visibleWith reaches them through the entry that reaches the invoice, so a draft’s
lines stay as hidden as the draft.
{ "table": "invoices", "methods": ["GET", "PATCH"], "level": "verified", "claimedBy": { "table": "clients", "column": "client_id" }, "filters": [{ "column": "status", "op": "in", "value": ["sent", "void"] }], "select": ["number", "total", "balance", "client_paid_at"], "writable": ["client_paid_at"], "writableWhen": { "client_paid_at": [null] }, "documents": ["invoice"] },{ "table": "invoice_lines", "methods": ["GET"], "level": "verified", "visibleWith": { "table": "invoices", "via": "document_id" }, "select": ["description", "qty", "rate", "amount"] },{ "table": "payments", "methods": ["GET"], "level": "verified", "visibleWith": { "table": "invoices", "via": "document_id" }, "select": ["number", "amount", "paid_on"], "documents": ["receipt"] }| Field | Rule |
|---|---|
table |
The parent’s table. Exactly one other entry on the same key reads it with GET: that entry is the parent. |
via |
This table’s foreign key to the parent, or the parent’s foreign key to this table. |
A row is reached only where the parent entry reaches the row it belongs to, with the parent’s
filters and claim. An entry may be at most two steps from the entry its person claims (a
project’s versions, through the project), and the chain must lead to a claimed person. An entry
with visibleWith takes no claim or claimedBy. It may PATCH the rows it reaches, naming
what it may write in writable, but not when a parent on its way up is on its own table: one
statement cannot change such a row on every database. On a key whose identity signs in by an
emailed link, it says level: "verified" like every other entry.
Where via is the parent’s foreign key (a proposal naming its terms), that column is the link the
child’s rows are read by. No entry on the key that creates or changes the parent’s rows may write
it, choose its values or fill it by default (writable, writableValues, defaults): a browser
that could would re-point its own row at another person’s child and read it. An entry that only
reads writes nothing, so it does not count.
A create with its child rows
Section titled “A create with its child rows”A create may carry the rows that belong to it, two levels at most, in one write: an order, its
lines, and each line’s options. children maps each child table to what the browser may send for
its rows.
{ "table": "orders", "methods": ["POST"], "select": ["id", "number", "pickup_at", "subtotal", "tax", "total"], "writable": ["name", "email", "pickup_at", "note", "client_key"], "requires": ["name", "email"], "humanCheck": true, "children": { "order_items": { "via": "order_id", "writable": ["menu_item_id", "qty", "note"], "select": ["id", "qty", "unit_price", "line_total"], "requires": ["menu_item_id"], "position": "position", "min": 1, "max": 40, "plainText": ["note"], "sumMax": { "column": "qty", "max": { "table": "settings", "column": "max_items" } }, "children": { "order_item_modifiers": { "via": "order_item_id", "writable": ["modifier_id"], "select": ["id", "name", "price"], "max": 20, "agrees": [{ "column": "modifier_id", "path": ["group_id", "item_id"], "eq": { "parent": "menu_item_id" } }], "counts": [{ "by": ["modifier_id", "group_id"], "every": { "column": "item_id", "eq": { "parent": "menu_item_id" } }, "min": "min", "max": "max" }] } } } }, "dryRun": true, "expect": "total", "clientKey": "client_key" }Each child table, 1–4 at each level, takes:
| Field | Rule |
|---|---|
via |
The child’s foreign key to the row it belongs to. |
writable |
The columns a child row may set. Never a column Adminium decides, nor a link to the people who sign in (Adminium fills it). |
select |
What the reply shows of each child row; without it, the child’s key only. |
defaults, writableValues, requires |
As on the entry itself, for the child’s rows. |
position |
A whole-number column Adminium numbers 1, 2, 3… in the order the rows were sent. |
min, max |
Rows per parent row: max 1–200, min no more than max. |
agrees |
1–8 checks that tie a row’s values to its parent or to what it points at (below). |
counts |
1–2 limits on how many sibling rows fall in one group (below). |
plainText |
1–8 writable text columns that hold plain text only, as a guest’s change does: no digits, no web address and no @ handle. A column may be { "column": "note", "digits": 4, "max": 140 } instead: up to 4 digits in all, up to 200 characters. |
sumMax |
{ "column", "max" }: the most a number column may add up to across the rows of one write (a dozen items to an order), max a number or a whole-number setting. |
children |
One more level, the same shape without children of its own. |
An agreement is { "column", "path"?, "when"?, <one of "eq", "lte", "gte">: target }: the row’s
column, followed along path (1–3 foreign keys, the last naming the column compared), equals, is
at most or is at least the target. The target is { "parent", "path"? }, a column of the row it
belongs to; { "via", "column" }, a column of a row this row points at; or { "value" }. when
({ "path"?, "in" }) applies it only to rows whose value is one of in. On the entry itself,
agrees checks the created row’s own values (guests no more than a room sleeps), with no parent
target. A root agrees is held on staff writes to the table too: creates and changes at the desk,
single, bulk or with links. An import is history, and is not held.
A count is { "by", "every"?, "min", "max" }: by follows the row’s foreign keys to its group
(an option’s group), whose whole-number min and max columns bound how many sibling rows fall in
it. every ({ "column", "eq": { "parent" } }) names the groups judged even when no row falls in
them, those whose column equals the parent row’s (a required size left out).
A guest’s number that feeds a price Adminium works out (a line’s qty) declares
validation.min (at least 0) and validation.max. A create anyone may make with child rows asks
the human check once, for the whole write. Everything is checked by Adminium, never trusted from
the browser, and the whole write is kept or nothing is. For the wire, see
An order with its lines.
Dry runs, price checks and retries
Section titled “Dry runs, price checks and retries”dryRun: true lets a page ask for the same create or change without writing: the reply carries
every figure Adminium would work out (the totals, a stay’s nights, the stamps it would decide), and
refuses what the save would refuse. A quote takes no locks, keeps nothing and is never charged
against a stranger’s limits. A row visible with a parent is
created alone, so a quote there belongs to a change.
expect names a money column Adminium works out (a decimal or money column that a rule decides),
which the entry shows in select. A write may send the figure the guest was shown ("expect": { "total": "34.10" }); when the save comes to another figure, nothing is written and the answer is
409 PUBLIC_PRICE_CHANGED, with params.total and, on a create, each line’s figures
(params.lines).
clientKey names a text column, unique and writable, that holds a key the browser mints for
the create and sends as that column’s value: 22–64 letters, digits, - or _. A retry of a create that already landed (a reply lost to a dropped connection) answers
the same row, marked replayed, even after the entry’s switch was turned off; a new create with no
matching key is refused as before. Adminium keeps only a keyed hash of the key, 43 characters: give
the column room for it. No entry shows, filters or orders by a retry-key column. clientKey belongs
to a create, and not to a row visible with a parent.
A person found by address
Section titled “A person found by address”A guest checking out types their address. identity finds the person it belongs to, or makes one,
and links the new row to them; the guest is never told which happened.
"identity": { "table": "customers", "email": "email", "link": "customer_id", "fill": { "name": "name" } }| Field | Rule |
|---|---|
table |
The people table: its identity entry signs people in by an emailed link. |
email |
The text column of this entry’s row the address is typed into: writable, in requires, with validation.format: "email". The people table’s address column is unique, nullable (it is emptied when a person is forgotten), kept normalize: "email", and at least as wide. |
link |
This row’s nullable foreign key to the person. Adminium fills it; it is never shown, since it would tell whether the address was on file. |
fill |
Up to 4 { "<people column>": "<this row's column>" }: a new person’s text columns, filled from what the guest typed. Never a column Adminium decides, a secret, or a unique one. |
on |
{ "to": "<state>" }, on a change through a row’s own link: find the person only on the save that moves the row to that state (a ticket accepted), not on every save made while it has no person. |
A person is found by address whatever its case, and a second person is never made for an address
stored in another case. The entry asks the human check and limits creates per address
(anonymous.perValue counting email); a signed-in guest’s create links them as it always has
(claimedBy with optional: true). The people table may carry no running number, no limit and no
message on create: each would tell a stranger’s order apart from its owner’s. Nothing on the row
reads through link.
A row’s own link
Section titled “A row’s own link”A row’s own link opens that one row to its owner: an order’s confirmation link, a ticket sent
to a friend. It is a token claim with "own": true, served on a key of its
own:
"publicKeys": { "ticket": {} },"publicAccess": [ { "table": "tickets", "key": "ticket", "methods": ["GET", "PATCH"], "claim": { "by": "token", "column": "link_token", "own": true, "address": ["pending_email", "holder_email"] }, "select": ["id", "status", "code", "pending_name", "offer_until"], "withhold": { "columns": ["code"], "when": { "where": [{ "column": "holder_customer_id", "isNull": true }] } }, "writable": ["status"], "writableValues": { "status": ["valid"] }, "writableWhen": { "status": ["offered"] }, "identity": { "table": "customers", "email": "pending_email", "link": "holder_customer_id", "on": { "to": "valid" } } }]- Handed once. A create entry’s
shareLinknames the row’s own-link column: the create answers the new row’s link, once, to whoever made it. Otherwise the link is only ever emailed, to the row’s own address:addressnames the 1–2textcolumns it may go to (the holder’s, and the friend it is offered to). - Verified. The link opens a
verifiedsession, and the key’s entries may change the row within theirwritablelist; every entry on the key is read atlevel: "verified". A link that opens nothing (stopped, expired, renewed) answers410LINK_EXPIRED. - Bound to its address. When the address it was sent to changes, the sessions it opened stop.
An entry whose change writes one of the
addresscolumns must renew the link’s code on that change:rules.code.renew.on{ "column": "<address>", "changed": true }. No browser writes the link’s own column, itsexpiresor itsstopped.
newLink: { "column", "kind" } on an entry that reads a signed-in person’s rows (claimedBy) lets
the person ask for a fresh link: POST /api/v1/public/records/<ref>/<id>/new-link. The row’s own
link gets a new code, which stops every session the old one opened, and the new link is emailed as
the app’s outbox message kind, sent once per code. The outbox needs a repeatKey column, a link
to the table, and its recipient table must be the claimedBy table. Only a verified sign-in may
ask, five times a day per row; a second ask within a minute changes nothing; when the email cannot
go, the answer is 503 PUBLIC_CODE_UNAVAILABLE. See
Guests, their details and their own links.
On a row’s own-link entry, newLink makes another own link of the row again (“Send it again” for a
confirmation email): column is a code another key opens this table by with own: true, never the
entry’s own. The email goes to the row’s own address, as the outbox’s other messages about the row,
and the asking session stays open. The address is the one the kind’s own producer uses, read from
the row, and no entry may let a guest change it. Here when: { "where": [conditions] } is required
(it is optional on a signed-in entry): the ask goes only while the row holds every condition, and
never while that link is stopped; otherwise it is refused 409 PUBLIC_WRITE_REFUSED, with nothing
made, sent or counted. Besides 5 a day per row, it is 5 a day per mailbox over the whole table.
Delete my details
Section titled “Delete my details”forget on an identity entry that signs people in by email lets a person delete their details
(DELETE /api/v1/public/account, with a code confirmed or a link pressed in the last ten minutes,
else 403 PUBLIC_CODE_STEP_UP).
"forget": { "columns": ["name", "email", "phone"], "stamp": "forgotten_at", "links": true }columns lists 1–16 nullable columns of the person’s row that are emptied, the address they sign
in with among them; never the key, nor a column Adminium decides. stamp is a nullable
timestamptz written with the time they were forgotten. With "links": true, every own link of the
rows the person holds is renewed first and the sessions those links opened are ended (a device
learns it by the x-adminium-session-ended: forgotten header, once); if that cannot be done,
nothing is forgotten. A message about a forgotten person is not sent, even one queued before they
asked. Every session the person holds can also be ended without forgetting anything:
POST /api/v1/public/session/revoke-all.
Reads for a signed-in guest
Section titled “Reads for a signed-in guest”An entry with a level and no claim, claimedBy or visibleWith reads its table’s rows (within
its filters) for a signed-in session of its key alone, never for a stranger: a hotel’s rate cards
shown to a guest who has booked. It only reads, lists what it shows in select, and says the
level its key’s sessions are.
Withheld columns
Section titled “Withheld columns”withhold leaves some columns out of a row for some readers: a ticket sent on to a friend keeps
its code from the buyer who sent it, and tickets of an order not yet paid show no code at all.
"withhold": { "columns": ["code", "holder_email"], "unlessHolder": "holder_customer_id", "when": { "linked": [{ "via": "order_id", "where": [{ "column": "status", "in": ["held", "awaiting_transfer"] }] }] } }| Field | Rule |
|---|---|
columns |
1–8 columns of the entry’s select, each once. |
unlessHolder |
On rows read through a parent (visibleWith or claimedBy): a foreign key to the person the key signs in. The columns are left out while it names someone other than the reader. |
when |
{ "where"?, "linked"? }: the columns are left out while the row, or a row one of its links points at, meets the conditions. |
A withhold names unlessHolder, when, or both; either one holding leaves the columns out. A
when holds for readers of the key the declaring entry is served through; mail and documents drawn
for a person read as the customer key, and a message to an address column as the row’s own link
whose address names that column. A reader of no key at all (a document drawn for nobody) meets
every when about the row’s state, and none declared through a row’s own-link key. On a row’s own
link, which names nobody, a withhold says when alone. No browser writes a column a withhold’s
conditions read.
The columns are left out of every public read of the table, whichever entry declared the rule:
lists, one row, a change’s reply, a replayed create, files, emails (values, rows and QR codes) and
documents. Filtering, searching or sorting by one is refused 400 PUBLIC_QUERY_REFUSED; a file
in one is served to its holder only; an email’s joins and a document’s lists never name one.
Limits on a guest’s change
Section titled “Limits on a guest’s change”limits holds a change a guest makes, signed in or not, on a PATCH entry: a ticket sent on to
a friend’s address.
"limits": { "perValue": { "columns": ["pending_email"], "n": 5 }, "plainText": ["pending_name"] }perValue ({ "columns", "n" }, 1–4 text columns, 1–20) allows each value at most n changes a
day that write it: an address a ticket is sent on to. plainText lists 1–8 writable text columns
that hold plain text only: letters, spaces and sentence punctuation (Latin, CJK and Arabic), up to
80 characters, no digits, no web address (a known ending such as .com, or a /) and no @
handle; “Mary.Ann”, “J.R.R. Tolkien” and “St. John” pass. A column given as
{ "column", "digits", "max" } takes up to digits (1–4) digits and max (at most 200)
characters. A stranger’s create’s
plainText is the same rule. A limit names perValue, plainText, or both. Over a limit is 409
PUBLIC_LIMIT_REACHED; a value that is not plain text is 400 PUBLIC_WRITE_REFUSED.
Codes that unlock rows
Section titled “Codes that unlock rows”unlockBy makes an entry’s rows readable only with a code that unlocks them: a presale code that
reveals its hidden ticket type.
{ "table": "ticket_types", "methods": ["GET"], "select": ["id", "name", "price"], "unlockBy": { "table": "presale_codes", "column": "code", "link": "ticket_type_id", "where": [{ "column": "active", "eq": true }] } }A row of table whose column holds the typed code, and whose link points at the entry’s row,
unlocks it. column is compared as a typed code (normalize: "code", or a code
column) and finds one row; where takes the same conditions as a lookup’s. The entry only reads,
and is its own entry: no claim, no parent, not an availability entry. The code travels in the
x-adminium-code request header, never in the address: ?code= is refused 400
PUBLIC_QUERY_REFUSED.
A row may also open with its own code — a gift card’s balance, read by whoever types the card’s code:
{ "table": "cards", "methods": ["GET"], "select": ["balance"], "unlockBy": { "header": true, "column": "code", "self": true, "length": 12, "where": [{ "column": "status", "eq": "active" }] } }column is a code column of the entry’s own table, and the entry never shows it. length is
needed, 10 to 16: the code is all that keeps a stranger from the row, and a shorter one is found
by trying. A typed code of any other length — counted without the column’s own prefix — opens
nothing and is never looked up. A wrong code here is counted apart from wrong discount codes: five a minute for a
visitor, sixty for the key. Needs Adminium 0.3.18.
Pictures
Section titled “Pictures”pictures lists 1–4 image columns any visitor may see through the rows an entry reads: a dish’s
photo on the menu, a room type’s picture.
{ "table": "menu_items", "methods": ["GET"], "select": ["id", "name", "price", "photo"], "pictures": ["photo"] }Each is a text column with semantic: "image" (a picture column’s file rule beside it, or none), in select, never writable, never personal or
kept from readers, and not one of the entry’s files. The entry only reads rows, on the app’s
customer key. A picture is served at GET /api/v1/public/pictures/… only for the row it is
attached to, cleaned once and kept beside its file, and only up to 5 MiB (5242880 bytes). So a
column listed here takes no larger file: an upload or a chosen file above 5 MiB is refused, saying
customers see the column’s pictures, whatever the column’s own maxBytes says. A file rule with a
larger maxBytes is a warning. See
Pictures on public pages.
Availability
Section titled “Availability”An availability entry on a table with a capacity answers by the limit’s kind:
| Kind | A page asks | The answer |
|---|---|---|
slot |
A day (or a run of days) and a party | Each slot, free or full. |
parent |
The pools under a row (under: a column of the pool’s rows, such as an event’s ticket types) |
Each pool, on sale or sold out. |
night |
Arrival and departure, and the guests | Each pool that has a room on every night. |
| Field | Rule |
|---|---|
rule |
Which of the table’s limits it answers, 0–2 (absent: the first). |
showLeft |
{ "below": n } or { "belowShare": 1–100 }: say how many are left, but only when little is (below a number, or a share of the pool). A parent or night limit only. |
under |
A parent limit only: the column of the pool’s rows a page asks by. |
A night rule counting one unit per row (size: 1) has no pool to answer. A slot rule whose
amount is a column needs that column’s validation.max: a party asked about is capped at it, or
a page asking ever larger parties would learn how full each time is. Parent and night answers read
the pool’s rows, so the key also needs a plain read entry on the pool’s table. Rows of the
session’s own open hold are left out of a count when asked (exclude); ids outside the session
are ignored. A code that unlocks a hidden pool travels in the x-adminium-code header. For the
query parameters, see the REST API.
An entry may instead be answered by an add-on: "words": "<add-on key>:<words id>" names one of
its stock words, and each row asked about is in, low or out. The add-on is one
the manifest names.
Limits on a stranger’s create
Section titled “Limits on a stranger’s create”anonymous limits a create that nobody signed in for: an entry with no claim at all, or an
optional claimedBy create made with no session. It needs POST.
| Field | Rule |
|---|---|
perValue |
{ "columns", "n" }: at most n (1–20) creates a day for one phone number or address in any of these text columns (1–4), through any key or page. A phone number counts by its last nine digits, and an address in lower case, so two spellings of one number are one number. |
perKeyHour |
At most this many (1–1000) such creates an hour through the key, from everyone. |
perIpHour |
At most this many (1–60) such creates an hour through this entry from one visitor (an IPv6 subscriber’s whole /64). Every visitor is held to 60 an hour on any key; this only lowers it. |
plainText |
1–8 text columns that hold plain text only: letters, spaces and sentence punctuation (Latin, CJK such as ,。 and Arabic such as ،), up to 80 characters, with no digits and no web or email address in its common forms (a known ending such as .com); “Mary.Ann”, “J.R.R. Tolkien” and “K.Y.Ng” pass. A column given as { "column": "note", "digits": 4, "max": 140 } takes up to digits (1–4) digits in all and up to max (at most 200) characters (Plain text). The same rule as a guest’s change, held on every create (signed in or not) and on every change that writes the column. |
A create over a limit is refused with PUBLIC_LIMIT_REACHED, and one that breaks plainText with
PUBLIC_WRITE_REFUSED. A single create refused for another reason (the slot was taken) does not
count, except against the visitor’s hour. A create with child rows
gives its charge back only when a value the guest typed was refused; any other refusal of the whole
write (sold out, busy, a changed price) keeps it. A quote is
never charged.
An entry anyone may call (no claim, no parent, or a create a session is optional on) may not
select a personal column: one marked personal, or one whose name the install reads as personal
data (an address, a phone number, a birth date, a person’s name on a table of people).
See Limits on a stranger’s create.
requireSetting switches an entry off from the settings row: while one of its bools is false,
writes through the entry are refused with PUBLIC_SWITCHED_OFF. With "when": "anonymous", the
switch holds only for a create with no session, so a found patient can still book while new
patients are turned away; it is allowed only on an optional claimedBy entry. A switch is read
from the settings table and trusted for 15 seconds. No row, no column or a failed read counts as
off. See Switches in the settings row.
publicKeys
Section titled “publicKeys”publicKeys declares browser keys besides customer, keyed by a kebab-case name of up to 32
characters. Such a key is never published on the customer side. The staff side is handed it, in
its surface-config.json under publicKeys, only when the person signed in holds the key’s role.
Every request on the key must then carry that same staff sign-in, from the same origin, with the
CSRF token on a write. A token copied out of the page opens nothing on its own.
"publicKeys": { "kiosk": { "requiresStaff": { "role": "kiosk" }, "enabledBy": { "table": "settings", "column": "kiosk_on" } }},"roles": [ { "key": "kiosk", "name": "Check-in tablet", "screensOnly": true, "permissions": ["app:@:staff"] }],"publicAccess": [ { "table": "patients", "key": "kiosk", "methods": ["GET"], "select": ["name"], "claim": { "match": ["surname", "date_of_birth"] } }, { "table": "appointments", "key": "kiosk", "methods": ["GET", "PATCH"], "claimedBy": { "table": "patients", "column": "patient_id" }, "filters": [{ "column": "starts_at", "op": "today" }], "select": ["id", "starts_at", "status"], "writable": ["status"], "writableValues": { "status": ["checked_in"] }, "writableWhen": { "status": ["booked"], "starts_at": { "within": 60 } } }]| Field | Required | Rule |
|---|---|---|
requiresStaff |
yes, but see below | { "role" }: one of the app’s roles. It must be screensOnly, with no cloneFrom and no grant but app:@:staff, because the screen stands where anyone can walk up to it. |
enabledBy |
no | { "table", "column" }: a bool of the settings table. While it is false the key answers PUBLIC_KEY_OFF. Read as requireSetting is, and trusted for 15 seconds. |
peak |
no | { "reads", "writes" }: the key’s own budget a minute at the app’s peak (a show going on sale), from Adminium’s own 3,000 reads and 300 writes up to five times that. Each visitor still gets a twelfth. Written at install, and changed by an update. |
customer is declared here only for its peak: "customer": { "peak": { "reads": 9000, "writes": 600 } }. At least one entry must name every other key. A request without
the right staff sign-in is refused with PUBLIC_STAFF_REQUIRED; a super-admin is refused too.
Claims through a staff-bound key ask no proof, and their sessions last 3 minutes. See
A kiosk.
A key with no requiresStaff is a share key: nobody signs it in, so it opens one row by its
token to whoever holds the link, and reads nothing else. Its identity entry
claims by: "token", and every entry on it is GET only, reaching the rest through
visibleWith:
"publicKeys": { "handover": {} },"publicAccess": [ { "table": "projects", "key": "handover", "methods": ["GET"], "select": ["name", "handover_file"], "claim": { "by": "token", "column": "share_token", "expires": "share_expires_on", "stopped": "share_stopped" }, "files": ["handover_file"] }, { "table": "deliverable_versions", "key": "handover", "methods": ["GET"], "select": ["v", "file"], "visibleWith": { "table": "projects", "via": "project_id" }, "files": ["file"] }]Automations
Section titled “Automations”automations lists up to 24 rules (more than 12 needs minAdminiumVersion 0.3.28) an app or an add-on brings with it: “when a stock point falls
to its reorder level, write a request and tell the managers”. A rule is written the way the
rules page stores one, a trigger and then a flow of steps. A manifest
that uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.
"automations": [{ "key": "low-stock", "name": { "en-US": "Tell the managers when stock is low" }, "enabled": true, "trigger": { "kind": "record", "event": "updated", "table": "stock_points", "changedColumn": "state", "when": [{ "left": { "field": "state" }, "op": "is", "right": "low" }] }, "graph": { "version": 1, "nodes": [ { "id": "t", "kind": "trigger", "title": "A stock point turns low" }, { "id": "n", "kind": "action", "title": "Tell the managers", "action": { "kind": "notification", "to": { "roles": ["manager"] }, "title": { "en-US": "{{record.name}} is running low" } } } ] }}]| Field | Rule |
|---|---|
key |
kebab-case, up to 80 characters, unique in the manifest. It is how an update finds the rule it shipped before. |
name, description |
One text, or one per language with en-US among them. The install keeps the one the workspace speaks. So for a step’s title and sub. A notice’s title and body keep every language: each person is told in their own language. |
enabled |
Whether the rule is switched on when it is first installed. After that the owner’s switch stands. |
trigger |
{ "kind": "record", "event", "table", "changedColumn"?, "when"? } with event one of created, updated, deleted; or { "kind": "schedule", "schedule", "forEach"? }. A schedule is { "kind": "interval", "everyMinutes" } ("5", "10", "15", "30", "60") or daily, weekly (with dayOfWeek, 0 = Sunday) or monthly (with dayOfMonth, 1–28) at a time such as "17:00". forEach is { "table", "where", "once" }: the rows a run visits. |
module |
Optional. The part of the app the rule belongs to: it starts no run while that part is off. Needs Adminium 0.3.28. |
graph |
{ "version": 1, "nodes" }: up to 40 steps with unique ids, the trigger first and only once. A step is a trigger, an action, a condition, a wait (up to 30 days), a stop, or a branch with two branches of up to 20 steps. |
A step’s action is one of six:
kind |
Keys | What it does |
|---|---|---|
notification |
to: { "roles" }, to: { "column", "lead"?, "else"? } or to: { "listed" }, title, body?, urgent? |
Tells everyone who holds one of the manifest’s own roles and can open the record, or the person on the row, or the lead of that person, or the people listed on the row. "urgent": true tells them at once, also in their quiet hours. |
email |
templateKey, to: { "kind": "field", "column" }, to: { "kind": "staff", "column" } or to: { "kind": "listed", "table" }, vars?, replyTo: { "staff": column }? |
Sends one of the manifest’s own templates to the address a column of the record holds, to the person on the row, or one to each of the people listed on the row. |
digest |
templateKey, to: { "roles" }, sections, sendEmpty? |
Makes a summary for each person who holds one of the roles: their own rows, or theirs and those of the people they lead, read as they read. |
record.create |
table, values, name? |
Adds a row of one of the manifest’s tables. With a name, the steps after it read the row it made. |
record.update |
values |
Writes columns of the record. |
add-on.step |
addOn, step, inputs |
Runs a step an add-on gives: the add-on’s key, the step’s key, and a text for each input. An add-on’s own step is checked here; another add-on’s is checked where the rule is installed, and fails by name while that add-on is not there. Needs minAdminiumVersion 0.3.22. |
A value is a text, which may carry {{record.<column>}}, or { "now": true }. It may also carry
the record as it was and the name of a person,
or be a value of the settings row, or a day so many days on.
A mail never carries a secret column of its record, whoever it goes to: not to the address a
column holds, not to a person, not written as a date or an amount, not as it was. A personal
column goes in a mail to the address a column of the record holds (the mail goes to that person),
and in a mail to a person only when their role is shown that table’s personal columns.
A notice is about its record, and its title and body carry the record’s values. So a notice to
roles, or to people listed by hand in the dashboard, goes only to those of them whose roles open
the record: they read its table, and the row is among the rows they reach. A sales manager whose
reach is her own team is not told of another team’s quote. Nobody left is a line in the run’s log
(notified 0 people), not a failure. A notice about a row that was removed goes only to those
whose read of its table is not limited to some rows, since a removed row cannot be read again to
ask. A rule that runs for no record (a clock with no rows to visit) tells everybody it names.
Each of them is told the record as they read it: a column their role is not shown is empty in
their notice ({{record.<column>}}, {{was.<column>}} and the name a staff column names alike),
so give such a placeholder a backup, {{record.cost|-}}.
A notice in the reader’s language
Section titled “A notice in the reader’s language”A notice’s title and body may each be given in several languages, en-US among them, as an
email template’s locales are. The install keeps them all on the rule, beside the text in the
workspace’s language, and each person is told in their own language as the notice is written: their
language’s text (de-DE), else another of the same language (de-AT reads de-DE), else the
rule’s own text in the workspace’s language. Their bell shows what they were told, and an email
copy of the notice says the same. A notice already in the bell keeps the words it was told in when
the person later changes language.
{ "kind": "notification", "to": { "roles": ["manager"] }, "title": { "en-US": "{{record.name}} is running low", "de-DE": "{{record.name}} wird knapp" }, "body": { "en-US": "Order more.", "de-DE": "Bitte nachbestellen." } }A title and a body both given in several languages give the same ones (a reader is never told half a notice in theirs); one text on either side is the same for everybody. Each language’s text is held to the same placeholders. The rules page shows that a step carries other languages; a title or message changed there is one text for everybody from then on. Needs Adminium 0.3.35 to be told per language; an older server tells everybody in the workspace’s language.
The lead of the person on the row
Section titled “The lead of the person on the row”{ "kind": "notification", "to": { "column": "asked_by_id", "lead": true, "else": { "roles": ["admin"] } }, "title": { "en-US": "Quote {{record.number}} needs your approval" } }With "lead": true the notice goes not to the person the staff column names but to whoever
leads a team that person is in (Settings → Teams), never to the person themselves, and only to a
lead who can open the record. When there is none (she is in no team, she leads her own team and
nobody leads her, or her lead’s roles do not reach the record) the holders of the else roles who
can open the record are told instead, again never the person. A notice to a lead without else
tells nobody then, and the run’s log says so: a second step to the roles tells them every time,
so the roles hear once when there is a lead and once when there is none (Adminium 0.3.35,
automations.leadAlone; a release before refused it). else also works without lead: a record whose column
names nobody then tells those roles instead of nobody. A team she leads herself, alone or with
others, leads her not: a co-lead of her own team is not told as her lead. Needs Adminium 0.3.28.
The people listed on the row
Section titled “The people listed on the row”{ "kind": "notification", "to": { "listed": "crew_members" }, "urgent": true, "title": { "en-US": "Your day sheet changed: {{record.what}} at {{record.start_time}}" } }listed names a table that lists people against the rule’s record: one of the record’s table’s
belongs ways reads through it (a deal’s deal_members, a visit’s crew
through crew_members, a deal’s owner’s covers). The step tells the people that listing names
for the record when it runs, each once; a dated listing (from, to) counts on the business’s
day, so a cover that ended or has not begun names nobody. On an e-mail step, "to": { "kind": "listed", "table": "deal_members" } sends one mail to each of them, in their own language, and may
carry {{recordUrl}}.
Being listed is no right to be told. Of the people listed, only those who sign in (not suspended, not only invited, not a sample person) and whose own roles open the record at that moment are told: they read its table, and the row is among the rows they reach. A fitter on a crew whose role reads no visit, a marketer listed on a deal, a rep who has since lost her role, an account manager whose role reads won deals only, listed on an open one: none of them hears. A record that lists nobody, or nobody who may be told, is a line in the run’s log, not a failure. The validator refuses a table the record’s ways do not read through, and a rule that runs for no record. At most 100 people are told through one listing of one record; a listing that names more fails the step by name. Needs Adminium 0.3.28.
The record as it was
Section titled “The record as it was”A rule run when its record is updated may read the record as it was before the change:
{{was.<column>}}, in a notice, in a value a step writes, and in the template its e-mail sends.
"trigger": { "kind": "record", "event": "updated", "table": "deals", "changedColumn": "stage" },"graph": { "version": 1, "nodes": [ { "id": "t", "kind": "trigger", "title": "A deal moves" }, { "id": "k", "kind": "action", "title": "Keep where it came from", "action": { "kind": "record.update", "values": { "came_from": "{{was.stage}}" } } }] }It follows the record’s own rules: a personal column only in an e-mail, a secret never, and a mail
writes a date or an amount as it writes the record’s. A rule run when a row is made or removed, or
by the clock, has no “as it was”, and the validator refuses {{was.*}} there. Of the row as it
was, the run keeps only the columns its steps and its e-mails’ templates read, never a secret one;
they are never shown in Workflow Logs, and are dropped when the run ends, or when it cannot go on.
Needs Adminium 0.3.28.
A person’s name, and the record’s address
Section titled “A person’s name, and the record’s address”{{<staff column>.name}} is the name of the person a staff column names, as
Adminium keeps it: {{asked_by_id.name}}, {{record.owner_id.name}}, or one link further
{{deal_id.owner_id.name}}. A column that names nobody, or somebody since removed, writes nothing,
so give it a backup: {{asked_by_id.name|nobody}}. A staff column is never a link to another
table, whatever its name: rep_id beside a reps table names a person, not a rep’s row.
An e-mail step sent to a person ("to": { "kind": "staff", … }) may carry the record’s address,
{{recordUrl}}: the address a notice’s e-mail button gives, the app’s own screen for the record
when the person’s roles open it, on the operator’s host. Somebody who cannot open the record gets
no address, so give it a backup or tie its block to it. A step’s own vars never set it. A mail to
an address a column holds has no {{recordUrl}}; the validator refuses it there. Both need
Adminium 0.3.28.
The person on the row
Section titled “The person on the row”A notice’s to: { "column": "owner_id" } and an email’s to: { "kind": "staff", "column": "owner_id" } tell the person a staff column of the rule’s record names. One
link further is allowed: "deal_id.owner_id" on a follow-up is the owner of the deal the
follow-up is on. The column must be a staff column; the validator refuses any other.
A record that names nobody there (a deal with no owner yet, an empty link, a person since removed
or suspended) tells nobody, and the run goes on: its log says who was not there. That is not a
failure. Nor does it tell somebody who cannot open the record: an approver named on a deal
whose roles do not read deals, or reach other people’s only, is neither told nor mailed. A notice
then tells its else roles (those of them who can open it); without them, and for an email
always, the log says so. An email to a person goes to the address they sign in with, in their
language, and waits for the end of their quiet hours like a notice does. It reads the record as
they do: a column their role is not shown is empty, and a personal column is there only for
somebody shown that table’s personal columns.
An email step may also say where a reply goes: "replyTo": { "staff": "owner_id" }, or one link
further, "deal_id.owner_id". The mail then carries the sign-in address of that person as its
Reply-To, whoever it is sent to. It follows the rules of A reply to the rep,
and a rule has no house address to fall back on: with nobody there, the mail goes without a
Reply-To. Needs Adminium 0.3.28.
An amount of time the business sets
Section titled “An amount of time the business sets”A relative condition (within_next, within_last, more_than_ago, more_than_ahead) may read its
amount from a whole-number column of the app’s settings row (the row of that table with the
lowest key):
{ "left": { "field": "last_activity_at" }, "op": "more_than_ago", "right": { "amount": 7, "unit": "days", "setting": { "table": "settings", "column": "nudge_quiet_days" } } }“Quiet for more than settings.nudge_quiet_days days”: the number is read at each tick of a
rule by the clock (in forEach.where) and as a run reaches a condition step, so a change on the
app’s Settings screen counts from the next tick. amount is the number used while the setting holds
none from 1 to 3650 (empty, 0, a text). A trigger’s own when, a summary’s sections and a count do
not read settings; the validator refuses one there, and a column that is not a whole number.
Which rows a rule visits tells what the number is, so it is read like any other table a rule
reads: a secret or personal column, and a private table, are refused (as is a
private table whose rows a condition counts), and somebody who saves such a rule in the dashboard
must read the table the number is kept in. A run reads it as a rule reads the connection: a table
made private since holds nothing, and amount is used. Needs Adminium 0.3.28.
A value from the settings row, and a day so many days on
Section titled “A value from the settings row, and a day so many days on”A value a record.create or record.update step writes may be read from the app’s settings row,
or be a day so many days after a date of the record. An aftercare call 28 days after a job is
signed off, owned by the person the business names on its Settings screen:
{ "kind": "record.create", "table": "follow_ups", "values": { "job_id": "{{record.job_id}}", "owner_id": { "setting": { "table": "settings", "column": "aftercare_owner_id" } }, "due_at": { "daysAfter": { "column": "signed_at", "days": 28, "setting": { "table": "settings", "column": "aftercare_days" }, "at": "09:00" } }, "kind": "aftercare", "text": "Aftercare call" } }| Value | What it writes |
|---|---|
{ "setting": { "table", "column" } } |
What that column of the settings row (the row of that table with the lowest key) holds when the step runs. Into a staff column, from a staff column only, and only somebody who signs in: a setting that names nobody, somebody since removed or suspended, leaves the value empty, and the run’s log says so. Any other column takes it from a column of its own type. "else": { "staff": "<column>" } beside it names, for a staff column, the person to write instead: a staff column of the rule’s record, or one link further (deal_id.owner_id), again only somebody who signs in (the rep who sold the job, when nobody is named for aftercare). Needs Adminium 0.3.29 (automations.settingElse). One exception, for the sample: on a run about a sample row, a sample person may be written too, by the setting or by else (a sample job’s sign-off books its aftercare call for the sample rep who sold it); on a real row a sample person is nobody. Adminium 0.3.35. |
{ "daysAfter": { "column" or "now", "days", "setting"?, "at"? } } |
A day days (0–3650) after a date or timestamptz column of the rule’s record, or after the run ("now": true), counted on the business’s calendar (the connection’s time zone). setting: a whole-number column of the settings row whose number is used while it holds one from 1 to 3650 (else days: an empty setting or a 0 is none). Written into a date column as the day; into a timestamptz column at at ("HH:MM") on the business’s clock, else at the wall time it counts from (midnight for a date), so a call booked at 09:30 is due at 09:30 after the clocks change. A record whose date is empty leaves the value empty, and the log says so. |
A value is no way to read a table. The settings are read as a rule reads the connection, when the
step runs: a private table, a secret or a personal column are refused by the
validator and when a rule is saved, and a rule stored before holds nothing from one; so is a day
counted from a secret or personal date of the record, which it would tell. Somebody who
saves such a rule in the dashboard must read the table the setting is kept in. A clock with no rows
to visit has no record to count from: "now": true only. Both need Adminium 0.3.28
(automations.settingValue, automations.daysAfter).
Once per spell
Section titled “Once per spell”forEach.once is false (every time the rule runs), true (once ever, for each row) or
"per-spell": once for each time the row starts to meet the conditions. A deal that has gone
quiet is told to its owner once; when somebody works on it and it goes quiet again a month later,
its owner is told again. Adminium keeps, for each rule and row, whether the row met the conditions
at the last look. A rule whose forEach is changed ends every spell, so a row that still matches is told once more.
Quiet hours and urgent
Section titled “Quiet hours and urgent”Each person may set, under their own notification settings, quiet hours for each weekday on their
own clock, and the day and time their summaries are sent. A notice that arrives in quiet hours is
in the app at once and its email waits for the hours to end; so does a rule’s email to a person.
"urgent": true on a notice is not held (a change to today’s day sheet), and neither is a kind
of notice the person marked “always tell me”. A person who chose no time zone is read on the
business’s. Where one window ends as the next begins (Friday evening into a Saturday that is
quiet all day), what is held waits for the end of the last of them.
Two switches are kept beside these (quietOn and summariesOn on
/api/v1/me/notification-schedule). Quiet hours switched off hold nothing, and keep their hours
for when they are switched on again. Summaries switched off are “not me”: no rule’s digest step
makes this person one, whichever of its roles they hold, and one already waiting for its hour is
not sent; the day and time are kept. A summary the person asks for themselves
(POST /api/v1/me/summaries/{ruleId}/send) is still sent. A write that leaves either switch out
leaves it as it was.
A summary for each person
Section titled “A summary for each person”{ "kind": "digest", "templateKey": "crm-week", "to": { "roles": ["rep", "sales-manager"] }, "sections": [ { "key": "open", "table": "deals", "owner": "owner_id", "where": [{ "left": { "field": "state" }, "op": "is", "right": "open" }], "orderBy": { "column": "expected_close" }, "limit": 20 } ] }The step runs in a rule by the clock with no forEach. For each person who holds one of the
roles it queues one email, sent at the day and time that person chose (at once for a person who
chose none). When it is sent, each section is read as that person: the rows of table whose
owner staff column names them, that meet where, among the rows their roles reach and with the
columns their roles show. A manager who reads every deal still gets a summary of their own; a
person who lost the role between the run and the send gets none; a person with nothing in any
section gets no email unless "sendEmpty": true. A summary that is waiting for its hour is not sent
once its rule is switched off or deleted, or its part is off. {{count.<section>}} is how many rows
there are, also when the section lists fewer.
The template draws each section with a rows block that names it, { "block": "email.rows", "data": { "section": "open", "row": { "title": "{{row.name}}" }, "empty": "…" } }, and may read
{{name}}, {{appName}}, {{ruleName}}, {{readAt}} and {{count.<section>}}. Up to 6 sections
of up to 50 rows. The validator refuses a section whose owner is not a staff column, a section no
rows block draws, and a rows block that names no section.
A section of a team, with sums, a line a person and target cover
Section titled “A section of a team, with sums, a line a person and target cover”Four more words on a section, each read from Adminium 0.3.28 (and within, below, from 0.3.35):
{ "key": "reps", "table": "deals", "owner": "owner_id", "reach": "team", "where": [{ "left": { "field": "state" }, "op": "is", "right": "open" }], "sum": ["amount", "weighted_value"], "by": "owner", "target": { "table": "targets", "owner": "rep_id", "column": "amount", "year": "year", "of": "weighted_value" } }| Key | What it does |
|---|---|
"reach": "team" |
The rows whose owner is the person or somebody they lead when the summary is sent (the other members of every team they lead). Default "own". There is no "all": somebody whose role reads every row still gets their own, or their team’s. Somebody who leads nobody gets their own. |
"sum": [columns] |
Up to 4 number columns, each added up by the database over every row of the section, not over the rows it lists: {{sum.<section>.<column>}}, written as money where the column is money and as a number otherwise. A section with no row sums to 0. |
"by": "owner" |
The section lists one line a person instead of one a row, by name: {{row.owner}} (their name in Adminium), {{row.count}}, {{row.sum.<column>}}, and with a target, {{row.target}} and {{row.cover}}. Such a section has no orderBy; limit is the most lines. |
"target" |
What the sum of is measured against: column of the rows of table whose owner staff column names the same people. year, a whole-number column, keeps only the rows of the calendar year the business is in. Fills {{target.<section>}} and {{cover.<section>}} (the sum as a share of the target, a whole percentage), and each line’s own. |
A section of this year, or of the last days
Section titled “A section of this year, or of the last days”"within" holds a section to the rows whose date column falls in the current calendar year, or
in the last N days, on the business’s calendar (the connection’s time zone), from Adminium 0.3.35:
{ "key": "won", "table": "deals", "owner": "owner_id", "where": [{ "left": { "field": "state" }, "op": "is", "right": "won" }], "within": { "column": "closed_on", "this": "year" }, "sum": ["amount"] }| Form | The rows it keeps |
|---|---|
{ "column": <col>, "this": "year" } |
From the business’s midnight on 1 January of the year it is in when the rule runs, to the next 1 January. A summary made at 01:00 on 1 January in Auckland counts the new year, whatever the server’s clock says. |
{ "column": <col>, "lastDays": N } |
Today and the N − 1 days before it, each a whole day on the business’s calendar (1 to 366). "lastDays": 7 made on a Monday is that Monday and the six days before it. |
"orEmpty": true, beside either |
The rows whose column is empty count too: { "column": "closed_on", "this": "year", "orEmpty": true } with state not lost is the open deals and the ones won this year (a cover). |
The column is a date or a timestamptz of the section’s table; a date is compared with the
business’s days, a moment with the instants its midnights begin. Like every condition of a section,
it is counted from when the rule ran (a summary held for its reader’s hour counts the same days),
and its rows, counts and sums are the reader’s own reach. The validator refuses a column that is
not a date, and any other form.
Everything is the reader’s own reach or nothing. Each figure is read through the same row limit and the same columns as a list the person opens: a sum is never of a row they could not open, a line never names somebody whose rows they do not reach, and a target is read through their reach of the targets’ table (their own for a rep, their team’s for a manager whose role reads the team’s). A figure that cannot be answered for a person is left out, never written as zero:
- a column their role does not show is not added up, and nothing is measured by it;
- a section that chooses or sorts rows by a column their role does not show lists nothing and counts nothing;
- rows in more than one currency (a
currencycolumn) have no single sum; - a person with no target they reach has no
{{row.target}}and no{{row.cover}}, and the section has no{{cover.<section>}}while anybody whose rows are in its sum has none.
So a template writes {{target.<section>}} and {{cover.<section>}} with a backup
({{cover.reps|not set}}) or in a block tied to them with showWhen: the validator refuses them
bare. It also refuses a sum of a column that is not a number, a target.of that is not one of
the section’s sums, a target.owner that is not a staff column, and a line of a "by": "owner"
section that reads a column of a row.
When the figures were read, and “send me mine now”
Section titled “When the figures were read, and “send me mine now””A summary may wait hours for the end of its reader’s quiet hours. Every summary therefore says the
moment its figures were read, in the reader’s language and on the business’s clock: where the
template writes {{readAt}}, or as a last line (“Figures read on …”) when it does not.
A signed-in person asks for their own summary with POST /api/v1/me/summaries/{ruleId}/send
(GET /api/v1/me/summaries lists the rules that make them one). It takes no body: the rule’s own
sections, read as that person at that moment, sent to their own address, also when nothing is in
any section and also while the rule is switched off. Three an hour for each person and rule. A
rule that is not there and a rule that writes to roles the person does not hold answer the same
404.
What a summary does not do
Section titled “What a summary does not do”- It never reads “all rows”, and it has no section without an
ownercolumn: whose a row is in a summary is that one staff column, not every way of the table’sbelongs. A rep who covers for a colleague, or helps on a colleague’s deal, reads those deals on the screen and does not get them in their summary. - “The people they lead” is read when the summary is sent, not when the rule ran: somebody moved out of a team, and a manager who stopped leading it, are out of the next summary.
- It reads no private table, no column through a link, and no count of related rows.
- It adds no two sections together and divides no sum by another: one sum, one target. A figure made of two conditions (booked plus weighted) is a column of the table, or two figures.
- A line a person has only people with a row in the section or a target; it sorts by name.
- It keeps no copy: the job that makes it holds the rule’s sections and the person’s id, and the mail’s own job holds the mail sealed. A summary job cannot be queued by hand.
- “This week” is still counted from when the rule ran, whatever hour the person reads it.
A row a step makes
Section titled “A row a step makes”A record.create step with "name": "contact" keeps the row it made, and every step after it may
read {{steps.contact.<column>}}: an enquiry makes a contact, then a deal for that contact, then
a follow-up on that deal, in one rule. The row is kept in the run’s stored trace, so it is still
there after a wait.
A notice, or a mail to a person, carries what that person reads of the row: nothing of it for somebody whose roles do not read its table or reach that row (a costing made for an approver, told to the deal’s owner), and nothing of a column their role is not shown. A personal or secret column of the row is never kept for the steps after.
A {{steps.…}} that names a row no step before it makes, or a column that row’s table has not,
is refused by the validator and when a rule is saved. A run that reaches a step whose row was not
made (it was made on the other side of a branch) stops there and fails by name: the text is never
written with the braces left in. A test run makes no rows; it reads a stand-in and says so.
A manifest knows nothing of the install it lands on, so a shipped rule differs from one drawn on the rules page in three ways:
- Tables are the manifest’s own, by their short names. It names no connection and no time zone: a rule by the clock runs on the database’s zone, else the server’s.
- A notice goes to the manifest’s own roles and an email to a column of the record: never a fixed address, a user, a web address or a document.
- An email’s template carries no document and no block marked
withAttachment,onlyWithoronlyWithout: those are the outbox’s.
A condition is { "left", "op", "right"? }. left is { "field" }, a column of the record, or
{ "count": { "table", "matchColumn", "equalsField", "where"? } }, a count of related rows. On a
yes/no column only is and is_not are allowed, with "true" or "false". A count cannot be
used in a schedule’s forEach.where: keep the count in a rollup
column and compare that. A changedColumn that is a total Adminium settles after the save needs
a formula column over it with "announce": true. A rule that visits each row once needs a
condition that picks the row.
Each rule is installed with the app or add-on and listed on the rules page under “From your
add-ons” or “From your apps”. The owner may switch it, test it and copy it; a shipped rule itself
is never edited or deleted there (409 AUTOMATION_MANAGED). An update rewrites a rule nobody
changed, keeping the owner’s switch, and removes one the new version no longer ships. While its
add-on is switched off a rule is skipped. Uninstalling removes the shipped rules.
Whether each of an app’s own rules is switched on can be read by the app’s own people, for a screen that depends on one: see Whether a rule is switched on.
Sample data
Section titled “Sample data”sampleData points at a file of demo rows inside the package:
"sampleData": { "file": "seeds/visits.sample.json" }The file must be in seeds/ and end in .json. skipWhenShared ({ "table", "skip": [1–50 table refs] }) leaves the listed tables’ sample rows out when table is a shared
table another installed app uses and it already holds real rows (rows no app’s
sample added): a venue’s real menu never gains sample dishes, nor sample orders of them. The list
must include every table that links to a skipped one. After installing, the operator can add the
sample data in one step and later remove it. Removal shows a preview first. It keeps every sample row the
operator’s own records still point at and, if they choose, the rows they have changed since.
Sample people
Section titled “Sample people”A sample business has people: a rep per pipeline, a crew per van. people and teams say who
they are, and a sample row names one with { "@person": "<key>" }:
"sampleData": { "file": "seeds/crm.sample.json", "people": [ { "key": "dana", "name": "Dana Whitlock", "email": "dana@meridian.example", "role": "rep" }, { "key": "ivy", "name": "Ivy Sandoval", "role": "sales-manager" } ], "teams": [{ "name": "Sales", "lead": "ivy", "members": ["dana"] }]}| Field | Rule |
|---|---|
people |
1–200 of { "key", "name", "email"?, "role"? }. key is lower-case letters, digits, _ and -, at most 40, and listed once. role is the key of one of this app’s own roles. email is shown beside the name and never mailed; where it is left out, or somebody already has it, Adminium gives the person an address of its own (dana.crm@sample.invalid). |
teams |
1–50 of { "name", "lead"?, "members" }: the people of one team, by key. lead leads it, so a role limited to its team’s rows reaches the members’ rows. A team name the operator already uses is left to them: the sample’s team is made beside it as “Sales (sample)”. |
“Add sample data” makes each person a staff member marked as a sample person: no password and no way to get one, nothing is ever mailed to them, and nothing about them is changed by hand. They are listed on the Team page under their own status and are in no other count. “Remove sample data” takes them and their teams away, but for a person a row that stays still names (a deal the operator made and gave to a sample rep): that person stays until the row names somebody else, and the next “Add sample data” takes them back instead of making a second one. An add-on’s manifest takes neither field. Needs Adminium 0.3.28.
A rule hands a row to nobody who cannot sign in, so a rule’s write that names a person from the settings row or the record leaves a sample person out, with one exception: when the row the run is about is a sample row, it may name a sample person (a sample job’s sign-off books its aftercare call for the sample rep who sold it). A real row is never handed to a sample person. The rows such a run creates are sample rows too: they are listed with the sample, and “Remove sample data” takes them with the rest, and with them the sample row they hang off. A rule-made row somebody has since written a row of their own under stays, as any sample row their records use does, and a row a rule made from a real row is never listed. Adminium 0.3.35.
The file uses the adminium.sample/1 format:
{ "format": "adminium.sample/1", "app": "visits", "tables": [ { "ref": "clients", "rows": [{ "@label": "ada", "name": "Ada Byrne" }] }, { "ref": "visits", "rows": [ { "client_id": { "@ref": "ada" }, "starts_at": { "@day": 1, "@time": "09:30" }, "note": { "@t": { "en-US": "First visit", "de-DE": "Erster Besuch" } } } ] } ]}| Field | Rule |
|---|---|
format |
Always "adminium.sample/1". |
app |
The app’s key. |
tables |
At least one { "ref", "rows" }, each with 1–5000 rows, written in the order listed (parents first) and removed in reverse. Every ref and column must be declared in requiredSchema. |
assets |
Optional files for @asset: an object from a label to { "file": "seeds/…", "sha256": "<64 hex>" }. |
weekAnchor |
Optional: the weekday (mon … sun) the sample was written for, which @week days count from. |
A row may carry an @label (letters, digits and : . _ -, unique in the file) so a later row can
point at it.
A column numbered without gaps is spelled null in every sample row
("number_seq": null); its format text is whatever the row gives (its own spelling, such as
INV-S2041), or empty. A sample row then
stays off the real series: the first real invoice is still number one. Adding the sample is
refused when a row leaves a gapless column out or gives it a number.
A series marked "sample": true is the exception, for numbers that are the app’s own and no tax
document’s (a job’s J-01). Each sample row gives the column its number, with the format text
written the table’s way ("code_seq": 7, "code": "J-07"), or null for a row with no number.
A number twice in one series is refused, and so is a text that does not match its number. The
next real row takes the number after the largest, and goes on from there once the sample is
removed.
A value is plain JSON, or one of these directives:
| Directive | Value |
|---|---|
{ "@ref": "<label>" } |
The key of an earlier row with that label. In an add-on’s sample it may also be the label of one of the add-on’s seeds rows (a unit the install wrote). |
{ "@person": "<key>" } |
One of the manifest’s sample people: the staff member made for that key. In a staff column or another text column that holds a staff member’s id, and anywhere inside a json value (a saved filter that names a rep). Only in the app’s own sample file, not in its rows for an add-on. |
{ "@ago": "PT19M" } |
An ISO 8601 duration before now. |
{ "@in": "PT20M", "@grid": 15 } |
An ISO 8601 duration after now. With @grid, rounded up to the next step of that many minutes on the venue’s own clock, counted from its midnight (never past the next midnight): the first pickup slot at least 20 minutes away. |
{ "@in": "PT20M", "@slot": "orders" } |
The first open time of that table’s slot limit at least that far ahead: its hours, closures and pauses, on its grid, with room left after the sample’s own rows placed so far; on the next open day when today has none, looking two weeks ahead, else the plain @in time. Not with @grid. |
{ "@day": -1, "@time": "09:30" } |
A wall time in the venue’s time zone, a number of days from today (−366 to 366). |
{ "@day": 3 } |
A date: that many days from today, as the venue’s calendar has it. For a date column. |
"@workdays": true |
Added to either @day form: the days count Monday to Friday only, and day 0 on a weekend is the Monday after. So the sample’s busy day is never a Saturday. |
"@week": true |
Added to either @day form instead: the days count from the bundle’s weekAnchor in the week nearest today (three days either way), so every date keeps the weekday it was written for: a weekend stay stays on a weekend, whatever day the sample is added on. Needs weekAnchor. |
{"@month": -2, "@dom": 14} |
A date in the venue’s time zone: that day of the month, so many months back (0 is this month). Add "@time": "10:00" for a time on that day. A day past the month’s end is its last day, and a day that has not come yet is today (a time not yet come, now). Use it for history counted in calendar months: “this month” and “in May” read the same whether the sample is added on the 3rd or the 28th. |
{ "@t": { "en-US": "…" } } |
Text in the language of the person adding the sample. Keys are xx or xx-XX. |
{ "@asset": "<label>" } |
A file from assets, added to the Files library. |
Beside its @label, a row may carry the row directive @byClock, so that a sample day’s
statuses match the time it is added at:
{ "@label": "visit-2", "starts_at": { "@day": 0, "@time": "10:00", "@workdays": true }, "status": "booked", "@byClock": { "at": "starts_at", "before": { "status": "seen" }, "around": { "status": "checked_in" } } }| Field | Rule |
|---|---|
at |
The row’s time: the name of a column the row sets, or a @day with a @time. |
before |
Columns merged into the row when its time is more than half an hour before the moment the sample is added. |
around |
Columns merged when its time is within half an hour of it. |
after |
Columns merged when its time is later. |
Each set holds columns of the table, and may use directives. A set with "@skip": true leaves the
row out altogether: a payment for a visit that has not happened yet.
A row that lasts from one time to another (a stay) may carry @byStay instead, placing it by where
the adding moment falls against its two times:
{ "@label": "stay-4", "arrive": { "@day": -1, "@week": true }, "depart": { "@day": 2, "@week": true }, "status": "booked", "@byStay": { "from": "arrive", "to": "depart", "times": { "from": "15:00", "to": "11:00" }, "during": { "status": "in_house" }, "after": { "status": "departed" } } }| Field | Rule |
|---|---|
from, to |
The arrival and the departure: columns the row sets, or @days. |
times |
{ "from"?, "to"? }: the wall times a date is read at (arriving from 15:00, leaving by 11:00); else its midnight. |
before, during, after |
Columns merged when the adding moment is before the stay, during it, or after it. |
A row takes one of @byClock and @byStay. A later @ref to a row a @skip left out fails.
Every table that keeps totals is settled once all the sample rows are in, so a sample visit’s balance is right from the start. The totals so far are also settled before each table’s rows go in, so a row that copies a total from an earlier table (a stage invoice copying its quote’s subtotal) reads it worked out.
A row may also carry "@onlyIfEmpty": true, for a table that holds one row, such as the app’s own
settings. The row is added only when the table is empty. When the operator already has a row there,
the sample leaves theirs alone, and a @ref to the sample row’s label points at theirs.
Add "@fillEmpty": true beside it when the row that is already there should still take the
sample’s values: a settings row the install wrote with its defaults (seeds)
gets the sample’s depot name and address in the columns it holds nothing in. A column that holds a
value is never written over. “Remove sample data” empties those columns again where nobody
changed them since, and leaves the row.
A table of many rows the operator sets up themselves (a kitchen’s opening hours, one row a
weekday) takes "onlyIfEmpty": true on the table instead: its rows go in only when the table holds
none, and otherwise all of them stay out. No row may @ref a row of such a table, since it may not
be written.
Adminium keeps track of the rows it added in a ledger table named <key>_sample_data (with - in
the key written as _), so avoid a table of that name.
seeds and widgets
Section titled “seeds and widgets”seeds are the rows an app’s tables hold from the first second: the one row of its settings, its
list of reasons, its units. They are not demo rows; use sampleData for those.
"seeds": [ { "table": "settings", "rows": [{}] }, { "table": "reasons", "rows": [ { "@label": "late", "label": { "@t": { "en-US": "Late", "de-DE": "Verspätet" } } }, { "label": "Damaged" } ] }, { "table": "checks", "rows": [{ "name": "Call back", "reason_id": { "@ref": "late" } }] }]Each entry names one of the app’s own tables and gives its rows inline (rows) or in a file of
the package ("file": "seeds/reasons.json", a JSON list of rows), exactly one of the two. A table
is seeded with at most 200 rows.
- When they are written. Once, by the install, after the tables and their rules exist: a row
gets its table’s defaults, copies, formulas, numbers and codes like any new row, so
{}is a row of defaults. All of an install’s seeds go in one statement group: a refused row leaves none behind and the install stops at itsseedsstep (APP_INSTALL_INCOMPLETE); installing again takes it up from there. - Only into an empty table. A table that already holds a row (tables kept from an earlier install, a table the owner filled) is left exactly as it is, and a row that points at a row left out that way is left out with it.
- An update writes the seeds of the tables that version brings and of no other: a table the version before already had is the owner’s by now, empty or not.
- They are the owner’s rows afterwards. “Remove sample data” never takes one, and the app’s roles decide who may change them.
- A value is a plain value,
{ "@t": { "en-US": "…", "de-DE": "…" } }(written in the installing person’s language) or{ "@ref": "<label>" }(the key of an earlier seed row that carries"@label"). No other directive is read. - Not allowed: a table built on an add-on’s shape (the add-on numbers its own rows), a private table, and a value in a column that holds a staff member (a manifest does not know who works here; leave the column out).
An app’s seeds are read from Adminium 0.3.28 on, so a manifest that has them sets
compatibility.minAdminiumVersion to 0.3.28 or later; earlier releases accepted the block and
wrote nothing. An add-on’s seeds are described under Add-on manifests.
widgets (a list of { "id", "entry" }) are accepted by the schema, but this release does not act
on them.
Add-on manifests
Section titled “Add-on manifests”An add-on manifest has "kind": "add-on" and shares the identity fields, compatibility,
capabilities, settings, widgets and requiredSchema with an app. It differs in these ways:
- Categories come from a separate list:
artwork,delivery,payments,email,data. - No
frontends, noaddOns.requiresand noaddOns.features: an add-on has no screens outside the dashboard and never requires another add-on (it maysuggestsone). - An add-on that keeps tables of its own sets
compatibility.minAdminiumVersionto0.3.18or later andrequiredSchema.prefixedtotrue. It may then declare, in an app’s words,pages,navGroups,roles,optionLists,documents,seeds,outbox,emailTemplates,sampleData,publicAccessandpublicKeys. See below. An add-on released before that floor declares none of them and installs exactly as it always did: it uses its host app’s tables, or creates unprefixed ones that are kept when it is disconnected. - A page’s
refstarts with the add-on’s key (stock-kit-items), so no two add-ons, and no add-on and app, can declare the same page. - An
addOnblock is required:
| Field | Required | Rule |
|---|---|---|
attaches |
yes | At least one { "app", "range"?, "table"? }: an app key, or "*" when the add-on is not specific to one app. range is 1.2.3, ^1.2.3, ~1.2.3 or *. table is only for the record editor panel slot. |
connect |
yes | { "kind" }, with kind one of none, api-key, oauth2. oauth2 needs authorizeUrl, tokenUrl and the oauth-connect capability; scopes is optional. |
provides |
no | Contracts the add-on implements: { "contract", "version", "server" }. |
consumes |
no | Contracts it uses: { "contract", "version" }. |
slots |
no | Places in the host’s screens it fills: { "slot", "client", "order" }. |
events |
no | Events it handles: { "on", "server" }. |
scopes |
no | What it may reach, such as records:<table>:write. A records: scope must name a table of the host app or of the add-on. |
network |
no | { "allow": [hostnames] }: the exact HTTPS hosts its server code may call. Required, and non-empty, with the outbound-http capability. No wildcards, IP addresses or ports. |
publicSettings |
no | The setting keys its browser code may read. Never a secret setting. |
demoTransport |
no | The module that stands in for the real third-party service in a demo. |
pages |
no | Dashboard pages it renders from its own bundle: { "ref", "title", "titles"?, "icon", "client", "nav"?, "detail"? }, served at /add-ons/<key>/<ref>. Needs hostApi. titles is the title in the other languages, by tag ({ "de-DE": "Zählen" }): the sidebar is drawn before the page’s code loads, so a title that is only in the bundle’s own strings shows in English. |
navGroups |
no | Sidebar groups for those pages: { "key", "label", "labels"?, "order" } (labels: the label in the other languages, by tag). A group may not reuse a built-in key (workspace, library, planning, people, account), and every declared group must be used by a page. |
hostApi |
with pages |
The version of the host API its pages are built against: 1, or 2 for a page that reads the data kit. 2 needs compatibility.minAdminiumVersion 0.3.18 or later. |
words |
no | 1–4 questions asked of a ledger with nothing written. See Stock words. |
recordTabs |
no | 1–6 tabs of its rows shown on another table’s record. See A tab on another table’s record. |
shapes |
no | 1–8 shapes apps build their tables on. See below. |
Contract ids and slot ids come from closed registries in the add-on contracts package. How Adminium runs add-on code, and why only first-party add-ons are accepted, is explained in Add-on trust.
An add-on with tables of its own
Section titled “An add-on with tables of its own”| Field | Rule |
|---|---|
requiredSchema.prefixed |
true. Every table is created as <key with _ for ->_<ref>: stock-kit and items make stock_kit_items. A name that is taken is a problem on the check, never a rename. |
requiredSchema.tables[].indexes |
Up to six sets of one to five columns, each made as an index. A set equal to a unique set is refused. |
seeds |
[{ "table", "rows" }] or [{ "table", "file" }]: rows written once, at install, into a table that is empty. A value is a plain value, { "@t": { "en-US": "…", "de-DE": "…" } } (written in the installing person’s language) or { "@ref": "<label>" } (the key of an earlier row that carries "@label"). Never for a ledger or a receipt table. The add-on’s sample data may point at a labelled row written in rows (not one in a file) with the same { "@ref" }: the row is found again by its one-of-a-kind column ("unique": true), else by its table’s keyField. If the owner deleted it, the sample row that names it is left out. |
requiredSchema.tables[].appGrants |
The most an app’s role may hold of the table. See What an app’s role may hold of an add-on’s table. |
addOn.settingsTable |
A table that holds exactly one row. It is made at install from the columns’ defaults, so every required column has one. |
roles |
As an app’s. A role grants the add-on’s own tables (table:@items:read), its own pages (page:@stock-kit-items:view) and its settings (addOn:<key>:settings). Never screensOnly. |
sampleData |
As an app’s, for its own tables. |
publicAccess |
Entries as an app’s. One with no key is served through the customer key of an app that names the add-on, once that is allowed. One with key set to the add-on’s link key is served through that key. |
publicKeys |
At most one key, under a name that is not customer, with no requiresStaff, enabledBy or peak. It opens one row by its link and only reads: every entry on it has "methods": ["GET"] and either claims by token ("claim": { "by": "token", "column": "…" }, on a column with rules.code { "length": 16, "hiddenFromStaff": true }) or is read with the row a link opened (visibleWith, claimedBy). |
A column may keep the name of a table ("rules": { "tableRef": true }): Adminium stores it so
that renaming the table it names keeps the rows pointing at it.
What an app’s role may hold of an add-on’s table
Section titled “What an app’s role may hold of an add-on’s table”An app gives its roles a hold on an add-on’s tables with roles[].tables.
The table is the add-on’s, so the add-on says how far that may go, on the table itself:
{ "ref": "suppliers", "columns": [ … ], "appGrants": { "actions": ["read"], "readable": ["id", "name", "lead_days", "active"] } }| Field | Rule |
|---|---|
actions |
The most an app’s role is given here: read, create, update. |
readable |
The only columns an app’s read shows. Absent: every column but those in never, and never a secret. |
writable |
The only columns an app’s update may change. Required when actions has update. |
creatable |
The only columns a new row an app’s role adds may be given. Required when actions has create. |
never |
Columns no app’s grant reaches, whatever else is listed. Not the key: every read shows it. |
Once an add-on sets appGrants on one table, a table without it is given to no app’s role. An
add-on that sets it on none is held only by its own shape: no hold on a ledger’s receipt table,
read only on a table a ledger’s code writes. A ledger’s receipt table takes no appGrants, and a
table a ledger’s code writes allows only read. Each column named must be one of the table’s.
An app’s entry is cut to it when it is written, and again whenever the add-on is installed,
updated, connected or switched back on. Whatever is cut is named: in the install check, and in the
reply as narrowed with skipped: "ceiling", or skipped: "not-offered" for a table given to no
app. A grant on such a table always carries its column lists. The key is read with any read; a
link to another row only when readable lists it, as with every read limit.
A release that widens appGrants asks first. When an add-on’s new version would let an app’s
role hold more of its tables than the running version did (an action, a column, or every column
where before it was some), the update’s check lists each such grant (roleTables: the app, the
role, the table and what is new), and the update is refused with 422 ROLE_TABLES_WIDER until it
is sent with "roleTables": true. Both doors take it: POST /add-ons/{key}/update and the old
POST /add-ons/{key}/upgrade. An app’s install or update that brings such a release along asks the
same, with the same "roleTables": true, for its own roles and for those of every other app the
add-on is connected to. A release that gives no role more is not asked about; one that narrows is
applied at once.
A manifest that uses appGrants sets compatibility.minAdminiumVersion to 0.3.28 or later.
Ledgers
Section titled “Ledgers”An add-on with tables of its own may keep up to four ledgers under addOn.ledgers: tables
whose rows it alone plans, written by Adminium inside the save of the row that
posts into them.
"ledgers": [ { "id": "stock", "refusal": "stock", "receipts": "postings", "writes": { "movements": { "insert": ["level_id", "quantity", "kind"] }, "reservations": { "insert": ["level_id", "quantity", "state"], "update": { "by": ["id"], "set": ["state"] } } }, "actions": { "use": { "inputs": { "item": "link", "quantity": "decimal", "note": "text?" }, "phases": ["reserve", "post", "reverse"], "reads": [ { "as": "levels", "table": "levels", "by": [{ "column": "item_id", "from": "input.item" }] }, { "as": "mine", "table": "reservations", "by": [{ "column": "receipt_id", "from": "receipt.id" }] } ], "locks": [{ "read": "levels", "column": "id", "table": "levels" }], "holds": true } } }]| Field | Rule |
|---|---|
id |
A lowercase name. |
refusal |
stock or value: which of the two public refusals a guest hears (PUBLIC_OUT_OF_STOCK, PUBLIC_CARD_REFUSED). |
receipts |
One of the add-on’s tables, with exactly the receipt columns. Adminium alone writes it. |
writes |
Up to twelve of the add-on’s own tables, each with the columns an answer may insert and, by a key, update. Never a delete. Never a total, a balance, a formula, a stamp, a number, a code, a copy or the key. A table an answer inserts into carries receipt_id, a link to the receipt table that may be empty; Adminium fills it. Such a table carries no booking rule, no slot or night limit and no number without gaps. |
actions |
1–16, by name. |
An action:
| Field | Rule |
|---|---|
inputs |
What a posting maps: each link, number, decimal, text, date, bool (with ? when it may be empty), tableRef or rowRef (a row of any table: { "row": true } in a posting for the row itself, or a link column for the row it points at). |
phases |
Which of reserve, post, reverse it has. |
reads |
Up to six { "as", "table", "by", "where"?, "limit"? }: the rows Adminium reads for the code, from the add-on’s own tables only. by is 1–3 { "column", "from" }; from is input.<name>, <an earlier read>.<column>, receipt.id, setting.<column>, or a list of up to three of them. Up to 1,000 rows a read, 3,000 a call. |
locks |
1–4 { "read", "column", "table" }: the rows a save stands on. Two saves that would take from the same row wait for one another. Every table in writes that feeds a capped balance has that balance’s table (or the table it stands for) here. |
writes |
Optional: the tables of the ledger this action writes, when not all of them. |
holds |
true: reserve writes something that ends. A posting into it says until when. |
decides |
{ "input", "min": "0", "max": { "input" } | { "read", "column" } } entries: an amount Adminium writes to the posting row itself, between the bounds it reads. Two entries for one input are two ceilings. |
unavailable |
{ "allow": { "read", "column" } }: while the code cannot be asked, a save may still go through when every row of that read has the yes/no column on. It leaves a receipt to be worked out later. |
Three rules of the add-on’s own tables are for ledgers:
capUnlesson a capped total."announce": trueon a formula column (up to four a table): when a posting’s settle changes it (an item that turns low), the change is told to automations, emails and open screens as a change of that row — though no one saved the row."planned": trueon a state move: only a row the add-on’s own code writes makes it. It is not offered to any person, effect or timed move. A row that code adds may start in the state such a move reaches from the first state, where that move asks nothing (requires): adding it there is the add and the move in one.
The receipt table
Section titled “The receipt table”One row a phase, written by Adminium: id, source_table and line_table (text, with
"rules": { "tableRef": true }), source_row, source_line, ledger, action, posting (text),
phase (reserve, post, reverse), round (a whole number), state (planned, unplanned),
rows (a whole number), add_on_version, origin (staff, public, system), by (text), at
(a date and time) and held_until (a date and time that may be empty). Adminium makes its unique
key and its indexes itself.
Code that decides
Section titled “Code that decides”The add-on’s package provides the contract posting-rows, version 1, as one self-contained
script. Adminium calls its rows(input) with the lines, the rows it read and the add-on’s
settings, and writes what it answers. See
A ledger, and code that decides.
The price question
Section titled “The price question”An add-on that keeps offers declares addOn.adjuster and provides the contract price-adjust
(provides: [{ "contract": "price-adjust", "version": 1, "server": "dist/server.js" }]). Adminium
reads what the adjuster names, asks the add-on’s code, and writes the answer. From Adminium 0.3.19.
| Field | Rule |
|---|---|
offers |
Up to 6 reads of the add-on’s own tables, in order: { "as", "table", "by": [{ "column", "from" }], "where"?, "limit"? }. from is now, input.codes (the keys of the code rows the typed codes found), setting.<name> or <as>.<column> of an earlier read. The first read’s table is the table of offers. |
codes |
{ "table", "column", "where"?, "reserved"? }: discount codes. reserved: 1–8 words of 2–4 capital letters no discount code may start with (a voucher’s and a gift card’s routing words). |
vouchers |
{ "table", "column", "prefixes" }: vouchers and packs, found by the typed text with its prefix cut (VC-). |
applied |
Where Adminium keeps what was applied: table, source (table, row, line), columns (offer, code, voucher, name, kind, amount, reason, typed, at). |
person |
Optional, read only for a proved customer: groups (table, member, group), uses (table, customer, offer, state, counted), orders?. |
ceilings |
Optional: { "table", "role", "maxPercent", "maxAmount", "comp"? } — what each role may take off by hand. |
customerKey |
"hash": a customer reaches the add-on as a keyed hash, never as an address. |
The codes and the vouchers are two tables. A discount code written with a reserved start is refused
(VALIDATION_FAILED, the code column reserved), whichever way it is written; a code Adminium
makes for that column never has one.
Shapes
Section titled “Shapes”A shape is what an app’s tables are built on: named parts, each with its columns, rules and states, the document profiles made for an app’s tables, and the messages an app built on it sends.
| Field | Required | Rule |
|---|---|---|
name |
yes | kebab-case. An app names the shape <add-on key>/<name>@<version>. |
version |
yes | 1–99. A change an app’s tables cannot follow is a new version. |
parts |
yes | At least one, keyed by a snake_case part name: { "columns", "states"? }, with 1–60 columns and optional states. |
documentProfiles |
no | Up to 8 documents, each naming the part it is drawn for instead of a table, with no addOn and no feature. |
outbox |
no | { "producers", "templates"? }: 1–16 producers, whose tables are the shape’s parts, and up to 16 templates, each an email template with a kind (the producer’s) instead of a key. |
A part is checked the way an app’s tables are, with the parts of all the add-on’s shapes as the
tables. A column’s references names another part of the same shape ("document"), or a part of
another of the add-on’s shapes ("quote@1/document"). A rule in a part that reads a setting reads
one of the add-on’s own ({ "addOn": "<its key>", "setting" }), since the add-on cannot know an
app’s settings row.
A part may also carry the rules an app’s table gets: postings, adjust and indexes. A shape
with such a part is not built as new tables: an app adds it to tables it already has, under its own
names, and writes no builtOn. A part of such a shape may say what its rows are to a price rule
that prices the same table, with inAdjust: { "excludes": "<column>" } (a row that fills the
column takes no reduction, as a gift-card load) or { "paidBy": "<column>" } (a row that fills it
is something sold that pays later, as a voucher). The column is one of the part’s. The app’s price
rule then names it on its line (excludes, paidBy); a tool that adds the shape writes it there.
Steps for Automations
Section titled “Steps for Automations”addOn.steps gives the owner’s rules up to 8 steps of the add-on’s own. A step is a named
write of one row of one of the add-on’s tables: Adminium writes the row through the same door
as a rule’s own “create a record”, so the table’s column rules, its ledgers and the add-on’s
outbox do the rest. No code of the add-on runs in a rule.
"steps": [{ "key": "issue-voucher", "name": { "en-US": "Issue a voucher", "…": "…" }, "does": { "en-US": "Sends a voucher to one person", "…": "…" }, "inputs": [ { "key": "to", "kind": "email", "required": true, "label": { "en-US": "Send to", "…": "…" } }, { "key": "worth", "kind": "choice", "required": true, "label": { "…": "…" }, "options": [{ "value": "percent", "label": { "…": "…" } }] } ], "writes": { "table": "vouchers", "values": { "holder_email": { "input": "to" }, "worth": { "input": "worth" }, "note": { "text": "From a rule" }, "issued_note_at": { "token": "now" } } }}]| Field | Rule |
|---|---|
key |
Kebab-case, unique among the add-on’s steps. A rule names the step by it. |
name, does, each label |
A text in every one of the eight languages (60, 160 and 60 characters). |
inputs |
Up to 8. kind is text, email, number, choice (with options) or record (with table: a row of one of the add-on’s tables is picked, and its key is the value). required makes the rule unfinished until it is filled. |
writes.table |
One of the add-on’s own tables. |
writes.values |
A column → { "input": key }, { "text": "…" } (plain text, no braces) or { "token": "now" | "ruleName" | "recordLabel" }. |
The validator refuses a step that could not run: a table or column that is not the add-on’s, a
column Adminium decides (its key, a code, stamp, rollup, formula… rule), an input no
value reads, a column that must hold a value and that nothing fills, and such a column filled
from an input that is not required. An input the rule leaves empty is left out of the row, so
the column’s own default or rule stands.
What an owner fills an input with is text that may carry {{record.<column>}}. A personal column
of the rule’s record is read only for an input whose value goes only into columns the add-on
marks personal itself; such a value is never written to a run’s log.
A manifest that gives steps sets compatibility.minAdminiumVersion to 0.3.22 or later.
What an add-on tells the assistant
Section titled “What an add-on tells the assistant”addOn.assistant is text, in two parts, and either may be left out:
"assistant": { "tables": { "vouchers": { "is": "A single-use code issued to one person.", "columns": { "uses_left": "How many times it can still be used." } } }, "questions": [ { "key": "unused", "text": { "en-US": "Which vouchers were never used?", "…": "…" } }, { "key": "batch", "page": "offers-voucher-batches", "text": { "…": "…" } } ]}| Part | Rule |
|---|---|
tables |
One of the add-on’s own tables → is, one English line (200 characters) on what a row of it is, and columns, a line (160) for a column that needs one. The assistant is given them with the schema, as the add-on’s words about its table, wherever it looks at that table. |
questions |
Up to 8 questions a person might ask, in every one of the eight languages (120 characters). Shown as starters in the assistant’s panel on the add-on’s pages, under the add-on’s name. With page (one of the add-on’s page refs, generated or its own) a question is shown on that page only. |
It names no tool, grants no read and switches nothing on. A reader who may not read a table is
told nothing of it; the lines travel as data inside the schema tool’s answer, never as
instructions; and no other key is accepted. A manifest that uses it sets
compatibility.minAdminiumVersion to 0.3.22 or later.
Stock words
Section titled “Stock words”addOn.words names a question a page may ask of one of the add-on’s ledgers with
nothing written: for each row asked about, is one of it in, low or out. A manifest that uses
it sets compatibility.minAdminiumVersion to 0.3.18 or later.
"words": [{ "id": "units-left", "ledger": "units", "action": "use", "input": "what", "showLeftBelow": { "setting": "show_left_below" } }]| Field | Rule |
|---|---|
id |
kebab-case, unique among the add-on’s words. |
ledger, action |
One of the add-on’s ledgers and one of its actions. The add-on’s code is asked what that action would do for a quantity of one, in the action’s words mode. |
input |
The action’s input that takes the row asked about. |
showLeftBelow |
{ "setting" }: a column of the add-on’s settings table. A customer is told how many are left only when fewer than the number it holds are, and only for today. Without it, never. |
A customer’s page asks through a public entry of the table the rows are of, with
"kind": "availability", "methods": ["GET"] and "words": "<add-on key>:<id>" in place of a
limit of the table; such an entry takes no rule, showLeft, under or unlockBy. The page asks
GET /public/availability/<entry>?under=<id>,<id> with up to 60 row ids. The answer lists
{ "id", "state", "left"? } for each row a plain read of the same key would show; any other row
is left out. Answers are kept for five seconds. An add-on that cannot answer (switched off, not
loaded, its code failing) says nothing and every row reads in: a page never says “sold out”
because an add-on is off, and the save still refuses what is not there.
Staff ask GET /api/v1/words/<add-on key>/<id>?table=<stored name>&ids=<id>,<id> and are told the
same, never from a kept answer. A caller who also reads the add-on’s stock tables is given the
figure behind the word: the exact count, the batch a use would take and its expiry, and the line
that runs out first. When the add-on cannot answer, staff are told so (409), never “in stock”.
A tab on another table’s record
Section titled “A tab on another table’s record”addOn.recordTabs adds a tab of the add-on’s rows to the record page of another table: the stock
a dish uses, on the dish. The tab is declared, not coded. Adminium draws it with the page’s own
parts and the reader’s own grants. A manifest that uses it sets
compatibility.minAdminiumVersion to 0.3.18 or later.
"recordTabs": [{ "id": "stock", "label": { "key": "kit.tab.stock", "fallback": "Stock" }, "table": "links", "match": { "table": "source_table", "row": "source_row" }, "on": "linked", "columns": ["item_id", "qty", "note"], "edit": ["qty"], "add": { "pick": { "table": "items", "label": "name" } }, "remove": true, "empty": { "key": "kit.tab.empty", "fallback": "Nothing is linked yet." }, "summary": { "words": "units-left" }, "actions": [{ "id": "use", "label": { "key": "kit.tab.use", "fallback": "Use stock" }, "child": { "table": "uses", "form": ["qty"] } }]}]| Field | Rule |
|---|---|
table, match |
The add-on’s own table whose rows the tab lists, and its two columns that say which record a row belongs to: match.table holds a table’s stored name ("rules": { "tableRef": true }), match.row the row’s key as text. |
on |
"linked": every table a posting rule hands in to this add-on as the row asked about. Or up to 24 tables by their stored names. |
columns |
1–8 columns shown. edit lists those a reader who may change the rows edits in place. |
add |
{ "pick" }: a row is added by picking a row of another of the add-on’s tables, shown by its label column. One pick, or two (an item or a kit). |
remove |
true: a row may be removed from the tab. |
form |
1–8 columns: one row edited as a form, in place of a list. |
summary |
{ "words" }: one of the add-on’s stock words, whose answer heads the tab. |
actions |
1–2 buttons that add a row of another own table for the same record. |
An app’s table has the tab only while the add-on is connected to that app and switched on. A
reader who cannot read the add-on’s table has no tab; one who can is offered only what their role
allows. A tab is drawn before any code of the add-on runs, so its words in the other languages
sit beside each message, by tag: labels beside label, empties beside empty, and labels
on each of its actions ("labels": { "de-DE": "Bestand" }). A reader whose language is not
there reads the English. The rest of the tab’s words are Adminium’s.
An add-on that works with another
Section titled “An add-on that works with another”An add-on never requires another, and may name up to 8 in addOns.suggests. Naming one lets it
ship a document the other draws and list the other’s rows by a
pair. Each works only while
both are installed: a document whose add-on is absent is not made, and is made the moment that
add-on is installed, and an email that would carry it goes without it.
What a typed code may find
Section titled “What a typed code may find”addOn.lookUp says which of the add-on’s tables a typed or scanned code is looked for in, and
what the answer may carry. Adminium reads the row; the add-on’s code does not. A manifest that
uses it sets compatibility.minAdminiumVersion to 0.3.18 or later.
"lookUp": { "kinds": [ { "id": "gift-card", "table": "gift_cards", "code": "code", "prefix": "GC-", "show": ["label", "status", "balance"], "rows": { "table": "card_ledger", "via": "card_id", "columns": ["at", "kind", "amount"] } }, { "id": "pack", "table": "vouchers", "code": "code", "prefix": "PK-", "where": [{ "column": "worth", "eq": "pack" }], "show": ["status", "uses_left"] } ], "address": { "table": "gift_cards", "column": "owner_email", "show": ["status", "balance"] }}| Field | Rule |
|---|---|
kinds |
1–6, tried in order. table is one of the add-on’s own; code is a text column with a code rule, or a unique one. |
prefix |
Two capitals and a dash. A typed value that starts with it is that kind’s and no other’s; no two kinds share one. |
where |
Up to 2 conditions { "column", "eq" } or { "column", "in" } on the row. Two kinds that read one table differ by it. |
show |
1–16 columns of the found row the answer carries. |
rows |
One table of history, newest first: via is its foreign key to table, columns 1–8 of its columns. |
address |
Rows found by a customer’s address instead of a code. column carries normalize: "email". |
An answer never carries the code column, a secret column or a code kept from staff: a page shows
what was typed. A document kind may also be drawn on a6, a card of 105 × 148 mm, beside a4,
letter and receipt-80mm.
Validation
Section titled “Validation”Adminium validates a manifest when a package is uploaded or downloaded from the catalogue, and
refuses an invalid one before anything is written. The refusal lists every problem,
each with the path of the field (requiredSchema.tables.0.columns.2.default) and a message. The
checks are:
- The schema. Every field on this page, its type and its limits. Every object is strict, so a misspelled or unknown field is an error.
- Cross-references. Every name the manifest uses must be declared in it, and be of the right
type: a rule’s columns, a formula’s inputs, a setting a rule reads, a capacity’s or a booking’s
tables and columns, a table’s states and the child tables tied to them, a public entry’s table
and columns, a public key’s role, the outbox’s columns and templates, a document’s columns, an
add-on a shape or a document needs, a page’s feature, an option list, a foreign key’s target. A
PATCHwithout a claim, a writable column that Adminium decides, and anavailabilityentry on a table with neithercapacitynorbookingare refused, along with every rule stated in the sections above. - Policy. The publisher must be
adminium, orlocalfor an app installed from a file, and the key must not be reserved. - The version floor. An app or add-on whose
minAdminiumVersionis newer than the server is refused with a message naming both versions, including when an older server cannot parse a newer manifest. - The install plan, against the operator’s database: name lengths for that database (63 bytes
on Postgres and SQLite, 64 on MySQL, including foreign key names
fk_<table>_<column>), role names, page forms, email template blocks, public endpoints, and tables that are taken.
There is no published JSON Schema file for manifests. The schema itself is published as the
@adminiumjs/manifest npm package. Its validateManifest(document) runs the schema,
cross-reference and policy checks and returns every issue, so an app’s own CI can refuse a bad
manifest before it is released.
Beside issues, validateManifest returns warnings: advice that never refuses a manifest. A
manifest with warnings validates. Today there are four:
- a column that is neither nullable nor given a
default, a role or a rule that fills it will beNOT NULLonce installed, so every new row must give it a value, and a draft saved half-filled is refused; - a
textcolumn in a unique set with nonormalize(and nocoderule): MySQL compares text ignoring case and accents, Postgres and SQLite do not, so give itnormalize: "email"or"trim"; - a setting whose key reads like bank details and is not
secret: a setting that is not secret is published to the customer side; - a capped balance whose
ofis a formula reading a column that stays writable while the capped rows can exist: a bulk edit or an import settles the balance without the cap (see Totals that count and climb).
Keep warnings apart from issues in your CI, so that new advice never fails a build.
{ "ok": true, "manifest": { … }, "warnings": [ { "path": "requiredSchema.tables.2.columns.4", "message": "\"invoices.client_id\" has no default and is not nullable, so it will be required at install: every new row must give it a value" } ] }