objectstack-ai/objectstack

objectstack-data

Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data lifecycle retention/TTL/rotation, metadata protection locks, and external / federated dat…

View source
Original skill document

Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.

Data Modeling — ObjectStack Data Protocol

Skill Boundaries

NeedUse instead
Query, filter, or aggregate recordsobjectstack-query
Define REST API endpoints or authobjectstack-api
Build views, dashboards, or appsobjectstack-ui
Create a plugin or register servicesobjectstack-platform

Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

  • [Naming Conventions](./rules/naming.md) — snake_case rules, option values, config properties
  • [Field Types](./rules/field-types.md) — All 49 field types and configs
  • [Relationships](./rules/relationships.md) — lookup vs master_detail, junction patterns, delete behaviors
  • [Validation Rules](./rules/validation.md) — All validation types, script inversion, severity levels
  • [Index Strategy](./rules/indexing.md) — btree/gin/gist/fulltext, composite indexes, partial indexes
  • [Data Lifecycle & Retention](./rules/lifecycle.md)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](./references/data-hooks.md) — the 8 lifecycle events, handler vs sandboxed body (ctx + capability contract), registration, canonical patterns
  • [Datasources & Federation](./rules/datasources.md)defineDatasource, external/federated objects (remoteName/columnMap), auto-connect gating, credentials; ❌ no field.columnName on external objects
  • [Security & Access Control](./rules/security.md) — 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:

PropertyTypeConventionDescription
namestringsnake_caseImmutable machine identifier (/^[a-z_][a-z0-9_]*$/)
fieldsmapkeys in snake_caseField definitions
sharingModelenumone of the four belowOrg-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:

