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.jsonWhy 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()withField.*helpers in TypeScript for compile-time type checking and runtime validation.
Object Definition
Minimal Example
name: project
label: ProjectThis 2-line definition creates:
- Database table
projectwith 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
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name. Must be snake_case, unique across the system. |
label | string | ❌ | Display name for UI (singular form). Auto-generated from name if omitted. |
pluralLabel | string | ❌ | Plural form for lists. |
description | string | ❌ | Human-readable description for documentation. |
icon | string | ❌ | Icon identifier (Lucide/Material name). |
datasource | string | ❌ | Target datasource ID. Defaults to "default" (the primary DB). |
enable | object | ❌ | Feature flags (see Capabilities). |
fields | object | ✅ | Field definitions map (see Field Definition). |
validations | array | ❌ | Business validation rules, including state-machine transitions (see Validation). |
indexes | array | ❌ | Composite 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 cloningPerformance Impact:
trackHistory: trueadds write overhead (additional inserts to audit table)searchable: trueadds 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
| Property | Type | Applies To | Description |
|---|---|---|---|
type | string | All | Required. Field type (see Types). |
label | string | All | Required. Display label in UI. |
required | boolean | All | Validation: Field must have a value. |
unique | boolean | All | Enforce uniqueness at database level. |
searchable | boolean | All | Is searchable. |
defaultValue | any | All | Default value when creating new records. |
description | string | All | Tooltip/Help text. |
maxLength | number | text, textarea | Maximum character length. |
minLength | number | text, textarea | Minimum character length. |
min | number | number, currency | Minimum numeric value. |
max | number | number, currency | Maximum numeric value. |
scale | number | number, currency | Decimal places (e.g., 2 for cents). |
precision | number | number, currency | Total digits (including scale). |
options | array | select, multiselect | List of valid values. |
multiple | boolean | select, lookup | Allow multiple selections. |
reference | string | lookup, master_detail | Target object for relationships. |
expression | string | formula | Calculation expression. |
summaryOperations | object | summary | Roll-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 systemCustomizing system fields:
# Override system field behavior
fields:
created_at:
searchable: true # Add to search indexField 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: BiographyNumeric 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: falseSelection 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: trueRelationship 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 deletedValidation 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: warningEach 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 listcoalesce(value, fallback): Returnvalueunless null/undefinedtrim(value): Trim surrounding whitespacelen(value)/size(value): Length of a string or listcontains(s, sub),startsWith(s, sub),endsWith(s, sub),matches(s, regex)today(): Current date (UTC, start of day)now(): Current timestampdaysFromNow(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: ginSupported index type values: btree (default), hash, gin, gist, fulltext.
Index Strategy:
- Add indexes for:
- Foreign keys (lookup fields)
- Fields used in
WHEREclauses - 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/afterInsertbeforeUpdate/afterUpdatebeforeDelete/afterDelete- Query-side:
beforeFind/afterFind,beforeCount/afterCount, and the bulk variantsbeforeUpdateMany/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 expressionMulti-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: trueWhen 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> --installBest 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: currencyField 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: currencyPerformance 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 storageExamples: 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