import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../core/constants.dart'; import 'tables/catalog_tables.dart'; import 'tables/user_tables.dart'; part 'app_database.g.dart'; @DriftDatabase(tables: [ // Catalog 8 Protocols, BreakProtocols, CommonFrames, Methodologies, FramePatterns, RewardMenuItems, References, DietPatterns, // User 11 + 정규화 부속 1 Users, Phases, Habits, HabitDoseVariants, IfThenRules, TrackerEntries, LapseLogs, UrgeLogs, RewardDeclarations, RewardClaims, Reflections, // Meta MetaKv, ]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); /// In-memory for tests. AppDatabase.memory() : super(NativeDatabase.memory()); @override int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) async { await m.createAll(); await _createIndexes(m); }, onUpgrade: (m, from, to) async { // v1 → v2 (#226): Protocols.category CHECK 6 → 7 신 카테고리. // Read-only catalog 의 첫 마이그레이션. DROP + reseed 패턴 (ADR-0004). // user 테이블 (Habits, Phases, ...) 무변화. if (from == 1 && to >= 2) { await migrateV1ToV2(m, this); } if (from > to || to > schemaVersion) { assert(false, 'Unknown upgrade path: from=$from to=$to (schemaVersion=$schemaVersion)'); } }, ); Future _createIndexes(Migrator m) async { // Catalog indexes 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 indexes 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)')); } } Future appDatabaseFile() async { final dir = await getApplicationDocumentsDirectory(); return File(p.join(dir.path, 'life_helper.sqlite')); } /// v1 → v2 마이그레이션. fn-migration_v1_to_v2.md 참고. /// /// - Protocols 테이블 DROP + CREATE (v2 CHECK 적용) + 인덱스 재생성. /// - `kSeededV1Flag` 클리어 → 다음 부팅이 SeedImporter.importIfNeeded() 호출 → 새 JSON 재시드. /// - user 테이블 (Habits, Phases, TrackerEntries, ...) 무변화. /// /// `onUpgrade` 에서 dispatch. 테스트는 직접 호출. Future migrateV1ToV2(Migrator m, AppDatabase db) async { await m.deleteTable(db.protocols.actualTableName); await m.createTable(db.protocols); await m.createIndex(Index('IDX_protocols_category', 'CREATE INDEX IDX_protocols_category ON protocols(category)')); await (db.delete(db.metaKv)..where((t) => t.key.equals(kSeededV1Flag))).go(); }