# Plan 004: Initialize Alembic Migrations **Priority**: CRITICAL **Effort**: 2-3 hours **Status**: NOT STARTED **Risk Level**: HIGH - Schema evolution blocked --- ## Problem Statement The database schema is created via `Base.metadata.create_all()` with no migration history. Only one migration file exists (`004_create_stat_materialized_views.py`), and it's for materialized views only. **Current State**: - No version control of schema changes - Cannot rollback to previous schema versions - No documentation of schema evolution - Production schema sync is risky ## Impact - **Operations**: Cannot safely evolve schema - **Rollback**: No way to revert schema changes - **Audit**: No history of what changed when - **Team**: Other developers can't sync schema ## Files to Modify/Create | File | Action | |------|--------| | `backend/alembic/` | Initialize properly | | `backend/alembic/env.py` | Configure for async SQLAlchemy | | `backend/alembic/versions/001_initial_schema.py` | Create initial migration | | `backend/app/database/session.py` | Remove `create_all()` call | ## Implementation Steps ### Step 1: Backup Current Schema (15 min) ```bash # Export current schema cd /mnt/NV2/Development/strat-gameplay-webapp/backend # Dump schema from database pg_dump --schema-only -d strat_gameplay > schema_backup.sql # Also save current models cp app/models/db_models.py db_models_backup.py ``` ### Step 2: Configure Alembic for Async (30 min) Update `backend/alembic/env.py`: ```python 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 from app.models.db_models import Base from app.config import settings # Alembic Config object config = context.config # Set database URL from settings config.set_main_option("sqlalchemy.url", settings.database_url.replace("+asyncpg", "")) # Interpret the config file for Python logging if config.config_file_name is not None: fileConfig(config.config_file_name) # Model metadata for autogenerate target_metadata = Base.metadata def run_migrations_offline() -> None: """Run migrations in 'offline' mode.""" url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def do_run_migrations(connection: Connection) -> None: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() async def run_async_migrations() -> None: """Run migrations in 'online' mode with async engine.""" 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.""" asyncio.run(run_async_migrations()) if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ``` ### Step 3: Create Initial Migration (30 min) ```bash # Generate initial migration from existing models cd /mnt/NV2/Development/strat-gameplay-webapp/backend alembic revision --autogenerate -m "Initial schema from models" ``` Review the generated migration and ensure it matches existing schema. Create/update `backend/alembic/versions/001_initial_schema.py`: ```python """Initial schema from models Revision ID: 001 Revises: Create Date: 2025-01-27 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers revision: str = '001' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### Game table ### op.create_table('games', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('league_id', sa.String(10), nullable=False), sa.Column('home_team_id', sa.Integer(), nullable=False), sa.Column('away_team_id', sa.Integer(), nullable=False), sa.Column('home_user_id', sa.Integer(), nullable=True), sa.Column('away_user_id', sa.Integer(), nullable=True), sa.Column('current_inning', sa.Integer(), server_default='1'), sa.Column('current_half', sa.String(10), server_default='top'), sa.Column('home_score', sa.Integer(), server_default='0'), sa.Column('away_score', sa.Integer(), server_default='0'), sa.Column('status', sa.String(20), server_default='pending'), sa.Column('allow_spectators', sa.Boolean(), server_default='true'), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), sa.PrimaryKeyConstraint('id') ) op.create_index('idx_game_status', 'games', ['status']) op.create_index('idx_game_league', 'games', ['league_id']) # ### Lineup table ### op.create_table('lineups', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('team_id', sa.Integer(), nullable=False), sa.Column('card_id', sa.Integer(), nullable=True), sa.Column('player_id', sa.Integer(), nullable=True), sa.Column('position', sa.String(5), nullable=False), sa.Column('batting_order', sa.Integer(), nullable=True), sa.Column('is_active', sa.Boolean(), server_default='true'), sa.Column('entered_game_at', sa.Integer(), nullable=True), sa.Column('exited_game_at', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) op.create_index('idx_lineup_game', 'lineups', ['game_id']) op.create_index('idx_lineup_game_team', 'lineups', ['game_id', 'team_id']) # ### Play table ### op.create_table('plays', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('play_number', sa.Integer(), nullable=False), sa.Column('inning', sa.Integer(), nullable=False), sa.Column('half', sa.String(10), nullable=False), sa.Column('outs_before', sa.Integer(), nullable=False), sa.Column('outs_after', sa.Integer(), nullable=False), sa.Column('batter_id', sa.Integer(), nullable=True), sa.Column('pitcher_id', sa.Integer(), nullable=True), sa.Column('catcher_id', sa.Integer(), nullable=True), sa.Column('outcome', sa.String(50), nullable=False), sa.Column('description', sa.Text(), nullable=True), # ... additional columns ... sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['batter_id'], ['lineups.id']), sa.ForeignKeyConstraint(['pitcher_id'], ['lineups.id']), sa.ForeignKeyConstraint(['catcher_id'], ['lineups.id']), sa.PrimaryKeyConstraint('id') ) op.create_index('idx_play_game', 'plays', ['game_id']) op.create_index('idx_play_game_number', 'plays', ['game_id', 'play_number']) # ### Additional tables (Roll, GameSession, RosterLink, etc.) ### # ... (similar patterns for remaining tables) def downgrade() -> None: op.drop_table('plays') op.drop_table('lineups') op.drop_table('games') # ... drop remaining tables in reverse order ``` ### Step 4: Stamp Existing Database (15 min) For existing databases, mark as already at initial migration: ```bash # Mark existing database as having initial schema alembic stamp 001 ``` ### Step 5: Remove create_all() (10 min) Update `backend/app/database/session.py`: ```python async def init_db() -> None: """ Initialize database connection. NOTE: Schema creation is now handled by Alembic migrations. Run `alembic upgrade head` to create/update schema. """ # Removed: await conn.run_sync(Base.metadata.create_all) logger.info("Database connection initialized") ``` ### Step 6: Update README (15 min) Add to `backend/README.md`: ```markdown ## Database Migrations This project uses Alembic for database migrations. ### Initial Setup ```bash # Apply all migrations alembic upgrade head ``` ### Creating New Migrations ```bash # Auto-generate from model changes alembic revision --autogenerate -m "Description of changes" # Review the generated migration! # Then apply: alembic upgrade head ``` ### Rolling Back ```bash # Rollback one migration alembic downgrade -1 # Rollback to specific revision alembic downgrade 001 ``` ### Viewing History ```bash # Show migration history alembic history # Show current revision alembic current ``` ``` ### Step 7: Integrate Existing Materialized Views Migration (15 min) Ensure `004_create_stat_materialized_views.py` is properly linked: ```python # Update revision to chain properly revision: str = '004_stat_views' down_revision: str = '001' # Chain to initial ``` ### Step 8: Write Migration Tests (30 min) Create `backend/tests/integration/test_migrations.py`: ```python import pytest from alembic import command from alembic.config import Config class TestMigrations: """Tests for Alembic migrations.""" @pytest.fixture def alembic_config(self): config = Config("alembic.ini") return config def test_upgrade_to_head(self, alembic_config): """Migrations can be applied cleanly.""" command.upgrade(alembic_config, "head") def test_downgrade_to_base(self, alembic_config): """Migrations can be rolled back.""" command.upgrade(alembic_config, "head") command.downgrade(alembic_config, "base") def test_upgrade_downgrade_upgrade(self, alembic_config): """Full round-trip migration works.""" command.upgrade(alembic_config, "head") command.downgrade(alembic_config, "base") command.upgrade(alembic_config, "head") ``` ## Verification Checklist - [ ] `alembic upgrade head` creates all tables - [ ] `alembic downgrade base` removes all tables - [ ] Existing database can be stamped without issues - [ ] New migrations can be auto-generated - [ ] Migration tests pass - [ ] README updated with migration instructions ## CI/CD Integration Add to CI pipeline: ```yaml # .github/workflows/test.yml - name: Run migrations run: | cd backend alembic upgrade head ``` ## Rollback Plan If issues arise: 1. `alembic downgrade -1` to revert last migration 2. Restore from `schema_backup.sql` if needed 3. Re-enable `create_all()` temporarily ## Dependencies - None (can be implemented independently) ## Notes - Always review auto-generated migrations before applying - Test migrations on staging before production - Keep migrations small and focused - Future: Add data migrations for complex changes