Skip to content

Latest commit

 

History

History
438 lines (369 loc) · 19.9 KB

objects.mdx

File metadata and controls

438 lines (369 loc) · 19.9 KB
title Object metadata — define your data schema
navTitle Object Metadata
description Define business entities with ObjectSchema — the core building block of every ObjectStack application

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

{/* os:check */}

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

export const Account = ObjectSchema.create({
  name: 'account',
  sharingModel: 'private',
  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' }),
    owner: Field.user({ label: 'Owner', required: true }),
  },

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

Properties

Identity

Property Type Required Description
name string ✅ Machine name (snake_case). Immutable after creation.
label string optional Human-readable singular label (e.g. 'Account')
pluralLabel string optional Plural label (e.g. 'Accounts')
description string optional Developer documentation
icon string optional Icon name (Lucide/Material)

Data

Property Type Required Description
fields Record<string, Field> ✅ Field definitions. Keys must be snake_case.
indexes Index[] optional Database performance indexes
datasource string optional Target datasource ID. Default: 'default'

Display

Property Type Required Description
nameField string optional The stored field used as the record display name, e.g. 'name' or 'title' (ADR-0079). The deprecated alias displayNameField is still accepted.
titleFormat string optional Deprecated (ADR-0079 → nameField). Render-only title template (e.g. '{{record.name}} - {{record.code}}'); an explicit nameField takes precedence. An object definition that declares it is refused at save and by os validate; a stored object that carries it still loads
imageField string optional The record's picture: names a field of this object whose type is image or avatar — any other name is refused when the object is validated or published. It is the one object-level declaration the record page header draws beside the title; a record whose field is empty shows no picture.
highlightFields string[] optional Most-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)
stageField string | false optional Linear lifecycle field; false declares the status field non-linear and suppresses stage heuristics (ADR-0085)

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 object via automatic APIs (REST)
  apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
  files: true,             // File attachments
  feeds: true,             // Activity feed and comments
  activities: true,        // Tasks and events tracking
  clone: true,             // Deep record cloning
}
Flag Default Description
trackHistory false Field history tracking for audit compliance
searchable true Index records for global search
apiEnabled true Expose object via automatic APIs
apiMethods all Whitelist over the six primitives (get/list/create/update/delete/bulk); derived verbs (search/export/upsert/…) follow automatically. undefined = all, [] = none
files false Enable file attachments
feeds true Enable social feed and comments
activities true Enable tasks and events tracking
clone true Allow 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,
}

The tenant column defaults to nothing at the spec level: leave tenantField undeclared and the driver scopes by organization_id — the kernel-injected column the RLS predicates use. Declare it only when an object's tenant column genuinely is not organization_id, and only when the object really has that field (a declared name for a column that does not exist is ignored, and the organization_id fallback applies):

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

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'] } } },
}

// TTL that spares terminal rows kept for audit: key on a value's ABSENCE.
// A tombstone stamps `revoked_at` (and may backdate `expires_at`, making it
// look maximally expired), so the null predicate is the only property that
// distinguishes the live rows the TTL should reap.
lifecycle: {
  class: 'transient',
  ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } },
}
Key Description
class Persistence contract (see table below); record is the implicit default
retention.maxAge Age-based reap on created_at
retention.onlyWhen Row filter the reap is scoped to (per-field equality, { $in: [...] }, or the null predicate { $null: true|false }); rows outside it are retained regardless of age. Incompatible with rotation storage and archive
ttl Per-row expiry: field + expireAfter, plus optional onlyWhen — the same row filter (same value union) scoping the TTL reap; rows outside it are retained regardless of expiry, and the same rotation/archive incompatibilities apply
storage { strategy: 'rotation', shards, unit } — time-shard + O(1) shard DROP (SQLite)
archive Cold-store hand-off: after (must equal retention.maxAge) + to (datasource name) + optional keep
reclaim Space reclamation after sweeps (default on for non-record)
Class Contract Typical use
record Business truth — permanent, no policies allowed accounts, invoices
audit Compliance ledger — retain → archive → delete audit logs
telemetry High-frequency log — rotation / short retention activity streams, job runs
transient Ephemeral state — TTL auto-expire receipts, device codes
event Bus 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.

Indexes

Optimize query performance:

{/* os:check */}

indexes: [
  { fields: ['name'] },
  { fields: ['email'], unique: 'organization' },
  { fields: ['type', 'status'] },
]
Property Type Required Description
fields string[] ✅ Fields in the index
unique boolean | 'global' | 'organization' optional Enforce uniqueness, and at which scope (default: false)
name string optional Index name (auto-generated if omitted)
**`type` and `partial` were retired in protocol 17** (#5248, #4943). Neither had a driver consumer: declared indexes are created through knex's `table.index()` / `table.unique()`, so an authored `type` selected no access method and an authored `partial` produced a **full** index with the predicate silently discarded. Writing either now fails `tsc` and the parse with a migration prescription — run `os migrate meta --from 16` to strip them.

Both capabilities remain available where they are actually implementable: the index method is the driver/dialect's choice, and a partial index is issued as raw SQL from a runtime migration (CREATE [UNIQUE] INDEX … WHERE …, the way metadata-protocol builds sys_metadata's overlay index). Drift detection reads partiality back from the database's own DDL, so migration-created partial indexes are recognized and left alone.

Additional Properties

