| title | 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.
{/* os:check */}
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,
},
});| 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) |
| 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' |
| 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 |
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) |
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
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 |
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',
}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'] } } },
}| 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 or { $in: [...] }); rows outside it are retained regardless of age. Incompatible with rotation storage and archive |
ttl |
Per-row expiry: field + expireAfter |
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-
recordclass must declare at least one bounding policy (retention,ttl, or rotationstorage) — rejected at parse time otherwise. Policies onrecordare rejected too. - Duration literals are
<n>+h/d/w/y(e.g.'6h','14d','7y');archive.aftermust equalretention.maxAge. - An
archive-declared object is never hot-deleted before the archive copy succeeded. No datasource registered under thearchive.toname ⇒ rows are retained (safe default), not dropped. - Registering a datasource named
telemetryroutes everytelemetry/event/auditobject 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.
Optimize query performance:
{/* os:check */}
indexes: [
{ fields: ['name'], type: 'btree', unique: false },
{ fields: ['email'], type: 'btree', unique: true },
{ fields: ['type', 'status'], type: 'btree', unique: false },
]| Property | Type | Required | Description |
|---|---|---|---|
fields |
string[] |
✅ | Fields in the index |
type |
enum |
optional | 'btree', 'hash', 'gin', 'gist', 'fulltext' (default: 'btree') |
unique |
boolean |
optional | Enforce uniqueness (default: false) |
partial |
string |
optional | Conditional index (SQL WHERE clause) |
name |
string |
optional | Index name (auto-generated if omitted) |
| 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', '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 makes a system/append-only object admin/user-writable. 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), 'org', or 'none' (no per-record owner — Dataverse-style catalog / junction tables, skips owner_id). Distinct from the package own/extend contribution kind. |
validations |
ValidationRule[] |
Object-level validation rules (see Validation) |
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 |
Platform-defined schema holding admin/user-writable data (RBAC link tables, preferences, messaging config). Locked by default; each object opens its writes via 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. writable system objects (ADR-0103). Two buckets share
the same locked default matrix but say different things:
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/dataAPI. A fail-closed guard (assertEngineOwnedWriteAllowed) rejects user-context generic writes to them.system— platform-defined schema holding admin/user-writable data: the RBAC link tables,sys_user_preference,sys_approval_delegation, the messaging config grids. These declareuserActionsto open the writes they legitimately take:
export const SysUserPreference = ObjectSchema.create({
name: 'sys_user_preference',
managedBy: 'system',
// Affordance only — RLS / delegated administration is the actual authz.
userActions: { create: true, edit: true, delete: true },
// …
});The same override works on append-only. userActions is an affordance
declaration; the real authorization for these rows is still enforced by RLS,
delegated administration, and permission sets.
| Element | Convention | Example |
|---|---|---|
Object name |
snake_case |
project_task, user_profile |
| Export constant | PascalCase |
ProjectTask, UserProfile |
| Config keys | camelCase |
trackHistory, apiEnabled |
{/* os:check */}
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,
},
validations: [
{
name: 'due_date_future',
type: 'script',
severity: 'warning',
message: 'Due date should be in the future',
condition: 'record.due_date < today()',
events: ['insert'],
},
],
});- Field Metadata — All field types and configuration options
- Validation Metadata — Data integrity rules
- View Metadata — Customize how objects are displayed
- Permission Metadata — Control access to objects