Lesson 1 · Ship it — code → prod plumbing
Migrations (golang-migrate)
How a schema change reaches the database here — forward-only, RLS-stamped, run by a subcommand of the one binary, and reflected into the schema that sqlc reads.
Your win: add a migration the repo's way and run it locally, and explain the one thing that surprises everyone — there are no rollbacks.
The tool & the file convention
Schema changes live in migrations/{service}/ and are applied by
golang-migrate, which tracks the current version in the database and applies any
files newer than it.1 The repo's convention is rigid and
worth memorising:
CREATE INDEX IF NOT EXISTS submission_markers_marker_id_idx
ON public.submission_markers USING btree (marker_id);
- Name is always
NNNN_migrate.up.sql— a 4-digit sequence starting at 1001, then the literal wordmigrate(not a description). - To add one: find the highest number in the service's folder, add 1, keep the width.
- The human-readable description goes in a side file (
README.md/CHANGELOG.md), never the filename.
.up.sql with a .down.sql. This repo
has zero .down.sql files (against 2887 .up.sql), because
the wrapper only ever calls m.Up():
// internal/golibs/database/migrate.go:100
err = m.Up()
if errors.Is(err, migrate.ErrNoChange) || errors.Is(err, migrate.ErrNilVersion) {
return nil // nothing to do
}
Rollback is not a motion. If a migration is wrong, you don't roll back — you write a
new forward migration that fixes it. Deployed schema only ever moves forward.2
New tables carry RLS — always
Because the platform is multi-tenant (the Auth course's resource_path), a migration
that creates a table must also stamp it with tenant isolation — in the same file:
That boilerplate is spelled out in the migration skill and is non-negotiable for tenant tables — a table without it leaks across tenants.3
Running them: a subcommand, not a make target
There is no make migrate. Migrations run as a job of the one binary
— gjob sql_migrate — which you reach locally through the run script:
./local/run.bash start -m migration -s eureka # local: docker-compose.migration.yaml → gjob sql_migrate
In production the exact same gjob sql_migrate runs as a Helm hook job. The migration
source is configured per service as source: file:///migrations/{service}, and the job
is handed the service's encrypted secrets file, which it decrypts in-process (Lesson 3).2
Keeping sqlc in sync: gen-db-schema
internal/eureka/eureka.sql,
dwh.sql) — and those are generated pg_dump outputs, never
hand-edited. After adding a migration you run make gen-db-schema,
which spins a throwaway postgres:11.9, applies all migrations, and dumps the
resulting schema. So the loop is: write the migration → make gen-db-schema → sqlc now
sees the new columns. (It covers only the ~14 services hardcoded in the dbschema image.)3
golang-migrate — migration format
The canonical spec for the file format and the up/down model the repo deliberately half-uses.
→ golang-migrate — MIGRATIONS.md
→ in-repo .claude/skills/migration/SKILL.md,
internal/golibs/database/migrate.go
Check yourself (from memory)
Q1. How do you undo a bad migration that already deployed?
m.Up().
Schema moves forward only — fix mistakes with a new migration.
Q2. What must a migration that creates a tenant table include?
resource_path + the two RLS
policies + ENABLE/FORCE, in the same migration — or they leak across tenants.
Q3. After adding a migration, how does sqlc see the new column?
eureka.sql/dwh.sql are generated pg_dumps;
make gen-db-schema applies all migrations to a scratch DB and re-dumps them.
migrations/{svc}/NNNN_migrate.up.sql — 4-digit
from 1001, literal "migrate", description in side files. New tenant table → resource_path
+ 2 RLS policies + ENABLE/FORCE, same file. No down migrations: 0 .down.sql;
migrate.go:100 only calls m.Up() → fix-forward, never roll back.
Run: gjob sql_migrate (no make migrate) — locally
./local/run.bash start -m migration -s {svc}; prod = Helm hook job. sqlc sync:
eureka.sql/dwh.sql are generated pg_dumps → after a migration run
make gen-db-schema (throwaway PG → apply all → dump).1. golang-migrate; in-repo internal/golibs/database/migrate.go:35 (MigrateDatabase).
2. In-repo (verified): migrate.go:100 (only m.Up()); 0 .down.sql vs 2887 .up.sql; cmd/server/migrate.go:13 (gjob sql_migrate); local/run.bash:135-148.
3. In-repo: .claude/skills/migration/SKILL.md (RLS boilerplate, step 4 = make gen-db-schema); Makefile:267; developments/dbschema.Dockerfile:20.