Skip to content

Latest commit

 

History

History
571 lines (470 loc) · 23.5 KB

fields.mdx

File metadata and controls

571 lines (470 loc) · 23.5 KB
title Field metadata — every option explained
navTitle Field Metadata
description Configure field types and properties — text, numbers, dates, relationships, files, and more

A Field defines an individual property within an Object. ObjectStack provides a comprehensive set of field types covering text, numbers, dates, selections, relationships, files, calculations, and specialized types like vectors and QR codes.

Basic Usage

Fields are defined inside an Object's fields map using Field.* factory methods:

{/* os:check */}

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Contact = ObjectSchema.create({
  name: 'contact',
  sharingModel: 'private',
  label: 'Contact',
  fields: {
    first_name: Field.text({ label: 'First Name', required: true }),
    last_name: Field.text({ label: 'Last Name', required: true }),
    email: Field.email({ label: 'Email', unique: 'organization' }),
    phone: Field.phone({ label: 'Phone' }),
    birth_date: Field.date({ label: 'Date of Birth' }),
    is_active: Field.boolean({ label: 'Active', defaultValue: true }),
    account: Field.lookup('account', { label: 'Account', required: true }),
  },
});

Field Types

Text Types

Type Factory Description
text Field.text() Single-line text
textarea Field.textarea() Multi-line text
email Field.email() Email address with validation
url Field.url() URL with validation
phone Field.phone() Phone number
password Field.password() Masked password input
markdown Field.markdown() Markdown editor
html Field.html() HTML content
richtext Field.richtext() Rich text (WYSIWYG) editor
name: Field.text({ label: 'Name', required: true, maxLength: 255 }),
bio: Field.textarea({ label: 'Bio', maxLength: 5000 }),
website: Field.url({ label: 'Website' }),
notes: Field.markdown({ label: 'Notes' }),

Number Types

Type Factory Description
number Field.number() Integer or decimal
currency Field.currency() Monetary value with currency support
percent Field.percent() Percentage value
quantity: Field.number({ label: 'Quantity', min: 0, max: 10000, step: 1 }),
price: Field.currency({ label: 'Price', min: 0 }),
// percent stores a fraction: 0.15 = 15% (the UI renders it as a percentage)
discount: Field.percent({ label: 'Discount', scale: 2, min: 0, max: 1 }),

Currency Configuration:

price: Field.currency({
  label: 'Price',
  currencyConfig: {
    currencyMode: 'fixed',      // 'fixed' pins the column to defaultCurrency;
                                // 'dynamic' (default) shows the tenant default
    defaultCurrency: 'USD',     // the column's currency under 'fixed'
  },
}),

The stored value is a bare number in both modes — never { value, currency }. Under dynamic (the default) the amount displays in the tenant's localization.currency setting and defaultCurrency is not the displayed currency; fixed pins the column to defaultCurrency.

Date & Time Types

Type Factory Description
date Field.date() Date only
datetime Field.datetime() Date and time
time — Time only
start_date: Field.date({ label: 'Start Date', required: true }),
created_at: Field.datetime({ label: 'Created At', readonly: true }),

Deadlines: dueLike and settledWhen

A date is a deadline only when its field says so. dueLike: true on a date or datetime field declares one: once the date has passed, a renderer may show it as overdue, with relative wording ("Overdue 3d") and an overdue colour. Without the key the field is not a deadline, and nothing is inferred from its name — a due_date that is not declared is just a date.

A deadline usually stops mattering when the record is finished, and only the object knows what finished means. settledWhen says it: a CEL predicate over the record, in the same family as visibleWhen, and while it is TRUE no overdue wording or colour applies to that record.

import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

due_date: Field.date({
  label: 'Due Date',
  dueLike: true,
  settledWhen: P`record.status == 'done'`,
}),

Both keys are judged when the metadata is authored, not when a view renders it:

  • Either key on any other field type is refused, and so is settledWhen without dueLike: true — a settle condition on a date that is no deadline would change nothing.
  • settledWhen is checked exactly like visibleWhen: it must parse, read fields as record.<field>, name fields the object declares, and read no root but record (plus previous, and parent on a master-detail line item). objectstack validate, objectstack build and saving the object all refuse anything else, naming the field.

Both are display semantics: nothing on the write path reads them, and a past deadline never blocks a save.

Boolean Type

is_active: Field.boolean({ label: 'Active', defaultValue: true }),

Selection Types

Type Factory Description
select Field.select() Single or multi-select dropdown
radio — Radio button group
checkboxes — Checkbox group
// Single select
status: Field.select({
  label: 'Status',
  options: [
    { label: 'New', value: 'new', color: '#999', default: true },
    { label: 'Active', value: 'active', color: '#0A0' },
    { label: 'Closed', value: 'closed', color: '#A00' },
  ],
}),

