Files
life-helper/docs/design/204-flutter-bootstrap/04-migrations.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

7.4 KiB

04 — Drift 마이그레이션 전략 (#204)

부모 설계서: README.md

1. schemaVersion 정책

version 범위 작성자 비고
v1 19 SoT 테이블 (Catalog 8 + User 11) + 부속 1 (habit_dose_variants) + meta_kv = 21 테이블 초기 생성 Architect (이 설계서) → Developer (구현) Phase 1 종료 시점 = production v1
v2+ TBD (Phase 2+) 해당 시점 Architect data-model.md §8 의 후속 엔티티 (habit_stack, notification_rule 등)

v1 테이블 합계 명확화: Catalog 8 (protocols, break_protocols, common_frames, methodologies, frame_patterns, reward_menu_items, references, diet_patterns) + User 11 (users, phases, habits, if_then_rules, tracker_entries, lapse_logs, urge_logs, reward_declarations, reward_claims, reflections + ※ habit row 의 정규화 부속 별도) + 부속 1 (habit_dose_variants — ADR-0002) + meta_kv = 21 테이블.

규칙:

  • schema 가 바뀌면 반드시 schemaVersion 증가 + onUpgrade 콜백 작성.
  • 컬럼 추가만은 가능하나, drop/rename 은 destructive — m.alterTable(TableMigration(...)) 사용.
  • production user 가 0 명인 본 Phase (v1 미배포 상태) 에서만 destructive recreate 허용. v1 이 디바이스에 올라간 후엔 데이터 보존 마이그레이션 필수.

2. v1 진입점 (Drift MigrationStrategy)

@DriftDatabase(tables: [
  // catalog (8)
  Protocols, BreakProtocols, CommonFrames, Methodologies,
  FramePatterns, RewardMenuItems, References, DietPatterns,
  // user (11) + 부속 (1, ADR-0002)
  Users, Phases, Habits, HabitDoseVariants, IfThenRules,
  TrackerEntries, LapseLogs, UrgeLogs,
  RewardDeclarations, RewardClaims, Reflections,
  // meta
  MetaKv,
])
class AppDatabase extends _$AppDatabase {
  AppDatabase(QueryExecutor e) : super(e);

  @override
  int get schemaVersion => 1;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (m) async {
      await m.createAll();
      await _createIndexes(m);
    },
    onUpgrade: (m, from, to) async {
      // Phase 1 한정: v1 만 존재. 도달 불가 보장.
      assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
    },
    beforeOpen: (details) async {
      if (details.wasCreated) {
        // SeedImporter 는 main.dart 에서 별도 호출 (트랜잭션 분리 위해)
      }
    },
  );

  Future<void> _createIndexes(Migrator m) async {
    // catalog
    await m.createIndex(Index('IDX_protocols_category',
        'CREATE INDEX IDX_protocols_category ON protocols(category)'));
    await m.createIndex(Index('IDX_break_protocols_category',
        'CREATE UNIQUE INDEX IDX_break_protocols_category ON break_protocols(category)'));
    await m.createIndex(Index('IDX_methodologies_core',
        'CREATE INDEX IDX_methodologies_core ON methodologies(is_core_engine) '
        'WHERE is_core_engine = 1'));
    await m.createIndex(Index('IDX_frame_patterns_keyword',
        'CREATE INDEX IDX_frame_patterns_keyword ON frame_patterns(avoidance_keyword)'));
    await m.createIndex(Index('IDX_reward_menu_tier',
        'CREATE INDEX IDX_reward_menu_tier ON reward_menu_items(tier_recommended)'));
    await m.createIndex(Index('IDX_references_kind',
        'CREATE INDEX IDX_references_kind ON "references"(kind)'));
    await m.createIndex(Index('IDX_references_doi',
        'CREATE INDEX IDX_references_doi ON "references"(doi) WHERE doi IS NOT NULL'));
    await m.createIndex(Index('IDX_diet_patterns_evidence',
        'CREATE INDEX IDX_diet_patterns_evidence ON diet_patterns(evidence_strength)'));
    await m.createIndex(Index('IDX_diet_patterns_kfit',
        'CREATE INDEX IDX_diet_patterns_kfit ON diet_patterns(korean_context_fit) '
        'WHERE korean_context_fit IS NOT NULL'));
    // user
    await m.createIndex(Index('IDX_phases_user_status',
        'CREATE INDEX IDX_phases_user_status ON phases(user_id, status)'));
    await m.createIndex(Index('IDX_habits_user_status_type',
        'CREATE INDEX IDX_habits_user_status_type ON habits(user_id, status, type)'));
    await m.createIndex(Index('IDX_habits_phase',
        'CREATE INDEX IDX_habits_phase ON habits(phase_id)'));
    await m.createIndex(Index('IDX_habit_dose_variants_habit',
        'CREATE INDEX IDX_habit_dose_variants_habit ON habit_dose_variants(habit_id)'));
    await m.createIndex(Index('IDX_habit_dose_variants_habit_min',
        'CREATE INDEX IDX_habit_dose_variants_habit_min '
        'ON habit_dose_variants(habit_id, is_minimum)'));
    await m.createIndex(Index('IDX_if_then_habit',
        'CREATE INDEX IDX_if_then_habit ON if_then_rules(habit_id)'));
    await m.createIndex(Index('UQ_tracker_habit_date',
        'CREATE UNIQUE INDEX UQ_tracker_habit_date ON tracker_entries(habit_id, date)'));
    await m.createIndex(Index('IDX_tracker_habit_date',
        'CREATE INDEX IDX_tracker_habit_date ON tracker_entries(habit_id, date)'));
    await m.createIndex(Index('IDX_tracker_date',
        'CREATE INDEX IDX_tracker_date ON tracker_entries(date)'));
    await m.createIndex(Index('IDX_lapse_habit_date',
        'CREATE INDEX IDX_lapse_habit_date ON lapse_logs(habit_id, date)'));
    await m.createIndex(Index('IDX_urge_habit_occurred',
        'CREATE INDEX IDX_urge_habit_occurred ON urge_logs(habit_id, occurred_at)'));
    await m.createIndex(Index('IDX_reflections_user_scope',
        'CREATE INDEX IDX_reflections_user_scope ON reflections(user_id, scope)'));
  }
}

