use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
use stow_types::error::Context;
const STATE_DB_FILE_NAME: &str = "state-v3.sqlite3";
#[derive(Debug, PartialEq, Eq)]
struct SchemaColumn<'a> {
table: &'a str,
name: &'a str,
definition: &'a str,
}
const CONSTRAINT_KEYWORDS: &[&str] = &["PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "CONSTRAINT"];
pub fn state_db_path(cache_dir: &Path) -> PathBuf {
cache_dir.join(STATE_DB_FILE_NAME)
}
#[tracing::instrument(name = "stow.sqlite.connect", skip_all)]
pub async fn connect_pool(cache_dir: &Path) -> stow_types::error::Result<SqlitePool> {
let path = state_db_path(cache_dir);
if let Some(parent) = path.parent() {
async_fs::create_dir_all(parent)
.await
.wrap_err_with(|| format!("create state db parent {}", parent.display()))?;
}
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
.wrap_err_with(|| format!("build sqlite connect options for {}", path.display()))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true);
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.wrap_err_with(|| format!("open state db {}", path.display()))?;
sqlx::raw_sql(include_str!("state-db-schema.sql"))
.execute(&pool)
.await
.wrap_err_with(|| format!("initialize state db schema {}", path.display()))?;
ensure_declared_columns(&pool, &path).await?;
Ok(pool)
}
async fn ensure_declared_columns(
pool: &SqlitePool,
db_path: &Path,
) -> stow_types::error::Result<()> {
let schema = include_str!("state-db-schema.sql");
let declared = declared_columns(schema);
let mut current_table = "";
let mut existing: Vec<String> = Vec::new();
for column in declared {
if column.table != current_table {
current_table = column.table;
existing = sqlx::query_scalar::<_, String>(&format!(
"SELECT name FROM pragma_table_info('{current_table}')"
))
.fetch_all(pool)
.await
.wrap_err_with(|| {
format!("inspect {current_table} columns in {}", db_path.display())
})?;
}
if existing.is_empty() || existing.iter().any(|name| name == column.name) {
continue;
}
let statement = format!(
"ALTER TABLE {} ADD COLUMN {}",
column.table, column.definition
);
sqlx::query(&statement)
.execute(pool)
.await
.wrap_err_with(|| {
format!(
"add missing column {}.{} in {}",
column.table,
column.name,
db_path.display()
)
})?;
tracing::debug!(
table = column.table,
column = column.name,
"added a column the state database was missing"
);
}
Ok(())
}
fn declared_columns(schema: &str) -> Vec<SchemaColumn<'_>> {
let mut columns = Vec::new();
let mut rest = schema;
while let Some(start) = rest.find("CREATE TABLE") {
let after = &rest[start..];
let Some(open) = after.find('(') else {
break;
};
let table = table_name(&after[..open]);
let body = balanced_body(&after[open..]);
rest = &after[open + body.len()..];
let Some(table) = table else {
continue;
};
for declaration in split_top_level(body) {
let declaration = declaration.trim();
let Some(name) = declaration.split_whitespace().next() else {
continue;
};
if CONSTRAINT_KEYWORDS
.iter()
.any(|keyword| name.eq_ignore_ascii_case(keyword))
{
continue;
}
columns.push(SchemaColumn {
table,
name,
definition: declaration,
});
}
}
columns
}
fn table_name(header: &str) -> Option<&str> {
header
.split_whitespace()
.last()
.filter(|name| !name.eq_ignore_ascii_case("TABLE") && !name.eq_ignore_ascii_case("EXISTS"))
}
fn balanced_body(input: &str) -> &str {
let mut depth = 0usize;
for (index, character) in input.char_indices() {
match character {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return &input[1..index];
}
}
_ => {}
}
}
""
}
fn split_top_level(body: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (index, character) in body.char_indices() {
match character {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
parts.push(&body[start..index]);
start = index + 1;
}
_ => {}
}
}
parts.push(&body[start..]);
parts
}
#[cfg(test)]
pub async fn connect(cache_dir: &Path) -> stow_types::error::Result<SqlitePool> {
connect_pool(cache_dir).await
}
pub fn now_millis() -> u64 {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_millis();
u64::try_from(millis).expect("current time in milliseconds exceeds u64 range")
}
#[allow(clippy::cast_possible_truncation)]
pub const fn duration_millis(duration: Duration) -> u64 {
duration.as_millis() as u64
}
pub fn db_int<Source, Target>(value: Source, what: &str) -> stow_types::error::Result<Target>
where
Target: TryFrom<Source>,
Source: Copy + std::fmt::Display,
{
TryFrom::try_from(value).map_err(|_| {
stow_types::stow_error!(
"{what} value {value} is out of range for its database representation"
)
})
}
#[cfg(test)]
mod schema_tests {
use super::{connect_pool, declared_columns};
#[test]
fn the_schema_file_declares_its_columns() {
let schema = include_str!("state-db-schema.sql");
let columns = declared_columns(schema);
assert!(
columns.iter().any(
|column| column.table == "artifact_cache_native_out_dir_files"
&& column.name == "sha256"
),
"the parser must see artifact_cache_native_out_dir_files.sha256"
);
assert!(
columns
.iter()
.any(|column| column.table == "artifact_cache_entries"
&& column.name == "compile_key"),
"the parser must see artifact_cache_entries.compile_key"
);
assert!(
!columns
.iter()
.any(|column| column.name.eq_ignore_ascii_case("PRIMARY")
|| column.name.eq_ignore_ascii_case("FOREIGN")),
"table constraints are not columns"
);
}
#[tokio::test]
async fn an_older_database_gains_the_columns_it_is_missing() {
let cache_dir = tempfile::tempdir().expect("temp dir");
let path = super::state_db_path(cache_dir.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display()))
.await
.expect("open the old database");
sqlx::query(
"CREATE TABLE artifact_cache_native_out_dir_files (\
rustc_version TEXT NOT NULL, \
cache_key TEXT NOT NULL, \
ordinal INTEGER NOT NULL, \
relative_path TEXT NOT NULL, \
PRIMARY KEY (rustc_version, cache_key, ordinal))",
)
.execute(&pool)
.await
.expect("create the old table");
pool.close().await;
let pool = connect_pool(cache_dir.path())
.await
.expect("open the database through the migration");
let columns = sqlx::query_scalar::<_, String>(
"SELECT name FROM pragma_table_info('artifact_cache_native_out_dir_files')",
)
.fetch_all(&pool)
.await
.expect("read the migrated columns");
assert!(
columns.iter().any(|column| column == "sha256"),
"the missing column must be added: {columns:?}"
);
sqlx::query_scalar::<_, String>(
"SELECT relative_path FROM artifact_cache_native_out_dir_files LIMIT 1",
)
.fetch_optional(&pool)
.await
.expect("the table stays usable after the migration");
}
}