strat-gameplay-webapp/backend/alembic/versions/001_initial_schema.py
Cal Corum 9d0d29ef18 CLAUDE: Add Alembic migrations and database session injection
Database Infrastructure:
- Added Alembic migration system (alembic.ini, env.py)
- Migration 001: Initial schema
- Migration 004: Stat materialized views (enhanced)
- Migration 005: Composite indexes for performance
- operations.py: Session injection support for test isolation
- session.py: Enhanced session management

Application Updates:
- main.py: Integration with new database infrastructure
- health.py: Enhanced health checks with pool monitoring

Integration Tests:
- conftest.py: Session injection pattern for reliable tests
- test_operations.py: Database operations tests
- test_migrations.py: Migration verification tests

Session injection pattern enables:
- Production: Auto-commit per operation
- Testing: Shared session with automatic rollback
- Transactions: Multiple ops, single commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:09:09 -06:00

289 lines
15 KiB
Python

"""Initial database schema
Revision ID: 001
Revises:
Create Date: 2025-01-27
Creates the core tables for the Paper Dynasty Real-Time Game Engine:
- games: Game container with status, scores, AI configuration
- plays: At-bat records with 30+ statistical fields
- lineups: Player assignments and substitution tracking
- game_sessions: WebSocket state tracking
- rolls: Dice roll audit trail
- roster_links: Eligible cards (PD) or players (SBA) per game
- game_cardset_links: PD league cardset configuration
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
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:
# === GAMES TABLE ===
op.create_table(
'games',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('league_id', sa.String(50), nullable=False),
sa.Column('home_team_id', sa.Integer(), nullable=False),
sa.Column('away_team_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('game_mode', sa.String(20), nullable=False),
sa.Column('visibility', sa.String(20), nullable=False),
sa.Column('current_inning', sa.Integer(), nullable=True),
sa.Column('current_half', sa.String(10), nullable=True),
sa.Column('home_score', sa.Integer(), nullable=True, server_default='0'),
sa.Column('away_score', sa.Integer(), nullable=True, server_default='0'),
sa.Column('home_team_is_ai', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('away_team_is_ai', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('ai_difficulty', sa.String(20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('winner_team_id', sa.Integer(), nullable=True),
sa.Column('game_metadata', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_games_created_at', 'games', ['created_at'])
op.create_index('ix_games_league_id', 'games', ['league_id'])
op.create_index('ix_games_status', 'games', ['status'])
# === LINEUPS 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(10), nullable=False),
sa.Column('batting_order', sa.Integer(), nullable=True),
sa.Column('is_starter', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('entered_inning', sa.Integer(), nullable=True, server_default='1'),
sa.Column('replacing_id', sa.Integer(), nullable=True),
sa.Column('after_play', sa.Integer(), nullable=True),
sa.Column('is_fatigued', sa.Boolean(), nullable=True),
sa.Column('lineup_metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.CheckConstraint(
'(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
name='lineup_one_id_required'
),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_lineups_game_id', 'lineups', ['game_id'])
op.create_index('ix_lineups_is_active', 'lineups', ['is_active'])
op.create_index('ix_lineups_team_id', 'lineups', ['team_id'])
# === PLAYS 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, server_default='1'),
sa.Column('half', sa.String(10), nullable=False),
sa.Column('outs_before', sa.Integer(), nullable=False, server_default='0'),
sa.Column('batting_order', sa.Integer(), nullable=False, server_default='1'),
sa.Column('away_score', sa.Integer(), nullable=True, server_default='0'),
sa.Column('home_score', sa.Integer(), nullable=True, server_default='0'),
# Players involved
sa.Column('batter_id', sa.Integer(), nullable=False),
sa.Column('pitcher_id', sa.Integer(), nullable=False),
sa.Column('catcher_id', sa.Integer(), nullable=False),
sa.Column('defender_id', sa.Integer(), nullable=True),
sa.Column('runner_id', sa.Integer(), nullable=True),
# Base runners
sa.Column('on_first_id', sa.Integer(), nullable=True),
sa.Column('on_second_id', sa.Integer(), nullable=True),
sa.Column('on_third_id', sa.Integer(), nullable=True),
# Runner final positions
sa.Column('on_first_final', sa.Integer(), nullable=True),
sa.Column('on_second_final', sa.Integer(), nullable=True),
sa.Column('on_third_final', sa.Integer(), nullable=True),
sa.Column('batter_final', sa.Integer(), nullable=True),
# Base state code
sa.Column('on_base_code', sa.Integer(), nullable=True, server_default='0'),
# Strategic decisions
sa.Column('defensive_choices', sa.JSON(), nullable=True),
sa.Column('offensive_choices', sa.JSON(), nullable=True),
# Play result
sa.Column('dice_roll', sa.String(50), nullable=True),
sa.Column('hit_type', sa.String(50), nullable=True),
sa.Column('result_description', sa.Text(), nullable=True),
sa.Column('outs_recorded', sa.Integer(), nullable=False, server_default='0'),
sa.Column('runs_scored', sa.Integer(), nullable=True, server_default='0'),
# Defensive details
sa.Column('check_pos', sa.String(10), nullable=True),
sa.Column('error', sa.Integer(), nullable=True, server_default='0'),
# Batting statistics
sa.Column('pa', sa.Integer(), nullable=True, server_default='0'),
sa.Column('ab', sa.Integer(), nullable=True, server_default='0'),
sa.Column('hit', sa.Integer(), nullable=True, server_default='0'),
sa.Column('double', sa.Integer(), nullable=True, server_default='0'),
sa.Column('triple', sa.Integer(), nullable=True, server_default='0'),
sa.Column('homerun', sa.Integer(), nullable=True, server_default='0'),
sa.Column('bb', sa.Integer(), nullable=True, server_default='0'),
sa.Column('so', sa.Integer(), nullable=True, server_default='0'),
sa.Column('hbp', sa.Integer(), nullable=True, server_default='0'),
sa.Column('rbi', sa.Integer(), nullable=True, server_default='0'),
sa.Column('sac', sa.Integer(), nullable=True, server_default='0'),
sa.Column('ibb', sa.Integer(), nullable=True, server_default='0'),
sa.Column('gidp', sa.Integer(), nullable=True, server_default='0'),
# Baserunning statistics
sa.Column('sb', sa.Integer(), nullable=True, server_default='0'),
sa.Column('cs', sa.Integer(), nullable=True, server_default='0'),
# Pitching events
sa.Column('wild_pitch', sa.Integer(), nullable=True, server_default='0'),
sa.Column('passed_ball', sa.Integer(), nullable=True, server_default='0'),
sa.Column('pick_off', sa.Integer(), nullable=True, server_default='0'),
sa.Column('balk', sa.Integer(), nullable=True, server_default='0'),
# Ballpark power events
sa.Column('bphr', sa.Integer(), nullable=True, server_default='0'),
sa.Column('bpfo', sa.Integer(), nullable=True, server_default='0'),
sa.Column('bp1b', sa.Integer(), nullable=True, server_default='0'),
sa.Column('bplo', sa.Integer(), nullable=True, server_default='0'),
# Advanced analytics
sa.Column('wpa', sa.Float(), nullable=True, server_default='0.0'),
sa.Column('re24', sa.Float(), nullable=True, server_default='0.0'),
# Earned/unearned runs
sa.Column('run', sa.Integer(), nullable=True, server_default='0'),
sa.Column('e_run', sa.Integer(), nullable=True, server_default='0'),
# Game situation flags
sa.Column('is_tied', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('is_go_ahead', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('is_new_inning', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('in_pow', sa.Boolean(), nullable=True, server_default='false'),
# Play workflow
sa.Column('complete', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('locked', sa.Boolean(), nullable=True, server_default='false'),
# Timestamps
sa.Column('created_at', sa.DateTime(), nullable=True),
# Extensibility
sa.Column('play_metadata', sa.JSON(), nullable=True),
# Foreign keys
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.ForeignKeyConstraint(['defender_id'], ['lineups.id']),
sa.ForeignKeyConstraint(['runner_id'], ['lineups.id']),
sa.ForeignKeyConstraint(['on_first_id'], ['lineups.id']),
sa.ForeignKeyConstraint(['on_second_id'], ['lineups.id']),
sa.ForeignKeyConstraint(['on_third_id'], ['lineups.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_plays_complete', 'plays', ['complete'])
op.create_index('ix_plays_created_at', 'plays', ['created_at'])
op.create_index('ix_plays_game_id', 'plays', ['game_id'])
# === GAME_SESSIONS TABLE ===
op.create_table(
'game_sessions',
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('connected_users', sa.JSON(), nullable=True),
sa.Column('last_action_at', sa.DateTime(), nullable=True),
sa.Column('state_snapshot', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('game_id')
)
op.create_index('ix_game_sessions_last_action_at', 'game_sessions', ['last_action_at'])
# === ROLLS TABLE ===
op.create_table(
'rolls',
sa.Column('roll_id', sa.String(), nullable=False),
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('roll_type', sa.String(), nullable=False),
sa.Column('league_id', sa.String(), nullable=False),
sa.Column('team_id', sa.Integer(), nullable=True),
sa.Column('player_id', sa.Integer(), nullable=True),
sa.Column('roll_data', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('context', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('roll_id')
)
op.create_index('ix_rolls_game_id', 'rolls', ['game_id'])
op.create_index('ix_rolls_league_id', 'rolls', ['league_id'])
op.create_index('ix_rolls_player_id', 'rolls', ['player_id'])
op.create_index('ix_rolls_roll_type', 'rolls', ['roll_type'])
op.create_index('ix_rolls_team_id', 'rolls', ['team_id'])
op.create_index('ix_rolls_timestamp', 'rolls', ['timestamp'])
# === ROSTER_LINKS TABLE ===
op.create_table(
'roster_links',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=True),
sa.Column('player_id', sa.Integer(), nullable=True),
sa.Column('team_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.CheckConstraint(
'(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
name='roster_link_one_id_required'
),
sa.UniqueConstraint('game_id', 'card_id', name='uq_game_card'),
sa.UniqueConstraint('game_id', 'player_id', name='uq_game_player'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_roster_links_game_id', 'roster_links', ['game_id'])
op.create_index('ix_roster_links_team_id', 'roster_links', ['team_id'])
# === GAME_CARDSET_LINKS TABLE ===
op.create_table(
'game_cardset_links',
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('cardset_id', sa.Integer(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=True, server_default='1'),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('game_id', 'cardset_id')
)
op.create_index('ix_game_cardset_links_priority', 'game_cardset_links', ['priority'])
def downgrade() -> None:
# Drop tables in reverse order (respecting foreign key constraints)
op.drop_index('ix_game_cardset_links_priority', table_name='game_cardset_links')
op.drop_table('game_cardset_links')
op.drop_index('ix_roster_links_team_id', table_name='roster_links')
op.drop_index('ix_roster_links_game_id', table_name='roster_links')
op.drop_table('roster_links')
op.drop_index('ix_rolls_timestamp', table_name='rolls')
op.drop_index('ix_rolls_team_id', table_name='rolls')
op.drop_index('ix_rolls_roll_type', table_name='rolls')
op.drop_index('ix_rolls_player_id', table_name='rolls')
op.drop_index('ix_rolls_league_id', table_name='rolls')
op.drop_index('ix_rolls_game_id', table_name='rolls')
op.drop_table('rolls')
op.drop_index('ix_game_sessions_last_action_at', table_name='game_sessions')
op.drop_table('game_sessions')
op.drop_index('ix_plays_game_id', table_name='plays')
op.drop_index('ix_plays_created_at', table_name='plays')
op.drop_index('ix_plays_complete', table_name='plays')
op.drop_table('plays')
op.drop_index('ix_lineups_team_id', table_name='lineups')
op.drop_index('ix_lineups_is_active', table_name='lineups')
op.drop_index('ix_lineups_game_id', table_name='lineups')
op.drop_table('lineups')
op.drop_index('ix_games_status', table_name='games')
op.drop_index('ix_games_league_id', table_name='games')
op.drop_index('ix_games_created_at', table_name='games')
op.drop_table('games')