SuperAPI AI Directory

objectstack-data

Data & analytics

View repository

Updated 10-15 13:39

About this skill

Data Modeling — ObjectStack Data Protocol

Skill Boundaries

Need Use instead
Query, filter, or aggregate records objectstack-query
Define REST API endpoints or auth objectstack-api
Build views, dashboards, or apps objectstack-ui
Create a plugin or register services objectstack-platform

Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

  • Naming Conventions — snake_case rules, option values, config properties
  • Field Types — All 49 field types and configs
  • Relationships — lookup vs master_detail, junction patterns, delete behaviors
  • Validation Rules — All validation types, script inversion, severity levels
  • Index Strategy — btree/gin/gist/fulltext, composite indexes, partial indexes
  • Data Lifecycle & Retention — lifecycle classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle hooks below)
  • Lifecycle Hooks — the 8 lifecycle events, handler vs sandboxed body (ctx + capability contract), registration, canonical patterns
  • Datasources & Federation — defineDatasource, external/federated objects (remoteName/columnMap), auto-connect gating, credentials; ❌ no field.columnName on external objects
  • Security & Access Control — permission sets, assignment rows, RLS policies, secret / requiredPermissions, tenancy, platform-global posture

Core Concepts

Object Definition

An Object is the fundamental data entity in ObjectStack. It maps to a database table and exposes automatic CRUD APIs.

Required properties:

Property Type Convention Description
name string snake_case Immutable machine identifier (/^[a-z_][a-z0-9_]*$/)
fields map keys in snake_case Field definitions
sharingModel enum one of the four below Org-wide default record visibility (OWD). Zod marks it optional, but a publish with no authored sharingModel is refused with the 422 lint envelope security-owd-unset — absence is not a decision (maintainer ruling 2026-08-13). Author it on every object

sharingModel — the four canonical values (ADR-0090 D4; legacy aliases removed). A custom object that omits it resolves to private at runtime, but the publish door rejects it before that:

Value Who can read / write
private owner only (widen with sharing rules, RLS, or readScope/writeScope)
public_read everyone reads; the owner writes
public_read_write everyone reads and writes
controlled_by_parent inherited from the master record (master_detail children)

Important optional properties:

Property Default Description
label Auto from name Human-readable singular label
pluralLabel — Plural form (e.g., "Accounts") — on 31/31 objects in the reference apps; author it alongside label
icon — Icon name for nav, list headers and lookup pickers — on 31/31 objects in the reference apps
highlightFields derived Ordered field keys used as a record's compact face: the columns a related list renders on the parent's detail page, and what a lookup picker shows. Declare it on the CHILD object (see Relationships)
namespace — Not a schema key — ObjectSchema.create() rejects unknown keys, so authoring it is a build error. Embed the prefix directly in name instead (e.g. name: 'crm_account')
datasource 'default' Target datasource ID for virtualized data
nameField derived (e.g. 'name'/'title') Canonical record-title field — the stored field used as the record's display name. Use a single text/email field, or a formula field (returnType: 'text') for a composite title
displayNameField — Deprecated alias for nameField (still honored as a fallback)
titleFormat — Deprecated, not removed (ADR-0079) — render-only: the server can't return or query it. Use nameField (it wins); for a composite title, designate a returnType: 'text' formula field as nameField
enable — Capability flags (trackHistory, searchable, apiEnabled, etc.)
fieldGroups — Ordered list of logical field groups for forms/detail pages (see Field Groups)
lifecycle record semantics (permanent) Data retention/rotation/archival contract. Required for append-only, high-write-rate objects — a telemetry/transient/event/audit class must declare a bounding policy or parsing fails (see Data Lifecycle & Retention)

Object Capabilities (enable)

Toggle system behaviours per object:

Flag Default Purpose
trackHistory false Field-level audit trail
searchable true Index records for global search
apiEnabled true Expose via automatic REST + MCP APIs
apiMethods all Whitelist over the six primitives (get, list, create, update, delete, bulk); derived verbs (search/export/upsert/…) follow automatically
files false Attachments & document management
feeds true Social feed, comments, mentions — opt-out: explicit false hides the feed UI and rejects new comments
activities true Activity timeline (sys_activity mirror of CRUD) — opt-out: explicit false stops mirroring and hides the timeline
clone true Record deep cloning

Search Fields (searchableFields)

