ObjectStackObjectStack

Schema Definition

The complete specification for defining objects, fields, and relationships in ObjectQL

In ObjectStack, data structure is configuration, not code. The Schema Definition Protocol governs how you declare your data model using declarative .object.yml files.

Single Source of Truth: This schema drives database DDL, API generation, UI form layouts, permission scopes, and validation rules.

File Structure

The .object.yml Convention

Each business entity is defined in a separate file:

my-app/
├── objects/
│   ├── customer.object.yml       # Customer entity
│   ├── order.object.yml          # Order entity
│   ├── product.object.yml        # Product entity
│   └── invoice.object.yml        # Invoice entity
├── objectstack.config.ts         # Package manifest
└── package.json

Why separate files?

  • Version control: Git shows clear diffs when objects change
  • Team collaboration: Developers can work on different objects simultaneously
  • Code generation: Each file → Database table + API + UI
  • Deployment: Selective schema deployment (only changed objects)

Alternative Formats

ObjectStack supports multiple schema formats:

# YAML (Recommended for readability)
name: customer
label: Customer

# JSON (Machine-generated)
{ "name": "customer", "label": "Customer" }

TypeScript (Recommended for strict validation):

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

export const Customer = ObjectSchema.create({
  name: 'customer',
  label: 'Customer',
  icon: 'building',
  
  fields: {
    name: Field.text({
      label: 'Company Name',
      required: true,
    }),
  },
});

📘 Best Practice: Use ObjectSchema.create() with Field.* helpers in TypeScript for compile-time type checking and runtime validation.

Object Definition

Minimal Example

name: project
label: Project

This 2-line definition creates:

  • Database table project with system fields (id, created_at, updated_at)
  • REST API under /api/v1/data/project (list, get, create, update, delete)
  • Admin UI: List view + Form
  • TypeScript types

Complete Example

name: project
label: Project
pluralLabel: Projects
description: "A business project or initiative"
icon: standard:case
datasource: default        # Datasource ID; "default" is the primary DB
# color / bucket / offline_sync are NOT ObjectSchema fields
enable:
  trackHistory: true
  searchable: true
  apiEnabled: true
  activities: true
fields:
  name:
    type: text
    label: Project Name
    required: true
    maxLength: 255
  status:
    type: select
    label: Status
    options:
      - { value: draft, label: Draft }
      - { value: active, label: Active }
      - { value: completed, label: Completed }
    defaultValue: draft
  budget:
    type: currency
    label: Budget
    scale: 2
    precision: 18
  account_id: # Snake case for field name
    type: lookup
    label: Account
    reference: account
    required: true
validations:
  - name: budget_positive
    type: script
    condition: "record.budget < 0"
    message: "Budget must be positive"

TypeScript Complete Example:

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

export const Project = ObjectSchema.create({
  name: 'project',
  label: 'Project',
  pluralLabel: 'Projects',
  description: 'A business project or initiative',
  icon: 'folder',
  
  fields: {
    name: Field.text({
      label: 'Project Name',
      required: true,
      maxLength: 255,
      searchable: true,
    }),
    
    status: Field.select({
      label: 'Status',
      options: [
        { label: 'Draft', value: 'draft', default: true },
        { label: 'Active', value: 'active' },
        { label: 'Completed', value: 'completed' },
      ],
    }),
    
    budget: Field.currency({
      label: 'Budget',
      scale: 2,
      min: 0,
    }),
    
    account: Field.lookup('account', {
      label: 'Account',
      required: true,
    }),
  },
  
  enable: {
    trackHistory: true,
    searchable: true,
    apiEnabled: true,
    activities: true,
  },

  validations: [
    {
      name: 'budget_positive',
      type: 'script',
      severity: 'error',
      message: 'Budget must be positive',
      condition: 'record.budget < 0',
    },
    // Lifecycle state machine — modelled as a `state_machine` validation rule
    // (ADR-0020), not a separate `stateMachine` map.
    {
      name: 'project_lifecycle',
      type: 'state_machine',
      field: 'status',
      message: 'Illegal status transition',
      transitions: {
        draft: ['active'],
        active: ['completed'],
        completed: [],
      },
    },
  ],
});

