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:
34
docs/sync-invariants.md
Normal file
34
docs/sync-invariants.md
Normal 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.
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user