Skip to content

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.pyModuleSeeder ABC. name is the module slug (e.g. item_catalog); it labels logs and drives the SEED_<NAME> flag.
  • src/shared/seeding/registry.pySEEDERS list. Registering a seeder here is what makes it visible (mirrors src/shared/db/registry.py).
  • src/shared/seeding/runner.pySeedRunner: 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

make seed                 # all enabled seeders
make seed M=item_catalog  # one module

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

  1. Create src/modules/<module>/infrastructure/seeding.py with a ModuleSeeder subclass: set name = "<module>", implement seed(session, *, tenant_id, created_by) to insert only missing rows (dedupe by natural key — code, name, etc.).
  2. Register it in src/shared/seeding/registry.py.
  3. Add SEED_<MODULE>: bool = False to the # Seeding block in src/config.py.
  4. Add tests: a unit test for enabled() gating and an integration test asserting rows are created once and a second run creates zero.
  5. Document the new flag in deploy/env-vars.md.