PropertyDefaultDescription
labelAuto from nameHuman-readable singular label
pluralLabelPlural form (e.g., "Accounts") — on 31/31 objects in the reference apps; author it alongside label
iconIcon name for nav, list headers and lookup pickers — on 31/31 objects in the reference apps
highlightFieldsderivedOrdered 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)
namespaceNot a schema keyObjectSchema.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
nameFieldderived (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
displayNameFieldDeprecated alias for nameField (still honored as a fallback)
titleFormatDeprecated, 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
enableCapability flags (trackHistory, searchable, apiEnabled, etc.)
fieldGroupsOrdered list of logical field groups for forms/detail pages (see Field Groups)
lifecyclerecord 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:

FlagDefaultPurpose
trackHistoryfalseField-level audit trail
searchabletrueIndex records for global search
apiEnabledtrueExpose via automatic REST + MCP APIs
apiMethodsallWhitelist over the six primitives (get, list, create, update, delete, bulk); derived verbs (search/export/upsert/…) follow automatically
filesfalseAttachments & document management
feedstrueSocial feed, comments, mentions — opt-out: explicit false hides the feed UI and rejects new comments
activitiestrueActivity timeline (sys_activity mirror of CRUD) — opt-out: explicit false stops mirroring and hides the timeline
clonetrueRecord 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:

typescript
// `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:

WhenWhat maintains the mirror
A task is created, or re-pointed at another projectbeforeInsert / beforeUpdate hook on task — read the parent's name for the incoming project_id, stamp project_name
A project is renamedafterUpdate 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.


Field Groups (MVP)

Organize fields into logical groups (e.g., "Contact Information", "Billing", "System") for forms, detail pages, and editors.

  • Declare groups on ObjectSchema.fieldGroupsarray order is the display order.
  • Assign each field to a group via Field.group, which references an

ObjectFieldGroup.key. In-group display order equals the traversal order of fields.

  • Group keys must be snake_case; group labels are human-readable.
  • Optional per-group: icon, description, and collapse

('none' always open · 'expanded' collapsible, starts open · 'collapsed' collapsible, starts closed — replaces the deprecated defaultExpanded flag, ADR-0085). Groups render identically on forms, modals, and detail pages; for a bespoke single-page layout assign a custom Page instead.

<!-- os:check -->

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

export default ObjectSchema.create({
  name: 'account',
  label: 'Account',
  sharingModel: 'private',

  fieldGroups: [
    { key: 'contact_info', label: 'Contact Information', icon: 'user' },
    { key: 'billing',      label: 'Billing', collapse: 'collapsed' },
    { key: 'system',       label: 'System' },
  ],

  fields: {
    name:       { type: 'text',  required: true, group: 'contact_info' },
    email:      { type: 'email',                  group: 'contact_info' },
    phone:      { type: 'phone',                  group: 'contact_info' },
    vat_id:     { type: 'text',                   group: 'billing' },
    billing_address: { type: 'address',           group: 'billing' },
    created_at: { type: 'datetime', readonly: true, group: 'system' },
    created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
  },
});

Supported migrations at this layer: add / rename / delete / reorder groups (edit the fieldGroups array), assign a field to a group (edit Field.group). Explicit per-field in-group ordering is deferred to a future iteration.


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.

<!-- os:check -->

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


Quick-Start Template

<!-- os:check -->

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

export default ObjectSchema.create({
  name: 'support_case',
  label: 'Support Case',
  pluralLabel: 'Support Cases',
  description: 'A customer-reported issue tracked to resolution.',
  icon: 'life-buoy',
  sharingModel: 'private',                 // required in practice — see above
  highlightFields: ['subject', 'status', 'priority'],
  enable: {
    trackHistory: true,
    feeds: true,
    activities: true,
  },
  fields: {
    subject:     { type: 'text', required: true, maxLength: 255 },
    description: { type: 'richtext' },
    status:      { type: 'select', required: true, options: [
      { label: 'New',       value: 'new', default: true },
      { label: 'Open',      value: 'open' },
      { label: 'Escalated', value: 'escalated', color: '#e74c3c' },
      { label: 'Resolved',  value: 'resolved',  color: '#2ecc71' },
      { label: 'Closed',    value: 'closed' },
    ]},
    priority:    { type: 'select', options: [
      { label: 'Low',    value: 'low' },
      { label: 'Medium', value: 'medium', default: true },
      { label: 'High',   value: 'high',   color: '#e67e22' },
      { label: 'Urgent', value: 'urgent',  color: '#e74c3c' },
    ]},
    account:     { type: 'lookup', reference: 'account', required: true },
    contact:     { type: 'lookup', reference: 'contact' },
    assigned_to: { type: 'lookup', reference: 'user' },
    due_date:    { type: 'datetime' },
  },
  validations: [
    {
      name: 'status_flow',
      type: 'state_machine',
      field: 'status',
      transitions: {
        new:       ['open'],
        open:      ['escalated', 'resolved'],
        escalated: ['open', 'resolved'],
        resolved:  ['open', 'closed'],
        closed:    [],
      },
      message: 'Invalid status transition.',
    },
  ],
});

Declared indexes are a separate decision — see Index Strategy.


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:

ChangeExisting 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-tenantdev auto-heals; otherwise os migrate apply (replace_unique_index)
type / length change, drop field, renameos migrate apply (--allow-destructive for drops / tightenings)
declared index removed, or its columns changedos 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

ContextConventionExample
Object namesnake_caseproject_task
Field keyssnake_casefirst_name, due_date
Schema propertiescamelCasemaxLength, lookupFilters
Option valuelowercasein_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, useruser is a person picker (a lookup specialized to sys_user; stored identically to lookup)
  • Media: image, file, avatar, video, audio
  • Calculated: formula, summary, autonumberformula 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

PatternImplementation
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
Hierarchicaltree 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.

typescript
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 strips them. Access methods and partial predicates are database-layer migrations.
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.

Lifecycle Hooks

Implement business logic at data operation lifecycle points:

<!-- os:check -->

typescript
import { defineHook, HookContext } from '@objectstack/spec/data';

export default defineHook({
  name: 'account_defaults',
  object: 'account',
  events: ['beforeInsert'],
  handler: async (ctx: HookContext) => {
    if (!ctx.input.industry) {
      ctx.input.industry = 'Other';
    }
    ctx.input.created_at = new Date().toISOString();
  },
});

The handler above is the inline (in-process) form. The preferred, metadata-native form is a sandboxed body{ language: 'js', source, capabilities } run in an isolated VM, the shape that AI/Studio-authored hooks and every build artifact carry. See references/data-hooks.md for all 8 lifecycle events, both registration forms, the sandboxed `body` ctx + capability contract, and the canonical patterns.



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](./rules/security.md).

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


