Simpl-Resultat/docs/adr/0022-canal-evenements-donnees.md
le king fu ee17f31352
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
docs(onboarding): the import counter, D9 and the QA cases from the stack's review
- Guide (FR, EN, in-app keys and `.md`) and CHANGELOG: the Import card
  never says "Done." for an import that added no transaction, and the
  step waits for a file that brought one; a Fixed entry, since v0.17.0
  already ticked the step after such an import.
- CHANGELOG: the Free edition shows the Budget step locked, it does not
  lack one.
- architecture.md: the import counter's query, and D9's flag read
  outside any transaction, its log line written after the COMMIT.
- ADR 0022: `applyMigration` and `deleteTransaction` named among the
  writes that do not notify, with their reason.
- QA list (44 cases): the noop import at rest, the focus ring on the
  highlighted control, "Skip" on the folder step without a folder, the
  Transactions and Budget cards on a profile without transactions, the
  focus after "Hide" and "Close", the doubled counter of the unfolded
  list, and a month-agnostic note on the off-screen cell.

Reviews of #402 to #407.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:30 -04:00

9.4 KiB

ADR 0022 — Canal d'événements de données : les écritures signalent, le parcours de démarrage relit

  • Status: Accepted
  • Date: 2026-10-08
  • Issues: #396 (milestone planned-2026-10-06-onboarding-parcours-guide, phase 2 de l'onboarding, #391)
  • S'appuie sur l'ADR 0002 (un useReducer par domaine, pas de store global) et l'ADR 0016 (préférences par profil dans user_preferences)

Contexte

La phase 2 de l'onboarding fait suivre l'utilisateur par le parcours de démarrage : un bandeau sur chaque page et, sur la page de l'étape en cours, une carte qui voit l'étape se cocher pendant que la page est ouverte. Il faut donc que l'état du parcours soit partagé par toute l'application et relu après chaque écriture qui le concerne.

