fix(export): preserve import sources and templates across data export/import #341
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#341
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-331-sref-import-sources"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Exporting then re-importing data destroyed every import configuration.
dataExportServiceserialised only categories, suppliers, keywords and transactions, then ranDELETE FROM import_sourceson restore and replaced them with a synthetic "Data Import" source. After restoring a backup, every source had to be reconfigured by hand.This was the third path by which the import format was lost, independent of the root bug (#324) and of format drift (#330), and the most radical of the three.
What changed
import_sourcesandimport_config_templatesare serialised into the envelope, behind an explicitformat_version. A file without one is the earlier format: its missing arrays are treated as empty and the import behaves as before.withTransaction— the service had none. A constraint violation mid-restore used to abandon the operation and destroy the user's financial history with no rollback.template_idis a foreign key), upserted by name, andimport_sources.template_idis remapped through the resolved ids. Restoring into a profile that already holds templates no longer hitsUNIQUE constraint failed: import_config_templates.name— the normal path, not a theoretical one.amount_modeandsign_conventionare whitelisted at the import boundary with a readable message; the v17CHECKalready refuses bad values at the DB level, but an SQLite constraint error is not a user-facing message.Verification
npm run buildclean (tsc + vite),cargo checkclean (no Rust touched)Recovery note
The worker implementing this link was killed by a session limit during its final validation pass, after writing the code but before committing. The work was recovered from its worktree and verified from scratch (full suite, build, cargo) before this commit. Its decisions log covers only the branch setup; the rationale above was reconstructed from the diff and the issue body, so this PR deserves a closer read than its siblings.
Resolves #331
Generated autonomously by /autopilot run of 2026-08-13
Revue adversariale — PR #341
Verdict : APPROVE — aucun blocage.
Résumé
L'export SREF transporte maintenant
import_sourcesetimport_config_templates, la restauration les remet en place, et les deux chemins destructifs passent enfin par une transaction. Les 6 affirmations du corps ont été vérifiées une à une dans le code, plus les 6 pièges listés au cahier de revue. Elles tiennent toutes. Détail de la vérification ci-dessous, puis 7 suggestions non bloquantes.Ce qui a été vérifié (et tient)
1. Contrat
withTransaction— respecté à la lettre.dataExportService.ts:417-436(runRestore) suit exactement le contrat documenté dansdb.ts:80-104:withTransactionne garantit que l'exclusivité du lock, l'appelant émetBEGIN/COMMIT/ROLLBACK. C'est l'idiome des sites existants (balance.service.ts:1608-1611,categoryMigrationService.ts:205).Le
BEGINest hors dutry: un échec deBEGINne déclenche pas deROLLBACKfantôme. Le drapeauopenrestetruependant leCOMMIT, donc unCOMMITqui échoue déclenche bien unROLLBACK. UnROLLBACKqui échoue est avalé et l'erreur d'origine préservée — correct, c'est elle qui explique la panne.2. Aucun
withTransactionimbriqué. Les 3 fonctions d'import ont exactement 2 appelants hors tests —useDataImport.ts:185-191etcategoryRestoreService.ts:255— aucun des deux n'est dans une transaction.categoryMigrationServicecrée sa sauvegarde avant d'ouvrir la sienne (:180-190). Aucune fonction du corps (restoreCategories,restoreSuppliers,restoreKeywords,restoreImportTemplates,restoreImportSources,attachTransactions) n'appellegetDb(): toutes reçoivent le handle en paramètre. Pas de seconde connexion ouverte depuis l'intérieur.3. Échec à mi-restauration → profil intact. Les 6
DELETE FROMsont bien à l'intérieur durunRestoredans les deux chemins (:568-573et:614-616), pas avant. La liste blanche, elle, est appelée avantrunRestore(:559et:607) : un fichier refusé n'ouvre même pas de transaction — le test:685l'assert littéralement (expect(db.log).not.toContain("BEGIN")).4. Rétrocompatibilité — le chemin sans version est bien l'ancien.
parseImportedJsongarde son contrôle!envelope.dataavant de toucher aux nouveaux tableaux, donc pas deTypeErrorsur un fichier tronqué.validateImportedFormatRows(undefined, undefined)est un no-op (?? []). Fichier v1 → tableaux absents → rien restauré → source hôte créée. Un seul écart de comportement assumé : la source « Data Import » et sa ligneimported_filesne sont plus créées quand le fichier ne porte aucune transaction (attachTransactions:483sort tôt). C'est un mieux, mais c'est bien un écart vs. l'ancien code.5. Remap
template_id— pas de liaison silencieusement fausse. Untemplate_namesans correspondance retombe surNULL(:460-462), jamais sur un modèle local homonyme : la Map ne contient que les noms du fichier. Le sens dangereux (accrocher un modèle local différent) est donc fermé. Reste le sens inverse, cf. suggestion 2.6. La source « Data Import » n'atteint plus le codec avec
'{}'.HOST_SOURCE_MAPPING={date:0,description:1,amount:2}, et c'est exact :serializeTransactionsToCsv:228-238émet les colonnes dans cet ordre,Papa.unparsesépare par virgule,t.dateest en ISO, les dépenses sont négatives — ledelimiter: ","/has_header: 1/skip_lines: 0/%Y-%m-%d/negative_expenseinsérés décrivent réellement ce fichier.formatFromRowl'accepte (les deux index sont numériques).7. Chiffrement — transparent.
write_export_file/read_import_file(export_import_commands.rs:110-137) traitent le contenu comme une chaîne UTF-8 opaque : chiffrement AES-256-GCM des octets, aucun parsing de schéma côté Rust. Ajouter des tableaux à l'enveloppe n'interagit pas avec ce chemin. Le round-trip decreatePreMigrationBackup(sha256 avant/après) reste valide.8. Pas de valeur
NULLcapable de faire refuser une sauvegarde légitime. Point qui aurait pu être un blocage : l'ancien restore insérait ses sources sansamount_modenisign_convention. Vérifié en base — les deux colonnes sontNOT NULL DEFAULT 'single'/'negative_expense'surimport_sources(v17) et surimport_config_templatesdepuis leur création en v5. Les sources écrites par l'ancien code retombent donc dans la liste blanche. Seulabsolute_indicator(admis par leCHECKv17, refusé parAMOUNT_MODES) peut échouer, et aucune UI ne l'écrit — cf. suggestion 4.9. Reste.
ImportSummarygagne 3 champs requis mais n'est construit qu'aux 2 sites mis à jour (parseImportedJson,parseImportedCsv) — aucun autre littéral à corriger. i18n : les 5 clés existent dansfr.jsoneten.json, les deux fichiers restent du JSON valide, et il n'y a pas de collision avec les clés préexistantes de l'assistant (elles vivent sousimport.errorsracine, pas soussettings.dataManagement.import.errors). Aucune migration touchée, aucun secret, logique dansservices/, commentaires en anglais, aucun.skip/.only. CI verte surf377d76. CHANGELOG etdocs/architecture.mdabsents par construction : #332 est le maillon docs+changelog de la milestone.Suggestions (aucune bloquante)
format_versionest écrit mais ne sert à rien en lecture. Il est stampé, remonté dans le résumé, testé — et jamais comparé. Un fichierformat_version: 3produit par une version future serait accepté en silence par ce build, avec la sémantique v2. Refuserformat_version > SREF_FORMAT_VERSIONavec un message dédié ferait faire au champ le travail pour lequel il a été ajouté. (dataExportService.ts:331)L'upsert de modèle écrase un modèle local homonyme au contenu différent.
import_config_templatesn'est dans aucune liste de purge, donc restaurer dans un profil existant remplace le format d'un « Desjardins EOP » local par celui du fichier, sans que rien ne le dise : la modale compte désormais les modèles entrants mais ne signale pas que les homonymes existants sont remplacés. Le commentaire derestoreImportTemplates:1380-1387argumente contre la purge de la table — l'argument est bon, mais l'écrasement par nom produit la même perte sur le sous-ensemble homonyme. Soit le nommer dans la modale, soit renommer en cas de collision.createPreMigrationBackuppeut maintenant échouer là où il réussissait. Il vérifie sa sauvegarde en la relisant parparseImportedJson(categoryBackupService.ts:309), qui valide désormais. Un profil portantamount_mode = 'absolute_indicator'— accepté par leCHECKv17, refusé parAMOUNT_MODES— ne pourrait plus produire de sauvegarde pré-migration (verification_mismatch). Injoignable aujourd'hui par l'UI, mais leCHECKadmet la valeur exprès pour que le 3e mode arrive sans migration : le jour où il arrive, la porte s'ouvre. L'export sort volontairement sans valider (pickFormatRow), mais cette relecture-là annule la propriété sur ce chemin précis.La liste blanche ne couvre que les 2 champs énumérés. Un
.srefédité à la main avecskip_lines: nullouhas_header: "oui"traverse la validation et se casse sur la contrainte SQLite — rollback propre, aucune perte, mais message brut. Cohérent avec le choix documenté de ne pas décodercolumn_mapping; à considérer si le fichier doit être traité comme franchement hostile.La propriété « tout ou rien » est prouvée contre un FakeDb, pas contre SQLite. Le fake snapshote les tables au
BEGINet les restaure auROLLBACK: ça prouve querunRestoreémet leROLLBACK, pas que SQLite défait les 6DELETE. C'est le bon niveau pour un test unitaire et le fake est honnête (il modéliseUNIQUE(name)et la brancheON CONFLICT), mais la garantie centrale de cette PR mériterait une vérification au niveau intégration.useDataExport.performExportinterroge sources et modèles même enformat === "csv", oùserializeTransactionsToCsvles jette. Deux allers-retours DB pour rien (useDataExport.ts:65-70).Note préexistante, pas un défaut de cette PR :
serialize()dansdb.ts:56-66contourne le lock FIFO tant queinTransactionest vrai. Tout appel DB émis depuis l'extérieur pendant la restauration s'exécuterait donc directement sur le pool et pourrait forcer sqlx à ouvrir une 2e connexion, séparant les statements suivants de la restauration de sa transaction ouverte. Inoffensif aux sites existants (transactions courtes) ; ici la transaction couvre la réécriture du profil entier. Vérifié : aucun poller de fond ne touche la DB aujourd'hui, donc rien ne le déclenche — à garder en tête si l'un apparaît.Récapitulatif : les 6 tâches et les 2 critères d'acceptation de #331 sont couverts, y compris les 3 corrections de
/review-spec(transaction, ordre modèles→sources + upsert, liste blanche avec message lisible). Les 7 points ci-dessus sont du durcissement et du suivi, pas des conditions de merge.Mergée dans
mainen fast-forward avec le reste de la pile (tip37b832e).Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son
Resolves #N.Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push
main, cette validation locale était donc le seul filet.Pull request closed