Files
GameTime/.ideai/tickets/130/carnet.md
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

4.2 KiB

issueRef, version, updatedBy, updatedAt
issueRef version updatedBy updatedAt
#130 6
kind
user
1785243026193

#130 — Cadrage technique Architect (2026-07-27)

Root cause identifiée dans le code réel — bug critique de non-idempotence, confirmé et reproductible par construction (pas besoin du détail score 20/25 pour l'expliquer, mais cohérent avec le scénario rapporté).

Erreur SQLite 2067 = violation de contrainte UNIQUE. La table active_set_results (lib/infrastructure/local/tables.dart:473-513) porte :

UNIQUE (active_workout_session_id, program_index, exercise_index, set_index)

ActiveWorkoutSessionUseCases.recordCurrentSetResult (lib/application/use_cases.dart:2451-2511), appelée par le bouton "Terminer la série" (workout_execution_screen.dart:1459, via _recordAndAdvance), construit toujours un ActiveSetResult avec une metadata/id neuve (_newMetadata(ids, originDeviceId, now), ligne 2485) puis appelle sessionRepository.saveSetResultinsertOnConflictUpdate (drift_repositories.dart:1130-1140). Drift résout ce conflit sur la clé primaire (id), pas sur la contrainte UNIQUE métier — donc si une ligne existe déjà pour (session, program, exercise, set) avec un autre id, insertOnConflictUpdate ne trouve pas de conflit sur id, tente un INSERT, et percute l'UNIQUE → 2067.

Comparer avec le code correct existant : upsertSetResultAtPosition (ligne 2513-2588, utilisée pour l'édition a posteriori d'une série passée) fait ça bien — elle cherche d'abord la ligne existante via listSetResults (ligne 2546-2555) et réutilise son metadata/id (existing.metadata.touch(now)) si trouvée, sinon en crée une neuve. C'est le pattern à répliquer dans recordCurrentSetResult.

Deux points d'entrée indépendants écrivent sur la même clé sans coordination, ce qui rend la collision non hypothétique :

  1. Le bouton téléphone (_recordAndAdvancerecordCurrentSetResult).
  2. Le handler de commande montre _finishCurrentSet (use_cases.dart:3546, déclenché par WatchCommandType.finishCurrentSet/skipCurrentSet, ligne 3472-3476) qui appelle aussi recordCurrentSetResult (ligne 3574) — un chemin d'écriture séparé pour la même position logique.

Toute situation où une ligne existe déjà pour cette position au moment de l'appel (retry après un premier appel resté en vol, double-tap sans garde métier au niveau du use case, ou commande montre + tap téléphone proches dans le temps) déclenche l'erreur — pas seulement le cas particulier score/reps décrit dans le ticket, mais c'est cohérent avec un scénario où l'utilisateur a interagi via les deux surfaces (téléphone + montre) pour la même série.

Correctif (contrat clair pour DevBackend)

Faire de recordCurrentSetResult un véritable upsert sur la clé logique, à l'identique du pattern déjà en place dans upsertSetResultAtPosition :

final existingResults = await sessionRepository.listSetResults(sessionId);
final existing = existingResults.firstWhereOrNull((r) =>
    r.programIndex == programIndex &&
    r.exerciseIndex == exerciseIndex &&
    r.setIndex == setIndex);
final result = ActiveSetResult(
  metadata: existing == null
      ? _newMetadata(ids, originDeviceId, now)
      : existing.metadata.touch(now),
  // ... reste inchangé
);

Ce correctif rend recordCurrentSetResult idempotent et sûr vis-à-vis des deux points d'entrée (téléphone/montre) et de tout retry, sans changer sa signature publique ni le contrat vu par l'UI/la montre.

Tests

  • Use case : appeler recordCurrentSetResult deux fois de suite pour la même position (simulant un retry ou une double soumission téléphone/montre) → doit réussir les deux fois sans exception, avec la seconde valeur qui gagne (dernière écriture) et un seul enregistrement en base.
  • Cas croisé : recordCurrentSetResult puis vérifier listSetResults ne retourne qu'une ligne pour cette position.
  • Régression : vérifier que upsertSetResultAtPosition reste inchangée et continue de passer ses tests existants.

Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire. Priorité critique confirmée (bloque la fin de série dans un cas déjà survenu en usage réel).