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 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.

See migrations for schema changes — seeds populate data, migrations shape the tables.