feat(db): adopt Alembic for PostgreSQL migrations — Phase 1 (#1183) - #1186
Conversation
The PostgreSQL backend's schema is now owned by Alembic instead of the fresh-build-from-schema.py path. SQLite keeps its bespoke db/migrations.py runner — the two coexist during the Postgres transition (SQLite is being retired). - migrations/ (env.py targets db/tables.py MetaData), alembic.ini, and a 0001_baseline revision that reuses the exact init_schema_postgres head DDL (tables + indexes + triggers) so a fresh PG DB built by `alembic upgrade head` is identical to the old fresh build. - db/alembic_runner.upgrade_to_head(): fresh DB -> upgrade head; pre-Alembic PG DB (no alembic_version) -> stamp baseline then upgrade (not rebuilt); managed DB -> apply pending revisions. - init_database() non-SQLite branch calls the runner (was init_schema_postgres). - alembic==1.18.4 in the backend image (scheduler doesn't run migrations). - Postgres-gated integration tests (TEST_POSTGRES_URL): fresh build, idempotency, downgrade-to-base, pre-Alembic stamp path, and schema parity vs the legacy init_schema_postgres build. All 5 pass against postgres:16. Verified end-to-end against a throwaway postgres:16. SQLite init path unchanged. Deferred: enterprise Alembic domain ships in the private enterprise repo (Phase 2); autogenerate-from-metadata + SQLite retirement (Phase 3, with #746). Related to #1183. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Resolve by running |
Add CLAUDE.md Rule #9: every schema change needs both a SQLite entry in db/migrations.py and a Postgres Alembic revision under migrations/versions/, plus DDL in schema.py/tables.py. SQLite stays supported; PG-only is eventual, not near-term. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Validation Report —
|
| Category | Status | Notes |
|---|---|---|
| Commit messages | ✅ | Conventional (feat(db):, docs:), descriptive |
| Base branch | ✅ | Targets dev |
| PR size | ✅ | 11 files |
| Issue link | ✅ | "Related to #1183" (relate-don't-close convention) |
| Requirements | ➖ | Migration infra, not a user-facing capability — no requirements.md entry needed |
| Architecture | ✅ | Invariant #3 + POSTGRESQL_SETUP.md + CLAUDE.md Rule #9 updated |
| Feature flows | ➖ | No user-facing flow |
| Security check | ✅ | No secrets/tokens/emails/IPs/.env/cred files |
| Code quality | ✅ | Focused, well-commented; matches three-layer + schema conventions |
| Tests | ✅ | test_alembic_postgres.py covers fresh build, idempotency, downgrade, pre-Alembic stamp, schema parity |
CI status
lint (sys.modules pollution check) is red — but not caused by this PR. dev's backend-unit-test is broken: tests/unit/test_1199_agent_subscription_correlation.py has 2 un-baselined sys.modules.pop calls (#762 baseline). This PR inherits it via the PR-merged-with-dev ref. Fix is up as #1213 → dev. All other checks (schema-parity, prod-image-smoke, pytest matrix, CodeQL, container-security) pass.
Engineering notes (non-blocking)
- Two sources must not drift.
0001_baselinebuilds fromdb/schema.py(TABLES/INDEXES/POSTGRES_TRIGGERS), butmigrations/env.pyautogenerate targetsdb/tables.pyMetaData. Theschema-paritygate guards this today; once Collapse schema.py + migrations.py into single source of truth (follow-up to #713) #746 collapses them ontotables.pythe risk goes away. Worth a comment inenv.pypointing at the parity gate as the guard. - Startup migration concurrency.
init_database()runsalembic upgrade headon every boot. Single-instance today, but multi-replica deploys would race without an advisory lock — tracked in fix(db): migration runner — DROP-rebuild data-loss window and no cross-process serialization #1160 (runner serialization). Fine for Phase 1. - Dual-write is intentional and documented — every schema change lands in both
db/migrations.py(SQLite) and a new Alembic revision (PG) until SQLite retires (Collapse schema.py + migrations.py into single source of truth (follow-up to #713) #746). Captured inCLAUDE.mdRule Fix git pushing bug #9.
Recommendation
APPROVE — pending two non-code gates:
- Land test(#1199): fix sys.modules lint baseline failure (dev red) #1213 into
dev, then mergedev→ this branch to clear the inherited lint failure and the stale-branch merge conflict the nightly bot flagged. - One human review approval (none on record yet).
P1 feature → /review + /cso --diff recommended before merge per the review pipeline.
🤖 Generated via /validate-pr with Claude Code
db/alembic_runner.py sets Alembic's script_location to /app/migrations at runtime, but the Dockerfile only globbed top-level *.py plus named subdirs — the new migrations/ dir and alembic.ini were dropped from the prod image. A PostgreSQL deploy would crash-loop in init_database() -> upgrade_to_head() because the script directory is absent (same #1033 packaging-gap class; CI missed it since schema-parity/integration tests run against source and the prod smoke boots SQLite). COPY both into the image and include migrations/ in the readability chmod step. alembic.ini isn't needed at runtime (the runner builds its Config programmatically) but is copied so the alembic CLI works in-container for ops. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validation (
|
Summary
Phase 1 of adopting Alembic for the PostgreSQL backend (#1183). The PG schema is now owned by Alembic instead of the fresh-build-from-
schema.pypath; SQLite keeps its bespokedb/migrations.pyrunner — the two coexist during the Postgres transition (SQLite is being retired).Related to #1183 (and #746 single-source-of-truth, #1160 runner serialization, #300 the Core foundation).
What's here
src/backend/migrations/—env.py(targets thedb/tables.pyMetaData, URL fromDATABASE_URL),script.py.mako, andversions/0001_baseline.py. The baseline reuses the exactinit_schema_postgreshead DDL (tables + indexes + triggers), so a fresh PG DB built byalembic upgrade headis identical to the old fresh build.src/backend/alembic.ini— for CLI/autogenerate use (runner builds its own Config programmatically).db/alembic_runner.upgrade_to_head()— fresh DB →upgrade head; pre-Alembic PG DB (noalembic_version) →stamp 0001_baselinethen upgrade (adopted, not rebuilt); managed DB → apply pending revisions.init_database()non-SQLite branch now calls the runner (wasinit_schema_postgres). SQLite branch untouched.alembic==1.18.4in the backend image (the scheduler doesn't run migrations).Verification
Ran against a throwaway postgres:16 —
tests/integration/test_alembic_postgres.py(Postgres-gated onTEST_POSTGRES_URL, skips otherwise so SQLite CI stays green):upgrade headbuilds the full 60-table schema (incl. migration-era columns likeoperator_queue.cleared_at)downgrade basetears down cleanly (and preserves Alembic's bookkeeping)init_schema_postgresbuildAll 5 pass. Confirmed the SQLite
init_databasepath is unchanged (still runs the bespoke migrations).Interim workaround (accepted, per #1183)
Until SQLite is retired, a schema change lands in both
db/migrations.py(SQLite) and a new Alembic revision (Postgres). Tracked for cleanup in Phase 3.Deferred (separate work)
tables.pymetadata; retireschema.pyTABLES+db/migrations.py(with Collapse schema.py + migrations.py into single source of truth (follow-up to #713) #746).🤖 Generated with Claude Code