mantimon-tcg/backend/app/db/migrations/versions/7ac994d6f89c_initial_schema.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

284 lines
12 KiB
Python

"""initial_schema
Revision ID: 7ac994d6f89c
Revises:
Create Date: 2026-01-27 08:52:08.554218
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "7ac994d6f89c"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("display_name", sa.String(length=50), nullable=False),
sa.Column("avatar_url", sa.String(length=500), nullable=True),
sa.Column("oauth_provider", sa.String(length=20), nullable=False),
sa.Column("oauth_id", sa.String(length=255), nullable=False),
sa.Column("is_premium", sa.Boolean(), nullable=False),
sa.Column("premium_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index("ix_users_oauth", "users", ["oauth_provider", "oauth_id"], unique=True)
op.create_table(
"active_games",
sa.Column(
"game_type",
sa.Enum(
"CAMPAIGN",
"FREEPLAY",
"RANKED",
"PRACTICE",
name="game_type",
create_constraint=True,
),
nullable=False,
),
sa.Column("player1_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("player2_id", sa.UUID(as_uuid=False), nullable=True),
sa.Column("npc_id", sa.String(length=100), nullable=True),
sa.Column("rules_config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("game_state", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("turn_number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_action_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("turn_deadline", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["player1_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["player2_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_active_games_player1", "active_games", ["player1_id"], unique=False)
op.create_index("ix_active_games_player2", "active_games", ["player2_id"], unique=False)
op.create_index(
"ix_active_games_players", "active_games", ["player1_id", "player2_id"], unique=False
)
op.create_table(
"campaign_progress",
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("current_club", sa.String(length=50), nullable=False),
sa.Column("medals", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("defeated_npcs", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("total_wins", sa.Integer(), nullable=False),
sa.Column("total_losses", sa.Integer(), nullable=False),
sa.Column("booster_packs", sa.Integer(), nullable=False),
sa.Column("mantibucks", sa.Integer(), nullable=False),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
sa.UniqueConstraint("user_id", name="uq_campaign_user"),
)
op.create_table(
"collections",
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("card_definition_id", sa.String(length=100), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column(
"source",
sa.Enum(
"STARTER",
"BOOSTER",
"REWARD",
"PURCHASE",
"TRADE",
"GIFT",
name="card_source",
create_constraint=True,
),
nullable=False,
),
sa.Column("obtained_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "card_definition_id", name="uq_collection_user_card"),
)
op.create_index("ix_collection_card", "collections", ["card_definition_id"], unique=False)
op.create_index(op.f("ix_collections_user_id"), "collections", ["user_id"], unique=False)
op.create_table(
"decks",
sa.Column("user_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("cards", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("energy_cards", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_valid", sa.Boolean(), nullable=False),
sa.Column("validation_errors", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("is_starter", sa.Boolean(), nullable=False),
sa.Column("starter_type", sa.String(length=20), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_decks_user_id"), "decks", ["user_id"], unique=False)
op.create_index("ix_decks_user_name", "decks", ["user_id", "name"], unique=False)
op.create_table(
"game_history",
sa.Column(
"game_type",
sa.Enum(
"CAMPAIGN",
"FREEPLAY",
"RANKED",
"PRACTICE",
name="game_type",
create_constraint=True,
),
nullable=False,
),
sa.Column("player1_id", sa.UUID(as_uuid=False), nullable=False),
sa.Column("player2_id", sa.UUID(as_uuid=False), nullable=True),
sa.Column("npc_id", sa.String(length=100), nullable=True),
sa.Column("winner_id", sa.UUID(as_uuid=False), nullable=True),
sa.Column("winner_is_npc", sa.Boolean(), nullable=False),
sa.Column(
"end_reason",
sa.Enum(
"PRIZES_TAKEN",
"NO_POKEMON",
"CANNOT_DRAW",
"RESIGNATION",
"TIMEOUT",
"TURN_LIMIT",
"DISCONNECTION",
"DRAW",
name="end_reason",
create_constraint=True,
),
nullable=False,
),
sa.Column("turn_count", sa.Integer(), nullable=False),
sa.Column("duration_seconds", sa.Integer(), nullable=False),
sa.Column("replay_data", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("played_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["player1_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["player2_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["winner_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_game_history_played_at", "game_history", ["played_at"], unique=False)
op.create_index("ix_game_history_player1", "game_history", ["player1_id"], unique=False)
op.create_index("ix_game_history_player2", "game_history", ["player2_id"], unique=False)
op.create_index(
"ix_game_history_type_played", "game_history", ["game_type", "played_at"], unique=False
)
op.create_index("ix_game_history_winner", "game_history", ["winner_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_game_history_winner", table_name="game_history")
op.drop_index("ix_game_history_type_played", table_name="game_history")
op.drop_index("ix_game_history_player2", table_name="game_history")
op.drop_index("ix_game_history_player1", table_name="game_history")
op.drop_index("ix_game_history_played_at", table_name="game_history")
op.drop_table("game_history")
op.drop_index("ix_decks_user_name", table_name="decks")
op.drop_index(op.f("ix_decks_user_id"), table_name="decks")
op.drop_table("decks")
op.drop_index(op.f("ix_collections_user_id"), table_name="collections")
op.drop_index("ix_collection_card", table_name="collections")
op.drop_table("collections")
op.drop_table("campaign_progress")
op.drop_index("ix_active_games_players", table_name="active_games")
op.drop_index("ix_active_games_player2", table_name="active_games")
op.drop_index("ix_active_games_player1", table_name="active_games")
op.drop_table("active_games")
op.drop_index("ix_users_oauth", table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
# ### end Alembic commands ###