전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
126
apps/api/app/runtime_schema.py
Normal file
126
apps/api/app/runtime_schema.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue