From 9a25c4baab28abf732e54910a293c5f3885207cb Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Tue, 9 Jun 2026 20:31:55 -0600 Subject: [PATCH] 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 --- .gitignore | 1 + CLAUDE.md | 21 ++ backend/alembic.ini | 149 ++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 49 ++++ backend/alembic/script.py.mako | 29 +++ .../alembic/versions/c3da001a0eb3_baseline.py | 221 ++++++++++++++++++ backend/app/db.py | 101 +++----- backend/app/main.py | 5 +- backend/app/models/models.py | 15 +- 10 files changed, 519 insertions(+), 73 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/c3da001a0eb3_baseline.py diff --git a/.gitignore b/.gitignore index d8198aa..42478db 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tech_docs/ # Claude Code local state .claude/ +.venv/ diff --git a/CLAUDE.md b/CLAUDE.md index 8abe827..557dbdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,27 @@ The backend refuses to boot without `SECRET_KEY`, `ADMIN_USERNAME`, and `ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill it in; the dev docker-compose reads it. +## Database Migrations + +Schema changes go through Alembic — never edit the schema by hand and never +reset the prod DB. Workflow: + +```bash +cd backend +# 1. Edit app/models/models.py +# 2. Autogenerate against the dev DB, then REVIEW the generated file +DATABASE_URL='postgresql://wealthy_user:wealthy_pass@localhost:5433/wealthysmart' \ + .venv/bin/alembic revision --autogenerate -m "describe the change" +# 3. Migrations apply automatically at app startup (run_alembic_upgrade) +``` + +## Tests + +```bash +cd backend && .venv/bin/python -m pytest tests/ -q # backend (55+ tests) +cd frontend && pnpm typecheck # frontend +``` + ## Local Docker ```bash diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e48b10b --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from app.config import settings +import app.models.models # noqa: F401 — register all tables on the metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Single source of truth for the DB URL: the app settings. +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..697cf67 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/c3da001a0eb3_baseline.py b/backend/alembic/versions/c3da001a0eb3_baseline.py new file mode 100644 index 0000000..f730b42 --- /dev/null +++ b/backend/alembic/versions/c3da001a0eb3_baseline.py @@ -0,0 +1,221 @@ +"""baseline + +Revision ID: c3da001a0eb3 +Revises: +Create Date: 2026-06-09 20:00:18.301439 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'c3da001a0eb3' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('account', + sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False), + sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False), + sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('balance', sa.Float(), nullable=False), + sa.Column('account_type', sa.Enum('BANK', 'PENSION', 'CRYPTO', 'SAVINGS', 'LIABILITY', name='accounttype'), nullable=False), + sa.Column('next_payment', sa.Float(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('apitoken', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_apitoken_token_hash'), 'apitoken', ['token_hash'], unique=False) + op.create_table('balanceoverride', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('month', sa.Integer(), nullable=False), + sa.Column('override_balance', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year', 'month') + ) + op.create_table('category', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('icon', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('auto_match_patterns', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_category_name'), 'category', ['name'], unique=True) + op.create_table('exchangerate', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('buy_rate', sa.Float(), nullable=False), + sa.Column('sell_rate', sa.Float(), nullable=False), + sa.Column('fetched_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('municipalreceipt', + sa.Column('receipt_date', sa.Date(), nullable=False), + sa.Column('due_date', sa.Date(), nullable=False), + sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('account', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('finca', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('holder_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('holder_cedula', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('holder_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('interests', sa.Float(), nullable=False), + sa.Column('iva', sa.Float(), nullable=False), + sa.Column('total', sa.Float(), nullable=False), + sa.Column('raw_charges', sa.JSON(), server_default='[]', nullable=False), + sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('account', 'period') + ) + op.create_table('pensionsnapshot', + sa.Column('fund', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False), + sa.Column('contract_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('period_start', sa.Date(), nullable=False), + sa.Column('period_end', sa.Date(), nullable=False), + sa.Column('saldo_anterior', sa.Float(), nullable=False), + sa.Column('aportes', sa.Float(), nullable=False), + sa.Column('rendimientos', sa.Float(), nullable=False), + sa.Column('retiros', sa.Float(), nullable=False), + sa.Column('traslados', sa.Float(), nullable=False), + sa.Column('comision', sa.Float(), nullable=False), + sa.Column('correccion', sa.Float(), nullable=False), + sa.Column('bonificacion', sa.Float(), nullable=False), + sa.Column('saldo_final', sa.Float(), nullable=False), + sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('fund', 'period_start', 'period_end') + ) + op.create_table('pushsubscription', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('endpoint', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('p256dh', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('auth', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('endpoint') + ) + op.create_table('savingsaccrual', + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('month', sa.Integer(), nullable=False), + sa.Column('memp_amount', sa.Float(), nullable=False), + sa.Column('mpat_amount', sa.Float(), nullable=False), + sa.Column('trigger_transaction_id', sa.Integer(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('applied_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year', 'month') + ) + op.create_table('usersettings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('data', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='{}', nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_usersettings_key'), 'usersettings', ['key'], unique=True) + op.create_table('recurringitem', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False), + sa.Column('item_type', sa.Enum('INCOME', 'EXPENSE', 'SAVINGS', name='recurringitemtype'), nullable=False), + sa.Column('frequency', sa.Enum('WEEKLY', 'MONTHLY', 'QUARTERLY', 'BIANNUAL', 'YEARLY', name='recurringfrequency'), nullable=False), + sa.Column('day_of_month', sa.Integer(), nullable=True), + sa.Column('month_of_year', sa.Integer(), nullable=True), + sa.Column('override_amounts', sa.JSON(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('transaction', + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False), + sa.Column('merchant', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('date', sa.DateTime(), nullable=False), + sa.Column('card_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('card_last4', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('authorization_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('reference', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('transaction_type', sa.Enum('COMPRA', 'DEVOLUCION', 'DEPOSITO', 'SALARY', name='transactiontype'), nullable=False), + sa.Column('source', sa.Enum('CREDIT_CARD', 'CASH', 'TRANSFER', name='transactionsource'), nullable=False), + sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('deferred_to_next_cycle', sa.Boolean(), server_default='false', nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_transaction_reference'), 'transaction', ['reference'], unique=False) + op.create_table('watermeterreading', + sa.Column('meter_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('reading_previous', sa.Float(), nullable=False), + sa.Column('reading_current', sa.Float(), nullable=False), + sa.Column('consumption_m3', sa.Float(), nullable=False), + sa.Column('agua_potable', sa.Float(), nullable=False), + sa.Column('serv_ambientales', sa.Float(), nullable=False), + sa.Column('alcant_sanitario', sa.Float(), nullable=False), + sa.Column('iva', sa.Float(), nullable=False), + sa.Column('is_historical', sa.Boolean(), nullable=False), + sa.Column('receipt_id', sa.Integer(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['receipt_id'], ['municipalreceipt.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('meter_id', 'period', 'is_historical') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('watermeterreading') + op.drop_index(op.f('ix_transaction_reference'), table_name='transaction') + op.drop_table('transaction') + op.drop_table('recurringitem') + op.drop_index(op.f('ix_usersettings_key'), table_name='usersettings') + op.drop_table('usersettings') + op.drop_table('savingsaccrual') + op.drop_table('pushsubscription') + op.drop_table('pensionsnapshot') + op.drop_table('municipalreceipt') + op.drop_table('exchangerate') + op.drop_index(op.f('ix_category_name'), table_name='category') + op.drop_table('category') + op.drop_table('balanceoverride') + op.drop_index(op.f('ix_apitoken_token_hash'), table_name='apitoken') + op.drop_table('apitoken') + op.drop_table('account') + # ### end Alembic commands ### diff --git a/backend/app/db.py b/backend/app/db.py index 80abf89..dad28b8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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(): diff --git a/backend/app/main.py b/backend/app/main.py index c381903..5d768e6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 5b92c1b..4d29013 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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)