feat(profiles): name the copy of a stopped catch-up, one copy per profile, startup error cleared (#395) #436

Merged
maximus merged 8 commits from issue-395-catch-up-followups into main 2026-10-14 00:28:51 +00:00
21 changed files with 1047 additions and 127 deletions

View file

@ -5,9 +5,12 @@
### Modifié
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
### Corrigé
- La page d'erreur du démarrage ne reste plus affichée quand le profil finit de s'ouvrir après le délai de 10 secondes : l'application s'affiche dès que le profil est ouvert (#395).
- Le petit badge à côté du bouton « Récupérer le prix » d'un titre boursier, dans le Bilan, affiche désormais « non garanti » au lieu de l'anglais « best-effort » (#389).
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).
### Sécurité

View file

@ -5,9 +5,12 @@
### Changed
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
### Fixed
- The startup error page no longer stays on screen when the profile finishes opening after the 10-second delay: the app now shows as soon as the profile is open (#395).
- In French, the small badge next to the "Fetch price" button of a stock in the Balance sheet now reads "non garanti" instead of the English "best-effort" (#389).
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).
### Security

View file

@ -42,7 +42,9 @@ Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble f
### 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.
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.
@ -56,7 +58,7 @@ Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étr
### 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 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` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
### 8. Un seul dossier des bases
@ -79,6 +81,7 @@ Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant
- **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.tsx` affiche la page d'erreur si l'ouverture dépasse `STARTUP_TIMEOUT_MS` (10 s, `src/utils/startupConnection.ts`), délai qui couvre `repair_migrations`, `prepare_profile_db` et `Database.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 copie `VACUUM INTO`, le `foreign_key_check` final 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 de `db.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 que `profiles.json` ne cite pas : un `profiles.json` abîmé coûterait alors de vrais profils.

View file

@ -148,9 +148,9 @@ Index Bilan (9) — 7 ajoutés en v9 :
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule.
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`.
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. Cause typique : un `getDb()` dans un rappel. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
## Système de migrations
@ -184,7 +184,7 @@ La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique
Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) :
- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases, puis supprimée avec le profil ;
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases ; elle est refaite à chaque rattrapage, une seule est gardée par profil (les plus anciennes sont supprimées après un rattrapage réussi), elle est nommée au Journal même quand le rattrapage s'arrête (`catch_up_backup`), et supprimée avec le profil (#395) ;
- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ;
- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1).
@ -544,7 +544,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
- **Timeout au démarrage** : `App.tsx` appelle `startProfileConnection` (`src/utils/startupConnection.ts`), qui applique un timeout de 10 secondes (`STARTUP_TIMEOUT_MS`) sur `connectActiveProfile()` et réessaie trois fois — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue ; une ouverture qui aboutit après le délai efface l'erreur et affiche l'application (#395, mesure dans l'ADR 0021)
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390)
@ -613,13 +613,13 @@ Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit).
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Le seul faux `t` du dépôt est `markedT` : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. Motif unique, en tête du fichier de test :
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
```ts
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
@ -629,7 +629,7 @@ Options de la fabrique : `language` (« fr » par défaut ; une fonction est rel
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue sur une propriété `useTranslation:` ou `t: (` (ancrées sur une frontière de mot) hors de `src/test/i18nMock.ts`, ou sur un `vi.mock("react-i18next", …)` qui ne passe pas par `i18nMock`.
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
## CI/CD

View file

@ -76,7 +76,7 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données.
- Un profil par défaut est créé automatiquement au premier lancement
- Le NIP est demandé à chaque fois que vous accédez à un profil protégé
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). La copie est supprimée avec le profil
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). Une seule copie est gardée par profil, la plus récente : elle est refaite à chaque mise à jour de la base et supprimée avec le profil
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
- Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres

View file

@ -160,6 +160,7 @@ fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String>
profile_db::remove_backups(
&dir.join(profile_db::MIGRATION_BACKUP_DIR),
db_filename.trim_end_matches(".db"),
None,
)?;
}
Ok(())

View file

@ -75,6 +75,10 @@ pub struct ProfilePreparation {
pub catch_up: Option<CatchUp>,
/// Why the catch-up stopped, when it did. The profile still opens.
pub catch_up_error: Option<String>,
/// File name (never a path) of the copy this opening took before its
/// catch-up, set as soon as the copy exists: a catch-up that stops
/// afterwards still names it, which is when it is needed (#395).
pub catch_up_backup: Option<String>,
/// Rows whose manual-categorization flag was repaired.
pub repaired_flags: usize,
pub elapsed_ms: u64,
@ -328,7 +332,20 @@ fn apply_pending(conn: &mut Connection, pending: &[&Migration], report: &mut Cat
Ok(())
}
/// Bring a secondary profile database to the last migration.
/// `catch_up_into` returning its report, for the tests.
#[cfg(test)]
pub fn catch_up<F>(conn: &mut Connection, migrations: &[Migration], backup: F) -> Result<CatchUp, String>
where
F: FnOnce(&Connection, i64) -> Result<String, String>,
{
let mut report = CatchUp::default();
catch_up_into(conn, migrations, backup, &mut report)?;
Ok(report)
}
/// Bring a secondary profile database to the last migration, filling
/// `report` as it goes: on an error, `report` still holds what was done
/// before it, the name of the copy included (#395).
///
/// - `floor` comes from the schema itself, not from the history, so an
/// interrupted first run can never make a version below it look pending.
@ -345,7 +362,12 @@ fn apply_pending(conn: &mut Connection, pending: &[&Migration], report: &mut Cat
/// declares ON DELETE SET NULL; anything else rolls it back. The case it
/// exists for: v7 adds subcategories under category 31, which a profile
/// whose categories were restored from a newer export no longer has.
pub fn catch_up<F>(conn: &mut Connection, migrations: &[Migration], backup: F) -> Result<CatchUp, String>
pub fn catch_up_into<F>(
conn: &mut Connection,
migrations: &[Migration],
backup: F,
report: &mut CatchUp,
) -> Result<(), String>
where
F: FnOnce(&Connection, i64) -> Result<String, String>,
{
@ -376,10 +398,7 @@ where
return Err("refused_initial_migrations".to_string());
}
let mut report = CatchUp {
floor: Some(floor),
..CatchUp::default()
};
report.floor = Some(floor);
if let Some(first) = pending.first() {
report.backup_file = Some(backup(conn, first.version)?);
@ -399,13 +418,14 @@ where
}
if pending.is_empty() {
return Ok(report);
return Ok(());
}
// Foreign keys on, as sqlx runs the default profile's migrations.
conn.execute_batch("PRAGMA foreign_keys = ON")
fail_point("pragma")
.and_then(|()| conn.execute_batch("PRAGMA foreign_keys = ON").map_err(|e| e.to_string()))
.map_err(|e| format!("pragma_failed: {}", e))?;
if let Err(e) = apply_pending(conn, &pending, &mut report) {
if let Err(e) = apply_pending(conn, &pending, report) {
return Err(if report.applied.is_empty() {
e
} else {
@ -414,7 +434,24 @@ where
}
let left = violations(conn).map_err(read_err)?;
report.fk_violations = count_by_table(left.iter().map(|(table, _, _)| table.as_str()));
Ok(report)
Ok(())
}
#[cfg(test)]
thread_local! {
/// Test only: the step of `catch_up` made to fail on this thread, for the
/// one failure real SQLite offers no way to provoke (`PRAGMA foreign_keys`).
static FAIL_STEP: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
/// Always `Ok` outside tests.
fn fail_point(step: &'static str) -> Result<(), String> {
#[cfg(test)]
if FAIL_STEP.with(|s| s.get()) == Some(step) {
return Err(format!("injected failure at {}", step));
}
let _ = step;
Ok(())
}
/// Normalize the manual-categorization flag of every transaction stored as
@ -428,16 +465,15 @@ pub fn repair_manual_flags(conn: &Connection) -> Result<usize, String> {
}
/// Copy the database to `<dir>/<stem>-before-v<first_pending>.db` and return
/// the file name. The copy is written as `.tmp` and renamed only once
/// complete, so an existing final file is trusted as is and kept; a `.tmp`
/// left by an interrupted run is replaced. The path is bound, never formatted
/// into the SQL, so a folder such as `C:\Users\L'Heureux` works.
/// the file name. The copy is taken afresh at every catch-up (#395): a copy of
/// the same name, left by a catch-up that stopped, predates what was entered
/// since. It is written as `.tmp`, synced, then renamed over that file, which
/// is never deleted first; a `.tmp` left by an interrupted run is replaced.
/// The path is bound, never formatted into the SQL, so a folder such as
/// `C:\Users\L'Heureux` works.
pub fn backup_to(conn: &Connection, dir: &Path, stem: &str, first_pending: i64) -> Result<String, String> {
let name = format!("{}-before-v{}.db", stem, first_pending);
let final_path = dir.join(&name);
if final_path.exists() {
return Ok(name);
}
fs::create_dir_all(dir).map_err(|e| format!("backup_failed: {}", e))?;
let tmp_path = dir.join(format!("{}.tmp", name));
if tmp_path.exists() {
@ -449,8 +485,8 @@ pub fn backup_to(conn: &Connection, dir: &Path, stem: &str, first_pending: i64)
conn.execute("VACUUM INTO ?1", [tmp])
.map_err(|e| format!("backup_failed: {}", e))?;
// VACUUM INTO does not sync its output; the rename must not publish a
// copy a power cut could still truncate. Write access: Windows refuses
// to flush a file opened read-only.
// copy a power cut could still truncate, nor replace a good one with it.
// Write access: Windows refuses to flush a file opened read-only.
fs::OpenOptions::new()
.write(true)
.open(&tmp_path)
@ -496,9 +532,11 @@ pub fn scrub(message: String, dir: &Path) -> String {
message.replace(full.as_ref(), "<profile folder>")
}
/// Remove the catch-up copies of one profile (and any `.tmp` left behind).
/// Returns how many files were removed.
pub fn remove_backups(dir: &Path, stem: &str) -> Result<usize, String> {
/// Remove the catch-up copies of one profile (and any `.tmp` left behind),
/// except the file named `keep`. Returns how many files were removed.
/// `None` when the profile is deleted; `Some(copy)` after a catch-up that
/// succeeded, once its copy is renamed into place, to keep only that one.
pub fn remove_backups(dir: &Path, stem: &str, keep: Option<&str>) -> Result<usize, String> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
@ -510,6 +548,9 @@ pub fn remove_backups(dir: &Path, stem: &str) -> Result<usize, String> {
let entry = entry.map_err(|e| format!("backup_cleanup_failed: {}", e))?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if keep == Some(name) {
continue;
}
if name.starts_with(&prefix) && (name.ends_with(".db") || name.ends_with(".db.tmp")) {
fs::remove_file(entry.path()).map_err(|e| format!("backup_cleanup_failed: {}", e))?;
removed += 1;
@ -545,8 +586,27 @@ pub fn prepare_at(db_path: &Path, db_filename: &str, migrations: &[Migration]) -
if db_filename != DEFAULT_PROFILE_DB {
let backup_dir = dir.join(MIGRATION_BACKUP_DIR);
let stem = db_filename.trim_end_matches(".db");
match catch_up(&mut conn, migrations, |conn, first| backup_to(conn, &backup_dir, stem, first)) {
Ok(done) => report.catch_up = Some(done),
let mut done = CatchUp::default();
let result = catch_up_into(
&mut conn,
migrations,
|conn, first| backup_to(conn, &backup_dir, stem, first),
&mut done,
);
// Named whatever happened next: a stopped catch-up needs it most.
report.catch_up_backup = done.backup_file.clone();
match result {
Ok(()) => {
// Retention (#395): one copy per profile, the one just taken.
// Only now, the catch-up done and the copy renamed into
// place; nothing is pruned on an error or a failed copy.
if let Some(copy) = done.backup_file.as_deref() {
// Best effort: an older copy left behind harms nothing,
// and the next catch-up prunes it.
let _ = remove_backups(&backup_dir, stem, Some(copy));
}
report.catch_up = Some(done);
}
Err(e) => report.catch_up_error = Some(scrub(e, dir)),
}
}
@ -865,6 +925,7 @@ mod tests {
assert_eq!(done.fk_violations, Vec::<(String, i64)>::new(), "{}", state.name);
let expected_backup = format!("profile_0a1b2c3d-before-v{}.db", state.floor + 1);
assert_eq!(done.backup_file.as_deref(), Some(expected_backup.as_str()), "{}", state.name);
assert_eq!(report.catch_up_backup.as_deref(), Some(expected_backup.as_str()), "{}", state.name);
assert_eq!(report.repaired_flags, 1, "{}", state.name);
let conn = open(&path);
@ -1068,6 +1129,7 @@ mod tests {
assert!(done.recorded.is_empty(), "{}", state.name);
assert!(done.applied.is_empty(), "{}", state.name);
assert_eq!(done.backup_file, None, "{}", state.name);
assert_eq!(again.catch_up_backup, None, "{}", state.name);
assert_eq!(again.repaired_flags, 0, "{}", state.name);
assert_eq!(fs::read_dir(dir.join(MIGRATION_BACKUP_DIR)).unwrap().count(), 1, "{}", state.name);
}
@ -1121,14 +1183,24 @@ mod tests {
// subcategories have no parent to point at. v7 is retried without
// foreign keys, and its three rows become root categories, as the key's
// ON DELETE SET NULL declares.
//
// The profile also carries a violation older than the catch-up: a
// transaction left pointing at a category that no longer exists,
// written with foreign keys off. Its key is SET NULL too, so
// only the `before` set keeps the retry of v7 from "repairing" it
// (#395, RV7), and the final check must report it (RV4).
let dir = unique_dir("no31");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]);
open(&path)
.execute_batch(
"DELETE FROM keywords; DELETE FROM categories; \
INSERT INTO categories (id, name, type, sort_order) VALUES (1000, 'Logement', 'expense', 1);",
INSERT INTO categories (id, name, type, sort_order) VALUES (1000, 'Logement', 'expense', 1); \
PRAGMA foreign_keys = OFF; \
INSERT INTO transactions (date, description, amount, category_id) \
VALUES ('2026-02-01', 'Orphan', -10, 4242);",
)
.unwrap();
assert_eq!(violations(&open(&path)).unwrap().len(), 1, "the probe needs its pre-existing violation");
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
@ -1136,10 +1208,16 @@ mod tests {
let done = report.catch_up.unwrap();
assert_eq!(done.applied, versions(7, max_version()));
assert_eq!(done.fk_repaired, vec![("categories".to_string(), 3)]);
assert_eq!(done.fk_violations, Vec::<(String, i64)>::new());
assert_eq!(done.fk_violations, vec![("transactions".to_string(), 1)]);
let conn = open(&path);
assert_history_complete(&conn, "no31");
assert!(violations(&conn).unwrap().is_empty());
let left: Vec<String> = violations(&conn).unwrap().into_iter().map(|(table, _, _)| table).collect();
assert_eq!(left, vec!["transactions".to_string()], "only the pre-existing violation is left");
assert_eq!(
count(&conn, "SELECT category_id FROM transactions WHERE description = 'Orphan'"),
4242,
"a violation older than the catch-up is reported, never touched"
);
assert_eq!(
count(&conn, "SELECT COUNT(*) FROM categories WHERE id IN (310, 311, 312) AND parent_id IS NULL"),
3
@ -1292,18 +1370,186 @@ mod tests {
}
#[test]
fn a_complete_backup_is_kept() {
let dir = unique_dir("kept");
fn a_copy_of_the_same_name_is_refreshed() {
// Left by a catch-up that stopped at v17: it predates what was entered
// since, so the next catch-up takes the copy again (#395).
let dir = unique_dir("refreshed");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
fs::create_dir_all(&backups).unwrap();
let existing = backups.join("profile_0a1b2c3d-before-v17.db");
fs::write(&existing, b"earlier copy").unwrap();
seed_user_rows(&open(&path));
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up.unwrap().applied, vec![17]);
assert_eq!(fs::read(&existing).unwrap(), b"earlier copy");
assert_eq!(report.catch_up_backup.as_deref(), Some("profile_0a1b2c3d-before-v17.db"));
assert_ne!(fs::read(&existing).unwrap(), b"earlier copy");
let copy = open(&existing);
assert_eq!(count(&copy, "SELECT COUNT(*) FROM transactions"), 1, "the copy holds today's data");
assert!(!column_exists(&copy, "import_sources", "header_signature").unwrap());
assert!(!backups.join("profile_0a1b2c3d-before-v17.db.tmp").exists());
}
/// Copies an earlier catch-up left: two of this profile, one of another.
fn plant_old_copies(backups: &Path) -> Vec<&'static str> {
fs::create_dir_all(backups).unwrap();
let planted = vec![
"profile_0a1b2c3d-before-v7.db",
"profile_0a1b2c3d-before-v12.db.tmp",
"profile_99999999-before-v7.db",
];
for name in &planted {
fs::write(backups.join(name), b"old copy").unwrap();
}
planted
}
fn files_in(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
names.sort();
names
}
#[test]
fn a_successful_catch_up_keeps_only_its_own_copy() {
let dir = unique_dir("pruned");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
plant_old_copies(&backups);
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up_backup.as_deref(), Some("profile_0a1b2c3d-before-v17.db"));
assert_eq!(
files_in(&backups),
vec!["profile_0a1b2c3d-before-v17.db", "profile_99999999-before-v7.db"],
"older copies of this profile pruned, another profile's kept"
);
assert!(has_table(&open(&backups.join("profile_0a1b2c3d-before-v17.db")), "transactions"));
}
#[test]
fn nothing_is_pruned_without_a_copy_taken() {
// Nothing pending: no copy, so no retention either.
let dir = unique_dir("no-prune-idle");
let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let mut planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
planted.sort();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up_backup, None);
assert_eq!(files_in(&backups), planted);
}
/// The live migrations up to v16, then a v17 that works and a v18 that
/// fails halfway.
fn list_failing_at_v18() -> Vec<Migration> {
let mut list: Vec<Migration> = migrations().into_iter().filter(|m| m.version <= 16).collect();
list.push(Migration {
version: 17,
description: "works",
sql: "CREATE TABLE ok_one (x INTEGER);",
kind: MigrationKind::Up,
});
list.push(Migration {
version: 18,
description: "breaks halfway",
sql: "CREATE TABLE half_done (x INTEGER); INSERT INTO no_such_table VALUES (1);",
kind: MigrationKind::Up,
});
list
}
/// The copy a stopped catch-up took is named in the report and still on
/// disk, and the older copies are all kept.
fn assert_stopped_after_the_copy(report: &ProfilePreparation, backups: &Path, prefix: &str, planted: &[String]) {
let error = report.catch_up_error.as_deref().unwrap();
assert!(error.starts_with(prefix), "{}", error);
assert_eq!(report.catch_up, None);
let copy = report.catch_up_backup.as_deref().unwrap();
assert_eq!(copy, "profile_0a1b2c3d-before-v17.db", "a file name, never a path");
assert!(has_table(&open(&backups.join(copy)), "transactions"));
let mut expected: Vec<String> = planted.to_vec();
expected.push(copy.to_string());
expected.sort();
assert_eq!(files_in(backups), expected, "nothing is pruned when the catch-up stops");
}
#[test]
fn a_failing_version_names_the_copy_and_prunes_nothing() {
let dir = unique_dir("stop-apply");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
let report = prepare_at(&path, SECONDARY, &list_failing_at_v18()).unwrap();
assert_stopped_after_the_copy(&report, &backups, "migration_failed:v18", &planted);
assert!(report.catch_up_error.as_deref().unwrap().ends_with("(applied before stopping: [17])"));
}
#[test]
fn a_failed_history_write_names_the_copy_and_prunes_nothing() {
// A history table that refuses every row: the copy is taken, then the
// first write of the run fails.
let dir = unique_dir("stop-history");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
open(&path)
.execute_batch(&format!(
"{} CREATE TRIGGER no_history BEFORE INSERT ON _sqlx_migrations \
BEGIN SELECT RAISE(ABORT, 'history refused'); END;",
SQLX_MIGRATIONS_DDL
))
.unwrap();
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
assert_stopped_after_the_copy(&report, &backups, "history_write_failed", &planted);
}
#[test]
fn a_failed_pragma_names_the_copy_and_prunes_nothing() {
let dir = unique_dir("stop-pragma");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
FAIL_STEP.with(|s| s.set(Some("pragma")));
let report = prepare_at(&path, SECONDARY, &migrations());
FAIL_STEP.with(|s| s.set(None));
assert_stopped_after_the_copy(&report.unwrap(), &backups, "pragma_failed", &planted);
assert!(!column_exists(&open(&path), "import_sources", "header_signature").unwrap());
}
#[test]
fn a_failed_copy_prunes_nothing() {
// A folder where the `.tmp` goes: removing it fails, so does the copy.
let dir = unique_dir("stop-copy");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]);
let backups = dir.join(MIGRATION_BACKUP_DIR);
let mut planted: Vec<String> = plant_old_copies(&backups).into_iter().map(String::from).collect();
fs::create_dir(backups.join("profile_0a1b2c3d-before-v17.db.tmp")).unwrap();
planted.push("profile_0a1b2c3d-before-v17.db.tmp".to_string());
planted.sort();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
let error = report.catch_up_error.unwrap();
assert!(error.starts_with("backup_failed"), "{}", error);
assert_eq!(report.catch_up_backup, None, "no copy, nothing to name");
assert_eq!(files_in(&backups), planted);
}
#[test]
@ -1363,14 +1609,24 @@ mod tests {
fs::write(dir.join(name), b"x").unwrap();
}
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d"), Ok(2));
let mut left: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
left.sort();
assert_eq!(left, vec!["profile_0a1b2c3d-notes.txt", "profile_99999999-before-v17.db"]);
assert_eq!(remove_backups(&dir.join("absent"), "profile_0a1b2c3d"), Ok(0));
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d", None), Ok(2));
assert_eq!(files_in(&dir), vec!["profile_0a1b2c3d-notes.txt", "profile_99999999-before-v17.db"]);
assert_eq!(remove_backups(&dir.join("absent"), "profile_0a1b2c3d", None), Ok(0));
}
#[test]
fn remove_backups_keeps_the_named_copy() {
let dir = unique_dir("cleanup-keep");
for name in [
"profile_0a1b2c3d-before-v7.db",
"profile_0a1b2c3d-before-v17.db",
"profile_0a1b2c3d-before-v18.db.tmp",
] {
fs::write(dir.join(name), b"x").unwrap();
}
assert_eq!(remove_backups(&dir, "profile_0a1b2c3d", Some("profile_0a1b2c3d-before-v17.db")), Ok(2));
assert_eq!(files_in(&dir), vec!["profile_0a1b2c3d-before-v17.db"]);
}
// -- the default profile, missing files and the repair ------------------
@ -1442,6 +1698,73 @@ mod tests {
assert_eq!(repair_manual_flags(&conn), Ok(0));
}
// -- the startup budget (#395) -------------------------------------------
/// How long `prepare_at` takes on a large profile at the lowest floor
/// `infer_floor` knows (6, the v0.3.0 schema): copy (`VACUUM INTO`),
/// versions 7 to 17, the final `foreign_key_check` and the flag repair,
/// all inside the startup delay of `App.tsx`. Prints, never asserts:
/// `cargo test --release -- --ignored a_large_profile_catch_up_duration --nocapture`.
/// The measure and the rule it fed are in ADR 0021 (points de vigilance).
#[test]
#[ignore]
fn a_large_profile_catch_up_duration() {
const TRANSACTIONS: i64 = 100_000;
let dir = unique_dir("large");
let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]);
{
let mut conn = open(&path);
seed_user_rows(&conn);
let categories: Vec<i64> = conn
.prepare("SELECT id FROM categories ORDER BY id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let tx = conn.transaction().unwrap();
{
let mut insert = tx
.prepare(
"INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, \
is_manually_categorized) VALUES (?1, ?2, ?3, ?4, 1, 1, ?5)",
)
.unwrap();
for i in 0..TRANSACTIONS {
// One in ten classified by hand while the bug stood.
let flag: rusqlite::types::Value = if i % 10 == 0 { "true".to_string().into() } else { 0_i64.into() };
insert
.execute(params![
format!("20{:02}-{:02}-{:02}", 20 + i % 7, 1 + i % 12, 1 + i % 28),
format!("PURCHASE {} STORE {}", i, i % 500),
-((i % 20_000) as f64) / 100.0,
categories[(i as usize) % categories.len()],
flag
])
.unwrap();
}
}
tx.commit().unwrap();
}
let size = fs::metadata(&path).unwrap().len();
let started = Instant::now();
let report = prepare_at(&path, SECONDARY, &migrations()).unwrap();
let elapsed = started.elapsed();
assert_eq!(report.catch_up_error, None);
assert_eq!(report.catch_up.as_ref().unwrap().floor, Some(6));
println!(
"prepare_at: {} transactions, {:.1} MB, floor 6 -> v{}, {} flags repaired: {} ms (report: {} ms)",
TRANSACTIONS + 1,
size as f64 / 1_048_576.0,
max_version(),
report.repaired_flags,
elapsed.as_millis(),
report.elapsed_ms
);
}
// -- the migration list itself ------------------------------------------
#[test]

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useProfile } from "./contexts/ProfileContext";
import AppShell from "./components/layout/AppShell";
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature";
const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1_000;
import { startProfileConnection } from "./utils/startupConnection";
export default function App() {
const { t } = useTranslation();
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
const [dbReady, setDbReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
if (activeProfile && !isLoading) {
setDbReady(false);
setStartupError(null);
cancelledRef.current = false;
timeoutRef.current = setTimeout(() => {
setStartupError(t("error.startupTimeout"));
}, STARTUP_TIMEOUT_MS);
const attemptConnect = async (attempt: number): Promise<void> => {
try {
await connectActiveProfile();
if (cancelledRef.current) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setDbReady(true);
} catch (err) {
if (cancelledRef.current) return;
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
if (!cancelledRef.current) return attemptConnect(attempt + 1);
} else {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setStartupError(err instanceof Error ? err.message : String(err));
}
}
};
attemptConnect(1);
}
return () => {
cancelledRef.current = true;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
if (!activeProfile || isLoading) return;
return startProfileConnection({
connect: connectActiveProfile,
setDbReady,
setStartupError,
timeoutMessage: t("error.startupTimeout"),
});
}, [activeProfile, isLoading, connectActiveProfile, t]);
if (isLoading) {

View file

@ -7,13 +7,17 @@
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
* module itself that:
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
* 2. declares a `t: (` property (a hand-written fake `t`);
* 3. mocks `react-i18next` without going through `i18nMock`.
* 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
* function that takes `t`);
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
* alone, in a comment for instance, is not enough: comments are dropped
* before the search).
*
* Keywords rather than an analysis of the factory: less fragile. The matches
* are anchored on a word boundary, so `useLicenseContext: () =>` or
* `get: (target, key)` are not a `t: (`. This file names the patterns only
* inside regular expressions and excludes itself.
* are anchored on a word boundary, so `useLicenseContext: () =>`,
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
* file names the patterns only inside regular expressions and excludes itself.
*/
import { describe, it, expect } from "vitest";
@ -25,8 +29,14 @@ const MODULE = resolve(SRC, "test", "i18nMock.ts");
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
const LOCAL_MODULE = /\buseTranslation\s*:/;
const LOCAL_T = /\bt\s*:\s*\(/;
const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** The offending lines of one source, as `path:line: text`. */
function offences(path: string, source: string): string[] {
@ -36,7 +46,7 @@ function offences(path: string, source: string): string[] {
.flatMap((line, i) =>
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
);
if (MOCKS_I18NEXT.test(source) && !source.includes("i18nMock")) {
if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
found.push(`${name}: mocks react-i18next without i18nMock`);
}
return found;
@ -66,10 +76,35 @@ describe("i18n mock guard (#389)", () => {
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
"await view.removeKey({ confirm, t: markedT });",
"const at = (n: number) => n; const format = (v: string) => v;",
"if (t === (other as unknown)) return; const run = (t) => t;",
"const t = markedT; const set = { t: markedT };",
].join("\n");
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
});
it("recognises a fake t bound to a name or built with vi.fn", () => {
const x = resolve(SRC, "x.test.ts");
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
});
it("requires an actual call to i18nMock(, not its name in a comment", () => {
const x = resolve(SRC, "x.test.tsx");
const inComment = [
"// Not i18nMock(): a hand-written module.",
"/* i18nMock( would be the pattern */",
'vi.mock("react-i18next", () => fake);',
].join("\n");
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
expect(offences(x, called)).toEqual([]);
});
it("finds no other fake t than the one of src/test/i18nMock.ts", () => {
const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);

View file

@ -378,6 +378,8 @@ describe("PriceFetchControl — displayed texts", () => {
expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
expect(html).toContain(">t(balance.priceFetching.button)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
expect(html).toContain(">t(balance.priceFetching.bestEffortBadge)<");
expect(html).not.toContain(">best-effort<");
expect(html).toContain('aria-label="t(common.close)"');
});
});

View file

@ -201,7 +201,7 @@ export default function PriceFetchControl({
{/* Best-effort badge (stock only) */}
{assetType === "stock" && (
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
best-effort
{t("balance.priceFetching.bestEffortBadge")}
</span>
)}
</button>

View file

@ -125,7 +125,8 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
vi.useFakeTimers();
harness.dispatch.mockReset();
// A route change re-runs the read, not the follower: one subscription only.
// A route change re-runs the read (that it leaves the follower and its
// pending re-read alone is pinned by the next test).
ctx.pathname = "/reports";
render();
harness.commit();
@ -146,6 +147,31 @@ describe("useOnboardingChecklist — wiring (#396)", () => {
expect(actionsOfType("LOAD_START")).toHaveLength(1);
});
it("a route change during the 150 ms wait does not drop the deferred re-read", async () => {
render();
harness.commit();
await flush();
vi.useFakeTimers();
harness.dispatch.mockReset();
notifyDataChanged();
vi.advanceTimersByTime(100);
// The route changes while the re-read waits: the read effect runs again,
// the follower (deps: [refresh]) must not be cleaned up and resubscribed,
// which would drop its pending timer.
ctx.pathname = "/reports";
render();
harness.commit();
await flush();
expect(actionsOfType("LOAD_START")).toHaveLength(1);
vi.advanceTimersByTime(50);
await flush();
// One read for the route, one for the data event.
expect(actionsOfType("LOAD_START")).toHaveLength(2);
expect(getOnboardingSnapshot).toHaveBeenCalledTimes(3);
});
it("stores a card latch that changed during render, and only then", () => {
mountWith({ ...ready, cardPage: "/import" });
ctx.pathname = "/reports";

View file

@ -936,7 +936,7 @@
"A default profile is automatically created on first launch",
"The PIN is requested each time you access a protected profile",
"Deleting a profile permanently removes all its data — this action cannot be undone",
"When you open a profile created by an earlier version, the app updates its database after keeping a copy in the migration-backups folder, next to the databases (on Linux ~/.config/com.simpl.resultat/, on Windows %APPDATA%\\com.simpl.resultat\\). The copy is deleted with the profile",
"When you open a profile created by an earlier version, the app updates its database after keeping a copy in the migration-backups folder, next to the databases (on Linux ~/.config/com.simpl.resultat/, on Windows %APPDATA%\\com.simpl.resultat\\). One copy is kept per profile, the most recent: it is taken again at each database update and deleted with the profile",
"On Linux, versions up to 0.17.0 left a deleted profile's database on disk (~/.config/com.simpl.resultat/profile_….db); you can delete that file by hand",
"The getting-started path belongs to each profile: its steps tick from the profile's data, and hiding it in one profile does not hide it in the others"
]
@ -2293,6 +2293,7 @@
"button": "Fetch price",
"tooltipNotPremium": "Available with premium subscription",
"bestEffortNotice": "Source not guaranteed, may be unavailable. Manual input remains primary.",
"bestEffortBadge": "best-effort",
"attribution": "via Maximus on {{date}}",
"consent": {
"title": "Price fetching via Maximus",

View file

@ -936,7 +936,7 @@
"Un profil par défaut est créé automatiquement au premier lancement",
"Le NIP est demandé à chaque fois que vous accédez à un profil protégé",
"La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible",
"À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier migration-backups, à côté des bases (sous Linux ~/.config/com.simpl.resultat/, sous Windows %APPDATA%\\com.simpl.resultat\\). La copie est supprimée avec le profil",
"À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier migration-backups, à côté des bases (sous Linux ~/.config/com.simpl.resultat/, sous Windows %APPDATA%\\com.simpl.resultat\\). Une seule copie est gardée par profil, la plus récente : elle est refaite à chaque mise à jour de la base et supprimée avec le profil",
"Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (~/.config/com.simpl.resultat/profile_….db) ; vous pouvez supprimer ce fichier à la main",
"Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres"
]
@ -2293,6 +2293,7 @@
"button": "Récupérer le prix",
"tooltipNotPremium": "Disponible avec abonnement premium",
"bestEffortNotice": "Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire.",
"bestEffortBadge": "non garanti",
"attribution": "via Maximus le {{date}}",
"consent": {
"title": "Récupération de prix via Maximus",

View file

@ -45,6 +45,7 @@ import {
initializeNewProfileDb,
TransactionHandleCloseError,
TransactionHandleRevokedError,
withoutFolders,
withTransaction,
type ProfilePreparation,
} from "./db";
@ -321,6 +322,65 @@ describe("profile switch goes through the lock (#408)", () => {
await expect(db.execute("AFTER")).rejects.toBeInstanceOf(DbConnectionClosedError);
});
it("close() on a getDb() handle kept from the previous profile rejects and keeps the current one open", async () => {
const before = await getDb();
const next = makeFakeDb("next");
h.fakeDb = next;
await connectToProfile("next.db");
events.length = 0;
await expect(before.close()).rejects.toBeInstanceOf(DbConnectionClosedError);
expect(next.close).not.toHaveBeenCalled();
await (await getDb()).execute("STILL OPEN");
expect(next.execute).toHaveBeenCalledWith("STILL OPEN", undefined);
});
it("close() on the current getDb() handle closes the connection", async () => {
const current = h.fakeDb;
await (await getDb()).close();
expect(current.close).toHaveBeenCalledTimes(1);
await expect(getDb()).rejects.toThrow(/No database connection/);
});
it("a failing close of the old pool does not prevent the switch, and goes to the Journal", async () => {
vi.mocked(logWarn).mockClear();
const old = h.fakeDb;
const before = await getDb();
vi.mocked(old.close).mockImplementation(async () => {
throw new Error("close refused for /home/someone/.config/x.db");
});
const next = makeFakeDb("next");
h.fakeDb = next;
await connectToProfile("next.db");
expect(old.close).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(
"DB: closing the previous connection failed, continuing without it: close refused for x.db"
);
await (await getDb()).execute("ON NEXT");
expect(next.execute).toHaveBeenCalledWith("ON NEXT", undefined);
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
expect(old.execute).not.toHaveBeenCalled();
});
it("closeDb with a failing close resolves, warns, and leaves a coherent state without connection", async () => {
vi.mocked(logWarn).mockClear();
const old = h.fakeDb;
const before = await getDb();
vi.mocked(old.close).mockImplementation(async () => {
throw new Error("close refused");
});
await expect(closeDb()).resolves.toBeUndefined();
expect(logWarn).toHaveBeenCalledWith("DB: closing the previous connection failed, continuing without it: close refused");
// No connection, and the generation moved: the old handle refuses to run.
await expect(getDb()).rejects.toThrow(/No database connection/);
await expect(withTransaction(async () => undefined)).rejects.toThrow(/No database connection/);
await expect(before.execute("ON OLD")).rejects.toBeInstanceOf(DbConnectionClosedError);
expect(old.execute).not.toHaveBeenCalled();
// The old pool is no longer referenced: the next open does not close it again.
h.fakeDb = makeFakeDb("next");
await connectToProfile("next.db");
expect(old.close).toHaveBeenCalledTimes(1);
});
it("initializeNewProfileDb runs its SQL inside the section, before a queued query", async () => {
const next = makeFakeDb("new");
h.fakeDb = next;
@ -370,6 +430,8 @@ describe("blocking alert (#408)", () => {
const message = String(vi.mocked(logWarn).mock.calls[0][0]);
expect(message).toContain("a query has waited 15 s");
expect(message).toContain(`held by a transaction since ${startedAt}`);
// A cause the reader may check, not a diagnosis: a long restore also waits 15 s.
expect(message).toContain("(possible cause: a getDb() call inside a withTransaction callback");
// Once per operation: still one warning much later.
await vi.advanceTimersByTimeAsync(BLOCKED_ALERT_MS * 3);
expect(logWarn).toHaveBeenCalledTimes(1);
@ -381,6 +443,32 @@ describe("blocking alert (#408)", () => {
}
});
it("says the lock is free, not who held it last, when the check lands between two operations", async () => {
// In the app only microtasks separate the end of one operation from the
// start of the next, so a timer cannot fire there; the test puts the check
// there to pin that the holder is cleared when an operation ends.
const db = await getDb();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
vi.mocked(h.fakeDb.execute).mockImplementationOnce(async () => {
await gate;
return { rowsAffected: 0 };
});
const first = db.execute("LONG");
const waiting = db.select("WAITING");
// Registered after the queue's own reaction: runs once the first operation
// ended (holder cleared), before the waiting one starts.
const checked = first.then(() => vi.advanceTimersByTime(BLOCKED_ALERT_MS));
release();
await checked;
await vi.advanceTimersByTimeAsync(10);
await waiting;
expect(logWarn).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(logWarn).mock.calls[0][0])).toBe("DB lock: a query has waited 15 s behind the queue");
});
it("says nothing for an operation that got the lock in time", async () => {
const db = await getDb();
const run = db.execute("QUICK");
@ -420,7 +508,14 @@ describe("blocking alert (#408)", () => {
// ---------------------------------------------------------------------------
function report(overrides: Partial<ProfilePreparation> = {}): ProfilePreparation {
return { catch_up: null, catch_up_error: null, repaired_flags: 0, elapsed_ms: 12, ...overrides };
return {
catch_up: null,
catch_up_error: null,
catch_up_backup: null,
repaired_flags: 0,
elapsed_ms: 12,
...overrides,
};
}
function catchUp(overrides: Partial<NonNullable<ProfilePreparation["catch_up"]>> = {}) {
@ -489,6 +584,34 @@ describe("a profile is prepared before the plugin opens it (#390)", () => {
);
});
it("writes no folder when the preparation fails with a path (#395)", async () => {
h.prepare = async () => {
throw new Error(
"Cannot read /home/someone/.config/com.simpl.resultat/profile_0a1b2c3d.db nor C:\\Users\\L'Heureux\\AppData\\x.db"
);
};
await connectToProfile("profile_0a1b2c3d.db");
expect(logError).toHaveBeenCalledWith(
"Profile profile_0a1b2c3d.db: database preparation failed: Cannot read profile_0a1b2c3d.db nor x.db"
);
});
it("names the copy in the Journal when the catch-up stopped after taking it (#395)", async () => {
h.prepare = async () =>
report({
catch_up_error: "history_write_failed: history refused",
catch_up_backup: "profile_0a1b2c3d-before-v17.db",
});
await connectToProfile("profile_0a1b2c3d.db");
expect(vi.mocked(logError).mock.calls.map((call) => call[0])).toEqual([
"Profile profile_0a1b2c3d.db: database update stopped: history_write_failed: history refused; copy of the previous state kept as profile_0a1b2c3d-before-v17.db",
]);
});
it("writes what changed to the Journal", async () => {
h.prepare = async () =>
report({
@ -533,4 +656,97 @@ describe("describePreparation (#390)", () => {
const lines = describePreparation("x.db", report({ catch_up_error: "migration_failed:v12: boom" }));
expect(lines.errors).toEqual(["Profile x.db: database update stopped: migration_failed:v12: boom"]);
});
it("names the rows that already pointed to a missing row (RT2, #395)", () => {
const lines = describePreparation(
"x.db",
report({ catch_up: catchUp({ applied: [7], fk_violations: [["transactions", 1]] }) })
);
expect(lines.info).toContain("Profile x.db: 1 row(s) of transactions point to a missing row");
});
// The three ways a catch-up stops after its copy (profile_db.rs), each with
// the message shape Rust produces.
it.each([
["history_write_failed: history refused"],
["pragma_failed: database is locked"],
["migration_failed:v18: no such table: no_such_table (applied before stopping: [17])"],
])("names the copy taken before a catch-up that stopped on %s (#395)", (error) => {
const lines = describePreparation(
"x.db",
report({ catch_up_error: error, catch_up_backup: "x-before-v17.db" })
);
expect(lines.errors).toEqual([
`Profile x.db: database update stopped: ${error}; copy of the previous state kept as x-before-v17.db`,
]);
});
it("writes no folder in an error line (#395)", () => {
const lines = describePreparation(
"x.db",
report({
// What scrub leaves of a real VACUUM INTO failure, plus a copy name a
// regression would turn into a path, on both separators.
catch_up_error:
"backup_failed: unable to open database: <profile folder>/migration-backups/x-before-v17.db.tmp",
catch_up_backup: "C:\\Users\\someone\\migration-backups\\x-before-v17.db",
})
);
expect(lines.errors).toEqual([
"Profile x.db: database update stopped: backup_failed: unable to open database: x-before-v17.db.tmp; copy of the previous state kept as x-before-v17.db",
]);
for (const line of lines.errors) {
expect(line).not.toMatch(/[\\/]/);
}
});
it("withoutFolders keeps text without a path as is", () => {
expect(withoutFolders("migration_failed:v12: near \"x\": syntax error")).toBe(
'migration_failed:v12: near "x": syntax error'
);
expect(withoutFolders("a /home/u/f.db, b")).toBe("a f.db, b");
// A separator that is no path: kept readable, still no slash.
expect(withoutFolders("pragma_failed: disk I/O error")).toBe("pragma_failed: disk I-O error");
});
// A folder holding a blank must not leave its first word behind (#436 review, S1).
it.each([
["C:\\Users\\Jean Dupont\\AppData\\Roaming\\com.simpl.resultat\\profile_x.db", "profile_x.db"],
["C:/Users/Jean Dupont/x.db", "x.db"],
["/home/marie claire/.config/x.db", "x.db"],
["~/Jean Dupont/x.db", "x.db"],
["\\\\server\\share\\Jean Dupont\\x.db", "x.db"],
["C:\\Program Files (x86)\\Simpl\\x.db", "x.db"],
['"C:\\Users\\Jean Dupont\\x.db" failed', '"x.db" failed'],
["(see /home/marie claire/x.db) and I/O", "(see x.db) and I-O"],
["path=/home/marie claire/x.db;next", "path=x.db;next"],
])("withoutFolders reduces %s to its last segment", (input, expected) => {
const cleaned = withoutFolders(input);
expect(cleaned).toBe(expected);
expect(cleaned).not.toMatch(/Jean|Dupont|marie|claire|Users|home|Program|server/);
});
it("withoutFolders ends a path at sentence punctuation and at the next path", () => {
expect(withoutFolders("unable to open /home/marie claire/x.db: disk I/O error")).toBe(
"unable to open x.db: disk I-O error"
);
expect(withoutFolders("read /home/marie claire/a.db, then C:\\Users\\Jean Dupont\\b.db")).toBe(
"read a.db, then b.db"
);
expect(withoutFolders("'C:\\a\\b.db' and 'C:\\Users\\Jean Dupont\\d.db'")).toBe("'b.db' and 'd.db'");
});
it.each([["\""], [";"], ["|"], ["`"], ["\n"]])("withoutFolders ends the folders at %j", (stop) => {
expect(withoutFolders(`/home/marie claire/x.db${stop} I/O`)).toBe(`x.db${stop} I-O`);
});
it("withoutFolders keeps a single quote around the file name, both sides", () => {
expect(withoutFolders("'C:\\Users\\Bob\\x.db'")).toBe("'x.db'");
expect(withoutFolders("cannot open '/home/marie claire/x.db' now")).toBe("cannot open 'x.db' now");
});
it("withoutFolders drops more text rather than a folder when nothing ends the path", () => {
// Conservative: everything up to the last separator of the line goes.
expect(withoutFolders("/home/marie claire/x.db then I/O")).toBe("O");
});
});

View file

@ -41,6 +41,11 @@ let serializedInstance: Database | null = null;
// generation when its operation leaves the queue: an operation queued for a
// connection that was closed meanwhile throws `DbConnectionClosedError`
// rather than running on the next profile's database or on a closed pool.
// `close()` on a `getDb()` handle is checked the same way, so a handle kept
// from a previous profile cannot close the current one.
// - A failing `close()` of the old pool never blocks a switch: the state is
// already cleared and the generation bumped, the failure goes to the Journal
// and the next profile still opens (see `closeUnlocked`).
// ---------------------------------------------------------------------------
/** An operation queued for a connection closed before it could run. */
@ -77,10 +82,13 @@ type LockKind =
/**
* An operation still waiting for the lock after this delay writes ONE warning
* to the Journal, naming what holds the lock and since when. The typical cause
* is a `getDb()` call from inside a `withTransaction` callback (deadlock). A
* profile catch-up (`prepare_profile_db`) that takes longer also triggers it,
* naming a "profile opening": expected, nothing can run during a switch.
* to the Journal, naming what holds the lock and since when. One possible
* cause is a `getDb()` call from inside a `withTransaction` callback
* (deadlock), but a long transaction (a large restore, a category migration on
* a big profile) also exceeds the delay, so the warning names it as a possible
* cause only. A profile catch-up (`prepare_profile_db`) that takes longer also
* triggers it, naming a "profile opening": expected, nothing can run during a
* switch.
* `logWarn` only writes to memory and sessionStorage, never to the DB, so it
* is safe to call while the lock is held.
*/
@ -102,7 +110,7 @@ function runExclusive<T>(kind: LockKind, fn: () => Promise<T>): Promise<T> {
holder
? `DB lock: a ${kind} has waited ${waited} s, the lock is held by a ${holder.kind} since ${new Date(holder.since).toISOString()}` +
(holder.kind === "transaction"
? " (a getDb() call inside a withTransaction callback deadlocks: use the callback's handle)"
? " (possible cause: a getDb() call inside a withTransaction callback, which deadlocks; the callback must use its own handle)"
: "")
: `DB lock: a ${kind} has waited ${waited} s behind the queue`
);
@ -157,7 +165,13 @@ function serialize(db: Database, handleGeneration: number): Database {
});
}
if (prop === "close") {
return () => closeDb();
// Same check as execute/select: a handle kept from a previous profile
// must not close the current profile's connection (#408 review).
return () =>
runExclusive("connection close", async () => {
assertGeneration(handleGeneration);
await closeUnlocked();
});
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
@ -169,6 +183,11 @@ function serialize(db: Database, handleGeneration: number): Database {
* The handle a `withTransaction` callback receives: runs directly on the
* connection (the transaction already holds the lock), refuses `close()`, and
* throws once `revoke()` ran at the end of the callback.
*
* Its generation check is a defence, not reachable today: only the exclusive
* profile sections change the generation, and none of them can run while a
* transaction holds the lock, so nothing closes the connection under a
* running callback. It stays in case a future path closes without the lock.
*/
function transactionHandle(
db: Database,
@ -247,13 +266,29 @@ export async function withTransaction<T>(
// Internal helpers for the profile sections below. They NEVER take the lock:
// only call them from inside a `runExclusive` section.
/**
* Drop the current connection, then close its pool. Never rejects: the state
* is cleared and the generation bumped BEFORE `close()`, so a failing close
* leaves a coherent "no connection" state (every older handle refuses to run)
* and only writes a warning to the Journal. A switch therefore still opens the
* next profile, and `closeDb()` resolves: the app no longer references the old
* pool, and keeping it would leave the app without any usable connection.
*/
async function closeUnlocked(): Promise<void> {
if (rawInstance) {
const closing = rawInstance;
rawInstance = null;
serializedInstance = null;
generation += 1;
await closing.close();
try {
await closing.close();
} catch (e) {
logWarn(
withoutFolders(
`DB: closing the previous connection failed, continuing without it: ${e instanceof Error ? e.message : String(e)}`
)
);
}
}
}
@ -276,10 +311,106 @@ export interface ProfilePreparation {
backup_file: string | null;
} | null;
catch_up_error: string | null;
/**
* File name of the copy taken before the catch-up, set even when the
* catch-up stopped afterwards (#395).
*/
catch_up_backup: string | null;
repaired_flags: number;
elapsed_ms: number;
}
/**
* Where a path starts: the placeholder Rust's `scrub` puts for the profile
* folder, a drive (`C:\`), the home (`~/`), `./` or `../`, or a separator
* (rooted path, UNC share). Only after the start of the text or one of
* BEFORE_START, so the `/` of `I/O` starts nothing.
*/
const PATH_START = /<profile folder>[\\/]|[A-Za-z]:[\\/]|~[\\/]|\.{1,2}[\\/]|[\\/]/y;
const BEFORE_START = /[\s'"`(=:,[]/;
/** What ends the folders of a path for sure: a line break, a double quote or a backtick, `;`, `|`. */
const PATH_STOP = /[\n\r"`;|]/;
/** The last segment of a path: the file name, kept. */
const LAST_SEGMENT = /[^\s"`()<>\\/]*/y;
/**
* Fallback for what the first pass leaves: a run of non-blank characters
* holding a `/` or `\` that no PATH_START opened (`dir/sub/x.db`, `I/O`).
*/
const PATH_RUN = /(?:<profile folder>)?[^\s"`()<>]*[\\/][^\s"`()<>]*/g;
/** A run that names a place on disk: rooted, home, relative to `.`, a drive, or two separators. */
const LOOKS_LIKE_PATH = /^(?:<profile folder>|[\\/~.]|[A-Za-z]:)|[\\/].*[\\/]/;
/** Length of the path start at `i`, 0 when no path starts there. */
function pathStartAt(text: string, i: number): number {
if (i > 0 && !BEFORE_START.test(text[i - 1])) return 0;
PATH_START.lastIndex = i;
return PATH_START.exec(text)?.[0].length ?? 0;
}
/** Whether a new path starts after the blank at `k` (quotes and an opening parenthesis skipped). */
function newPathAfter(text: string, k: number): boolean {
let j = k + 1;
while (j < text.length && /['"`(]/.test(text[j])) j++;
return pathStartAt(text, j) > 0;
}
/** Index of the last separator of the path whose start ends at `from` - 1. */
function lastSeparator(text: string, from: number): number {
let last = from - 1;
for (let k = from; k < text.length; k++) {
const c = text[k];
const next = text[k + 1] ?? "";
if (c === "\\" || c === "/") {
last = k;
continue;
}
if (PATH_STOP.test(c)) break;
// Sentence punctuation: Windows forbids `:` in a name, and no user name holds `,`.
if ((c === "," || c === ":") && (next === "" || /\s/.test(next))) break;
// `Program Files (x86)\…` goes on; `(see /x/y.db) and…` stops.
if (c === ")" && next !== "\\" && next !== "/") break;
if (/\s/.test(c) && newPathAfter(text, k)) break;
}
return last;
}
/**
* Keep only the last segment of every path in `text`: error lines can be
* attached to a feedback report, and a folder holds the OS user name
* (Loi 25). A folder may contain blanks (`C:\Users\Jean Dupont\…`,
* `/home/marie claire/…`): from its start, a path runs to its LAST separator
* before a line break, a double quote, a backtick, `;`, `|`, a `,` or `:`
* followed by a blank, a closing parenthesis, or a blank followed by another
* path. When nothing ends it, the rule is conservative: text after a path is
* dropped up to the last separator of the line (`/a/x.db then I/O` gives
* `O`), rather than letting a folder through. A quote right before the path is
* not part of it, so both quotes stay around the file name
* (`'C:\Users\Bob\x.db'` gives `'x.db'`). Any other separator becomes `-` (SQLite's "disk I/O error" reads
* "disk I-O error"), so an error line never contains a `/` nor a `\` (#395).
*/
export function withoutFolders(text: string): string {
let out = "";
let i = 0;
while (i < text.length) {
const start = pathStartAt(text, i);
if (start === 0) {
out += text[i];
i += 1;
continue;
}
const from = lastSeparator(text, i + start) + 1;
LAST_SEGMENT.lastIndex = from;
const segment = LAST_SEGMENT.exec(text)?.[0] ?? "";
out += segment;
i = from + segment.length;
}
return out.replace(PATH_RUN, (run) =>
LOOKS_LIKE_PATH.test(run)
? run.split(/[\\/]/).filter(Boolean).pop() ?? ""
: run.replace(/[\\/]/g, "-")
);
}
/**
* Journal lines for one preparation: only what changed. Names files, never
* folders: an error line can be attached to a feedback report, and the
@ -312,7 +443,13 @@ export function describePreparation(
info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} point to a missing row`);
}
if (report.catch_up_error) {
errors.push(`Profile ${dbFilename}: database update stopped: ${report.catch_up_error}`);
const copy = report.catch_up_backup ? withoutFolders(report.catch_up_backup) : "";
errors.push(
withoutFolders(
`Profile ${dbFilename}: database update stopped: ${report.catch_up_error}` +
(copy ? `; copy of the previous state kept as ${copy}` : "")
)
);
}
if (report.repaired_flags > 0) {
info.push(`Profile ${dbFilename}: ${report.repaired_flags} manual categorization flag(s) repaired`);
@ -343,7 +480,9 @@ export function connectToProfile(dbFilename: string): Promise<void> {
lines.errors.forEach((line) => logError(line));
} catch (e) {
logError(
`Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}`
withoutFolders(
`Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}`
)
);
}
await loadUnlocked(dbFilename);

View file

@ -140,6 +140,22 @@ describe("hookHarness — useMemo and useCallback", () => {
expect(compute).toHaveBeenCalledTimes(3);
});
it("recomputes when the dependency array changes length, even over an equal prefix", () => {
// React warns on such an array; the harness still treats it as a change.
// Shrinking keeps every remaining item equal, and growing by `undefined`
// matches the missing item read as `undefined`: only the length differs.
let deps: unknown[] = [1, 2];
const compute = vi.fn(() => ({}));
const Memo = () => R.useMemo(compute, deps);
harness.render(Memo);
deps = [1];
harness.render(Memo);
expect(compute).toHaveBeenCalledTimes(2);
deps = [1, undefined];
harness.render(Memo);
expect(compute).toHaveBeenCalledTimes(3);
});
it("useMemo without dependencies recomputes at every render", () => {
const compute = vi.fn(() => ({}));
const Memo = () => R.useMemo(compute, undefined as unknown as []);

View file

@ -55,7 +55,10 @@
// children (a component in the returned tree is an element, never called),
// the DOM, concurrent features, the bail-out of a setter called with the same
// value, the render-phase update loop React runs for a dispatch during render
// (the next `render()` reads it).
// (the next `render()` reads it), and a reducer that changes between renders:
// the `dispatch` of a `useReducer` keeps the reducer of the first render (it is
// created once, with the slot), where React applies the reducer of the latest
// render. A module-level reducer, the only kind in use, is not affected.
//
// React is imported here only as a type: a value import, from inside the
// factory that builds the mock of "react", would load that very mock.

View file

@ -1,17 +1,16 @@
import { describe, it, expect } from "vitest";
import { markedT as t } from "../test/i18nMock";
import {
renderCategoryLabel,
renderCategoryLabelFromCategory,
renderCategoryLabelFromAccount,
} from "./renderCategoryLabel";
// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys
// the way react-i18next does. We model "known" keys explicitly.
const known: Record<string, string> = {
"balance.category.cash": "Liquidités",
};
const t = (key: string, options?: { defaultValue?: string }) =>
known[key] ?? options?.defaultValue ?? key;
// `t` is the marked one of src/test/i18nMock.ts (#389): a translation reads
// `t(key[defaultValue=…])`, so a label that skipped `t` fails. Falling back to
// `defaultValue` for an unknown key is i18next's job; the helper's part is to
// pass the raw key as that default, which the mark shows.
const CASH = "t(balance.category.cash[defaultValue=cash])";
describe("renderCategoryLabel", () => {
it("prefers a non-empty custom_label over the i18n translation", () => {
@ -23,25 +22,25 @@ describe("renderCategoryLabel", () => {
it("trims custom_label and treats whitespace-only as absent", () => {
expect(
renderCategoryLabel(" ", "balance.category.cash", "cash", t)
).toBe("Liquidités");
).toBe(CASH);
});
it("falls back to the i18n translation when custom_label is null", () => {
expect(
renderCategoryLabel(null, "balance.category.cash", "cash", t)
).toBe("Liquidités");
).toBe(CASH);
});
it("falls back to the i18n translation when custom_label is undefined", () => {
expect(
renderCategoryLabel(undefined, "balance.category.cash", "cash", t)
).toBe("Liquidités");
).toBe(CASH);
});
it("falls back to the raw key when no translation exists", () => {
it("passes the raw key as the default i18next falls back to", () => {
expect(
renderCategoryLabel(null, "balance.category.unknown", "unknown", t)
).toBe("unknown");
).toBe("t(balance.category.unknown[defaultValue=unknown])");
});
it("returns a trimmed custom_label (leading/trailing spaces removed)", () => {
@ -58,7 +57,7 @@ describe("renderCategoryLabelFromCategory", () => {
{ custom_label: null, i18n_key: "balance.category.cash", key: "cash" },
t
)
).toBe("Liquidités");
).toBe(CASH);
expect(
renderCategoryLabelFromCategory(
{ custom_label: "X", i18n_key: "balance.category.cash", key: "cash" },
@ -79,7 +78,7 @@ describe("renderCategoryLabelFromAccount", () => {
},
t
)
).toBe("Liquidités");
).toBe(CASH);
expect(
renderCategoryLabelFromAccount(
{

View file

@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
MAX_RETRIES,
RETRY_DELAY_MS,
STARTUP_TIMEOUT_MS,
startProfileConnection,
} from "./startupConnection";
// The startup of App.tsx: what the error page and the app see while the
// active profile opens (#395).
function deferred() {
let resolve: () => void = () => {};
let reject: (e: unknown) => void = () => {};
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(connect: () => Promise<void>) {
const setDbReady = vi.fn();
const setStartupError = vi.fn();
const cleanup = startProfileConnection({
connect,
setDbReady,
setStartupError,
timeoutMessage: "startup timeout",
});
// The state React would hold: the last value each setter received.
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0];
return {
cleanup,
setDbReady,
setStartupError,
error: () => last(setStartupError),
ready: () => last(setDbReady),
};
}
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("startProfileConnection", () => {
it("shows the app once the profile opens in time", async () => {
const s = setup(async () => {});
await vi.advanceTimersByTimeAsync(0);
expect(s.ready()).toBe(true);
expect(s.error()).toBeNull();
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
expect(s.error()).toBeNull();
});
it("shows the error page after the delay while the profile is still opening", async () => {
const opening = deferred();
const s = setup(() => opening.promise);
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS - 1);
expect(s.error()).toBeNull();
await vi.advanceTimersByTimeAsync(1);
expect(s.error()).toBe("startup timeout");
expect(s.ready()).toBe(false);
});
it("leaves the error page when the profile opens after the delay (#395)", async () => {
// A catch-up of a large profile can outlast the delay (ADR 0021).
const opening = deferred();
const s = setup(() => opening.promise);
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS + 500);
expect(s.error()).toBe("startup timeout");
opening.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(s.error()).toBeNull();
expect(s.ready()).toBe(true);
});
it("retries, then shows the last error", async () => {
const connect = vi.fn(async () => {
throw new Error("no database");
});
const s = setup(connect);
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS * MAX_RETRIES);
expect(connect).toHaveBeenCalledTimes(MAX_RETRIES);
expect(s.error()).toBe("no database");
expect(s.ready()).toBe(false);
// The delay is disarmed: the real error stays.
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
expect(s.error()).toBe("no database");
});
it("does nothing more once cleaned up", async () => {
const opening = deferred();
const s = setup(() => opening.promise);
s.cleanup();
await vi.advanceTimersByTimeAsync(STARTUP_TIMEOUT_MS);
opening.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(s.setStartupError.mock.calls).toEqual([[null]]);
expect(s.setDbReady.mock.calls).toEqual([[false]]);
});
});

View file

@ -0,0 +1,66 @@
// Opening the active profile at startup, out of App.tsx so it can be tested
// without rendering the router (#395).
/** Delay after which the startup error page shows while the profile opens. */
export const STARTUP_TIMEOUT_MS = 10_000;
export const MAX_RETRIES = 3;
export const RETRY_DELAY_MS = 1_000;
export interface StartupConnection {
/** Opens the active profile (`connectActiveProfile`). */
connect: () => Promise<void>;
setDbReady: (ready: boolean) => void;
setStartupError: (error: string | null) => void;
/** Shown when the opening outlasts `STARTUP_TIMEOUT_MS`. */
timeoutMessage: string;
}
/**
* Open the profile, retrying up to `MAX_RETRIES` times, and show the error
* page after `STARTUP_TIMEOUT_MS` while it is still opening. The opening goes
* on past the delay (a catch-up of a large profile can, ADR 0021): when it
* then succeeds, the error is cleared and the app shows (#395). Returns the
* cleanup of the effect that calls it.
*/
export function startProfileConnection({
connect,
setDbReady,
setStartupError,
timeoutMessage,
}: StartupConnection): () => void {
let cancelled = false;
setDbReady(false);
setStartupError(null);
const timeout = setTimeout(() => {
setStartupError(timeoutMessage);
}, STARTUP_TIMEOUT_MS);
const attemptConnect = async (attempt: number): Promise<void> => {
try {
await connect();
if (cancelled) return;
clearTimeout(timeout);
// The delay may have shown the error page already.
setStartupError(null);
setDbReady(true);
} catch (err) {
if (cancelled) return;
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
if (!cancelled) return attemptConnect(attempt + 1);
} else {
clearTimeout(timeout);
setStartupError(err instanceof Error ? err.message : String(err));
}
}
};
void attemptConnect(1);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}