ObjectStackObjectStack

Seed Data & Fixtures

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

Seed Data & Fixtures

defineSeed() is the canonical way to define seed data in ObjectStack. It provides compile-time type safety by inferring valid field keys directly from your object definition, so typos in record field names are caught before the code runs.

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 TypeScript validate every field name in records against the object's fields map at compile time.

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
insertCreate only, throw on duplicateAppend-only tables, audit logs
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 },
  ],
});

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

Type Safety

defineSeed() infers valid field keys from the object definition you pass as the first argument. If you reference a field that does not exist on the object, TypeScript reports an error 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 'Partial<Record<keyof ...>>'
    },
  ],
});

This is a major advantage over writing plain JSON — always use defineSeed() over the raw SeedSchema.parse() call.


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];

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, a one-time ownership handoff re-owns every orphaned row (owner_id null, or the legacy usr_system` value from older databases) to that admin.

  • 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'

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']).

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;                    // default: 'name'
    mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
    env?: Array<'prod' | 'dev' | 'test'>;  // default: ['prod','dev','test']
    records: Array<Partial<Record<keyof TObj['fields'], unknown>>>;
  }
): Seed

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