Seeds
defineSeed — idempotent data fixtures for reference data and demo data, with fingerprint-based re-run detection and lifecycle hooks.
A seed answers two questions: where does my reference data live (countries, plan tiers, feature flags) and where does my demo data live (a populated dashboard on first boot). A *.seed.ts file default-exports defineSeed({...}); the framework discovers it like any other primitive and runs it idempotently.
// apps/api/seeds/plans.seed.ts
import { defineSeed } from '@voltro/database'
export default defineSeed({
id: 'plans',
name: 'Subscription plan tiers',
lifecycle: 'boot',
steps: ({ step }) => [
step('upsert plans', async ({ upsertByUnique }) => {
for (const p of [
{ id: 'free', name: 'Free', priceCents: 0 },
{ id: 'pro', name: 'Pro', priceCents: 2900 },
]) {
await upsertByUnique('plans', { id: p.id }, p)
}
}),
],
})Idempotency is the whole point
Seeds are meant to be safe to re-run. The upsertByUnique(table, matchFields, fullRow) helper does "row matching matchFields exists? update it : insert it", returning { row, created }. Use stable string ids (not random tokens) so subsequent runs match the same rows instead of duplicating.
The runner also fingerprints each seed by hashing its source. On a boot-lifecycle seed it only re-runs when the fingerprint changes — so an unchanged seed doesn't re-execute on every voltro dev restart. The record lives in _voltro_seeds, one row per seed. If the runner cannot write it, it says so at warn level and names the seed — a ledger that silently fails to record looks exactly like a working one whose seeds all changed, so it is not something to find out from a debug stream.
Two things the skip deliberately does not do. A failed run is recorded as failed and never satisfies the skip, so a broken seed retries on the next boot instead of disabling itself permanently. And if the ledger cannot be read at all — unmigrated database, memory store, missing table — every boot seed runs: re-doing idempotent work costs time, whereas skipping data restoration on a database we could not inspect costs data.
Override the fingerprint when the seed depends on external state (env vars) that should force a re-run:
fingerprint: ({ src }) => `${src}:${process.env.SEED_VERSION ?? ''}`,What a step's ctx.store can do
query (full descriptor — order / take / skip / projection), insert,
insertIgnore, update, delete, plus the upsertByUnique helper.
Reach for insertIgnore when restoring a snapshot: it is one statement per row
and leaves an existing row alone. upsertByUnique costs a read per row and
overwrites what it finds, which is wrong whenever the live row is newer than
the snapshot.
await ctx.store.insertIgnore('ai_models', row, { conflictColumns: ['id'] })Reads are unscoped and include soft-deleted rows — by construction, not by
flag. A seed runs at boot with no request, so there is no subject to scope to
and nothing applies the deletedAt IS NULL predicate. That is why there is no
.unscoped() / .withDeleted() to reach for: a seed already sees the whole
table. If you want one tenant's rows, say so in your own predicate.
Lifecycles
lifecycle |
Runs… | Requires |
|---|---|---|
boot |
On every voltro dev boot, only if the fingerprint changed |
— |
manual |
Only via voltro db seed --id <name> or the dashboard |
— |
onTenantCreate |
Not wired yet — declared and validated, never triggered | — |
onSchemaChange |
Not wired yet — declared and validated, never triggered | watchedTables |
cron |
Not wired yet — declared and validated, never triggered | cron |
Only boot and manual actually execute today. The other three lifecycles
validate at definition time (a cron seed without a cron field throws, an
onSchemaChange seed without watchedTables throws) and are registered so the
dashboard can list them — but nothing fires them. A seed declared with one of
them will never run, silently.
Until they are wired, express the same intent with a primitive that does run: a
*.cron.tsx schedule whose handler does the seeding, or an explicit voltro db seed --id <name> from your migration/provisioning step. This table said
otherwise until 0.10.0, which is exactly the kind of promise that costs someone
a debugging afternoon — it is corrected here rather than quietly dropped.
defineSeed({
id: 'refresh-search-index',
name: 'Rebuild search index nightly',
lifecycle: 'cron',
cron: '0 3 * * *', // required for lifecycle: 'cron'
steps: ({ step }) => [ /* … */ ],
})defineSeed validates at definition time: cron lifecycle without a cron field throws, onSchemaChange without watchedTables throws, and a seed with zero steps throws.
Steps
steps is a factory ({ step }) => SeedStep[]. Each step is a named async function; the runner executes them as a workflow, so a long seed is observable and resumable. The step context gives you:
store— the typedDataStore(query/insert/update/delete).upsertByUnique(table, matchFields, fullRow)— the idempotent upsert above.progress(done, total)— emit progress for a long-running step (wired through the workflow's progress stream).
steps: ({ step }) => [
step('import countries', async ({ upsertByUnique, progress }) => {
const rows = COUNTRIES
let i = 0
for (const c of rows) {
await upsertByUnique('countries', { iso: c.iso }, c)
progress(++i, rows.length)
}
}),
]Running seeds
voltro db seed # run all boot-lifecycle seeds (forced, ignores fingerprint)
voltro db seed --id plans # run one seed by id
voltro db seed --lifecycle manual # run all seeds of a lifecycle
voltro db seed --store memory # explicit opt-in: in-memory smoke run
voltro db seedruns against the same store the app runs on — resolution isDB_DIALECTenv →STOREenv →app.config.ts'sstore→postgres. Rows persist to the real database. Seeding to memory by default was a silent data-loss footgun (rows written, process exits, nothing persists), so it's no longer the default — pass--store memoryfor the rare smoke-test case where you genuinely want an in-memory run.
Reference data vs demo data
- Reference data (must exist in every environment):
lifecycle: 'boot', idempotent viaupsertByUnique. Safe in production. - Demo data (populate a fresh dashboard):
lifecycle: 'boot'in dev, orlifecycle: 'manual'so it's operator-triggered and never auto-runs in prod.
See migrations for schema changes — seeds populate data, migrations shape the tables.