ObjectStackObjectStack

Seed data — fixtures and demo datasets

Populate ObjectStack objects with bootstrap data, reference records, and demo fixtures using defineSeed()

defineSeed() is the canonical way to define seed data in ObjectStack. It checks every record key against your object definition, so a typo in a record field name is caught before any row is loaded: by TypeScript for a record you write inline, and by defineSeed() itself, for every record, when the config is loaded (objectstack validate, objectstack build, boot).

Use seed data for:

  • System bootstrap — default roles, admin users, system configuration
  • Reference data — countries, currencies, ISO codes, standard picklist values
  • Demo / test fixtures — realistic sample records for development and CI

Quick Start

import { defineSeed } from '@objectstack/spec/data';
import { Account } from './objects/account.object';

export const accountsSeed = defineSeed(Account, {
  externalId: 'name',      // field used as the upsert / idempotency key
  mode: 'upsert',          // create if new, update if found
  env: ['dev', 'test'],    // only load in dev and test environments
  records: [
    {
      name: 'Acme Corporation',
      type: 'customer',
      industry: 'technology',
      annual_revenue: 5000000,
    },
    {
      name: 'Globex Industries',
      type: 'prospect',
      industry: 'manufacturing',
      annual_revenue: 12000000,
    },
  ],
});

The first argument is the object definition (the exported constant from your object file), not a string. This lets defineSeed() check every field name in records against the object's fields map. See Type Safety for which half TypeScript checks and which half the call checks.

Import defineSeed from @objectstack/spec/data. Do not confuse it with defineDataset (from @objectstack/spec/ui), which is the unrelated ADR-0021 analytics-dataset helper — it takes a single config object and is not a seed factory.


Import Modes

The mode field controls how the seed runner behaves when it encounters an existing record (matched by externalId).