Metadata Protection (protection)

Package authors can lock shipped metadata against Studio edits / overlays / deletes.

The protection block is declared on the source schema (*.object.ts, *.app.ts, *.view.ts, …) and stripped at load time — it never appears in the runtime envelope. The runtime instead populates _lock, _lockReason, _lockDocsUrl, _lockSource, and _packageId, which REST returns to Studio and the lock banner reads.

Schema

ts
protection?: {
  /** Lock level — controls what Studio can do to this item. */
  lock: 'none' | 'no-overlay' | 'no-delete' | 'full';
  /** REQUIRED — reason shown in the Studio lock banner (1–500 chars). */
  reason: string;
  /** Optional doc URL — renders as a "View docs" link in the banner. */
  docsUrl?: string;
}

The block is .strict(): reason is required (min 1 / max 500 chars) and unknown keys are rejected.

lockEdit (overlay)DeleteTypical use
none (default)Normal authored metadata
no-overlaySchema is platform-defined but tenant can drop it (e.g. sys_role)
no-deleteTenant may customize fields but the object itself must exist
fullCore admin UI / platform identity (e.g. sys_user, app/setup)

Example

<!-- os:check -->

typescript
// src/objects/sys-user.object.ts
import { ObjectSchema } from '@objectstack/spec/data';

export const SysUserObject = ObjectSchema.create({
  name: 'sys_user',
  label: 'User',
  sharingModel: 'public_read',
  protection: {
    lock: 'full',
    reason: 'Core identity object',
    docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
  },
  fields: { username: { type: 'text', required: true } },
});

The same block works on non-object metadata (apps, views, dashboards, flows, agents, tools, skills, reports, email-templates). Enforcement: PUT/DELETE on /api/v1/meta/:type/:name return 403 item_locked, and an artifact lock overrides a package lock. Default to no protection block for tenant-authored metadata.


Seed Data & Fixtures (defineSeed())

Object definition and seed data live together — a *.object.ts usually pairs with a *.seed.ts (fixtures, reference rows, bootstrap data). defineSeed() is type-safe: TypeScript checks every record's field keys against the object definition.

The factory is defineSeednot defineDataset, which is the unrelated ADR-0021 analytics semantic layer (@objectstack/spec/ui).
sys_organization is platform-bootstrapped — never a seed target; the deployment posture decides how many exist (rules/security.md § Multi-tenancy).

Quick start

typescript
// src/data/index.ts
import { defineSeed } from '@objectstack/spec/data';
import { Status } from '../objects/status.object';
import { Category } from '../objects/category.object';

// Reference data — every environment
export const statusSeed = defineSeed(Status, {
  externalId: 'code',
  mode: 'upsert',
  records: [
    { code: 'active',   label: 'Active',   color: '#2ecc71' },
    { code: 'inactive', label: 'Inactive', color: '#95a5a6' },
  ],
});

// Demo data — dev/test only
export const categorySeed = defineSeed(Category, {
  externalId: 'slug',
  mode: 'upsert',
  env: ['dev', 'test'],
  records: [
    { slug: 'electronics', name: 'Electronics' },
  ],
});

export const SeedData = [statusSeed, categorySeed];   // parents first

Seed fields

FieldDefaultPurpose
objectderivedAuto-set from objectDef.name — never write manually
externalId'name'Stable business key used for upsert / update lookup
mode'upsert'Import strategy (see below)
env['prod','dev','test']Environments where the seed loads
recordsPartial<Record<keyof object.fields, unknown>>[]

Full Zod shape: node_modules/@objectstack/spec/src/data/seed.zod.ts.