searchableFields on the object is the canonical set $search scans (ADR-0061). Leave it unset and search auto-defaults to the nameField plus the object's short-text and enum columns (text / email / phone / url / autonumber / textarea / markdown / select / status); declare it to pin the set explicitly. Views may narrow it, never widen it.

⛔ Searching by a related record's title — mirror the value, always

$search scans the queried object's own columns. A dotted path is never a search target: unlike fields / sort / filters, the search axis does not resolve traversal, and project_id.name in searchableFields (or in a $searchFields override) is refused, not silently dropped.

This is the one prescription — emit it every time. To search by a related record's title, copy that title into a stored field on this object and declare that field searchable. A task list searched by project name:

// `project_name` is a stored, denormalized mirror of the parent's title.
{
  name: 'task',
  enable: { searchable: true },
  fields: {
    name:         { type: 'text', required: true },
    project_id:   { type: 'lookup', reference: 'project' },
    project_name: { type: 'text', label: 'Project Name' },   // ← the mirror
  },
  searchableFields: ['name', 'project_name'],
}

?search=apollo expands to name $contains 'apollo' OR project_name $contains 'apollo' — one single-table scan, every driver, no traversal. (A text mirror also lands in the auto-default set when the object declares no searchableFields.)

❌ Never mirror onto a formula field. A formula field is virtual — no driver materializes a column for it, so a $contains predicate against one has nothing to scan (the SQL driver would emit a WHERE over a column that does not exist). CEL also only reads this record's own fields (record.<field>), so a formula cannot fetch the related title in the first place. The mistake is refused, not silent: a formula entry in any searchableFields — the object's own set included — is an os validate error (searchable-field-unsearchable), and a request naming one is 400 INVALID_FIELD. It used to clear both and then never match.

Mirror maintenance is the trade-off — a mirror is denormalized data, only as fresh as whatever writes it. Cover both write paths:

When What maintains the mirror
A task is created, or re-pointed at another project beforeInsert / beforeUpdate hook on task — read the parent's name for the incoming project_id, stamp project_name
A project is renamed afterUpdate hook on project — re-stamp project_name on that project's tasks

Rows written by a path that bypasses hooks (bulk import, direct SQL) need a one-off backfill. See Lifecycle Hooks.

Both spellings are refused loudly: os validate reports searchable-field-unknown, and a request naming the dotted path is 400 INVALID_FIELD. Each message carries its own prescription.

Cross-object search paths are rejected by design, not pending. Do not invent a per-project convention for this — the mirror field is the answer.


→ Moved verbatim to references/examples-objects.md § Field Groups (MVP).


Conditional Field Rules

Put conditional UI/data-entry rules on the field definition when the rule belongs to the data model and should apply everywhere the field is edited: default forms, Studio-authored forms, inline master-detail grids, public forms, and API-backed writes.

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

export const Invoice = ObjectSchema.create({
  name: 'invoice',
  sharingModel: 'private',
  fields: {
    status: Field.select({
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Sent', value: 'sent' },
        { label: 'Paid', value: 'paid' },
        { label: 'Void', value: 'void' },
      ],
    }),
    paid_at: Field.datetime({
      visibleWhen: P`record.status == 'paid'`,
      requiredWhen: P`record.status == 'paid'`,
    }),
    locked_total: Field.currency({
      readonlyWhen: P`record.status == 'paid'`,
    }),
  },
});
  • Use visibleWhen to hide irrelevant fields in ObjectUI forms.
  • Use readonlyWhen for state-locked fields; the ObjectQL write path ignores incoming changes when the predicate is TRUE.
  • readonly: true governs the end-user surface, not trusted system writers. A non-system write (REST/UI, and any runAs:'user' flow — the default) has the field stripped from any non-system write; the write reports success but the value never lands. System-context writes — runAs:'system' flows, system hooks, seeds, imports, migrations — are exempt and DO write it. So the pattern "users can't edit this, but automation maintains it" is expressed by declaring the field readonly and running the maintaining flow runAs:'system' (see objectstack-automation), not by removing readonly. Writing a readonly field from a runAs:'user' update_record node is a build-time error (os validate / os build).
  • Use requiredWhen for conditional requiredness; the ObjectQL validator enforces it on submit. The conditionalRequired alias was REMOVED in protocol 17 — emitting it is a parse error.
  • Choose by intent — invariant or transition gate. A fact true of every stored record is an invariant: a validations[] script rule. A row that violates it is refused on any edit until repaired. A transition condition ("required once the record reaches paid") is requiredWhen / field bounds, which judge the write, not the stored row. "Required when X" reads like an invariant and is not one; an invariant written as a gate never enforces itself.
  • For inline master_detail grids, predicates are evaluated row-by-row against the child row's record, so line-item rules should live on child fields.
  • For complex predicates, load objectstack-formula and emit CEL via P\...`; do not use Salesforce-style AND, IN (...), or {field}` syntax.

