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():

View File

@@ -20,7 +20,7 @@ from app.auth import (
verify_admin_credentials,
)
from app.config import settings
from app.db import get_session, init_db, run_migrations
from app.db import get_session, run_alembic_upgrade
from app.seed import seed_db
from app.services.exchange_rate import refresh_rates_periodically
@@ -65,8 +65,7 @@ def _pair_orphan_tool_calls(messages: list) -> list:
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
run_migrations()
run_alembic_upgrade()
seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try:

View File

@@ -1,12 +1,17 @@
import enum
from datetime import date, datetime
from app.timeutil import utcnow
from typing import Optional
from sqlalchemy import JSON, Column, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel
from app.timeutil import utcnow
# Live DBs store usersettings.data as jsonb; keep plain JSON elsewhere and on
# SQLite (tests).
JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql")
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
@@ -144,7 +149,9 @@ class TransactionBase(SQLModel):
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
deferred_to_next_cycle: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
class Transaction(TransactionBase, table=True):
@@ -227,7 +234,7 @@ class UserSettings(SQLModel, table=True):
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSON, nullable=False, server_default="{}"),
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=utcnow)