Property Type Description
isSystem boolean System object, protected from deletion (default: false)
managedBy enum Lifecycle bucket that sets the default CRUD affordances and write policy — 'platform' (default), 'config', 'system-data', 'engine-owned', 'append-only', 'better-auth'. See Lifecycle bucket below.
userActions object Per-object override of the CRUD affordances the managedBy default implies — { create?, edit?, delete?, import?, exportCsv? }. This is what NARROWS a system-data object, opens CSV import on one, or opens a verb on an engine-owned/append-only one. See Lifecycle bucket.
sharingModel enum Org-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)
ownership enum Record-ownership model: 'user' (default — injects the reassignable owner_id lookup, engaging owner-scoped RLS, "My" views and owner reports, plus owning_business_unit_id), 'business_unit' (owned by an org unit rather than a person — injects owning_business_unit_id and deliberately no owner_id; ADR-0117 D1), 'org', or 'none' (no per-record owner of either kind — Dataverse-style catalog / junction tables). Distinct from the package own/extend contribution kind.
validations ValidationRule[] Object-level validation rules (see Validation)

Lifecycle bucket (managedBy)

managedBy declares which lifecycle bucket an object belongs to. It sets the default CRUD affordances the UI renders and the write policy the platform enforces. The enforced policy is the resolved affordance — the bucket default with any userActions override applied (resolveCrudAffordances) — not the bare bucket string.

Bucket Default write policy
platform Default. User-owned business data — full New / Import / Edit / Delete.
config Admin-authored configuration — New / Edit / Delete, no CSV import.
system-data Platform-defined schema holding admin/user-writable data (RBAC link tables, preferences, messaging config). New / Edit / Delete / Export by default — no CSV import, which is opt-in per object; narrow the rest with userActions.
engine-owned Runtime rows a platform service owns end to end — generic CRUD hidden, exposed ['get', 'list'] only, no user writes ever.
append-only Immutable audit trail — View + Export only.
better-auth Identity tables owned by the better-auth driver — generic user-context CRUD is suppressed; mutations flow through the auth API (sign-in, invite, reset).

engine-owned vs. system-data (ADR-0103, #3355). Both hold a platform-defined schema no tenant may model; they differ on who owns the rows:

  • engine-owned — jobs, notifications, approval runtime rows, sys_record_share, sys_automation_run, the metadata store, sys_secret, audit trails — written only by their owning service under a system context, never through the generic /data API. Locked by default, and a fail-closed guard (assertEngineOwnedWriteAllowed) rejects user-context generic writes.
  • system-data — the schema is the platform's, the data is the admin's or the user's: the RBAC link tables, sys_user_preference, sys_approval_delegation, the messaging config grids. New / Edit / Delete / Export by default, and no write guard covers the bucket — a writable default has nothing to fail closed on:
export const SysUserPreference = ObjectSchema.create({
  name: 'sys_user_preference',
  // New / Edit / Delete / Export by default — no `userActions` needed. RLS /
  // delegated administration is the actual authz.
  managedBy: 'system-data',
  // …
});

Pick between them on the data, not the table name: if no user ever writes a row through the generic API, it is engine-owned. Declaring system-data on an object whose resolved affordances grant no create, edit or delete is a contradiction, and ObjectSchema.create() refuses it.

userActions NARROWS system-data (an editable-only grid: { create: false, delete: false }) and OPENS a verb on append-only. Either way it is an affordance declaration; the real authorization for these rows is still enforced by RLS, delegated administration, and permission sets.

CSV import on system-data is opt-in

system-data is the one writable bucket that does not hand out the CSV bulk-import wizard. platform is now the only bucket whose default grants import:

export const SysUserPermissionSet = ObjectSchema.create({
  name: 'sys_user_permission_set',
  managedBy: 'system-data',
  // No `userActions` → New / Edit / Delete / Export, but no Import wizard.
});

export const SysHolidayCalendar = ObjectSchema.create({
  name: 'sys_holiday_calendar',
  managedBy: 'system-data',
  // Bulk loading a year of dates from a spreadsheet is the whole point here,
  // so this object asks for the wizard explicitly.
  userActions: { import: true },
});

The reason is leverage, not authorization. The bucket's charter members are the RBAC assignment tables — sys_user_position and sys_user_permission_set, the grant surface of the entire permission model. (A position's own sets are its definition's permissionSets, not rows.) Every row a CSV import writes still passes the delegated-admin gate, RLS and permission-set adjudication one at a time, so an admin who cannot grant a permission set by hand cannot grant it by file either. What differs is blast radius: row by row, one misclick affects one person; one wrong CSV is a bulk grant with no natural review rhythm. Making the wizard a per-object declaration keeps "nobody thought about import" resolving to the safe answer.

**Upgrading from v16.** `managedBy: 'system'` was retired in protocol 17 — rename it to `'system-data'`, or run `os migrate meta --from 16`. Because the new bucket defaults to New / Edit / Delete / Export, a `userActions` block that existed only to re-open create/edit/delete is now redundant and can be deleted. CSV import needs no attention either way: a v16 `system` object resolved `import: false`, and so does its renamed `system-data` self. A managed object may not advertise `enable.apiMethods` verbs its resolved affordances forbid — the registry strips the contradiction at registration (`reconcileManagedApiMethods`, ADR-0049). To expose a generic write verb on an `engine-owned`/`append-only` object, declare the matching `userActions` rather than listing the verb in `apiMethods`.

Naming Conventions

Element Convention Example
Object name snake_case project_task, user_profile
Export constant PascalCase ProjectTask, UserProfile
Config keys camelCase trackHistory, apiEnabled

Complete Example

{/* os:check */}

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

export const ProjectTask = ObjectSchema.create({
  name: 'project_task',
  sharingModel: 'private',
  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'] },
    { fields: ['project', 'status'] },
  ],

  enable: {
    apiEnabled: true,
    searchable: true,
    trackHistory: true,
    feeds: 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'],
    },
  ],
});

Related