→ Moved verbatim to references/examples-objects.md § Quick-Start Template.


Schema evolution on an existing database

The metadata→DB sync is additive-only: new tables/columns are created on boot, but existing columns are never altered or dropped. A non-additive change to an object that already has data silently diverges from the physical schema, and the database column wins at write time:

Change Existing DB on restart
add object / field / index ✅ applied automatically (additive)
storage: { notNull: true } removed (relax NOT NULL) ⚠️ never auto-applied — the drift is category: 'needs_confirm' (relax_not_null), so os migrate apply confirms it. Relaxing required alone changes no column
unique re-scoped global → per-tenant dev auto-heals; otherwise os migrate apply (replace_unique_index)
type / length change, drop field, rename os migrate apply (--allow-destructive for drops / tightenings)
declared index removed, or its columns changed os migrate apply (--allow-destructive when it drops, or rebuilds as UNIQUE)

required is not the NOT NULL dial (ADR-0113). required is the write contract — the engine refuses an insert that omits the value — and it implies nothing about the column. The physical constraint is a separate explicit opt-in, storage: { notNull: true }, and it is what drift detection compares against. So tightening required on a deployed object is safe (existing null rows stay readable), while declaring storage.notNull over null rows is a destructive migration. The two cannot be combined with requiredWhen — a conditional contract cannot be an unconditional column constraint.

Tell-tale: /meta reports a field optional (no required, no storage.notNull) but writes that omit it fail with a raw driver error rather than a clean validation 400 — that is a stale NOT NULL column, not a validator bug. Run os migrate plan to preview and os migrate apply to reconcile, or ratify the column by declaring storage: { notNull: true }. CLI details: see objectstack-platform.


Common Patterns

Naming Rules Summary

Context Convention Example
Object name snake_case project_task
Field keys snake_case first_name, due_date
Schema properties camelCase maxLength, lookupFilters
Option value lowercase in_progress

See rules/naming.md for incorrect/correct examples.

Field Type Selection

49 types available. Quick categories:

  • Text: text, textarea, email, url, phone, password, markdown, html, richtext — ⚠️ password on a generic object is plaintext at rest (masked on read, never hashed); prefer secret for credentials
  • Secret: secret — reversible, encrypted-at-rest credential (DB password, API key, token) via the registered ICryptoProvider; masked on read, fail-closed (ADR-0100). The recommended type for credentials
  • Numbers: number, currency, percent
  • Date/Time: date, datetime, time
  • Logic: boolean, toggle
  • Selection: select, multiselect, radio, checkboxes
  • Relational: lookup, master_detail, tree, user — user is a person picker (a lookup specialized to sys_user; stored identically to lookup)
  • Media: image, file, avatar, video, audio
  • Calculated: formula, summary, autonumber — formula fields take a CEL expression in expression (use F\...`from@objectstack/spec`); see objectstack-formula skill
  • Embedded: composite, repeater, record — embedded JSON sub-objects stored on the parent row (no separate table / FK)
  • Enhanced: location, address, code, json, color, rating, slider, signature, qrcode, progress, tags, vector

See rules/field-types.md for full reference.

Relationship Patterns

Pattern Implementation
One-to-Many (independent) lookup field on child
One-to-Many (owned) master_detail field on child
Many-to-Many (simple) multi-value lookup (multiple: true) — an array column of ids
Many-to-Many (with attributes) Junction object with two lookup fields
Hierarchical tree field (self-reference)

See rules/relationships.md for detailed examples.

multiple: true lookup ≠ junction object. A multi-value lookup ({ type: 'lookup', reference: 'x', multiple: true }) is stored and read as an array of ids on the record — reference elements positionally ({record.tags.0} in flow values). It is NOT a junction table. Reach for a junction object (two lookups) only when the relationship itself carries attributes (role, added_at, …).

Validation Patterns

⚠️ Script validation is inverted: Validation fails when expression is true.

