Documentation

Fields — ZigBase

The complete catalog of ZigBase field types and their options — text, email, url, editor, date, autodate, bool, number, json, select, relation, and file.

This is the complete catalog of ZigBase field types and their options shapes. It is the companion reference to the API docs (which describe the request/ response envelopes) and the recipes (which show provisioning in practice). Every type name, option key, default, and validation rule below matches src/schema.zig.

How fields appear in requests vs. responses

When you create or update a collection (POST/PATCH /api/collections), you supply the field list under the key fields. When the server returns a collection, the same list is exposed under the key schema (see api.md → Input vs. output shape). The per-field object is identical in both directions:

{
  "id": "f_title",        // your stable field id (any non-empty string; you choose it)
  "name": "title",        // the column name — see naming rules below
  "type": "text",         // one of the 12 types below
  "required": false,      // optional, default false
  "unique": false,        // optional, default false
  "options": { }          // type-specific; see each type
}

required and unique are common to every type and both default to false. A create or update that would duplicate a unique value is rejected with 409 Conflict (not 500), so a client can tell the conflict apart from a server fault — see api.md → Records. A third common boolean, encrypted (default false), stores the value encrypted at rest and is valid only on text/editor/json fields — see Encryption at rest. A fourth, searchable (default false), mirrors a text/editor field’s text into the collection’s full-text index (SQLite FTS5, or a Postgres tsvector) so it can be matched via the ?search= list param — see Search; it is mutually exclusive with encrypted (ciphertext is not searchable). The options object is type-specific; unknown/omitted option keys fall back to the defaults listed for each type. A field whose options you don’t care about can pass "options": {} (all defaults).

Field-name rules (apply to every type)

A field name must:

  • be a valid identifier — start with a letter, then letters/digits/underscores only (^[A-Za-z][A-Za-z0-9_]*$);

  • not collide (case-insensitively) with another field in the same collection;

  • not be a reserved system name. The reserved set is: id, created, updated, email, username, passwordHash, tokenKey, verified, token_epoch. These are injected/managed by the engine. (On an auth collection, email, username, passwordHash, tokenKey, verified and token_epoch are added for you automatically — see auth collections.)

    On an auth collection a submitted field carrying one of these names is ignored rather than rejected: a client that reads a collection is handed the injected fields and must be able to write the same document back. Everywhere else — a base or view collection, where nothing is injected — the name is refused (validation_reserved_name). email, username, verified, created and updated are ordinary column names on an ordinary table, so a contacts.email field is a mistake worth reporting, not a column to quietly discard.

A field that violates any of these produces a 400 with a validation_* field error (validation_invalid_name, validation_duplicate_name, validation_reserved_name).


The 12 field types

typeStored as (SQLite)options keys
textTEXTmin, max, pattern
emailTEXT(none)
urlTEXT(none)
editorTEXT(none)
dateTEXTmin, max
autodateTEXTonCreate, onUpdate
boolINTEGER(none)
numberREAL (float) / INTEGER (int, fixed)mode, scale, min, max
jsonTEXTmaxSize
selectTEXTvalues, maxSelect
relationTEXTtargetCollectionId, cascadeDelete, minSelect, maxSelect
fileTEXTmaxSelect, maxSize, mimeTypes

Note the type tag is spelled bool in JSON (the Zig enum is @"bool").


text

A string column.

OptionTypeDefaultMeaning
mininteger (≥0)unsetminimum length, counted in unicode codepoints
maxinteger (≥0)unsetmaximum length, counted in unicode codepoints
patternstringunseta regular expression the value must match (see below)

min/max violations are a 400 with a validation_min / validation_max field error. An explicitly empty value ("") on an optional field skips the min check so the field stays clearable; use required to forbid empty.

pattern is enforced on every record write via a pure-Zig, linear-time Thompson-NFA matcher (src/regex.zig) — no catastrophic backtracking is possible regardless of input. Matching is unanchored (substring): the pattern must be found anywhere in the value. To require a full-string match, anchor with ^…$. A violation returns 400 with a validation_pattern field error.

Supported syntax:

  • Literals — any UTF-8 character matches itself.
  • . — matches any Unicode codepoint except \n.
  • Anchors — ^ (start of string) and $ (end of string).
  • Character classes — [abc], negated [^abc], ranges [a-z].
  • Predefined classes — \d / \D (digit), \w / \W (word char), \s / \S (whitespace) — ASCII semantics only.
  • Escape sequences — \t, \n, \r, \f, \v, and \-escaped metacharacters.
  • Alternation — a|b.
  • Groups — (…) and non-capturing (?:…).
  • Quantifiers — * (0+), + (1+), ? (0–1), {m}, {m,}, {m,n}.

