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.

BucketWhatWhere it may run
essential/Data the app needs to RUN — taxonomy, geography, carriers, settingsLocal always; remote on demand via the explicit command
fixtures/Made-up demo content for devLocal only — hard-refuses a non-local POSTGRES_URL
showcase/A small real-looking catalog for a market with no sellers yetLocal, 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

CommandRunsNotes
pnpm db:resetsupabase db reset → db:migrate → db:seed:essentialThe fresh-database path
pnpm db:seedfixtures onlyThe reseed. Local only. Does not re-run essentials
pnpm db:seed:eu / pnpm db:seed:brfixtures for one marketSEED_MARKETS=EU/BR; they compose
pnpm db:seed:essentialessentialsRe-run when a loader changed
pnpm db:seed:essential:remoteessentials, remoteThe only seed path allowed off localhost
pnpm db:seed:ordersfixtures/orders-run.tsJust the order matrix
pnpm db:seed:showcase / :showcase:remoteshowcaseis_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 only db:seed:essential:remote sets.

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 embeddings

Embeddings 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 above

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

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

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