From 8204675a66b105e4ffcfa9e57bb787b5d6073e8e Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 4 Jul 2026 19:35:55 -0400 Subject: [PATCH] feat(db): Alembic migration for users table (async env, DATABASE_URL) Adds alembic/ (async env.py driven by DATABASE_URL) and the 0001 users-table migration matching the model. enabled now has a DB-level server_default of false (secure default even for non-ORM inserts). Verified upgrade/downgrade on sqlite. --- .gitignore | 2 + alembic.ini | 38 +++++++++++++++++++ alembic/env.py | 54 +++++++++++++++++++++++++++ alembic/script.py.mako | 23 ++++++++++++ alembic/versions/0001_create_users.py | 38 +++++++++++++++++++ src/intervals_mcp_server/db/models.py | 7 +++- 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_create_users.py diff --git a/.gitignore b/.gitignore index 27b8ebe..44f4fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,5 @@ TODO .claude/ .DS_Store CLAUDE.md +_alembic_test.db +*.db diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ca1baaa --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = src +# DATABASE_URL from the environment is used at runtime (see alembic/env.py). + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..9ad40a3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,54 @@ +"""Alembic migration environment (async, driven by DATABASE_URL).""" + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from intervals_mcp_server.db.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _database_url() -> str: + url = os.environ.get("DATABASE_URL") or config.get_main_option("sqlalchemy.url") + if not url: + raise RuntimeError("DATABASE_URL is not set") + return url + + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def _run(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + engine = create_async_engine(_database_url()) + async with engine.connect() as connection: + await connection.run_sync(_run) + await engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..3217cf0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_create_users.py b/alembic/versions/0001_create_users.py new file mode 100644 index 0000000..bdaeb1b --- /dev/null +++ b/alembic/versions/0001_create_users.py @@ -0,0 +1,38 @@ +"""create users table + +Revision ID: 0001 +Revises: +Create Date: 2026-07-04 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("sub", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("athlete_id", sa.String(length=64), nullable=True), + sa.Column("api_key_enc", sa.LargeBinary(), nullable=True), + sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("sub"), + ) + + +def downgrade() -> None: + op.drop_table("users") diff --git a/src/intervals_mcp_server/db/models.py b/src/intervals_mcp_server/db/models.py index f91a4b8..5a71a7a 100644 --- a/src/intervals_mcp_server/db/models.py +++ b/src/intervals_mcp_server/db/models.py @@ -11,7 +11,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import Boolean, DateTime, LargeBinary, String, func +from sqlalchemy import Boolean, DateTime, LargeBinary, String, false, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -31,7 +31,10 @@ class User(Base): athlete_id: Mapped[str | None] = mapped_column(String(64), nullable=True) api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Secure default: any row created outside the ORM still lands disabled. + enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now()