Not supported: captures/backreferences, lazy quantifiers, \b, \p{}, or a case-insensitive flag.

Patterns are validated when a collection is saved — a syntactically invalid pattern is a 400 field error. For comptime .collections schemas, a malformed pattern is a @compileError at build time.

{ "name": "title", "type": "text", "required": true, "options": { "min": 1, "max": 200 } }

email

A string column intended to hold an email address. No options.

{ "name": "contact", "type": "email", "options": {} }

url

A string column intended to hold a URL. No options.

{ "name": "homepage", "type": "url", "options": {} }

editor

A rich-text / HTML string column. No options. (Stored as TEXT; the type is a hint to clients/admin UI.)

{ "name": "body", "type": "editor", "options": {} }

The admin record editor renders editor fields with a toolbar-driven WYSIWYG rich-text editor (bold, italic, underline, strikethrough, headings, lists, blockquote, inline code, links). Every write — on input, paste, and toolbar action — is passed through a sanitizer that allowlists a fixed tag set (p, br, strong/b, em/i, u, s, h1-h3, ul, ol, li, blockquote, code, pre, a[href]) and strips everything else (scripts, event-handler attributes, images, iframes, and any other markup), only allowing http:, https:, and mailto: link targets. Content is stored as raw HTML — consumer frontends that render an editor field’s value must sanitize it themselves before injecting it into the DOM; the admin sanitizer only protects the admin UI’s own editing surface. The editor relies on the browser’s (deprecated but universally supported) execCommand API and degrades to plain contenteditable formatting on browsers that drop it.

date

A date/time string column.

OptionTypeDefaultMeaning
minstringunsetearliest allowed value (inclusive) — enforced on every record write
maxstringunsetlatest allowed value (inclusive) — enforced on every record write

min/max are enforced on every record write (src/datetime.zig). The write-path normalizes the value and the bounds to a common UTC instant before comparing, so mixed input formats compare correctly. A violation returns 400 with a validation_min / validation_max field error.

Accepted date input formats:

  • YYYY-MM-DD (date only).
  • YYYY-MM-DD followed by T or a space, then HH:MM or HH:MM:SS or HH:MM:SS.fff (fractional seconds).
  • Any of the above followed by Z (UTC) or a ±HH:MM timezone offset. A missing timezone is treated as UTC.

Malformed or out-of-range values (e.g. 25:99:99, 2026-02-29 in a non-leap year) are rejected with 400 (validation_date).

A field declaring both min and max with min later than max is unsatisfiable (no value could ever pass) and is rejected when the collection is saved — and at build time (@compileError) for a comptime schema literal.

Bounds are validated at collection-save time — an out-of-range or malformed min/max string is a 400 field error. For comptime .collections schemas, a malformed min/max is a @compileError at build time.

{ "name": "starts_at", "type": "date", "options": { "min": "2026-01-01 00:00:00" } }

autodate

A server-managed timestamp column, written automatically by the engine on create and/or update.

OptionTypeDefaultMeaning
onCreatebooltruestamp the current time when the record is created
onUpdateboolfalsere-stamp on every update
{ "name": "published_at", "type": "autodate", "options": { "onCreate": true, "onUpdate": false } }

The base created and updated columns already exist on every record; use autodate for additional managed timestamps.

bool

A boolean column (stored as INTEGER 0/1). No options. Spell the type "bool".

{ "name": "is_active", "type": "bool", "options": {} }

number

A numeric column. The mode decides storage and precision.

OptionTypeDefaultMeaning
mode"float" | "int" | "fixed""float"numeric kind (see below)
scaleinteger 1..8unsetdecimal places — required when mode is "fixed"
minnumberunsetminimum value, inclusive (validation_min on violation)
maxnumberunsetmaximum value, inclusive (validation_max on violation)
  • float — stored as SQLite REAL (IEEE-754 double). The default.
  • int — stored as INTEGER (whole numbers).
  • fixed — fixed-point decimal stored as INTEGER (scaled by 10^scale). scale is mandatory and must be 1..8. Use this for money and other values where binary-float rounding is unacceptable.

Wire representation (the mode changes it). A float field is a JSON number in responses (and on write). An int or fixed field is a decimal string in responses — "seats": "3", "price_per_hour": "5.00" — to preserve exact integer / fixed-point precision across JSON parsers. On write, that decimal string is the canonical form, but a raw JSON number is also accepted (3 and "3" both bind). So read an int/fixed field as a string and parse it — record.seats + 1 does string concatenation in JavaScript, not arithmetic.

Validation gotcha: mode: "fixed" without a valid scale (1..8) is a 400 (validation_invalid_scale: “fixed number requires scale 1..8.”). A float or int field never needs scale.

