Database Seeding¶
How GrinSystem populates dev sample data into a fresh database. Seeding is
deliberately kept separate from Alembic: migrations own the schema and stay
deterministic and alembic check-able; seeders own data.
What seeding is (and is not)¶
| Seeding (this doc) | Migrations | |
|---|---|---|
| Owns | Sample/dev rows | Schema (tables, indexes, RLS) |
| Runs | Only when an env flag is set | Always (alembic upgrade head) |
| Idempotent | Yes (re-run safe) | Yes (revision chain) |
| Prod | Off by default | Always |
Out of scope: system/reference data (is_system=True) that every tenant
must have in production. That is tenant-scoped and tenants are created at
runtime, so it belongs to tenant provisioning — not a seeder and not a
migration. It is deferred until the identity/onboarding flow exists.
Model¶
A seeder is a small, idempotent, per-module class. It runs only when its env flag is set, seeds into the dev tenant, and inserts only missing rows so re-running is a no-op.
src/shared/seeding/base.py—ModuleSeederABC.nameis the module slug (e.g.item_catalog); it labels logs and drives theSEED_<NAME>flag.src/shared/seeding/registry.py—SEEDERSlist. Registering a seeder here is what makes it visible (mirrorssrc/shared/db/registry.py).src/shared/seeding/runner.py—SeedRunner: runs each enabled seeder in its own committed transaction with the RLS GUC pinned to the dev tenant.src/shared/seeding/__main__.py— the CLI:python -m src.shared.seeding.
Tenant context¶
Seeders write into DEV_TENANT_ID (with created_by = DEV_USER_ID). The runner
sets the per-transaction RLS GUC app.current_tenant to that tenant, so inserts
pass the tenant_isolation WITH CHECK policy. The CLI connects via the
application role (DATABASE_URL), exactly like the running app — no BYPASSRLS.
Env flags¶
Declared in Settings (src/config.py), both default False:
| Flag | Effect |
|---|---|
SEED_ALL |
Master switch — enables every registered seeder. |
SEED_<MODULE> |
Per-module override (e.g. SEED_ITEM_CATALOG). |
A seeder runs when SEED_ALL or its own flag is true. With nothing enabled,
python -m src.shared.seeding is a fast no-op — safe to wire unconditionally
into a deploy step.
Environment matrix¶
| Environment | Flags | Result |
|---|---|---|
| local | SEED_ITEM_CATALOG=true (.env.docker) |
Sample catalog seeded |
| dev (server) | set SEED_ALL=true (or per-module) in Coolify env |
Seeded on deploy |
| uat / prod | unset | No-op |
Running it¶
Locally, make up also runs the one-shot grinsystem-seed service after
migrations (gated by the same flags). It is intentionally not a dependency
of grinsystem-api — seeding must never block app startup.
Server (Coolify)¶
The seeder runs on the runtime app image with a command override
(python -m src.shared.seeding) — the same pattern the API resource uses for
its pre-deploy alembic upgrade head. On the server it is a post-migrate deploy
step in the grinsystem-api Coolify resource (GRI-127),
enabled purely by env (dev on, uat/prod off). deploy/docker-compose.infra.yml
(infra-only) is untouched. The env contract lives in deploy/env-vars.md.
Adding a seeder for a new module¶
- Create
src/modules/<module>/infrastructure/seeding.pywith aModuleSeedersubclass: setname = "<module>", implementseed(session, *, tenant_id, created_by)to insert only missing rows (dedupe by natural key — code, name, etc.). - Register it in
src/shared/seeding/registry.py. - Add
SEED_<MODULE>: bool = Falseto the# Seedingblock insrc/config.py. - Add tests: a unit test for
enabled()gating and an integration test asserting rows are created once and a second run creates zero. - Document the new flag in
deploy/env-vars.md.