Simpl-Resultat/docs/adr/0021-migrations-profils-secondaires.md
le king fu 02fca2ad3c docs: ADR 0021, guide and changelog for the profile catch-up (#390)
ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose
claims about secondary profiles were false. architecture.md, CLAUDE.md
and the migration rule describe the two migration paths; the guide tells
where the pre-update copy lives and how to remove a database a deletion
on Linux left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:00 -04:00

9.6 KiB

ADR 0021 — Les profils secondaires rattrapent leurs migrations à l'ouverture

  • Status: Accepted
  • Date: 2026-10-06
  • Issues: #390
  • Modifie l'ADR 0003 (le schéma consolidé crée un profil, il ne le tient pas à jour) et l'ADR 0005 (« les migrations s'appliquent indépendamment à chaque base » était faux pour les profils secondaires)

Contexte

Le constat de départ est un bug d'onboarding : classer une transaction à la main ne cochait pas la case 5 de la liste de démarrage. updateTransactionCategory liait le booléen JS true, que tauri-plugin-sql 2.3.2 transmet à sqlx comme une valeur JSON, donc stockée en texte 'true'. La liste compte is_manually_categorized = 1 et ne trouvait rien. Corriger les valeurs déjà en base par une migration v18 a fait apparaître deux défauts plus anciens.

Les profils secondaires ne reçoivent aucune migration. Le plugin associe la liste des migrations à une URL (add_migrations("sqlite:simpl_resultat.db", …)) et la retire au premier Database.load. Un profil secondaire (profile_<id>.db, depuis la v0.3.0) est créé par initializeNewProfileDb avec le schéma consolidé de la version qui le crée, puis n'en reçoit plus aucune. Un profil secondaire créé avant la v0.15.0 n'a donc pas les colonnes de format de import_sources (v17), et un profil créé avant la v0.10.0 n'a ni balance_securities ni balance_accounts.kind. Les commentaires de consolidated_schema.sql et de get_new_profile_init_sql, ainsi que l'ADR 0005, affirmaient le contraire.

Trois commandes cherchaient les bases au mauvais endroit sous Linux. Le plugin range les bases sous app_config_dir() (wrapper.rs : DbPool::connect puis path_mapper). repair_migrations, delete_profile_db et compute_account_return les cherchaient sous app_data_dir(), là où vivent profiles.json et les fichiers de licence. C'est le même dossier sous Windows et macOS, pas sous Linux (~/.config/com.simpl.resultat et ~/.local/share/com.simpl.resultat). Sous Linux, repair_migrations ne faisait donc rien, supprimer un profil laissait sa base sur le disque, et le rendement d'un compte du Bilan échouait.

Décision

À chaque ouverture d'un profil, avant Database.load, la commande prepare_profile_db amène un profil secondaire à la dernière migration, avec la même liste que le profil par défaut, et répare les drapeaux texte de tous les profils. Neuf règles la tiennent ; le code est dans src-tauri/src/database/profile_db.rs.

1. Une seule liste de migrations

migrations() (lib.rs) sert le plugin et le rattrapage. Le test migration_checksums_match_the_released_ones épingle le SHA-384 de chaque version, calculé avant que la liste soit sortie de run(). L'historique d'un profil secondaire vit dans la même table _sqlx_migrations que celui du profil par défaut, au format sqlx : même DDL, même checksum.

2. Un nouveau profil naît avec son historique

get_new_profile_init_sql renvoie le schéma consolidé, puis history_sql, qui inscrit toutes les versions avec leur checksum. Le plugin exécute les deux dans le même flux. Un profil créé depuis #390 n'a donc jamais rien à déduire : les versions suivantes l'atteignent par le rattrapage.

3. Un profil sans historique : un plancher déduit du schéma

Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble fermé, un état par schéma consolidé qu'une version publiée a livré, et chaque état est conservé en fixture sous src-tauri/src/database/fixtures/ :

État Versions qui l'ont créé Plancher
A1 v0.3.0 à v0.8.3, sauf v0.4.0 6
A2 v0.4.0 (seed à trois niveaux, retiré en v0.4.1) 6
B v0.8.4 8
C et D v0.9.0, v0.9.1 (même schéma ; v11 ne touche que des données) 10
E v0.10.0 à v0.14.0 16
F v0.15.0 à v0.17.x 17

infer_floor reconnaît l'état par la dernière colonne ou table ajoutée, en partant de la plus récente. Le plancher vient du schéma, jamais de l'historique : une première écriture interrompue ne peut donc pas faire passer une version inférieure pour une version à appliquer. C'est ce qui interdit de rejouer le seed v2 (DELETE FROM categories) sur une base remplie. Un schéma qu'aucun marqueur ne reconnaît n'est pas touché. Aucun marqueur ne s'ajoute après la v17, puisqu'aucune base sans historique ne peut porter une version postérieure.

4. Une copie avant la première écriture

Quand une version reste à appliquer, la base est d'abord copiée par VACUUM INTO vers <dossier des bases>/migration-backups/<profil>-before-v<N>.db. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme C:\Users\L'Heureux. La copie est écrite en .tmp, synchronisée sur disque, puis renommée : un fichier final existant est donc complet, et il est gardé tel quel. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.

Elle reste dans le dossier des bases, pas dans ~/Documents/Simpl-Resultat/backups/ que la sauvegarde manuelle utilise : ce dossier est souvent synchronisé vers un service en ligne (OneDrive sous Windows), et une copie automatique en clair d'une base financière n'a rien à y faire.

5. Des écritures atomiques

L'historique, soit la table et chaque version jusqu'au plancher, est écrit en une transaction. Chaque version à appliquer s'exécute ensuite dans sa propre transaction. La première erreur arrête le rattrapage et les versions déjà appliquées restent inscrites.

6. Les clés étrangères restent actives

Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étrangère est reprise une fois sans elles. Les lignes qu'elle laisse sans parent repassent à NULL quand leur clé déclare ON DELETE SET NULL ; toute autre violation annule la version. Le cas réel est la v7, qui ajoute trois sous-catégories sous la catégorie 31 : un profil A dont les catégories viennent d'une restauration plus récente n'a plus de catégorie 31, et les trois sous-catégories deviennent des catégories racines. Couper les clés pour tout le rattrapage aurait laissé ces parents introuvables, et la restauration d'une sauvegarde de ce profil aurait échoué.

7. L'échec reste ouvert

Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25).

8. Un seul dossier des bases

profile_db_path résout app_config_dir(), le dossier du plugin, pour les quatre commandes qui ouvrent une base. Il n'accepte que les deux formes de nom que l'application a jamais données : simpl_resultat.db et profile_<hex>.db.

9. Les drapeaux texte sont réparés partout

À chaque ouverture, dans tous les profils, 'true' devient 1 et tout autre texte 0. L'opération est idempotente. Côté TypeScript, toSqlFlag empêche d'écrire de nouveau un booléen, y compris à la restauration d'une sauvegarde qui porte encore "true".

Conséquences

Positives

  • Un profil secondaire ancien retrouve le schéma courant à sa prochaine ouverture, et chaque migration future l'atteint.
  • Sous Linux, supprimer un profil supprime enfin sa base, ses fichiers annexes et ses copies.
  • Les règles sont éprouvées sur les sept états réels, avec des données d'utilisateur : comptes du Bilan convertis par v12 à v16, source d'import complétée par v17, drapeau réparé.

Négatives et points de vigilance

  • Une correction de données livrée seulement en migration n'atteint pas un nouveau profil, qui naît avec l'historique complet. Elle doit aussi entrer dans consolidated_schema.sql (règle .claude/rules/sql-migrations.md). Le test qui compare chaque état rattrapé à un profil neuf ne regarde que le schéma.
  • Deux moteurs appliquent les mêmes migrations : sqlx pour le profil par défaut, rusqlite pour les autres. Le SQL est le même et les clés étrangères sont actives des deux côtés. La reprise sans clés étrangères n'existe que du côté rusqlite.
  • La commande est synchrone. Tauri l'exécute sur le fil principal, ce qui sérialise deux ouvertures du même profil (React StrictMode monte deux fois en développement). L'interface attend pendant un rattrapage, une fois par profil ancien, et la durée est écrite au Journal.
  • Le test qui distingue les deux dossiers ne voit l'écart que sous Linux, la plateforme de la CI. La vérification à la main sous Linux reste due.
  • Les profils supprimés sous Linux avant ce correctif ont laissé leur base dans ~/.config/com.simpl.resultat/. Rien ne supprime automatiquement un fichier que profiles.json ne cite pas : un profiles.json abîmé coûterait alors de vrais profils.

Alternatives écartées

  • Une migration v18 de données, proposée par le corps de #390 : elle n'aurait réparé que le profil par défaut.
  • Enregistrer chaque profil auprès du plugin au démarrage (add_migrations par URL dans setup) : l'état des migrations du plugin est privé et figé au démarrage, un profil créé en cours de session n'y figurerait pas, et sqlx aurait rejoué v1 et v2 sur une base sans historique.
  • Un rattrapage côté TypeScript : le plugin n'expose aucune transaction (voir db.ts), et ni VACUUM INTO ni les clés étrangères ne seraient testables hors de Tauri.
  • Des clés étrangères coupées pendant tout le rattrapage : les actions ON DELETE ne joueraient plus comme sur le profil par défaut, et les lignes sans parent feraient échouer la restauration d'une sauvegarde de ce profil.