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 DataStore rather 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 serve does 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-deploy voltro db apply is 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 onSchemaChange seed 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.ts gets 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, and voltro migrate never imports your modules — so the table set is decided from the FILENAME, and it has to be decided identically by voltro dev, voltro serve, voltro db apply and voltro migrate or 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 typed DataStore (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 seed runs against the same store the app runs on — resolution is DB_DIALECT env → STORE env → app.config.ts's storepostgres. 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 memory for 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 via upsertByUnique. Safe in production.
  • Demo data (populate a fresh dashboard): lifecycle: 'boot' in dev, or lifecycle: '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.