Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ tests/ pytest — async via pytest-asyncio

- **SQLAlchemy 2.x async syntax** — `select(Model).where(...)`, `await session.scalars(...)`. No legacy `Query` API.
- **Sessions come from `async_session()` in `db.py`** — use `async with async_session() as session:` and commit explicitly.
- **Migrations are not yet wired.** When you add a column, also add a startup migration in `db.py:init_db()` until we adopt Alembic. Don't skip this and ship — it'll break prod.
- **Migrations are Alembic.** When you change `models.py`, generate a revision (`cd backend && alembic revision --autogenerate -m "..."`), read it, and commit it under `alembic/versions/`. `init_db()` in `db.py` runs `alembic upgrade head` on every boot (in a worker thread, since Alembic's `env.py` drives its own async engine — see the comment on `_run_alembic_sync`); a legacy DB that already has the full schema but no `alembic_version` table gets `stamp head` instead (see `_pre_alembic_schema_present`). Don't hand-write `ALTER TABLE` in `db.py` anymore — that's what `_run_migrations` used to be (kept only as dead code / rollback reference, see its docstring).
- **Boot-time migration assumes a single app instance** (which is what docker-compose runs). The stamp-vs-upgrade decision and the `alembic upgrade head` that follows are not atomic: two instances booting simultaneously against the same empty Postgres can both pick the upgrade path and race on `CREATE TABLE` ("relation already exists"). If this app is ever scaled to multiple replicas, run migrations as a one-off init container/job (`alembic upgrade head`) before starting replicas, instead of relying on boot-time migration.
- **No raw SQL strings without a comment explaining why.** ORM first.

## Errors
Expand Down Expand Up @@ -74,7 +75,7 @@ tests/ pytest — async via pytest-asyncio

- [ ] `ruff check . && ruff format .` clean
- [ ] `pytest tests/ -v` passes
- [ ] New columns / tables migrated in `db.py:init_db()` (until Alembic lands)
- [ ] New columns / tables: `alembic revision --autogenerate`, reviewed and committed under `alembic/versions/`
- [ ] Logger used; no `print`
- [ ] Error path tested
- [ ] If you added a route, also added a Pydantic schema for body/response
56 changes: 56 additions & 0 deletions backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Alembic configuration for the trainable backend.
#
# The actual DB URL is NOT read from here — env.py overrides it at runtime
# from `config.settings.database_url` (same source the app's async engine
# uses), so DATABASE_URL / .env stays the single source of truth. The
# sqlalchemy.url below is only a placeholder to satisfy tools that expect
# the key to exist.

[alembic]
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname


[post_write_hooks]
# See https://alembic.sqlalchemy.org/en/latest/autogenerate.html#post-write-hooks
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME

# Logging configuration.
[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
112 changes: 112 additions & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Alembic environment script.

Wires Alembic to the app's own settings/models instead of a second,
hand-maintained config:

- the DB URL always comes from ``config.settings.database_url`` (so
DATABASE_URL / .env stays the single source of truth — nothing new to
keep in sync in alembic.ini),
- ``target_metadata`` is ``db.Base.metadata`` after importing every model
module, so ``alembic revision --autogenerate`` sees the full schema.

The app uses an async engine (asyncpg / aiosqlite), so migrations run
through SQLAlchemy's async engine too, following the pattern from the
Alembic cookbook for async applications:
https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic
"""

import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

# Import the app's settings + models so Base.metadata is fully populated
# and the DB URL matches exactly what the running app uses.
from config import settings
from db import Base
import models # noqa: F401 (populates Base.metadata as a side effect)

# this is the Alembic Config object, which provides access to the values
# within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
#
# disable_existing_loggers=False is load-bearing: this env.py also runs
# in-process at app startup (init_db() -> _run_alembic_sync), after main.py
# and every module-level `logging.getLogger(__name__)` have already been
# created. fileConfig's default (True) would silently disable all of those
# app loggers the moment migrations run — killing app logging after boot
# (and breaking any caplog-based test that runs after an Alembic test).
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)

# The app's own metadata — autogenerate diffs against this.
target_metadata = Base.metadata

# Always drive the URL from the app's settings, not alembic.ini.
config.set_main_option("sqlalchemy.url", settings.database_url)


def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (emits SQL, no DB connection)."""
url = settings.database_url
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# Batch mode so future ALTER-style migrations work on SQLite (which
# cannot ALTER COLUMN / DROP COLUMN natively); a no-op on Postgres.
render_as_batch=True,
)

with context.begin_transaction():
context.run_migrations()


def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
# Batch mode so future ALTER-style migrations work on SQLite (which
# cannot ALTER COLUMN / DROP COLUMN natively); a no-op on Postgres.
render_as_batch=True,
)

with context.begin_transaction():
context.run_migrations()


async def run_async_migrations() -> None:
"""Run migrations against the async engine built from settings."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)

await connectable.dispose()


def run_migrations_online() -> None:
"""Run migrations in 'online' mode.

Called both from the `alembic` CLI (its own fresh event loop) and
programmatically from `db.py` via `asyncio.to_thread` (also a fresh
thread with no running loop) — `asyncio.run()` is safe in both cases.
"""
asyncio.run(run_async_migrations())


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
26 changes: 26 additions & 0 deletions backend/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""${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
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[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:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Loading
Loading