// money: two decimal places, fixed-point
{ "name": "price_per_hour", "type": "number", "options": { "mode": "fixed", "scale": 2, "min": 0 } }
// a plain integer count
{ "name": "seats", "type": "number", "options": { "mode": "int", "min": 1, "max": 8 } }

json

An arbitrary JSON value, stored as TEXT.

OptionTypeDefaultMeaning
maxSizeinteger (bytes)unsetmaximum serialized size
{ "name": "metadata", "type": "json", "options": { "maxSize": 4096 } }

The admin record editor renders json fields with a monospace code editor that validates on input, offers a Format button, and blocks Save while the JSON is invalid.

select

A single- or multi-value enumerated column.

OptionTypeDefaultMeaning
valuesarray of stringsrequired, non-emptythe allowed values
maxSelectinteger1how many values may be chosen at once
  • maxSelect: 1 → single-valued (the record stores one of values).
  • maxSelect > 1 → multi-valued (the field becomes a multi-value column).

Validation gotcha: an empty values array is a 400 (validation_required: “select requires at least one value.”).

{ "name": "status", "type": "select", "options": { "values": ["pending", "confirmed", "cancelled"], "maxSelect": 1 } }

relation

A foreign-key reference to records in another collection.

OptionTypeDefaultMeaning
targetCollectionIdstringrequiredthe target collection’s id (see gotcha)
cascadeDeleteboolfalsedelete this record when its referenced record is deleted
minSelectintegerunsetminimum number of references
maxSelectinteger1maximum number of references (1 = single relation)
  • maxSelect: 1 → a single relation (one referenced id).
  • maxSelect > 1 → a multi-relation.

CRITICAL: targetCollectionId is an id, not a name

targetCollectionId must be the id that the target collection’s create response returned — not the collection’s name. ZigBase assigns the id when you create the collection; it is in the "id" field of the POST /api/collections response:

// response from creating the "simulators" collection
{ "id": "a1b2c3d4e5f6g7h", "name": "simulators", "schema": [ ... ] }
//        ^^^^^^^^^^^^^^^ THIS is what a relation's targetCollectionId must hold

So provisioning is order-dependent: create the target collection first, capture its id from the response, then create the referencing collection with targetCollectionId set to that captured id. The provisioning recipe shows the exact create-then-capture-id curl sequence. A relation with an empty targetCollectionId is a 400 (validation_required: “relation requires targetCollectionId.”).

// "listing" belongs to one "simulator"; cascade-delete the listing if the simulator is removed
{ "name": "simulator", "type": "relation", "required": true,
  "options": { "targetCollectionId": "a1b2c3d4e5f6g7h", "cascadeDelete": true, "maxSelect": 1 } }

file

An uploaded-file reference. Files themselves are uploaded as multipart/form-data on record create/update and served from GET /api/files/:col/:rec/:name (see api.md → Files).

OptionTypeDefaultMeaning
maxSelectinteger1maximum number of files (1 = single file)
maxSizeinteger (bytes)unsetmaximum per-file size
mimeTypesarray of stringsunsetallowed MIME types; others are rejected
  • maxSelect: 1 → single file; maxSelect > 1 → multiple files.
  • An upload exceeding maxSize → 413; a disallowed mimeTypes value → 400 (“File type not allowed.”); too many files for maxSelect → 400.
{ "name": "photos", "type": "file",
  "options": { "maxSelect": 6, "maxSize": 5242880, "mimeTypes": ["image/png", "image/jpeg", "image/webp"] } }

Encryption at rest (encrypted)

Set "encrypted": true on a field to store its value encrypted at rest (AES-256-GCM, versioned envelope, a fresh random nonce per write). The records layer encrypts on write and decrypts on read, so your handlers, the records API, and HTTP responses always see plaintext — only the SQLite file holds ciphertext.

{ "name": "ssn", "type": "text", "encrypted": true }
RuleBehavior
Allowed typestext, editor, json only. encrypted on any other type is rejected.
Cannot combine withunique, an index, a ?filter, or a ?sort — ciphertext is per-row-nonce, so it is not comparable. A request filtering/sorting on an encrypted field returns 400.
KeyComes only from the ZIGBASE_FIELD_KEY env var; it is never auto-generated, persisted, or logged. If any collection declares an encrypted field and the key is unset, the server refuses to start (fail-closed).
Reads are strictA stored value that is not a valid envelope (e.g. legacy plaintext) or fails authentication fails closed — there is no plaintext passthrough. Enabling encrypted on a column that already holds plaintext requires running zigbase rewrap first.

Access rules that compare an encrypted field compare against ciphertext and effectively never match — don’t reference encrypted fields in rules. Key rotation (multiple generations via ZIGBASE_FIELD_KEY_V<n>) and the zigbase rewrap command are covered in framework.md → Field encryption at rest.

