feat(db): Alembic migration for users table (async env, DATABASE_URL)
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 24s

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.
This commit is contained in:
2026-07-04 19:35:55 -04:00
parent f067f9639a
commit 8204675a66
6 changed files with 160 additions and 2 deletions
+2
View File
@@ -183,3 +183,5 @@ TODO
.claude/ .claude/
.DS_Store .DS_Store
CLAUDE.md CLAUDE.md
_alembic_test.db
*.db
+38
View File
@@ -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
+54
View File
@@ -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())
+23
View File
@@ -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"}
+38
View File
@@ -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")
+5 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime 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 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) athlete_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
api_key_enc: Mapped[bytes | None] = mapped_column(LargeBinary, 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()