Rien ne le permettait. Les pages lisent leurs données à leur montage, et aucun mécanisme ne relie une écriture à un écran qui en dépend. La liste de démarrage de la phase 1 (#351) vivait dans DashboardPage, lue une fois au montage : choisir un dossier d'import, importer un fichier ou classer une transaction ne la mettait à jour qu'au retour sur le tableau de bord.

Le parcours se calcule depuis la base : cinq compteurs lus par COUNTERS_SQL (onboardingService.ts), sur import_sources, imported_files, transactions et budget_entries, plus la préférence import_folder. Ces données s'écrivent depuis plusieurs pages et plusieurs services, et une restauration de sauvegarde depuis Paramètres → Données les remplace toutes.

Décision

Un canal d'événements de données, src/services/dataEvents.ts : les écritures qui peuvent changer un compteur appellent notifyDataChanged() une fois réussies, et le fournisseur du parcours, abonné par subscribeDataChanged, relit son état. Le module n'importe pas React ; il suit le modèle de logService.subscribe.

1. Portée

Le message dit seulement : « des données que lisent des compteurs ont peut-être changé ». Il ne porte rien, ni table, ni identifiant, ni valeur : l'abonné relit ce dont il a besoin, en une requête. Le nom est côté données, parce que la couche de données ne sait pas qui écoute : aucun service n'importe de hook ni de contexte.

notifyDataChanged est synchrone et ne lève jamais. Un abonné qui lève est écrit au Journal (Data events: a subscriber failed) et les autres sont appelés quand même : une écriture réussie n'est jamais présentée comme un échec à cause d'un abonné.

2. Les écritures qui notifient

La liste est construite depuis les tables que lit COUNTERS_SQL, plus le dossier d'import. Chaque notification part après une écriture réussie ; une écriture qui lève ne notifie pas. Une fonction qui écrit en plusieurs requêtes notifie une fois, après la dernière.

Service Écritures Moment
userPreferenceService.ts setImportFolder après l'écriture de la préférence (setPreference seul ne notifie pas)
importSourceService.ts createSource, deleteSource après l'écriture ; createSource notifie avant la relecture qui suit un conflit de nom, qui ne fait que lire
importedFileService.ts createImportedFile (insertion comme mise à jour), updateFileStatus, deleteImportWithTransactions, deleteAllImportsWithTransactions après l'écriture ; pour les suppressions, après la dernière (source orpheline comprise). Un refus du contrôle préalable (transactions liées au Bilan) ne notifie pas
transactionService.ts insertBatch, updateTransactionCategory insertBatch une fois, après la dernière insertion
categorizationService.ts applyKeywordWithReassignment après le COMMIT, une fois le verrou de db.ts relâché ; un ROLLBACK relance l'erreur et ne notifie pas
budgetService.ts upsertBudgetEntry, deleteBudgetEntry, upsertBudgetEntriesForYear, applyTemplate après l'écriture ; upsertBudgetEntriesForYear après les douze mois, applyTemplate une fois après la dernière entrée du modèle
dataExportService.ts runRestore après le COMMIT, une fois le verrou relâché : un seul point pour les trois modes de restauration
useImportWizard.ts fin d'executeImport une notification de plus, en succès comme en échec (finally), pour que la dernière relecture voie l'état final de l'import

Cette liste ne doit pas rétrécir. Sans insertBatch ni updateFileStatus, la dernière relecture d'un import partait avant l'insertion des transactions, et le parcours restait sur un état intermédiaire.

Ne notifient pas :

  • updateSource, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
  • categoryMigrationService.applyMigration, qui réécrit budget_entries (DELETE des mois en double sous la catégorie cible, puis UPDATE de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
  • transactionService.deleteTransaction (DELETE FROM transactions), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
  • les préférences du parcours lui-même (hideChecklist, showChecklist, skipStep, resumeStep, et initializeGuidedPath, la règle D9). Le fournisseur en est le seul écrivain et met son état à jour lui-même.

Règle pour la suite : une nouvelle écriture d'une table que lit COUNTERS_SQL s'ajoute à ce tableau et notifie, avec un test qui prouve la notification après un succès et son absence après un échec. Une écriture faite dans withTransaction notifie après la fin de withTransaction, jamais à l'intérieur du rappel : le COMMIT est fait et le verrou relâché.

3. Une attente de 150 ms côté abonné

L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant de relire (followDataChanges, useOnboardingChecklist.ts) : chaque événement relance l'attente, et une rafale, comme les écritures d'un import (source, fichiers, insertions, fin), coûte une seule relecture. Le démontage de l'abonné désabonne et annule une relecture en attente.

4. Les réponses périmées sont ignorées

Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (HIDE, SHOW, SKIP, RESUME) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.

5. Abonnés admis

Un abonné :

  • relit, et n'écrit rien en réponse : une écriture déclenchée par une notification ouvrirait une boucle ;
  • regroupe les rafales par une attente, et ignore les réponses périmées ;
  • se désabonne à son démontage ;
  • ne fait rien de coûteux dans l'appel lui-même, qui est synchrone : il arme un minuteur.

Aujourd'hui, un seul abonné : le fournisseur du parcours de démarrage (OnboardingProvider, par useOnboardingChecklist). Un abonné qui aurait besoin d'autres tables élargit la portée du canal : il amende cet ADR, avec la liste des écritures concernées et leurs tests.

Conséquences

Positives

  • Le parcours suit l'utilisateur en direct, sur toutes les pages, sans retour au tableau de bord.
  • Une seule liste d'écritures, au même endroit que le code qui écrit, et gardée par un test par service.
  • Les services restent ignorants de l'interface : ils n'importent qu'un module sans React.

Négatives et points de vigilance

  • La liste est manuelle. Une écriture oubliée ne coche pas l'étape en direct ; la relecture au changement de page la rattrape.
  • Une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas, alors que des lignes ont pu changer (par exemple insertBatch après quelques insertions). La notification finale d'executeImport, ou la relecture suivante, rattrape.
  • La notification est synchrone. Un abonné lent ralentirait l'écriture qui notifie : la règle des abonnés admis l'interdit.
  • Le canal est global à la fenêtre. Un changement de profil remonte AppShell (BrowserRouter key={refreshKey}) ; le fournisseur du profil précédent se désabonne alors et annule sa relecture en attente.

Alternatives écartées

  • Relire la base à intervalle fixe : des requêtes sans raison sur le verrou unique de db.ts, et une étape qui se coche avec un retard.
  • Un store global de données : contraire à l'ADR 0002, et un store ne sait pas mieux qu'un canal quand la base change.
  • Notifier depuis db.ts à chaque execute : un import écrit une requête par transaction, et le canal ne saurait pas distinguer une table d'une autre ; il deviendrait bavard et sans portée.
  • Des événements typés (table, identifiant) : plus de couplage, et aucun abonné n'en a besoin, puisque les compteurs se relisent en une requête.
  • Faire appeler refresh() par les hooks des pages : chaque page dépendrait du parcours, et une écriture faite ailleurs, comme une restauration depuis Paramètres → Données, serait oubliée.