[02-Architect] #226 design spec + ADR-0004
- docs/design/226-catalog-gallery/README.md — 12 changed files, DisplayCategory enum, sealed CatalogItem, 5 scenarios - docs/design/226-catalog-gallery/fn-catalog_repository.md — 3-source unification algorithm + helpers - docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md — first schema migration (DROP + reseed pattern) - docs/adr/0004-catalog-recategorization-and-first-migration.md — Catalog 재분류 + 첫 마이그레이션 정책 Refs #226
This commit is contained in:
@@ -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+ 에서 같은 결정 다시 해야 함 — 일찍 답하는 게 싸다.
|
||||
457
docs/design/226-catalog-gallery/README.md
Normal file
457
docs/design/226-catalog-gallery/README.md
Normal file
@@ -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<Protocol> (34)
|
||||
│ ├─► db.select(breakProtocols) → List<BreakProtocol> (8)
|
||||
│ └─► db.select(dietPatterns) → List<DietPattern> (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<CatalogItem>] (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<bool>(
|
||||
"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<String> 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<List<CatalogItem>> all()` | none | List<CatalogItem> 47 | DB 에러 throw | **복잡** → [fn-catalog_repository.md](./fn-catalog_repository.md) |
|
||||
| `CatalogRepository.byId(String)` | 단건 조회 (Preview 화면 진입 시) | `Future<CatalogItem?> byId(String)` | id | CatalogItem or null | DB 에러 throw | 단순 (lookup) |
|
||||
| `CatalogRepository.referencesByIds(List<String>)` | reference id 들 → ReferenceRow 리스트 | `Future<List<ReferenceRow>>` | ids | rows | DB 에러 throw | 단순 |
|
||||
| `groupByCategory(items)` (pure) | List<CatalogItem> → Map<DisplayCategory, List<CatalogItem>> | `Map<DisplayCategory, List<CatalogItem>> groupByCategory(List<CatalogItem>)` | items | grouped map (빈 카테고리 키 미포함) | — | 단순 |
|
||||
| `_summary(Protocol)` (pure) | Protocol.what 의 1줄 요약 추출 | `String _summary(Protocol p)` | Protocol | string ≤ 60자 | what 빈 문자열이면 title 반환 | 단순 |
|
||||
| `migrateV1ToV2(Migrator)` | onUpgrade 1→2 실행 | `Future<void> 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
|
||||
191
docs/design/226-catalog-gallery/fn-catalog_repository.md
Normal file
191
docs/design/226-catalog-gallery/fn-catalog_repository.md
Normal file
@@ -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<List<CatalogItem>> all();
|
||||
Future<CatalogItem?> byId(String id);
|
||||
Future<List<ReferenceRow>> referencesByIds(List<String> ids);
|
||||
}
|
||||
```
|
||||
|
||||
본 fn-*.md 는 `all()` 의 알고리즘만 다룬다. `byId` / `referencesByIds` 는 단순 lookup 이므로 README §7 표 한 줄로 충분.
|
||||
|
||||
## 2. 책임 (단일 책임, 1줄)
|
||||
|
||||
3 source (Protocols / BreakProtocols / DietPatterns) 를 단일 `List<CatalogItem>` 으로 통합 — 본 이슈의 핵심 변환 한 점.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| (인스턴스 필드) `_db` | `AppDatabase` | non-null | seed 가 끝난 DB (시드 안 끝났으면 호출자가 보장) |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `List<CatalogItem>` — 총 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 = <CatalogItem>[];
|
||||
|
||||
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<String> _decodeIds(String? json) {
|
||||
if (json == null) return const [];
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is List ? decoded.cast<String>() : const [];
|
||||
}
|
||||
|
||||
List<String> _decodeList(String? json) {
|
||||
if (json == null) return const [];
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is List ? decoded.map((e) => e.toString()).toList() : const [];
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodeAnchor(String? json) {
|
||||
if (json == null) return null;
|
||||
final decoded = jsonDecode(json);
|
||||
return decoded is Map<String, dynamic> ? 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<List<CatalogItem>>`).
|
||||
|
||||
## 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 (본 이슈에서 발행).
|
||||
120
docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md
Normal file
120
docs/design/226-catalog-gallery/fn-migration_v1_to_v2.md
Normal file
@@ -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<void> _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<void>`.
|
||||
- **부수효과**:
|
||||
- `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).
|
||||
Reference in New Issue
Block a user