feat(profiles): name the copy of a stopped catch-up and keep one copy per profile
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
PR Check — Rust / rust (pull_request) Successful in 3m56s

- ProfilePreparation.catch_up_backup carries the file name of the copy as
  soon as it exists, so the Journal error line of a catch-up that stops
  afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
  path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
  renamed over a same-name copy; after a successful catch-up only, the
  other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
  category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
  306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.

Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-12 20:18:18 -04:00
parent 3882df772b
commit 419b20e769
11 changed files with 504 additions and 47 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).
- 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).
- 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` ne garde que le dernier segment de tout chemin d'une ligne d'erreur, message de repli compris : aucune ligne d'erreur ne contient `/` ni `\`.
### 8. Un seul dossier des bases
@ -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

@ -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)

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,7 +485,7 @@ 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
// 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)
@ -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

@ -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"
]

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"
]

View file

@ -45,6 +45,7 @@ import {
initializeNewProfileDb,
TransactionHandleCloseError,
TransactionHandleRevokedError,
withoutFolders,
withTransaction,
type ProfilePreparation,
} from "./db";
@ -420,7 +421,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 +497,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 +569,56 @@ 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");
});
});

View file

@ -276,10 +276,38 @@ 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;
}
/**
* A run of non-blank characters holding a `/` or `\`, with the placeholder
* Rust's `scrub` puts for the profile folder in front of it. An apostrophe
* stays inside the run, so `C:\Users\L'Heureux\x.db` is one path.
*/
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]:)|[\\/].*[\\/]/;
/**
* 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). 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 {
return text.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 +340,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 +377,9 @@ export function connectToProfile(dbFilename: string): Promise<void> {
lines.errors.forEach((line) => logError(line));
} catch (e) {
logError(
withoutFolders(
`Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}`
)
);
}
await loadUnlocked(dbFilename);