references 는 SQL reserved-ish (특히 일부 dialects). 본 SQLite 에선 안전하나 Drift 가 안전한 quoting 자동 처리. 직접 SQL 쓸 땐 따옴표 사용.

3. 향후 변경 패턴 (v1 → v2 가이드)

3.1 컬럼 추가 (안전, 권장)

onUpgrade: (m, from, to) async {
  if (from < 2) {
    await m.addColumn(habits, habits.newColumnName);
  }
}

3.2 새 테이블 추가 (안전)

if (from < 2) await m.createTable(newTable);

3.3 컬럼 drop / rename (위험 — TableMigration 사용)

if (from < 2) {
  await m.alterTable(TableMigration(
    habits,
    columnTransformer: { habits.oldCol: const Constant(null) },
    newColumns: [habits.replacementCol],
  ));
}
  • 데이터 보존 마이그레이션은 Drift 의 TableMigration 으로 한 번에 처리.
  • 컬럼 의미 변경 (예: timezone 의미 변경) 은 마이그레이션 + 데이터 정규화 스크립트 별도.

3.4 enum 값 추가 (안전)

  • CHECK 제약 문자열 업데이트 = alterTable 안에서 다시 만들거나, Drift 의 enum CHECK 재생성.

3.5 enum 값 제거 / rename (위험)

  • 기존 데이터 변환 필요. 마이그레이션 + 별도 코드 경로.

4. 회귀 방지 — schema dump diff

  • v1 통과 시점에 tool/schema/v1.json 으로 Drift schema dump (dart run drift_dev schema dump).
  • CI 가 schema dump 와 v1.json 의 diff 를 비교 — schemaVersion 증가 없이 schema 가 변경되면 fail.

5. 본 Phase 의 운영 규칙

  • v1 출시 전이므로 schema 변경은 자유롭게 가능. 단 이 설계서를 먼저 수정한 뒤 코드 변경 (Design-First, CLAUDE.md §2).
  • 첫 디바이스 배포 (Architect → QA → Designer → Release) 이후엔 schemaVersion 증가 + onUpgrade 명시 필수.

6. 도구

  • dart run drift_dev schema dump lib/data/db/app_database.dart tool/schema/
  • dart run drift_dev schema generate tool/schema/ test/generated_migrations/ — 마이그레이션 step 검증용 generated test.