| title | Schema Definition |
|---|---|
| description | The complete specification for defining objects, fields, and relationships in ObjectQL |
import { FileCode, Database, Layers, Package } from 'lucide-react';
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.
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)
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.
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
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: [],
},
},
],
});| 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. |
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
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/GraphQL 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 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
Fields are the columns/attributes of your object. Each field has a type and configuration.
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"| 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. |
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 indexcompany_name:
type: text
label: Company Name
required: true
maxLength: 255
searchable: true
description:
type: textarea
label: Description
maxLength: 5000
bio:
type: html
label: Biographyquantity:
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' }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)is_active:
type: boolean
label: Active
defaultValue: true
email_opt_in:
type: toggle
label: Subscribe to Newsletter
defaultValue: falsepriority:
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: trueaccount_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 deletedValidation 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.
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"Optimize query performance with indexes:
indexes:
# Single-field unique index
- 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)
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) — 8 events:
beforeInsert/afterInsertbeforeUpdate/afterUpdate— fire for single-id and bulk (multi: true) updatesbeforeDelete/afterDelete— fire for single-id and bulk (multi: true) deletesbeforeFind/afterFind— fire for bothfindandfindOne
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.
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.
Fields calculated at query time (not stored):
full_name:
type: formula
label: Full Name
expression: "record.first_name + ' ' + record.last_name" # CEL expressionIsolate 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 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# ✅ 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: currencyGroup 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# Enforce uniqueness on frequently queried fields; declare additional
# query indexes in the object-level `indexes[]` array
email:
type: text
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: Attachmentname: 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"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