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:

migrations/eureka/1640_migrate.up.sql
CREATE INDEX IF NOT EXISTS submission_markers_marker_id_idx
    ON public.submission_markers USING btree (marker_id);
The flagship surprise: there are no down migrations Standard golang-migrate pairs every .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:

CREATE TABLE foo ( ... , resource_path text DEFAULT autofillresourcepath() ); CREATE POLICY rls_foo ON foo USING (permission_check(resource_path, 'foo')); CREATE POLICY rls_foo_restrictive ON foo AS RESTRICTIVE ...; ALTER TABLE foo ENABLE ROW LEVEL SECURITY; ALTER TABLE foo FORCE ROW LEVEL SECURITY;

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

Migrations feed the schema that sqlc reads The v2 architecture course's sqlc reads a schema file (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
Read this next

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?

No down files exist; the wrapper only calls m.Up(). Schema moves forward only — fix mistakes with a new migration.

Q2. What must a migration that creates a tenant table include?

Multi-tenant tables need 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.
Recall: the migration workflow + the no-down flagship.
files + no-down + run + sqlc sync, then reveal
Files: 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).
🎯 Interview one-liner "How do you handle schema migrations?" → "Ordered, forward-only SQL via golang-migrate — no down files, so we fix mistakes with a new forward migration rather than rolling back. New tables ship with row-level-security for tenant isolation. Migrations run as a job of our single binary, and a generated schema dump feeds our typed-SQL codegen."
Next: the quality gate before code even builds — linting & githooks, including a custom analyzer with a twist. Ask me if the no-rollback model feels risky (it's a deliberate trade).

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.