Object Properties Reference

PropertyTypeRequiredDescription
namestringMachine name. Must be snake_case, unique across the system.
labelstringDisplay name for UI (singular form). Auto-generated from name if omitted.
pluralLabelstringPlural form for lists.
descriptionstringHuman-readable description for documentation.
iconstringIcon identifier (Lucide/Material name).
datasourcestringTarget datasource ID. Defaults to "default" (the primary DB).
enableobjectFeature flags (see Capabilities).
fieldsobjectField definitions map (see Field Definition).
validationsarrayBusiness validation rules, including state-machine transitions (see Validation).
indexesarrayComposite indexes for query optimization.

Naming Conventions

Object Names (Machine Identifiers):

  • Format: snake_case (lowercase, underscores)
  • Pattern: /^[a-z][a-z0-9_]*$/
  • Examples:customer, project_task, sales_order
  • Invalid:Customer, projectTask, 123project

Object Labels (Display Names):

  • Format: Title Case, human-readable
  • Examples:Customer, Project Task, Sales Order

Capabilities

The enable object controls which features are active:

enable:
  # Data Management
  trackHistory: true         # Track field history (who changed what, when)
  searchable: true           # Index for global search
  # API & Integration
  apiEnabled: true           # Generate REST endpoints
  
  # Collaboration & Activities
  files: true                # Enable file attachments
  feeds: true                # Enable social feed, comments
  activities: true           # Enable tasks and events
  
  # User Experience
  clone: true                # Allow record deep cloning

Performance Impact:

  • trackHistory: true adds write overhead (additional inserts to audit table)
  • searchable: true adds search indexes (storage overhead)

When to enable:

  • trackHistory: Regulated industries, compliance requirements
  • searchable: User-facing search features

Field Definition

Fields are the columns/attributes of your object. Each field has a type and configuration.

Basic Field Structure

fields:
  field_name:
    type: text              # Field type (required)
    label: Field Label      # Display label (required)
    required: false         # Validation
    defaultValue: null      # Default when creating records
    description: "Helper text for users"

Field Properties Reference

PropertyTypeApplies ToDescription
typestringAllRequired. Field type (see Types).
labelstringAllRequired. Display label in UI.
requiredbooleanAllValidation: Field must have a value.
uniqueboolean | 'global' | 'organization'AllEnforce uniqueness at database level, at a stated scope. 'organization' = one holder per organization (true is its positional synonym); 'global' = one holder across the whole installation. See Uniqueness and scope.
searchablebooleanAllIs searchable.
defaultValueanyAllDefault value when creating new records.
descriptionstringAllTooltip/Help text.
maxLengthnumbertext, textareaMaximum character length.
minLengthnumbertext, textareaMinimum character length.
minnumbernumber, currencyMinimum numeric value.
maxnumbernumber, currencyMaximum numeric value.
scalenumbernumber, currencyDecimal places (e.g., 2 for cents).
precisionnumbernumber, currencyTotal digits (including scale).
optionsarrayselect, multiselectList of valid values.
multiplebooleanselect, lookupAllow multiple selections.
referencestringlookup, master_detailTarget object for relationships.
expressionstringformulaCalculation expression.
summaryOperationsobjectsummaryRoll-up summary definition.

System Fields

Every object automatically includes system fields:

# Auto-generated (not defined in .object.yml)
id:
  type: text
  label: Record ID
  readonly: true

created_at:
  type: datetime
  label: Created At
  readonly: true

updated_at:
  type: datetime
  label: Updated At
  readonly: true

created_by:
  type: lookup
  reference: sys_user
  label: Created By
  readonly: true  # Managed by system

updated_by:
  type: lookup
  reference: sys_user
  label: Updated By
  readonly: true  # Managed by system

