Enums
An enum is a named list of allowed values, each with the text shown for it and optional display metadata, declared once at schema level and referenced from any number of fields. In the database an enum is a table, the same on every backend.
What an enum is
Section titled “What an enum is”Every enum becomes an ordinary entity named enum_<name>, enum_order_status for the enum
order_status (with a datastore prefix
of em1_ the table is em1_enum_order_status): a table with a key column and a value column, plus the display metadata, with
the audit and soft-delete columns every entity carries. The prefix is on the entity as well as the
table, so the path, the schema listing, the reference and a sorted table listing all say what it
is. A field that names the enum stores the key and carries a reference to the enum’s table,
which the engine enforces on every write, on every backend, exactly as it enforces a declared
reference. No backend gets a native enum type.
| The enum’s table | |
|---|---|
key | The stored value, the primary key. A permanent identity: final, so no write re-keys a row; never empty |
value | The text shown for the key, never empty. It may change; the rows that store the key do not |
description, color, icon, group | Display metadata, nullable |
position | Declaration order; the table’s default sort |
deprecated | Still stored by old rows, refused for new ones |
The rows come from the declaration: at bootstrap and after every applied plan the values are
upserted by key, and skipped while the declaration has not changed. Change a value’s text in
the YAML and the next plan updates the row; nothing that stores the key is touched, so a relabel is
never a data migration. To retire a key, mark it deprecated: true: rows that already store it keep
working, a new write that names it is refused like an unknown key. Deleting the row is refused
while any row still stores the key (409, reference_restrict). To rename a key, add the new key
and retire the old one.
| Capability | Status |
|---|---|
| Declaration, multi-file merge, name resolution | Ships |
| A table per enum, seeded from the declaration, readable and writable through every route | Ships |
| Membership enforced on write through the reference, on every backend | Ships (422, reference_missing) |
The value’s text through ?include= | Ships |
Enum names listed by GET /schema; values rendered into the served exports (JSON Schema, OpenAPI, Zod, CUE) | Ships |
| Protobuf, Avro and OData renderers | Implemented, no route registered |
A database enum type (CREATE TYPE … AS ENUM) | Never, on any backend, by design |
| GraphQL enum types in the generated SDL | Not generated: enum fields render as String; the enum’s table is a GraphQL type like any entity |
Declaring an enum
Section titled “Declaring an enum”Enums are a top-level list. They are not declared inside an entity.
| Key | Type | Required | Meaning |
|---|---|---|---|
enums[].name | string | Yes | What a field’s enum: key resolves against; the entity and table are enum_<name>. Exact, case-sensitive. A declared entity of that name is a load error |
enums[].description | string | No | Carried onto the entity |
enums[].values[] | scalar or map | Yes | See the three forms below |
enums[].values[].key | string | The stored key | |
enums[].values[].value | string | The text shown for the key (with key:); the key itself in the older form | |
enums[].values[].label | string | The text shown, in the older form | |
enums[].values[].description, color, icon, group | string | No | Display metadata, columns of the table |
enums[].values[].deprecated | bool | No | Retires the key for new writes |
enums[].access | map | No | The entity’s access rules. Without it the table is read-only through the API: everyone may read, nobody may create, update or delete, and the declaration is the only writer. Declare the block to let, say, an admin add and retire values |
A value takes any of three forms:
enums: - name: order_status description: Lifecycle of a sales order. values: - draft # the key, shown as itself - { key: confirmed, value: Confirmed, group: open, color: "#2563eb" } - { value: shipped, label: Shipped } # the older form: value is the key - { key: cancelled, value: Cancelled, deprecated: true }A key declared twice, a value with no key, an unknown key in the mapping form, and an enum that collides with an entity name all fail the load.
Referencing an enum from a field
Section titled “Referencing an enum from a field”| Key | Type | Effect |
|---|---|---|
enum | string | Names a top-level enum. The field becomes an enum field whatever its type: says, and references the enum’s key |
enumvalues | []string | Inline value list with no table and no metadata; add a validation to enforce it |
entities: - name: sales_order fields: - name: status type: enum enum: order_status defaultvalue: draftThe reference is named <entity>_<field>, here sales_order_status, to enum_order_status.key,
many to one, restrict on delete, enforced by the engine and not by a database constraint. A reference the schema declares
itself on that column is left as declared. An enum: naming an enum no file declares fails the
load.
What reaches the database
Section titled “What reaches the database”The enum is a table; the field is a plain text column holding the key:
CREATE TABLE "enum_order_status" ("key" TEXT NOT NULL, "value" TEXT NOT NULL, "description" TEXT, … PRIMARY KEY ("key"));-- on sales_order"status" TEXT NOT NULL DEFAULT 'draft'| Backend | Enum table | Column type for enum / state |
|---|---|---|
| PostgreSQL | a table | TEXT |
| ClickHouse | a table | String |
| DuckDB | a table | VARCHAR(255) |
| SQLite | a table | TEXT |
Nothing differs between backends: no CREATE TYPE, no check constraint, no foreign key unless the
reference asks for one with create: true.
Reading and writing the values
Section titled “Reading and writing the values”The enum’s table is an entity, so every route serves it. Reads are open; writes need the enum’s
own access: block, since the default is read-only:
GET /data/rest/enum_order_status?orderby=positionPOST /data/rest/enum_order_status {"key": "on_hold", "value": "On hold"}PATCH /data/rest/enum_order_status/id/on_hold {"deprecated": true}enums: - name: order_status values: [draft, confirmed, shipped] access: actions: read: { language: expr, expression: "true" } create: { language: expr, expression: '"admin" in user.roles' } update: { language: expr, expression: '"admin" in user.roles' } delete: { language: expr, expression: '"admin" in user.roles' }With such a block a tenant that needs a value the product did not declare adds a row; the next
re-seed leaves it alone, since seeding upserts the declared keys only. A write that changes a
row’s key is refused (422, the key is final). The value’s text rides along on a read of the
referencing entity through the reference:
GET /data/rest/sales_order?include=sales_order_status{"data":[{"id":"…","status":"confirmed","sales_order_status":{"key":"confirmed","value":"Confirmed","group":"open"}}]}Enforcing values
Section titled “Enforcing values”Membership is the reference’s job. A write that stores a key the enum’s table does not hold, holds
only as a soft-deleted row, or holds as a deprecated one, fails with 422 and the validation code
reference_missing, like any reference to a missing parent. The validators still exist for an inline enumvalues:
list or an extra constraint:
validations[].type | Behaviour |
|---|---|
enum | Fails when the value is not in values |
in | Identical to enum |
notin | Fails when the value is in values |
- name: channel type: enum enumvalues: [web, pos, partner] validations: - type: enum values: [web, pos, partner]They run on create and on update, are skipped when the payload does not carry the field, compare
with a stringified fallback, and pass silently when values: is omitted.
Enums in the typed exports
Section titled “Enums in the typed exports”Enum names are canonicalised to PascalCase wherever a named type is emitted: order_status becomes
OrderStatus. The values rendered are the keys.
| Export | Named enum renders as |
|---|---|
GET /schema/jsonschema.json | $defs.OrderStatus = {"type":"string","enum":[…]}; the field emits {"$ref":"#/$defs/OrderStatus"} |
GET /schema/openapi.json | components.schemas.OrderStatus; the field emits a $ref |
GET /schema/zod.ts | export const OrderStatusEnum = z.enum([…]) plus an inferred type |
GET /schema/cue | #OrderStatus: "draft" | "shipped" |
GET /schema/avro.avsc | a named enum type with the keys as symbols; the field references it by name |
GET /schema/proto | enum OrderStatus { ORDER_STATUS_UNSPECIFIED = 0; … } |
GET /schema/odata.xml | an EnumType with one Member per key |
GET /schema/asyncapi.json | the event payloads carry the same $defs the JSON Schema export does |
GET /schema/graphql | String. No GraphQL enum type is generated |
| Protobuf (no route) | enum OrderStatus with an ORDER_STATUS_UNSPECIFIED = 0 member prepended and values upper-snake-cased |
| Avro (no route) | An enum record whose symbols are the values with every character outside [A-Za-z0-9_] replaced by _ |
| OData (no route) | <EnumType Name="OrderStatus"> with ordinal Value attributes |
No renderer carries label, color, icon, group, description or deprecated. Exports contain values only.
GET /schema returns the enum names for the tenant as a flat list. The per-entity detail response does not report which enum a field references; use the exports for that.
Merging across files
Section titled “Merging across files”Enums merge by name across every YAML file under the loaded root, last write wins: a later file declaring order_status replaces the earlier one wholesale, with no diagnostic. This is the opposite of entities and traits, which keep the first definition and log the duplicate. There is no deep merge: you cannot add a value to an enum by re-declaring it with one entry.