| 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:
GET/POST/PUT/DELETE /api/project - Admin UI: List view + Form
- TypeScript types
name: project
label: Project
pluralLabel: Projects
description: "A business project or initiative"
icon: standard:case
# color: "#4A90E2" # Removed or check if color exists in ObjectSchemaBase
# bucket: main # Removed or check
# datasource: postgres_primary # Removed or check
enable:
trackHistory: true
searchable: true
apiEnabled: true
activities: true # Replaces workflow?
# offline_sync: true # Removed not in schema
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
condition: "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,
},
stateMachine: {
id: 'project_lifecycle',
initial: 'draft',
states: {
draft: { on: { ACTIVATE: 'active' } },
active: { on: { COMPLETE: 'completed' } },
completed: { type: 'final' }
}
},
validations: [
{
name: 'budget_positive',
type: 'script',
severity: 'error',
message: 'Budget must be positive',
condition: 'budget < 0',
},
],
});| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Machine name. Must be snake_case, unique across the system. |
label |
string |
✅ | Display name for UI (singular form). |
pluralLabel |
string |
❌ | Plural form for lists. Defaults to label + "s". |
description |
string |
❌ | Human-readable description for documentation. |
icon |
string |
❌ | Icon identifier (e.g., standard:case, custom:project). |
datasource |
string |
❌ | External datasource ID. If omitted, uses default database. |
enable |
object |
❌ | Feature flags (see Capabilities). |
fields |
object |
❌ | Field definitions (see Field Definition). |
stateMachine |
object |
❌ | Lifecycle state machine definition (see State Machine). |
validations |
array |
❌ | Business validation rules (see Validation). |
triggers |
array |
❌ | Database triggers (before/after insert/update/delete). |
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
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
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: id
label: Record ID
read_only: true
created_at:
type: datetime
label: Created At
read_only: true
updated_at:
type: datetime
label: Updated At
read_only: true
created_by:
type: lookup
reference: user
label: Created By
# readonly: true # Managed by system
updated_by:
type: lookup
reference: 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
# rows: 10 # UI hint, moved to UI protocol or check if supported
bio:
type: html
label: Biography
# sanitize: true # Remove dangerous HTML tags (Check security config)quantity:
type: number
label: Quantity
min: 0
max: 9999
defaultValue: 1
discount_rate:
type: percent
label: Discount
scale: 2
min_value: 0
max_value: 100
revenue:
type: currency
label: Annual Revenue
scale: 2
precision: 18
default_value: { amount: 0, currency: 'USD' }start_date:
type: date
label: Start Date
required: true
due_datetime:
type: datetime
label: Due Date & Time
default_value: "{{NOW() + 7}}" # 7 days from nowis_active:
type: boolean
label: Active
default_value: true
email_opt_in:
type: checkbox
label: Subscribe to Newsletter
default_value: 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 }
default_value: medium
tags:
type: multi_select
label: Tags
options:
- { value: customer, label: Customer }
- { value: partner, label: Partner }
- { value: vendor, label: Vendor }
multiple: trueaccount_id:
type: lookup
label: Account
reference_to: account
required: true
reference_filters:
- ['is_active', '=', true] # Only show active accounts
project_id:
type: master_detail
label: Project
reference_to: project
cascade_delete: true # Delete tasks when project is deletedValidation rules enforce business logic at the data layer:
validation_rules:
# Simple comparison
- name: end_after_start
condition: "end_date < start_date"
message: "End date must be after start date"
severity: error
# Cross-field validation
- name: discount_requires_approval
condition: "discount > 20 AND approved_by = null"
message: "Discounts over 20% require manager approval"
severity: error
# Conditional validation
- name: enterprise_contract_required
condition: "account_type = 'Enterprise' AND contract_value = null"
message: "Enterprise accounts must have a contract value"
severity: error
active: true
# Warning (non-blocking)
- name: budget_threshold_warning
condition: "budget > 1000000"
message: "Large budget. Please verify approval."
severity: warningFormulas use Excel-like syntax:
Operators:
- Comparison:
=,!=,>,<,>=,<= - Logical:
AND,OR,NOT - Arithmetic:
+,-,*,/,^(power) - String:
&(concatenate)
Functions:
ISBLANK(field): Check if field is emptyLEN(field): String lengthCONTAINS(field, "text"): Substring checkREGEX(field, "pattern"): Regular expressionTODAY(): Current dateNOW(): Current datetime
Examples:
# Date validation
"start_date > TODAY()"
# String validation
"LEN(email) > 0 AND CONTAINS(email, '@')"
# Null check
"NOT(ISBLANK(manager_id))"
# Complex logic
"(status = 'Closed' AND close_date != null) OR status != 'Closed'"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
# Geospatial index
- fields: [location]
type: geoIndex 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)
Execute code on data changes:
triggers:
# Before insert
- event: before_insert
handler: set_owner
code: |
record.owner_id = context.user.id;
# After insert
- event: after_insert
handler: send_welcome_email
async: true
code: |
await emailService.send({
to: record.email,
template: 'welcome'
});
# Before update
- event: before_update
handler: validate_status_change
condition: "old.status != new.status"
code: |
if (old.status === 'Closed' && new.status !== 'Closed') {
throw new Error('Cannot reopen closed records');
}Event Types:
before_insert: Before creating a recordafter_insert: After creating a recordbefore_update: Before updating a recordafter_update: After updating a recordbefore_delete: Before deleting a recordafter_delete: After deleting a record
Reuse field definitions across objects:
# mixins/auditable.mixin.yml
fields:
created_at:
type: datetime
label: Created At
# readonly: true
updated_at:
type: datetime
label: Updated At
# readonly: true
# customer.object.yml
name: customer
extends: [auditable] # Inherit fields from mixin
fields:
company_name:
type: textReference multiple object types:
# activity.object.yml
parent_id:
type: polymorphic
label: Related To
reference: [account, contact, opportunity]Query polymorphic fields:
const activity = await ObjectQL.findOne('activity', id);
// activity.parent_id = { id: '123', _type: 'account' }Fields calculated at query time (not stored):
full_name:
type: formula
label: Full Name
expression: "first_name & ' ' & last_name" # formula -> expression
# virtual: true # Not stored in database (Implied by formula type?)Isolate data by tenant:
name: customer
tenancy:
enabled: true # Automatically filter by tenant_id
fields:
tenant_id:
type: lookup
reference: tenant
required: true
defaultValue: "{{CURRENT_TENANT()}}"Runtime behavior:
// User in Tenant A queries customers
const customers = await ObjectQL.query({ object: 'customer' });
// ObjectQL automatically adds: WHERE tenant_id = 'tenant_a'
// User CANNOT access Tenant B's dataWhen schemas change, ObjectQL generates migrations:
Before:
# v1: customer.object.yml
fields:
name:
type: textAfter:
# v2: customer.object.yml
fields:
first_name:
type: text
migration:
from: name
transform: "SPLIT(name, ' ')[0]"
last_name:
type: text
migration:
from: name
transform: "SPLIT(name, ' ')[1]"Generated migration:
-- PostgreSQL
ALTER TABLE customer ADD COLUMN first_name TEXT;
ALTER TABLE customer ADD COLUMN last_name TEXT;
UPDATE customer SET
first_name = SPLIT_PART(name, ' ', 1),
last_name = SPLIT_PART(name, ' ', 2);
ALTER TABLE customer DROP COLUMN name;# 1. Detect schema changes
objectstack diff
# 2. Generate migration plan
objectstack migrate:plan
# 3. Review migration SQL
cat .objectstack/migrations/001_add_customer_name.sql
# 4. Apply migration
objectstack deploy --migrate
# 5. Rollback if needed
objectstack migrate:rollback# ✅ 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# 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: [...]
# Avoid large text in main table
file_content:
type: text
store: blob_storage # Store in separate table/S3name: 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: 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
condition: "industry = 'finance' AND 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