diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 27f3bcc..69231f2 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -64,12 +64,12 @@ pub async fn init_pool() -> Result { // Catch the "DB ahead of binary" case BEFORE letting sqlx fire its // cryptic `migration N was previously applied but is missing in the // resolved migrations` error. The setup hook in `lib.rs` matches the - // `DB_AHEAD` prefix to render a friendly recovery notice — typical - // user landing here ran a newer dev build against this DB and is now - // trying to launch an older installed binary. + // `DB_AHEAD_MARKER` token to render a friendly recovery notice — the + // typical user landing here ran a newer dev build against this DB + // and is now trying to launch an older installed binary. check_db_is_not_ahead_of_binary(&pool) .await - .map_err(|e| format!("DB_AHEAD::{e}"))?; + .map_err(|e| format!("{DB_AHEAD_MARKER}{e}"))?; // Run migrations sqlx::migrate!("src/db/migrations") @@ -80,6 +80,17 @@ pub async fn init_pool() -> Result { Ok(pool) } +/// Token prepended to the error message when `check_db_is_not_ahead_of_binary` +/// detects the failure mode. The setup hook in `lib.rs` matches this exact +/// token via `find()` (not `strip_prefix`) so the consumer is robust to any +/// future wrapping the producer adds. Single source of truth on both sides: +/// changing the token here must compile-error if the consumer drifts. +/// +/// Boundary string `::` chosen because it's syntactically unusual in plain +/// French error messages, so a false positive on `find()` is effectively +/// impossible. +pub const DB_AHEAD_MARKER: &str = "DB_AHEAD::"; + /// Detect the "DB has been migrated by a newer version than this binary /// knows about" failure mode BEFORE `sqlx::migrate!().run()` does. The /// stock sqlx error message — `migration 18 was previously applied but @@ -679,6 +690,34 @@ mod migration_tests { .expect("matching-version DB must pass the pre-flight"); } + #[test] + fn db_ahead_marker_is_findable_even_when_wrapped() { + // Regression guard against the Sourcery review on PR #64: the + // consumer in lib.rs uses `find(db::DB_AHEAD_MARKER)` rather + // than `strip_prefix("Failed to init SQLite: DB_AHEAD::")`. + // This test simulates the marker wrapped under arbitrary + // prefixes and confirms the detection still works — so a + // future refactor of the error wrapping in lib.rs cannot + // silently break the STARTUP_BLOCKED.txt path. + let marker = super::DB_AHEAD_MARKER; + let wrappings = [ + format!("Failed to init SQLite: {marker}payload"), + format!("Some new wrapping: {marker}payload"), + format!("{marker}payload"), // no wrapping at all + format!("Layer A → Layer B: {marker}payload"), + ]; + for wrapped in &wrappings { + let idx = wrapped + .find(marker) + .unwrap_or_else(|| panic!("marker must be findable in wrapping: {wrapped}")); + let extracted = &wrapped[idx + marker.len()..]; + assert_eq!( + extracted, "payload", + "payload must be cleanly extractable after the marker" + ); + } + } + #[tokio::test] async fn db_ahead_check_blocks_when_db_has_future_migration() { // Reproduces the v0.3.8 ← v0.3.9 dev workflow crash: the user diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29e6726..4fc175a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,11 +115,21 @@ pub fn run() { // Special handler for the "DB ahead of binary" pre-flight // failure surfaced by db::check_db_is_not_ahead_of_binary. // The user can't read the log file from a crashed app; we - // write a self-explanatory text file to the data dir so - // double-clicking app.db's folder reveals the recovery - // path. Pure best-effort — if the write fails too we just + // write a self-explanatory text file to the data dir + // (same dir as `app.db`, NOT the log dir which lives + // under the bundle identifier on Windows) so double- + // clicking that folder reveals the recovery path. + // Pure best-effort — if the write fails too we just // fall through to the normal log-and-exit. - if let Some(payload) = e.strip_prefix("Failed to init SQLite: DB_AHEAD::") { + // + // We `find()` the marker rather than `strip_prefix()` + // so the detection survives any future change to the + // upstream wrapping in `setup_result` (e.g. a different + // prefix than "Failed to init SQLite: "). The marker + // itself is the single source of truth, defined in + // `db::DB_AHEAD_MARKER` and prepended in `init_pool`. + if let Some(idx) = e.find(db::DB_AHEAD_MARKER) { + let payload = &e[idx + db::DB_AHEAD_MARKER.len()..]; write_startup_blocked_notice(payload); } return Err(e.into());