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
# 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 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 | 'global' | 'organization' | All | Enforce 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. |
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: 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 midnightBoolean 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
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
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 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: 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)
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: globalBare 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/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.
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
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: trueThe 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: truetenantField 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> --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
# 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: AttachmentExamples: 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