| title | Seed Data & Fixtures |
|---|---|
| description | 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 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
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.
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 |
Insert every record unconditionally — no existing-row check, so it duplicates the table on each replay boot (#3434). Rarely what you want | Append-only rows loaded exactly once (never re-run) |
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 |
defineSeed(Currency, {
externalId: 'code',
mode: 'upsert',
records: [
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'GBP', name: 'British Pound', symbol: '£' },
],
});defineSeed(SystemRole, {
externalId: 'code',
mode: 'ignore',
records: [
{ code: 'admin', label: 'Administrator' },
{ code: 'viewer', label: 'Viewer' },
],
});// ⚠️ 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 },
],
});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.
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.
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' },
],
});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.
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];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).
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
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.
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.
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
];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' |
| Join / junction tables (no single natural key) | a composite list, e.g. ['team', 'project'] |
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']).
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.
Always export parent datasets before child datasets. If contact has a lookup to
account, accountsSeed must appear before contactsSeed in the exported array.
Demo data appears in screenshots, documentation, and live demos. Use realistic
company names, email addresses, and values — not foo, bar, or test123.
Split large seed payloads into {object}.seed.ts files. A single index.ts that
re-exports and orders them keeps the entry point clean.
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']
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