Skip to main content

fsqlite_core/
migration.rs

1//! bd-zywqc.5 — one-time idempotent repair pass at first open after upgrade.
2//!
3//! Databases created by a FrankenSQLite version predating the issue-#70 recovery
4//! work may carry latent corruption that `integrity_check` exposes but the old
5//! code kept writing over (for example the "Page N: never used" orphan-page
6//! class healed by [`Connection::repair_orphaned_pages`]). This module is the
7//! operability bridge for those upgraders: on the first open of such a database
8//! it runs a bounded, idempotent, interrupt-safe repair pass and records a
9//! marker so it never runs again for the same `(database, version)` pair.
10//!
11//! ## Marker-at-birth
12//!
13//! Every on-disk database *created* by the current code is stamped with the
14//! marker at birth (`storage_was_empty == true`). A database that lacks the
15//! marker was therefore created by code without this migration logic — exactly
16//! the pre-fix population we want to repair. This also keeps the pass from
17//! interfering with a database the current code created and merely reopened
18//! (its marker is already present), and from re-running the repair on every
19//! open.
20//!
21//! ## Interrupt safety
22//!
23//! Ordering guarantees "either the pre-migration state or the post-migration
24//! state, never a partial one":
25//! 1. The original files are copied to `<db>.pre-migration-bak*` **before** any
26//!    mutation, each via a temp file + atomic rename.
27//! 2. Each repair is its own atomic (WAL/journal-backed) commit, so an
28//!    interruption leaves the database at a valid inter-commit state.
29//! 3. The marker is written **last**, via a temp file + atomic rename, so its
30//!    presence means "fully migrated to this version". An interruption before
31//!    the marker write simply re-runs the (idempotent) pass on the next open.
32
33use std::ffi::OsString;
34use std::path::{Path, PathBuf};
35use std::time::{Instant, SystemTime, UNIX_EPOCH};
36
37use fsqlite_error::{FrankenError, Result};
38use fsqlite_vfs::host_fs;
39use serde::{Deserialize, Serialize};
40
41use crate::connection::Connection;
42
43/// Sidecar suffix for the migration-state marker.
44pub const MIGRATION_MARKER_SUFFIX: &str = ".fsqlite-migration-state";
45
46/// Sidecar suffix for the pre-migration backup of the main database file.
47pub const PRE_MIGRATION_BACKUP_SUFFIX: &str = ".pre-migration-bak";
48
49/// Environment variable that opts a process out of the automatic migration pass
50/// (for users who prefer to handle migration themselves). Set it to `1`.
51pub const SKIP_MIGRATION_ENV: &str = "FRANKENSQLITE_SKIP_MIGRATION";
52
53/// Version of the migration *logic*.
54///
55/// A database whose marker records a smaller value (or has no marker at all) is
56/// (re)migrated; a database already at this version is left untouched. Bump this
57/// when a new repairable corruption class is added so upgraders re-run the pass.
58pub const CURRENT_MIGRATION_VERSION: u32 = 1;
59
60/// Companion suffixes copied alongside the main file into the pre-migration
61/// backup, so a WAL-mode database can be restored faithfully.
62const BACKUP_COMPANION_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
63
64/// Persisted migration marker (`<db>.fsqlite-migration-state`, JSON).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MigrationMarker {
67    /// Migration-logic version that last ran to completion on this database.
68    pub last_upgrade_version: u32,
69    /// Unix seconds when the marker was last written (informational).
70    pub last_run_at: u64,
71    /// Names of the repairs the pass applied (empty when the database was
72    /// already clean or freshly created).
73    pub repairs_applied: Vec<String>,
74}
75
76/// What the pass did, for callers/tests that want to observe it.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum MigrationOutcome {
79    /// In-memory database — nothing to migrate.
80    SkippedMemory,
81    /// `FRANKENSQLITE_SKIP_MIGRATION=1` — the user opted out.
82    SkippedOptOut,
83    /// Marker already at (or beyond) the current version.
84    AlreadyMigrated,
85    /// Freshly created database — stamped with the marker at birth.
86    MarkedAtBirth,
87    /// Pre-existing database whose `integrity_check` was already clean.
88    CleanNoRepair,
89    /// Pre-existing database that was repaired; carries the applied-repair names.
90    Repaired { repairs: Vec<String> },
91}
92
93/// Append `suffix` to a database path (mirrors `wal_path_for_db_path` /
94/// `db_fec_path_for_db`: operate on the raw `OsString`, not a UTF-8 boundary).
95fn sidecar_path(db_path: &str, suffix: &str) -> PathBuf {
96    let mut s = OsString::from(db_path);
97    s.push(suffix);
98    PathBuf::from(s)
99}
100
101/// Path of the migration marker for `db_path`.
102#[must_use]
103pub fn migration_marker_path(db_path: &str) -> PathBuf {
104    sidecar_path(db_path, MIGRATION_MARKER_SUFFIX)
105}
106
107/// Path of the pre-migration backup of the main database file for `db_path`.
108#[must_use]
109pub fn pre_migration_backup_path(db_path: &str) -> PathBuf {
110    sidecar_path(db_path, PRE_MIGRATION_BACKUP_SUFFIX)
111}
112
113/// Read and parse the migration marker for `db_path`, if present and valid.
114#[must_use]
115pub fn read_migration_marker(db_path: &str) -> Option<MigrationMarker> {
116    let bytes = host_fs::read(&migration_marker_path(db_path)).ok()?;
117    serde_json::from_slice(&bytes).ok()
118}
119
120/// Decide whether the value of [`SKIP_MIGRATION_ENV`] opts the process out.
121/// Extracted as a pure function so the policy is unit-testable without mutating
122/// the process environment (`std::env::set_var` is `unsafe`, forbidden here).
123fn opt_out_from_env_value(value: Option<&str>) -> bool {
124    value == Some("1")
125}
126
127/// The user-facing stderr line emitted after repairs are applied. Pure so its
128/// wording/format is unit-testable.
129fn migration_repair_message(elapsed_secs: f64, backup_path: &Path) -> String {
130    format!(
131        "fsqlite: applied migration repairs (took {elapsed_secs:.1}s). Original DB preserved at {}",
132        backup_path.display()
133    )
134}
135
136fn now_unix_secs() -> u64 {
137    SystemTime::now()
138        .duration_since(UNIX_EPOCH)
139        .map(|d| d.as_secs())
140        .unwrap_or(0)
141}
142
143/// Serialize `marker` to its sidecar via a temp file + atomic rename, so a
144/// reader never observes a half-written marker.
145fn write_marker_atomic(db_path: &str, marker: &MigrationMarker) -> Result<()> {
146    let final_path = migration_marker_path(db_path);
147    let tmp_path = sidecar_path(db_path, &format!("{MIGRATION_MARKER_SUFFIX}.tmp"));
148    let json = serde_json::to_vec_pretty(marker)
149        .map_err(|e| FrankenError::internal(format!("serialize migration marker: {e}")))?;
150    host_fs::write(&tmp_path, &json)?;
151    host_fs::rename(&tmp_path, &final_path)
152}
153
154/// Copy `from` to `<to>.tmp` then atomically rename to `to`. A missing source
155/// is not an error (the companion simply does not exist).
156fn backup_file_atomic(from: &Path, to: &Path) -> Result<bool> {
157    if host_fs::metadata(from).is_err() {
158        return Ok(false);
159    }
160    let mut tmp = to.as_os_str().to_owned();
161    tmp.push(".tmp");
162    let tmp_path = PathBuf::from(tmp);
163    host_fs::copy_file(from, &tmp_path)?;
164    host_fs::rename(&tmp_path, to)?;
165    Ok(true)
166}
167
168/// Back up the main database file and any `-wal`/`-shm` companions, so the
169/// original is preserved before any repair mutation. Returns the main backup
170/// path on success.
171fn backup_original(db_path: &str) -> Result<PathBuf> {
172    let main_backup = pre_migration_backup_path(db_path);
173    backup_file_atomic(Path::new(db_path), &main_backup)?;
174    for suffix in BACKUP_COMPANION_SUFFIXES {
175        let from = sidecar_path(db_path, suffix);
176        let to = sidecar_path(db_path, &format!("{PRE_MIGRATION_BACKUP_SUFFIX}{suffix}"));
177        // Companions are best-effort: a missing/uncopyable -shm must not abort
178        // the migration (it is rebuilt on next open).
179        let _ = backup_file_atomic(&from, &to);
180    }
181    Ok(main_backup)
182}
183
184/// Run the one-time first-open migration/repair pass for `conn`.
185///
186/// Infallible from the caller's perspective: any internal error is logged and
187/// the database is left no worse than it was found (the backup preserves the
188/// original). `storage_was_empty` is `true` when this open created the file.
189pub(crate) async fn run_first_open_migration(
190    conn: &Connection,
191    storage_was_empty: bool,
192) -> MigrationOutcome {
193    let db_path = conn.path().to_owned();
194
195    // In-memory databases have no on-disk state to migrate.
196    if db_path == ":memory:" {
197        return MigrationOutcome::SkippedMemory;
198    }
199    // Explicit user opt-out.
200    if opt_out_from_env_value(std::env::var(SKIP_MIGRATION_ENV).ok().as_deref()) {
201        return MigrationOutcome::SkippedOptOut;
202    }
203    // Already migrated to (or beyond) the current version — the common path,
204    // checked before any I/O-heavy integrity walk.
205    if let Some(marker) = read_migration_marker(&db_path)
206        && marker.last_upgrade_version >= CURRENT_MIGRATION_VERSION
207    {
208        return MigrationOutcome::AlreadyMigrated;
209    }
210
211    // A database created by the current code is clean by construction: stamp it
212    // at birth so reopens short-circuit and the repair pass never touches it.
213    if storage_was_empty {
214        let marker = MigrationMarker {
215            last_upgrade_version: CURRENT_MIGRATION_VERSION,
216            last_run_at: now_unix_secs(),
217            repairs_applied: Vec::new(),
218        };
219        if let Err(err) = write_marker_atomic(&db_path, &marker) {
220            tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to stamp migration marker at birth");
221        }
222        return MigrationOutcome::MarkedAtBirth;
223    }
224
225    let started = Instant::now();
226
227    // A pre-existing, unmarked database: check it, and repair the repairable
228    // corruption classes if any are present.
229    match conn.validate_database_integrity(false).await {
230        Ok(()) => {
231            // bd-7o1vu (GH#370): a legacy CONTENTLESS FTS5 table can carry an
232            // orphaned `%_content` corpus shadow. It is integrity-CLEAN, so it
233            // reaches THIS Ok branch, not the repair branch below. Reclaim it —
234            // but, because the clean path does not otherwise mutate the
235            // database, take the pre-migration backup HERE first (the Err branch
236            // already has one). If the backup fails, skip the reclaim and leave
237            // the database untouched — and do not stamp the marker, so a later
238            // open retries — exactly as the Err branch does on backup failure.
239            let mut repairs_applied = Vec::new();
240            if !conn.orphaned_fts5_content_shadow_names().is_empty() {
241                if let Err(err) = backup_original(&db_path) {
242                    tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "could not back up database before reclaiming orphaned FTS5 content shadows; leaving it untouched");
243                    return MigrationOutcome::CleanNoRepair;
244                }
245                match conn.reclaim_orphaned_fts5_content_shadows().await {
246                    Ok(dropped) if !dropped.is_empty() => {
247                        repairs_applied
248                            .push(format!("reclaim_orphaned_fts5_content:{}", dropped.len()));
249                    }
250                    Ok(_) => {}
251                    Err(err) => {
252                        tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "reclaim_orphaned_fts5_content_shadows failed during migration");
253                    }
254                }
255            }
256            // Record the marker so the walk runs at most once.
257            let marker = MigrationMarker {
258                last_upgrade_version: CURRENT_MIGRATION_VERSION,
259                last_run_at: now_unix_secs(),
260                repairs_applied: repairs_applied.clone(),
261            };
262            if let Err(err) = write_marker_atomic(&db_path, &marker) {
263                tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to write migration marker for a clean database");
264            }
265            if repairs_applied.is_empty() {
266                MigrationOutcome::CleanNoRepair
267            } else {
268                MigrationOutcome::Repaired {
269                    repairs: repairs_applied,
270                }
271            }
272        }
273        Err(integrity_err) => {
274            // Preserve the original before any mutation.
275            let backup_path = match backup_original(&db_path) {
276                Ok(path) => path,
277                Err(err) => {
278                    tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "could not back up database before repair; leaving it untouched");
279                    return MigrationOutcome::CleanNoRepair;
280                }
281            };
282
283            // Apply the repairable-class repairs. `repair_orphaned_pages` only
284            // re-frees genuinely-orphaned in-range pages (a no-op otherwise), so
285            // running it is safe even when the corruption is a different class.
286            let mut repairs_applied = Vec::new();
287            match conn.repair_orphaned_pages().await {
288                Ok(freed) if freed > 0 => {
289                    repairs_applied.push(format!("repair_orphaned_pages:{freed}"));
290                }
291                Ok(_) => {}
292                Err(err) => {
293                    tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "repair_orphaned_pages failed during migration");
294                }
295            }
296
297            // bd-7o1vu (GH#370): also reclaim any orphaned CONTENTLESS FTS5
298            // `%_content` shadows here — the pre-migration backup was already
299            // taken above, so no additional backup is needed.
300            match conn.reclaim_orphaned_fts5_content_shadows().await {
301                Ok(dropped) if !dropped.is_empty() => {
302                    repairs_applied.push(format!("reclaim_orphaned_fts5_content:{}", dropped.len()));
303                }
304                Ok(_) => {}
305                Err(err) => {
306                    tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "reclaim_orphaned_fts5_content_shadows failed during migration");
307                }
308            }
309
310            // Best-effort confirmation (informational only).
311            let integrity_ok_after = conn.validate_database_integrity(false).await.is_ok();
312            if !integrity_ok_after {
313                tracing::warn!(
314                    target: "fsqlite.migration",
315                    db = %db_path,
316                    original = %integrity_err,
317                    "database still fails integrity_check after the migration repair pass; original preserved at the backup"
318                );
319            }
320
321            // Record the marker last (atomic), so its presence means done.
322            let marker = MigrationMarker {
323                last_upgrade_version: CURRENT_MIGRATION_VERSION,
324                last_run_at: now_unix_secs(),
325                repairs_applied: repairs_applied.clone(),
326            };
327            if let Err(err) = write_marker_atomic(&db_path, &marker) {
328                tracing::warn!(target: "fsqlite.migration", %err, db = %db_path, "failed to write migration marker after repair");
329            }
330
331            let elapsed = started.elapsed().as_secs_f64();
332            eprintln!("{}", migration_repair_message(elapsed, &backup_path));
333
334            MigrationOutcome::Repaired {
335                repairs: repairs_applied,
336            }
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn sidecar_paths_append_suffix_to_raw_db_path() {
347        assert_eq!(
348            migration_marker_path("/tmp/foo.db"),
349            PathBuf::from("/tmp/foo.db.fsqlite-migration-state")
350        );
351        assert_eq!(
352            pre_migration_backup_path("/tmp/foo.db"),
353            PathBuf::from("/tmp/foo.db.pre-migration-bak")
354        );
355    }
356
357    #[test]
358    fn marker_roundtrips_through_json() {
359        let marker = MigrationMarker {
360            last_upgrade_version: CURRENT_MIGRATION_VERSION,
361            last_run_at: 1_700_000_000,
362            repairs_applied: vec!["repair_orphaned_pages:3".to_owned()],
363        };
364        let json = serde_json::to_vec(&marker).expect("serialize");
365        let back: MigrationMarker = serde_json::from_slice(&json).expect("deserialize");
366        assert_eq!(marker, back);
367    }
368
369    #[test]
370    fn read_missing_marker_is_none() {
371        assert!(read_migration_marker("/nonexistent/path/to/db-xyzzy").is_none());
372    }
373
374    #[test]
375    fn opt_out_only_for_exactly_one() {
376        assert!(opt_out_from_env_value(Some("1")));
377        assert!(!opt_out_from_env_value(Some("0")));
378        assert!(!opt_out_from_env_value(Some("true")));
379        assert!(!opt_out_from_env_value(Some("")));
380        assert!(!opt_out_from_env_value(None));
381    }
382
383    #[test]
384    fn repair_message_names_time_and_backup_path() {
385        let msg = migration_repair_message(2.34, Path::new("/tmp/foo.db.pre-migration-bak"));
386        assert!(msg.contains("applied migration repairs"), "got: {msg}");
387        assert!(msg.contains("2.3s"), "one-decimal elapsed seconds; got: {msg}");
388        assert!(
389            msg.contains("/tmp/foo.db.pre-migration-bak"),
390            "names the backup path; got: {msg}"
391        );
392    }
393}