From 2df7b24ac9af242bcbbd514fcb0d59fcfe4cf0ce Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 27 Apr 2026 08:18:40 +0900 Subject: [PATCH] fix(study): split migration 164 into 10 single-statement files (asyncpg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asyncpg prepared statement 는 single-command 만 허용. 원래 한 파일이던 study_sessions 스키마(CREATE TABLE x2 + CREATE INDEX x8)를 143~146 분할 패턴 따라 10개로 분리. 164: CREATE TABLE study_sessions 165~169: study_sessions 인덱스 5개 (partial) 170: CREATE TABLE study_session_assets 171~173: study_session_assets 인덱스 3개 문제: cannot insert multiple commands into a prepared statement 원인: _run_migrations 가 conn.exec_driver_sql 로 단일 prepared statement 실행 --- migrations/164_study_sessions.sql | 136 ++++-------------- .../165_study_sessions_idx_type_user.sql | 5 + migrations/166_study_sessions_idx_cert.sql | 6 + migrations/167_study_sessions_idx_lang.sql | 6 + migrations/168_study_sessions_idx_review.sql | 6 + migrations/169_study_sessions_idx_quiz.sql | 6 + migrations/170_study_session_assets.sql | 20 +++ .../171_study_session_assets_idx_session.sql | 5 + .../172_study_session_assets_idx_document.sql | 5 + .../173_study_session_assets_idx_type.sql | 5 + 10 files changed, 90 insertions(+), 110 deletions(-) create mode 100644 migrations/165_study_sessions_idx_type_user.sql create mode 100644 migrations/166_study_sessions_idx_cert.sql create mode 100644 migrations/167_study_sessions_idx_lang.sql create mode 100644 migrations/168_study_sessions_idx_review.sql create mode 100644 migrations/169_study_sessions_idx_quiz.sql create mode 100644 migrations/170_study_session_assets.sql create mode 100644 migrations/171_study_session_assets_idx_session.sql create mode 100644 migrations/172_study_session_assets_idx_document.sql create mode 100644 migrations/173_study_session_assets_idx_type.sql diff --git a/migrations/164_study_sessions.sql b/migrations/164_study_sessions.sql index 6e5a8a0..98acde8 100644 --- a/migrations/164_study_sessions.sql +++ b/migrations/164_study_sessions.sql @@ -1,137 +1,53 @@ -- 164_study_sessions.sql --- iPad 손글씨 학습 세션 + 모바일 암기노트/퀴즈 — Phase 1 MVP +-- iPad 손글씨 학습 세션 + 모바일 암기노트/퀴즈 — Phase 1 MVP (1/10) -- plan: ~/.claude/plans/scalable-chasing-stonebraker.md -- --- 목적: 자격증(산업안전기사 등) + 어학(일본어/한자 우선) 두 도메인을 모두 받는 일반 학습 세션. --- iPad write 모드(필사) / 모바일 review 모드(암기노트) / quiz 모드(SRS) 가 같은 데이터를 공유. +-- asyncpg prepared statement 는 single-command 만 허용. +-- 원래 한 파일이던 학습 세션 스키마를 10개로 분리: +-- 164: CREATE TABLE study_sessions +-- 165~169: study_sessions 인덱스 5개 +-- 170: CREATE TABLE study_session_assets +-- 171~173: study_session_assets 인덱스 3개 +-- +-- 자격증(certification) + 어학(language) 두 도메인을 모두 받는 일반 학습 세션. +-- iPad write / 모바일 review / 모바일 quiz 가 같은 데이터를 공유. -- -- 핵심 원칙: --- - study_sessions: 학습 메타 + stroke JSON 원본 + 도메인별 자유 metadata --- - study_session_assets: documents 의 스캔/필기PNG/오디오/영상/자막을 연결 (단일 *_id 컬럼 금지) --- - documents 원본은 절대 삭제하지 않음 (assets 만 cascade) --- - Phase 1 미사용 필드 (review_state / quiz / ocr / ai_summary / prompt) 는 NULL 허용, 자동 로직 X --- --- 인덱스 전략: --- - 자격증/어학 카드 그룹: partial index (study_type 별) --- - SRS 복습 큐: review_state IS NOT NULL 만 --- - assets join: study_session_id, document_id, asset_type 별 +-- - study_type 으로 도메인 분기. metadata jsonb 가 도메인별 자유 메타. +-- - 단일 *_document_id 컬럼 금지. 모든 미디어 연결은 study_session_assets. +-- - documents 본체는 절대 삭제하지 않음 (assets 만 cascade). +-- - Phase 1 미사용 필드 (review_state / quiz / ocr / ai_summary / prompt) 는 NULL 허용, +-- 자동 로직은 Phase 2~4 별도 PR 에서 활성. CREATE TABLE IF NOT EXISTS study_sessions ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- 도메인 분기 study_type VARCHAR(30) NOT NULL DEFAULT 'certification', - -- 'certification' | 'language' (Phase 2+ 'general' 등 확장 여지) - - -- 자격증 메타 - certification VARCHAR(120), -- 예: "산업안전기사" (study_type='certification' 시) - -- 어학 메타 - language_code VARCHAR(20), -- 'ja' | 'en' | 'zh' (study_type='language' 시) - -- 공통: 학습 레벨 (도메인 무관) - learning_level VARCHAR(80), -- 예: "JLPT N3", "TOEIC 750", "산업안전기사 1차" - - -- 공통: 과목/주제 - subject VARCHAR(120), -- 예: "산업안전보건법" / "漢字" - topic VARCHAR(200), -- 예: "안전보건관리책임자의 직무" / "安全" - - -- 원문 텍스트 snapshot (assets 의 source_scan 과 별개로 발췌 텍스트만 보존) - source_text TEXT, -- 예: "安全" / 법령 본문 발췌 + certification VARCHAR(120), + language_code VARCHAR(20), + learning_level VARCHAR(80), + subject VARCHAR(120), + topic VARCHAR(200), + source_text TEXT, source_page INTEGER, - - -- 학습 모드 mode VARCHAR(30) NOT NULL DEFAULT 'copy', - -- 공통: 'copy'(필사)/'trace'(트레이싱)/'blank-repeat'(깜지) - -- 어학: 'dictation'(받아쓰기)/'shadowing'(쉐도잉) - -- Phase 2+: 'quiz'/'flashcard' - prompt_question TEXT, -- Phase 2: AI 역질문 - expected_answer TEXT, -- Phase 2: 기대 정답 - - -- 도메인별 자유 메타 - -- 어학 예: {"reading":"あんぜん","meaning":"안전","romaji":"anzen","furigana":"...", - -- "example_sentence":"安全第一です。","grammar_point":"...","unit_type":"kanji"} - -- unit_type: 'kanji' | 'vocabulary' | 'sentence' | 'listening' | 'shadowing' - -- 자격증 예: {"law_article":"산업안전보건법 제15조","formula":"...","keywords":[...]} + prompt_question TEXT, + expected_answer TEXT, metadata JSONB, - - -- 횟수 카운트 (보조) target_count INTEGER, repetition_count INTEGER NOT NULL DEFAULT 0, - - -- 필기 데이터 (원본) — Phase 1 핵심 - strokes_json JSONB, -- perfect-freehand input points + style + strokes_json JSONB, canvas_width INTEGER, canvas_height INTEGER, schema_version INTEGER NOT NULL DEFAULT 1, - - -- 필기 파생 텍스트 — Phase 2 채움 (Phase 1 NULL) ocr_text TEXT, user_corrected_text TEXT, - ai_summary TEXT, -- 모바일 카드 view 용 (classify worker 동기화) - - -- SRS / 퀴즈 통계 — Phase 4 활성, Phase 1 NULL - review_state VARCHAR(20), -- 'new' | 'learning' | 'weak' | 'mastered' + ai_summary TEXT, + review_state VARCHAR(20), next_review_at TIMESTAMPTZ, last_quiz_at TIMESTAMPTZ, correct_count INTEGER NOT NULL DEFAULT 0, incorrect_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - --- 도메인+사용자별 최근 세션 조회 (자격증/어학 공통 목록) -CREATE INDEX IF NOT EXISTS idx_study_sessions_type_user_created - ON study_sessions (user_id, study_type, created_at DESC); - --- 자격증 카드 그룹 (Phase 3) -CREATE INDEX IF NOT EXISTS idx_study_sessions_cert - ON study_sessions (user_id, certification, subject, topic) - WHERE study_type = 'certification'; - --- 어학 카드 그룹 (Phase 3) -CREATE INDEX IF NOT EXISTS idx_study_sessions_lang - ON study_sessions (user_id, language_code, learning_level, subject, topic) - WHERE study_type = 'language'; - --- SRS 복습 큐 (Phase 4) — review_state 가 있는 행만 -CREATE INDEX IF NOT EXISTS idx_study_sessions_review - ON study_sessions (user_id, review_state, next_review_at) - WHERE review_state IS NOT NULL; - --- 퀴즈 통계 (Phase 4) — last_quiz_at 가 있는 행만 -CREATE INDEX IF NOT EXISTS idx_study_sessions_quiz_stats - ON study_sessions (user_id, study_type, last_quiz_at) - WHERE last_quiz_at IS NOT NULL; - - -CREATE TABLE IF NOT EXISTS study_session_assets ( - id BIGSERIAL PRIMARY KEY, - study_session_id BIGINT NOT NULL REFERENCES study_sessions(id) ON DELETE CASCADE, - document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - - -- asset_type: 'source_scan' | 'handwriting_png' | 'audio' | 'video' | 'transcript' | 'reference' - asset_type VARCHAR(30) NOT NULL, - -- role: 'prompt' | 'answer' | 'pronunciation' | 'lecture' | 'listening_source' - -- | 'shadowing_source' | 'reference' - role VARCHAR(40), - sort_order INTEGER NOT NULL DEFAULT 0, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- 같은 (세션, 문서, asset_type, role) 조합 중복 금지 → POST /assets 의 409 근거 - -- role 이 NULL 인 경우 NULL 끼리는 unique 비교 시 다른 값으로 취급 (Postgres 기본). 의도된 동작. - UNIQUE (study_session_id, document_id, asset_type, role) -); - --- 세션별 assets 정렬 조회 -CREATE INDEX IF NOT EXISTS idx_session_assets_session - ON study_session_assets (study_session_id, sort_order); - --- 문서가 어느 세션에 연결됐는지 역참조 (filter ?document_id=...) -CREATE INDEX IF NOT EXISTS idx_session_assets_document - ON study_session_assets (document_id); - --- 특정 asset_type 보유 세션 조회 (filter ?asset_type=audio) -CREATE INDEX IF NOT EXISTS idx_session_assets_type - ON study_session_assets (study_session_id, asset_type); diff --git a/migrations/165_study_sessions_idx_type_user.sql b/migrations/165_study_sessions_idx_type_user.sql new file mode 100644 index 0000000..728875d --- /dev/null +++ b/migrations/165_study_sessions_idx_type_user.sql @@ -0,0 +1,5 @@ +-- 165_study_sessions_idx_type_user.sql (2/10) +-- 도메인+사용자별 최근 세션 목록 조회 (자격증/어학 공통) + +CREATE INDEX IF NOT EXISTS idx_study_sessions_type_user_created + ON study_sessions (user_id, study_type, created_at DESC); diff --git a/migrations/166_study_sessions_idx_cert.sql b/migrations/166_study_sessions_idx_cert.sql new file mode 100644 index 0000000..37350d7 --- /dev/null +++ b/migrations/166_study_sessions_idx_cert.sql @@ -0,0 +1,6 @@ +-- 166_study_sessions_idx_cert.sql (3/10) +-- 자격증 카드 그룹 (Phase 3 모바일 암기노트) + +CREATE INDEX IF NOT EXISTS idx_study_sessions_cert + ON study_sessions (user_id, certification, subject, topic) + WHERE study_type = 'certification'; diff --git a/migrations/167_study_sessions_idx_lang.sql b/migrations/167_study_sessions_idx_lang.sql new file mode 100644 index 0000000..89e2249 --- /dev/null +++ b/migrations/167_study_sessions_idx_lang.sql @@ -0,0 +1,6 @@ +-- 167_study_sessions_idx_lang.sql (4/10) +-- 어학 카드 그룹 (Phase 3 모바일 암기노트) + +CREATE INDEX IF NOT EXISTS idx_study_sessions_lang + ON study_sessions (user_id, language_code, learning_level, subject, topic) + WHERE study_type = 'language'; diff --git a/migrations/168_study_sessions_idx_review.sql b/migrations/168_study_sessions_idx_review.sql new file mode 100644 index 0000000..2cc15f5 --- /dev/null +++ b/migrations/168_study_sessions_idx_review.sql @@ -0,0 +1,6 @@ +-- 168_study_sessions_idx_review.sql (5/10) +-- SRS 복습 큐 (Phase 4) — review_state 가 있는 행만 + +CREATE INDEX IF NOT EXISTS idx_study_sessions_review + ON study_sessions (user_id, review_state, next_review_at) + WHERE review_state IS NOT NULL; diff --git a/migrations/169_study_sessions_idx_quiz.sql b/migrations/169_study_sessions_idx_quiz.sql new file mode 100644 index 0000000..4919ea6 --- /dev/null +++ b/migrations/169_study_sessions_idx_quiz.sql @@ -0,0 +1,6 @@ +-- 169_study_sessions_idx_quiz.sql (6/10) +-- 퀴즈 통계 (Phase 4) — last_quiz_at 가 있는 행만 + +CREATE INDEX IF NOT EXISTS idx_study_sessions_quiz_stats + ON study_sessions (user_id, study_type, last_quiz_at) + WHERE last_quiz_at IS NOT NULL; diff --git a/migrations/170_study_session_assets.sql b/migrations/170_study_session_assets.sql new file mode 100644 index 0000000..4055f52 --- /dev/null +++ b/migrations/170_study_session_assets.sql @@ -0,0 +1,20 @@ +-- 170_study_session_assets.sql (7/10) +-- documents 의 스캔 교재 / 필기 PNG / 오디오 / 영상 / 자막을 study_sessions 에 연결하는 테이블. +-- +-- 단일 *_document_id 컬럼 금지 — 한 세션에 여러 오디오/영상/스캔/필기 PNG 가능. +-- documents 원본은 assets 와 별도 자원 — 본 테이블 cascade 는 study_sessions / documents 둘 중 +-- 어느 쪽이 사라지면 연결 행 정리 (orphan 방지). +-- +-- UNIQUE (study_session_id, document_id, asset_type, role): POST /assets 의 409 근거. +-- role 이 NULL 인 경우 NULL 끼리는 Postgres 기본대로 다른 값으로 취급 (의도된 동작). + +CREATE TABLE IF NOT EXISTS study_session_assets ( + id BIGSERIAL PRIMARY KEY, + study_session_id BIGINT NOT NULL REFERENCES study_sessions(id) ON DELETE CASCADE, + document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + asset_type VARCHAR(30) NOT NULL, + role VARCHAR(40), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (study_session_id, document_id, asset_type, role) +); diff --git a/migrations/171_study_session_assets_idx_session.sql b/migrations/171_study_session_assets_idx_session.sql new file mode 100644 index 0000000..04f6ba6 --- /dev/null +++ b/migrations/171_study_session_assets_idx_session.sql @@ -0,0 +1,5 @@ +-- 171_study_session_assets_idx_session.sql (8/10) +-- 세션별 assets 정렬 조회 (UI rendering) + +CREATE INDEX IF NOT EXISTS idx_session_assets_session + ON study_session_assets (study_session_id, sort_order); diff --git a/migrations/172_study_session_assets_idx_document.sql b/migrations/172_study_session_assets_idx_document.sql new file mode 100644 index 0000000..e789605 --- /dev/null +++ b/migrations/172_study_session_assets_idx_document.sql @@ -0,0 +1,5 @@ +-- 172_study_session_assets_idx_document.sql (9/10) +-- 문서가 어느 세션에 연결됐는지 역참조 (filter ?document_id=...) + +CREATE INDEX IF NOT EXISTS idx_session_assets_document + ON study_session_assets (document_id); diff --git a/migrations/173_study_session_assets_idx_type.sql b/migrations/173_study_session_assets_idx_type.sql new file mode 100644 index 0000000..70ca500 --- /dev/null +++ b/migrations/173_study_session_assets_idx_type.sql @@ -0,0 +1,5 @@ +-- 173_study_session_assets_idx_type.sql (10/10) +-- 특정 asset_type 보유 세션 조회 (filter ?asset_type=audio) + +CREATE INDEX IF NOT EXISTS idx_session_assets_type + ON study_session_assets (study_session_id, asset_type);