- ProfilePreparation.catch_up_backup carries the file name of the copy as soon as it exists, so the Journal error line of a catch-up that stops afterwards (history_write_failed, pragma_failed, apply_pending) names it. - Error lines and the JS fallback message keep only the last segment of a path (withoutFolders): no error line contains a slash or a backslash. - The copy is taken again at every catch-up, written as .tmp, synced and renamed over a same-name copy; after a successful catch-up only, the other copies of that profile are pruned (remove_backups keep argument). - Pins: a pre-existing foreign key violation in the profile without category 31 (RV4, RV7) and the Journal line that reports it (RT2). - Ignored test measuring prepare_at on 100 000 transactions at floor 6: 306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021). - Guide, docs.profiles.tips, ADR 0021, architecture and changelogs. Resolves #395 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 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. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.
Rétention (#395, décision de Max du 2026-10-10) : une seule copie par profil, la plus récente. La copie est refaite à chaque rattrapage, même quand un fichier du même nom existe : laissé par un rattrapage qui s'est arrêté, il précède les données saisies depuis. Le renommage remplace ce fichier, qui n'est jamais supprimé d'abord. Après un rattrapage réussi, et seulement après le renommage, les autres <profil>-before-v*.db (et .db.tmp) de ce profil sont supprimés (remove_backups(dossier, profil, Some(copie)), au mieux : une copie restée ne nuit pas). Rien n'est supprimé quand le rattrapage s'arrête, quand la copie échoue, ni quand rien n'est à appliquer. Coût accepté : tant qu'un rattrapage échoue, chaque ouverture refait un VACUUM INTO.
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). Un rattrapage qui s'arrête après sa copie la nomme quand même : catch_up_backup (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, withoutFolders ne garde que le dernier segment de tout chemin d'une ligne d'erreur, message de repli compris : aucune ligne d'erreur ne contient / ni \.
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 délai de démarrage couvre le rattrapage du profil actif (#395).
App.tsxaffiche la page d'erreur si l'ouverture dépasseSTARTUP_TIMEOUT_MS(10 s,src/utils/startupConnection.ts), délai qui couvrerepair_migrations,prepare_profile_dbetDatabase.load. Mesure (test#[ignore]a_large_profile_catch_up_duration) : un profil au plus bas plancher reconnu (6, schéma v0.3.0) avec 100 001 transactions (17 Mo, 10 001 drapeaux texte), rattrapé de la v7 à la v17 avec la copieVACUUM INTO, leforeign_key_checkfinal et la réparation des drapeaux, prend 306 ms en release et 422 ms en debug, le 2026-10-12, sur le ThinkPad T490 de Max (Intel Core i5-8365U, 4 cœurs / 8 fils, 1,6 à 4,1 GHz, 16 Go, SSD NVMe), sous Linux. Règle fixée en planification : au-delà de 3 s, le délai passait à 30 s ; la mesure étant sous 3 s, il reste à 10 s. Risque résiduel : un disque lent (HDD, antivirus Windows qui inspecte la copie), une base bien plus grosse ou une machine bien plus faible peuvent dépasser 10 s. La page d'erreur s'affiche alors, mais l'ouverture continue et, depuis #395, l'application s'affiche dès qu'elle aboutit (setStartupError(null)au succès). Au-delà de 15 s, l'alerte « profile opening » du verrou dedb.ts(#408) s'écrit aussi au Journal, ce qui est attendu. - 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 queprofiles.jsonne cite pas : unprofiles.jsonabî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_migrationspar URL danssetup) : 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 niVACUUM INTOni 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 DELETEne joueraient plus comme sur le profil par défaut, et les lignes sans parent feraient échouer la restauration d'une sauvegarde de ce profil.