fix(migrations): eid 301~305 multi-statement → 1-statement/파일 분리 (301~316)

asyncpg 러너가 exec_driver_sql 을 prepared statement(extended protocol)로 처리해
multi-statement 를 거부(cannot insert multiple commands) → fastapi init_db crash.
(001 등 초기 multi-stmt 는 postgres initdb=psql simple protocol 로 적용됐던 것 — 작성자 가정 오류.)
301~305(각 2~4 stmt)를 내용 불변으로 16개 single-statement 파일(301~316)로 분리:
 eid_study_weakness(table/rule2/idx)·eid_review_set_draft(동)·eid_weekly_recap(동)
 ·approval_requests(table/idx)·eid_schedule_views(view2). 원순서·FK 의존성 보존.
프로덕션 pkm DB 대상 트랜잭션 dry-run(ROLLBACK) 16/16 무오류 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 20:42:32 +09:00
parent e817a0abfc
commit 5bde1c765c
21 changed files with 117 additions and 150 deletions
-40
View File
@@ -1,40 +0,0 @@
-- 301_eid_study_weakness.sql
-- 이드 학습 약점 스냅샷 (append-only derived-fact). eid_study_weakness 워커가 study_question_progress
-- + study_quiz_sessions 집계로 산출(LLM 0). study_diagnosis 표면이 최신 행을 읽어 코치 발화.
--
-- ★ append-only 구조강제 (project_eid_persona_substrate 불변식 #8) — 2중:
-- (1) INSERT 스탬프 누락 거부: actor·source_generated_at = NOT NULL·DEFAULT 없음
-- → 스탬프 없는 INSERT 를 DB 가 거부. NOT NULL 은 owner 포함 모든 role 에 적용(role 독립).
-- (2) UPDATE/DELETE 차단: CREATE RULE ... DO INSTEAD NOTHING → 행 불변(owner·superuser 독립).
--
-- ★ 설계 원안 'REVOKE UPDATE,DELETE' 정정(load-bearing): 단일 DB role `pkm` 이 테이블 OWNER 라
-- REVOKE 가 무효(owner 는 GRANT/REVOKE 우회). plpgsql trigger(RAISE)는 migration 검증기가
-- 본문의 BEGIN 키워드를 거부(_validate_sql_content)해 불가. → RULE 이 owner 독립 + 검증기 통과하는
-- 유일한 구조 enforcement(silent no-op, 행은 구조적으로 불변). 별도 read-only role 미존재.
--
-- ★ '현재' 스냅샷 = 최신 created_at 행(WHERE status='active'). 상태전이 UPDATE 없음(append-only).
-- dispute = status='disputed' + supersedes_id 로 특정 스냅샷 무효화(새 INSERT). 표면이 disputed 제외.
--
-- runner = exec_driver_sql(simple protocol) → multi-statement 처리(001_initial_schema 선례, 18 stmt).
-- BEGIN/COMMIT/ROLLBACK 없음(검증기 통과). CREATE RULE 은 IF NOT EXISTS 미지원 → OR REPLACE 로 idempotent.
CREATE TABLE IF NOT EXISTS eid_study_weakness (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
weaknesses JSONB NOT NULL, -- [{topic_id,topic,chronic,relapsed,leech,coverage_gap,unsure,overdue,trend,tier}]
habit_signals JSONB NOT NULL, -- {avoidance_topics,session_abandon_rate,stale_due_count,skew_topics}
trend_label VARCHAR(20) NOT NULL, -- overall 개선|정체|악화 (코드 산출)
sample_attempts INTEGER NOT NULL DEFAULT 0,
is_shallow_sample BOOLEAN NOT NULL DEFAULT false,
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'(dispute marker)
supersedes_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
actor VARCHAR(20) NOT NULL, -- 스탬프(no default) = 'eid'
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프(no default) = 집계 시점
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE RULE eid_study_weakness_no_update AS ON UPDATE TO eid_study_weakness DO INSTEAD NOTHING;
CREATE OR REPLACE RULE eid_study_weakness_no_delete AS ON DELETE TO eid_study_weakness DO INSTEAD NOTHING;
CREATE INDEX IF NOT EXISTS idx_eid_weakness_user_current
ON eid_study_weakness (user_id, created_at DESC) WHERE status = 'active';
@@ -0,0 +1,16 @@
-- 301_eid_study_weakness_table.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE TABLE IF NOT EXISTS eid_study_weakness (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
weaknesses JSONB NOT NULL, -- [{topic_id,topic,chronic,relapsed,leech,coverage_gap,unsure,overdue,trend,tier}]
habit_signals JSONB NOT NULL, -- {avoidance_topics,session_abandon_rate,stale_due_count,skew_topics}
trend_label VARCHAR(20) NOT NULL, -- overall 개선|정체|악화 (코드 산출)
sample_attempts INTEGER NOT NULL DEFAULT 0,
is_shallow_sample BOOLEAN NOT NULL DEFAULT false,
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'(dispute marker)
supersedes_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
actor VARCHAR(20) NOT NULL, -- 스탬프(no default) = 'eid'
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프(no default) = 집계 시점
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-26
View File
@@ -1,26 +0,0 @@
-- 302_eid_review_set_draft.sql
-- 이드 복습세트 초안 (append-only derived-fact). 워커가 약점 스냅샷에서 권장 복습세트를 '제안'만 한다.
-- study overlay 항목6: "복습세트를 실제 복습 큐에 편성은 자율로 못 한다 — 초안만 제시, 사용자 1클릭".
-- 실제 편성(study_question_progress.due_at 편집)은 별도 T2 액션 — 이 draft 는 불변 제안 기록.
--
-- append-only 구조강제(=301 동일): actor·source_generated_at NOT NULL no-default(스탬프) + RULE(불변).
-- 상태전이 없음 — '현재 제안' = 최신 created_at. 새 제안은 supersedes_id 로 이전 것 가리킴(새 INSERT).
-- question_ids = ordered list[int] snapshot (study_quiz_sessions.question_ids 패턴, junction 안 씀).
CREATE TABLE IF NOT EXISTS eid_review_set_draft (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
study_topic_id BIGINT REFERENCES study_topics(id) ON DELETE CASCADE, -- nullable = cross-topic 세트
question_ids JSONB NOT NULL, -- ordered list[int]
reason VARCHAR(40) NOT NULL, -- chronic | relapse | coverage | overdue
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
source_weakness_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
supersedes_id BIGINT REFERENCES eid_review_set_draft(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE RULE eid_review_set_draft_no_update AS ON UPDATE TO eid_review_set_draft DO INSTEAD NOTHING;
CREATE OR REPLACE RULE eid_review_set_draft_no_delete AS ON DELETE TO eid_review_set_draft DO INSTEAD NOTHING;
CREATE INDEX IF NOT EXISTS idx_eid_review_draft_user ON eid_review_set_draft (user_id, created_at DESC);
@@ -0,0 +1,3 @@
-- 302_eid_study_weakness_no_update.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_study_weakness_no_update AS ON UPDATE TO eid_study_weakness DO INSTEAD NOTHING;
@@ -0,0 +1,3 @@
-- 303_eid_study_weakness_no_delete.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_study_weakness_no_delete AS ON DELETE TO eid_study_weakness DO INSTEAD NOTHING;
-27
View File
@@ -1,27 +0,0 @@
-- 303_eid_weekly_recap.sql
-- 이드 주간 회고 카드 (append-only derived-fact). 회고 워커(scaffold, 미배선 — W4/Phase2)가 산출.
-- recap overlay: 'T1 write 자율 eid_weekly_recap(append-only)'. 미결 액션아이템 open/done UPDATE 는
-- events 측(가변)이지 이 카드가 아님 — 카드 자체는 불변 스냅샷.
-- 현재는 통합 migration 의 scaffold 테이블(dispatch enum WRITE_WEEKLY_RECAP 의 write target 예약).
--
-- append-only 구조강제(=301 동일): 스탬프 NOT NULL no-default + RULE(불변). '현재' = 최신 created_at.
CREATE TABLE IF NOT EXISTS eid_weekly_recap (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
recap JSONB NOT NULL, -- {activity_summary, open_action_items:[event_id], recurring_topics}
trend_label VARCHAR(20),
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'
supersedes_id BIGINT REFERENCES eid_weekly_recap(id) ON DELETE SET NULL,
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE RULE eid_weekly_recap_no_update AS ON UPDATE TO eid_weekly_recap DO INSTEAD NOTHING;
CREATE OR REPLACE RULE eid_weekly_recap_no_delete AS ON DELETE TO eid_weekly_recap DO INSTEAD NOTHING;
CREATE INDEX IF NOT EXISTS idx_eid_recap_user_current
ON eid_weekly_recap (user_id, created_at DESC) WHERE status = 'active';
-24
View File
@@ -1,24 +0,0 @@
-- 304_approval_requests.sql
-- 외부 전송 승인 큐 (★ 가변 workflow queue — append-only 아님). 설계 3-4 명시 카브아웃:
-- "approval_requests 는 status 를 pending→approved 로 바꾸는 가변 state 라 eid_* 불변 REVOKE/RULE 대상 아님".
-- → 여기엔 RULE(append-only) 안 건다. status 전이(UPDATE) 허용.
--
-- ★ Phase1 현재: app/eid/tools/dispatch.py 의 request_external_approval = 즉시 거부(INSERT 0).
-- dispatcher 워커(유일 egress 집행)는 Phase3. 이 테이블은 그때까지 scaffold(빈 상태).
-- ★ payload 는 고정 템플릿 슬롯만(free-form 금지) — app 층이 request_type 별 화이트리스트 검증.
-- 승인 UI 는 전송 body 전문 diff 노출. 불변 결정 원장이 필요하면 별도 append-only approval_events(Phase3).
CREATE TABLE IF NOT EXISTS approval_requests (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
request_type VARCHAR(40) NOT NULL, -- 고정 템플릿 슬롯 타입(app 화이트리스트)
payload JSONB NOT NULL, -- 고정 템플릿 슬롯만
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | approved | rejected (전이 허용)
requester VARCHAR(20) NOT NULL, -- 'eid'
decided_by VARCHAR(40),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests (status, created_at);
@@ -0,0 +1,4 @@
-- 304_eid_study_weakness_idx.sql — 301_eid_study_weakness.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE INDEX IF NOT EXISTS idx_eid_weakness_user_current
ON eid_study_weakness (user_id, created_at DESC) WHERE status = 'active';
@@ -0,0 +1,14 @@
-- 305_eid_review_set_draft_table.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE TABLE IF NOT EXISTS eid_review_set_draft (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
study_topic_id BIGINT REFERENCES study_topics(id) ON DELETE CASCADE, -- nullable = cross-topic 세트
question_ids JSONB NOT NULL, -- ordered list[int]
reason VARCHAR(40) NOT NULL, -- chronic | relapse | coverage | overdue
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
source_weakness_id BIGINT REFERENCES eid_study_weakness(id) ON DELETE SET NULL,
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
supersedes_id BIGINT REFERENCES eid_review_set_draft(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-33
View File
@@ -1,33 +0,0 @@
-- 305_eid_schedule_views.sql
-- 이드 일정(schedule_brief, 미래 surface) 파생뷰 2. 신규 schedule 테이블 0 — events/events_history 재활용.
-- quadrant(중요×긴급)·D-N 정렬은 app 층(schedule overlay). 뷰는 raw 입력 필드 + today/defer 집계만.
-- CREATE VIEW 선례 = 010_soft_delete / 283_corpus_chunks. BEGIN/COMMIT 없음.
--
-- v_schedule_today: 오늘(Asia/Seoul local day) 활성 일정. active 필터 = events.py:list_today reference.
-- today 경계 = Seoul 자정→UTC 변환(date_trunc ... AT TIME ZONE 왕복). LATERAL 로 1회 계산.
-- v_schedule_defer_pattern: events_history change_kind IN(defer,reschedule) 를 event_id 별 COUNT.
-- '반복 미룸' 임계 3회+ (schedule overlay 판단근거 #5). reactivate 는 제외.
CREATE OR REPLACE VIEW v_schedule_today AS
SELECT e.id, e.user_id, e.title, e.kind, e.status, e.priority,
e.due_at, e.start_at, e.end_at, e.started_at, e.defer_until, e.project_tag
FROM events e
CROSS JOIN LATERAL (
SELECT (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') AS lo
) b
WHERE (e.status IN ('inbox','next','scheduled','in_progress')
OR (e.status = 'deferred' AND e.defer_until IS NOT NULL AND e.defer_until <= now()))
AND (
(e.kind = 'task' AND e.due_at >= b.lo AND e.due_at < b.lo + interval '1 day')
OR (e.kind = 'calendar_event' AND e.start_at >= b.lo AND e.start_at < b.lo + interval '1 day')
OR (e.kind = 'activity_log' AND e.started_at >= b.lo AND e.started_at < b.lo + interval '1 day')
);
CREATE OR REPLACE VIEW v_schedule_defer_pattern AS
SELECT eh.event_id,
COUNT(*)::int AS defer_reschedule_count,
MAX(eh.changed_at) AS last_changed_at,
(COUNT(*) >= 3) AS is_repeat_defer
FROM events_history eh
WHERE eh.change_kind IN ('defer','reschedule')
GROUP BY eh.event_id;
@@ -0,0 +1,3 @@
-- 306_eid_review_set_draft_no_update.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_review_set_draft_no_update AS ON UPDATE TO eid_review_set_draft DO INSTEAD NOTHING;
@@ -0,0 +1,3 @@
-- 307_eid_review_set_draft_no_delete.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_review_set_draft_no_delete AS ON DELETE TO eid_review_set_draft DO INSTEAD NOTHING;
@@ -0,0 +1,3 @@
-- 308_eid_review_set_draft_idx.sql — 302_eid_review_set_draft.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE INDEX IF NOT EXISTS idx_eid_review_draft_user ON eid_review_set_draft (user_id, created_at DESC);
+15
View File
@@ -0,0 +1,15 @@
-- 309_eid_weekly_recap_table.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE TABLE IF NOT EXISTS eid_weekly_recap (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
recap JSONB NOT NULL, -- {activity_summary, open_action_items:[event_id], recurring_topics}
trend_label VARCHAR(20),
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 'active' | 'disputed'
supersedes_id BIGINT REFERENCES eid_weekly_recap(id) ON DELETE SET NULL,
actor VARCHAR(20) NOT NULL, -- 스탬프 = 'eid'
source_generated_at TIMESTAMPTZ NOT NULL, -- 스탬프
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -0,0 +1,3 @@
-- 310_eid_weekly_recap_no_update.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_weekly_recap_no_update AS ON UPDATE TO eid_weekly_recap DO INSTEAD NOTHING;
@@ -0,0 +1,3 @@
-- 311_eid_weekly_recap_no_delete.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE RULE eid_weekly_recap_no_delete AS ON DELETE TO eid_weekly_recap DO INSTEAD NOTHING;
+4
View File
@@ -0,0 +1,4 @@
-- 312_eid_weekly_recap_idx.sql — 303_eid_weekly_recap.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE INDEX IF NOT EXISTS idx_eid_recap_user_current
ON eid_weekly_recap (user_id, created_at DESC) WHERE status = 'active';
@@ -0,0 +1,14 @@
-- 313_approval_requests_table.sql — 304_approval_requests.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE TABLE IF NOT EXISTS approval_requests (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
request_type VARCHAR(40) NOT NULL, -- 고정 템플릿 슬롯 타입(app 화이트리스트)
payload JSONB NOT NULL, -- 고정 템플릿 슬롯만
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | approved | rejected (전이 허용)
requester VARCHAR(20) NOT NULL, -- 'eid'
decided_by VARCHAR(40),
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+3
View File
@@ -0,0 +1,3 @@
-- 314_approval_requests_idx.sql — 304_approval_requests.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests (status, created_at);
@@ -0,0 +1,16 @@
-- 315_eid_schedule_views_v_schedule_today.sql — 305_eid_schedule_views.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE VIEW v_schedule_today AS
SELECT e.id, e.user_id, e.title, e.kind, e.status, e.priority,
e.due_at, e.start_at, e.end_at, e.started_at, e.defer_until, e.project_tag
FROM events e
CROSS JOIN LATERAL (
SELECT (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') AS lo
) b
WHERE (e.status IN ('inbox','next','scheduled','in_progress')
OR (e.status = 'deferred' AND e.defer_until IS NOT NULL AND e.defer_until <= now()))
AND (
(e.kind = 'task' AND e.due_at >= b.lo AND e.due_at < b.lo + interval '1 day')
OR (e.kind = 'calendar_event' AND e.start_at >= b.lo AND e.start_at < b.lo + interval '1 day')
OR (e.kind = 'activity_log' AND e.started_at >= b.lo AND e.started_at < b.lo + interval '1 day')
);
@@ -0,0 +1,10 @@
-- 316_eid_schedule_views_v_schedule_defer_pattern.sql — 305_eid_schedule_views.sql 분리본 (single-statement). asyncpg 러너가 prepared statement 로 처리해 multi-statement 거부 → 1 stmt/파일. 내용 불변.
CREATE OR REPLACE VIEW v_schedule_defer_pattern AS
SELECT eh.event_id,
COUNT(*)::int AS defer_reschedule_count,
MAX(eh.changed_at) AS last_changed_at,
(COUNT(*) >= 3) AS is_repeat_defer
FROM events_history eh
WHERE eh.change_kind IN ('defer','reschedule')
GROUP BY eh.event_id;