Seeding strategy
The three seed buckets — essential, fixtures, showcase — and which databases each one is allowed to touch
Three buckets, one deployment contract
Seeding lives in packages/db-tools/seed/. The split is not about "reference vs demo" — it is about where each bucket is allowed to run.
| Bucket | What | Where it may run |
|---|---|---|
essential/ | Data the app needs to RUN — taxonomy, geography, carriers, settings | Local always; remote on demand via the explicit command |
fixtures/ | Made-up demo content for dev | Local only — hard-refuses a non-local POSTGRES_URL |
showcase/ | A small real-looking catalog for a market with no sellers yet | Local, and production on purpose — every row carries is_seed |
These names are the vocabulary. "Reseed" with no other word means fixtures/ — pnpm db:seed.
Commands
| Command | Runs | Notes |
|---|---|---|
pnpm db:reset | supabase db reset → db:migrate → db:seed:essential | The fresh-database path |
pnpm db:seed | fixtures only | The reseed. Local only. Does not re-run essentials |
pnpm db:seed:eu / pnpm db:seed:br | fixtures for one market | SEED_MARKETS=EU/BR; they compose |
pnpm db:seed:essential | essentials | Re-run when a loader changed |
pnpm db:seed:essential:remote | essentials, remote | The only seed path allowed off localhost |
pnpm db:seed:orders | fixtures/orders-run.ts | Just the order matrix |
pnpm db:seed:showcase / :showcase:remote | showcase | is_seed sellers + listings |
The reseed deliberately excludes the essentials: reference data does not go stale while iterating, and walking ~60k cities on every reseed is what made demo runs slow. db:seed aborts with an instruction if the taxonomy is empty — that means the essentials were never seeded on this database.
essential/ contract (production-grade)
- Idempotent upsert on a stable natural key (slug/code) — re-running updates changed values and adds new rows on existing databases.
- Never destructive: no deletes of unknown rows, no overwriting ops-owned fields (
enabled, admin-synced logos). - Pointing it at a remote database must be a deliberate act: the runner aborts unless
CONFIRM_REMOTE_ESSENTIAL=1, which onlydb:seed:essential:remotesets.
Known gap: several older loaders still skip-if-any-rows-exist instead of a real upsert. Fix them to the upsert contract as they are touched — carriers already follows it.
FK-safe order
essential/index.ts runs the loaders in this order:
countries → cities → city_translations
categories
brands → brand_categories → brand_assets
colors → color_translations
conditions → condition_translations
option_groups (+ option_values)
attributes (+ translations, sets, set_attributes → option_groups)
option_value_translations
banks
carriers → shipping_rate_estimates
market_payment_providers
platform_settings
ucp_sentinel_buyer
admins (promotes accounts that already signed up, by username)
taxonomy embeddingsEmbeddings travel with the seed
The taxonomy's vectors ship as JSON in essential/data/embeddings/, keyed by the embedding text, so the sell form's photo → category match works on a fresh database with no model run. Same for fixtures listings (fixtures/data/embeddings/). A row whose text changed since the last pnpm db:embed:export is reported and left NULL; pnpm db:embed repairs it, then export and commit. Never run db:embed as part of a reset.
fixtures/ contract (never production)
Fake users, addresses, items, groups, conversations, orders, reviews, social graph, searches, bundle discounts, and sandbox Pagar.me recipients. assertLocalDatabase refuses any non-local URL.
The market is the unit. SEED_MARKETS scopes the whole pipeline — cleanup included — so db:seed:eu then db:seed:br composes into the full set, and either alone leaves the other market's data standing. Every user and address record must declare its market: there is no default, so a module that omits it fails to compile rather than going quietly Brazilian.
Pipeline order in fixtures/index.ts:
cleanup(markets) # traceable seed identities only
users → addresses → pagarme recipients → bundle discounts
items # generated per seller, inherit the seller's market
groups
orders → reviews → conversations
social → searches
onboarding_state # derived last, from the rows aboveOrders are seeded, but never written mid-lifecycle. Each is created at pending_payment the way checkout does and advanced through the real transitionOrder engine with the actors' wallet ops, so every state in the matrix is one the engine can actually produce. What they lack is a durable Restate actor (no timers) — the full durable path is still click buy → webhook → ship → accept. Offers and payouts are not seeded at all; create them through the real flow.
Determinism comes from seed/lib/context.ts (seedRandom + seedNow): the same SEED_RNG_SEED (default 42) and SEED_CLOCK_ANCHOR give identical content and counts across reseeds. Supabase Auth UUIDs still differ — determinism is about content, not ids.
File layout
packages/db-tools/seed/
├── index.ts # pnpm db:seed → fixtures only (LOCAL ONLY)
├── embeddings-paths.ts
├── lib/ # config, helpers, context (seeded RNG + fixed clock)
├── essential/
│ ├── index.ts # FK-safe orchestrator
│ ├── seeders/ # one loader per table
│ └── data/ # .ts and .json data + embeddings/
├── fixtures/
│ ├── index.ts
│ ├── orders-run.ts # pnpm db:seed:orders
│ ├── reset.ts # wipes previous fixtures (seed identities only)
│ ├── seeders/
│ └── data/ # users.br.ts, users.eu.ts, items, groups, photos…
└── showcase/
├── index.ts
├── sellers.ts
├── listings.ts
└── showcase.data.tsAdding a market to the fixtures: add fixtures/data/users.<code>.ts (users + addresses) and wire it into data/users.ts. Items follow automatically, since they inherit their seller's market.
Related
Push vs generate vs reset, the local/remote twin commands, and what CI does on merge: the cute-db skill (.claude/skills/cute-db/SKILL.md).