From e38dd75f69a3b54af4dbbae6d683aaf1acd2d8ef Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 18 Jul 2026 00:53:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20pr=C3=A9paration=20=C3=A0=20la=20?= =?UTF-8?q?synchronisation=20cloud=20(ticket=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajuste les ports et le bootstrap applicatif, ainsi que le mapping Drift des repositories, pour préparer une future synchronisation cloud (invariants documentés dans docs/sync-invariants.md). Corrige au passage un bug de fuseau horaire UTC dans le mapping Drift. flutter analyze propre, 35/35 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- docs/sync-invariants.md | 34 ++ lib/application/app_bootstrap.dart | 3 + lib/application/ports.dart | 23 + .../local/drift_repositories.dart | 541 ++++++++++++++---- .../drift_repositories_test.dart | 124 +++- 5 files changed, 603 insertions(+), 122 deletions(-) create mode 100644 docs/sync-invariants.md diff --git a/docs/sync-invariants.md b/docs/sync-invariants.md new file mode 100644 index 0000000..fbedf81 --- /dev/null +++ b/docs/sync-invariants.md @@ -0,0 +1,34 @@ +# Sync invariants + +GameTime remains offline-first. The current sync surface is preparatory only: +there is no server protocol, remote auth, or network adapter yet. + +## Local mutation log + +Every local mutation of a syncable aggregate must update the row metadata and +append a `change_log` entry in the same local transaction. + +Required row metadata: +- `id` is stable and generated locally. +- `updatedAt` is the logical mutation timestamp. +- `syncState` is `dirty` for local writes and `deleted` for soft deletes. +- `localRevision` increases when an existing row changes. +- `originDeviceId` identifies the device that produced the row mutation. +- `futureOwnerProfileId` is nullable until user profiles exist. + +`change_log.operation` currently supports `insert`, `update`, `softDelete`, and +`restore`. Exercise archive/unarchive is represented as `update` because the row +is not deleted and the current schema has no `archive` operation. + +## Future conflict policy + +Conflict resolution is not implemented yet. The intended first server strategy is +last-write-wins per entity using `updatedAt`, with `localRevision` and +`originDeviceId` retained for diagnostics and later upgrade. Vector clocks are not +implemented and are not assumed by the current model. + +## Future profiles + +The profile boundary will attach to `futureOwnerProfileId`. Until profile support +is added, rows keep it nullable and sync code must not infer ownership from local +device IDs. diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 76a6116..9044530 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -11,6 +11,7 @@ final class AppBootstrap { required this.activeWorkoutSessionUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, + required this.syncGateway, }); final AppDatabase database; @@ -21,6 +22,7 @@ final class AppBootstrap { final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final WorkoutHistoryUseCases workoutHistoryUseCases; + final SyncGateway syncGateway; static Future create() async { final database = AppDatabase.open(); @@ -83,6 +85,7 @@ final class AppBootstrap { repository: historyRepository, clock: clock, ), + syncGateway: const NoOpSyncGateway(), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 83f1981..5c46e1e 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -92,3 +92,26 @@ abstract interface class WorkoutHistoryRepository { Future saveSetResult(WorkoutHistorySetResult result); Future delete(String id, DateTime deletedAt); } + +final class SyncRunSummary { + const SyncRunSummary({ + required this.pushedChanges, + required this.pulledChanges, + }); + + final int pushedChanges; + final int pulledChanges; +} + +abstract interface class SyncGateway { + Future synchronize(); +} + +final class NoOpSyncGateway implements SyncGateway { + const NoOpSyncGateway(); + + @override + Future synchronize() async { + return const SyncRunSummary(pushedChanges: 0, pulledChanges: 0); + } +} diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 6931ec2..f479db5 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -45,9 +45,15 @@ final class DriftExerciseRepository implements ExerciseRepository { @override Future save(domain.Exercise exercise) async { - await database - .into(database.exercises) - .insertOnConflictUpdate(_exerciseCompanion(exercise)); + await _upsertWithChangeLog( + database: database, + tableName: 'exercises', + entityType: 'Exercise', + metadata: exercise.metadata, + write: () => database + .into(database.exercises) + .insertOnConflictUpdate(_exerciseCompanion(exercise)), + ); } } @@ -74,9 +80,15 @@ final class DriftMediaAssetRepository implements MediaAssetRepository { @override Future save(domain.MediaAsset mediaAsset) async { - await database - .into(database.mediaAssets) - .insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset)); + await _upsertWithChangeLog( + database: database, + tableName: 'media_assets', + entityType: 'MediaAsset', + metadata: mediaAsset.metadata, + write: () => database + .into(database.mediaAssets) + .insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset)), + ); } } @@ -133,19 +145,33 @@ final class DriftProgramRepository implements ProgramRepository { @override Future save(domain.Program program) async { - await database - .into(database.programs) - .insertOnConflictUpdate(_programCompanion(program)); - for (final exercise in program.exercises) { - await saveExercise(exercise); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'programs', + entityType: 'Program', + metadata: program.metadata, + write: () => database + .into(database.programs) + .insertOnConflictUpdate(_programCompanion(program)), + ); + for (final exercise in program.exercises) { + await saveExercise(exercise); + } + }); } @override Future saveExercise(domain.ProgramExercise exercise) async { - await database - .into(database.programExercises) - .insertOnConflictUpdate(_programExerciseCompanion(exercise)); + await _upsertWithChangeLog( + database: database, + tableName: 'program_exercises', + entityType: 'ProgramExercise', + metadata: exercise.metadata, + write: () => database + .into(database.programExercises) + .insertOnConflictUpdate(_programExerciseCompanion(exercise)), + ); } @override @@ -158,26 +184,25 @@ final class DriftProgramRepository implements ProgramRepository { final activeIds = program.exercises .map((exercise) => exercise.metadata.id) .toSet(); - final update = database.update(database.programExercises); + final staleQuery = database.select(database.programExercises); if (activeIds.isEmpty) { - update.where( + staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull(), ); } else { - update.where( + staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeIds), ); } - await update.write( - db.ProgramExercisesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteProgramExerciseRows( + database, + await staleQuery.get(), + deletedAt, ); }); } @@ -244,33 +269,53 @@ final class DriftWorkoutTemplateRepository @override Future save(domain.WorkoutTemplate template) async { - await database - .into(database.workoutTemplates) - .insertOnConflictUpdate(_workoutTemplateCompanion(template)); - for (final program in template.programs) { - await saveProgram(program); - } - for (final override in template.overrides) { - await saveOverride(override); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'workout_templates', + entityType: 'WorkoutTemplate', + metadata: template.metadata, + write: () => database + .into(database.workoutTemplates) + .insertOnConflictUpdate(_workoutTemplateCompanion(template)), + ); + for (final program in template.programs) { + await saveProgram(program); + } + for (final override in template.overrides) { + await saveOverride(override); + } + }); } @override Future saveProgram(domain.WorkoutTemplateProgram program) async { - await database - .into(database.workoutTemplatePrograms) - .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_template_programs', + entityType: 'WorkoutTemplateProgram', + metadata: program.metadata, + write: () => database + .into(database.workoutTemplatePrograms) + .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)), + ); } @override Future saveOverride( domain.WorkoutTemplateExerciseOverride override, ) async { - await database - .into(database.workoutTemplateExerciseOverrides) - .insertOnConflictUpdate( - _workoutTemplateExerciseOverrideCompanion(override), - ); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_template_exercise_overrides', + entityType: 'WorkoutTemplateExerciseOverride', + metadata: override.metadata, + write: () => database + .into(database.workoutTemplateExerciseOverrides) + .insertOnConflictUpdate( + _workoutTemplateExerciseOverrideCompanion(override), + ), + ); } @override @@ -293,26 +338,27 @@ final class DriftWorkoutTemplateRepository final activeProgramIds = template.programs .map((program) => program.metadata.id) .toSet(); - final programUpdate = database.update(database.workoutTemplatePrograms); + final staleProgramsQuery = database.select( + database.workoutTemplatePrograms, + ); if (activeProgramIds.isEmpty) { - programUpdate.where( + staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull(), ); } else { - programUpdate.where( + staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeProgramIds), ); } - await programUpdate.write( - db.WorkoutTemplateProgramsCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteWorkoutTemplateProgramRows( + database, + await staleProgramsQuery.get(), + deletedAt, ); final activeOverrideIds = template.overrides @@ -322,28 +368,27 @@ final class DriftWorkoutTemplateRepository if (relevantProgramIds.isEmpty) { return; } - final overrideUpdate = database.update( + final staleOverridesQuery = database.select( database.workoutTemplateExerciseOverrides, ); if (activeOverrideIds.isEmpty) { - overrideUpdate.where( + staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull(), ); } else { - overrideUpdate.where( + staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull() & table.id.isNotIn(activeOverrideIds), ); } - await overrideUpdate.write( - db.WorkoutTemplateExerciseOverridesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteWorkoutTemplateExerciseOverrideRows( + database, + await staleOverridesQuery.get(), + deletedAt, ); }); } @@ -379,23 +424,41 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { @override Future save(domain.ActiveWorkoutSession session) async { - await database - .into(database.activeWorkoutSessions) - .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_workout_sessions', + entityType: 'ActiveWorkoutSession', + metadata: session.metadata, + write: () => database + .into(database.activeWorkoutSessions) + .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)), + ); } @override Future saveSetResult(domain.ActiveSetResult result) async { - await database - .into(database.activeSetResults) - .insertOnConflictUpdate(_activeSetResultCompanion(result)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_set_results', + entityType: 'ActiveSetResult', + metadata: result.metadata, + write: () => database + .into(database.activeSetResults) + .insertOnConflictUpdate(_activeSetResultCompanion(result)), + ); } @override Future saveRestState(domain.ActiveRestState restState) async { - await database - .into(database.activeRestStates) - .insertOnConflictUpdate(_activeRestStateCompanion(restState)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_rest_states', + entityType: 'ActiveRestState', + metadata: restState.metadata, + write: () => database + .into(database.activeRestStates) + .insertOnConflictUpdate(_activeRestStateCompanion(restState)), + ); } @override @@ -484,71 +547,307 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(domain.WorkoutHistory history) async { - await database - .into(database.workoutHistories) - .insertOnConflictUpdate(_workoutHistoryCompanion(history)); - for (final result in history.results) { - await saveSetResult(result); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'workout_history', + entityType: 'WorkoutHistory', + metadata: history.metadata, + write: () => database + .into(database.workoutHistories) + .insertOnConflictUpdate(_workoutHistoryCompanion(history)), + ); + for (final result in history.results) { + await saveSetResult(result); + } + }); } @override Future saveSetResult(domain.WorkoutHistorySetResult result) async { - await database - .into(database.workoutHistorySetResults) - .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_history_set_results', + entityType: 'WorkoutHistorySetResult', + metadata: result.metadata, + write: () => database + .into(database.workoutHistorySetResults) + .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)), + ); } @override Future delete(String id, DateTime deletedAt) async { await database.transaction(() async { + final history = await (database.select( + database.workoutHistories, + )..where((table) => table.id.equals(id))).getSingle(); + final revision = history.localRevision + 1; await (database.update( database.workoutHistories, )..where((table) => table.id.equals(id))).write( db.WorkoutHistoriesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), ), ); - await (database.update( - database.workoutHistorySetResults, - )..where((table) => table.workoutHistoryId.equals(id))).write( - db.WorkoutHistorySetResultsCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _writeChangeLog( + database: database, + entityType: 'WorkoutHistory', + entityId: id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: history.originDeviceId, + createdAt: deletedAt, + ); + final resultRows = + await (database.select(database.workoutHistorySetResults)..where( + (table) => + table.workoutHistoryId.equals(id) & + table.deletedAt.isNull(), + )) + .get(); + await _softDeleteWorkoutHistorySetResultRows( + database, + resultRows, + deletedAt, ); }); } } +Future _upsertWithChangeLog({ + required db.AppDatabase database, + required String tableName, + required String entityType, + required domain.EntityMetadata metadata, + required Future Function() write, +}) async { + await database.transaction(() async { + final operation = await _operationForMutation( + database: database, + tableName: tableName, + entityId: metadata.id, + deletedAt: metadata.deletedAt, + ); + await write(); + await _writeChangeLog( + database: database, + entityType: entityType, + entityId: metadata.id, + operation: operation, + localRevision: metadata.localRevision, + originDeviceId: metadata.originDeviceId, + createdAt: metadata.updatedAt, + ); + }); +} + +Future _operationForMutation({ + required db.AppDatabase database, + required String tableName, + required String entityId, + required DateTime? deletedAt, +}) async { + final existing = await database + .customSelect( + 'SELECT deleted_at FROM $tableName WHERE id = ? LIMIT 1', + variables: [Variable(entityId)], + ) + .getSingleOrNull(); + if (existing == null) { + return 'insert'; + } + final wasDeleted = existing.data['deleted_at'] != null; + if (deletedAt != null) { + return 'softDelete'; + } + if (wasDeleted) { + return 'restore'; + } + return 'update'; +} + +Future _writeChangeLog({ + required db.AppDatabase database, + required String entityType, + required String entityId, + required String operation, + required int localRevision, + required String originDeviceId, + required DateTime createdAt, +}) async { + final id = [ + entityType, + entityId, + operation, + localRevision, + createdAt.microsecondsSinceEpoch, + ].join(':'); + await database + .into(database.changeLogEntries) + .insertOnConflictUpdate( + db.ChangeLogEntriesCompanion.insert( + id: id, + entityType: entityType, + entityId: entityId, + operation: operation, + localRevision: localRevision, + originDeviceId: originDeviceId, + createdAt: createdAt.toUtc(), + ), + ); +} + +Future _softDeleteProgramExerciseRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.programExercises, + )..where((table) => table.id.equals(row.id))).write( + db.ProgramExercisesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'ProgramExercise', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutTemplateProgramRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutTemplatePrograms, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutTemplateProgramsCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutTemplateProgram', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutTemplateExerciseOverrideRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutTemplateExerciseOverrides, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutTemplateExerciseOverridesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutTemplateExerciseOverride', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutHistorySetResultRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutHistorySetResults, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutHistorySetResultsCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutHistorySetResult', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +DateTime _utc(DateTime value) => value.toUtc(); + +DateTime? _utcOrNull(DateTime? value) => value?.toUtc(); + domain.EntityMetadata _metadataFromRow(dynamic row) { return domain.EntityMetadata( id: row.id as String, - createdAt: row.createdAt as DateTime, - updatedAt: row.updatedAt as DateTime, - deletedAt: row.deletedAt as DateTime?, + createdAt: _utc(row.createdAt as DateTime), + updatedAt: _utc(row.updatedAt as DateTime), + deletedAt: _utcOrNull(row.deletedAt as DateTime?), schemaVersion: row.schemaVersion as int, syncState: _syncStateFromDb(row.syncState as String), localRevision: row.localRevision as int, originDeviceId: row.originDeviceId as String, futureOwnerProfileId: row.futureOwnerProfileId as String?, - lastSyncedAt: row.lastSyncedAt as DateTime?, + lastSyncedAt: _utcOrNull(row.lastSyncedAt as DateTime?), remoteRevision: row.remoteRevision as String?, ); } List _metadataValues(domain.EntityMetadata metadata) => [ Value(metadata.id), - Value(metadata.createdAt), - Value(metadata.updatedAt), - Value(metadata.deletedAt), + Value(metadata.createdAt.toUtc()), + Value(metadata.updatedAt.toUtc()), + Value(_utcOrNull(metadata.deletedAt)), Value(metadata.schemaVersion), Value(_syncStateToDb(metadata.syncState)), Value(metadata.localRevision), Value(metadata.originDeviceId), Value(metadata.futureOwnerProfileId), - Value(metadata.lastSyncedAt), + Value(_utcOrNull(metadata.lastSyncedAt)), Value(metadata.remoteRevision), ]; @@ -575,7 +874,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { hasScoreMeasure: Value(exercise.hasScoreMeasure), scoreLabel: Value(exercise.scoreLabel), scoreUnit: Value(exercise.scoreUnit), - archivedAt: Value(exercise.archivedAt), + archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } @@ -591,7 +890,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row) { hasScoreMeasure: row.hasScoreMeasure, scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, - archivedAt: row.archivedAt, + archivedAt: _utcOrNull(row.archivedAt), ); } @@ -753,7 +1052,7 @@ db.WorkoutTemplatesCompanion _workoutTemplateCompanion( lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, name: Value(template.name), - lastStartedAt: Value(template.lastStartedAt), + lastStartedAt: Value(_utcOrNull(template.lastStartedAt)), ); } @@ -765,7 +1064,7 @@ domain.WorkoutTemplate _workoutTemplateFromRow( return domain.WorkoutTemplate( metadata: _metadataFromRow(row), name: row.name, - lastStartedAt: row.lastStartedAt, + lastStartedAt: _utcOrNull(row.lastStartedAt), programs: programs, overrides: overrides, ); @@ -868,10 +1167,10 @@ db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion( remoteRevision: values[10] as Value, sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId), status: Value(_activeStatusToDb(session.status)), - startedAt: Value(session.startedAt), - pausedAt: Value(session.pausedAt), - endedAt: Value(session.endedAt), - lastPersistedAt: Value(session.lastPersistedAt), + startedAt: Value(session.startedAt.toUtc()), + pausedAt: Value(_utcOrNull(session.pausedAt)), + endedAt: Value(_utcOrNull(session.endedAt)), + lastPersistedAt: Value(session.lastPersistedAt.toUtc()), elapsedActiveMs: Value(session.elapsedActiveMs), currentProgramIndex: Value(session.currentProgramIndex), currentExerciseIndex: Value(session.currentExerciseIndex), @@ -887,10 +1186,10 @@ domain.ActiveWorkoutSession _activeWorkoutSessionFromRow( metadata: _metadataFromRow(row), sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, status: _activeStatusFromDb(row.status), - startedAt: row.startedAt, - pausedAt: row.pausedAt, - endedAt: row.endedAt, - lastPersistedAt: row.lastPersistedAt, + startedAt: _utc(row.startedAt), + pausedAt: _utcOrNull(row.pausedAt), + endedAt: _utcOrNull(row.endedAt), + lastPersistedAt: _utc(row.lastPersistedAt), elapsedActiveMs: row.elapsedActiveMs, currentProgramIndex: row.currentProgramIndex, currentExerciseIndex: row.currentExerciseIndex, @@ -921,8 +1220,8 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion( programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), - startedAt: Value(result.startedAt), - completedAt: Value(result.completedAt), + startedAt: Value(_utcOrNull(result.startedAt)), + completedAt: Value(_utcOrNull(result.completedAt)), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), @@ -942,8 +1241,8 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) { programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, - startedAt: row.startedAt, - completedAt: row.completedAt, + startedAt: _utcOrNull(row.startedAt), + completedAt: _utcOrNull(row.completedAt), actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, @@ -976,9 +1275,9 @@ db.ActiveRestStatesCompanion _activeRestStateCompanion( afterSetIndex: Value(restState.afterSetIndex), plannedRestSeconds: Value(restState.plannedRestSeconds), adjustedRestSeconds: Value(restState.adjustedRestSeconds), - startedAt: Value(restState.startedAt), - endedAt: Value(restState.endedAt), - skippedAt: Value(restState.skippedAt), + startedAt: Value(restState.startedAt.toUtc()), + endedAt: Value(_utcOrNull(restState.endedAt)), + skippedAt: Value(_utcOrNull(restState.skippedAt)), ); } @@ -991,9 +1290,9 @@ domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) { afterSetIndex: row.afterSetIndex, plannedRestSeconds: row.plannedRestSeconds, adjustedRestSeconds: row.adjustedRestSeconds, - startedAt: row.startedAt, - endedAt: row.endedAt, - skippedAt: row.skippedAt, + startedAt: _utc(row.startedAt), + endedAt: _utcOrNull(row.endedAt), + skippedAt: _utcOrNull(row.skippedAt), ); } @@ -1016,8 +1315,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion( sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId), sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId), nameSnapshot: Value(history.nameSnapshot), - startedAt: Value(history.startedAt), - endedAt: Value(history.endedAt), + startedAt: Value(history.startedAt.toUtc()), + endedAt: Value(history.endedAt.toUtc()), totalActiveMs: Value(history.totalActiveMs), completed: Value(history.completed), historySnapshotJson: Value(history.historySnapshotJson), @@ -1059,8 +1358,8 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( actualScore: Value(result.actualScore), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), - startedAt: Value(result.startedAt), - completedAt: Value(result.completedAt), + startedAt: Value(_utcOrNull(result.startedAt)), + completedAt: Value(_utcOrNull(result.completedAt)), status: Value(_setResultStatusToDb(result.status)), ); } @@ -1074,8 +1373,8 @@ domain.WorkoutHistory _workoutHistoryFromRow( sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId, nameSnapshot: row.nameSnapshot, - startedAt: row.startedAt, - endedAt: row.endedAt, + startedAt: _utc(row.startedAt), + endedAt: _utc(row.endedAt), totalActiveMs: row.totalActiveMs, completed: row.completed, historySnapshotJson: row.historySnapshotJson, @@ -1107,8 +1406,8 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( actualScore: row.actualScore, scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, - startedAt: row.startedAt, - completedAt: row.completedAt, + startedAt: _utcOrNull(row.startedAt), + completedAt: _utcOrNull(row.completedAt), status: _setResultStatusFromDb(row.status), ); } diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index be44f69..663da31 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; @@ -8,12 +9,16 @@ import 'package:gametime/infrastructure/local/local.dart' as local; void main() { late local.AppDatabase database; + late local.DriftExerciseRepository exerciseRepository; + late local.DriftProgramRepository programRepository; late local.DriftActiveSessionRepository activeRepository; late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); + exerciseRepository = local.DriftExerciseRepository(database); + programRepository = local.DriftProgramRepository(database); activeRepository = local.DriftActiveSessionRepository(database); templateRepository = local.DriftWorkoutTemplateRepository(database); historyRepository = local.DriftWorkoutHistoryRepository(database); @@ -23,6 +28,102 @@ void main() { await database.close(); }); + test('exercise mutations are written to change log', () async { + final now = DateTime.utc(2026, 7, 17, 12); + final exercise = Exercise( + metadata: _metadata('exercise-1', now), + name: 'Squat', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ); + + await exerciseRepository.save(exercise); + await exerciseRepository.save( + exercise.copyWith( + metadata: _metadata( + 'exercise-1', + now.add(const Duration(seconds: 1)), + 1, + ), + name: 'Front squat', + ), + ); + await exerciseRepository.save( + exercise + .copyWith( + metadata: _metadata( + 'exercise-1', + now.add(const Duration(seconds: 2)), + 2, + ), + name: 'Front squat', + ) + .archive(now.add(const Duration(seconds: 2))), + ); + + final changes = + await (database.select(database.changeLogEntries) + ..where((table) => table.entityId.equals('exercise-1')) + ..orderBy([ + (table) => drift.OrderingTerm.asc(table.localRevision), + ])) + .get(); + + expect(changes.map((change) => change.operation), [ + 'insert', + 'update', + 'update', + ]); + expect(changes.every((change) => change.entityType == 'Exercise'), isTrue); + }); + + test( + 'removing a program exercise writes a soft delete change log entry', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + final first = _programExercise('program-exercise-1', now, position: 0); + final second = _programExercise('program-exercise-2', now, position: 1); + await programRepository.save( + Program( + metadata: _metadata('program-1', now), + name: 'Jambes', + defaultRestSeconds: 60, + exercises: [first, second], + ), + ); + + final deletedAt = now.add(const Duration(minutes: 1)); + await programRepository.replaceExercises( + Program( + metadata: _metadata('program-1', deletedAt, 1), + name: 'Jambes', + defaultRestSeconds: 60, + exercises: [first], + ), + deletedAt, + ); + + final deletedRow = await (database.select( + database.programExercises, + )..where((table) => table.id.equals('program-exercise-2'))).getSingle(); + final change = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityType.equals('ProgramExercise') & + table.entityId.equals('program-exercise-2') & + table.operation.equals('softDelete'), + )) + .getSingleOrNull(); + + expect(deletedRow.deletedAt?.toUtc(), deletedAt); + expect(deletedRow.syncState, 'deleted'); + expect(deletedRow.localRevision, 1); + expect(change, isNotNull); + expect(change!.localRevision, 1); + }, + ); + test( 'running session elapsed time survives repository reconstruction', () async { @@ -167,12 +268,33 @@ String _resolvedSnapshot() { }); } -EntityMetadata _metadata(String id, DateTime now) { +EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) { return EntityMetadata( id: id, createdAt: now, updatedAt: now, originDeviceId: 'device-1', + localRevision: localRevision, + ); +} + +ProgramExercise _programExercise( + String id, + DateTime now, { + required int position, +}) { + return ProgramExercise( + metadata: _metadata(id, now), + programId: 'program-1', + position: position, + exerciseNameSnapshot: 'Exercise $position', + availableTimeSnapshot: false, + availableRepsSnapshot: true, + availableScoreSnapshot: false, + setsCount: 1, + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, ); }