Import modes

ModeBehaviorUse for
upsert (default)Update by externalId, insert if missing. Idempotent.Reference data, bootstrap rows
insertInsert all; fail on duplicate externalId.Append-only / audit tables
updateUpdate only existing rows; never create.Patching existing config
ignoreInsert; silently skip duplicates.Additive bootstrap
replace ⚠️Delete everything, then insert. Data loss.Cache / lookup tables only — never user data

externalId selection

Pick a stable natural key. Never use `id` — UUIDs differ across environments.

ScenarioKey
Named entities (country, currency)'code' / 'slug'
Users / contacts'email'
Externally sourced'external_id'
Generic'name' (default)

Relationship references

For lookup fields, supply the natural key of the target record (not its UUID). The seed runner resolves at load time. Order seeds so parents appear before children in the exported array:

If a lookup value matches no natural key, the loader falls back to resolving it as the target's id — so a reference to an existing record by internal id resolves instead of dangling to null. Natural keys remain the portable default; rely on the id fallback only for records you didn't seed (e.g. a system user).
typescript
const contacts = defineSeed(Contact, {
  externalId: 'email',
  records: [{
    email: 'john@acme.example.com',
    first_name: 'John',
    account: 'Acme Corporation',   // natural key of an Account record
  }],
});

Dynamic values (CEL)

Any field value may be a CEL expression evaluated at install time against a single per-load pinned now. This is the only correct way to author time-based or identity-derived seed values — new Date() ships the package author's clock to every customer and breaks build determinism.