ModeBehaviorUse Case
upsertCreate if new, update if foundDefault — idempotent for most data
insertInsert every record unconditionally — no existing-row check, so it duplicates the table on each replay boot (#3434). Rarely what you wantAppend-only rows loaded exactly once (never re-run)
updateUpdate only, skip if not foundMigration patches on existing rows
ignoreCreate if new, silently skip duplicatesBootstrap data that must not overwrite user edits
replaceInsert without checking for an existing match — the seed loader does not delete anything itself; the target table must already be empty (or cleared by the caller)Cache / lookup tables rebuilt from an already-truncated table
defineSeed(Currency, {
  externalId: 'code',
  mode: 'upsert',
  records: [
    { code: 'USD', name: 'US Dollar',    symbol: '$' },
    { code: 'EUR', name: 'Euro',         symbol: '€' },
    { code: 'GBP', name: 'British Pound', symbol: '£' },
  ],
});

ignore — Bootstrap Without Overwriting

defineSeed(SystemRole, {
  externalId: 'code',
  mode: 'ignore',
  records: [
    { code: 'admin',  label: 'Administrator' },
    { code: 'viewer', label: 'Viewer' },
  ],
});

replace — Insert Into an Already-Cleared Table

// ⚠️ The seed loader does NOT delete existing records for you — `replace`
// always inserts. Truncate/clear the table yourself before this seed runs,
// or every load will attempt to insert duplicate rows.
// Only use for cache or lookup tables with no user-generated data.
defineSeed(ExchangeRateCache, {
  externalId: 'key',
  mode: 'replace',
  env: ['dev'],
  records: [
    { key: 'USD_EUR', rate: 0.92 },
    { key: 'USD_GBP', rate: 0.79 },
  ],
});

insert — Not Idempotent (Avoid for Replayed Seeds)

Seeds are replayed — they re-load on every dev-server boot and every package re-publish, not applied once. insert writes every record with no existing-row check, so a replayed insert dataset duplicates its table on each restart (a 3-row fixture becomes 6, then 9 — #3434). It is almost never the right mode: reach for upsert (or ignore) with a stable externalId instead. os validate warns on any mode: 'insert' seed.


Seeds and State Machines

A seed is a curated snapshot of established facts — a project already completed, an opportunity already closed_won — not a record walking its lifecycle. So a seed write is exempt from the object's state_machine rule (#3433): it may be born in any state, and neither initialStates (the insert entry guard) nor transitions (the update guard) is enforced. Without this exemption a fixture that seeds a mid-lifecycle status would be silently rejected on the first boot, taking its lookup children down with it — the "installed but no data" trap.

The exemption is scoped to the state machine only. Every other validation still runs, so a seeded value must still be a valid field option and pass format, cross_field, and the rest. And os validate warns when a seeded value is not a state the machine declares (seed-value-outside-state-machine), so a typo like 'complete' for 'completed' still surfaces before boot.


Environment Scoping

The env array controls which deployment environments receive the records. The default is ['prod', 'dev', 'test'] — all environments.

// Reference data — safe for all environments (default)
defineSeed(Country, {
  // env omitted → defaults to ['prod', 'dev', 'test']
  records: [
    { code: 'US', name: 'United States' },
    { code: 'GB', name: 'United Kingdom' },
  ],
});

// Demo data — never reaches production
defineSeed(Account, {
  env: ['dev', 'test'],
  records: [
    { name: 'Demo Corp', type: 'customer' },
  ],
});

// Automated test fixtures — CI/CD only
defineSeed(TestUser, {
  env: ['test'],
  records: [
    { email: 'ci-admin@example.com', role: 'admin' },
  ],
});

Locale Scoping

The locale array scopes a dataset to one or more language markets, as BCP-47 tags. It is a second filter axis alongside env, and the two compose: a dataset loads when it passes env and locale.

Omitting locale means every locale — unlike env there is no default array, because locales are open-ended tags with no closed set to spell out.

// Reference data — every market (locale omitted)
defineSeed(Country, {
  records: [{ code: 'US', name: 'United States' }],
});

// The Chinese market's demo plans
defineSeed(Plan, {
  locale: ['zh-CN'],
  records: [{ name: '专业版', price: 99 }],
});

// The same plans for English-speaking markets
defineSeed(Plan, {
  locale: ['en', 'en-GB'],
  records: [{ name: 'Professional', price: 15 }],
});

Tags are matched case-insensitively (zh-cn and zh-CN are the same tag) and otherwise exactly — ['zh'] does not match a loading locale of zh-CN. List every tag the dataset is for.

The platform does not translate anything. locale only selects between record sets you authored yourself; both sets stay in your source tree, and the choice is made when the seeds load rather than when your config is assembled — so switching markets does not mean rebuilding, and the axis is evaluated in the one layer that could ever reconcile rows already written for another market.

It does not reconcile them, though. As Import Modes above states, the loader never deletes anything itself — every mode only ever writes. So switching a stack's active locale on a non-empty database loads the new locale's dataset alongside the rows the previous locale's dataset wrote: both sets stay resident and queryable, because the switch is additive and not a migration. Clearing the market you left behind is the operator's job — to switch locales cleanly, start from a fresh database.

Where the loading locale comes from. On the normal boot path your stack supplies it: the runtime reads your app's i18n.defaultLocale and passes it to the seed loader, so declaring a locale on a dataset takes effect with no extra wiring. Declare no i18n block and no locale is sent at all — which is the unrestricted setting, not a filter.

The axis is evaluated against the seed loader's config.locale. A host that supplies no locale — an app with no declared i18n.defaultLocale, or an embedding host that builds its own seed-load request — gets every dataset, and the loader warns naming each locale-scoped dataset it let through, so a scope that is not taking effect is one log line to diagnose rather than a silent no-op.

Publishing and installing are locale-neutral acts, by decision. Applying a package, publishing a draft and installing from the marketplace each build their own seed-load request and pass no locale, so a locale-scoped dataset reaching one of those paths loads for every market and is named in that warning. This is the declared bound of the axis rather than an unfinished path — a package is assembled once for every market, and selecting a market is what happens when it is opened, on the boot path above. Ruled 2026-09-10 on #17011.


Type Safety

A record key must name a column of the object: a field it declares, or a system column the platform injects on it (id, organization_id, created_at, created_by, updated_at, updated_by, owner_id, owning_business_unit_id, unless the object opts out of them with systemFields, tenancy, ownership or managedBy). defineSeed() checks this twice.

At compile time, for a record written as an object literal directly in records, TypeScript reports an unknown key immediately:

import { Account } from './objects/account.object';

defineSeed(Account, {
  records: [
    {
      name: 'Test Corp',
      typo_fild: 'value',
      //  ^^^^^^^^^
      //  TS Error: Object literal may only specify known properties,
      //  and 'typo_fild' does not exist in type 'SeedRecord<...>'
    },
  ],
});

This is TypeScript's excess-property check, so it does not see a record that is not a fresh literal in the call: one from a variable or a .map(), any inline record in an array that also spreads a Record<string, unknown>[], or a record for an object typed ServiceObject. It admits every system column name, because a type cannot evaluate the object's opt-outs.

When the call runs (when objectstack validate, objectstack build or boot loads your config), defineSeed() checks every record, whatever its shape, against the object's declared fields and the system columns injected on that object, and refuses the seed naming each unknown key:

defineSeed('crm_account'): unknown field(s) in records — typo_fild.
  • records[0]: `typo_fild` is not a field of `crm_account`.

This is a major advantage over writing plain JSON — always use defineSeed() over the raw SeedSchema.parse() call, which checks no record key.


Relationship Fields

For lookup fields that reference another object, supply the natural key value of the related record (such as name, email, or code) — not its UUID. The seed runner resolves natural keys to database IDs automatically at load time.

// Step 1 — seed the parent object first
const accountsSeed = defineSeed(Account, {
  externalId: 'name',
  records: [
    { name: 'Acme Corporation', type: 'customer' },
  ],
});

// Step 2 — seed the child object, referencing the parent by natural key
const contactsSeed = defineSeed(Contact, {
  externalId: 'email',
  records: [
    {
      email: 'john.smith@acme.example.com',
      first_name: 'John',
      last_name: 'Smith',
      account: 'Acme Corporation',   // natural key, not a UUID
    },
  ],
});

// Export in dependency order — parents before children
export const SeedData = [accountsSeed, contactsSeed];

Multi-Value Lookups (multiple: true)

A lookup declared multiple: true stores an array of related record ids, so its seed value is an array of natural keys. Each element resolves independently.

const Book = ObjectSchema.create({
  name: 'book',
  sharingModel: 'private',
  fields: {
    name: Field.text({ label: 'Title', required: true }),
    authors: Field.lookup('author', { label: 'Authors', multiple: true }),
  },
});

const booksSeed = defineSeed(Book, {
  externalId: 'name',
  records: [
    { name: 'Refactoring', authors: ['Alice', 'Bob'] },  // one natural key per author
  ],
});

A lone string is accepted as one-element shorthand (authors: 'Alice' stores [<alice id>]). Resolution is all-or-nothing per field: if any element is still missing, the whole array is deferred to pass 2 rather than written half-resolved, and a key that never materializes is reported as a load error naming that element.

Passing an array to a single-value lookup is rejected with a load error — declare the field multiple: true, or pass one natural key.

Composite externalId — Join / Junction Tables

A join (junction) table linking two objects many-to-many has no single-field natural key — the pair of foreign keys is what makes a row unique. Pass a list of field names as the externalId so the seed runner can match existing rows on the composite key and stay idempotent across replays. The foreign keys are compared by their resolved record ids, which are stable between boots.

// A team can staff many projects; a project can have many teams.
// `showcase_project_membership` is the join row — unique on (team, project).
const membershipsSeed = defineSeed(ProjectMembership, {
  mode: 'ignore',                     // skip pairs that already exist
  externalId: ['team', 'project'],    // composite natural key — both FKs
  records: [
    { team: 'Experience', project: 'Website Relaunch', engagement: 'owner' },
    { team: 'Platform',   project: 'Data Platform',    engagement: 'owner' },
    { team: 'Platform',   project: 'Website Relaunch', engagement: 'contributor' },
  ],
});

Without a composite key such a table can only fall back to mode: 'insert', which duplicates on every replay boot (#3434).


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 — a literal new Date() would ship the package author's clock to every customer and break build determinism.

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`,   // the seed identity
    organization_id: cel`os.org.id`,
  }],
});

Available in the seed CEL context:

  • Functions: now(), today(), daysFromNow(n), daysAgo(n), isBlank(v), coalesce(v, fallback)
  • Scope: os.user, os.org, os.env

Owner-style fields (owner_id, created_by, assigned_to)

Many objects have an owner lookup — owner_id, created_by, assigned_to. On a fresh boot there are no human users yet — seed data loads before the first sign-up — so the platform does not mint a placeholder system user to own these fields. The recommended pattern is to leave owner-style fields unset in the record:

defineSeed(Project, {
  externalId: 'code',
  records: [{
    code: 'bootstrap',
    name: 'Bootstrap Project',
    // owner_id intentionally omitted — filled in by the first-admin handoff below
  }],
});

What actually happens to os.user? The seed loader binds os.user to whatever identity the load run carries (config.identity). During the normal boot sequence no identity is supplied, so os.user resolves to a null identity and cel\os.user.id`evaluates tonull— the record still seeds successfully, with the field leftnull rather than the load failing. Once the first human user is promoted to platform admin, an ownership handoff re-owns every orphaned row (owner_id null, or the legacy usr_system` value from older databases) to that admin. The handoff is not one-time: it runs again whenever a seed settles, on that boot and on every later one, so rows a later boot's seed replay inserts go to the existing platform admin the same way. A row someone already owns is never touched.

  • Because cel\os.user.id`resolves tonull` before an admin exists, a required (non-nullable) owner-style field must not depend on it — either leave the field optional/unset (recommended) or supply a literal value.
  • cel\os.user.id`still resolves to a real user id when the loader is invoked with an explicit identity (e.g. a re-seed run through tooling that passesconfig.identity`).
  • os.org.id resolves to the current organization; during a per-tenant replay it is that tenant's id, falling back to the load's organizationId.

Failure is loud, not silent

If a record's CEL expression cannot be evaluated at all (a malformed expression, or a reference the CEL engine cannot compile), the record is not silently dropped. The loader counts it as an error, marks the load unsuccessful, and logs an actionable message:

[SeedLoader] Cannot resolve dynamic seed values for project record #0: <expression error>.
  `os.user.id` resolves to null at seed time (the owning admin does not exist yet) and
  owner-style fields are assigned by the first-admin handoff — so a required, non-owner
  field must not depend on it. Provide a literal value or make the field optional.

Separately, if the write itself fails after resolution (e.g. a NOT NULL column rejects a null value produced by an unresolved owner field), that surfaces as its own error:

[SeedLoader] Failed to write project record #0 (code=bootstrap): <driver error>

Both cases increment result.summary.totalErrored, add an entry to result.errors, and flip result.success to false. Tooling should check result.success / result.errors rather than assuming a clean run.


Organising Multiple Datasets

For applications with several objects, co-locate seed files under src/data/ and export a single aggregate array.

src/
  data/
    index.ts              ← exports SeedData array in dependency order
    accounts.seed.ts
    contacts.seed.ts
    leads.seed.ts
    products.seed.ts
// src/data/index.ts
import { accountsSeed }  from './accounts.seed';
import { contactsSeed }  from './contacts.seed';
import { leadsSeed }     from './leads.seed';
import { productsSeed }  from './products.seed';

/** All seed datasets — order determines load sequence */
export const SeedData = [
  accountsSeed,   // no dependencies
  productsSeed,   // no dependencies
  contactsSeed,   // depends on accounts
  leadsSeed,      // no dependencies
];

Best Practices

Choose a stable externalId

The externalId field must be a stable natural key that does not change between environments. Avoid using the auto-generated id (UUID) because UUIDs differ between databases.

ScenarioRecommended externalId
Named entities (countries, currencies)'code' or 'slug'
User records'email'
Generic named records'name' (default)
Externally sourced data'external_id'
Join / junction tables (no single natural key)a composite list, e.g. ['team', 'project']

Scope demo data with env

Keep demo and test-only records out of production by setting env: ['dev', 'test']. System bootstrap data that must exist in production should omit env (or explicitly set ['prod', 'dev', 'test']).

Ship one dataset per market, not one build per market

When the same records need different display strings per language, author both datasets and scope each with locale. Selecting between them in application code instead bakes the choice into your build output, so shipping another market means another build. Neither approach removes what the market you left behind already wrote — see Locale Scoping.

Use upsert by default

upsert is idempotent and the safest default. Only change the mode when the use case requires it — for example, ignore when you must not overwrite user edits to system defaults, or replace for ephemeral cache tables.

Seed in dependency order

Always export parent datasets before child datasets. If contact has a lookup to account, accountsSeed must appear before contactsSeed in the exported array.

Keep records realistic

Demo data appears in screenshots, documentation, and live demos. Use realistic company names, email addresses, and values — not foo, bar, or test123.

One file per object

Split large seed payloads into {object}.seed.ts files. A single index.ts that re-exports and orders them keeps the entry point clean.


defineSeed() API Reference

function defineSeed<
  const TObj extends { name: string; fields: Record<string, unknown> }
>(
  objectDef: TObj,
  config: {
    externalId?: string | string[];         // single field, or a composite list (join tables); default: 'name'
    mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
    env?: Array<'prod' | 'dev' | 'test'>;  // default: ['prod','dev','test']
    locale?: string[];                      // BCP-47 tags; omitted = every locale
    records: Array<SeedRecord<TObj['fields']>>;  // declared fields + injectable system columns
  }
): Seed

SeedRecord keys are the object's declared fields (a lookup / master_detail field takes its target's natural-key string) plus the injectable system column names. Every record key is checked again when the call runs.

The returned Seed object is a plain serialisable value — pass it to your stack's seed runner or store it in an export array.


Next: Data Modeling Guide

On this page