ObjectStackObjectStack

Schema Design

Design robust object schemas in ObjectStack — object metadata, enable flags, field groups, and enterprise best practices

Schema Design

Complete guide to designing robust data models in ObjectStack following enterprise best practices.


Object Schema Design

Basic Object Structure

Every object definition follows this pattern:

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

export const MyObject = ObjectSchema.create({
  // Metadata
  name: 'my_object',              // Machine name (snake_case)
  label: 'My Object',             // Display name
  pluralLabel: 'My Objects',      // Plural form
  icon: 'briefcase',              // Icon identifier
  description: 'Description...',   // Help text
  
  // Display configuration
  nameField: 'field1',            // Canonical title field (ADR-0079)
  highlightFields: ['field1', 'field2', 'field3'],
  
  // Fields definition
  fields: {
    // ... field definitions
  },
  
  // Performance
  indexes: [...],
  
  // Capabilities
  enable: {...},
  
  // Business rules
  validations: [...],
});

Object Metadata

PropertyTypeDescriptionExample
namestringMachine name (snake_case)'account'
labelstringDisplay name'Account'
pluralLabelstringPlural display name'Accounts'
iconstringIcon identifier'building'
descriptionstringHelp text'Companies...'
nameFieldstringCanonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias displayNameField is still accepted'name'
titleFormatstringDeprecated (ADR-0079 → nameField). Render-only title template ({{record.field}} interpolation); an explicit nameField now takes precedence'{{record.name}} - {{record.id}}'
highlightFieldsstring[]Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly compactLayout; the old spelling was retired and is now rejected)['name', 'status']

Enable Features

Control which features are available for an object:

enable: {
  trackHistory: true,        // Track field changes over time
  searchable: true,          // Include in global search
  apiEnabled: true,          // Expose via REST/GraphQL
  apiMethods: [              // Whitelist API operations — six primitives only;
    'get',                   // search/export/… DERIVE from them (list grants
    'list',                  // aggregate/search/export; create+update grants
    'create',                // upsert/import). undefined = all, [] = none.
    'update',
    'delete'
  ],
  files: true,               // Allow file attachments
  feeds: true,               // Enable activity feed (Chatter-like)
  activities: true,          // Track tasks and events
}

Global search — searchable + searchableFields

searchable: true includes an object's records in global search and the record picker. By default the search matches the name/title field plus short-text fields. To control exactly which fields are matched, set searchableFields on the object (ADR-0061):

{
  name: 'account',
  enable: { searchable: true },
  searchableFields: ['name', 'website', 'billing_city'],
}

Queries then pass a top-level $search parameter to match across searchableFields (e.g. { $search: 'acme' }); a request may narrow the set with $searchFields. When searchableFields is unset, search falls back to the name/title field plus short-text fields.

$search scans the queried object's own columns. A dotted path such as project_id.name is not a search target: unlike $select / $orderby / $filter, the search axis does not resolve traversal, and a dotted entry is refused rather than silently dropped (#4254). That refusal is deliberate, not a missing feature — cross-object search paths are rejected by design.

The declarative answer is a mirror field: copy the related record's title into a stored field on this object, and make that field the search target. To let users search a task list by project name:

// `project_name` is a stored, denormalized copy of the parent's title.
{
  name: 'task',
  enable: { searchable: true },
  fields: {
    name:         { type: 'text', required: true },
    project_id:   { type: 'lookup', reference: 'project' },
    project_name: { type: 'text', label: 'Project Name' },   // ← the mirror
  },
  searchableFields: ['name', 'project_name'],
}

?search=apollo now expands to name $contains 'apollo' OR project_name $contains 'apollo' — one single-table scan, on every driver, with no traversal. If the object declares no searchableFields at all, a text mirror is picked up by the auto-default anyway; declare the set explicitly when you want to pin it.

The mirror must be a stored field — a formula field does not work. A formula field is virtual: no driver materializes a column for it, so a $contains predicate against one has nothing to scan (the SQL driver would emit a WHERE over a column that does not exist). A CEL formula also only reads this record's own fields (record.<field>), so it cannot fetch the related title in the first place.

Since #6674 the mistake is refused rather than silent: a formula entry in any searchableFields — the object's own set included — is an os validate error (searchable-field-unsearchable), and a request naming one is 400 INVALID_FIELD. It used to clear both and then match nothing, which read as search coverage and delivered none.

Keeping the mirror fresh. A mirror is denormalized data, only as current as whatever maintains it. Two write paths have to be covered:

