ObjectStackObjectStack

Object Metadata

Define business entities with ObjectSchema — the core building block of every ObjectStack application

Object Metadata

An Object is the foundational metadata type in ObjectStack. It defines a business entity — its fields, capabilities, indexes, and behaviors. Each Object maps to a database table/collection and automatically gets CRUD APIs, UI forms, and query support.

Basic Structure

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',
  pluralLabel: 'Accounts',
  icon: 'building',
  description: 'Companies and organizations',

  fields: {
    name: Field.text({ label: 'Account Name', required: true }),
    industry: Field.select({
      label: 'Industry',
      options: [
        { label: 'Technology', value: 'technology' },
        { label: 'Finance', value: 'finance' },
        { label: 'Healthcare', value: 'healthcare' },
      ],
    }),
    annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2 }),
    owner: Field.user({ label: 'Owner', required: true }),
  },

  enable: {
    apiEnabled: true,
    searchable: true,
    trackHistory: true,
  },
});

Properties

Identity

PropertyTypeRequiredDescription
namestringMachine name (snake_case). Immutable after creation.
labelstringoptionalHuman-readable singular label (e.g. 'Account')
pluralLabelstringoptionalPlural label (e.g. 'Accounts')
descriptionstringoptionalDeveloper documentation
iconstringoptionalIcon name (Lucide/Material)
tagsstring[]optionalCategorization tags (e.g. ['sales', 'system'])

Data

PropertyTypeRequiredDescription
fieldsRecord<string, Field>Field definitions. Keys must be snake_case.
indexesIndex[]optionalDatabase performance indexes
datasourcestringoptionalTarget datasource ID. Default: 'default'

Display

PropertyTypeRequiredDescription
nameFieldstringoptionalThe stored field used as the record display name, e.g. 'name' or 'title' (ADR-0079). The deprecated alias displayNameField is still accepted.
titleFormatstringoptionalDeprecated (ADR-0079 → nameField). Render-only title template (e.g. '{{record.name}} - {{record.code}}'); an explicit nameField takes precedence
highlightFieldsstring[]optionalMost-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly compactLayout — the old spelling was retired and is now rejected)
stageFieldstring | falseoptionalLinear lifecycle field; false declares the status field non-linear and suppresses stage heuristics (ADR-0085)
recordNameobjectoptionalRecord name auto-generation config

Capabilities (enable)

Control which platform features are active for this object:

enable: {
  trackHistory: true,      // Field history tracking for audit
  searchable: true,        // Include in global search index
  apiEnabled: true,        // Expose via REST/GraphQL APIs
  apiMethods: ['get', 'list', 'create', 'update', 'delete'],
  files: true,             // File attachments
  feeds: true,             // Activity feed and comments
  activities: true,        // Tasks and events tracking
  trash: true,             // Soft delete with restore
  mru: true,               // Most Recently Used tracking
  clone: true,             // Deep record cloning
}
FlagDefaultDescription
trackHistoryfalseField history tracking for audit compliance
searchabletrueIndex records for global search
apiEnabledtrueExpose object via automatic APIs
apiMethodsallWhitelist of allowed API operations
filesfalseEnable file attachments
feedsfalseEnable social feed and comments
activitiesfalseEnable tasks and events tracking
trashtrueEnable soft delete with restore
mrutrueTrack Most Recently Used list
clonetrueAllow record deep cloning

Enterprise Features

Multi-Tenancy

Row-level tenant isolation in a shared database — the tenant field is injected on write and enforced on read. Database-per-tenant isolation is an environment/deployment choice (each environment carries its own database URL), not object metadata.

tenancy: {
  enabled: true,
  tenantField: 'tenant_id',
}

Soft Delete

softDelete: {
  enabled: true,
  field: 'deleted_at',
  cascadeDelete: false,
}

Versioning

versioning: {
  enabled: true,
  strategy: 'snapshot',       // 'snapshot' | 'delta' | 'event-sourcing'
  retentionDays: 365,
  versionField: 'version',
}

Data Lifecycle (Retention & Rotation)

Declares how long the object's data lives and how its space is reclaimed (ADR-0057). The platform LifecycleService (registered by ObjectQLPlugin, default-on) sweeps declared policies hourly. Objects without a lifecycle block keep permanent record semantics — nothing is ever deleted.

// High-frequency telemetry: rotation window + age reap
lifecycle: {
  class: 'telemetry',
  retention: { maxAge: '14d' },
  storage: { strategy: 'rotation', shards: 14, unit: 'day' },
}

// Ephemeral rows: TTL on the natural expiry field
lifecycle: {
  class: 'transient',
  ttl: { field: 'expires_at', expireAfter: '1d' },
}

// Compliance ledger: hot window, then cold storage
lifecycle: {
  class: 'audit',
  retention: { maxAge: '90d' },
  archive: { after: '90d', to: 'archive', keep: '7y' },
}

