import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'tables.dart'; part 'app_database.g.dart'; @DriftDatabase( tables: [ ActiveExerciseStepProgressStates, ActiveExerciseStepResults, ActiveRestStates, ActiveManualScoreStates, ActiveScoreStopwatchStates, ActiveSetTimerStates, ActiveSetResults, ActiveWorkoutSessions, ChangeLogEntries, Exercises, ExerciseImages, ExerciseSteps, LocalSeedMetadata, MediaAssets, OnlineAccountSessions, PendingShareActions, ProgramExercises, Programs, RemoteResourceMappings, ShareInboxItems, SyncMetadataEntries, WorkoutHistories, WorkoutHistorySetResults, WorkoutHistoryStepResults, WorkoutTemplateExerciseOverrides, WorkoutTemplatePrograms, WorkoutTemplates, ], ) final class AppDatabase extends _$AppDatabase { AppDatabase(super.executor); factory AppDatabase.open() { return AppDatabase( driftDatabase( name: 'gametime', native: const DriftNativeOptions(shareAcrossIsolates: true), ), ); } @override int get schemaVersion => 22; @override MigrationStrategy get migration { return MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); await _migrateToSchema15(); await _migrateToSchema16(migrator); await _createIndexes(); }, onUpgrade: (migrator, from, to) async { if (from < 2) { await customStatement( 'ALTER TABLE active_set_results ADD COLUMN status TEXT NOT NULL ' "DEFAULT 'completed' CHECK (status IN ('completed', 'skipped'))", ); await customStatement( 'ALTER TABLE workout_history_set_results ADD COLUMN status TEXT ' "NOT NULL DEFAULT 'completed' CHECK (status IN ('completed', " "'skipped'))", ); } if (from < 3) { await _migrateToSchema3(migrator); } if (from < 4) { await _migrateToSchema4(); } if (from < 5) { await _migrateToSchema5(migrator); } if (from < 6) { await _migrateToSchema6(); } if (from < 7) { await _migrateToSchema7(); } if (from < 8) { await _migrateToSchema8(migrator); } if (from < 9) { await _migrateToSchema9(migrator); } if (from < 10) { await _migrateToSchema10(migrator); } if (from < 11) { await _migrateToSchema11(migrator); } if (from < 12) { await _migrateToSchema12(migrator); } if (from < 13) { await _migrateToSchema13(); } if (from < 14) { await _migrateToSchema14(); } if (from < 15) { await _migrateToSchema15(); } if (from < 16) { await _migrateToSchema16(migrator); } if (from < 17) { await _migrateToSchema17(); } if (from < 19) { await _migrateToSchema19(); } if (from < 20) { await _migrateToSchema20(migrator); } if (from < 21) { await _migrateToSchema21(); } if (from < 22) { await _migrateToSchema22(); } await _createIndexes(); }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); }, ); } Future _createIndexes() async { for (final tableName in _syncableTableNames) { await customStatement( 'CREATE INDEX IF NOT EXISTS idx_${tableName}_deleted_at ' 'ON $tableName (deleted_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_${tableName}_updated_at ' 'ON $tableName (updated_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_${tableName}_sync_state ' 'ON $tableName (sync_state)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_${tableName}_local_revision ' 'ON $tableName (local_revision)', ); } await customStatement( 'CREATE INDEX IF NOT EXISTS idx_program_exercises_program_id ' 'ON program_exercises (program_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id ' 'ON exercise_images (exercise_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position ' 'ON exercise_steps (exercise_id, position)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_exercises_category ' 'ON exercises (category)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_exercises_is_example ' 'ON exercises (is_example)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_programs_is_example ' 'ON programs (is_example)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_templates_is_example ' 'ON workout_templates (is_example)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in ' 'ON online_account_sessions (is_logged_in, updated_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource ' 'ON remote_resource_mappings (resource_type, client_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_share_inbox_items_created_at ' 'ON share_inbox_items (created_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_pending_share_actions_status ' 'ON pending_share_actions (status, created_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'ON workout_template_programs (workout_template_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id ' 'ON active_set_results (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id ' 'ON active_manual_score_states (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id ' 'ON active_score_stopwatch_states (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_set_timer_states_session_id ' 'ON active_set_timer_states (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id ' 'ON active_rest_states (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS ' 'idx_active_exercise_step_progress_states_session_id ' 'ON active_exercise_step_progress_states (active_workout_session_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id ' 'ON active_exercise_step_results (active_workout_session_id)', ); await customStatement( 'CREATE UNIQUE INDEX IF NOT EXISTS ' 'idx_active_workout_sessions_single_open ' 'ON active_workout_sessions ((1)) ' 'WHERE deleted_at IS NULL ' "AND status IN ('running', 'paused', 'savedExit')", ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at ' 'ON workout_history (started_at)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_completed_started ' 'ON workout_history (completed, started_at) ' 'WHERE deleted_at IS NULL', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id ' 'ON workout_history_set_results (workout_history_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_history_set_progression_exercise ' 'ON workout_history_set_results (source_exercise_id_snapshot, ' 'exercise_snapshot_id, workout_history_id, status) ' 'WHERE deleted_at IS NULL', ); await customStatement( 'CREATE INDEX IF NOT EXISTS ' 'idx_workout_history_set_results_source_exercise ' 'ON workout_history_set_results (source_exercise_id_snapshot, ' 'set_index) WHERE deleted_at IS NULL AND ' 'source_exercise_id_snapshot IS NOT NULL', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id ' 'ON workout_history_step_results (workout_history_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_history_step_progression_exercise ' 'ON workout_history_step_results (source_exercise_id_snapshot, ' 'exercise_snapshot_id, workout_history_id, status) ' 'WHERE deleted_at IS NULL', ); await customStatement( 'CREATE INDEX IF NOT EXISTS ' 'idx_workout_history_step_results_source_exercise ' 'ON workout_history_step_results (source_exercise_id_snapshot, ' 'set_index, step_index) WHERE deleted_at IS NULL AND ' 'source_exercise_id_snapshot IS NOT NULL', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_change_log_entity ' 'ON change_log (entity_type, entity_id)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_change_log_local_revision ' 'ON change_log (local_revision)', ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_change_log_synced_at ' 'ON change_log (synced_at)', ); } } const _syncableTableNames = [ 'active_exercise_step_progress_states', 'active_exercise_step_results', 'active_manual_score_states', 'active_rest_states', 'active_score_stopwatch_states', 'active_set_timer_states', 'active_set_results', 'active_workout_sessions', 'exercises', 'exercise_images', 'exercise_steps', 'media_assets', 'program_exercises', 'programs', 'workout_history', 'workout_history_set_results', 'workout_history_step_results', 'workout_template_exercise_overrides', 'workout_template_programs', 'workout_templates', ]; extension on AppDatabase { Future _migrateToSchema3(Migrator migrator) async { await customStatement( 'ALTER TABLE exercises ADD COLUMN score_input_mode TEXT NOT NULL ' "DEFAULT 'manual' CHECK (score_input_mode IN ('manual', 'stopwatch'))", ); await customStatement( 'ALTER TABLE program_exercises ADD COLUMN score_input_mode_snapshot TEXT ' "NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN " "('manual', 'stopwatch'))", ); await customStatement( 'ALTER TABLE program_exercises ADD COLUMN target_score_time_ms INTEGER ' 'CHECK (target_score_time_ms IS NULL OR target_score_time_ms > 0)', ); await customStatement( 'ALTER TABLE workout_template_exercise_overrides ADD COLUMN ' 'target_score_time_ms_override INTEGER CHECK ' '(target_score_time_ms_override IS NULL OR ' 'target_score_time_ms_override > 0)', ); await customStatement( 'ALTER TABLE active_set_results ADD COLUMN actual_score_time_ms INTEGER ' 'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)', ); await customStatement( 'ALTER TABLE active_set_results ADD COLUMN score_input_mode_snapshot ' "TEXT NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN " "('manual', 'stopwatch'))", ); await customStatement( 'ALTER TABLE workout_history_set_results ADD COLUMN ' 'score_input_mode_snapshot TEXT NOT NULL DEFAULT ' "'manual' CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))", ); await customStatement( 'ALTER TABLE workout_history_set_results ADD COLUMN ' 'target_score_time_ms_snapshot INTEGER CHECK ' '(target_score_time_ms_snapshot IS NULL OR ' 'target_score_time_ms_snapshot > 0)', ); await customStatement( 'ALTER TABLE workout_history_set_results ADD COLUMN ' 'actual_score_time_ms INTEGER CHECK (actual_score_time_ms IS NULL OR ' 'actual_score_time_ms >= 0)', ); await migrator.createTable(activeScoreStopwatchStates); } Future _migrateToSchema4() async { await customStatement( 'ALTER TABLE exercises ADD COLUMN default_target_time_seconds INTEGER ' 'CHECK (default_target_time_seconds IS NULL OR ' 'default_target_time_seconds > 0)', ); await customStatement( 'ALTER TABLE exercises ADD COLUMN default_target_reps INTEGER ' 'CHECK (default_target_reps IS NULL OR default_target_reps > 0)', ); await customStatement( 'ALTER TABLE exercises ADD COLUMN default_target_score REAL ' 'CHECK (default_target_score IS NULL OR default_target_score > 0)', ); await customStatement( 'ALTER TABLE exercises ADD COLUMN default_target_score_time_ms INTEGER ' 'CHECK (default_target_score_time_ms IS NULL OR ' 'default_target_score_time_ms > 0)', ); } Future _migrateToSchema5(Migrator migrator) async { await migrator.createTable(exerciseImages); await customStatement( 'INSERT OR IGNORE INTO exercise_images (id, created_at, updated_at, ' 'deleted_at, schema_version, sync_state, local_revision, ' 'origin_device_id, future_owner_profile_id, last_synced_at, ' 'remote_revision, exercise_id, media_asset_id, position) ' "SELECT 'exercise-image:' || id || ':0', created_at, updated_at, " 'deleted_at, schema_version, sync_state, local_revision, ' 'origin_device_id, future_owner_profile_id, last_synced_at, ' 'remote_revision, id, image_media_id, 0 FROM exercises ' 'WHERE image_media_id IS NOT NULL', ); } Future _migrateToSchema6() async { await customStatement( 'ALTER TABLE program_exercises ADD COLUMN ' 'exercise_image_media_ids_snapshot_json TEXT', ); } Future _migrateToSchema7() async { await customStatement( 'ALTER TABLE exercises ADD COLUMN icon_media_id TEXT ' 'REFERENCES media_assets(id)', ); } Future _migrateToSchema8(Migrator migrator) async { await migrator.createTable(exerciseSteps); await customStatement( 'ALTER TABLE program_exercises ADD COLUMN ' 'exercise_steps_snapshot_json TEXT', ); } Future _migrateToSchema9(Migrator migrator) async { await migrator.createTable(activeExerciseStepProgressStates); await migrator.createTable(activeExerciseStepResults); await migrator.createTable(workoutHistoryStepResults); } Future _migrateToSchema10(Migrator migrator) async { await migrator.createTable(onlineAccountSessions); } Future _migrateToSchema11(Migrator migrator) async { await migrator.createTable(syncMetadataEntries); await migrator.createTable(remoteResourceMappings); } Future _migrateToSchema12(Migrator migrator) async { await migrator.createTable(shareInboxItems); await migrator.createTable(pendingShareActions); } Future _migrateToSchema13() async { await customStatement('PRAGMA foreign_keys = OFF'); await customStatement(''' CREATE TABLE exercises_new ( id TEXT NOT NULL PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER, schema_version INTEGER NOT NULL DEFAULT 1, sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), local_revision INTEGER NOT NULL CHECK (local_revision >= 0), origin_device_id TEXT NOT NULL, future_owner_profile_id TEXT, last_synced_at INTEGER, remote_revision TEXT, name TEXT NOT NULL, description TEXT, icon_media_id TEXT REFERENCES media_assets(id), video_media_id TEXT REFERENCES media_assets(id), has_time_measure INTEGER NOT NULL CHECK (has_time_measure IN (0, 1)), has_reps_measure INTEGER NOT NULL CHECK (has_reps_measure IN (0, 1)), has_score_measure INTEGER NOT NULL CHECK (has_score_measure IN (0, 1)), score_input_mode TEXT NOT NULL DEFAULT 'manual', score_label TEXT, score_unit TEXT, default_target_time_seconds INTEGER, default_target_reps INTEGER, default_target_score REAL, default_target_score_time_ms INTEGER, archived_at INTEGER, CHECK (length(trim(origin_device_id)) > 0), CHECK (length(trim(name)) > 0), CHECK (has_time_measure OR has_reps_measure OR has_score_measure), CHECK (score_input_mode IN ('manual', 'stopwatch')), CHECK (has_score_measure OR score_input_mode = 'manual'), CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR (score_label IS NOT NULL AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0)), CHECK (default_target_time_seconds IS NULL OR default_target_time_seconds > 0), CHECK (default_target_reps IS NULL OR default_target_reps > 0), CHECK (default_target_score IS NULL OR default_target_score >= 0), CHECK (default_target_score_time_ms IS NULL OR default_target_score_time_ms > 0), CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0) ) '''); await customStatement(''' INSERT INTO exercises_new ( id, created_at, updated_at, deleted_at, schema_version, sync_state, local_revision, origin_device_id, future_owner_profile_id, last_synced_at, remote_revision, name, description, icon_media_id, video_media_id, has_time_measure, has_reps_measure, has_score_measure, score_input_mode, score_label, score_unit, default_target_time_seconds, default_target_reps, default_target_score, default_target_score_time_ms, archived_at ) SELECT id, created_at, updated_at, deleted_at, schema_version, sync_state, local_revision, origin_device_id, future_owner_profile_id, last_synced_at, remote_revision, name, description, icon_media_id, video_media_id, has_time_measure, has_reps_measure, has_score_measure, score_input_mode, score_label, score_unit, default_target_time_seconds, default_target_reps, default_target_score, default_target_score_time_ms, archived_at FROM exercises '''); await customStatement('DROP TABLE exercises'); await customStatement('ALTER TABLE exercises_new RENAME TO exercises'); await customStatement('PRAGMA foreign_keys = ON'); } Future _migrateToSchema14() async { await customStatement( 'ALTER TABLE exercises ADD COLUMN auto_start_next_timed_step ' 'INTEGER NOT NULL DEFAULT 1 CHECK (auto_start_next_timed_step IN (0, 1))', ); await customStatement( 'ALTER TABLE program_exercises ADD COLUMN ' 'auto_start_next_timed_step_snapshot INTEGER NOT NULL DEFAULT 1 ' 'CHECK (auto_start_next_timed_step_snapshot IN (0, 1))', ); await customStatement( 'ALTER TABLE program_exercises ADD COLUMN ' 'auto_start_next_timed_step_override INTEGER ' 'CHECK (auto_start_next_timed_step_override IN (0, 1))', ); await customStatement( 'ALTER TABLE workout_template_exercise_overrides ADD COLUMN ' 'auto_start_next_timed_step_override INTEGER ' 'CHECK (auto_start_next_timed_step_override IN (0, 1))', ); } Future _migrateToSchema15() async { await customStatement('PRAGMA foreign_keys = OFF'); await customStatement(''' CREATE TABLE active_score_stopwatch_states_new ( id TEXT NOT NULL PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER, schema_version INTEGER NOT NULL DEFAULT 1, sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), local_revision INTEGER NOT NULL CHECK (local_revision >= 0), origin_device_id TEXT NOT NULL, future_owner_profile_id TEXT, last_synced_at INTEGER, remote_revision TEXT, active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id), program_index INTEGER NOT NULL, exercise_index INTEGER NOT NULL, set_index INTEGER NOT NULL, status TEXT NOT NULL, started_at INTEGER NOT NULL, accumulated_ms INTEGER NOT NULL, stopped_at INTEGER, UNIQUE (active_workout_session_id, program_index, exercise_index, set_index), CHECK (length(trim(origin_device_id)) > 0), CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0), CHECK (program_index >= 0), CHECK (exercise_index >= 0), CHECK (set_index >= 0), CHECK (status IN ('running', 'paused', 'stopped')), CHECK (accumulated_ms >= 0) ) '''); await customStatement(''' INSERT INTO active_score_stopwatch_states_new ( id, created_at, updated_at, deleted_at, schema_version, sync_state, local_revision, origin_device_id, future_owner_profile_id, last_synced_at, remote_revision, active_workout_session_id, program_index, exercise_index, set_index, status, started_at, accumulated_ms, stopped_at ) SELECT id, created_at, updated_at, deleted_at, schema_version, sync_state, local_revision, origin_device_id, future_owner_profile_id, last_synced_at, remote_revision, active_workout_session_id, program_index, exercise_index, set_index, status, started_at, accumulated_ms, stopped_at FROM active_score_stopwatch_states '''); await customStatement('DROP TABLE active_score_stopwatch_states'); await customStatement( 'ALTER TABLE active_score_stopwatch_states_new ' 'RENAME TO active_score_stopwatch_states', ); await customStatement('PRAGMA foreign_keys = ON'); await customStatement(''' CREATE TABLE IF NOT EXISTS active_set_timer_states ( id TEXT NOT NULL PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER, schema_version INTEGER NOT NULL DEFAULT 1, sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')), local_revision INTEGER NOT NULL CHECK (local_revision >= 0), origin_device_id TEXT NOT NULL, future_owner_profile_id TEXT, last_synced_at INTEGER, remote_revision TEXT, active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id), program_index INTEGER NOT NULL, exercise_index INTEGER NOT NULL, set_index INTEGER NOT NULL, status TEXT NOT NULL, started_at INTEGER, accumulated_ms INTEGER NOT NULL, stopped_at INTEGER, skipped_at INTEGER, UNIQUE (active_workout_session_id, program_index, exercise_index, set_index), CHECK (length(trim(origin_device_id)) > 0), CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0), CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0), CHECK (program_index >= 0), CHECK (exercise_index >= 0), CHECK (set_index >= 0), CHECK (status IN ('running', 'paused', 'stopped', 'skipped')), CHECK (status != 'running' OR started_at IS NOT NULL), CHECK (accumulated_ms >= 0), CHECK (stopped_at IS NULL OR skipped_at IS NULL) ) '''); if (!await _hasColumn('active_rest_states', 'paused_at')) { await customStatement( 'ALTER TABLE active_rest_states ADD COLUMN paused_at INTEGER', ); } if (!await _hasColumn('active_rest_states', 'accumulated_paused_ms')) { await customStatement( 'ALTER TABLE active_rest_states ADD COLUMN accumulated_paused_ms ' 'INTEGER NOT NULL DEFAULT 0 CHECK (accumulated_paused_ms >= 0)', ); } } Future _migrateToSchema16(Migrator migrator) async { await _addColumnIfMissing( tableName: 'exercises', columnName: 'category', definition: "category TEXT NOT NULL DEFAULT 'uncategorized' CHECK " "(category IN ('shoot', 'freeThrows', 'dribble', 'finishing', " "'conditioning', 'defense', 'mobility', 'uncategorized'))", ); await _addColumnIfMissing( tableName: 'exercises', columnName: 'is_example', definition: 'is_example INTEGER NOT NULL DEFAULT 0 ' 'CHECK (is_example IN (0, 1))', ); await _addColumnIfMissing( tableName: 'programs', columnName: 'is_example', definition: 'is_example INTEGER NOT NULL DEFAULT 0 ' 'CHECK (is_example IN (0, 1))', ); await _addColumnIfMissing( tableName: 'workout_templates', columnName: 'is_example', definition: 'is_example INTEGER NOT NULL DEFAULT 0 ' 'CHECK (is_example IN (0, 1))', ); await customStatement( 'CREATE TABLE IF NOT EXISTS local_seed_metadata (' 'key TEXT NOT NULL PRIMARY KEY, ' 'version INTEGER NOT NULL CHECK (version >= 0), ' 'applied_at INTEGER NOT NULL' ')', ); } Future _migrateToSchema17() async { await _addColumnIfMissing( tableName: 'workout_history_set_results', columnName: 'source_exercise_id_snapshot', definition: 'source_exercise_id_snapshot TEXT', ); await _addColumnIfMissing( tableName: 'workout_history_step_results', columnName: 'source_exercise_id_snapshot', definition: 'source_exercise_id_snapshot TEXT', ); await _backfillWorkoutHistorySetSourceExerciseIds(); await _backfillWorkoutHistoryStepSourceExerciseIds(); } Future _migrateToSchema19() async { await _addColumnIfMissing( tableName: 'exercises', columnName: 'tags_json', definition: "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", ); await _addColumnIfMissing( tableName: 'programs', columnName: 'tags_json', definition: "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", ); await _addColumnIfMissing( tableName: 'workout_templates', columnName: 'tags_json', definition: "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", ); } Future _migrateToSchema20(Migrator migrator) async { await migrator.createTable(activeManualScoreStates); } Future _migrateToSchema21() async { await _addColumnIfMissing( tableName: 'workout_history', columnName: 'average_heart_rate_bpm', definition: 'average_heart_rate_bpm REAL CHECK ' '(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)', ); await _addColumnIfMissing( tableName: 'workout_history', columnName: 'max_heart_rate_bpm', definition: 'max_heart_rate_bpm INTEGER CHECK ' '(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)', ); } Future _migrateToSchema22() async { await _addColumnIfMissing( tableName: 'exercise_steps', columnName: 'linked_to_series_score', definition: 'linked_to_series_score INTEGER NOT NULL DEFAULT 0 ' 'CHECK (linked_to_series_score IN (0, 1))', ); } Future _backfillWorkoutHistorySetSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_set_results AS result SET source_exercise_id_snapshot = ( SELECT json_extract(exercise.value, '$.sourceExerciseId') FROM workout_history AS history, json_each( COALESCE( json_extract( history.history_snapshot_json, '$.resolvedTemplateSnapshotJson' ), history.history_snapshot_json ), '$.programs' ) AS program, json_each( json_extract(program.value, '$.programSnapshotJson'), '$.exercises' ) AS exercise WHERE history.id = result.workout_history_id AND json_extract(program.value, '$.id') = result.program_snapshot_id AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id LIMIT 1 ) WHERE result.source_exercise_id_snapshot IS NULL '''); } Future _backfillWorkoutHistoryStepSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_step_results AS result SET source_exercise_id_snapshot = ( SELECT json_extract(exercise.value, '$.sourceExerciseId') FROM workout_history AS history, json_each( COALESCE( json_extract( history.history_snapshot_json, '$.resolvedTemplateSnapshotJson' ), history.history_snapshot_json ), '$.programs' ) AS program, json_each( json_extract(program.value, '$.programSnapshotJson'), '$.exercises' ) AS exercise WHERE history.id = result.workout_history_id AND json_extract(program.value, '$.id') = result.program_snapshot_id AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id LIMIT 1 ) WHERE result.source_exercise_id_snapshot IS NULL '''); } Future _addColumnIfMissing({ required String tableName, required String columnName, required String definition, }) async { final columns = await customSelect('PRAGMA table_info($tableName)').get(); final exists = columns.any((row) => row.data['name'] == columnName); if (!exists) { await customStatement('ALTER TABLE $tableName ADD COLUMN $definition'); } } Future _hasColumn(String tableName, String columnName) async { final rows = await customSelect('PRAGMA table_info($tableName)').get(); return rows.any((row) => row.data['name'] == columnName); } }