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
  trash: true                # Enable soft-delete with restore capability
  
  # API & Integration
  apiEnabled: true           # Generate REST/GraphQL endpoints
  
  # Collaboration & Activities
  files: true                # Enable file attachments
  feeds: true                # Enable social feed, comments
  activities: true           # Enable tasks and events
  
  # User Experience
  mru: true                  # Track Most Recently Used list
  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.
uniquebooleanAllEnforce uniqueness at database level.
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: { value: 0, currency: 'USD' }

Date/Time Fields

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

due_datetime:
  type: datetime
  label: Due Date & Time
  defaultValue: "daysFromNow(7)"  # 7 days from now (CEL)

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
  referenceFilters:
    - "is_active = 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 index (also via field.index: true)
  - fields: [email]
    unique: true
  
  # 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)

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 as a typed Hook and export it (or model the automation as a top-level record_change flow).

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

const customerHook: Hook = {
  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.
  },
};

export default customerHook;

Event Types (camelCase):

  • beforeInsert / afterInsert
  • beforeUpdate / afterUpdate
  • beforeDelete / afterDelete
  • Query-side: beforeFind / afterFind, beforeCount / afterCount, and the bulk variants beforeUpdateMany / beforeDeleteMany, etc.

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

Isolate data by tenant:

name: customer
tenancy:
  enabled: true       # Automatically filter by the tenant field (row-level isolation)
  tenantField: tenant_id
fields:
  tenant_id:
    type: lookup
    reference: tenant
    required: true

When the kernel runs in multi-tenant mode (OS_MULTI_ORG_ENABLED=true), the registry also auto-injects an organization_id lookup on every user object, and the default tenant_isolation RLS policy scopes reads/writes to the caller's tenant — so a user in Tenant A cannot see Tenant B's records.

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 ObjectOS 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 ObjectOS 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

# Index frequently queried fields
email:
  type: text
  index: true
  unique: true

# 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
  fileAttachmentConfig:
    storageProvider: s3  # Offload to object storage

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: true
    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