WhenWhat maintains the mirror
A task is created, or re-pointed at another projectbeforeInsert / beforeUpdate hook on task — read the parent's name for the incoming project_id and stamp project_name
A project is renamedafterUpdate hook on project — re-stamp project_name on that project's tasks

Rows written by a path that bypasses hooks (bulk import, direct SQL) need a one-off backfill. See Hooks for the hook shapes.

The errors you get if you try the dotted path. Both the lint and the runtime send you to the same fix, so either message is greppable back to this section.

os validate reports searchable-field-unknown:

searchableFields entry "project_id.name" is not a field on object "task". The
declaration is stale: searching it can never match, and the engine silently
drops it — leaving a narrower search than declared, or the auto-default set once
every entry is dropped.

hint: 'search' scans this object's own columns, so a related record's column
cannot be a search target — expand the relation and search the related object,
or copy the value onto a stored text field here. Clients echo this declaration
verbatim as the '$searchFields' override, so a stale entry becomes a 400
INVALID_FIELD on list search (#4254), not just a quietly narrowed one.

A request that sends the dotted path is 400 INVALID_FIELD:

Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
columns 'search' scans, so a name the object does not declare cannot narrow
anything — and the engine used to drop it and scan the default columns instead,
answering a NARROWER search with a WIDER one. 'search' scans this object's own
columns; a related record's column cannot be a search target.

If the dotted path is in the object's own searchableFields (so clients echo it back verbatim), the same 400 arrives under its stale-declaration wording instead: Field 'project_id.name' on object 'task' is declared in 'searchableFields' but does not exist.


Field Types & Configuration

ObjectStack fields cover text, numeric, date & time, boolean, select, lookup, address, location, and other specialized data, each configured through Field.* factory options. See Field Metadata for how to configure fields and their common properties, and the Field Type Gallery for the complete per-type reference.

AutoNumber Field

Generates sequential numbers automatically:

Field.autonumber({
  label: 'Account Number',
  autonumberFormat: 'ACC-{0000}',   // ACC-0001, ACC-0002, ...
})

// The format string is literal text plus {…} tokens:
//   {0000}                            — the sequence counter, zero-padded (at most one)
//   {YYYY} {YY} {MM} {DD} {YYYYMMDD}  — generation date in the business timezone
//   {field_name}                      — another field on the same record
// The counter is scoped to whatever renders before the {0000} slot, so
// 'AD{YYYYMMDD}{0000}' resets daily and '{plan_no}{000}' counts per parent —
// no separate reset config. A fixed prefix like 'INV-{000}' keeps one global counter.

User Fields

Pick a person — the equivalent of Airtable's Collaborator or Salesforce's Lookup(User). A user field is a lookup specialized to the built-in sys_user object: it stores the user's id (a real foreign key), resolves to the user's name/avatar via $expand, and renders as a searchable people picker. You never reference sys_user by hand.

// Single assignee
Field.user({ label: 'Assignee' })

// Collaborators / watchers (multiple)
Field.user({ label: 'Watchers', multiple: true })

// Auto-fill the acting user on create (record owner / reporter)
Field.user({ label: 'Owner', defaultValue: 'current_user' })

defaultValue: 'current_user' stamps the authenticated user's id at insert time (the people-field counterpart to 'NOW()' for timestamps). Because a user field is stored exactly like a lookup, an existing Field.lookup('sys_user') is equivalent at the storage layer — adopt Field.user() with no data migration. Record ownership and row-level security continue to use the existing owner_id convention.


Validation Rules

Validation rules are CEL predicates that run against the record on save — when a script rule's condition evaluates to TRUE, validation fails and the configured message is raised at error, warning, or info severity. Uniqueness is enforced with a unique index or a field-level unique flag, not a validation type. See Validation Metadata for all validation types and Field Validation Rules for the built-in constraints of each field type.


Formula Fields

Formula fields calculate values automatically. Expressions are written in CEL, reference fields as record.<field>, and go in the expression property — the field type is already formula, so don't pass type. See Expressions (CEL) for the expression language, standard library, and cookbook.


Field Groups

Organize related fields into logical groups for forms, detail pages, and editors. The group protocol is intentionally minimal (MVP):

  • ObjectSchema.fieldGroups declares the groups; array order is the display order (no separate order property).
  • Field.group assigns a field to a group by referencing an ObjectFieldGroup.key. In-group display order equals the traversal order of fields.
  • Fields whose group is unset (or references an undeclared key) render in a default bucket after the declared groups.
import { ObjectSchema } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',

  fieldGroups: [
    { key: 'contact_info', label: 'Contact Information', icon: 'user' },
    { key: 'billing',      label: 'Billing', collapse: 'collapsed' },
    { key: 'system',       label: 'System' },
  ],

  fields: {
    name:            { type: 'text',  required: true, group: 'contact_info' },
    email:           { type: 'email',                  group: 'contact_info' },
    phone:           { type: 'phone',                  group: 'contact_info' },
    vat_id:          { type: 'text',                   group: 'billing' },
    billing_address: { type: 'address',                group: 'billing' },
    created_at:      { type: 'datetime', readonly: true, group: 'system' },
    created_by:      { type: 'user', reference: 'sys_user', readonly: true, group: 'system' },
  },
});