Customizing system fields:

# Override system field behavior
fields:
  created_at:
    searchable: true  # Add to search index

Field Examples by Type

Text Fields

company_name:
  type: text
  label: Company Name
  required: true
  maxLength: 255
  searchable: true

description:
  type: textarea
  label: Description
  maxLength: 5000

bio:
  type: html
  label: Biography

Numeric Fields

quantity:
  type: number
  label: Quantity
  min: 0
  max: 9999
  defaultValue: 1

discount_rate:
  type: percent
  label: Discount
  scale: 2
  min: 0
  max: 100

revenue:
  type: currency
  label: Annual Revenue
  scale: 2
  precision: 18
  defaultValue: 0   # currency stores a bare number — never { value, currency }

Date/Time Fields

start_date:
  type: date
  label: Start Date
  required: true

due_datetime:
  type: datetime
  label: Due Date & Time
  # A CEL default needs the explicit `{ dialect, source }` envelope. Unlike a
  # formula field's `expression`, `defaultValue` has NO bare-string shorthand —
  # a bare string is stored as that literal text.
  defaultValue: { dialect: 'cel', source: 'daysFromNow(7)' }  # 7 days from now, at UTC midnight

Boolean Fields

is_active:
  type: boolean
  label: Active
  defaultValue: true

email_opt_in:
  type: toggle
  label: Subscribe to Newsletter
  defaultValue: false

Selection Fields

priority:
  type: select
  label: Priority
  options:
    - { value: low, label: Low, color: green }
    - { value: medium, label: Medium, color: yellow }
    - { value: high, label: High, color: orange }
    - { value: critical, label: Critical, color: red }
  defaultValue: medium

tags:
  type: multiselect
  label: Tags
  options:
    - { value: customer, label: Customer }
    - { value: partner, label: Partner }
    - { value: vendor, label: Vendor }
  multiple: true

Relationship Fields

account_id:
  type: lookup
  label: Account
  reference: account
  required: true
  lookupFilters:
    - { field: is_active, operator: eq, value: true }  # Only show active accounts

project_id:
  type: master_detail
  label: Project
  reference: project
  deleteBehavior: cascade  # Delete tasks when project is deleted

Validation Rules

Validation rules enforce business logic at the data layer:

validations:
  # Simple comparison
  - name: end_after_start
    type: script
    condition: "record.end_date < record.start_date"
    message: "End date must be after start date"
    severity: error
  
  # Cross-field validation
  - name: discount_requires_approval
    type: cross_field
    condition: "record.discount > 20 && record.approved_by == null"
    fields: [discount, approved_by]
    message: "Discounts over 20% require manager approval"
    severity: error
  
  # Conditional validation
  - name: enterprise_contract_required
    type: script
    condition: "record.account_type == 'enterprise' && record.contract_value == null"
    message: "Enterprise accounts must have a contract value"
    severity: error
    active: true
  
  # Warning (non-blocking)
  - name: budget_threshold_warning
    type: script
    condition: "record.budget > 1000000"
    message: "Large budget. Please verify approval."
    severity: warning

Each rule has a type (script, cross_field, format, json_schema, conditional, or state_machine). The condition is a CEL predicate — when it evaluates to true, the rule fails and message is shown.

Validation Formula Syntax

Conditions are written in CEL (Common Expression Language). Fields are referenced through the record binding (e.g. record.budget). A condition that evaluates to true means the rule fails and the message is shown.

Operators:

  • Comparison: ==, !=, >, <, >=, <=
  • Logical: &&, ||, !
  • Arithmetic: +, -, *, /, %
  • String concatenation: +

Built-in functions (from the formula stdlib):

  • isBlank(value): Check if a value is null, empty string, or empty list
  • coalesce(value, fallback): Return value unless null/undefined
  • trim(value): Trim surrounding whitespace
  • len(value) / size(value): Length of a string or list
  • contains(s, sub), startsWith(s, sub), endsWith(s, sub), matches(s, regex)
  • today(): Current date (UTC, start of day)
  • now(): Current timestamp
  • daysFromNow(n), daysAgo(n): Relative timestamps

