""" Database Base Configuration =========================== CTO-201: Async SQLAlchemy setup Features: - SQLAlchemy 2.0 async engine - PostgreSQL (asyncpg) - 188 PostgreSQL - Session dependency injection 統帥鐵律 2026-03-23: - 絕對禁止 SQLite - 所有 Episodic Memory 必須使用 PostgreSQL """ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy.orm import DeclarativeBase from src.core.config import settings # ============================================================================= # Base Model # ============================================================================= class Base(DeclarativeBase): """SQLAlchemy declarative base""" pass # ============================================================================= # Engine & Session Factory # ============================================================================= _engine: AsyncEngine | None = None _session_factory: async_sessionmaker[AsyncSession] | None = None def get_engine() -> AsyncEngine: """ Get or create async engine 統帥鐵律 2026-03-23: - 使用 DATABASE_URL (PostgreSQL) - 絕對禁止 SQLite """ global _engine if _engine is None: database_url = settings.DATABASE_URL # 統帥鐵律: 禁止 SQLite (AWOOOI 憲法) # 🔴 違反此規則必須立即報錯,禁止 fallback if "sqlite" in database_url.lower(): import structlog logger = structlog.get_logger(__name__) logger.error("sqlite_forbidden", url=database_url) raise ValueError( "SQLite is FORBIDDEN by AWOOOI Constitution. " "Set DATABASE_URL to PostgreSQL: postgresql+asyncpg://user:pass@host:5432/db" ) _engine = create_async_engine( database_url, echo=settings.DEBUG, pool_size=10, max_overflow=20, pool_pre_ping=True, ) return _engine def get_session_factory() -> async_sessionmaker[AsyncSession]: """Get or create session factory""" global _session_factory if _session_factory is None: _session_factory = async_sessionmaker( bind=get_engine(), class_=AsyncSession, expire_on_commit=False, autoflush=False, ) return _session_factory # ============================================================================= # Dependency Injection # ============================================================================= async def get_db() -> AsyncGenerator[AsyncSession, None]: """ FastAPI dependency for database session Usage: @router.get("/items") async def get_items(db: AsyncSession = Depends(get_db)): ... """ factory = get_session_factory() async with factory() as session: try: yield session await session.commit() except Exception: await session.rollback() raise @asynccontextmanager async def get_db_context() -> AsyncGenerator[AsyncSession, None]: """ Context manager for database session (non-FastAPI usage) Usage: async with get_db_context() as db: ... """ factory = get_session_factory() async with factory() as session: try: yield session await session.commit() except Exception: await session.rollback() raise # ============================================================================= # Initialization # ============================================================================= async def init_db() -> None: """ Initialize database tables Call this at application startup. """ engine = get_engine() # 2026-04-15 ogt: 多 replica 並行啟動競爭修復 # 問題:單一大 transaction 裡兩個 pod 同時建 table → 其中一個 CREATE INDEX 失敗 # PostgreSQL 中 transaction 內任何錯誤導致整個 transaction ROLLBACK # → table + index 全消失 → 下次重啟同樣問題 → 無限 CrashLoop # 修法:每個 table 獨立 transaction;先 DROP INDEX IF EXISTS 清殘留孤兒 index; # 捕捉 "already exists" 讓並行 pod 優雅跳過 async with engine.connect() as probe_conn: existing = set(await probe_conn.run_sync( lambda c: __import__('sqlalchemy', fromlist=['inspect']).inspect(c).get_table_names() )) for table in Base.metadata.sorted_tables: if table.name not in existing: try: async with engine.begin() as conn: # 先清殘留孤兒 index(前次 CrashLoop 留下的部分狀態) for index in table.indexes: await conn.execute(text(f'DROP INDEX IF EXISTS "{index.name}"')) await conn.run_sync(table.create) except Exception as exc: if "already exists" in str(exc).lower(): pass # 並行 pod 已建好,忽略 else: raise async with engine.begin() as conn: # 2026-04-02 Claude Code: 確保 risklevel enum 包含 'high' 值 # Phase 23 新增,避免舊 DB 缺少此值導致 InvalidTextRepresentation await conn.execute( text(""" DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_enum WHERE enumtypid = 'risklevel'::regtype AND enumlabel = 'high' ) THEN ALTER TYPE risklevel ADD VALUE 'high'; END IF; END $$; """) ) # 2026-04-09 Claude Sonnet 4.6: Sprint 5R C1 修復 — 批准執行閉環 Telegram 訊息持久化欄位 # create_all 不做 ALTER,需手動補欄位 await conn.execute( text(""" ALTER TABLE approval_records ADD COLUMN IF NOT EXISTS telegram_message_id INTEGER, ADD COLUMN IF NOT EXISTS telegram_chat_id INTEGER; """) ) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3.5 Playbook PostgreSQL 持久化 # ADR-085: AI 學習成果不可存 Cache — trust_score、EWMA 必須永久保存 # playbooks 表已存在(15 筆舊資料),補加新欄位 await conn.execute( text(""" ALTER TABLE playbooks ADD COLUMN IF NOT EXISTS trust_score FLOAT NOT NULL DEFAULT 0.3, ADD COLUMN IF NOT EXISTS requires_approval_level VARCHAR(20) NOT NULL DEFAULT 'auto', ADD COLUMN IF NOT EXISTS stateful_targets JSONB NOT NULL DEFAULT '[]', ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE; """) ) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 感官升級 # ADR-084: EvidenceSnapshot 加入 Phase 4 動態異常上下文(anomaly_context) await conn.execute( text(""" ALTER TABLE incident_evidence ADD COLUMN IF NOT EXISTS anomaly_context JSONB; """) ) # 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 6 自我治理閉環 # ADR-087: ai_governance_events 不可變 Event Sourcing 表 # asyncpg 不允許 prepared statement 內多條指令,必須分開 execute await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_event_type " "ON ai_governance_events (event_type);" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_triggered_at " "ON ai_governance_events (triggered_at);" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_ai_governance_resolved " "ON ai_governance_events (resolved);" )) async def close_db() -> None: """ Close database connections Call this at application shutdown. """ global _engine, _session_factory if _engine is not None: await _engine.dispose() _engine = None _session_factory = None