// Mixed table (live workflow state + terminal history): scope the reap
lifecycle: {
  class: 'telemetry',
  retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } },
}
KeyDescription
classPersistence contract (see table below); record is the implicit default
retention.maxAgeAge-based reap on created_at
retention.onlyWhenRow filter the reap is scoped to (per-field equality or { $in: [...] }); rows outside it are retained regardless of age. Incompatible with rotation storage and archive
ttlPer-row expiry: field + expireAfter
storage{ strategy: 'rotation', shards, unit } — time-shard + O(1) shard DROP (SQLite)
archiveCold-store hand-off: after (must equal retention.maxAge) + to (datasource name) + optional keep
reclaimSpace reclamation after sweeps (default on for non-record)
ClassContractTypical use
recordBusiness truth — permanent, no policies allowedaccounts, invoices
auditCompliance ledger — retain → archive → deleteaudit logs
telemetryHigh-frequency log — rotation / short retentionactivity streams, job runs
transientEphemeral state — TTL auto-expirereceipts, device codes
eventBus messages — very short TTL (hours)scheduled fan-out

Enforcement rules:

  • A non-record class must declare at least one bounding policy (retention, ttl, or rotation storage) — rejected at parse time otherwise. Policies on record are rejected too.
  • Duration literals are <n> + h/d/w/y (e.g. '6h', '14d', '7y'); archive.after must equal retention.maxAge.
  • An archive-declared object is never hot-deleted before the archive copy succeeded. No datasource registered under the archive.to name ⇒ rows are retained (safe default), not dropped.
  • Registering a datasource named telemetry routes every telemetry/event/audit object to it — separate storage, opt-in purely by the datasource's existence.

Operations knobs live in the lifecycle settings namespace: a runtime enabled switch, tenant-scoped retention_overrides (a regulated tenant sets years while dev keeps days), row quotas and growth_alert_rows (observe-and-alert only). OS_LIFECYCLE_DISABLED=1 disables sweeping entirely.

Record Name

Auto-generate unique record identifiers:

recordName: {
  type: 'autonumber',
  displayFormat: 'ACC-{0000}',   // ACC-0001, ACC-0002, ...
  startNumber: 1,
}
PropertyTypeDescription
type'text' | 'autonumber'Generation mode
displayFormatstringPattern (e.g. 'INV-{YYYY}-{0000}')
startNumbernumberStarting number (default: 1)

Indexes

Optimize query performance:

indexes: [
  { fields: ['name'], type: 'btree', unique: false },
  { fields: ['email'], type: 'btree', unique: true },
  { fields: ['type', 'status'], type: 'btree', unique: false },
]
PropertyTypeRequiredDescription
fieldsstring[]Fields in the index
typeenumoptional'btree', 'hash', 'gin', 'gist', 'fulltext' (default: 'btree')
uniquebooleanoptionalEnforce uniqueness (default: false)
partialstringoptionalConditional index (SQL WHERE clause)
namestringoptionalIndex name (auto-generated if omitted)

Additional Properties

PropertyTypeDescription
activebooleanIs object active (default: true)
isSystembooleanSystem object, protected from deletion (default: false)
abstractbooleanAbstract base, cannot be instantiated (default: false)
sharingModelenumOrg-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: 'private', 'public_read', 'public_read_write', 'controlled_by_parent' (detail visibility derived from its master). The legacy aliases ('read', 'read_write', 'full') were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to 'private' (ADR-0090 D1)
keyPrefixstringShort prefix for record IDs (e.g. '001')
validationsValidationRule[]Object-level validation rules (see Validation)

Naming Conventions

ElementConventionExample
Object namesnake_caseproject_task, user_profile
Export constantPascalCaseProjectTask, UserProfile
Config keyscamelCasetrackHistory, apiEnabled

Complete Example

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const ProjectTask = ObjectSchema.create({
  name: 'project_task',
  label: 'Project Task',
  pluralLabel: 'Project Tasks',
  icon: 'check-square',
  description: 'Tasks within a project',

  fields: {
    title: Field.text({ label: 'Title', required: true, maxLength: 255 }),
    description: Field.textarea({ label: 'Description' }),
    status: Field.select({
      label: 'Status',
      options: [
        { label: 'To Do', value: 'todo', default: true },
        { label: 'In Progress', value: 'in_progress' },
        { label: 'Done', value: 'done' },
      ],
    }),
    priority: Field.select({
      label: 'Priority',
      options: [
        { label: 'Low', value: 'low' },
        { label: 'Medium', value: 'medium', default: true },
        { label: 'High', value: 'high' },
      ],
    }),
    due_date: Field.date({ label: 'Due Date' }),
    assignee: Field.user({ label: 'Assignee' }),
    project: Field.lookup('project', { label: 'Project', required: true }),
    estimated_hours: Field.number({ label: 'Estimated Hours', min: 0 }),
  },

  indexes: [
    { fields: ['status'], type: 'btree', unique: false },
    { fields: ['project', 'status'], type: 'btree', unique: false },
  ],

  enable: {
    apiEnabled: true,
    searchable: true,
    trackHistory: true,
    feeds: true,
    trash: true,
  },

  validations: [
    {
      name: 'due_date_future',
      type: 'script',
      severity: 'warning',
      message: 'Due date should be in the future',
      condition: 'record.due_date < today()',
      events: ['insert'],
    },
  ],
});

On this page