Examples:

# Date validation (fails when start_date is in the past)
"record.start_date < today()"

# String validation (fails when email is missing or has no '@')
"isBlank(record.email) || !record.email.contains('@')"

# Null check (fails when manager is unset)
"isBlank(record.manager_id)"

# Complex logic
"record.status == 'closed' && record.close_date == null"

Indexes

Optimize query performance with indexes:

indexes:
  # Single-field unique index — state the scope (ADR-0120)
  - fields: [email]
    unique: organization
  
  # Composite index
  - fields: [account_id, status]
    name: idx_account_status
  
  # Full-text search index
  - fields: [name, description]
    type: fulltext
  
  # GIN index (e.g. for JSON / array columns)
  - fields: [attributes]
    type: gin

Supported index type values: btree (default), hash, gin, gist, fulltext.

Index Strategy:

  • Add indexes for:
    • Foreign keys (lookup fields)
    • Fields used in WHERE clauses
    • Fields used in ORDER BY
    • Unique constraints
  • Avoid indexes for:
    • Low-cardinality fields (boolean, status with 2-3 values)
    • Fields that change frequently
    • Large text fields (use full-text search instead)

Uniqueness and scope

unique states which boundary the value must be unique within. There are exactly two boundaries, and the same two words work on a field and on a declared index (ADR-0120):

fields:
  code:
    type: autonumber
    autonumberFormat: 'PROD-{00000}'
    unique: organization   # each organization may hold its own PROD-00001
  hostname:
    type: text
    unique: global         # no two organizations may claim the same hostname

'organization' matches every other organization-aware part of the platform: reads are filtered by the organization predicate, writes stamp the organization column, and the auto-number sequence gives each organization a counter starting at 1. An installation-wide index would contradict the sequence outright — the second organization's PROD-00001 would be rejected by an index it cannot see, and the rejection itself would reveal that some other organization holds the value.

Use 'global' for the identifiers that genuinely are installation-wide: a DNS hostname, a reserved slug, an external provider id, a device identity, an engine dedup key.

Field-level unique: true is the positional synonym of 'organization' and stays valid indefinitely; 'organization' is simply the preferred spelling in new code.

The organization key part is NULL-safe. It materializes as COALESCE(organization_id, '__global__'), so rows carrying no organization — platform rows, and every row on a single-organization deployment — form one platform bucket that is unique among itself. A plain (organization_id, field) composite would enforce nothing on those rows, because SQL UNIQUE treats every NULL as distinct. The value stored in the column is still NULL; the sentinel exists only inside the index key, so WHERE organization_id = '__global__' matches nothing by design.

On an object with no organization column (tenancy: { enabled: false }, or simply no such field) 'organization' degrades to the listed columns alone — identical to 'global' there.

Declared indexes use the same vocabulary:

indexes:
  - fields: [department, code]        # unique per organization —
    unique: organization              # you do NOT list the organization column
  - fields: [hostname]                # unique across the whole installation
    unique: global

Bare unique: true on a declared index is the deprecated spelling of 'global' (materialized over exactly the listed columns). It is warned by os lint / os build / os validate as unique/unscoped-declared-index and rejected at protocol 18 — state the scope instead. A legacy index that lists the organization column itself (fields: [organization_id, code]) keeps working unchanged; respelling its scope to 'organization' makes the listed column NULL-safe in place.

Lifecycle Hooks

Record-triggered logic is not an object-schema field — triggers (and hooks, workflows) are rejected at build time. Instead, author a lifecycle hook in its own src/objects/<name>.hook.ts module with defineHook() and export it (or model the automation as a top-level record_change flow).

// src/objects/customer.hook.ts
import { defineHook, HookContext } from '@objectstack/spec/data';