// Multi-select
skills: Field.select({
  label: 'Skills',
  multiple: true,
  options: [
    { label: 'JavaScript', value: 'js' },
    { label: 'Python', value: 'python' },
    { label: 'Go', value: 'go' },
  ],
}),

Options must use { label, value } objects. Values are stored in the database and must be lowercase.

Relationship Types

Type Factory Description
lookup Field.lookup() Many-to-one reference
master_detail Field.masterDetail() Parent-child with cascade delete
// Simple lookup
account: Field.lookup('account', {
  label: 'Account',
  required: true,
}),

// Filtered lookup — structured filters, cascading from another field
primary_contact: Field.lookup('contact', {
  label: 'Primary Contact',
  dependsOn: ['account'],
  lookupFilters: [
    { field: 'is_active', operator: 'eq', value: true },
  ],
}),

// Master-detail (cascade delete)
project: Field.masterDetail('project', {
  label: 'Project',
  required: true,
}),

// Inline line items on the parent form
order: Field.masterDetail('order', {
  label: 'Order',
  required: true,
  inlineEdit: true,
  inlineTitle: 'Lines',
  inlineAmountField: 'line_total',
}),
Property Type Description
reference (1st arg) string Target object name
lookupFilters { field, operator, value }[] Base filters restricting which records are selectable (structured, picker-honored)
deleteBehavior enum 'set_null', 'cascade', 'restrict'
inlineEdit boolean | 'grid' | 'form' Render child records inline on the parent create/edit form (true = auto-pick, 'grid', or 'form')
inlineColumns array Optional explicit columns for the inline grid
inlineAmountField string Optional numeric child field for the inline running total

Referential integrity

reference is enforced on write. A create or update that sets a lookup / master_detail to an id with no matching row in the target object is rejected with 400 VALIDATION_FAILED and a fields[] entry whose code is reference_not_found (see the error catalog). The same check runs on bulk updates.

Clearing a relationship is not a dangling reference: null, "" and [] all mean "no link". Which of them deleteBehavior: 'set_null' writes when the referenced record goes away depends on the field: a single-value lookup is set to null, while a multiple: true lookup keeps its other members and loses only the deleted one — and a set emptied that way is written as [], never null (the multiple doc block in packages/spec/src/data/field.zod.ts, rendered in the Field reference).

`deleteBehavior` governs what happens to *this* record when the **referenced** record is deleted; the integrity check above governs what may be **written** here in the first place. They are two halves of the same relationship contract.

File & Media Types

Type Factory Description
image Field.image() Image file upload
file Field.file() Generic file upload
avatar Field.avatar() User avatar/profile image
video — Video file
audio — Audio file
photo: Field.image({ label: 'Photo' }),
resume: Field.file({ label: 'Resume' }),
profile_image: Field.avatar({ label: 'Profile Image' }),

Calculation Types

Type Factory Description
formula Field.formula() Calculated from other fields
summary Field.summary() Roll-up from child records
autonumber Field.autonumber() Sequential auto-generated number
// Formula
total: Field.formula({
  label: 'Total',
  expression: 'record.quantity * record.price * (1 - record.discount / 100)',
}),

// Summary (roll-up)
total_amount: Field.summary({
  label: 'Total Amount',
  summaryOperations: {
    object: 'order_item',
    field: 'amount',
    function: 'sum',
    relationshipField: 'order',
  },
}),

// AutoNumber
case_number: Field.autonumber({
  label: 'Case Number',
  autonumberFormat: 'CASE-{0000}',
}),

Summary fields are server-owned. The engine recomputes them when child records are inserted, updated, or deleted. relationshipField is optional when the child has only one lookup/master_detail field pointing back to the parent; set it when multiple relationships target the same parent object.

Because the roll-up is engine-derived state rather than a user write, the recompute runs under a system context: a user who may create a child does not also need edit access on the parent, and the aggregate is computed over the whole child collection rather than the writer's visible subset. The permission decision that governs the write is the one on the child. The parent's own row is unaffected — a user still needs edit access to change any other field on it — and the summary column stays subject to the parent's field-level security on read.

