use std::cmp::Ordering;
use crate::storage::{Database, Result, StorageError};
use log::{info, warn};
use turso::Value;
struct Migration {
version: u32,
description: &'static str,
sql: &'static str,
}
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
description: "Initial schema",
sql: include_str!("migrations/001_initial.sql"),
},
Migration {
version: 2,
description: "Add default Inbox project",
sql: "INSERT OR IGNORE INTO projects (id, name, color, icon, created_at)
VALUES ('inbox', 'Inbox', '#3498db', '📥', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))",
},
Migration {
version: 3,
description: "Add app metadata table for version tracking",
sql: "CREATE TABLE IF NOT EXISTS _app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
},
];
pub async fn run_migrations(db: &Database) -> Result<()> {
db.execute(
"CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL
)",
(),
)
.await?;
let current_version = get_current_version(db).await?;
info!("Current database version: {}", current_version);
for migration in MIGRATIONS.iter().filter(|m| m.version > current_version) {
info!(
"Applying migration {}: {}",
migration.version, migration.description
);
db.execute_batch(migration.sql).await.map_err(|e| {
StorageError::Migration(format!(
"Failed to apply migration {}: {}",
migration.version, e
))
})?;
db.execute(
"INSERT INTO _migrations (version, description, applied_at)
VALUES (?1, ?2, datetime('now'))",
[
Value::Integer(migration.version as i64),
Value::Text(migration.description.to_string()),
],
)
.await?;
info!("Migration {} applied successfully", migration.version);
}
let final_version = get_current_version(db).await?;
if final_version > current_version {
info!(
"Database migrated from version {} to {}",
current_version, final_version
);
} else {
info!("Database is up to date (version {})", final_version);
}
Ok(())
}
async fn get_current_version(db: &Database) -> Result<u32> {
let value = db
.query_scalar("SELECT COALESCE(MAX(version), 0) FROM _migrations", ())
.await?;
match value {
Some(Value::Integer(v)) => Ok(v as u32),
Some(Value::Null) | None => Ok(0),
other => Err(StorageError::Conversion(format!(
"Unexpected version value: {:?}",
other
))),
}
}
fn compare_versions(a: &str, b: &str) -> Ordering {
fn parse(v: &str) -> Option<(u32, u32, u32)> {
let mut parts = v.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
match (parse(a), parse(b)) {
(Some(va), Some(vb)) => va.cmp(&vb),
_ => a.cmp(b),
}
}
async fn get_stored_app_version(db: &Database) -> Result<Option<String>> {
let row = db
.query_one(
"SELECT value FROM _app_meta WHERE key = 'app_version'",
(),
)
.await?;
match row {
Some(row) => match row.get_value(0)? {
Value::Text(v) => Ok(Some(v)),
_ => Ok(None),
},
None => Ok(None),
}
}
async fn set_app_version(db: &Database, version: &str) -> Result<()> {
db.execute(
"INSERT OR REPLACE INTO _app_meta (key, value, updated_at)
VALUES ('app_version', ?1, datetime('now'))",
[Value::Text(version.to_string())],
)
.await?;
Ok(())
}
pub async fn check_and_update_app_version(db: &Database) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let stored = get_stored_app_version(db).await?;
match stored {
None => {
info!(
"No stored app version found — recording v{} (fresh install or upgrade from pre-tracking version)",
current
);
set_app_version(db, current).await?;
}
Some(ref stored_version) if stored_version == current => {
info!("App version unchanged (v{})", current);
}
Some(ref stored_version) => match compare_versions(stored_version, current) {
Ordering::Less => {
info!(
"App upgraded from v{} to v{}",
stored_version, current
);
set_app_version(db, current).await?;
}
Ordering::Greater => {
warn!(
"App downgraded from v{} to v{} — database was last used by a newer version",
stored_version, current
);
set_app_version(db, current).await?;
}
Ordering::Equal => {
info!("App version unchanged (v{})", current);
}
},
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_run_migrations_fresh_db() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
let result = db
.query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tasks'",
(),
)
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(1)));
let result = db
.query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='projects'",
(),
)
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(1)));
let result = db
.query_scalar("SELECT COUNT(*) FROM projects WHERE id = 'inbox'", ())
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(1)));
}
#[tokio::test]
async fn test_migrations_are_idempotent() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
run_migrations(&db).await.unwrap();
let result = db
.query_scalar("SELECT COUNT(*) FROM _migrations", ())
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(3)));
}
#[tokio::test]
async fn test_version_tracking() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
let version = get_current_version(&db).await.unwrap();
assert_eq!(version, 3); }
#[tokio::test]
async fn test_indexes_created() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
let result = db
.query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_tasks_%'",
(),
)
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(5)));
}
#[tokio::test]
async fn test_app_meta_table_created() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
let result = db
.query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_app_meta'",
(),
)
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(1)));
}
#[tokio::test]
async fn test_app_version_stored_on_first_run() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
let stored = get_stored_app_version(&db).await.unwrap();
assert!(stored.is_none());
check_and_update_app_version(&db).await.unwrap();
let stored = get_stored_app_version(&db).await.unwrap();
assert_eq!(stored, Some(env!("CARGO_PKG_VERSION").to_string()));
}
#[tokio::test]
async fn test_app_version_idempotent() {
let db = Database::open_in_memory().await.unwrap();
run_migrations(&db).await.unwrap();
check_and_update_app_version(&db).await.unwrap();
check_and_update_app_version(&db).await.unwrap();
let result = db
.query_scalar("SELECT COUNT(*) FROM _app_meta WHERE key = 'app_version'", ())
.await
.unwrap();
assert_eq!(result, Some(Value::Integer(1)));
}
#[test]
fn test_compare_versions() {
assert_eq!(compare_versions("0.1.0", "0.2.0"), Ordering::Less);
assert_eq!(compare_versions("0.2.0", "0.2.0"), Ordering::Equal);
assert_eq!(compare_versions("0.2.0", "0.1.0"), Ordering::Greater);
assert_eq!(compare_versions("1.0.0", "0.9.9"), Ordering::Greater);
assert_eq!(compare_versions("0.10.0", "0.2.0"), Ordering::Greater);
assert_eq!(compare_versions("0.2.0", "0.10.0"), Ordering::Less);
}
}