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 |
Every time a tenant namespace is provisioned, into that namespace | a store with namespace isolation |
onSchemaChange |
After a schema apply that touched a watched table (voltro dev's boot auto-migrate, voltro db apply, voltro migrate) |
watchedTables |
cron |
On the cron expression, through the coordinated scheduler — one firing fleet-wide | cron |
onTenantCreate
Fires from provisionTenantNamespace, after the namespace DDL lands and before
provisioning resolves — so a caller that awaits it gets a tenant whose tables
and reference data exist, or an error. Three properties worth knowing:
- Steps run scoped to the new namespace, never the shared tables. A store
without namespace isolation (sqlite, a single-schema deployment) makes the run
refuse rather than fall back — the fallback would write one tenant's
fixture into every tenant's data. On a single-namespace deployment, declare
the seed
lifecycle: 'boot'instead. - No fingerprint skip. A new namespace has none of the data, whatever
another tenant's run recorded. The ledger row is keyed
<seedId>@<namespace>, so N tenants produce N rows in_voltro_seeds. - A failure fails provisioning. Unlike a boot seed, which logs and lets the
server come up, a failed tenant-create seed rejects the provisioning call — a
tenant whose namespace exists and whose data does not, reported as success, is
worse than a loud error. Re-run provisioning (it is idempotent) or
voltro db seed --id <name>once the cause is fixed.
onSchemaChange
Fires from the migration applier, so it covers every path that applies a schema:
voltro dev's boot auto-migrate, voltro db apply (bare and --plan) and
voltro migrate. Every seed whose watchedTables intersect the tables the apply
actually changed runs once, after the apply.
Three properties worth knowing:
- Strictly post-apply. It runs after the audit row is written, so on a
transactional dialect the DDL has already committed and the seed talks to its
own
DataStorerather than the migration's connection. - It cannot fail the migration. The schema landed; a fixture that throws is
logged and recorded in
_voltro_seeds. A successful apply is never reported as failed because reference data did not load. voltro servedoes not run it — for the same reason it does not run boot seeds. Serve never applies a schema, so there is no schema change for it to react to; the pre-deployvoltro db applyis where it happens.
This seam was declared and never called until 0.34.0: the hook installed, the seeds were discovered, listed and ledgered, and nothing ran. If you declared an
onSchemaChangeseed before that release, expect it to fire on your next apply.
cron
cron seeds are projected into real schedules — the same coordinated scheduler
*.cron.tsx uses, so one firing happens fleet-wide instead of one per replica.
Both voltro dev and voltro serve register them at boot and log the schedule
names they created.
The schedule is named seed:<id>, which is also the name its runs appear under
in _voltro_schedule_runs and in the dashboard — distinct from a *.cron.tsx
namespace, so a schedule and a seed may share an id without colliding.
A cron firing runs the seed unconditionally. The fingerprint skip that makes
a boot seed cheap does not apply here: the trigger is the clock, not a change
in the source.
defineSeed({
id: 'refresh-search-index',
name: 'Rebuild search index nightly',
lifecycle: 'cron',
cron: '0 3 * * *', // required for lifecycle: 'cron'
timezone: 'Europe/Berlin', // optional; defaults to an explicit 'UTC'
steps: ({ step }) => [ /* … */ ],
})timezone is defaulted rather than required (unlike defineSchedule's, where
it is mandatory) — reference data rarely cares about a local wall clock. The
default is an explicit 'UTC', never the container's clock.
defineSeed validates at definition time: cron lifecycle without a cron field throws, onSchemaChange without watchedTables throws, and a seed with zero steps throws. The cron EXPRESSION is validated at boot, so a typo fails the boot naming the seed rather than never firing.
An app with any
*.seed.tsgets the two schedule ledger tables (_voltro_schedule_runs,_voltro_schedule_claims), even without a cron seed. Whether a seed is a cron seed is a field INSIDE the file, andvoltro migratenever imports your modules — so the table set is decided from the FILENAME, and it has to be decided identically byvoltro dev,voltro serve,voltro db applyandvoltro migrateor the schema fingerprint diverges. Two empty ledger tables is the price of that agreement.
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. - Per-tenant starter data (a new tenant's default categories, roles, settings):
lifecycle: 'onTenantCreate', which runs scoped to the tenant's own namespace as it is provisioned.
See migrations for schema changes — seeds populate data, migrations shape the tables.