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).
| Mode | Behavior | Use Case |
|---|---|---|
upsert | Create if new, update if found | Default — idempotent for most data |
insert | Create only, throw on duplicate | Append-only tables, audit logs |
update | Update only, skip if not found | Migration patches on existing rows |
ignore | Create if new, silently skip duplicates | Bootstrap data that must not overwrite user edits |
replace | Insert 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 |
upsert — Recommended Default
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.idresolves to the current organization; during a per-tenant replay it is that tenant's id, falling back to the load'sorganizationId.
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.
| Scenario | Recommended 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>>>;
}
): SeedThe 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