mantimon-tcg/backend/project_plans/PHASE_1_DATABASE.json
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

442 lines
15 KiB
JSON

{
"meta": {
"version": "1.0.0",
"created": "2026-01-27",
"lastUpdated": "2026-01-27",
"planType": "phase",
"phaseId": "PHASE_1",
"phaseName": "Database + Infrastructure",
"description": "PostgreSQL models, Redis caching, CardService, and development environment setup",
"totalEstimatedHours": 40,
"totalTasks": 18,
"completedTasks": 0,
"masterPlan": "../PROJECT_PLAN_MASTER.json"
},
"goals": [
"Establish PostgreSQL as durable storage with async SQLAlchemy",
"Set up Redis for game state caching and future matchmaking",
"Implement GameStateManager with write-behind persistence",
"Create CardService to load bundled JSON into CardDefinitions",
"Configure environment-based settings (dev/staging/prod)",
"Docker compose for local development"
],
"architectureNotes": {
"gameStatePersistence": {
"strategy": "Write-behind cache",
"primary": "Redis (fast reads/writes during gameplay)",
"durable": "PostgreSQL (write at turn boundaries + game end)",
"recovery": "On restart, load active games from Postgres → Redis"
},
"asyncEverywhere": "All DB and Redis operations use async/await",
"sessionManagement": "Scoped async sessions with proper cleanup"
},
"directoryStructure": {
"db": "backend/app/db/",
"services": "backend/app/services/",
"config": "backend/app/config.py",
"docker": "backend/docker-compose.yml"
},
"tasks": [
{
"id": "DB-001",
"name": "Create environment config module",
"description": "Pydantic Settings class for environment-based configuration (database URLs, Redis URL, secrets)",
"category": "critical",
"priority": 1,
"completed": false,
"dependencies": [],
"files": [
{"path": "app/config.py", "status": "pending"}
],
"details": [
"Use pydantic-settings for env var parsing",
"Support .env files for local dev",
"Separate configs: DATABASE_URL, REDIS_URL, SECRET_KEY, OAUTH_*",
"Environment detection: dev/staging/prod"
],
"estimatedHours": 2
},
{
"id": "DB-002",
"name": "Create Docker compose for local dev",
"description": "Docker compose file with PostgreSQL and Redis services",
"category": "critical",
"priority": 2,
"completed": false,
"dependencies": ["DB-001"],
"files": [
{"path": "docker-compose.yml", "status": "pending"},
{"path": ".env.example", "status": "pending"}
],
"details": [
"PostgreSQL 15 with health check",
"Redis 7 with persistence disabled (dev only)",
"Volume mounts for data persistence",
"Network for service communication"
],
"estimatedHours": 1
},
{
"id": "DB-003",
"name": "Set up SQLAlchemy async engine",
"description": "Create async engine, session factory, and base model class",
"category": "critical",
"priority": 3,
"completed": false,
"dependencies": ["DB-001"],
"files": [
{"path": "app/db/__init__.py", "status": "pending"},
{"path": "app/db/session.py", "status": "pending"},
{"path": "app/db/base.py", "status": "pending"}
],
"details": [
"AsyncEngine with connection pooling",
"async_sessionmaker for scoped sessions",
"Base declarative class with common columns (id, created_at, updated_at)",
"get_session() async context manager"
],
"estimatedHours": 2
},
{
"id": "DB-004",
"name": "Create User model",
"description": "SQLAlchemy model for user accounts with OAuth support",
"category": "high",
"priority": 4,
"completed": false,
"dependencies": ["DB-003"],
"files": [
{"path": "app/db/models/user.py", "status": "pending"}
],
"details": [
"Fields: id (UUID), email, display_name, avatar_url",
"OAuth fields: oauth_provider, oauth_id (composite unique)",
"Premium fields: is_premium, premium_until (nullable datetime)",
"Timestamps: created_at, last_login",
"Index on (oauth_provider, oauth_id)"
],
"estimatedHours": 1.5
},
{
"id": "DB-005",
"name": "Create Collection model",
"description": "SQLAlchemy model for player card collections",
"category": "high",
"priority": 5,
"completed": false,
"dependencies": ["DB-004"],
"files": [
{"path": "app/db/models/collection.py", "status": "pending"}
],
"details": [
"Fields: id, user_id (FK), card_definition_id (str), quantity",
"Source tracking: source (enum: starter, booster, reward, purchase)",
"Timestamps: obtained_at",
"Unique constraint on (user_id, card_definition_id)",
"Index on user_id for fast collection lookups"
],
"estimatedHours": 1.5
},
{
"id": "DB-006",
"name": "Create Deck model",
"description": "SQLAlchemy model for player decks",
"category": "high",
"priority": 6,
"completed": false,
"dependencies": ["DB-004"],
"files": [
{"path": "app/db/models/deck.py", "status": "pending"}
],
"details": [
"Fields: id, user_id (FK), name, cards (JSONB), energy_cards (JSONB)",
"Validation: is_valid (bool), validation_errors (JSONB nullable)",
"Metadata: is_starter (bool), starter_type (nullable)",
"Timestamps: created_at, updated_at",
"Index on user_id"
],
"estimatedHours": 1.5
},
{
"id": "DB-007",
"name": "Create CampaignProgress model",
"description": "SQLAlchemy model for single-player campaign state",
"category": "high",
"priority": 7,
"completed": false,
"dependencies": ["DB-004"],
"files": [
{"path": "app/db/models/campaign.py", "status": "pending"}
],
"details": [
"Fields: id, user_id (FK unique), current_club, medals (JSONB)",
"Progress: defeated_npcs (JSONB), total_wins, total_losses",
"Economy: booster_packs (int), mantibucks (int)",
"Timestamps: created_at, updated_at"
],
"estimatedHours": 1.5
},
{
"id": "DB-008",
"name": "Create ActiveGame model",
"description": "SQLAlchemy model for in-progress games (Postgres backup)",
"category": "high",
"priority": 8,
"completed": false,
"dependencies": ["DB-003"],
"files": [
{"path": "app/db/models/game.py", "status": "pending"}
],
"details": [
"Fields: id (UUID), game_type (enum: campaign, freeplay, ranked)",
"Players: player1_id (FK), player2_id (FK nullable), npc_id (nullable)",
"State: rules_config (JSONB), game_state (JSONB), turn_number",
"Timing: started_at, last_action_at, turn_deadline (nullable)",
"Index on player IDs for reconnection lookup"
],
"estimatedHours": 2
},
{
"id": "DB-009",
"name": "Create GameHistory model",
"description": "SQLAlchemy model for completed games",
"category": "medium",
"priority": 9,
"completed": false,
"dependencies": ["DB-003"],
"files": [
{"path": "app/db/models/game.py", "status": "append"}
],
"details": [
"Fields: id, game_type, player1_id, player2_id, npc_id",
"Result: winner_id, end_reason, turn_count, duration_seconds",
"Replay: replay_data (JSONB for future replay feature)",
"Timestamp: played_at",
"Indexes for leaderboard queries"
],
"estimatedHours": 1.5
},
{
"id": "DB-010",
"name": "Create models __init__ and export",
"description": "Consolidate all models in db/models/__init__.py",
"category": "medium",
"priority": 10,
"completed": false,
"dependencies": ["DB-004", "DB-005", "DB-006", "DB-007", "DB-008", "DB-009"],
"files": [
{"path": "app/db/models/__init__.py", "status": "pending"}
],
"details": [
"Import and re-export all models",
"Import Base for Alembic"
],
"estimatedHours": 0.5
},
{
"id": "DB-011",
"name": "Set up Alembic migrations",
"description": "Initialize Alembic and create initial migration",
"category": "high",
"priority": 11,
"completed": false,
"dependencies": ["DB-010"],
"files": [
{"path": "alembic.ini", "status": "pending"},
{"path": "app/db/migrations/env.py", "status": "pending"},
{"path": "app/db/migrations/versions/001_initial.py", "status": "pending"}
],
"details": [
"Configure async Alembic",
"Auto-generate from SQLAlchemy models",
"Initial migration with all tables",
"Add to pyproject.toml dependencies"
],
"estimatedHours": 2
},
{
"id": "DB-012",
"name": "Create Redis connection utilities",
"description": "Async Redis client with connection pooling",
"category": "high",
"priority": 12,
"completed": false,
"dependencies": ["DB-001"],
"files": [
{"path": "app/db/redis.py", "status": "pending"}
],
"details": [
"Use redis.asyncio (redis-py)",
"Connection pool configuration",
"get_redis() async context manager",
"Helper methods: get_json, set_json, delete"
],
"estimatedHours": 1.5
},
{
"id": "DB-013",
"name": "Create GameStateManager",
"description": "Redis-primary, Postgres-backup game state management",
"category": "critical",
"priority": 13,
"completed": false,
"dependencies": ["DB-012", "DB-008"],
"files": [
{"path": "app/services/game_state_manager.py", "status": "pending"}
],
"details": [
"load_state(): Try Redis, fallback to Postgres",
"save_to_cache(): Write to Redis only (fast path)",
"persist_to_db(): Write to Postgres (turn boundaries)",
"delete_game(): Clean up Redis + Postgres",
"recover_active_games(): Load from Postgres → Redis on startup",
"Key format: game:{game_id}"
],
"estimatedHours": 3
},
{
"id": "DB-014",
"name": "Create CardService",
"description": "Load card definitions from bundled JSON files",
"category": "critical",
"priority": 14,
"completed": false,
"dependencies": [],
"files": [
{"path": "app/services/card_service.py", "status": "pending"}
],
"details": [
"Load from data/cards/_index.json on startup",
"Parse JSON → CardDefinition models",
"get_card(card_id) → CardDefinition",
"get_cards(card_ids) → dict[str, CardDefinition]",
"get_cards_by_set(set_code) → list[CardDefinition]",
"search_cards(filters) for deck building UI",
"Singleton pattern or dependency injection"
],
"estimatedHours": 3
},
{
"id": "DB-015",
"name": "Create database tests",
"description": "Tests for models, sessions, and basic CRUD",
"category": "high",
"priority": 15,
"completed": false,
"dependencies": ["DB-011"],
"files": [
{"path": "tests/db/__init__.py", "status": "pending"},
{"path": "tests/db/conftest.py", "status": "pending"},
{"path": "tests/db/test_models.py", "status": "pending"}
],
"details": [
"Use testcontainers-postgres for isolated DB",
"Test each model: create, read, update, delete",
"Test relationships and constraints",
"Test JSONB serialization"
],
"estimatedHours": 4
},
{
"id": "DB-016",
"name": "Create GameStateManager tests",
"description": "Tests for Redis + Postgres state management",
"category": "high",
"priority": 16,
"completed": false,
"dependencies": ["DB-013", "DB-015"],
"files": [
{"path": "tests/services/test_game_state_manager.py", "status": "pending"}
],
"details": [
"Use testcontainers for Redis",
"Test cache hit (Redis has state)",
"Test cache miss (fallback to Postgres)",
"Test persist_to_db writes correctly",
"Test recovery on startup",
"Test cleanup on game end"
],
"estimatedHours": 3
},
{
"id": "DB-017",
"name": "Create CardService tests",
"description": "Tests for card loading and lookup",
"category": "high",
"priority": 17,
"completed": false,
"dependencies": ["DB-014"],
"files": [
{"path": "tests/services/test_card_service.py", "status": "pending"}
],
"details": [
"Test loads all cards from JSON",
"Test get_card returns correct definition",
"Test get_cards batch lookup",
"Test search with filters (type, set, rarity)",
"Test handles missing card gracefully"
],
"estimatedHours": 2
},
{
"id": "DB-018",
"name": "Update dependencies in pyproject.toml",
"description": "Add all required packages for Phase 1",
"category": "high",
"priority": 18,
"completed": false,
"dependencies": [],
"files": [
{"path": "pyproject.toml", "status": "update"}
],
"details": [
"sqlalchemy[asyncio] >= 2.0",
"asyncpg (Postgres async driver)",
"alembic",
"redis >= 5.0 (async support)",
"pydantic-settings",
"testcontainers[postgres,redis] (dev)"
],
"estimatedHours": 0.5
}
],
"testingStrategy": {
"approach": "Testcontainers for isolated Postgres/Redis",
"fixtures": "Async fixtures in conftest.py",
"coverage": "Target 90%+ on new code"
},
"weeklyRoadmap": {
"week1": {
"theme": "Foundation",
"tasks": ["DB-001", "DB-002", "DB-003", "DB-018"],
"goals": ["Dev environment running", "SQLAlchemy configured"]
},
"week2": {
"theme": "Models + Migrations",
"tasks": ["DB-004", "DB-005", "DB-006", "DB-007", "DB-008", "DB-009", "DB-010", "DB-011"],
"goals": ["All models defined", "Migrations working"]
},
"week3": {
"theme": "Services + Testing",
"tasks": ["DB-012", "DB-013", "DB-014", "DB-015", "DB-016", "DB-017"],
"goals": ["GameStateManager working", "CardService working", "Full test coverage"]
}
},
"acceptanceCriteria": [
"docker-compose up starts Postgres + Redis locally",
"Alembic migrations run successfully",
"All models have CRUD tests passing",
"GameStateManager persists at turn boundaries",
"GameStateManager recovers from Postgres on restart",
"CardService loads all 372 cards from JSON",
"All tests pass with testcontainers"
]
}