export default defineHook({
  name: 'customer_logic',
  object: 'customer',
  events: ['beforeInsert', 'afterInsert', 'beforeUpdate'],
  handler: async (ctx: HookContext) => {
    // ctx exposes the input, the previous record (on update), the event, and
    // the session — e.g. set an owner on insert, send an email on afterInsert,
    // guard a status transition on beforeUpdate, etc.
  },
});

Prefer the factory over a bare : Hook literal (the same rule as defineDatasource): it validates when the module is imported, so constraint-level mistakes a bare annotation can't catch — a non-snake_case name, a misspelled key routed through a spread — fail while you author instead of at deploy, and the export carries defaults already materialized.

Event Types (camelCase) — 8 events:

  • beforeInsert / afterInsert
  • beforeUpdate / afterUpdate — fire for single-id and bulk (multi: true) updates
  • beforeDelete / afterDelete — fire for single-id and bulk (multi: true) deletes
  • beforeFind / afterFind — fire for both find and findOne

There are no per-method (findOne / count / aggregate) or *Many events: read authorization and row filtering are RLS/permission-rule concerns, field masking is field-level metadata, and bulk writes reuse the singular write events.

Advanced Features

Object Extensions

One package can add fields, validations, or indexes to an object owned by another package via the package-level objectExtensions array (declared in the stack/package manifest, not on the object schema itself):

objectExtensions: [
  {
    extend: 'contact',                       // target object to extend
    fields: {
      sales_stage: Field.select(['new', 'qualified', 'won']),
    },
  },
];

Multiple packages may extend the same object; extensions are merged at boot time by priority (higher wins on conflict). There is no extends: key or .mixin.yml inheritance mechanism on an object schema.

Computed Fields (Virtual)

Fields calculated at query time (not stored):

full_name:
  type: formula
  label: Full Name
  expression: "record.first_name + ' ' + record.last_name"  # CEL expression

Multi-Tenant Schemas

Row-level isolation runs on one platform column: organization_id, a lookup to sys_organization. The registry injects it into every registered object that has not opted out (hidden, readonly, required: false — it is server-populated on insert and stays NULL where no organization context exists), so a tenant-scoped object declares nothing about tenancy:

name: customer
fields:
  name:
    type: text
    required: true

The column's existence does not depend on the deployment's tenancy posture — only its index does, since nothing filters by organization on a single-organization stack. An object opts out by declaring tenancy.enabled: false (or systemFields.tenant: false), which withholds both the column and the tenant filter — the posture for a platform-global catalog that must stay readable across organizations:

name: currency_rate
tenancy:
  enabled: false      # platform-global: no organization_id, never tenant-scoped
fields:
  code:
    type: text
    required: true

tenantField is not part of the normal path and carries no default — leave it undeclared and the driver scopes by organization_id, the same column the RLS predicates and RLS.tenantPolicy() assume. Declare it only for an object whose tenant column genuinely is not organization_id (tenantField: workspace_id), and only when the object really carries that field: a declared name for a column that does not exist is ignored and the organization_id fallback applies.

Enforcement is not a per-object RLS policy. The organization wall is Layer 0 of the authorization kernel — its own code path, always first, AND-composed ahead of and independently of business RLS, so no permissive policy, sharing rule or viewAllRecords / modifyAllRecords superuser bit can widen it (ADR-0095 D1). What it filters is decided by the deployment's tenancy posture, selected with OS_TENANCY_POSTURE (ADR-0105 D1): single leaves the layer inert, group scopes to organization_id IN accessible_org_ids, isolated to organization_id = <active organization> — so under either walled posture a user in organization A cannot see organization B's records. See Tenancy Postures & Membership.

Schema Versioning

Deployment Workflow

Schema changes flow through the os CLI: validate the config against the protocol schema, compile it to a JSON artifact, compare it against the previously deployed config to surface breaking changes, then publish the artifact to ObjectStack Cloud.

# 1. Validate the config against the protocol schema
os validate

# 2. Compile to a deployable JSON artifact
os build

