Skip to main content

aven_core/db/
inspection.rs

1use std::fs;
2use std::io::Read as _;
3use std::path::Path;
4use std::sync::Arc;
5use std::time::Duration;
6
7use sqlx::Row;
8use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
9use tokio::sync::Mutex;
10
11use super::{Database, MIGRATOR, shm_path, wal_path};
12
13#[derive(Debug, Clone)]
14pub struct DatabaseInspection {
15    pub exists: bool,
16    pub is_file: bool,
17    pub header_is_sqlite: bool,
18    pub read_only_permissions: bool,
19    pub wal_exists: bool,
20    pub shm_exists: bool,
21    pub schema_version: Option<i64>,
22    pub latest_schema_version: Option<i64>,
23    pub pending_migrations: Vec<i64>,
24    pub unsupported_future_schema: bool,
25    pub failed_migration: Option<i64>,
26    pub open_error: Option<String>,
27}
28
29impl DatabaseInspection {
30    fn unavailable(latest_schema_version: Option<i64>) -> Self {
31        Self {
32            exists: false,
33            is_file: false,
34            header_is_sqlite: false,
35            read_only_permissions: false,
36            wal_exists: false,
37            shm_exists: false,
38            schema_version: None,
39            latest_schema_version,
40            pending_migrations: Vec::new(),
41            unsupported_future_schema: false,
42            failed_migration: None,
43            open_error: None,
44        }
45    }
46
47    pub fn supports_runtime_checks(&self) -> bool {
48        self.open_error.is_none()
49            && self.failed_migration.is_none()
50            && !self.unsupported_future_schema
51            && self.pending_migrations.is_empty()
52    }
53}
54
55pub struct InspectedDatabase {
56    pub inspection: DatabaseInspection,
57    pub database: Option<Database>,
58}
59
60impl Database {
61    pub fn latest_schema_version() -> Option<i64> {
62        MIGRATOR.iter().map(|migration| migration.version).max()
63    }
64
65    /// Copies an existing SQLite file and its sidecars into isolated temporary
66    /// storage, then opens the isolated snapshot for diagnostic queries.
67    ///
68    /// This path does not create parent directories, databases, sidecars, metadata,
69    /// workspaces, journal settings, or migrations in user state.
70    pub async fn inspect(path: &Path) -> InspectedDatabase {
71        let latest = Self::latest_schema_version();
72        let mut inspection = DatabaseInspection::unavailable(latest);
73        inspection.wal_exists = wal_path(path).exists();
74        inspection.shm_exists = shm_path(path).exists();
75
76        let metadata = match fs::metadata(path) {
77            Ok(metadata) => metadata,
78            Err(error) => {
79                inspection.open_error = Some(if error.kind() == std::io::ErrorKind::NotFound {
80                    "database file does not exist".to_string()
81                } else {
82                    format!("database metadata is unavailable: {}", error.kind())
83                });
84                return InspectedDatabase {
85                    inspection,
86                    database: None,
87                };
88            }
89        };
90        inspection.exists = true;
91        inspection.is_file = metadata.is_file();
92        inspection.read_only_permissions = metadata.permissions().readonly();
93        if !inspection.is_file {
94            inspection.open_error = Some("database path is not a regular file".to_string());
95            return InspectedDatabase {
96                inspection,
97                database: None,
98            };
99        }
100
101        let mut header = [0_u8; 16];
102        inspection.header_is_sqlite = fs::File::open(path)
103            .and_then(|mut file| file.read_exact(&mut header))
104            .is_ok()
105            && &header == b"SQLite format 3\0";
106        if !inspection.header_is_sqlite {
107            inspection.open_error = Some("database header is not SQLite format 3".to_string());
108            return InspectedDatabase {
109                inspection,
110                database: None,
111            };
112        }
113
114        let inspection_dir = match tempfile::Builder::new().prefix("aven-doctor-").tempdir() {
115            Ok(dir) => Arc::new(dir),
116            Err(error) => {
117                inspection.open_error = Some(format!(
118                    "could not create isolated inspection storage: {error}"
119                ));
120                return InspectedDatabase {
121                    inspection,
122                    database: None,
123                };
124            }
125        };
126        let snapshot_path = inspection_dir.path().join("database.sqlite");
127        if let Err(error) = fs::copy(path, &snapshot_path) {
128            inspection.open_error =
129                Some(format!("could not copy database for inspection: {error}"));
130            return InspectedDatabase {
131                inspection,
132                database: None,
133            };
134        }
135        for (source, target) in [
136            (wal_path(path), wal_path(&snapshot_path)),
137            (shm_path(path), shm_path(&snapshot_path)),
138        ] {
139            if source.exists()
140                && let Err(error) = fs::copy(&source, &target)
141            {
142                inspection.open_error = Some(format!(
143                    "could not copy SQLite sidecar {} for inspection: {error}",
144                    source.display()
145                ));
146                return InspectedDatabase {
147                    inspection,
148                    database: None,
149                };
150            }
151        }
152
153        let options = SqliteConnectOptions::new()
154            .filename(&snapshot_path)
155            .foreign_keys(true)
156            .busy_timeout(Duration::from_secs(5));
157        let pool = match SqlitePoolOptions::new()
158            .min_connections(1)
159            .max_connections(1)
160            .idle_timeout(None)
161            .max_lifetime(None)
162            .connect_with(options)
163            .await
164        {
165            Ok(pool) => pool,
166            Err(error) => {
167                inspection.open_error = Some(format!("SQLite read-only open failed: {error}"));
168                return InspectedDatabase {
169                    inspection,
170                    database: None,
171                };
172            }
173        };
174
175        let migration_rows = sqlx::query("SELECT version, success FROM _sqlx_migrations")
176            .fetch_all(&pool)
177            .await;
178        let mut applied = Vec::new();
179        match migration_rows {
180            Ok(rows) => {
181                for row in rows {
182                    let version = row.try_get::<i64, _>("version").unwrap_or_default();
183                    if row.try_get::<bool, _>("success").unwrap_or(false) {
184                        applied.push(version);
185                    } else {
186                        inspection.failed_migration = Some(version);
187                    }
188                }
189                inspection.schema_version = applied.iter().copied().max().or(Some(0));
190            }
191            Err(error) => {
192                let missing_table = error
193                    .as_database_error()
194                    .is_some_and(|error| error.code().as_deref() == Some("1"));
195                if missing_table {
196                    inspection.schema_version = Some(0);
197                } else {
198                    inspection.open_error = Some(format!("could not inspect schema: {error}"));
199                }
200            }
201        }
202        inspection.pending_migrations = MIGRATOR
203            .iter()
204            .filter(|migration| !applied.contains(&migration.version))
205            .map(|migration| migration.version)
206            .collect();
207        inspection.unsupported_future_schema = inspection
208            .schema_version
209            .zip(latest)
210            .is_some_and(|(current, latest)| current > latest);
211
212        let database = inspection.supports_runtime_checks().then(|| Database {
213            pool,
214            writer: Arc::new(Mutex::new(())),
215            path: path.to_path_buf(),
216            file_identity: fs::canonicalize(path).ok(),
217            _inspection_dir: Some(inspection_dir),
218            membership_cache: Default::default(),
219        });
220        InspectedDatabase {
221            inspection,
222            database,
223        }
224    }
225}