Fields
A field is one entry under entities[].fields[]. It becomes one column in the entity’s
table, plus a set of runtime rules the engine applies on every write and read.
Field keys
Section titled “Field keys”Every key below is read by the loader the deployable data.svc uses at boot. A field
with no name is a hard load error; everything else is optional.
| Key | Type | Default | What it does |
|---|---|---|---|
name | string | — | Column name. Required. |
description | string | — | Carried to schema exports and introspection. |
type | string | string | Logical type. An unrecognised value is a custom-type or embeddable reference, not an error. |
defaultvalue | scalar / expression / map | — | Value applied on create when the caller sent nothing. |
nullable | bool | false | false emits NOT NULL. |
required | bool | false | Runtime presence check on create. No DDL effect. |
unique | bool | false | Emits a column-level UNIQUE. |
modifiers | list | — | Write-path gates: final, writeonce, readonly, writeonly (plus required / unique). |
validations | list | — | Ordered runtime rules. |
transforms | list | — | Ordered value-mutation pipeline. |
compliances | list | — | Data-protection directives. compliance: (singular) is appended to the same list. |
computed | bool / string / map | — | Marks the field computed and carries the legacy expression. |
compute-strategy | string | materialized | virtual or materialized. |
compute-runtime | string | expr | Legacy language token for the computed expression. |
script | string | — | Legacy expression body. Setting it alone marks the field computed. |
compute | object | — | Nested compute block: script keys plus triggers:. |
enum | string | — | Reference to a named entry under top-level enums:. |
enumvalues | list of string | — | Inline value list, no metadata. |
maxlength | int | — | Shorthand for a maxlength validation. Does not size the column. |
minlength | int | — | Shorthand for a minlength validation. |
stateflow | string | — | Forces the field’s type to state. The name itself is discarded. |
state-transitions | list of {from, to} | — | Kept on the field; no runtime reader. |
references | object | — | Inline foreign key: {entity, field, onDelete, onUpdate}. |
embeddable | string | — | Names a top-level embeddables: entry. |
storage.codec | string | — | ClickHouse per-column codec, e.g. ZSTD(3). |
storage.compression | string | — | Kept on the field; no dialect reads it. |
storage.encoding | string | — | Kept on the field; no dialect reads it. |
position | int | declaration order | Column-order override when greater than 0. |
deprecated | bool | false | Kept on the field; no exporter reads it. |
deprecated-message | string | — | Kept alongside the flag; no reader. |
tokenized | bool | false | Appends a tokenize compliance. |
tokenstrategy | string | — | Algorithm name used by that synthesised compliance. |
metadata | map | — | Free-form map; other blocks read their own keys from it. |
attributes | map | — | Second free-form map, kept distinct so both spellings round-trip. |
These keys parse and are then discarded — they have no effect: hidden, serial,
subfields, annotations, stateflowfield. A second group survives onto the field but
nothing in the engine, the DDL emitter or the exporters reads it: state-transitions,
deprecated, deprecated-message, storage.compression, storage.encoding.
type: takes a canonical token or one of its aliases. Anything unresolved is carried
through as a custom-type or embeddable reference and treated as a string at the runtime
layer.
| Canonical | Accepted spellings |
|---|---|
string | string, varchar, char |
text | text (spelling preserved — this is what widens the column) |
int | int, integer, int32, int16, int8, uint8, uint16, uint32 |
bigint | bigint, long, int64, uint64 |
double | double, float, float64, float32, real |
decimal | decimal, numeric, money |
boolean | boolean, bool |
timestamp | timestamp, timestampz, datetime |
date | date |
time | time |
uuid | uuid |
ulid | ulid, klid (spelling preserved — 27 characters instead of 26) |
jsonb | jsonb, json, object |
enum | enum |
state | state |
file | file |
bytes | bytes, bytea, binary |
array | array, array(<elem>) |
The unsigned spellings, text and klid are kept verbatim on the field so a backend that
distinguishes them can emit the exact column type. Per-dialect column types are on
Types.
<type>[] is not one of these spellings. The loader does not recognise it, so the field
resolves to a custom-type reference typed string; only the DDL emitter strips the [] to
produce a native array column. The array-specific engine rules below — no NOT NULL, the
default left to the database — key off the logical type and therefore do not apply. Write
array(<elem>).
There is no size: key
Section titled “There is no size: key”A string field is VARCHAR(255) on PostgreSQL and DuckDB. maxlength: adds a runtime
validation and nothing else, so a 400-character value passes validation and then fails at
the database. To widen the column, declare a custom type carrying length:, precision:
or scale: and reference it from type: — or use type: text for an unbounded column.
There is also no field-level index: key. A single-column index comes from unique: true;
everything else is declared in the entity’s indexes: block.
Nullability, presence and uniqueness
Section titled “Nullability, presence and uniqueness”fields: - name: code type: string modifiers: [required, unique] - name: email type: string nullable: true| Declaration | Enforced where | On failure |
|---|---|---|
nullable: false (the default) | database, via NOT NULL | driver error, not a validation error |
required: true | engine, on create only | REQUIRED |
unique: true | database, via column UNIQUE | driver error |
validations: [{type: unique}] | database — the loader lifts it to unique: true | driver error |
NOT NULL is skipped for array fields and for materialized computed columns. Empty and
whitespace-only strings fail the required check. Uniqueness is never a runtime check: the
unique validator returns without querying, so the column constraint is the only enforcement
there is.
Default values
Section titled “Default values”defaultvalue: is applied on create only, and only when the caller supplied no value for
the field. It is never re-applied on update, so clearing a field stays cleared. Array
fields are skipped by the engine and rely on the database-side default instead.
| Expression | Value | Where it is resolved |
|---|---|---|
ulid() | 26-character Crockford ULID | engine; no SQL DEFAULT emitted |
uuid(), uuid_v4() | UUID v4 | engine; no SQL DEFAULT emitted |
uuid7() | RFC 9562 time-ordered UUID v7 | engine; no SQL DEFAULT emitted |
klid('X') | X + ULID, 27 characters | engine; no SQL DEFAULT emitted |
'X'+ulid() | same as klid('X') | engine; no SQL DEFAULT emitted |
contextget("<slot>") | — | no SQL DEFAULT emitted, and no engine resolver either |
currentTimestamp() | — | CURRENT_TIMESTAMP in the DDL, but no engine resolver |
now(), now, current_timestamp | current UTC time | engine at insert and CURRENT_TIMESTAMP in the DDL |
| anything else | the literal you wrote | emitted as a SQL DEFAULT |
Matching is case-insensitive and tolerates whitespace. The prefix argument to klid() is
mandatory — bare klid() is treated as a literal string. A string that merely looks like a
call, such as foo(), is stored as text.
Modifiers
Section titled “Modifiers”Modifiers gate the write path. They can be written under modifiers: or mixed into
validations: — both lists are split by the same resolver. required and unique written
in either list set the boolean flags rather than adding a modifier.
modifiers: [final]modifiers: - type: final message: "cannot change once set"| Modifier | Also accepted | Enforced | Error code |
|---|---|---|---|
final | immutable | rejects the field’s presence in an update payload | FINAL |
writeonce | write-once, writeonceonly | rejects the field’s presence in an update payload | WRITE_ONCE |
readonly | read-only | rejects the field’s presence in an update payload; create is allowed | READONLY |
writeonly | write-only | read side — the field is stripped from returned rows | — |
The check only fires when the payload actually carries a value for the field, so an update
that omits a final field succeeds. final, writeonce and readonly are the same rule
with three error codes; none of them blocks a create.
The audit hook overwrites fields named exactly createdon, updatedon, createdby and
updatedby on create, and updatedon and updatedby on update — regardless of what the
caller sent and regardless of their modifiers. A field named version is different: it is
set to 1 on create only when the caller supplied nothing, and on update the compiler
increments it rather than the hook writing a value.
Computed fields
Section titled “Computed fields”A field is computed when it declares computed:, script:, or a compute: block. The
expression is evaluated by the script runtime — it is not pushed into SQL, so SQL syntax in
a computed expression does not evaluate.
compute-strategy | Evaluated | Persisted |
|---|---|---|
virtual | on read, after rows are scanned | no |
materialized | on create and update, before the statement runs | yes, into a real column |
Any token other than virtual — including a typo such as virtaul — resolves to
materialized, and so does omitting the key on a field declared computed through
computed: or script:. The one exception: a field whose only compute declaration is a
nested compute: block with neither compute-strategy: nor triggers: is virtual.
fields: - name: takehome type: int computed: "basic + bonus - (professionaltax + incometax)" compute-strategy: materialized
- name: display_name type: string nullable: true compute: language: expr expression: "first_name + ' ' + last_name" compute-strategy: virtualBindings available to the expression are row (the row or payload as a map), entity (the
entity name), tenant and user_id. The materialized path additionally promotes every
payload key to the top level, so qty * unit_price resolves directly.
language: accepts expr (the default), cel, js, javascript, lua, starlark,
go and wasm. The body goes in expression: for a one-liner or script: for a full
program; function: names the entry point for the module languages.
The data.* service namespace — data.query, data.count, data.aggregate and the rest —
is injected only for js / javascript, lua, starlark and go. An expr, cel or
wasm body cannot reach it.
Two further limits are worth knowing before you rely on computed values:
- The engine never emits
GENERATED ALWAYS AS … STORED. Derived values are the engine’s responsibility, so anything writing to the table with direct SQL bypasses computation. - An expression that fails to evaluate does not fail the request. The error is recorded in
the response metadata under
computed:error:<field>ormaterialized:error:<field>and the field is left as-is. script-file:is not wired. Any script reference with a file and no inline body errors at evaluation time.
Cross-entity refresh triggers
Section titled “Cross-entity refresh triggers”A materialized field can declare the source entities whose mutations invalidate it. Same entity computation needs no triggers.
| Key | Meaning |
|---|---|
entity | Source entity. Required. |
on | create, update, delete. Empty matches all three. |
affected_via | Single source column selecting the target row. |
affected_via_cols | Composite key columns into the target row. |
affected_via_expr | Script expression deriving the target key. |
when_columns | Update-only: skip the refresh when none of these columns changed. |
when | Script predicate gate. Empty always enqueues. |
Exactly one of affected_via, affected_via_cols and affected_via_expr may be set;
more than one is a load error, as is a trigger naming an unknown entity or sitting on a
field that is not materialized.
fields: - name: order_count type: int nullable: true computed: true compute-strategy: materialized compute: language: javascript script: | return data.count("orders", { customer_id: row.id, status: "completed" }); triggers: - entity: orders on: [create, update, delete] affected_via: customer_id when_columns: [customer_id, status] when: language: expr expression: "row.status == 'completed'"Foreign key shorthand
Section titled “Foreign key shorthand”references: on a field declares the foreign key inline. Schema-level relations, with
cardinality and named traversals, are covered on
Entities.
| Key | Required | Notes |
|---|---|---|
entity | yes | Target entity. |
field | yes | Target column. |
onDelete | no | Note the camelCase spelling. |
onUpdate | no | Same set as onDelete. |
Referential actions parse case-insensitively from CASCADE, SET NULL (or SETNULL) and
RESTRICT. Anything else, including omission, is NO ACTION.
- name: customer_id type: ulid references: entity: customer field: id onDelete: SET NULLCompliance directives
Section titled “Compliance directives”compliances: classifies the field and declares how it is protected. Each entry takes
type: plus, depending on the directive, value:, key:, algorithm: and category:.
| Directive | Effect | Parameters |
|---|---|---|
mask | Masks the value on read | value: — the pattern |
redact | Replaces the value with [REDACTED] on read | — |
hidden | Removes the field from read output | — |
nolog | Keeps the value out of logs | — |
noexport | Keeps the field out of exports | — |
noindex | Suppresses index emission over the field | — |
encrypted | Encrypts at rest | key:, algorithm: |
tokenize | Stores a token instead of the value | — |
hash | Stores a one-way digest | — |
retention | Marks a retention policy | value: |
searchable | Opts the field out of default encryption | — |
pii, phi, pci, gdpr | Sensitivity classification | category: |
Declaring any of the four classifications applies a default bundle per dimension:
- mask on read, unless you already declared
mask,redactorhidden. Apcifield defaults to thelast4pattern. - encrypt at rest under key
default, unless you already declaredencrypted,tokenizeorhash, or opted out withsearchable. nologandnoexport, unconditionally.noindexwhenever encryption or tokenization is in effect.
Encryption keys are held in Vault.
Validation entries
Section titled “Validation entries”Full rules go in validations:, an ordered list evaluated on create and update. Every
entry takes these keys; the rest of the parameters are read straight off the entry
according to the rule’s type:.
| Key | Meaning |
|---|---|
type | Which rule to run. Required. |
value | Single-value parameter — bound, pattern, substring, or exact length. |
message | Overrides the generated failure message. |
code | Overrides the machine-readable error code. |
min, max | Bounds for length, minmax and filesize. |
An unrecognised type: is not ignored — it rejects the write with VALIDATION_UNKNOWN.
Two keys parse but have no reader: scope: (intended for compound-unique) and when:, so
a rule declared conditional runs unconditionally. There is no exists validator; declaring
one rejects every write that carries a value for the field.
Enum and state fields
Section titled “Enum and state fields”- name: status type: string enum: document_status defaultvalue: draft validations: - type: enum values: [draft, submitted, approved, cancelled]enum: points at a named entry under top-level enums:; enumvalues: declares the values
inline without metadata. Both are read by schema exporters. Neither constrains writes:
enum and state fields land as plain text columns, no enum type is created during migration,
and no validator checks membership. Add an explicit enum validation, as above, to enforce
the value set on every backend.
stateflow: forces the field’s type to state whatever type: says, and does nothing
else — the loader discards the name. A state machine attaches to a column through its own
field: key, not through this one. See Enums and
Stateflows.
Fields you do not declare
Section titled “Fields you do not declare”Two built-in traits are applied to every entity, and two more are opt-in via inherits:.
Fields you declare yourself always win over a trait field of the same name.
| Trait | Field | Type | Shape |
|---|---|---|---|
common (automatic) | createdby | string | default contextget("user"), final, length 3–255 |
common (automatic) | createdon | timestamp | default currentTimestamp(), final |
common (automatic) | updatedby | string | nullable, materialized compute |
common (automatic) | updatedon | timestamp | nullable, materialized compute |
softdelete (automatic) | deletedby | string | nullable, writeonce |
softdelete (automatic) | deletedon | timestamp | nullable, writeonce |
id (opt-in) | id | ulid | default ulid(), required, unique, final |
uid (opt-in) | id | klid | default 'U'+ulid(), required, unique, final |
If the entity has no primary-key: and no id field survives trait merging, the loader
injects the id column from the id trait. An entity with an explicit natural key gets no
such injection. The presence of deletedon is what turns soft delete on: deletes become an
update and reads filter on deletedon IS NULL.
Row-history strategies add more columns. type2 and type6 synthesise readonly
valid_from, valid_to (both renameable via history.valid_from_field and
history.valid_to_field) and _version_num; type3 and type6 add a readonly
<field>_prev for every name under history.tracked. See
Entities for the strategies themselves.
Worked example
Section titled “Worked example”The entity below is loaded end-to-end against PostgreSQL by the block’s own functional
test product. It never declares id, createdon, createdby, updatedon, updatedby,
deletedon or deletedby — the traits supply all seven.
entities: - name: customer description: "Customer master data" fields: - name: code type: string modifiers: [required, unique] - name: name type: string modifiers: [required] - name: email type: string nullable: true - name: credit_limit type: integer defaultvalue: 0 - name: tenant_id type: string nullable: true - name: owner_id type: string nullable: true access: actions: read: { language: expr, expression: "true" } create: { language: expr, expression: "true" } update: { language: expr, expression: "true" } delete: { language: expr, expression: '"admin" in user.roles' }Field-level failures are typed, so they surface as 422 responses whose envelope code is
v2.create_failed or v2.update_failed. The per-field codes named above — REQUIRED,
READONLY, FINAL, WRITE_ONCE, VALIDATION_UNKNOWN and the per-rule codes — stay on the
internal error and are not serialised into the body; what the client reads is the first
failure’s message. The request and response shapes are at Data API endpoints.