"""런타임 스키마 준비 상태 정책. 권위 스키마는 ``infra/db/init/*.sql``이다. 앱 기동 중 불완전한 스키마 보정은 로컬 개발 환경에서만 허용하고, 스테이징과 운영은 owner migration을 요구하며 fail-closed한다. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol from .config import settings class SchemaConnection(Protocol): async def fetchrow(self, query: str, *args: Any) -> Any: ... @dataclass(frozen=True, slots=True) class RuntimeSchemaContract: component: str relations: tuple[str, ...] columns: tuple[str, ...] = () policies: tuple[str, ...] = () REVIEW_SCHEMA_CONTRACT = RuntimeSchemaContract( component="review/evaluation", relations=( "app.session_evaluation", "app.case_worksheet", "app.live_coach_events", "app.safety_events", "app.session_review_status", "app.session_share_link", "app.session_archive_state", ), columns=( "app.live_coach_events.event_type", "app.live_coach_events.credit_delta", "app.live_coach_events.credit_balance", "app.live_coach_events.reason", "app.session_review_status.worksheet_status", "app.session_review_status.worksheet_note", "app.session_review_status.worksheet_reviewed_at", ), policies=( "app.session_evaluation.p_session_evaluation_select", "app.case_worksheet.p_case_worksheet_select", "app.live_coach_events.p_live_coach_events_select", "app.safety_events.p_safety_events_select", "app.session_review_status.p_session_review_status_select", "app.session_share_link.p_session_share_select", "app.session_archive_state.p_session_archive_select", ), ) NOTIFICATION_SCHEMA_CONTRACT = RuntimeSchemaContract( component="notification", relations=("app.notification_event", "app.notification_delivery"), policies=( "app.notification_event.p_notification_event_admin_all", "app.notification_delivery.p_notification_delivery_admin_all", ), ) async def schema_contract_ready( conn: SchemaConnection, contract: RuntimeSchemaContract, ) -> bool: row = await conn.fetchrow( """ SELECT NOT EXISTS ( SELECT 1 FROM unnest($1::text[]) AS required(relation_name) WHERE to_regclass(required.relation_name) IS NULL ) AND NOT EXISTS ( SELECT 1 FROM unnest($2::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM information_schema.columns c WHERE c.table_schema = split_part(required.qualified_name, '.', 1) AND c.table_name = split_part(required.qualified_name, '.', 2) AND c.column_name = split_part(required.qualified_name, '.', 3) ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($3::text[]) AS required(qualified_name) WHERE NOT EXISTS ( SELECT 1 FROM pg_policies p WHERE p.schemaname = split_part(required.qualified_name, '.', 1) AND p.tablename = split_part(required.qualified_name, '.', 2) AND p.policyname = split_part(required.qualified_name, '.', 3) ) ) AS ready """, list(contract.relations), list(contract.columns), list(contract.policies), ) return bool(row and row["ready"]) def runtime_schema_bootstrap_required( contract: RuntimeSchemaContract | str, *, ready: bool, ) -> bool: """로컬 보정 필요 여부를 반환하고, dev 외 환경에서는 불완전 스키마를 차단한다.""" if ready: return False component = ( contract.component if isinstance(contract, RuntimeSchemaContract) else contract ) if settings.environment != "dev": raise RuntimeError( f"{component} runtime DB schema is incomplete; run owner migration/init and " "scripts/check-deploy-preflight.py before starting the API" ) return True