A child validation rule can read a parent summary one hop through its reference, for example "each supplier has at most one primary contact" as a count summary filtered on { is_primary: true } plus the child rule record.is_primary == true && record.supplier.primary_contact_count > 0. A bulk create equals one-by-one creates (批量等于逐条): inside one bulk insert (an import batch, createManyData, insertManyData) each row's rules read the summary as it stands after the earlier rows of the same batch that the rules admitted, accumulated in memory, so two primary contacts in one import create one row and refuse the other with the rule's error. A row the rules refuse is not counted. This holds for count, and for sum, min and max over number values. An avg summary, a sum/min/max meeting a value that is not a number, and a summary whose filter reads a dotted path through another reference keep reading the stored value, as they did before. An import's dry run previews its create rows in the batches the import writes them in (200 rows each) and refuses the rows the import refuses, with the same message. It writes nothing, so a row in a later batch reads the summary as stored before the import: two such rows that fall in different batches of a dry run both preview valid, while the import refuses the second. Concurrent writes are not guaranteed (并发不保证): two writes in flight at the same time each read the summary they started from, so a guard like the one above can still admit one row from each.

Specialized Types

Type Factory Description
address Field.address() Structured address (street, city, state, country)
location Field.location() Geographic coordinates (lat/lng)
color Field.color() Color picker (hex/rgb/hsl)
rating Field.rating() Star rating (1-5)
slider Field.slider() Range slider
signature Field.signature() Digital signature capture
qrcode Field.qrcode() QR/barcode generator
code Field.code() Code editor with syntax highlighting
json Field.json() JSON data
vector Field.vector() AI embedding vectors
tags — Tag list
progress — Progress bar
office: Field.address({ label: 'Office Address' }),
coordinates: Field.location({ label: 'Location' }),
brand_color: Field.color({ label: 'Color' }),
satisfaction: Field.rating(5, { label: 'Rating' }),
approval_signature: Field.signature({ label: 'Signature' }),
embedding: Field.vector(1536, { label: 'Embedding' }),

Common Properties

These properties are available on all field types:

Constraints

Property Type Default Description
required boolean false Field must have a value
unique boolean false Values must be unique across records
defaultValue any — Default value for new records
maxLength number — Maximum character length
minLength number — Minimum character length
valueDomain 'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2' — Standard the written value must be a member of (text only): an IANA time zone, an ISO 4217 currency code or an ISO 3166-1 alpha-2 country code. The same closed vocabulary a settings specifier's valueDomain uses; membership, not a pattern, is what a write is judged against
min number — Minimum numeric value
max number — Maximum numeric value

Display

Property Type Default Description
label string — Human-readable display name
description string — Developer documentation
inlineHelpText string — Help text shown in UI
hidden boolean false Hide from default views
readonly boolean false Prevent editing — hidden from create/edit forms AND server-enforced on both write paths: a non-system write to the field is silently dropped on UPDATE and, since the maintainer ruling of 2026-09-03, on INSERT — both in the engine, so a direct engine.insert caller (a flow's create_record, a hook body's ctx.api) is covered exactly like the data API's REST/MCP/import faces. A stripped field still falls back to its defaultValue; seeding a readonly column at create requires a system context (import/migration/programmatic seed). Platform (sys_/managedBy) objects are governed by their own write policy instead — the resolved-affordance write guard keyed off the object's lifecycle bucket (ADR-0103), not this field-level flag.
sortable boolean true Allow sorting by this field
group string — Group name for organizing in forms (e.g. 'billing')

Search & Index

Property Type Default Description
searchable boolean false Include in full-text search
externalId boolean false Mark as external system identifier

Database indexes are declared at the object level in the object's indexes[] array — there is no per-field index flag. See Indexing.

Security

Property Type Default Description
requiredPermissions string[] — Capabilities required to read/edit this field — masked on read, denied on write unless the caller holds all of them (ADR-0066 D3)
trackHistory boolean — Render this field's value changes as entries on the record activity timeline

For values that must be encrypted at rest (API keys, tokens, DB passwords), use the secret field type — see the Field Type Gallery.

Conditional Logic

Property Type Description
visibleWhen string | Expression CEL predicate; field is shown only when TRUE
readonlyWhen string | Expression CEL predicate; field is read-only when TRUE
requiredWhen string | Expression CEL predicate; field is required when TRUE
settledWhen string | Expression CEL predicate on a dueLike date; the deadline is settled when TRUE — see Deadlines
conditionalFormatting { condition, style }[] Cell formatting rules; the first rule whose CEL condition is TRUE styles this field's cell — see Cell formatting
multiple boolean Allow multiple values (for select, lookup, file)
import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

close_reason: Field.select({
  label: 'Close Reason',
  visibleWhen: P`record.status == 'closed'`,
  requiredWhen: P`record.status == 'closed'`,
  options: [
    { label: 'Won', value: 'won' },
    { label: 'Lost', value: 'lost' },
    { label: 'Cancelled', value: 'cancelled' },
  ],
}),

