전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -0,0 +1,126 @@
"""런타임 스키마 준비 상태 정책.
권위 스키마는 ``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