1use 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
43pub const MIGRATION_MARKER_SUFFIX: &str = ".fsqlite-migration-state";
45
46pub const PRE_MIGRATION_BACKUP_SUFFIX: &str = ".pre-migration-bak";
48
49pub const SKIP_MIGRATION_ENV: &str = "FRANKENSQLITE_SKIP_MIGRATION";
52
53pub const CURRENT_MIGRATION_VERSION: u32 = 1;
59
60const BACKUP_COMPANION_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MigrationMarker {
67 pub last_upgrade_version: u32,
69 pub last_run_at: u64,
71 pub repairs_applied: Vec<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum MigrationOutcome {
79 SkippedMemory,
81 SkippedOptOut,
83 AlreadyMigrated,
85 MarkedAtBirth,
87 CleanNoRepair,
89 Repaired { repairs: Vec<String> },
91}
92
93fn 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#[must_use]
103pub fn migration_marker_path(db_path: &str) -> PathBuf {
104 sidecar_path(db_path, MIGRATION_MARKER_SUFFIX)
105}
106
107#[must_use]
109pub fn pre_migration_backup_path(db_path: &str) -> PathBuf {
110 sidecar_path(db_path, PRE_MIGRATION_BACKUP_SUFFIX)
111}
112
113#[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
120fn opt_out_from_env_value(value: Option<&str>) -> bool {
124 value == Some("1")
125}
126
127fn 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
143fn 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
154fn 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
168fn 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 let _ = backup_file_atomic(&from, &to);
180 }
181 Ok(main_backup)
182}
183
184pub(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 if db_path == ":memory:" {
197 return MigrationOutcome::SkippedMemory;
198 }
199 if opt_out_from_env_value(std::env::var(SKIP_MIGRATION_ENV).ok().as_deref()) {
201 return MigrationOutcome::SkippedOptOut;
202 }
203 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 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 match conn.validate_database_integrity(false).await {
230 Ok(()) => {
231 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 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 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 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 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 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 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}