invoice_total: Field.currency({
  label: 'Invoice Total',
  readonlyWhen: P`record.status == 'paid'`,
}),

Conditional field rules are evaluated in both tiers. ObjectUI forms update visibility, read-only state, and required markers live as the draft record changes, including row-scoped rules inside inline master-detail grids. The server enforces requiredWhen on submit and ignores writes to fields whose readonlyWhen predicate is TRUE, so client-side affordances are not the only guard. Use requiredWhen. The deprecated conditionalRequired alias was removed in protocol 17 (#3855): authoring it is rejected with an error naming the replacement, not silently stripped, and os migrate meta --from 16 lists the mechanical edits for existing sources.

Who the lock applies to — and the derived-field write path

A TRUE readonlyWhen predicate locks the field for every API-boundary caller. Unlike static readonly, passing { context: { isSystem: true } } does not exempt it: a state lock any system-context write could bypass would not be a state lock, and the frozen-paid-invoice case depends on it.

What the lock does not cover is the server's own trusted code. A value a beforeUpdate hook derives — or writes over a key the caller also sent — is a server value, not a caller write, and is never stripped. That is what makes "visible on the form but locked, and recomputed by a hook" a spelling the platform supports on one field:

// The field renders locked, and only the hook may move it.
next_maintenance_date: Field.date({
  label: 'Next Maintenance',
  readonlyWhen: P`true`,
}),
// The hook is the write path.
beforeUpdate: (ctx) => {
  if ('period_days' in ctx.input.data) {
    ctx.input.data.next_maintenance_date = addDays(today, ctx.input.data.period_days);
  }
},

A client cannot use this to launder a write. The engine snapshots the caller's payload at entry, before any middleware or hook runs, and judges a key only while it still holds that value — so echoing the locked key back never exempts it, and if a hook overwrites it, the value that persists is the hook's. Either way the caller's value does not land.

Locking a detail from its master: parent

On a detail object — one that declares a master_detail relationship — a readonlyWhen predicate may read the header record as parent. The canonical case is "once the invoice is Paid, its lines are frozen":

quantity: Field.number({
  label: 'Qty',
  required: true,
  // `parent` = the record on the other end of this object's master_detail field.
  readonlyWhen: P`parent.status == 'paid'`,
}),

The server binds parent on the update path by reading the master the row points at (a repointing write is judged against the master it lands on), so the lock holds against a direct API call, not only in the grid.

Two rules make this predictable:

  • Exactly one master. parent resolves only when the object declares exactly one master_detail relationship. With none — or with two, where the metadata does not say which one is "the parent" — objectstack compile rejects the predicate with an error naming the object.
  • An unresolvable parent means locked, not open. If the header cannot be read at write time, the field is treated as read-only rather than written. A lock the platform could not evaluate is never waived: that is what makes the declaration a guarantee instead of a hint.

Cell formatting: conditionalFormatting

conditionalFormatting styles a field's cell by a rule: an ordered list of { condition, style }, the same rule a list view's conditionalFormatting declares for its rows. The first rule whose CEL condition is TRUE applies its CSS style map to this field's cell wherever the field renders — a grid, a kanban card, the record page, a related list or a report cell. A list view's row rules style the row, and both may apply to one record.

The condition reads two roots: value, this field's value on the record, and record, the row.

import { P } from '@objectstack/spec';
import { Field } from '@objectstack/spec/data';

amount: Field.currency({
  label: 'Amount',
  conditionalFormatting: [
    { condition: P`value < 0`, style: { color: '#b91c1c' } },
  ],
}),

The rules are checked when the metadata is authored, like visibleWhen:

  • A rule is exactly { condition, style }, the condition is not blank, and style maps CSS property names to string values. Anything else is refused at parse.
  • The condition must parse, read other fields as record.<field>, name fields the object declares, and read no root but value and record. objectstack validate, objectstack build and saving the object all refuse anything else, naming the field and the rule.

The rules are for presentation: an amount below zero in red, stock under its safety level in amber, a priority cell coloured by its value. A state with a meaning of its own belongs in a declaration instead — a deadline's overdue state is dueLike with settledWhen, from which the wording and the colour both follow. Nothing on the write path reads the rules.

The console's cell renderers read these rules in a later ObjectUI release. Until then a rule is validated when it is authored and styles no cell.

Naming Conventions

  • Field names use snake_case: first_name, annual_revenue, is_active
  • Boolean fields use is_ or has_ prefix: is_active, has_children
  • Lookup fields use the object name directly: account (not account_id)
  • Configuration keys use camelCase: maxLength, defaultValue

Related