# 함수 설계서: `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 (본 이슈에서 발행).