- 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>
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
useReducerpar domaine, pas de store global) et l'ADR 0016 (préférences par profil dansuser_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éécritbudget_entries(DELETEdes mois en double sous la catégorie cible, puisUPDATEde 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, etinitializeGuidedPath, 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
insertBatchaprè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à chaqueexecute: 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.