ObjectFieldGroup Properties

PropertyTypeDescription
keystring (snake_case)Group machine key; referenced by Field.group
labelstringHuman-readable group header
iconstring?Lucide/Material icon name for the group header
descriptionstring?Optional description under the header
collapse'none' | 'expanded' | 'collapsed' (default 'none')Collapse behaviour on every surface: 'none' = always open (no toggle), 'expanded' = collapsible and starts open, 'collapsed' = collapsible and starts closed (ADR-0085; replaces the deprecated defaultExpanded flag, which is still accepted as an alias)

Supported Migrations (MVP)

✅ Add / rename / delete / reorder groups — edit the fieldGroups array. ✅ Assign an existing field to a group — set Field.group.

⏳ Deferred (future iterations): explicit per-field in-group ordering, nested groups, group-level visibility predicates. Groups render identically on forms, modals, and detail pages; when a single page needs a bespoke layout beyond groups, assign it a custom Page schema instead of adding per-surface hints here (ADR-0085).


Best Practices

1. Naming Conventions

DO:

  • Use snake_case for field names: first_name, account_number
  • Use descriptive names: annual_revenue not rev
  • Use boolean prefixes: is_active, has_children

DON'T:

  • Use camelCase for field names
  • Use abbreviations: acc_num
  • Add suffixes to lookups: account_id (use account)

2. Field Design

DO:

  • Use appropriate field types
  • Set reasonable max lengths
  • Add help text for complex fields
  • Use picklists instead of text when values are fixed
  • Mark required fields

DON'T:

  • Store multiple values in one field
  • Use text fields for dates/numbers
  • Create too many fields (split into related objects)

3. Relationships

DO:

  • Use lookup fields for relationships
  • Set up proper cascade delete rules
  • Use filtered lookups to improve UX
  • Document relationship cardinality

DON'T:

  • Store IDs as text fields
  • Create circular references
  • Over-normalize (balance normalization vs. performance)

4. Performance

DO:

  • Add indexes on frequently queried fields
  • Use formula fields for calculations
  • Limit related list queries
  • Use compound indexes for multi-field filters

DON'T:

  • Create too many indexes (slows writes)
  • Put indexes on low-cardinality fields
  • Run ObjectQL queries inside loops (N+1 queries)

5. Validation

DO:

  • Validate at the field level when possible
  • Use validation rules for complex logic
  • Provide clear error messages
  • Test validation rules thoroughly

DON'T:

  • Duplicate validations
  • Create overly complex rules
  • Block legitimate data entry

Real-World Examples

Account Object

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',
  pluralLabel: 'Accounts',
  icon: 'building',
  
  fields: {
    account_number: Field.autonumber({
      label: 'Account Number',
      autonumberFormat: 'ACC-{0000}',
    }),
    
    name: Field.text({
      label: 'Account Name',
      required: true,
      searchable: true,
      maxLength: 255,
    }),
    
    type: Field.select({
      label: 'Type',
      options: [
        { label: 'Prospect', value: 'prospect', default: true },
        { label: 'Customer', value: 'customer' },
        { label: 'Partner', value: 'partner' },
      ]
    }),
    
    annual_revenue: Field.currency({
      label: 'Annual Revenue',
      scale: 2,
      min: 0,
    }),
    
    billing_address: Field.address({
      label: 'Billing Address',
    }),
    
    owner: Field.user({
      label: 'Account Owner',
      required: true,
    }),
    
    is_active: Field.boolean({
      label: 'Active',
      defaultValue: true,
    }),
  },
  
  indexes: [
    { fields: ['name'], unique: false },
    { fields: ['owner'], unique: false },
    { fields: ['type', 'is_active'], unique: false },
  ],
  
  enable: {
    trackHistory: true,
    searchable: true,
    apiEnabled: true,
    files: true,
    feeds: true,
  },
  
  validations: [
    {
      name: 'revenue_positive',
      type: 'script',
      severity: 'error',
      message: 'Revenue must be positive',
      condition: 'record.annual_revenue < 0',
    },
  ],
});

Next: Automation →

On this page