126 lines
4.1 KiB
Python
126 lines
4.1 KiB
Python
"""런타임 스키마 준비 상태 정책.
|
|
|
|
권위 스키마는 ``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
|