typescript
import { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';

defineSeed(Opportunity, {
  records: [{
    name:            'Acme Q3 Renewal',
    close_date:      cel`daysFromNow(45)`,
    created_at:      cel`now()`,
    owner_id:        cel`os.user.id`,   // installer
    organization_id: cel`os.org.id`,
  }],
});

Stdlib in seed context: now(), today(), daysFromNow(n), daysAgo(n), isBlank(v), coalesce(v, fallback). Scope: os.user, os.org, os.env. See objectstack-formula for the full contract.

Determinism gate: two consecutive os build runs with no source changes must produce byte-identical dist/objectstack.json. CEL + pinned now is what guarantees that — using Date.now() will fail CI.

Seed best practices

PracticeWhy
Always use defineSeed(), never SeedSchema.parse()Lose compile-time field checking otherwise
Prefer natural keys (code / email / slug)Portable across environments
Default to upsertIdempotent re-runs
Scope demo data with env: ['dev','test']Keep noise out of prod
Order seeds parent → child in the exported arrayReferences resolve at load time
Use replace only on cache/lookup tables, with commentsData-loss footgun

Linting & Generation Quality

os lint checks the data model against the conventions in this skill — not just naming/labels but the relationship/master-detail/roll-up patterns. Run it after authoring or generating metadata. Severities: error (structural, fails the command), warning (likely-wrong choice), suggestion (nudge).

Data-model rules (in addition to naming/label/i18n):

RuleSeverityCatches
relationship/missing-referenceerrorlookup/master_detail without a reference target
relationship/master-detail-requiredwarninga master_detail that isn't required (a detail can't exist without its master)
relationship/delete-behaviorsuggestionmaster_detail without an explicit deleteBehavior
relationship/line-items-inline-editsuggestiona *_line/*_item master_detail child without inlineEdit
relationship/line-item-should-be-master-detailsuggestiona line-item-shaped child using lookup instead of master_detail
relationship/association-inline-editwarningan association (comment/audit/activity) marked inlineEdit (clutters the parent form — use a detail-page related list)
rollup/missing-summarysuggestiona parent of numeric master_detail children with no roll-up summary
field/select-missing-optionswarninga select/multiselect/radio with no options (or options source)
object/missing-name-fieldsuggestionan object with no nameField (ADR-0079's canonical title pointer) and no name-like field (name/title/subject/label/full_name/display_name/code)
security-owd-unseterroran object published with no authored sharingModel (422 lint envelope; absence is not a decision)
security-owd-aliaserrora legacy OWD spelling instead of the canonical four (ADR-0090 D4)
security-external-wider-than-internalerrorexternalSharingModel wider than sharingModel (ADR-0090 D11)
security-master-detail-ungrantedwarninga master_detail child whose master carries no matching grant
`code` counts for R9, but is NOT a title-derivation key. R9's name-like list above is the looser of two "name-like" sets, and the difference is deliberate. R9 asks "will records be anonymous?" — is there any readable face at all — and a code clears that bar. ADR-0079's title derivation (resolveDisplayField) asks the narrower "what IS the title?", and its name-ish set is name/title/subject/label/full_name/display_name without `code` — an identifier is not a title. So an object whose only name-ish field is code is R9-clean, yet its title is derived by the lower-priority "first title-eligible field by declaration order" tier rather than by name. Nothing user-visible turns on this (R9 is suggestion, and the Record #<id> floor guarantees a title regardless), but do not read the R9 list as the derivation contract — set nameField explicitly when the title matters.

These same rules are the rubric for AI-generated metadata — a generation is "good" exactly when it is schema-valid and lint-clean:

  • os lint --score — print a 0–100 metadata-quality score (+ letter

grade and severity breakdown) for the current project. Schema errors and lint errors weigh most; suggestions barely move it.

  • os lint --eval — run the generation eval over a bundled golden

corpus (invoice+lines, project+tasks, blog+comments, expense+lines, account+contacts) offline; each case must clear the pass bar (--eval-min, default 75). Deterministic, no API key.

When generating object metadata, target a lint-clean model: master_detail (with required + deleteBehavior + inlineEdit for line items), roll-up summaries on parents, select options, and a name/title field per object.


Verify your work

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

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

from this repository

More skills

All skills
objectstack-ai
Community

objectstack-automation

Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the jobs (defineJob) / webhooks (defineWebhook) stack collections. Use when the user is adding .flow.ts, wiring an event-driven rule, modelling an approval chain, or building an interactive screen flow / wizard (objectstack-ui routes those here). Do not use for data lifecycle hooks at the object layer (see objectstack-data) or for kernel / plugin events (see objectstack-platform). CEL expressions in flow conditions / edge guards: load objectstack-formula alongside.

installs
1
GitHub stars
59
Updated
Sep 17
objectstack-ai
Community

objectstack-i18n

Author ObjectStack translation bundles — object/field labels, view text, app navigation strings, automation messages — and configure locale fallback, coverage reporting, and the source layout. Use when the user is adding .translation.ts files, authoring a translation metadata item in the Studio or through the metadata API, shipping a generated bundle from a package or plugin, wiring a new locale, or resolving missing-translation warnings. Do not use for general i18n library questions unrelated to ObjectStack bundles.

installs
1
GitHub stars
59
Updated
Sep 17
objectstack-ai
Community

objectstack-query

Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. Use when the user is writing a query DSL expression or picking a pagination strategy. Do not use for defining objects / fields / relationships (see objectstack-data), for designing the API endpoint that exposes a query (see objectstack-api), or for a list view's filter rules / dashboard datasets (see objectstack-ui).

installs
1
GitHub stars
59
Updated
Sep 17
objectstack-ai
Community

objectstack-upgrade

Upgrade an ObjectStack metadata project across a protocol major — run the deterministic conversion chain, then work the semantic residue the chain cannot express (intent choices, custom code on retired APIs, stale prose) to a decision with the project's owner, and finish with a green validate plus a human-readable upgrade report. Use when a project is on an older protocol major and must move to the current one, when @objectstack/spec was bumped across a major and metadata or code stopped parsing, when a parse or tsc error quotes a [REMOVED] prescription, or when asked to "upgrade to v17" / "升级到 v17" / "一键升级元数据项目". Do not use to author new metadata (the domain skills cover that), or to reconcile physical database drift (that is objectstack-platform's os migrate plan / os migrate apply).

installs
1
GitHub stars
59
Updated
Sep 17