[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:
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 (본 이슈에서 발행).
|
||||
Reference in New Issue
Block a user