Files
life-helper/docs/design/204-flutter-bootstrap/03-drift-schema-user.md
joungmin 29befe4d97 [Architect] Refs #204 — apply OQ decisions: diet_pattern (19th), ADR-0002 normalize dose_variants
- OQ-1: dose_variants 정규화 결정을 ADR-0002 로 승격 (ADR-0001 = 왜, ADR-0002 = 어떻게).
- OQ-3: nutrition diet 패턴 5개를 별도 diet_pattern 카탈로그(19번째 SoT)로 분리.
  · 02-catalog §8 신규, 인덱스 IDX_diet_patterns_evidence / IDX_diet_patterns_kfit.
  · 05-seed: diet_patterns.json (5행) 추가, 로딩 순서 끝에 배치.
  · 04-migrations: v1 테이블 합계 = Catalog 8 + User 11 + 부속 1 + meta_kv = 21.
- README §2/§3/§6/§11 갱신: 18→19 SoT, AC-2 에 diet_pattern=5 검증 추가.
- README §12 OQ → Resolved Open Questions 표 (OQ-1~OQ-8 결정 결과).
- habit_dose_variant → habit_dose_variants 표기 통일.
- fn-weekly-minimum-ratio, 03-drift-schema-user 의 ADR-0002 cross-link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 17:13:04 +09:00

15 KiB

03 — User-Data 테이블 11 개 Drift 정의 + R 강제 매트릭스 (#204)

부모 설계서: 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 + (정규화 부속, ADR-0002) habit_dose_variants.

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)

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)

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 제외)

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-0002)

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)

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)

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)

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)

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)

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)

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)

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 (시드 플래그, 기타)

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 로 이력 보존.