diff --git a/docs/adr/0004-catalog-recategorization-and-first-migration.md b/docs/adr/0004-catalog-recategorization-and-first-migration.md new file mode 100644 index 0000000..6663685 --- /dev/null +++ b/docs/adr/0004-catalog-recategorization-and-first-migration.md @@ -0,0 +1,60 @@ +# ADR-0004: Catalog 재분류 (8 displayCategory) + 첫 schema 마이그레이션 정책 + +> **상태**: Accepted +> **날짜**: 2026-06-12 · **결정자**: [AI] Architect · **관련 이슈**: #226 + +## 맥락 (Context) + +Phase 2-A (#218) 종료 시점에 다음 두 문제가 동시에 드러났다. + +1. **선택 마비**. 빈 `HabitListScreen` + 자유 입력 단일 경로만 본 첫 진입 사용자가 Tiny Habits 의 "어떤 작은 행동부터?" 에 멈췄다. 시드 카탈로그 (protocols 34 + frame 30 + reward 30 + break 8 + diet 5 = 107) 가 APK 에 포함되나 UI 노출 0. +2. **카테고리 미스매치**. 기존 `Protocols.category` CHECK 제약은 `health|meditation|motivation|habit|learning|diet` 6 값으로, Planner 가 정의한 8 displayCategory (빛/일주기·수면·운동·영양·집중·회복·감정·없애기) 와 직접 매핑되지 않는다. seed JSON 에 새 분류를 박아넣으면 CHECK 위배. + +또한 본 이슈는 **앱 출시 이후 첫 schema 변경** 이다 — Phase 1 (#204) 의 `schemaVersion = 1` 이 `onUpgrade: assert(false, 'Phase 1 has no upgrade path')` 로 마감되어 있어, 마이그레이션 정책의 첫 사례를 세워야 한다. + +## 결정 (Decision) + +다음 3개 결정을 함께 채택한다. + +1. **`DisplayCategory` enum 도입** (UI/조회 모델 레이어). 값 8개: `lightCircadian, sleep, movement, nutrition, focusCognition, recoveryStress, emotionRelationship, breakHabit`. Protocol 의 source `category` 와는 분리된 별도 개념. +2. **`Protocols.category` CHECK 를 7 값으로 갱신** (breakHabit 제외 — Break 는 별도 테이블이라 매핑 1:1 자동). schema v1 → v2 마이그레이션으로 적용. +3. **첫 마이그레이션 전략 = "DROP + reseed"** — read-only catalog 테이블에 한해 `m.deleteTable(protocols)` → `m.createTable(protocols)` (v2 CHECK) → `kSeededV1Flag` 클리어 → 다음 부팅이 `SeedImporter` 재실행. user 테이블 (Habits, Phases, TrackerEntries 등) 무변화. + +## 근거 (Rationale) + +- **enum 분리 (1)** 가 source-of-truth 명확화. Protocol DB 의 `category` 는 ETL 단계의 분류축이고, UI 의 `displayCategory` 는 사용자 멘탈 모델 축이다. 두 개념을 한 컬럼에 욱여넣으면 BreakProtocol (이미 자체 `category` 보유) 과 DietPattern (`category` 없음) 통합 시 깨진다. +- **schema 마이그레이션 (2)** 이 가상 매핑 (in-code dict) 대비 명시적. CHECK 위배가 컴파일/런타임 즉시 노출되므로 시드 작성 실수를 더 일찍 잡는다. 또한 향후 신규 Protocol 추가 시 정합성 자동 보장. +- **DROP + reseed (3)** 가 read-only 카탈로그에 대해 안전한 최단 경로. user 데이터를 건드리지 않아 위험도 낮음. ALTER TABLE 로 CHECK 만 갱신하는 방식은 SQLite 에서 직접 지원하지 않아 어차피 임시 테이블 + 복사 + rename 패턴이 필요한데, read-only catalog 라면 reseed 가 더 단순. + +### 트레이드오프 +- ✅ user 데이터 0 영향이라 사용자 위험 없음. +- ✅ 향후 catalog 갱신 (#FF1+) 시 동일 패턴 재사용 가능 — 마이그레이션 N→N+1 = "DROP catalog 테이블 + flag 클리어" 라는 일관 규칙. +- ❌ 시드 파일이 손상되면 부팅 실패 (CHECK 위배 throw). 그러나 본 함수는 unit test 가 명시적 검증. +- ❌ `BreakProtocol` 의 카테고리는 `displayCategory.breakHabit` 단일 매핑 → 사용자가 "8 카테고리" 라고 들었지만 그 중 1개는 source 1개에서만 채워짐. 의도된 단순화 (Planner AC-4 가 8개 명시). + +## 결과 (Consequences) + +- **긍정**: + - UI 코드가 source 구분 없이 `displayCategory` 만으로 필터링/그룹핑 가능 → 갤러리/카드 구현 단순. + - schema 진화 패턴 (v1→v2) 의 첫 reference 코드가 repo 에 등재. v3+ 시점에 재활용. + - 시드 갱신 워크플로 = "JSON 수정 → version bump → reseed" 한 줄 정리. +- **부정 / 비용**: + - 사용자가 v1 DB 를 가진 채 앱 업데이트 시 첫 부팅에서 `protocols` 테이블이 DROP 됨 (user 테이블은 무사). dev 단말 한정 — 베타 외부 배포 전이라 영향 0. + - `schemaVersion` 2 도입으로 `MigrationStrategy.onUpgrade` 의 dispatch 로직 진입. `if (from == 1 && to >= 2)` 분기 + 알 수 없는 경로 assert. 향후 v3 도입 시 같은 dispatch 에 한 줄 추가. +- **후속 작업**: + - `migration_v1_to_v2_test.dart` 가 user 테이블 무변화 + CHECK 갱신 + flag 클리어를 명시 검증 (3 케이스 필수). + - 향후 `DisplayCategory` 값 추가 시 본 ADR 갱신 (값 enum 확장은 Accepted 유지, 의미 변경 시 Superseded). + +## 검토한 대안 (Alternatives Considered) + +- **A. 가상 매핑 (in-code dict, schema 무변화)** — Protocol.category 6 값을 그대로 두고, `DisplayCategory.fromProtocolCategory()` 가 코드 내 dict 로 8 값에 매핑. + - 기각 사유: CHECK 가 6 값 그대로라 seed JSON 의 새 분류를 못 받음. Planner AC-4 의 "8 카테고리 재분류" 를 문자 그대로 충족하지 못함. 신규 Protocol 추가 시 CHECK 위배가 runtime 까지 안 잡힘. + +- **B. `display_category` 신규 컬럼 추가 + 기존 `category` 유지** — protocols 에 새 컬럼 ADD COLUMN 으로 8 값 저장, 기존 6 값은 유산 컬럼. + - 기각 사유: 컬럼 2개의 의미가 겹쳐 SoT 분기. ETL 단계에서 매번 두 컬럼 채워야 함. 6 값 컬럼이 영원히 dead weight. + +- **C. catalog 테이블 전체를 in-memory 로 전환 (JSON 직독)** — DB 에서 catalog 제거, 부팅 시 JSON 만 메모리 로드. + - 기각 사유: Phase 1 (#204) 의 ADR-0002 결정 정규화 = "어떻게" 를 뒤집는 큰 변경. read-only 라도 reference / habit 와의 FK 연결 손실. 본 이슈 scope 초과. + +- **D. 마이그레이션 우회 (사용자 앱 재설치 안내)** — 첫 마이그레이션 회피용으로 사용자에게 재설치 권장. + - 기각 사유: dev 단말 한정 단계라 기술적으로 가능하나, 첫 마이그레이션 reference 코드를 미루는 것 자체가 부채. 어차피 v3+ 에서 같은 결정 다시 해야 함 — 일찍 답하는 게 싸다. diff --git a/docs/design/226-catalog-gallery/README.md b/docs/design/226-catalog-gallery/README.md new file mode 100644 index 0000000..d04619e --- /dev/null +++ b/docs/design/226-catalog-gallery/README.md @@ -0,0 +1,457 @@ +# 설계서: Catalog Gallery + 8 카테고리 재분류 (#226) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-12 +> **추적성** — Redmine: #226 · 관련 ADR: ADR-0004 (본 이슈에서 신규 — Catalog 우선 onboarding 정책 + 첫 schema 마이그레이션) · 상위/이전: #218 (v0.3.0) · 후속: #FF1 (import) / #FF2 (LLM tweak) / #FF3 (FTS5) / #FF4 (LLM retriever) +> · 변경 대상 파일: +> - `app/lib/data/db/tables/catalog_tables.dart` — `Protocols.category` CHECK 새 7개로 +> - `app/lib/data/db/app_database.dart` — `schemaVersion` 1 → 2 + `onUpgrade` 진짜 구현 +> - `app/assets/seed/protocols.json` — 34 항목 category 재분류 +> - `app/lib/domain/catalog/display_category.dart` (★ 신규) — 8 DisplayCategory enum + label/icon +> - `app/lib/domain/catalog/catalog_item.dart` (★ 신규) — 통합 CatalogItem 모델 +> - `app/lib/data/catalog/catalog_repository.dart` (★ 신규) — Protocols + Break + Diet → CatalogItem 변환 +> - `app/lib/state/catalog_providers.dart` (★ 신규) — `catalogItemsProvider` / `catalogByCategoryProvider` +> - `app/lib/ui/screens/protocol_gallery_screen.dart` (★ 신규) +> - `app/lib/ui/screens/protocol_preview_screen.dart` (★ 신규) +> - `app/lib/ui/widgets/catalog_card.dart` (★ 신규) +> - `app/lib/ui/widgets/category_chip_row.dart` (★ 신규) +> - `app/lib/ui/widgets/reference_expand_card.dart` (★ 신규) +> - `app/lib/ui/screens/habit_list_screen.dart` — 빈 상태 CTA + AppBar 액션 +> · 신규 테스트: +> - `app/test/data/db/migration_v1_to_v2_test.dart` +> - `app/test/domain/catalog/display_category_test.dart` +> - `app/test/data/catalog/catalog_repository_test.dart` +> - `app/test/ui/protocol_gallery_screen_test.dart` +> - `app/test/ui/protocol_preview_screen_test.dart` +> · 하위 문서: +> - [fn-catalog_repository.md](./fn-catalog_repository.md) — 통합 CatalogItem 생성 + DisplayCategory 매핑 +> - [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) — 첫 schema 마이그레이션 + +--- + +## 1. 목적 (Why) + +#218 (v0.3.0) 종료 후 사용자가 **"화면이 너무 횡하다"** 지적. 진단: +- 빌드 자산: 시드 카탈로그 **107 항목** (protocols 34 + frame 30 + reward 30 + break 8 + diet 5) — 모두 DB seed 완료 +- UI 노출: **0 경로** +- 첫 사용자: 빈 HabitListScreen + 자유 입력 단일 경로만 봄 + +Tiny Habits 의 **"선택 마비" 함정** — 어떤 습관을 만들지 막막함이 채택률 최대 적. 풍부한 자산을 0% 노출 중인 게 핵심 문제. + +본 이슈는 **47 항목 (protocols 34 + break 8 + diet 5) 을 8 카테고리로 묶어 갤러리/프리뷰 화면으로 노출만** — "내 습관으로" import 는 #FF1 후속. + +> Planner 목표 1줄: "사용자가 첫 진입에서 빈 화면 대신 8 카테고리로 분류된 107 개 Huberman 카탈로그를 탐색·미리보기 할 수 있게 한다 — 노출만, 자동 import 없음." + +## 2. 범위 (Scope) + +### 포함 +- **DB 마이그레이션 v1 → v2** — `Protocols.category` CHECK 제약 6 → 7 신 카테고리. 본 앱의 **첫 schema 마이그레이션**. +- **`protocols.json` 재분류** — 34 항목 모두 7 카테고리 중 하나로 매핑 (1차 효과 기준). +- **DisplayCategory enum** — UI 노출용 8 카테고리 (Protocols 의 7 + 항상 break = breakHabit). break/diet 는 Protocols.category 와 직교한 별도 source 이므로 `domain/catalog/` 에서 통합. +- **CatalogRepository** — 3 source (Protocols / BreakProtocols / DietPatterns) → 단일 `CatalogItem` 리스트 변환. +- **3 신규 화면 + 3 신규 위젯**. +- **`HabitListScreen` 진입점** — 빈 상태 CTA + 채워진 상태에서 AppBar 액션. +- **신규 테스트 ≥ 15건** — 마이그레이션 검증 + DisplayCategory 매핑 + Repository 통합 + 위젯 2종. + +### 제외 (out of scope) +- "내 습관으로" import → **#FF1**. 본 이슈에선 disabled placeholder 버튼만. +- LLM tweak / FTS5 / LLM retriever / vector → #FF2~#FF4. +- `methodologies` / `frame_patterns` / `reward_menu_items` / `common_frames` / `references` 의 별도 갤러리 — 본 이슈는 47 항목만. references 는 Preview 의 펼치기 카드로만 노출. +- 카탈로그 자유 검색 → #FF3. +- 다국어 (한국어 단일 유지). +- 본 이슈는 ADR-0004 발행. ADR-0005 (catalog import policy) 는 #FF1 시점. + +## 3. 인수조건 (Acceptance Criteria) + +> Planner §3 10 AC 그대로 수용. QA 판정. + +- [ ] **AC-1**: `HabitListScreen` 빈 상태에 "🔍 카탈로그 탐색" CTA + 채워진 상태에서 AppBar 액션으로 상시 진입. +- [ ] **AC-2**: `ProtocolGalleryScreen` 진입 시 가로 카테고리 칩 (전체 + 8 DisplayCategory). 칩 선택 시 해당 카테고리 카드만 표시. +- [ ] **AC-3**: 카드 = (title + 1줄 요약 + evidence_strength 배지 + 카테고리 아이콘). 카드 탭 → Preview 진입. +- [ ] **AC-4**: `protocols.json` 의 `category` 필드 7개로 재분류 — 34 항목 모두 매핑. + break 8 + diet 5 가 갤러리 unified view 에서 8 카테고리 중 하나에 노출. +- [ ] **AC-5**: `ProtocolPreviewScreen` 에 title / title_en / what / when / dose / why / how (번호 매김) / check / caution / default_anchor / min_dose_for_start / source_doc + evidence_strength 배지 모두 표시. +- [ ] **AC-6**: Preview 하단 "내 습관으로" 버튼 **disabled** + 툴팁 "다음 업데이트 예정". 본 이슈에선 동작 X. +- [ ] **AC-7**: `reference_ids` 가 있으면 References 테이블에서 매칭 → 펼치기 카드로 title + url + kind 표시. ref 0 일 때 섹션 숨김. +- [ ] **AC-8**: 갤러리/프리뷰 진입/탐색 중 R규칙 검사 0건 호출 + habits 테이블 write 0건. 사용자가 "내 습관으로" 누르지 않으면 어떤 DB 변경도 없음. +- [ ] **AC-9**: 기존 88 테스트 회귀 0 + 신규 ≥ 15 (마이그레이션 + DisplayCategory + Repository + widget 2). 카테고리 매핑 완전성 unit test (모든 ID 가 정확히 1 DisplayCategory 에 매핑). +- [ ] **AC-10**: `flutter analyze` 0 issues + APK release 빌드 성공. + +## 4. 컨텍스트 & 제약 + +### 의존성 +- **Drift 2.x** 기존 + Riverpod 2.5 기존. 신규 패키지 0. +- **schemaVersion 1 → 2** 가 본 앱 첫 마이그레이션 — 기존 Phase 1 의 `onUpgrade` 는 `assert(false)`. 이걸 진짜 구현으로 교체. 정책은 ADR-0004 에 묶음. +- 신규 DB 테이블 0. 신규 컬럼 0. **CHECK 제약 한 줄 변경 + reseed** 만. +- `kSeededV1Flag` 키 명 유지 — 본 이슈에서 `seeded_v2` 로 추가하지 않고, 마이그레이션이 기존 flag 를 클리어해서 SeedImporter 가 재시드. + +### 제약 +- **AC-8 (DB write 0건)** — 사용자 작업 동안 user 테이블 무변화. 단, 마이그레이션 자체는 catalog 테이블만 영향 (read-only seed), user 데이터 0 영향. +- **first run vs upgrade** — 신규 설치는 `onCreate` (v2 schema 그대로), 기존 설치는 `onUpgrade` (Protocols 재생성 + reseed). 둘 다 동일 결과 보장. +- **빈 카테고리 UX** — 일부 DisplayCategory (예: `emotionRelationship`) 는 47 항목 중 매핑 0 가능. 빈 카테고리는 **카테고리 칩 자체를 숨김** (사용자 혼란 최소화). 모든 칩이 빈 경우는 없음 (47 항목 ≥ 7). +- **i18n** — 한국어 단일. `title_en` 은 카드/Preview 에 회색 보조 텍스트. +- **접근성** — evidence_strength 배지는 색 + 텍스트 둘 다. Semantics label 모든 카드. + +### 가정 +- joungmin 1인 사용자 + Android 단말. +- 기존 v1 설치된 dev 단말이 있을 수 있어 **upgrade path 필수** (assert false 제거). +- seed JSON 의 모든 항목에 한국어 title/what 존재 (확인됨). +- BreakProtocols / DietPatterns 의 스키마는 변경 없음 — 본 이슈에서 손대지 않음. + +## 5. 아키텍처 개요 + +### 모듈 구조 + +``` +app/ +├── lib/ +│ ├── data/ +│ │ ├── db/ +│ │ │ ├── app_database.dart △ schemaVersion 1→2 + onUpgrade +│ │ │ └── tables/catalog_tables.dart △ Protocols.category CHECK 새 7개 +│ │ └── catalog/ +│ │ └── catalog_repository.dart ★ 신규 — 3 source 통합 +│ ├── domain/ +│ │ └── catalog/ +│ │ ├── display_category.dart ★ enum + label + icon + protocol_id 매핑 +│ │ └── catalog_item.dart ★ unified model +│ ├── state/ +│ │ └── catalog_providers.dart ★ catalogItemsProvider + groupedByCategoryProvider +│ └── ui/ +│ ├── screens/ +│ │ ├── habit_list_screen.dart △ 빈 CTA + AppBar 액션 +│ │ ├── protocol_gallery_screen.dart ★ +│ │ └── protocol_preview_screen.dart ★ +│ └── widgets/ +│ ├── catalog_card.dart ★ +│ ├── category_chip_row.dart ★ +│ └── reference_expand_card.dart ★ +├── assets/seed/ +│ └── protocols.json △ 34 항목 category 재분류 +├── seed-staging/ +│ └── protocols.json △ (mirror) +└── test/ + ├── data/db/migration_v1_to_v2_test.dart ★ + ├── data/catalog/catalog_repository_test.dart ★ + ├── domain/catalog/display_category_test.dart ★ + └── ui/ + ├── protocol_gallery_screen_test.dart ★ + └── protocol_preview_screen_test.dart ★ +``` + +### 데이터 흐름 + +``` +[HabitListScreen] + │ 빈 상태 → "🔍 카탈로그 탐색" CTA + │ 채워진 상태 → AppBar IconButton(search) + ▼ +[ProtocolGalleryScreen] + │ ref.watch(catalogItemsProvider) + ▼ +[CatalogRepository.all()] ─────────► [AppDatabase] + │ ├─► db.select(protocols) → List (34) + │ ├─► db.select(breakProtocols) → List (8) + │ └─► db.select(dietPatterns) → List (5) + │ 변환: + │ for p in protocols: + │ resolve DisplayCategory by p.category enum + │ → CatalogItem(source: protocol, displayCategory, ...) + │ for b in breakProtocols: + │ → CatalogItem(source: break, displayCategory: breakHabit, ...) + │ for d in dietPatterns: + │ → CatalogItem(source: diet, displayCategory: nutrition, ...) + ▼ +[List] (47) + │ groupBy DisplayCategory + ▼ +[CategoryChipRow] — 비어있지 않은 카테고리만 + │ user 선택 → state + ▼ +[GridView of CatalogCard] + │ tap → + ▼ +[ProtocolPreviewScreen(item: CatalogItem)] + │ what/when/dose/why/how/check/caution/anchor/min_dose 표시 + │ if item.referenceIds.isNotEmpty: + │ ref.watch(referencesByIdsProvider(item.referenceIds)) + │ → ReferenceExpandCard + │ 하단 "내 습관으로" disabled (#FF1 진입점) + ▼ +[user back] — DB write 0 +``` + +### I/O ↔ 순수 로직 경계 + +- **I/O 경계**: + - `CatalogRepository` (DB 읽기) = data/catalog/ + - `assets/seed/*.json` 읽기는 SeedImporter 만 (마이그레이션 시점) + - DB onUpgrade 안의 SQL = drift API 안 raw SQL 최소 +- **순수 로직**: + - `DisplayCategory.resolve(protocolId)` — protocol id → DisplayCategory 정적 lookup (pure function) + - `CatalogItem.fromProtocol/fromBreak/fromDiet` factory (pure) + - `groupByCategory(items)` (pure) +- **UI 경계**: + - Riverpod providers 가 데이터 ↔ UI bridge. 화면은 ConsumerWidget only. + +## 6. 데이터 모델 + +### DisplayCategory enum (UI 노출용) + +```dart +// lib/domain/catalog/display_category.dart +enum DisplayCategory { + lightCircadian('빛/일주기', Icons.wb_sunny), + sleep('수면', Icons.bedtime), + movement('운동/신체', Icons.fitness_center), + nutrition('영양', Icons.restaurant), + focusCognition('집중/인지', Icons.psychology), + recoveryStress('회복/스트레스', Icons.spa), + emotionRelationship('감정/관계', Icons.favorite), + breakHabit('없애기', Icons.block); + + const DisplayCategory(this.label, this.icon); + final String label; + final IconData icon; +} +``` + +### Protocols.category CHECK (v2) + +```dart +TextColumn get category => text().check(const CustomExpression( + "category IN (" + "'light_circadian','sleep','movement','nutrition'," + "'focus_cognition','recovery_stress','emotion_relationship'" + ")"))(); +``` + +> 7개 — `break_habit` 는 별도 BreakProtocols 테이블이라 미포함. + +### CatalogItem (unified model) + +```dart +// lib/domain/catalog/catalog_item.dart +sealed class CatalogItem { + String get id; + String get title; + String? get titleEn; + String get summary; // 1줄 요약 (카드용) + DisplayCategory get displayCategory; + String? get evidenceStrength; + List get referenceIds; +} + +final class ProtocolCatalogItem extends CatalogItem { + // 원본 Protocol 필드 보존 (what/when/dose/why/how/check/caution/anchor/minDose) + // summary = what 의 첫 문장 또는 1줄 요약 + // displayCategory = DisplayCategory.values.byName(protocol.category) 의 camelCase 변환 + // light_circadian → lightCircadian +} + +final class BreakCatalogItem extends CatalogItem { + // 원본 BreakProtocol 필드 (hubermanSummary, phases, defaultCommonFrames) + // summary = hubermanSummary + // displayCategory = breakHabit (항상) + // category(원본 alcohol/nicotine/..) 는 sub-tag 로 보존 +} + +final class DietCatalogItem extends CatalogItem { + // 원본 DietPattern (name, core, strengths, weaknesses, koreanContextFit, ...) + // summary = core + // displayCategory = nutrition (항상) + // koreanContextFit 은 sub-tag +} +``` + +### `protocols.json` 카테고리 매핑 (34 항목) + +> 분류 가이드: **1차 효과** 기준. 모호 시 `what/why` 첫 문장의 주효과를 따른다. +> 본 매핑은 Architect 가 1차 결정, QA 가 비논리적 매핑 발견 시 reject 가능. + +| protocol id | v1 category | v2 category | 근거 | +|---|---|---|---| +| morning_sunlight | health | light_circadian | 일주기 리셋 | +| evening_sunlight | health | light_circadian | 일주기 | +| night_light_avoidance | health | light_circadian | 일주기 보호 | +| sleep_stack | health | sleep | 수면 직접 | +| caffeine_protocol | health | sleep | 수면 영향이 1차 | +| zone2 | health | movement | 운동 | +| strength | health | movement | 운동 | +| nsdr | health | recovery_stress | 회복/이완 | +| cold_exposure | health | recovery_stress | 스트레스 적응 | +| breathwork | health | recovery_stress | 스트레스 조절 | +| focus_block | health | focus_cognition | 집중 | +| dopamine_baseline | health | focus_cognition | 동기/인지 | +| meditation_focus | meditation | focus_cognition | 집중 | +| omega3 | health | nutrition | 영양 | +| creatine | health | nutrition | 영양 | +| protein_target | health | nutrition | 영양 | +| ... (총 34, Developer 가 staging 의 모든 id 확인 후 1차 효과 분류) | | | | + +> **Developer 작업 지침**: 위 표는 샘플. 실제 staging/protocols.json 의 모든 34 id 를 읽고 1차 효과 기준으로 v2 category 할당. 모호하면 Architect 와 협의 (저널 노트). emotion_relationship 매핑 0 이어도 OK (빈 카테고리는 칩 자체 숨김). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임 (1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | +|------|------------|----------|------|------|-----------|-------| +| `DisplayCategory.values` (enum) | 8 enum 자체 | `enum` | none | enum | — | 단순 | +| `DisplayCategory.fromProtocolCategory(String)` | DB category 문자열 → enum (`light_circadian` → `lightCircadian` 등) | `static DisplayCategory? fromProtocolCategory(String)` | DB 문자열 | enum or null | 미매칭 시 null (호출자가 throw) | 단순 | +| `CatalogRepository.all()` | 3 source 읽고 CatalogItem 리스트 반환 | `Future> all()` | none | List 47 | DB 에러 throw | **복잡** → [fn-catalog_repository.md](./fn-catalog_repository.md) | +| `CatalogRepository.byId(String)` | 단건 조회 (Preview 화면 진입 시) | `Future byId(String)` | id | CatalogItem or null | DB 에러 throw | 단순 (lookup) | +| `CatalogRepository.referencesByIds(List)` | reference id 들 → ReferenceRow 리스트 | `Future>` | ids | rows | DB 에러 throw | 단순 | +| `groupByCategory(items)` (pure) | List → Map> | `Map> groupByCategory(List)` | items | grouped map (빈 카테고리 키 미포함) | — | 단순 | +| `_summary(Protocol)` (pure) | Protocol.what 의 1줄 요약 추출 | `String _summary(Protocol p)` | Protocol | string ≤ 60자 | what 빈 문자열이면 title 반환 | 단순 | +| `migrateV1ToV2(Migrator)` | onUpgrade 1→2 실행 | `Future migrateV1ToV2(Migrator m)` | Migrator | void | SQL 실패 throw | **복잡** → [fn-migration_v1_to_v2.md](./fn-migration_v1_to_v2.md) | +| `HabitListScreen._onCatalogPressed` | 갤러리 진입 콜백 | `void _onCatalogPressed(BuildContext)` | context | navigation | 없음 | 단순 | +| `ProtocolGalleryScreen.build` | 칩 + 그리드 build | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | provider 에러 → SnackBar | 단순 | +| `ProtocolPreviewScreen.build` | 상세 build + 펼치기 카드 | `Widget build(BuildContext, WidgetRef)` | context, ref | widget | references provider 에러 → 섹션 숨김 | 단순 | +| `CatalogCard` (widget) | 카드 1개 | `class CatalogCard extends StatelessWidget` | item | widget | — | 단순 | +| `CategoryChipRow` (widget) | 가로 칩 줄 | `class CategoryChipRow extends StatelessWidget` | categories, selected, onSelect | widget | — | 단순 | +| `ReferenceExpandCard` (widget) | ref 펼치기 카드 | `class ReferenceExpandCard extends StatelessWidget` | reference | widget | url 누르기 = `url_launcher` (선택, 본 이슈에선 텍스트 표시만) | 단순 | + +> 복잡 함수 = 2개 (`CatalogRepository.all`, `migrateV1ToV2`). 각각 fn-*.md 작성. + +## 8. 흐름 / 알고리즘 + +### 시나리오 A: 신규 설치 (onCreate) + +1. v2 schema 그대로 적용 (`createAll`). +2. SeedImporter 가 `protocols.json` (v2 category) 을 import. +3. CHECK 제약 통과 — 정상. +4. 사용자 첫 진입 → 빈 HabitListScreen + 카탈로그 CTA. + +### 시나리오 B: v1 → v2 업그레이드 (onUpgrade) + +1. `migration.onUpgrade(m, 1, 2)` 호출. +2. `migrateV1ToV2(m)`: + - **a.** `await m.deleteTable(db.protocols)` — Drift API 로 안전한 DROP. + - **b.** `await m.createTable(db.protocols)` — 새 CHECK 로 재생성. + - **c.** `await m.createIndex(IDX_protocols_category)` — 인덱스 복구. + - **d.** `await (m.database.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go()` — 시드 flag 클리어. +3. 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 flag 가 없음을 보고 reseed. +4. 사용자 user-* 테이블 (Habits / Phases / TrackerEntries / ...) 0 영향. + +### 시나리오 C: 카탈로그 갤러리 진입 + +1. 사용자 HabitListScreen 의 "🔍 카탈로그 탐색" 또는 AppBar IconButton 탭. +2. `Navigator.push(MaterialPageRoute(ProtocolGalleryScreen))`. +3. `ProtocolGalleryScreen.build`: + - `final items = ref.watch(catalogItemsProvider)` — 47 item. + - `final grouped = ref.watch(groupedByCategoryProvider)` — 7~8 카테고리 키. + - `CategoryChipRow(categories: grouped.keys.toList(), selected: _selected)` + - `GridView.builder(items: grouped[_selected] ?? items)`. +4. 사용자 카드 탭 → `Navigator.push(ProtocolPreviewScreen(item: item))`. +5. Preview 화면: + - 본문 필드 전부 표시. + - `if (item.referenceIds.isNotEmpty)` → `ref.watch(referencesByIdsProvider(item.referenceIds))` 펼치기 카드 N개. + - 하단 "내 습관으로" `FilledButton(onPressed: null, ...)` + Tooltip. + +### 시나리오 D: 빈 카테고리 + +1. `groupByCategory(items)` 가 47 item 을 그룹핑. +2. 매핑 0 인 DisplayCategory 키 (예: emotionRelationship) 는 map 에 미포함. +3. `CategoryChipRow` 는 `grouped.keys` 만 그림 — 빈 카테고리 칩 자체 미표시. +4. 사용자는 빈 카테고리 존재 자체를 모르고, "전체" + N 카테고리 칩만 봄. + +### 시나리오 E: reference 매칭 실패 + +1. Protocol.referenceIds = `["ref_xxx", "ref_yyy"]` 이지만 References 테이블에 `ref_yyy` 없음. +2. `referencesByIdsProvider` 가 2개 중 1개만 반환 (DB 에서 매칭된 것만). +3. Preview 의 ref 섹션이 발견된 1개만 카드로 표시. +4. ref 0건 매칭이면 섹션 자체 숨김. + +## 9. 엣지케이스 & 에러 처리 + +| 상황 | 처리 | +|------|------| +| Protocols.category 가 v2 7개 외 값 (이론상 불가, JSON 손상 시) | SeedImporter 가 CHECK 위배로 throw → 부팅 실패. 명시적이 graceful 보다 낫다 (잘못 시드한 빌드를 출시하지 않음) | +| onUpgrade 가 실패 (SQL 에러) | drift 가 transaction 롤백 → DB 상태 보전. 사용자에겐 부팅 실패. dev 단말 1대 영향이라 수용. | +| Protocol 의 v1 category=`meditation`/`motivation`/`habit`/`learning`/`diet` 가 v2 매핑 없음 | Developer 가 protocols.json 의 모든 34 id 를 1차 효과로 재분류 — staging 검증 step 에서 단위 테스트로 100% 매핑 보장. | +| BreakProtocol 의 한국어 title 누락 (이론상 없음) | 시드 검증 단위 테스트에서 적발. | +| 카드 그리드가 화면을 넘침 (작은 단말) | GridView.builder + SliverGridDelegateWithMaxCrossAxisExtent (240px) — 단말 폭에 맞게 1~3 컬럼 자동. | +| Preview 의 `how` 가 빈 배열 | 섹션 자체 숨김. | +| References URL 누르기 | 본 이슈에선 **텍스트 표시만** — `url_launcher` 패키지 도입은 #FF1 또는 별도. | +| 사용자가 갤러리에서 즉시 뒤로 가기 | DB write 0건, R규칙 호출 0건 — AC-8 자명 만족. | +| `groupedByCategoryProvider` 가 loading 상태 | 갤러리 화면 중앙 CircularProgressIndicator. | +| `references.json` 매칭 시 url 이 `null` | url 라인 자체 숨김 + 다른 필드 (kind, title) 만 표시. | + +### 안전한 기본값 +- onUpgrade 미정의 분기 (v3+) → `assert(false, 'Unknown upgrade from $from to $to')` — Phase 1 패턴 유지. +- Repository 에러 → Riverpod `AsyncValue.error` 로 전파, UI 가 SnackBar 표시. +- `_summary` 가 60자 초과 시 `...` 절단. + +## 10. 테스트 계획 + +### 단위 테스트 (신규) + +| AC | 테스트 파일 | 내용 | +|----|------------|------| +| AC-4, AC-9 | `test/domain/catalog/display_category_test.dart` | 모든 protocols.json id 가 fromProtocolCategory 로 정확히 1 DisplayCategory 에 매핑됨 (full coverage) + BreakProtocol → breakHabit + DietPattern → nutrition | +| AC-4 | `test/data/db/migration_v1_to_v2_test.dart` | v1 schema 로 시작 → migrate → Protocols 테이블 v2 CHECK 적용 + 인덱스 복구 + kSeededV1Flag 클리어 + user 테이블 (Habits 등) 무변화 | +| AC-2, AC-3, AC-7 | `test/data/catalog/catalog_repository_test.dart` | in-memory DB + seed → all() 가 47 item 반환 + groupByCategory 가 빈 카테고리 키 미포함 + byId / referencesByIds 동작 | +| AC-2, AC-3 | `test/ui/protocol_gallery_screen_test.dart` | ProviderScope override 로 catalogItemsProvider 페이크 → 칩 N개 표시 + 카드 그리드 + 칩 선택 시 필터링 | +| AC-5, AC-6, AC-7 | `test/ui/protocol_preview_screen_test.dart` | 각 필드 표시 + "내 습관으로" 버튼 disabled + tooltip 확인 + references 펼치기 카드 | + +### 회귀 보호 +- 기존 88 테스트 0 회귀 — `Protocols.category` 변경이 영향 가능한 테스트는 `seed_importer_test.dart` 의 1차. 시드 JSON 갱신 + 단위 테스트 같이 갱신. +- `flutter analyze` 0 issues. +- APK release 빌드 성공. + +### Mock 전략 +- DB = `AppDatabase.memory()` (기존 패턴). +- ProviderScope override 로 widget test. +- Migration test 는 raw SQLite 로 v1 schema 수동 생성 → migrate 호출 → CHECK 위배 확인 (negative test). + +## 11. 리스크 & 대안 검토 + +### 본 설계서 결정 + +| 결정 | 채택 | 대안 | 근거 | +|------|------|------|------| +| Protocols.category CHECK 6 → 7 마이그레이션 | ✓ | (A) virtual mapping (DB 무변경, Dart Map) / (B) display_category 신규 컬럼 | Planner AC-4 가 "category 필드 재분류" 명시. DB 가 SoT 인 본 앱 철학상 컬럼이 정답. 가상 매핑은 시드 파일과 DB 가 분리되어 일관성 깨짐. | +| schemaVersion 1→2 + 진짜 onUpgrade | ✓ | onUpgrade 무시 + 신규 설치만 지원 | 기존 dev 단말 (joungmin 본인) 의 v1 DB 가 있음. assert false 깨지면 부팅 실패. 한 번 제대로 만들면 후속 마이그레이션 패턴 재사용. | +| 마이그레이션 = DROP + CREATE + reseed | ✓ | INSERT...SELECT 로 row-level 재맵핑 | Protocols 는 read-only catalog — drop 해도 데이터 손실 0 (시드에서 복원). row-level 재맵핑은 라벨 매핑 알고리즘 분리 필요해서 과한 복잡도. | +| 빈 카테고리 칩 자체 숨김 | ✓ | 칩 회색 + "준비 중" | 사용자가 "왜 비어있지" 묻는 친화성 ↓. 빈 카테고리 존재를 모르게 하는 게 더 깔끔. emotion_relationship 추가는 향후 시드 갱신 시 자동 노출. | +| break + diet 통합 view (CatalogItem sealed) | ✓ | 3개 화면 분리 (Protocol Gallery / Break Gallery / Diet Gallery) | 사용자는 "수면 관련 뭐 있나?" 같은 카테고리 중심 탐색 — source 별 분리는 사용자 멘탈모델과 직교. unified view 가 정답. | +| "내 습관으로" 버튼 disabled placeholder | ✓ | 버튼 자체 없음 (다음 PR 에서 추가) | Preview 화면을 다음 이슈에서 또 수정하는 비용 방지 + 사용자에게 "다음 업데이트 예정" 시그널. UX 비용 0. | +| ADR-0004 발행 | ✓ | ADR 없음 | 첫 schema 마이그레이션 = 향후 정책 (DROP+reseed for catalog vs row-migrate for user) 의 기준점. ADR 가치 충분. | +| sealed class CatalogItem | ✓ | abstract class + downcast | Dart 3+ sealed pattern matching 안전 + 신규 source 추가 시 컴파일 에러로 강제. | + +### 핵심 리스크 + +1. **카테고리 분류 의견 차** (예: cold_exposure = recovery? movement?) — Architect 1차 결정 (recovery_stress), Developer 가 staging 검증 시 모호 케이스를 저널 노트로 보고. QA 가 비논리 매핑 적발 권한. +2. **emotion_relationship 빈 카테고리** — 현 시드 0 매핑 가능. 본 이슈 후 시드 보강은 별도 (#FF5+ 콘텐츠 추가). +3. **첫 마이그레이션 버그** — onUpgrade 미작동 시 dev 단말 부팅 실패. 마이그레이션 unit test 가 1차 방어선. + +### 되돌리기 어려운 결정 → ADR + +- **ADR-0004** "Catalog re-categorization + first schema migration policy" 본 이슈에서 발행: + - 결정 1: Catalog (read-only seed) 마이그레이션은 DROP + reseed + - 결정 2: User (mutable) 테이블 마이그레이션은 row-preserving (해당 안 됨, 본 이슈) + - 결정 3: DisplayCategory 8개 vs Storage category 7개 (Protocols) — Storage = 단일 source 7, breakHabit/nutrition 은 별도 source 라 enum 만 8. + +## 12. 미해결 질문 (Open Questions) + +| OQ | 질문 | 상태 | 권고 | +|----|------|------|------| +| **OQ-1** | `methodologies` / `frame_patterns` / `reward_menu_items` 도 갤러리에 노출? | OPEN → Planner OOS 명시. **본 이슈에서 미노출 확정**. | Phase 2-C 시점에 재검토 | +| **OQ-2** | url_launcher 패키지 도입? | OPEN → 본 이슈에서 미도입 (텍스트 표시만). | #FF1 또는 별도 | +| **OQ-3** | DietPattern 의 `koreanContextFit` 을 카드 배지로? | OPEN → 본 이슈에서 미표시 (Preview 만). | UX 피드백 후 결정 | +| **OQ-4** | "내 습관으로" 버튼이 disabled 상태에서 사용자가 누르면 toast? | OPEN → tooltip 만 (Material 표준). | #FF1 에서 활성화 | +| **OQ-5** | 갤러리 카드 정렬 순서 (id / evidence_strength / 가나다) | OPEN → **id 알파벳 순** (안정적 + 예측 가능). | Developer 결정 권한 | +| **OQ-6** | references URL 없을 때 카드 표시 vs 숨김 | OPEN → **표시** (title + kind 만, url 라인 숨김). | Developer 결정 | + +--- + +## 부록: 자가 점검 (Architect 종료 시 검증) + +- [x] §1~§12 모든 섹션 채워짐 +- [x] Planner 10 AC 모두 §3 + §10 1:1 매핑 +- [x] 모든 함수 §7 표에 등재. 복잡 2개 (`CatalogRepository.all`, `migrateV1ToV2`) → fn-*.md 작성 +- [x] I/O ↔ 순수 로직 경계 §5 명시 +- [x] 데이터 모델 §6 (DisplayCategory enum + Protocols.category v2 CHECK + CatalogItem sealed) 정의 +- [x] 마이그레이션 시나리오 (신규/업그레이드 둘 다) §8 명시 +- [x] 에러 / 빈 카테고리 / ref 매칭 실패 §9 처리 +- [x] ADR-0004 발행 항목 §11 +- [x] out-of-scope §2 명확 (#FF1~#FF4 라벨) +- [x] AC-8 (DB write 0건) 보장 매커니즘 §5 + §8 diff --git a/docs/design/226-catalog-gallery/fn-catalog_repository.md b/docs/design/226-catalog-gallery/fn-catalog_repository.md new file mode 100644 index 0000000..50bd347 --- /dev/null +++ b/docs/design/226-catalog-gallery/fn-catalog_repository.md @@ -0,0 +1,191 @@ +# 함수 설계서: `CatalogRepository.all` (#226) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/catalog/catalog_repository.dart` · **테스트**: `app/test/data/catalog/catalog_repository_test.dart` + +## 1. 시그니처 + +```dart +class CatalogRepository { + CatalogRepository(this._db); + final AppDatabase _db; + + Future> all(); + Future byId(String id); + Future> referencesByIds(List ids); +} +``` + +본 fn-*.md 는 `all()` 의 알고리즘만 다룬다. `byId` / `referencesByIds` 는 단순 lookup 이므로 README §7 표 한 줄로 충분. + +## 2. 책임 (단일 책임, 1줄) + +3 source (Protocols / BreakProtocols / DietPatterns) 를 단일 `List` 으로 통합 — 본 이슈의 핵심 변환 한 점. + +## 3. 입력 + +| 파라미터 | 타입 | 제약 | 설명 | +|---|---|---|---| +| (인스턴스 필드) `_db` | `AppDatabase` | non-null | seed 가 끝난 DB (시드 안 끝났으면 호출자가 보장) | + +## 4. 출력 + +- **반환**: `List` — 총 47 항목 (protocols 34 + break 8 + diet 5). + - 각 항목은 `ProtocolCatalogItem` / `BreakCatalogItem` / `DietCatalogItem` 중 하나 (sealed). + - 정렬: `displayCategory.index` → `id` 알파벳 순. +- **부수효과**: DB 3회 read. **write 0**. + +## 5. 동작 / 알고리즘 + +``` +1. final protocolRows = await _db.select(_db.protocols).get(); // 34 +2. final breakRows = await _db.select(_db.breakProtocols).get(); // 8 +3. final dietRows = await _db.select(_db.dietPatterns).get(); // 5 + +4. final items = []; + +5. for each p in protocolRows: + final dc = DisplayCategory.fromProtocolCategory(p.category); + if (dc == null) { + throw StateError( + 'unknown protocol category "${p.category}" for id=${p.id}' + ); + } + items.add(ProtocolCatalogItem( + id: p.id, + title: p.title, + titleEn: p.titleEn, + summary: _summary(p.what, fallback: p.title), + displayCategory: dc, + evidenceStrength: p.evidenceStrength, + referenceIds: _decodeIds(p.referenceIdsJson), + what: p.what, + whenText: p.whenText, + dose: p.dose, + why: p.why, + how: _decodeList(p.howJson), + checkText: p.checkText, + caution: p.caution, + defaultAnchor: _decodeAnchor(p.defaultAnchorJson), + minDoseForStart: p.minDoseForStart, + sourceDoc: p.sourceDoc, + )); + +6. for each b in breakRows: + items.add(BreakCatalogItem( + id: b.id, + title: b.title, + titleEn: null, + summary: b.hubermanSummary, // 이미 1줄 요약 형태 + displayCategory: DisplayCategory.breakHabit, + evidenceStrength: null, // BreakProtocol 스키마에 없음 + referenceIds: _decodeIds(b.referenceIdsJson), + breakCategory: b.category, // 'alcohol' / 'nicotine' / ... + hubermanSummary: b.hubermanSummary, + phases: _decodeList(b.phasesJson), + defaultCommonFrames: _decodeList(b.defaultCommonFramesJson), + tools: _decodeList(b.toolsJson), + medicalWarning: b.medicalWarning, + )); + +7. for each d in dietRows: + items.add(DietCatalogItem( + id: d.id, + title: d.name, + titleEn: null, + summary: d.core, + displayCategory: DisplayCategory.nutrition, + evidenceStrength: d.evidenceStrength, + referenceIds: _decodeIds(d.referenceIdsJson), + name: d.name, + core: d.core, + strengths: _decodeList(d.strengthsJson), + weaknesses: _decodeList(d.weaknessesJson), + koreanContextFit: d.koreanContextFit, + starterLevers: _decodeList(d.starterLeversJson), + medicalWarning: d.medicalWarning, + linkedProtocolIds: _decodeIds(d.linkedProtocolIdsJson), + )); + +8. items.sort((a, b) { + final c = a.displayCategory.index - b.displayCategory.index; + return c != 0 ? c : a.id.compareTo(b.id); + }); + +9. return items; +``` + +### 헬퍼 (file-private, 모두 pure) + +```dart +String _summary(String what, {required String fallback, int max = 60}) { + final firstSentence = what.split(RegExp(r'[.!?。!?]')).first.trim(); + final s = firstSentence.isEmpty ? fallback : firstSentence; + return s.length <= max ? s : '${s.substring(0, max - 1)}…'; +} + +List _decodeIds(String? json) { + if (json == null) return const []; + final decoded = jsonDecode(json); + return decoded is List ? decoded.cast() : const []; +} + +List _decodeList(String? json) { + if (json == null) return const []; + final decoded = jsonDecode(json); + return decoded is List ? decoded.map((e) => e.toString()).toList() : const []; +} + +Map? _decodeAnchor(String? json) { + if (json == null) return null; + final decoded = jsonDecode(json); + return decoded is Map ? decoded : null; +} +``` + +## 6. 에러 & 실패 모드 + +| 조건 | 처리 | 반환/예외 | +|---|---|---| +| `protocol.category` 가 DisplayCategory 매핑 0 | 부팅 직후 첫 호출에서 throw → AsyncValue.error → SnackBar "카탈로그 손상" | `StateError('unknown protocol category "x" for id=y')` | +| `howJson` / `referenceIdsJson` 파싱 실패 | 빈 리스트 반환 (graceful) | — | +| DB 미시드 (kSeededV1Flag 없음) | 호출자가 `seedInProgressProvider` 로 막아야 함. 본 함수는 raw 결과 (0 row) 반환 — UI 가 "준비 중" 표시. | — | +| 3 source 중 한 source 가 부분 손상 (예: protocols 0 row, break 정상) | partial 결과 반환 — 사용자가 break 만 봄. | — | +| `_summary` 가 빈 문자열 | fallback (title) 사용. | — | + +## 7. 엣지케이스 + +- **47 ≠ 실제 row 수**: 시드 갱신 후 row 수 변동 가능. 본 함수는 row 수 비검증 — `catalog_repository_test.dart` 가 일관성 검증. +- **DisplayCategory 추가**: enum 에만 추가하고 매핑은 staging JSON 으로 들어가는 새 protocol 만 채움. 기존 47 항목 매핑 무변화. +- **중복 id**: PK 제약상 발생 불가 — drift 가 보장. +- **빈 summary**: `what` 이 punctuation 으로만 시작하면 `_summary` 가 빈 문자열 → fallback 적용. + +## 8. 복잡도 / 성능 + +- 시간: O(N) — N=47, 사용자 화면 진입 1회. +- 공간: O(N) — 47 인스턴스. +- 호출 빈도: **갤러리 진입 시 1회** (Riverpod cache, 화면 사라질 때 dispose). +- 실측 추정 latency: < 5ms on 8GB+ Android. 5초 cold start 영향 0. + +## 9. 의존성 + +- 호출: `AppDatabase.select` x 3, `dart:convert` (jsonDecode), `DisplayCategory.fromProtocolCategory`. +- 호출처: `catalogItemsProvider` (Riverpod `FutureProvider>`). + +## 10. 테스트 케이스 + +- [ ] **정상**: seed 가 끝난 in-memory DB → all() 가 47 item + 정렬 (displayCategory.index → id) +- [ ] **카테고리 매핑**: 모든 ProtocolCatalogItem.displayCategory ≠ null +- [ ] **Break 단일 카테고리**: 모든 BreakCatalogItem.displayCategory == breakHabit +- [ ] **Diet 단일 카테고리**: 모든 DietCatalogItem.displayCategory == nutrition +- [ ] **summary 길이**: 모든 item 의 summary ≤ 60자 +- [ ] **referenceIds 디코딩**: 빈 JSON / null / 정상 케이스 3개 +- [ ] **에러**: 손상된 category 값을 직접 DB 에 insert 후 all() → StateError +- [ ] **빈 DB**: seed 안 한 DB → 빈 리스트 (throw 안 함) +- [ ] **byId 정상 / 미존재**: 2 케이스 +- [ ] **referencesByIds**: 일부 매칭 / 전부 미매칭 2 케이스 + +## 11. 추적성 + +- 인수조건: #226 AC-2 (카테고리 칩 표시), AC-3 (카드 그리드), AC-4 (8 카테고리 매핑), AC-7 (reference 매칭). +- 관련 ADR: ADR-0004 (본 이슈에서 발행). diff --git a/docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md b/docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md new file mode 100644 index 0000000..4171c2b --- /dev/null +++ b/docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md @@ -0,0 +1,120 @@ +# 함수 설계서: `migrateV1ToV2` (#226) + +> **부모 설계서**: ./README.md · **상태**: Draft +> **작성**: [AI] Architect (2026-06-12) · **구현**: `app/lib/data/db/app_database.dart` 의 `migration.onUpgrade` 인라인 또는 file-private top-level · **테스트**: `app/test/data/db/migration_v1_to_v2_test.dart` + +## 1. 시그니처 + +```dart +Future _migrateV1ToV2(Migrator m, AppDatabase db) async { ... } +``` + +`MigrationStrategy.onUpgrade` 에서 dispatch: + +```dart +onUpgrade: (m, from, to) async { + if (from == 1 && to >= 2) { + await _migrateV1ToV2(m, this); // this = AppDatabase + } + // future: + // if (from <= 2 && to >= 3) await _migrateV2ToV3(m, this); + if (from > to || to > schemaVersion) { + assert(false, 'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)'); + } +}, +``` + +## 2. 책임 (단일 책임, 1줄) + +v1 DB 의 `protocols` 테이블을 v2 CHECK 제약으로 교체하고 시드 flag 클리어 — read-only catalog 의 첫 마이그레이션 패턴. + +## 3. 입력 + +| 파라미터 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `m` | `Migrator` | drift 의 schema migrator | DDL API | +| `db` | `AppDatabase` | non-null | metaKv 클리어용 | + +## 4. 출력 + +- **반환**: `Future`. +- **부수효과**: + - `protocols` 테이블 DROP + CREATE (CHECK 제약 7 카테고리로) + 인덱스 `IDX_protocols_category` 재생성. + - `meta_kv` 에서 `kSeededV1Flag` row DELETE. + - 다음 부팅 시 `SeedImporter.importIfNeeded()` 가 재시드 트리거. +- **user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화**. + +## 5. 동작 / 알고리즘 + +``` +1. await m.deleteTable(db.protocols); + # SQLite: DROP TABLE protocols + # 인덱스도 자동 cascade drop. + +2. await m.createTable(db.protocols); + # v2 schema 로 CREATE TABLE protocols ( + # id TEXT PRIMARY KEY, + # category TEXT CHECK (category IN ( + # 'light_circadian','sleep','movement','nutrition', + # 'focus_cognition','recovery_stress','emotion_relationship' + # )) NOT NULL, + # title TEXT NOT NULL, + # ... + # ); + +3. await m.createIndex(Index( + 'IDX_protocols_category', + 'CREATE INDEX IDX_protocols_category ON protocols(category)', + )); + # drop 시 자동 cascade 됐어도 명시적 재생성. + +4. await (db.delete(db.metaKv) + ..where((t) => t.key.equals(kSeededV1Flag))) + .go(); + # 시드 flag 1 row 삭제. 다음 부팅이 importIfNeeded() 호출 → 새 JSON 으로 reseed. +``` + +## 6. 에러 & 실패 모드 + +| 조건 | 처리 | 반환/예외 | +|---|---|---| +| DROP 실패 (이론상 없음, 사용자 락) | drift 가 transaction 롤백 → 부팅 실패. 사용자에겐 명시적 에러. | SqliteException 전파 | +| CREATE 실패 (이론상 없음) | 동상 | SqliteException 전파 | +| metaKv 삭제 실패 (이론상 없음) | 동상 — but **여기까지 도달 시 protocols 테이블은 v2 형태**. 다음 부팅 시 flag 가 'true' 인 채라 reseed 안 함 → protocols 빈 상태. **위험.** 그래서 metaKv 삭제는 트랜잭션 내 마지막 단계가 아니라 순서가 중요. | drift onUpgrade 전체가 트랜잭션 — drop/create 와 metaKv 삭제 같이 묶임. | +| 다음 부팅 시 seed JSON 손상 | SeedImporter 가 CHECK 위배 throw → 부팅 실패. dev 단말 1대 영향이라 수용. | FormatException / SqliteException | + +## 7. 엣지케이스 + +- **신규 설치 (`onCreate`)** — 본 함수 호출 0. createAll 이 v2 schema 그대로 적용 후 seed 가 v2 JSON 로드. 정상. +- **v1 → v3+ 점프 (이론상 없음, 현재 schemaVersion=2)** — `from=1, to=3` 이면 v1→v2 → v2→v3 순차 실행 가정. `_migrateV2ToV3` 가 아직 없어 dispatch 가 발견 못 함 → assert false. v3 도입 시점에 명시. +- **트랜잭션 중단** — drift 의 onUpgrade 는 db.transaction 안에서 실행. 부분 실패 시 자동 롤백 → 사용자 DB 는 v1 그대로. 다음 시도에서 재실행. +- **사용자 데이터 보호** — 본 함수는 Protocols 만 건드림. Habits/TrackerEntries 등 user 테이블 0 영향. `migration_v1_to_v2_test.dart` 가 명시적 검증. +- **인덱스 재생성 누락 시** — query latency ↓ 만 영향 (정상 동작). 본 함수가 명시적으로 createIndex 호출하므로 보호. + +## 8. 복잡도 / 성능 + +- 시간: O(1) — DDL 4건. +- 실측: < 50ms (dev 단말, drift 의 transaction overhead 포함). +- 호출 빈도: **dev 단말 평생 1회** (v1 → v2 한 번). 사용자 신규 설치는 호출 0. + +## 9. 의존성 + +- drift `Migrator` API (deleteTable, createTable, createIndex). +- `kSeededV1Flag` 상수 (`core/constants.dart`). +- AppDatabase 의 `protocols` getter (스키마 가져오기). + +## 10. 테스트 케이스 + +- [ ] **smoke**: in-memory DB 를 v1 schema 로 raw SQL 로 생성 → `_migrateV1ToV2(m, db)` 호출 → protocols 테이블의 CHECK 제약이 v2 7 카테고리인지 검증 (PRAGMA / sqlite_master 조회) +- [ ] **flag 클리어**: 사전에 metaKv 에 `seeded_v1='true'` insert → migrate → metaKv 조회 시 row 없음 +- [ ] **user 데이터 보호**: 사전에 Habits / Phases / TrackerEntries 에 row insert → migrate → 모두 그대로 +- [ ] **v2 CHECK 위배 negative**: migrate 후 `INSERT INTO protocols (..., category='health', ...)` 시도 → SqliteException +- [ ] **v2 CHECK 통과 positive**: `category='light_circadian'` insert → 성공 +- [ ] **인덱스 존재**: migrate 후 `sqlite_master` 에서 `IDX_protocols_category` 발견 +- [ ] **이중 호출 안전성**: 동일 DB 에 migrate 2회 호출 → 두 번째도 성공 (idempotent 가정. drift `deleteTable` 이 미존재 테이블에 graceful 인지 확인 필요 — OQ) +- [ ] **integration with onUpgrade**: schemaVersion=2 인 AppDatabase 로 v1 DB 열기 → onUpgrade 자동 호출 → 정상 동작 + +## 11. 추적성 + +- 인수조건: #226 AC-4 (8 카테고리 재분류 — DB CHECK 갱신 부분), AC-8 (user 테이블 무변화), AC-9 (마이그레이션 unit test). +- 관련 ADR: **ADR-0004** (본 이슈에서 발행 — Catalog re-categorization + first schema migration policy).