Skip to content

Latest commit

 

History

History
923 lines (754 loc) · 22.6 KB

File metadata and controls

923 lines (754 loc) · 22.6 KB
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.

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

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

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 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
  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 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 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: 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) — 8 events:

  • beforeInsert / afterInsert
  • beforeUpdate / afterUpdate — fire for single-id and bulk (multi: true) updates
  • beforeDelete / afterDelete — fire for single-id and bulk (multi: true) deletes
  • beforeFind / afterFind — fire for both find and findOne

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

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

# 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: Attachment

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