Dev-only fake-encrypt mode

Set ZIGBASE_FIELD_CRYPTO=fake (or, in zigbase.testing, boot with no field_key — see framework.md → Encrypted-field apps (#260)) to swap the real AES-GCM envelope for a readable one: an .encrypted value is stored as fake:<key>:<value> — the plaintext, visible verbatim, prefixed with the label instead of a version tag. This is for debugging/testing an .encrypted-field app without managing a real key, and for the zigbase.testing harness to boot such an app at all (previously impossible — a real boot fails closed with no key configured).

  • Dev-build-only, never on a production binary. Fake mode is gated by the same dev_mode build option as the frozen clock and seeded entropy (-Ddev-mode, on by default in Debug, off in every release build — see framework.md §14). On a release binary the ZIGBASE_FIELD_CRYPTO env var is never even read; the mode is always .real.
  • Fail-closed across modes. A fake envelope and a real v<N>: envelope are mutually unreadable: a real binary can never read fake-encrypted data (it isn’t a valid envelope), and fake mode can never read real ciphertext. A database seeded with fake-encrypt data can never accidentally be served as if it were really encrypted.
  • Label-scoped. open requires the exact fake:<key>: prefix; a different label fails closed, same as a wrong real key would.

Row expiry (ttl_field) — a collection option

ttl_field is a collection-level option (not a field option): it names an existing date/autodate field on the collection to use as each row’s expiry timestamp. Rows whose value is non-null and at/before “now” are reaped by an internal GC and hidden from every read. It is set in the collection body’s options object, not on a field:

{
  "name": "sessions",
  "type": "base",
  "fields": [ { "name": "expires_at", "type": "date" } ],
  "options": { "ttl_field": "expires_at" }
}

See api.md → Collection options and framework.md → Row expiry (TTL) for full semantics.

Auth collections and system fields

A collection created with "type": "auth" automatically gains these system fields (you do not declare them, and you may not reuse their names):

FieldTypeNotes
emailemailidentity; uniqueness enforced via a partial unique index
usernametext
passwordHashtexthidden — never serialized in API output
tokenKeytexthidden — never serialized; rotated on password change
verifiedboolforced to false on create; flipped via email-verification

Auth collections also carry an options.auth block:

"options": {
  "auth": {
    "identityFields": ["email"],   // which fields auth-with-password matches against
    "minPasswordLength": 8,        // default 8
    "oauth2": { "enabled": false, "providers": [] }
  }
}

identityFields defaults to ["email"]; each entry must be a valid identifier (it is interpolated into SQL). minPasswordLength defaults to 8. See the signup recipe for how record-create acts as the signup path on an auth collection.


A complete multi-field POST /api/collections body

A listings base collection that exercises text, fixed-point number, select, relation, and file. (owner references a users auth collection and simulator references a simulators collection — substitute the ids those collections’ create responses returned for the two targetCollectionId values.)

{
  "name": "listings",
  "type": "base",
  "fields": [
    { "id": "f_title", "name": "title", "type": "text", "required": true,
      "options": { "min": 1, "max": 140 } },

    { "id": "f_desc", "name": "description", "type": "editor",
      "options": {} },

    { "id": "f_price", "name": "price_per_hour", "type": "number", "required": true,
      "options": { "mode": "fixed", "scale": 2, "min": 0 } },

    { "id": "f_status", "name": "status", "type": "select", "required": true,
      "options": { "values": ["draft", "published", "archived"], "maxSelect": 1 } },

    { "id": "f_owner", "name": "owner", "type": "relation", "required": true,
      "options": { "targetCollectionId": "REPLACE_WITH_users_id", "cascadeDelete": true, "maxSelect": 1 } },

    { "id": "f_sim", "name": "simulator", "type": "relation", "required": true,
      "options": { "targetCollectionId": "REPLACE_WITH_simulators_id", "cascadeDelete": true, "maxSelect": 1 } },

    { "id": "f_photos", "name": "photos", "type": "file",
      "options": { "maxSelect": 6, "maxSize": 5242880, "mimeTypes": ["image/png", "image/jpeg", "image/webp"] } }
  ],
  "listRule": "",
  "viewRule": "",
  "createRule": "@request.auth.id != \"\"",
  "updateRule": "@request.auth.id = owner",
  "deleteRule": "@request.auth.id = owner"
}

The five rule keys (listRule/viewRule/createRule/updateRule/deleteRule) are described in api.md → Access rules; owner-scoped and relation-traversal rule patterns are in recipes.md → Owner-scoped access rules.


See also: recipes.md · tutorial.md · api.md · framework.md