feat(sync): préparation à la synchronisation cloud (ticket #12)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:53:58 +02:00
parent d13a3eb8de
commit e38dd75f69
5 changed files with 603 additions and 122 deletions

34
docs/sync-invariants.md Normal file
View File

@ -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.

View File

@ -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<AppBootstrap> create() async {
final database = AppDatabase.open();
@ -83,6 +85,7 @@ final class AppBootstrap {
repository: historyRepository,
clock: clock,
),
syncGateway: const NoOpSyncGateway(),
);
}

View File

@ -92,3 +92,26 @@ abstract interface class WorkoutHistoryRepository {
Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> 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<SyncRunSummary> synchronize();
}
final class NoOpSyncGateway implements SyncGateway {
const NoOpSyncGateway();
@override
Future<SyncRunSummary> synchronize() async {
return const SyncRunSummary(pushedChanges: 0, pulledChanges: 0);
}
}

View File

@ -45,9 +45,15 @@ final class DriftExerciseRepository implements ExerciseRepository {
@override
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> _upsertWithChangeLog({
required db.AppDatabase database,
required String tableName,
required String entityType,
required domain.EntityMetadata metadata,
required Future<void> 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<String> _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<String>(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<void> _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<void> _softDeleteProgramExerciseRows(
db.AppDatabase database,
List<db.ProgramExercise> 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<void> _softDeleteWorkoutTemplateProgramRows(
db.AppDatabase database,
List<db.WorkoutTemplateProgram> 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<void> _softDeleteWorkoutTemplateExerciseOverrideRows(
db.AppDatabase database,
List<db.WorkoutTemplateExerciseOverride> 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<void> _softDeleteWorkoutHistorySetResultRows(
db.AppDatabase database,
List<db.WorkoutHistorySetResult> 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<dynamic> _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<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String?>,
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),
);
}

View File

@ -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,
);
}