# 3. Compare two configs to detect breaking changes
os diff ./before.json ./after.json

# 4. Publish the compiled artifact as a versioned package to ObjectStack Cloud
os package publish

# 5. Roll back / forward by installing a prior package version
os package publish ./before.json --env <env-id> --install

Best Practices

Naming Conventions

# ✅ Good
name: project_task
fields:
  assigned_to_id:  # Suffix _id for lookups
    type: lookup
  is_active:       # Prefix is_ for booleans
    type: boolean
  total_amount:    # Descriptive, specific
    type: currency

# ❌ Bad
name: ProjectTask  # Not snake_case
fields:
  user:            # Ambiguous (assigned? created?)
    type: lookup
  active:          # Missing is_ prefix
    type: boolean
  amount:          # Too generic
    type: currency

Field Organization

Group related fields:

fields:
  # Identity
  name:
    type: text
  description:
    type: textarea
  
  # Relationships
  account_id:
    type: lookup
  owner_id:
    type: lookup
  
  # Status & Lifecycle
  status:
    type: select
  stage:
    type: select
  
  # Financial
  budget:
    type: currency
  actual_cost:
    type: currency

Performance Optimization

# Enforce uniqueness on frequently queried fields; declare additional
# query indexes in the object-level `indexes[]` array
email:
  type: text
  unique: organization

# Use appropriate field types
status:
  type: select  # Better than text for fixed values
  options: [...]

# Store large binary content as a file/attachment instead of inline text
attachment:
  type: file
  label: Attachment

Examples: Real-World Schemas

CRM: Account Object

name: account
label: Account
pluralLabel: Accounts
icon: standard:account
enable:
  trackHistory: true
  searchable: true
  apiEnabled: true

fields:
  # Company Information
  company_name:
    type: text
    label: Company Name
    required: true
    maxLength: 255
    searchable: true
  
  website:
    type: url
    label: Website
  
  industry:
    type: select
    label: Industry
    options:
      - { value: tech, label: Technology }
      - { value: finance, label: Financial Services }
      - { value: healthcare, label: Healthcare }
      - { value: retail, label: Retail }
  
  # Contact Info
  billing_address:
    type: address
    label: Billing Address
  
  phone:
    type: phone
    label: Phone
  
  # Financial
  annual_revenue:
    type: currency
    label: Annual Revenue
    scale: 2
    precision: 18
  
  # Relationships
  owner_id:
    type: lookup
    label: Account Owner
    reference: sys_user
    required: true
  
  parent_account_id:
    type: lookup
    label: Parent Account
    reference: account
  
  # Metrics (Computed)
  total_opportunities:
    type: summary
    label: Total Opportunities
    summaryOperations:
      object: opportunity
      function: count
  
  total_opportunity_value:
    type: summary
    label: Total Opportunity Value
    summaryOperations:
      object: opportunity
      field: amount
      function: sum

validations:
  - name: enterprise_revenue_required
    type: script
    condition: "record.industry == 'finance' && record.annual_revenue == null"
    message: "Financial services accounts must have revenue"

E-Commerce: Product Object

name: product
label: Product
icon: standard:product
datasource: mongodb_catalog  # Use MongoDB for flexible schema

fields:
  sku:
    type: text
    label: SKU
    required: true
    unique: organization
    searchable: true
  
  name:
    type: text
    label: Product Name
    required: true
    maxLength: 255
  
  description:
    type: html
    label: Description
  
  category_id:
    type: lookup
    label: Category
    reference: category
  
  price:
    type: currency
    label: Price
    required: true
  
  inventory_qty:
    type: number
    label: Inventory Quantity
    min: 0
  
  attributes:
    type: json
    label: Product Attributes
    # schema: ... # Advanced JSON schema if supported
  
  is_active:
    type: boolean
    label: Active
    defaultValue: true

indexes:
  - fields: [category_id, is_active]
  - fields: [name, description]
    type: fulltext

Next Steps

On this page