[Architect] #204 design spec — Flutter bootstrap + 18 tables
Phase 1 설계서 작성 완료. docs/design/204-flutter-bootstrap/ 13 개 파일: - README.md (12 섹션 모두 채움, 함수 19 개 명세, AC 16 항) - 01-project-structure.md (feature-first + layer-first 하이브리드) - 02-drift-schema-catalog.md (Catalog 7 테이블 Dart 정의) - 03-drift-schema-user.md (User 11 테이블 + R1~R10 강제 매트릭스) - 04-migrations.md (schemaVersion v1 + 인덱스 17 개) - 05-seed-data.md (assets/seed/*.json + first-run import) - 06-ux-contracts.md (체크인 R8 ≤ 60 초 흐름) - fn-recommend-variant / fn-compute-streak / fn-weekly-minimum-ratio - fn-validate-frame-level / fn-active-habit-quota / fn-seed-importer 핵심 결정: dose_variants 는 별도 habit_dose_variant 테이블로 정규화 (FK 무결성 + recommendVariant SQL 단순성). ADR-0002 승격 권장. Refs #204 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
320
docs/design/204-flutter-bootstrap/03-drift-schema-user.md
Normal file
320
docs/design/204-flutter-bootstrap/03-drift-schema-user.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 03 — User-Data 테이블 11 개 Drift 정의 + R 강제 매트릭스 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
> SoT: `schema/user.schema.json`, `phase.schema.json`, `habit.schema.json`, `if_then_rule.schema.json`, `tracker_entry.schema.json`, `lapse_log.schema.json`, `urge_log.schema.json`, `reward_declaration.schema.json`, `reward_claim.schema.json`, `reflection.schema.json` + (정규화) `habit_dose_variant`.
|
||||
|
||||
## 0. 공통 규약
|
||||
|
||||
- 모든 user-data 테이블은 `TEXT user_id` (또는 간접 참조). 단일 사용자 default = `'u_local_default'`.
|
||||
- 일/일시 컬럼은 모두 ISO 8601 string (`TextColumn`). Drift `TypeConverter<DateTime, String>` 로 변환.
|
||||
- enum 강제는 (1) Dart enum + TypeConverter (1 차) (2) CHECK 제약 (2 차) 두 층.
|
||||
- FK 는 `customConstraint('REFERENCES habit(id) ON DELETE RESTRICT')` 로 박는다. 본 Phase 는 hard delete 없음 (soft delete only). RESTRICT 가 안전.
|
||||
|
||||
## 1. `users` (← `user.schema.json`)
|
||||
|
||||
```dart
|
||||
class Users extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get displayName => text().nullable()();
|
||||
TextColumn get locale => text().withDefault(const Constant('ko-KR'))();
|
||||
TextColumn get timezone => text().withDefault(const Constant('Asia/Seoul'))();
|
||||
TextColumn get createdAt => text()(); // ISO 8601
|
||||
TextColumn get preferencesJson => text().nullable()(); // celebration_style 등 nested
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
## 2. `phases` (← `phase.schema.json`)
|
||||
|
||||
```dart
|
||||
class Phases extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get title => text().nullable()();
|
||||
TextColumn get startedAt => text()(); // YYYY-MM-DD
|
||||
TextColumn get endedAt => text().nullable()();
|
||||
IntColumn get durationWeeks => integer().withDefault(const Constant(6))
|
||||
.withCheckConstraint("duration_weeks >= 1")();
|
||||
TextColumn get status => text().withCheckConstraint(
|
||||
"status IN ('active','completed','abandoned')")();
|
||||
TextColumn get intentionText => text().nullable()();
|
||||
BoolColumn get rewardDeclarationsLocked => boolean().withDefault(const Constant(false))();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_phases_user_status(user_id, status)` — active 1 개 조회 hot path.
|
||||
|
||||
## 3. `habits` (← `habit.schema.json`, dose_variants 제외)
|
||||
|
||||
```dart
|
||||
class Habits extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get phaseId => text().nullable().customConstraint(
|
||||
'REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get type => text().withCheckConstraint("type IN ('build','break')")();
|
||||
TextColumn get status => text().withCheckConstraint(
|
||||
"status IN ('active','paused','completed','abandoned')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get protocolId => text().nullable().customConstraint(
|
||||
'REFERENCES protocols(id)')();
|
||||
TextColumn get breakProtocolId => text().nullable().customConstraint(
|
||||
'REFERENCES break_protocols(id)')();
|
||||
TextColumn get commonFrameIdsJson => text().nullable()();
|
||||
TextColumn get frameLevel => text().withCheckConstraint(
|
||||
"frame_level IN ('L2','L3')")(); // R3 here
|
||||
TextColumn get frameOriginalText => text().nullable()();
|
||||
TextColumn get frameFramedText => text()();
|
||||
TextColumn get anchorWhen => text().nullable()(); // HH:MM
|
||||
TextColumn get anchorAfterWhat => text().nullable()();
|
||||
TextColumn get anchorWhere => text().nullable()();
|
||||
IntColumn get stackPosition => integer().nullable().withCheckConstraint(
|
||||
"stack_position IS NULL OR stack_position >= 1")();
|
||||
TextColumn get minDose => text().nullable()(); // deprecated when variants used
|
||||
TextColumn get targetDose => text().nullable()(); // deprecated when variants used
|
||||
TextColumn get startedAt => text()(); // YYYY-MM-DD
|
||||
TextColumn get endedAt => text().nullable()();
|
||||
TextColumn get tagsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
@override List<String> get customConstraints => [
|
||||
// XOR — application layer 추가 검증 (assertXorProtocol). CHECK 도 박는다.
|
||||
"CHECK ( (type = 'build' AND protocol_id IS NOT NULL AND break_protocol_id IS NULL) "
|
||||
" OR (type = 'break' AND break_protocol_id IS NOT NULL AND protocol_id IS NULL) )"
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_habits_user_status_type(user_id, status, type)` — R1/R2 빠른 카운트.
|
||||
- `IDX_habits_phase(phase_id)`.
|
||||
|
||||
## 4. `habit_dose_variants` (정규화 — ADR 후보)
|
||||
|
||||
```dart
|
||||
class HabitDoseVariants extends Table {
|
||||
TextColumn get variantId => text()(); // habit 안에서 unique. tracker_entry.variant_id 가 참조.
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE CASCADE')();
|
||||
TextColumn get label => text()();
|
||||
TextColumn get doseText => text()();
|
||||
TextColumn get contextTagsJson => text().nullable()();
|
||||
TextColumn get conditionTagsJson => text().nullable()();
|
||||
BoolColumn get isMinimum => boolean().withDefault(const Constant(false))();
|
||||
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||||
@override Set<Column> get primaryKey => {variantId};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_habit_dose_variants_habit(habit_id)`.
|
||||
- `IDX_habit_dose_variants_habit_minimum(habit_id, is_minimum)` — recommendVariant fallback 빠른 조회.
|
||||
|
||||
> CASCADE delete OK — variant 는 habit 의 부속. 단 habit 자체는 soft delete (status='abandoned'). variant 만 단독 삭제는 사용자가 명시적으로 한 경우만.
|
||||
|
||||
## 5. `if_then_rules` (← `if_then_rule.schema.json`)
|
||||
|
||||
```dart
|
||||
class IfThenRules extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE CASCADE')();
|
||||
TextColumn get ifCondition => text()();
|
||||
TextColumn get thenAction => text()();
|
||||
TextColumn get triggerType => text().nullable().withCheckConstraint(
|
||||
"trigger_type IS NULL OR trigger_type IN "
|
||||
"('time','location','emotion','preceding_action','urge')")();
|
||||
IntColumn get priority => integer().withDefault(const Constant(1))
|
||||
.withCheckConstraint("priority BETWEEN 1 AND 3")();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_if_then_habit(habit_id)`.
|
||||
|
||||
## 6. `tracker_entries` (← `tracker_entry.schema.json`)
|
||||
|
||||
```dart
|
||||
class TrackerEntries extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE RESTRICT')();
|
||||
TextColumn get date => text()(); // YYYY-MM-DD
|
||||
TextColumn get value => text().withCheckConstraint(
|
||||
"value IN ('done','blank')")(); // R5 here
|
||||
TextColumn get loggedAt => text().nullable()();
|
||||
TextColumn get note => text().nullable().withCheckConstraint(
|
||||
"note IS NULL OR length(note) <= 200")();
|
||||
TextColumn get variantId => text().nullable().customConstraint(
|
||||
'REFERENCES habit_dose_variants(variant_id) ON DELETE SET NULL')();
|
||||
TextColumn get ctxLocation => text().nullable()(); // context_snapshot.location
|
||||
TextColumn get ctxCondition => text().nullable()(); // context_snapshot.condition
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스 / 제약:
|
||||
- `UNIQUE INDEX UQ_tracker_habit_date(habit_id, date)` — (habit_id, date) unique.
|
||||
- `IDX_tracker_habit_date(habit_id, date)` — computeStreak 윈도우 쿼리.
|
||||
- `IDX_tracker_date(date)` — 주간 집계 (weeklyMinimumRatio).
|
||||
|
||||
## 7. `lapse_logs` (← `lapse_log.schema.json`)
|
||||
|
||||
```dart
|
||||
class LapseLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get date => text()();
|
||||
TextColumn get labelText => text()();
|
||||
TextColumn get examineHaltJson => text()(); // ['hungry','tired',...]
|
||||
TextColumn get antecedentJson => text()(); // 1..5 strings
|
||||
TextColumn get replan => text()();
|
||||
TextColumn get nextAction => text()();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_lapse_habit_date(habit_id, date)`.
|
||||
|
||||
## 8. `urge_logs` (← `urge_log.schema.json`)
|
||||
|
||||
```dart
|
||||
class UrgeLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get occurredAt => text()();
|
||||
IntColumn get intensityBefore => integer().nullable().withCheckConstraint(
|
||||
"intensity_before IS NULL OR intensity_before BETWEEN 0 AND 10")();
|
||||
IntColumn get intensityAfter => integer().nullable().withCheckConstraint(
|
||||
"intensity_after IS NULL OR intensity_after BETWEEN 0 AND 10")();
|
||||
IntColumn get durationSeconds => integer().nullable().withCheckConstraint(
|
||||
"duration_seconds IS NULL OR duration_seconds >= 0")();
|
||||
TextColumn get bodyLocationJson => text().nullable()();
|
||||
BoolColumn get passed => boolean()();
|
||||
TextColumn get methodUsed => text().nullable().withCheckConstraint(
|
||||
"method_used IS NULL OR method_used IN "
|
||||
"('cyclic_sighing','walk','water','social_contact','if_then_action','other')")();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_urge_habit_occurred(habit_id, occurred_at)`.
|
||||
|
||||
## 9. `reward_declarations` (← `reward_declaration.schema.json`)
|
||||
|
||||
```dart
|
||||
class RewardDeclarations extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get phaseId => text().customConstraint('REFERENCES phases(id)')();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get tier => text().withCheckConstraint(
|
||||
"tier IN ('T0','T1','T2','T3','T4')")();
|
||||
TextColumn get milestoneRule => text()();
|
||||
TextColumn get milestoneMachineRuleJson => text().nullable()(); // {window_days,min_done,require_consecutive}
|
||||
TextColumn get rewardText => text()();
|
||||
TextColumn get rewardMenuItemId => text().nullable().customConstraint(
|
||||
'REFERENCES reward_menu_items(id)')();
|
||||
IntColumn get estimatedCostKrw => integer().nullable().withCheckConstraint(
|
||||
"estimated_cost_krw IS NULL OR estimated_cost_krw >= 0")();
|
||||
BoolColumn get isEffortTied => boolean().nullable()();
|
||||
TextColumn get declaredAt => text()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
> R4 (phase.started_at + 7 일 이내만 insert) 는 CHECK 로 박을 수 없음 (다른 테이블 참조). **app layer 에서 강제** + phase.reward_declarations_locked 보조.
|
||||
|
||||
## 10. `reward_claims` (← `reward_claim.schema.json`)
|
||||
|
||||
```dart
|
||||
class RewardClaims extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get declarationId => text().customConstraint(
|
||||
'REFERENCES reward_declarations(id)')();
|
||||
TextColumn get milestoneReachedAt => text()();
|
||||
BoolColumn get fulfilled => boolean()();
|
||||
TextColumn get fulfilledAt => text().nullable()();
|
||||
TextColumn get reflection => text().nullable().withCheckConstraint(
|
||||
"reflection IS NULL OR length(reflection) <= 500")();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
## 11. `reflections` (← `reflection.schema.json`)
|
||||
|
||||
```dart
|
||||
class Reflections extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get scope => text().withCheckConstraint(
|
||||
"scope IN ('weekly','monthly','phase_end')")();
|
||||
TextColumn get periodStart => text()();
|
||||
TextColumn get periodEnd => text()();
|
||||
TextColumn get phaseId => text().nullable().customConstraint(
|
||||
'REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get kept => text().nullable()();
|
||||
TextColumn get missed => text().nullable()();
|
||||
TextColumn get adjust => text().nullable()();
|
||||
TextColumn get identityNote => text().nullable()();
|
||||
RealColumn get minimumRatio => real().nullable().withCheckConstraint(
|
||||
"minimum_ratio IS NULL OR (minimum_ratio BETWEEN 0 AND 1)")();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_reflections_user_scope(user_id, scope)`.
|
||||
|
||||
## 보조: `meta_kv` (시드 플래그, 기타)
|
||||
|
||||
```dart
|
||||
class MetaKv extends Table {
|
||||
TextColumn get key => text()();
|
||||
TextColumn get value => text()();
|
||||
@override Set<Column> get primaryKey => {key};
|
||||
}
|
||||
```
|
||||
|
||||
> AC-2 의 "18 테이블 모두 생성" 검증 시 본 보조 테이블은 제외. 단 실제 DB 에는 존재.
|
||||
|
||||
---
|
||||
|
||||
## R 강제 매트릭스 (필수)
|
||||
|
||||
| R | 규칙 | Schema CHECK | Index/Constraint | Trigger | App Layer | 위치 |
|
||||
|---|------|--------------|------------------|---------|-----------|------|
|
||||
| **R1** | active build ≤ 3 | ✗ | `IDX_habits_user_status_type` (조회 가속) | ✗ | ✓ `domain/rules/active_habit_quota.dart::checkActiveHabitQuota` | habit insert/status 변경 직전 |
|
||||
| **R2** | active break ≤ 1 | ✗ | 동일 인덱스 | ✗ | ✓ 동일 함수 | 동일 |
|
||||
| **R3** | frame.level ∈ {L2,L3} | ✓ `CHECK frame_level IN ('L2','L3')` | ✗ | ✗ | ✓ `validateFrameLevel` (UX 변환 제안) | 입력 단계 + DB 마지막 방어 |
|
||||
| **R4** | reward_declaration 은 phase 시작 +7 일 이내 | ✗ (cross-table) | ✗ | ✗ (트리거 가능하나 가독성↓) | ✓ `domain/rules/reward_window.dart::validateRewardDeclarationWindow` + `phase.reward_declarations_locked` 자동 토글 | reward_declaration insert 직전 |
|
||||
| **R5** | tracker_entry.value ∈ {done,blank} | ✓ `CHECK value IN ('done','blank')` | ✗ | ✗ | ✓ `validateTrackerValue` (UI 가드) | 입력 + DB 방어 |
|
||||
| **R6** | phase 중간 anchor 변경 → warning | ✗ | ✗ | ✗ | ✓ `phaseAnchorChangeWarning` (warning only, 차단 X) | habit.anchor* update 시 |
|
||||
| **R7** | if_then_rule.then_action 회피 키워드 → warning | ✗ | ✗ | ✗ | ✓ `detectAvoidanceKeywords` + `frame_patterns` 카탈로그 사전 검색 | UI 입력 시 실시간 |
|
||||
| **R8** | 일일 운영 ≤ 2 분 (UX) | ✗ | ✗ | ✗ | ✓ `CheckInTimer.elapsed` + UX 디자인 (06-ux-contracts.md) | UI 만 |
|
||||
| **R9** | dose_variants 개수 무제한, is_minimum=true 권장 | ✗ | ✗ | ✗ | ✓ — onboarding hint only (강제 없음) | habit_create 폼 |
|
||||
| **R10** | reflection.scope='weekly' 의 minimum_ratio hint | ✗ (RealColumn CHECK 만) | ✗ | ✗ | ✓ `weeklyMinimumRatio` 계산 + 표시 only | reflection 생성 시 |
|
||||
|
||||
### XOR (habit.protocol_id vs break_protocol_id) 강제
|
||||
|
||||
- Schema CHECK 박음 (위 §3 `customConstraints`). + `domain/rules/xor_protocol.dart::assertXorProtocol` 추가 검증 (clearer error).
|
||||
|
||||
### (habit_id, date) UNIQUE 강제
|
||||
|
||||
- `UNIQUE INDEX UQ_tracker_habit_date` 가 1 차. UI 가 중복 진입 시 readable 메시지.
|
||||
|
||||
---
|
||||
|
||||
## 12. dose_variants 저장 형태 — JSON vs 정규화 결정 (반복)
|
||||
|
||||
> README.md §11 의 결정. 본 문서에서 다시 명시.
|
||||
|
||||
**결정**: 별도 `habit_dose_variants` 테이블로 **정규화**.
|
||||
|
||||
**근거 한 줄**: `tracker_entry.variant_id` FK 무결성 + `recommendVariant` SQL 조회 + variant 단위 row CRUD 가 모두 standard SQL 로 가능하다 (JSON1 extension 의존성 회피).
|
||||
|
||||
**비용**: `habit.schema.json` 의 nested 표현 ↔ 정규화 row 변환 어댑터 1 개 (`data/seed/adapters/habit_variant_adapter.dart` — Phase 1 vertical slice 에는 사용자 입력 변환만 필요).
|
||||
|
||||
**ADR 승격 권장**: Phase 1 종료 후 ADR-0002 로 이력 보존.
|
||||
Reference in New Issue
Block a user