On insert, an optional field omitted from the payload reads as null in a validation predicate — so record.due_date == null matches an omitted field the same as an explicit null. (On update, the prior record supplies it.)

The complete set of validation types (ValidationRuleSchema discriminators):

  • script — Formula expression (inverted logic)
  • state_machine — Legal state transitions
  • format — Regex or built-in format
  • cross_field — Compare values across fields
  • json_schema — Validate a JSON field against a JSON Schema
  • conditional — Apply a nested rule only when a predicate holds

There is NO unique validation type (removed from the spec). Enforce uniqueness — including composite — with a unique index, and state its scope (ADR-0120): indexes: [{ fields: ['department', 'email'], unique: 'organization' }].

See rules/validation.md for all types and examples.

Index Patterns

The whole declaration surface is fields / unique / name. unique defaults to false; omit it when that is what you mean.

indexes: [
  { fields: ['status', 'created_at'] },                // composite
  { fields: ['email'], unique: 'organization' },       // unique per organization
  { fields: ['hostname'], unique: 'global' },          // unique platform-wide
  { name: 'idx_acct_status', fields: ['status'] },     // custom name
]

type and partial were retired at protocol 17: no driver ever read either, so an authored type chose no access method and an authored partial produced a full index with the predicate discarded. Both are now a tsc error and a parse error; os migrate meta --from 16 lists the edits; --write applies the ones it can prove, the rest by hand.

A unique index must state its scope — 'organization' (one holder per organization, NULL-safe) or 'global' (one holder across the installation). On a declared index bare unique: true is the deprecated spelling of 'global': it reads like "per organization" and does the opposite, so os lint warns and protocol 18 rejects it. On a FIELD, unique: true means 'organization' and stays valid.

See rules/indexing.md for composite indexes, unique scope, and how to build partial / gin / gist indexes at the database layer.

→ Moved verbatim to references/examples-objects.md § Lifecycle Hooks (the hooks reference itself is references/data-hooks.md).



Object Extension Model

To add fields/validations/indexes to an object you do not own, author defineObjectExtension({ extend, fields, priority }) and register it on defineStack({ objectExtensions: [...] }). priority sets merge order (default 200, range 0–999); an extension can add but never remove. ⛔ Not the same key as object-level ownership (the record-ownership enum 'user' | 'business_unit' | 'org' | 'none'). Schema: node_modules/@objectstack/spec/src/data/object.zod.ts (ObjectExtensionSchema).


Security & Access Control

Per-object access control is authored in permission sets, not on the object schema. There is no object-level permissions key (and no hooks key either) — ObjectSchema.create() rejects both as unknown keys.

Full rules: Security & Access Control.

Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

On an owner-scoped (private) object, a per-object grant in a permission set may carry readScope / writeScope to widen the owner match declaratively instead of hand-writing an RLS policy (ADR-0057 D1): own (default) · own_and_reports · unit · unit_and_below · org. It resolves at request time into an owner_id IN (…) set and AND-injects like RLS. ⚠️ Open-core boundary (ADR-0016): only own and org work in open source — the hierarchy-relative three need the paid @objectstack/security-enterprise plugin, fail closed to own without it, and defineStack errors unless the grant declares requires: ['hierarchy-security']. Schema: PermissionSetSchema.objects.*.


→ Moved verbatim to references/examples-objects.md § Metadata Protection (protection).


→ Seed Data & Fixtures (defineSeed()) moved verbatim to references/seeds.md (Quick start · Seed fields · Import modes · externalId selection · Relationship references · Dynamic values (CEL) · Seed best practices).


→ Linting & Generation Quality moved verbatim to references/lint-rules.md.


Verify your work

After authoring or editing any *.object.ts / *.seed.ts, run the author-time gate before reporting done:

os validate     # Zod schema + CEL predicates (record.<field> existence) + bindings
# or: os build  # the same gates, plus emits dist/

It catches what otherwise fails silently at runtime: a bare field ref in a requiredWhen / readonlyWhen / visibleWhen, a validation rule, a formula, or a row-level-security/sharing predicate (done instead of record.done) that evaluates to null and never fires. os lint is a separate pass that additionally checks the data model against the conventions in this skill (relationships, master-detail, roll-ups) — run it too, but it does not replace os validate. (Reminder: two consecutive os build runs with no source change must be byte-identical — see the determinism gate above.) In a scaffolded project the gate is npm run validate.


References

See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.