Adopt Alembic: autogenerated baseline, stamp-or-upgrade startup

The baseline (c3da001a0eb3) was autogenerated against an empty Postgres
and schema-diffed against the live dev schema until column-identical
(two model fixes fell out: deferred_to_next_cycle now declares its
server_default, usersettings.data is JSONB on Postgres to match the
live type). Startup replaces init_db/run_migrations with
run_alembic_upgrade(): pre-Alembic databases that already match the
baseline are adopted via stamp; fresh databases build from the
migration. The section is serialized with a pg advisory lock because
prod uvicorn runs 2 workers, each executing the lifespan. Verified:
adoption + idempotent re-run on the dev DB, fresh 13-table build on an
empty DB, full container boot, 55/55 tests. (ARCH-02, BE-19)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-09 20:31:55 -06:00
parent c725c1487d
commit 9a25c4baab
10 changed files with 519 additions and 73 deletions

View File

@@ -1,79 +1,48 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from sqlmodel import Session, create_engine
from app.config import settings
engine = create_engine(settings.DATABASE_URL)
def init_db():
SQLModel.metadata.create_all(engine)
_MIGRATION_LOCK_KEY = 727274 # app-wide advisory lock id, arbitrary constant
def run_migrations():
"""Run idempotent schema migrations for columns added after initial create."""
def _alembic_config() -> Config:
backend_dir = Path(__file__).resolve().parent.parent
cfg = Config(str(backend_dir / "alembic.ini"))
cfg.set_main_option("script_location", str(backend_dir / "alembic"))
return cfg
def run_alembic_upgrade() -> None:
"""Bring the schema to head at startup.
Databases that predate Alembic (prod, existing dev volumes) already match
the baseline revision — the baseline was autogenerated from, and
schema-diffed against, exactly that schema — so they are adopted by
stamping instead of re-running DDL. Serialized with a Postgres advisory
lock because prod uvicorn runs 2 workers, each executing the lifespan.
"""
cfg = _alembic_config()
is_postgres = engine.dialect.name == "postgresql"
with engine.connect() as conn:
if is_postgres:
conn.execute(text(f"SELECT pg_advisory_lock({_MIGRATION_LOCK_KEY})"))
try:
conn.execute(
text(
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
insp = inspect(conn)
if not insp.has_table("alembic_version") and insp.has_table("transaction"):
command.stamp(cfg, "head")
command.upgrade(cfg, "head")
finally:
if is_postgres:
conn.execute(
text(f"SELECT pg_advisory_unlock({_MIGRATION_LOCK_KEY})")
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS savingsaccrual (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
trigger_transaction_id INTEGER,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
notes TEXT,
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
)
"""
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
VALUES
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
ON CONFLICT (year, month) DO NOTHING
"""
)
)
conn.commit()
except Exception:
conn.rollback()
def get_session():