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.activeWorkoutSessionUseCases,
|
||||||
required this.closeWorkoutSessionUseCase,
|
required this.closeWorkoutSessionUseCase,
|
||||||
required this.workoutHistoryUseCases,
|
required this.workoutHistoryUseCases,
|
||||||
|
required this.syncGateway,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
@ -21,6 +22,7 @@ final class AppBootstrap {
|
|||||||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||||
|
final SyncGateway syncGateway;
|
||||||
|
|
||||||
static Future<AppBootstrap> create() async {
|
static Future<AppBootstrap> create() async {
|
||||||
final database = AppDatabase.open();
|
final database = AppDatabase.open();
|
||||||
@ -83,6 +85,7 @@ final class AppBootstrap {
|
|||||||
repository: historyRepository,
|
repository: historyRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
),
|
),
|
||||||
|
syncGateway: const NoOpSyncGateway(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -92,3 +92,26 @@ abstract interface class WorkoutHistoryRepository {
|
|||||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||||
Future<void> delete(String id, DateTime deletedAt);
|
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
|
@override
|
||||||
Future<void> save(domain.Exercise exercise) async {
|
Future<void> save(domain.Exercise exercise) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.exercises)
|
database: database,
|
||||||
.insertOnConflictUpdate(_exerciseCompanion(exercise));
|
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
|
@override
|
||||||
Future<void> save(domain.MediaAsset mediaAsset) async {
|
Future<void> save(domain.MediaAsset mediaAsset) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.mediaAssets)
|
database: database,
|
||||||
.insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset));
|
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
|
@override
|
||||||
Future<void> save(domain.Program program) async {
|
Future<void> save(domain.Program program) async {
|
||||||
await database
|
await database.transaction(() async {
|
||||||
.into(database.programs)
|
await _upsertWithChangeLog(
|
||||||
.insertOnConflictUpdate(_programCompanion(program));
|
database: database,
|
||||||
for (final exercise in program.exercises) {
|
tableName: 'programs',
|
||||||
await saveExercise(exercise);
|
entityType: 'Program',
|
||||||
}
|
metadata: program.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.programs)
|
||||||
|
.insertOnConflictUpdate(_programCompanion(program)),
|
||||||
|
);
|
||||||
|
for (final exercise in program.exercises) {
|
||||||
|
await saveExercise(exercise);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveExercise(domain.ProgramExercise exercise) async {
|
Future<void> saveExercise(domain.ProgramExercise exercise) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.programExercises)
|
database: database,
|
||||||
.insertOnConflictUpdate(_programExerciseCompanion(exercise));
|
tableName: 'program_exercises',
|
||||||
|
entityType: 'ProgramExercise',
|
||||||
|
metadata: exercise.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.programExercises)
|
||||||
|
.insertOnConflictUpdate(_programExerciseCompanion(exercise)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -158,26 +184,25 @@ final class DriftProgramRepository implements ProgramRepository {
|
|||||||
final activeIds = program.exercises
|
final activeIds = program.exercises
|
||||||
.map((exercise) => exercise.metadata.id)
|
.map((exercise) => exercise.metadata.id)
|
||||||
.toSet();
|
.toSet();
|
||||||
final update = database.update(database.programExercises);
|
final staleQuery = database.select(database.programExercises);
|
||||||
if (activeIds.isEmpty) {
|
if (activeIds.isEmpty) {
|
||||||
update.where(
|
staleQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.programId.equals(program.metadata.id) &
|
table.programId.equals(program.metadata.id) &
|
||||||
table.deletedAt.isNull(),
|
table.deletedAt.isNull(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
update.where(
|
staleQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.programId.equals(program.metadata.id) &
|
table.programId.equals(program.metadata.id) &
|
||||||
table.deletedAt.isNull() &
|
table.deletedAt.isNull() &
|
||||||
table.id.isNotIn(activeIds),
|
table.id.isNotIn(activeIds),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await update.write(
|
await _softDeleteProgramExerciseRows(
|
||||||
db.ProgramExercisesCompanion(
|
database,
|
||||||
deletedAt: Value(deletedAt),
|
await staleQuery.get(),
|
||||||
updatedAt: Value(deletedAt),
|
deletedAt,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -244,33 +269,53 @@ final class DriftWorkoutTemplateRepository
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(domain.WorkoutTemplate template) async {
|
Future<void> save(domain.WorkoutTemplate template) async {
|
||||||
await database
|
await database.transaction(() async {
|
||||||
.into(database.workoutTemplates)
|
await _upsertWithChangeLog(
|
||||||
.insertOnConflictUpdate(_workoutTemplateCompanion(template));
|
database: database,
|
||||||
for (final program in template.programs) {
|
tableName: 'workout_templates',
|
||||||
await saveProgram(program);
|
entityType: 'WorkoutTemplate',
|
||||||
}
|
metadata: template.metadata,
|
||||||
for (final override in template.overrides) {
|
write: () => database
|
||||||
await saveOverride(override);
|
.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
|
@override
|
||||||
Future<void> saveProgram(domain.WorkoutTemplateProgram program) async {
|
Future<void> saveProgram(domain.WorkoutTemplateProgram program) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.workoutTemplatePrograms)
|
database: database,
|
||||||
.insertOnConflictUpdate(_workoutTemplateProgramCompanion(program));
|
tableName: 'workout_template_programs',
|
||||||
|
entityType: 'WorkoutTemplateProgram',
|
||||||
|
metadata: program.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.workoutTemplatePrograms)
|
||||||
|
.insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveOverride(
|
Future<void> saveOverride(
|
||||||
domain.WorkoutTemplateExerciseOverride override,
|
domain.WorkoutTemplateExerciseOverride override,
|
||||||
) async {
|
) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.workoutTemplateExerciseOverrides)
|
database: database,
|
||||||
.insertOnConflictUpdate(
|
tableName: 'workout_template_exercise_overrides',
|
||||||
_workoutTemplateExerciseOverrideCompanion(override),
|
entityType: 'WorkoutTemplateExerciseOverride',
|
||||||
);
|
metadata: override.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.workoutTemplateExerciseOverrides)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
_workoutTemplateExerciseOverrideCompanion(override),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -293,26 +338,27 @@ final class DriftWorkoutTemplateRepository
|
|||||||
final activeProgramIds = template.programs
|
final activeProgramIds = template.programs
|
||||||
.map((program) => program.metadata.id)
|
.map((program) => program.metadata.id)
|
||||||
.toSet();
|
.toSet();
|
||||||
final programUpdate = database.update(database.workoutTemplatePrograms);
|
final staleProgramsQuery = database.select(
|
||||||
|
database.workoutTemplatePrograms,
|
||||||
|
);
|
||||||
if (activeProgramIds.isEmpty) {
|
if (activeProgramIds.isEmpty) {
|
||||||
programUpdate.where(
|
staleProgramsQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.workoutTemplateId.equals(template.metadata.id) &
|
table.workoutTemplateId.equals(template.metadata.id) &
|
||||||
table.deletedAt.isNull(),
|
table.deletedAt.isNull(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
programUpdate.where(
|
staleProgramsQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.workoutTemplateId.equals(template.metadata.id) &
|
table.workoutTemplateId.equals(template.metadata.id) &
|
||||||
table.deletedAt.isNull() &
|
table.deletedAt.isNull() &
|
||||||
table.id.isNotIn(activeProgramIds),
|
table.id.isNotIn(activeProgramIds),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await programUpdate.write(
|
await _softDeleteWorkoutTemplateProgramRows(
|
||||||
db.WorkoutTemplateProgramsCompanion(
|
database,
|
||||||
deletedAt: Value(deletedAt),
|
await staleProgramsQuery.get(),
|
||||||
updatedAt: Value(deletedAt),
|
deletedAt,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final activeOverrideIds = template.overrides
|
final activeOverrideIds = template.overrides
|
||||||
@ -322,28 +368,27 @@ final class DriftWorkoutTemplateRepository
|
|||||||
if (relevantProgramIds.isEmpty) {
|
if (relevantProgramIds.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final overrideUpdate = database.update(
|
final staleOverridesQuery = database.select(
|
||||||
database.workoutTemplateExerciseOverrides,
|
database.workoutTemplateExerciseOverrides,
|
||||||
);
|
);
|
||||||
if (activeOverrideIds.isEmpty) {
|
if (activeOverrideIds.isEmpty) {
|
||||||
overrideUpdate.where(
|
staleOverridesQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.workoutTemplateProgramId.isIn(relevantProgramIds) &
|
table.workoutTemplateProgramId.isIn(relevantProgramIds) &
|
||||||
table.deletedAt.isNull(),
|
table.deletedAt.isNull(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
overrideUpdate.where(
|
staleOverridesQuery.where(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.workoutTemplateProgramId.isIn(relevantProgramIds) &
|
table.workoutTemplateProgramId.isIn(relevantProgramIds) &
|
||||||
table.deletedAt.isNull() &
|
table.deletedAt.isNull() &
|
||||||
table.id.isNotIn(activeOverrideIds),
|
table.id.isNotIn(activeOverrideIds),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await overrideUpdate.write(
|
await _softDeleteWorkoutTemplateExerciseOverrideRows(
|
||||||
db.WorkoutTemplateExerciseOverridesCompanion(
|
database,
|
||||||
deletedAt: Value(deletedAt),
|
await staleOverridesQuery.get(),
|
||||||
updatedAt: Value(deletedAt),
|
deletedAt,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -379,23 +424,41 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(domain.ActiveWorkoutSession session) async {
|
Future<void> save(domain.ActiveWorkoutSession session) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.activeWorkoutSessions)
|
database: database,
|
||||||
.insertOnConflictUpdate(_activeWorkoutSessionCompanion(session));
|
tableName: 'active_workout_sessions',
|
||||||
|
entityType: 'ActiveWorkoutSession',
|
||||||
|
metadata: session.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.activeWorkoutSessions)
|
||||||
|
.insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(domain.ActiveSetResult result) async {
|
Future<void> saveSetResult(domain.ActiveSetResult result) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.activeSetResults)
|
database: database,
|
||||||
.insertOnConflictUpdate(_activeSetResultCompanion(result));
|
tableName: 'active_set_results',
|
||||||
|
entityType: 'ActiveSetResult',
|
||||||
|
metadata: result.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.activeSetResults)
|
||||||
|
.insertOnConflictUpdate(_activeSetResultCompanion(result)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveRestState(domain.ActiveRestState restState) async {
|
Future<void> saveRestState(domain.ActiveRestState restState) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.activeRestStates)
|
database: database,
|
||||||
.insertOnConflictUpdate(_activeRestStateCompanion(restState));
|
tableName: 'active_rest_states',
|
||||||
|
entityType: 'ActiveRestState',
|
||||||
|
metadata: restState.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.activeRestStates)
|
||||||
|
.insertOnConflictUpdate(_activeRestStateCompanion(restState)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -484,71 +547,307 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> save(domain.WorkoutHistory history) async {
|
Future<void> save(domain.WorkoutHistory history) async {
|
||||||
await database
|
await database.transaction(() async {
|
||||||
.into(database.workoutHistories)
|
await _upsertWithChangeLog(
|
||||||
.insertOnConflictUpdate(_workoutHistoryCompanion(history));
|
database: database,
|
||||||
for (final result in history.results) {
|
tableName: 'workout_history',
|
||||||
await saveSetResult(result);
|
entityType: 'WorkoutHistory',
|
||||||
}
|
metadata: history.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.workoutHistories)
|
||||||
|
.insertOnConflictUpdate(_workoutHistoryCompanion(history)),
|
||||||
|
);
|
||||||
|
for (final result in history.results) {
|
||||||
|
await saveSetResult(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
|
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
|
||||||
await database
|
await _upsertWithChangeLog(
|
||||||
.into(database.workoutHistorySetResults)
|
database: database,
|
||||||
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result));
|
tableName: 'workout_history_set_results',
|
||||||
|
entityType: 'WorkoutHistorySetResult',
|
||||||
|
metadata: result.metadata,
|
||||||
|
write: () => database
|
||||||
|
.into(database.workoutHistorySetResults)
|
||||||
|
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(String id, DateTime deletedAt) async {
|
Future<void> delete(String id, DateTime deletedAt) async {
|
||||||
await database.transaction(() 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(
|
await (database.update(
|
||||||
database.workoutHistories,
|
database.workoutHistories,
|
||||||
)..where((table) => table.id.equals(id))).write(
|
)..where((table) => table.id.equals(id))).write(
|
||||||
db.WorkoutHistoriesCompanion(
|
db.WorkoutHistoriesCompanion(
|
||||||
deletedAt: Value(deletedAt),
|
deletedAt: Value(deletedAt.toUtc()),
|
||||||
updatedAt: Value(deletedAt),
|
updatedAt: Value(deletedAt.toUtc()),
|
||||||
|
localRevision: Value(revision),
|
||||||
|
syncState: const Value('deleted'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await (database.update(
|
await _writeChangeLog(
|
||||||
database.workoutHistorySetResults,
|
database: database,
|
||||||
)..where((table) => table.workoutHistoryId.equals(id))).write(
|
entityType: 'WorkoutHistory',
|
||||||
db.WorkoutHistorySetResultsCompanion(
|
entityId: id,
|
||||||
deletedAt: Value(deletedAt),
|
operation: 'softDelete',
|
||||||
updatedAt: Value(deletedAt),
|
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) {
|
domain.EntityMetadata _metadataFromRow(dynamic row) {
|
||||||
return domain.EntityMetadata(
|
return domain.EntityMetadata(
|
||||||
id: row.id as String,
|
id: row.id as String,
|
||||||
createdAt: row.createdAt as DateTime,
|
createdAt: _utc(row.createdAt as DateTime),
|
||||||
updatedAt: row.updatedAt as DateTime,
|
updatedAt: _utc(row.updatedAt as DateTime),
|
||||||
deletedAt: row.deletedAt as DateTime?,
|
deletedAt: _utcOrNull(row.deletedAt as DateTime?),
|
||||||
schemaVersion: row.schemaVersion as int,
|
schemaVersion: row.schemaVersion as int,
|
||||||
syncState: _syncStateFromDb(row.syncState as String),
|
syncState: _syncStateFromDb(row.syncState as String),
|
||||||
localRevision: row.localRevision as int,
|
localRevision: row.localRevision as int,
|
||||||
originDeviceId: row.originDeviceId as String,
|
originDeviceId: row.originDeviceId as String,
|
||||||
futureOwnerProfileId: row.futureOwnerProfileId as String?,
|
futureOwnerProfileId: row.futureOwnerProfileId as String?,
|
||||||
lastSyncedAt: row.lastSyncedAt as DateTime?,
|
lastSyncedAt: _utcOrNull(row.lastSyncedAt as DateTime?),
|
||||||
remoteRevision: row.remoteRevision as String?,
|
remoteRevision: row.remoteRevision as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<dynamic> _metadataValues(domain.EntityMetadata metadata) => [
|
List<dynamic> _metadataValues(domain.EntityMetadata metadata) => [
|
||||||
Value(metadata.id),
|
Value(metadata.id),
|
||||||
Value(metadata.createdAt),
|
Value(metadata.createdAt.toUtc()),
|
||||||
Value(metadata.updatedAt),
|
Value(metadata.updatedAt.toUtc()),
|
||||||
Value(metadata.deletedAt),
|
Value(_utcOrNull(metadata.deletedAt)),
|
||||||
Value(metadata.schemaVersion),
|
Value(metadata.schemaVersion),
|
||||||
Value(_syncStateToDb(metadata.syncState)),
|
Value(_syncStateToDb(metadata.syncState)),
|
||||||
Value(metadata.localRevision),
|
Value(metadata.localRevision),
|
||||||
Value(metadata.originDeviceId),
|
Value(metadata.originDeviceId),
|
||||||
Value(metadata.futureOwnerProfileId),
|
Value(metadata.futureOwnerProfileId),
|
||||||
Value(metadata.lastSyncedAt),
|
Value(_utcOrNull(metadata.lastSyncedAt)),
|
||||||
Value(metadata.remoteRevision),
|
Value(metadata.remoteRevision),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -575,7 +874,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
|
|||||||
hasScoreMeasure: Value(exercise.hasScoreMeasure),
|
hasScoreMeasure: Value(exercise.hasScoreMeasure),
|
||||||
scoreLabel: Value(exercise.scoreLabel),
|
scoreLabel: Value(exercise.scoreLabel),
|
||||||
scoreUnit: Value(exercise.scoreUnit),
|
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,
|
hasScoreMeasure: row.hasScoreMeasure,
|
||||||
scoreLabel: row.scoreLabel,
|
scoreLabel: row.scoreLabel,
|
||||||
scoreUnit: row.scoreUnit,
|
scoreUnit: row.scoreUnit,
|
||||||
archivedAt: row.archivedAt,
|
archivedAt: _utcOrNull(row.archivedAt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -753,7 +1052,7 @@ db.WorkoutTemplatesCompanion _workoutTemplateCompanion(
|
|||||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||||
remoteRevision: values[10] as Value<String?>,
|
remoteRevision: values[10] as Value<String?>,
|
||||||
name: Value(template.name),
|
name: Value(template.name),
|
||||||
lastStartedAt: Value(template.lastStartedAt),
|
lastStartedAt: Value(_utcOrNull(template.lastStartedAt)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,7 +1064,7 @@ domain.WorkoutTemplate _workoutTemplateFromRow(
|
|||||||
return domain.WorkoutTemplate(
|
return domain.WorkoutTemplate(
|
||||||
metadata: _metadataFromRow(row),
|
metadata: _metadataFromRow(row),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
lastStartedAt: row.lastStartedAt,
|
lastStartedAt: _utcOrNull(row.lastStartedAt),
|
||||||
programs: programs,
|
programs: programs,
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
);
|
);
|
||||||
@ -868,10 +1167,10 @@ db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion(
|
|||||||
remoteRevision: values[10] as Value<String?>,
|
remoteRevision: values[10] as Value<String?>,
|
||||||
sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId),
|
sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId),
|
||||||
status: Value(_activeStatusToDb(session.status)),
|
status: Value(_activeStatusToDb(session.status)),
|
||||||
startedAt: Value(session.startedAt),
|
startedAt: Value(session.startedAt.toUtc()),
|
||||||
pausedAt: Value(session.pausedAt),
|
pausedAt: Value(_utcOrNull(session.pausedAt)),
|
||||||
endedAt: Value(session.endedAt),
|
endedAt: Value(_utcOrNull(session.endedAt)),
|
||||||
lastPersistedAt: Value(session.lastPersistedAt),
|
lastPersistedAt: Value(session.lastPersistedAt.toUtc()),
|
||||||
elapsedActiveMs: Value(session.elapsedActiveMs),
|
elapsedActiveMs: Value(session.elapsedActiveMs),
|
||||||
currentProgramIndex: Value(session.currentProgramIndex),
|
currentProgramIndex: Value(session.currentProgramIndex),
|
||||||
currentExerciseIndex: Value(session.currentExerciseIndex),
|
currentExerciseIndex: Value(session.currentExerciseIndex),
|
||||||
@ -887,10 +1186,10 @@ domain.ActiveWorkoutSession _activeWorkoutSessionFromRow(
|
|||||||
metadata: _metadataFromRow(row),
|
metadata: _metadataFromRow(row),
|
||||||
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
|
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
|
||||||
status: _activeStatusFromDb(row.status),
|
status: _activeStatusFromDb(row.status),
|
||||||
startedAt: row.startedAt,
|
startedAt: _utc(row.startedAt),
|
||||||
pausedAt: row.pausedAt,
|
pausedAt: _utcOrNull(row.pausedAt),
|
||||||
endedAt: row.endedAt,
|
endedAt: _utcOrNull(row.endedAt),
|
||||||
lastPersistedAt: row.lastPersistedAt,
|
lastPersistedAt: _utc(row.lastPersistedAt),
|
||||||
elapsedActiveMs: row.elapsedActiveMs,
|
elapsedActiveMs: row.elapsedActiveMs,
|
||||||
currentProgramIndex: row.currentProgramIndex,
|
currentProgramIndex: row.currentProgramIndex,
|
||||||
currentExerciseIndex: row.currentExerciseIndex,
|
currentExerciseIndex: row.currentExerciseIndex,
|
||||||
@ -921,8 +1220,8 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion(
|
|||||||
programIndex: Value(result.programIndex),
|
programIndex: Value(result.programIndex),
|
||||||
exerciseIndex: Value(result.exerciseIndex),
|
exerciseIndex: Value(result.exerciseIndex),
|
||||||
setIndex: Value(result.setIndex),
|
setIndex: Value(result.setIndex),
|
||||||
startedAt: Value(result.startedAt),
|
startedAt: Value(_utcOrNull(result.startedAt)),
|
||||||
completedAt: Value(result.completedAt),
|
completedAt: Value(_utcOrNull(result.completedAt)),
|
||||||
actualTimeMs: Value(result.actualTimeMs),
|
actualTimeMs: Value(result.actualTimeMs),
|
||||||
actualReps: Value(result.actualReps),
|
actualReps: Value(result.actualReps),
|
||||||
actualScore: Value(result.actualScore),
|
actualScore: Value(result.actualScore),
|
||||||
@ -942,8 +1241,8 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) {
|
|||||||
programIndex: row.programIndex,
|
programIndex: row.programIndex,
|
||||||
exerciseIndex: row.exerciseIndex,
|
exerciseIndex: row.exerciseIndex,
|
||||||
setIndex: row.setIndex,
|
setIndex: row.setIndex,
|
||||||
startedAt: row.startedAt,
|
startedAt: _utcOrNull(row.startedAt),
|
||||||
completedAt: row.completedAt,
|
completedAt: _utcOrNull(row.completedAt),
|
||||||
actualTimeMs: row.actualTimeMs,
|
actualTimeMs: row.actualTimeMs,
|
||||||
actualReps: row.actualReps,
|
actualReps: row.actualReps,
|
||||||
actualScore: row.actualScore,
|
actualScore: row.actualScore,
|
||||||
@ -976,9 +1275,9 @@ db.ActiveRestStatesCompanion _activeRestStateCompanion(
|
|||||||
afterSetIndex: Value(restState.afterSetIndex),
|
afterSetIndex: Value(restState.afterSetIndex),
|
||||||
plannedRestSeconds: Value(restState.plannedRestSeconds),
|
plannedRestSeconds: Value(restState.plannedRestSeconds),
|
||||||
adjustedRestSeconds: Value(restState.adjustedRestSeconds),
|
adjustedRestSeconds: Value(restState.adjustedRestSeconds),
|
||||||
startedAt: Value(restState.startedAt),
|
startedAt: Value(restState.startedAt.toUtc()),
|
||||||
endedAt: Value(restState.endedAt),
|
endedAt: Value(_utcOrNull(restState.endedAt)),
|
||||||
skippedAt: Value(restState.skippedAt),
|
skippedAt: Value(_utcOrNull(restState.skippedAt)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -991,9 +1290,9 @@ domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) {
|
|||||||
afterSetIndex: row.afterSetIndex,
|
afterSetIndex: row.afterSetIndex,
|
||||||
plannedRestSeconds: row.plannedRestSeconds,
|
plannedRestSeconds: row.plannedRestSeconds,
|
||||||
adjustedRestSeconds: row.adjustedRestSeconds,
|
adjustedRestSeconds: row.adjustedRestSeconds,
|
||||||
startedAt: row.startedAt,
|
startedAt: _utc(row.startedAt),
|
||||||
endedAt: row.endedAt,
|
endedAt: _utcOrNull(row.endedAt),
|
||||||
skippedAt: row.skippedAt,
|
skippedAt: _utcOrNull(row.skippedAt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1016,8 +1315,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
|||||||
sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId),
|
sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId),
|
||||||
sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId),
|
sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId),
|
||||||
nameSnapshot: Value(history.nameSnapshot),
|
nameSnapshot: Value(history.nameSnapshot),
|
||||||
startedAt: Value(history.startedAt),
|
startedAt: Value(history.startedAt.toUtc()),
|
||||||
endedAt: Value(history.endedAt),
|
endedAt: Value(history.endedAt.toUtc()),
|
||||||
totalActiveMs: Value(history.totalActiveMs),
|
totalActiveMs: Value(history.totalActiveMs),
|
||||||
completed: Value(history.completed),
|
completed: Value(history.completed),
|
||||||
historySnapshotJson: Value(history.historySnapshotJson),
|
historySnapshotJson: Value(history.historySnapshotJson),
|
||||||
@ -1059,8 +1358,8 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
|
|||||||
actualScore: Value(result.actualScore),
|
actualScore: Value(result.actualScore),
|
||||||
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
|
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
|
||||||
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
|
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
|
||||||
startedAt: Value(result.startedAt),
|
startedAt: Value(_utcOrNull(result.startedAt)),
|
||||||
completedAt: Value(result.completedAt),
|
completedAt: Value(_utcOrNull(result.completedAt)),
|
||||||
status: Value(_setResultStatusToDb(result.status)),
|
status: Value(_setResultStatusToDb(result.status)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1074,8 +1373,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
|||||||
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
|
sourceWorkoutTemplateId: row.sourceWorkoutTemplateId,
|
||||||
sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId,
|
sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId,
|
||||||
nameSnapshot: row.nameSnapshot,
|
nameSnapshot: row.nameSnapshot,
|
||||||
startedAt: row.startedAt,
|
startedAt: _utc(row.startedAt),
|
||||||
endedAt: row.endedAt,
|
endedAt: _utc(row.endedAt),
|
||||||
totalActiveMs: row.totalActiveMs,
|
totalActiveMs: row.totalActiveMs,
|
||||||
completed: row.completed,
|
completed: row.completed,
|
||||||
historySnapshotJson: row.historySnapshotJson,
|
historySnapshotJson: row.historySnapshotJson,
|
||||||
@ -1107,8 +1406,8 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
|
|||||||
actualScore: row.actualScore,
|
actualScore: row.actualScore,
|
||||||
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
scoreLabelSnapshot: row.scoreLabelSnapshot,
|
||||||
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
scoreUnitSnapshot: row.scoreUnitSnapshot,
|
||||||
startedAt: row.startedAt,
|
startedAt: _utcOrNull(row.startedAt),
|
||||||
completedAt: row.completedAt,
|
completedAt: _utcOrNull(row.completedAt),
|
||||||
status: _setResultStatusFromDb(row.status),
|
status: _setResultStatusFromDb(row.status),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:gametime/application/application.dart';
|
import 'package:gametime/application/application.dart';
|
||||||
@ -8,12 +9,16 @@ import 'package:gametime/infrastructure/local/local.dart' as local;
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late local.AppDatabase database;
|
late local.AppDatabase database;
|
||||||
|
late local.DriftExerciseRepository exerciseRepository;
|
||||||
|
late local.DriftProgramRepository programRepository;
|
||||||
late local.DriftActiveSessionRepository activeRepository;
|
late local.DriftActiveSessionRepository activeRepository;
|
||||||
late local.DriftWorkoutTemplateRepository templateRepository;
|
late local.DriftWorkoutTemplateRepository templateRepository;
|
||||||
late local.DriftWorkoutHistoryRepository historyRepository;
|
late local.DriftWorkoutHistoryRepository historyRepository;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
database = local.AppDatabase(NativeDatabase.memory());
|
database = local.AppDatabase(NativeDatabase.memory());
|
||||||
|
exerciseRepository = local.DriftExerciseRepository(database);
|
||||||
|
programRepository = local.DriftProgramRepository(database);
|
||||||
activeRepository = local.DriftActiveSessionRepository(database);
|
activeRepository = local.DriftActiveSessionRepository(database);
|
||||||
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
||||||
historyRepository = local.DriftWorkoutHistoryRepository(database);
|
historyRepository = local.DriftWorkoutHistoryRepository(database);
|
||||||
@ -23,6 +28,102 @@ void main() {
|
|||||||
await database.close();
|
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(
|
test(
|
||||||
'running session elapsed time survives repository reconstruction',
|
'running session elapsed time survives repository reconstruction',
|
||||||
() async {
|
() async {
|
||||||
@ -167,12 +268,33 @@ String _resolvedSnapshot() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
EntityMetadata _metadata(String id, DateTime now) {
|
EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
|
||||||
return EntityMetadata(
|
return EntityMetadata(
|
||||||
id: id,
|
id: id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
originDeviceId: 'device-1',
|
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