use crate::storage::{Database, Result, StorageError};
use log::info;
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'))",
},
];
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
))),
}
}
#[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(2)));
}
#[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, 2); }
#[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)));
}
}