From e6b6903eb3f6561e36cae14c7668c063d134b889 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 09:43:48 +0200 Subject: [PATCH] fix(local): corrige contrainte CHECK change_log en fin de serie et contrainte UNIQUE active_exercise_step_progress_states (#190 #191) Correctifs regroupes: memes hunks de drift_repositories.dart concernes par les deux tickets, decoupage atomique par ticket non applicable proprement. Co-Authored-By: Claude Opus 4.8 --- .../local/drift_repositories.dart | 340 ++++++++++++++---- .../drift_repositories_test.dart | 182 ++++++++++ 2 files changed, 446 insertions(+), 76 deletions(-) diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 2e327ad..491465c 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1408,28 +1408,76 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { Future saveScoreStopwatchState( domain.ActiveScoreStopwatchState state, ) async { - await _upsertWithChangeLog( - database: database, - tableName: 'active_score_stopwatch_states', - entityType: 'ActiveScoreStopwatchState', - metadata: state.metadata, - write: () => database - .into(database.activeScoreStopwatchStates) - .insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)), - ); + await database.transaction(() async { + final existing = await _findActiveScoreStopwatchStateRowByPosition( + database, + state.activeWorkoutSessionId, + state.programIndex, + state.exerciseIndex, + state.setIndex, + ); + final effectiveState = existing == null + ? state + : state.copyWith( + metadata: _metadataWithStoredIdentity( + state.metadata, + _metadataFromRow(existing), + ), + ); + final operation = existing == null + ? 'insert' + : _operationForExistingMutation( + existingDeletedAt: existing.deletedAt, + deletedAt: effectiveState.metadata.deletedAt, + ); + await _upsertActiveScoreStopwatchState(database, effectiveState); + await _writeChangeLog( + database: database, + entityType: 'ActiveScoreStopwatchState', + entityId: effectiveState.metadata.id, + operation: operation, + localRevision: effectiveState.metadata.localRevision, + originDeviceId: effectiveState.metadata.originDeviceId, + createdAt: effectiveState.metadata.updatedAt, + ); + }); } @override Future saveManualScoreState(domain.ActiveManualScoreState state) async { - await _upsertWithChangeLog( - database: database, - tableName: 'active_manual_score_states', - entityType: 'ActiveManualScoreState', - metadata: state.metadata, - write: () => database - .into(database.activeManualScoreStates) - .insertOnConflictUpdate(_activeManualScoreStateCompanion(state)), - ); + await database.transaction(() async { + final existing = await _findActiveManualScoreStateRowByPosition( + database, + state.activeWorkoutSessionId, + state.programIndex, + state.exerciseIndex, + state.setIndex, + ); + final effectiveState = existing == null + ? state + : state.copyWith( + metadata: _metadataWithStoredIdentity( + state.metadata, + _metadataFromRow(existing), + ), + ); + final operation = existing == null + ? 'insert' + : _operationForExistingMutation( + existingDeletedAt: existing.deletedAt, + deletedAt: effectiveState.metadata.deletedAt, + ); + await _upsertActiveManualScoreState(database, effectiveState); + await _writeChangeLog( + database: database, + entityType: 'ActiveManualScoreState', + entityId: effectiveState.metadata.id, + operation: operation, + localRevision: effectiveState.metadata.localRevision, + originDeviceId: effectiveState.metadata.originDeviceId, + createdAt: effectiveState.metadata.updatedAt, + ); + }); } @override @@ -1486,14 +1534,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return; } final revision = row.localRevision + 1; - await (database.delete( + await (database.update( database.activeScoreStopwatchStates, - )..where((table) => table.id.equals(row.id))).go(); + )..where((table) => table.id.equals(row.id))).write( + db.ActiveScoreStopwatchStatesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); await _writeChangeLog( database: database, entityType: 'ActiveScoreStopwatchState', entityId: row.id, - operation: 'delete', + operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, @@ -1522,14 +1577,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return; } final revision = row.localRevision + 1; - await (database.delete( + await (database.update( database.activeManualScoreStates, - )..where((table) => table.id.equals(row.id))).go(); + )..where((table) => table.id.equals(row.id))).write( + db.ActiveManualScoreStatesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); await _writeChangeLog( database: database, entityType: 'ActiveManualScoreState', entityId: row.id, - operation: 'delete', + operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, @@ -4277,6 +4339,184 @@ Future _updateActiveRestPauseFields( ); } +Future +_findActiveScoreStopwatchStateRowByPosition( + db.AppDatabase database, + String sessionId, + int programIndex, + int exerciseIndex, + int setIndex, +) { + return (database.select(database.activeScoreStopwatchStates)..where( + (table) => + table.activeWorkoutSessionId.equals(sessionId) & + table.programIndex.equals(programIndex) & + table.exerciseIndex.equals(exerciseIndex) & + table.setIndex.equals(setIndex), + )) + .getSingleOrNull(); +} + +Future _findActiveManualScoreStateRowByPosition( + db.AppDatabase database, + String sessionId, + int programIndex, + int exerciseIndex, + int setIndex, +) { + return (database.select(database.activeManualScoreStates)..where( + (table) => + table.activeWorkoutSessionId.equals(sessionId) & + table.programIndex.equals(programIndex) & + table.exerciseIndex.equals(exerciseIndex) & + table.setIndex.equals(setIndex), + )) + .getSingleOrNull(); +} + +domain.EntityMetadata _metadataWithStoredIdentity( + domain.EntityMetadata metadata, + domain.EntityMetadata stored, +) { + return domain.EntityMetadata( + id: stored.id, + createdAt: stored.createdAt, + updatedAt: metadata.updatedAt, + deletedAt: metadata.deletedAt, + schemaVersion: metadata.schemaVersion, + syncState: metadata.syncState, + localRevision: metadata.localRevision, + originDeviceId: metadata.originDeviceId, + futureOwnerProfileId: metadata.futureOwnerProfileId, + lastSyncedAt: metadata.lastSyncedAt, + remoteRevision: metadata.remoteRevision, + ); +} + +String _operationForExistingMutation({ + required DateTime? existingDeletedAt, + required DateTime? deletedAt, +}) { + if (deletedAt != null) { + return 'softDelete'; + } + if (existingDeletedAt != null) { + return 'restore'; + } + return 'update'; +} + +Future _upsertActiveScoreStopwatchState( + db.AppDatabase database, + domain.ActiveScoreStopwatchState state, +) async { + final metadata = state.metadata; + await database.customInsert( + 'INSERT INTO active_score_stopwatch_states (' + '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' + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' + 'set_index) DO UPDATE SET ' + 'id = active_score_stopwatch_states.id, ' + 'created_at = active_score_stopwatch_states.created_at, ' + 'updated_at = excluded.updated_at, ' + 'deleted_at = excluded.deleted_at, ' + 'schema_version = excluded.schema_version, ' + 'sync_state = excluded.sync_state, ' + 'local_revision = excluded.local_revision, ' + 'origin_device_id = excluded.origin_device_id, ' + 'future_owner_profile_id = excluded.future_owner_profile_id, ' + 'last_synced_at = excluded.last_synced_at, ' + 'remote_revision = excluded.remote_revision, ' + 'active_workout_session_id = excluded.active_workout_session_id, ' + 'program_index = excluded.program_index, ' + 'exercise_index = excluded.exercise_index, ' + 'set_index = excluded.set_index, ' + 'status = excluded.status, ' + 'started_at = excluded.started_at, ' + 'accumulated_ms = excluded.accumulated_ms, ' + 'stopped_at = excluded.stopped_at', + variables: [ + Variable(metadata.id), + Variable(metadata.createdAt.toUtc()), + Variable(metadata.updatedAt.toUtc()), + Variable(_utcOrNull(metadata.deletedAt)), + Variable(metadata.schemaVersion), + Variable(_syncStateToDb(metadata.syncState)), + Variable(metadata.localRevision), + Variable(metadata.originDeviceId), + Variable(metadata.futureOwnerProfileId), + Variable(_utcOrNull(metadata.lastSyncedAt)), + Variable(metadata.remoteRevision), + Variable(state.activeWorkoutSessionId), + Variable(state.programIndex), + Variable(state.exerciseIndex), + Variable(state.setIndex), + Variable(_scoreStopwatchStatusToDb(state.status)), + Variable(state.startedAt.toUtc()), + Variable(state.accumulatedMs), + Variable(_utcOrNull(state.stoppedAt)), + ], + ); +} + +Future _upsertActiveManualScoreState( + db.AppDatabase database, + domain.ActiveManualScoreState state, +) async { + final metadata = state.metadata; + await database.customInsert( + 'INSERT INTO active_manual_score_states (' + '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, value, score_updated_at' + ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' + 'set_index) DO UPDATE SET ' + 'id = active_manual_score_states.id, ' + 'created_at = active_manual_score_states.created_at, ' + 'updated_at = excluded.updated_at, ' + 'deleted_at = excluded.deleted_at, ' + 'schema_version = excluded.schema_version, ' + 'sync_state = excluded.sync_state, ' + 'local_revision = excluded.local_revision, ' + 'origin_device_id = excluded.origin_device_id, ' + 'future_owner_profile_id = excluded.future_owner_profile_id, ' + 'last_synced_at = excluded.last_synced_at, ' + 'remote_revision = excluded.remote_revision, ' + 'active_workout_session_id = excluded.active_workout_session_id, ' + 'program_index = excluded.program_index, ' + 'exercise_index = excluded.exercise_index, ' + 'set_index = excluded.set_index, ' + 'value = excluded.value, ' + 'score_updated_at = excluded.score_updated_at', + variables: [ + Variable(metadata.id), + Variable(metadata.createdAt.toUtc()), + Variable(metadata.updatedAt.toUtc()), + Variable(_utcOrNull(metadata.deletedAt)), + Variable(metadata.schemaVersion), + Variable(_syncStateToDb(metadata.syncState)), + Variable(metadata.localRevision), + Variable(metadata.originDeviceId), + Variable(metadata.futureOwnerProfileId), + Variable(_utcOrNull(metadata.lastSyncedAt)), + Variable(metadata.remoteRevision), + Variable(state.activeWorkoutSessionId), + Variable(state.programIndex), + Variable(state.exerciseIndex), + Variable(state.setIndex), + Variable(state.value), + Variable(state.updatedAt.toUtc()), + ], + ); +} + Future _upsertActiveSetTimerState( db.AppDatabase database, domain.ActiveSetTimerState state, @@ -4459,58 +4699,6 @@ String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) { }; } -db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion( - domain.ActiveScoreStopwatchState state, -) { - final values = _metadataValues(state.metadata); - return db.ActiveScoreStopwatchStatesCompanion( - id: values[0] as Value, - createdAt: values[1] as Value, - updatedAt: values[2] as Value, - deletedAt: values[3] as Value, - schemaVersion: values[4] as Value, - syncState: values[5] as Value, - localRevision: values[6] as Value, - originDeviceId: values[7] as Value, - futureOwnerProfileId: values[8] as Value, - lastSyncedAt: values[9] as Value, - remoteRevision: values[10] as Value, - activeWorkoutSessionId: Value(state.activeWorkoutSessionId), - programIndex: Value(state.programIndex), - exerciseIndex: Value(state.exerciseIndex), - setIndex: Value(state.setIndex), - status: Value(_scoreStopwatchStatusToDb(state.status)), - startedAt: Value(state.startedAt.toUtc()), - accumulatedMs: Value(state.accumulatedMs), - stoppedAt: Value(_utcOrNull(state.stoppedAt)), - ); -} - -db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion( - domain.ActiveManualScoreState state, -) { - final values = _metadataValues(state.metadata); - return db.ActiveManualScoreStatesCompanion( - id: values[0] as Value, - createdAt: values[1] as Value, - updatedAt: values[2] as Value, - deletedAt: values[3] as Value, - schemaVersion: values[4] as Value, - syncState: values[5] as Value, - localRevision: values[6] as Value, - originDeviceId: values[7] as Value, - futureOwnerProfileId: values[8] as Value, - lastSyncedAt: values[9] as Value, - remoteRevision: values[10] as Value, - activeWorkoutSessionId: Value(state.activeWorkoutSessionId), - programIndex: Value(state.programIndex), - exerciseIndex: Value(state.exerciseIndex), - setIndex: Value(state.setIndex), - value: Value(state.value), - scoreUpdatedAt: Value(state.updatedAt.toUtc()), - ); -} - domain.ActiveManualScoreState _activeManualScoreStateFromRow( db.ActiveManualScoreState row, ) { diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 7cfdb99..6350b11 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -2067,6 +2067,188 @@ CREATE TABLE pending_share_actions ( }, ); + test( + 'active score states are soft deleted with softDelete change log entries', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + await activeRepository.save( + ActiveWorkoutSession( + metadata: _metadata('session-score-delete', now), + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _resolvedSnapshot(), + ), + ); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-delete', now), + activeWorkoutSessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.stopped, + startedAt: now, + accumulatedMs: 12000, + stoppedAt: now.add(const Duration(seconds: 12)), + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-delete', now), + activeWorkoutSessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 3, + updatedAt: now, + ), + ); + + final deletedAt = now.add(const Duration(minutes: 1)); + await activeRepository.deleteScoreStopwatchState( + sessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + deletedAt: deletedAt, + ); + await activeRepository.deleteManualScoreState( + sessionId: 'session-score-delete', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + deletedAt: deletedAt, + ); + + final stopwatchRow = + await (database.select(database.activeScoreStopwatchStates) + ..where((table) => table.id.equals('score-stopwatch-delete'))) + .getSingle(); + final manualRow = await (database.select( + database.activeManualScoreStates, + )..where((table) => table.id.equals('manual-score-delete'))).getSingle(); + final changes = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityId.isIn([ + 'score-stopwatch-delete', + 'manual-score-delete', + ]) & + table.localRevision.equals(1), + )) + .get(); + + expect(stopwatchRow.deletedAt?.toUtc(), deletedAt); + expect(stopwatchRow.syncState, 'deleted'); + expect(manualRow.deletedAt?.toUtc(), deletedAt); + expect(manualRow.syncState, 'deleted'); + expect(changes.map((change) => change.operation), [ + 'softDelete', + 'softDelete', + ]); + }, + ); + + test( + 'active score states keep stored identity when upserted by position', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + await activeRepository.save( + ActiveWorkoutSession( + metadata: _metadata('session-score-upsert', now), + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: _resolvedSnapshot(), + ), + ); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-original', now), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.running, + startedAt: now, + accumulatedMs: 0, + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-original', now), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 1, + updatedAt: now, + ), + ); + + final updatedAt = now.add(const Duration(minutes: 1)); + await activeRepository.saveScoreStopwatchState( + ActiveScoreStopwatchState( + metadata: _metadata('score-stopwatch-new-id', updatedAt, 1), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.paused, + startedAt: updatedAt, + accumulatedMs: 30000, + stoppedAt: updatedAt, + ), + ); + await activeRepository.saveManualScoreState( + ActiveManualScoreState( + metadata: _metadata('manual-score-new-id', updatedAt, 1), + activeWorkoutSessionId: 'session-score-upsert', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 4, + updatedAt: updatedAt, + ), + ); + + final stopwatchRow = + await (database.select(database.activeScoreStopwatchStates) + ..where((table) => table.id.equals('score-stopwatch-original'))) + .getSingle(); + final manualRow = + await (database.select(database.activeManualScoreStates) + ..where((table) => table.id.equals('manual-score-original'))) + .getSingle(); + final changes = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityId.isIn([ + 'score-stopwatch-original', + 'manual-score-original', + ]) & + table.operation.equals('update'), + )) + .get(); + + expect(stopwatchRow.createdAt.toUtc(), now); + expect(stopwatchRow.status, 'paused'); + expect(stopwatchRow.accumulatedMs, 30000); + expect(manualRow.createdAt.toUtc(), now); + expect(manualRow.value, 4); + expect(changes.map((change) => change.localRevision), [1, 1]); + }, + ); + test( 'closing a session stores autonomous history rows with set snapshots', () async {