--- title: "Comprendre l'architecture" description: 'Le pourquoi des choix techniques de TrainUs : local-first, le thread Worker, le système de migration, et quelques décisions de conception notables.' slug: 'comprendre-l-architecture' weight: 4 date: 2026-06-16 --- > Cette page explique _pourquoi_ TrainUs est construit ainsi. Pour les faits — schéma, routes, champs exacts — voir la [Référence]({{< relref "technical.fr.md" >}}). ## Pourquoi pas de serveur ? Beaucoup d'applications de fitness fonctionnent avec un compte et un serveur central. TrainUs fait le choix inverse : c'est une Progressive Web Application qui tourne entièrement dans le navigateur, avec toutes les données stockées localement via SQLite WASM et OPFS. Il n'y a aucun serveur à exploiter, aucun système de comptes à concevoir et sécuriser, et l'application continue de fonctionner — intégralement — sans aucune connexion réseau. La contrepartie est la même que celle décrite côté utilisateur dans [Comprendre TrainUs]({{< relref "comprendre.fr.md" >}}) : pas de synchronisation automatique entre appareils, pas de sauvegarde dans le cloud. Pour un contributeur, la conséquence pratique est qu'il n'y a aucune couche API à concevoir ni à maintenir — chaque fonctionnalité est, par construction, une lecture/écriture locale contre les repositories de `src/db/repositories/`. ## Pourquoi SQLite tourne dans un Web Worker Les API d'accès synchrone aux fichiers d'OPFS (`FileSystemSyncAccessHandle`) ne sont disponibles que dans un Worker — elles n'existent pas sur le thread principal. C'est donc `worker.js` qui possède la connexion SQLite proprement dite, et `database.js`, sur le thread principal, communique avec lui exclusivement via `postMessage()`, enveloppé dans des Promises identifiées par un ID de réponse (voir le diagramme dans la [Référence §1]({{< relref "technical.fr.md" >}}#1-architecture)). Cela signifie aussi que chaque appel à un repository est intrinsèquement asynchrone, même pour une lecture triviale — il n'y a pas d'échappatoire synchrone du type « interroger directement la base ». Les composables existent précisément pour absorber cette asynchronicité derrière une interface réactive en `ref`, afin que chaque composant n'ait pas à réinventer sa propre gestion du chargement et des erreurs. ## Pourquoi le VFS `opfs-sahpool` — et pas de repli Parmi les options de VFS OPFS proposées par SQLite WASM, `opfs-sahpool` a été choisi parce qu'il offre des entrées/sorties synchrones sans nécessiter de `SharedArrayBuffer`, ce qui veut dire qu'il n'a pas non plus besoin des en-têtes COOP/COEP (contrairement à l'autre VFS OPFS, non-SAH). Le serveur de développement les pose tout de même, par précaution — voir [Guides pratiques → Déployer TrainUs]({{< relref "how-to-guides.fr.md" >}}#déployer-trainus) pour les en-têtes eux-mêmes. Il n'y a délibérément aucun repli en mémoire quand OPFS n'est pas disponible (navigateur ancien, navigation privée). Un repli silencieux donnerait l'impression que l'application enregistre bien les données d'entraînement alors que ce n'est pas le cas — une base vide à chaque rechargement, sans aucun avertissement. Une erreur claire en plein écran a été jugée préférable à ce genre de perte de données silencieuse. ## Pourquoi le système de migration est construit ainsi Le flux de migration au démarrage (machine à états complète dans la [Référence §2]({{< relref "technical.fr.md" >}}#2-couche-base-de-données)) a quelques propriétés qui ne sont pas évidentes à la seule lecture du diagramme de flux : - **L'utilisateur doit télécharger une sauvegarde avant même que le bouton Mettre à jour soit activé.** Les migrations de schéma exécutent du SQL sans surveillance sur de vraies données utilisateur ; si un script de migration a un bug, le filet de sécurité doit déjà être entre les mains de l'utilisateur avant que l'étape risquée ne s'exécute, pas proposé après coup. - **Chaque migration s'exécute dans une transaction avec son estampillage `schema_version`.** Un échec revient à un état précédent parfaitement cohérent — la base n'est jamais laissée à moitié migrée. Combiné à la sauvegarde forcée, une migration échouée est récupérable de deux façons : la base fonctionne toujours à l'ancienne version, et l'utilisateur dispose de toute façon d'un export. - **Une base de données plus récente que l'application en cours d'exécution (`dbVersion > SCHEMA_VERSION`) échoue de façon sécurisée** avec une erreur bloquante plutôt que de tenter quoi que ce soit. Cela couvre le cas de quelqu'un revenant à une version plus ancienne de l'application alors que les données ont déjà été migrées en avant — deviner comment gérer ce cas serait bien plus risqué que de simplement s'arrêter. - **Le même registre `RELEASES` et les mêmes scripts `migrationScript` pilotent à la fois la migration au démarrage et l'adaptation de Restaurer la base de données.** La restauration devait déjà résoudre « amener une sauvegarde plus ancienne au schéma actuel » ; réutiliser ce mécanisme pour le chemin de mise à jour courant a évité de construire (et de maintenir) deux moteurs de migration séparés. C'est aussi pourquoi les augmentations de `SCHEMA_VERSION` sont traitées comme une décision délibérée, à valider avant d'agir (voir [Guides pratiques → Ajouter une migration de base de données]({{< relref "how-to-guides.fr.md" >}}#ajouter-une-migration-de-base-de-données)) plutôt que comme quelque chose à utiliser à la légère : chaque augmentation engage un script de migration testé et une entrée `RELEASES` permanente, pour toujours. ## Quelques décisions de conception notables ### Une boîte de dialogue de sauvegarde plutôt que le sélecteur natif Chaque enregistrement sur disque (exports JSON, sauvegarde SQLite, export HTML) passe par une `SaveFileDialog.vue` personnalisée qui demande un nom de fichier, plutôt que par l'API native `showSaveFilePicker()` du navigateur. L'API native a été rejetée précisément parce qu'elle n'est pas supportée par Firefox, qui est le navigateur de test principal du projet — voir la [Référence §3.12]({{< relref "technical.fr.md" >}}#312-invite-de-nom-de-fichier-à-la-sauvegarde) pour la façon dont la boîte de dialogue est branchée sur les trois points d'entrée de téléchargement existants. ### Journaux d'exécution partiels en cas de sortie anticipée Quitter une séance en cours en sauvegarde les étapes déjà complétées, avec `finished_at = null` pour marquer une exécution partielle, plutôt que d'abandonner toute la séance ou de forcer l'utilisateur à la terminer. Les séances d'entraînement sont interrompues dans la vraie vie (coup de téléphone, matériel pris par quelqu'un d'autre, temps écoulé) — perdre un échauffement déjà enregistré parce que le reste n'a pas eu lieu rendrait les statistiques moins fiables, pas plus. ### Deux stratégies différentes pour les exercices asymétriques Les exercices asymétriques permettent d'alterner le côté soit après chaque répétition (`alternate: true`), soit après une série complète (`alternate: false`) — voir [Référence §3.7]({{< relref "technical.fr.md" >}}#gestion-du-côté-pour-les-exercices-asymétriques). Les deux sont des pratiques d'entraînement réelles (fentes alternées vs. rangées un bras à la fois), donc le choix a été laissé en réglage par exercice plutôt que d'en imposer un seul à tout le monde. ### Pourquoi EMOM, AMRAP et TABATA calculent chacun les tours différemment Les trois types de combo minutés réutilisent le même champ `repetitions` d'un élément de séance avec des significations différentes (tours complets pour NONE/TABATA, durée totale en minutes pour EMOM/AMRAP — voir [Référence §3.7]({{< relref "technical.fr.md" >}}#modèle-de-file-dexécution)) plutôt que d'ajouter un champ séparé par type. Cela garde le modèle de données et le formulaire uniformes entre les types de combo, au prix d'un libellé dépendant du type dans l'interface (« Répétitions » vs. « Durée (min) ») — un compromis qui en vaut la peine puisqu'il évite trois jeux de champs quasi identiques en parallèle. ## Pourquoi la largeur de contenu est volontairement limitée Les vues liste, détail, formulaire et réglages limitent toutes la largeur de leur contenu bien en deçà de la largeur de l'écran sur les grands écrans de bureau (voir [Référence, Annexe A]({{< relref "technical.fr.md" >}}#annexe-a--largeur-de-contenu-limitée-sur-les-vues-listedétail)) — c'est un choix de lisibilité, pas un oubli. Une carte de séance ou un formulaire étiré sur un écran de 1920px forcerait l'œil à parcourir plus de distance que nécessaire pour ce qui est, fondamentalement, un contenu de liste assez court. La limite est appliquée par vue plutôt que globalement, pour qu'une future mise en page multi-colonnes (voir la recette dans cette même annexe) puisse être introduite progressivement, vue par vue, sans toucher aux autres.