pub mod models;
use crate::error::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use std::path::Path;
pub async fn create_pool(db_path: &Path) -> Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_millis(5000))
.pragma("foreign_keys", "ON");
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
Ok(pool)
}
fn parse_version(v: &str) -> (u32, u32, u32) {
let mut parts = v.splitn(3, '.');
let major = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
(major, minor, patch)
}
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name=?")
.bind(name)
.fetch_one(pool)
.await
.unwrap_or(0);
count > 0
}
#[cfg(test)]
async fn column_exists(pool: &SqlitePool, table: &str, column: &str) -> bool {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name=?")
.bind(table)
.bind(column)
.fetch_one(pool)
.await
.unwrap_or(0);
count > 0
}
async fn set_schema_version(pool: &SqlitePool, version: &str) -> Result<()> {
sqlx::query(
"INSERT INTO workspace_state (key, value) VALUES ('schema_version', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(version)
.execute(pool)
.await?;
Ok(())
}
async fn detect_schema_version(pool: &SqlitePool) -> Result<(u32, u32, u32)> {
if table_exists(pool, "workspace_state").await {
let stored: Option<String> =
sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'schema_version'")
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some(v) = stored {
return Ok(parse_version(&v));
}
}
if !table_exists(pool, "tasks").await {
return Ok((0, 0, 0));
}
Ok((0, 1, 0))
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::query("PRAGMA journal_mode=WAL").execute(pool).await?;
let version = detect_schema_version(pool).await?;
if version == (0, 0, 0) {
migrate_fresh(pool).await?;
return Ok(());
}
if version < (0, 9, 0) {
upgrade_to_v0_9_0(pool).await?;
}
if version < (0, 11, 0) {
upgrade_to_v0_11_0(pool).await?;
}
if version < (0, 12, 0) {
upgrade_to_v0_12_0(pool).await?;
}
if version < (0, 13, 0) {
upgrade_to_v0_13_0(pool).await?;
}
if version < (0, 14, 0) {
upgrade_to_v0_14_0(pool).await?;
}
if version < (0, 15, 0) {
upgrade_to_v0_15_0(pool).await?;
}
Ok(())
}
async fn migrate_fresh(pool: &SqlitePool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
spec TEXT,
status TEXT NOT NULL DEFAULT 'todo',
complexity INTEGER,
priority INTEGER DEFAULT 0,
first_todo_at DATETIME,
first_doing_at DATETIME,
first_done_at DATETIME,
active_form TEXT,
owner TEXT NOT NULL DEFAULT 'human',
metadata TEXT DEFAULT '{}',
deleted_at DATETIME,
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE,
CHECK (status IN ('todo', 'doing', 'done')),
CHECK (owner IS NOT NULL AND owner != '')
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
name,
spec,
content=tasks,
content_rowid=id,
tokenize='trigram'
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_ai AFTER INSERT ON tasks BEGIN
INSERT INTO tasks_fts(rowid, name, spec) VALUES (new.id, new.name, new.spec);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_ad AFTER DELETE ON tasks BEGIN
DELETE FROM tasks_fts WHERE rowid = old.id;
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_au_active
AFTER UPDATE ON tasks WHEN old.deleted_at IS NULL AND new.deleted_at IS NULL BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, name, spec)
VALUES('delete', old.id, old.name, old.spec);
INSERT INTO tasks_fts(rowid, name, spec) VALUES (new.id, new.name, new.spec);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_au_softdelete
AFTER UPDATE ON tasks WHEN old.deleted_at IS NULL AND new.deleted_at IS NOT NULL BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, name, spec)
VALUES('delete', old.id, old.name, old.spec);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
log_type TEXT NOT NULL,
discussion_data TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE RESTRICT
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_task_id ON events(task_id)")
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_events_task_type_time \
ON events(task_id, log_type, timestamp)",
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
discussion_data,
content=events,
content_rowid=id
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, discussion_data) VALUES (new.id, new.discussion_data);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN
DELETE FROM events_fts WHERE rowid = old.id;
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, discussion_data)
VALUES('delete', old.id, old.discussion_data);
INSERT INTO events_fts(rowid, discussion_data)
VALUES (new.id, new.discussion_data);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS workspace_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
current_task_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (current_task_id) REFERENCES tasks(id) ON DELETE SET NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_last_active ON sessions(last_active_at)")
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
dismissed BOOLEAN NOT NULL DEFAULT 0,
CHECK (type IN ('task_structure', 'event_synthesis', 'error'))
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_suggestions_active \
ON suggestions(dismissed, created_at) WHERE dismissed = 0",
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blocking_task_id INTEGER NOT NULL,
blocked_task_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (blocking_task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (blocked_task_id) REFERENCES tasks(id) ON DELETE CASCADE,
UNIQUE(blocking_task_id, blocked_task_id),
CHECK(blocking_task_id != blocked_task_id)
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_dependencies_blocking ON dependencies(blocking_task_id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_dependencies_blocked ON dependencies(blocked_task_id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_status_parent_priority \
ON tasks(status, parent_id, priority, id) WHERE deleted_at IS NULL",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_priority_complexity \
ON tasks(priority, complexity, id) WHERE deleted_at IS NULL",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_doing_at \
ON tasks(first_doing_at) WHERE status = 'doing' AND deleted_at IS NULL",
)
.execute(pool)
.await?;
set_schema_version(pool, "0.15.0").await?;
Ok(())
}
async fn upgrade_to_v0_9_0(pool: &SqlitePool) -> Result<()> {
let uses_trigram: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_schema \
WHERE name='tasks_fts' AND type='table' AND sql LIKE '%trigram%'",
)
.fetch_one(pool)
.await
.unwrap_or(0);
if uses_trigram == 0 {
let _ = sqlx::query("DROP TABLE IF EXISTS tasks_fts")
.execute(pool)
.await;
sqlx::query(
r#"
CREATE VIRTUAL TABLE tasks_fts USING fts5(
name,
spec,
content=tasks,
content_rowid=id,
tokenize='trigram'
)
"#,
)
.execute(pool)
.await?;
sqlx::query("INSERT INTO tasks_fts(rowid, name, spec) SELECT id, name, spec FROM tasks")
.execute(pool)
.await?;
}
let _ = sqlx::query("ALTER TABLE tasks ADD COLUMN owner TEXT NOT NULL DEFAULT 'human'")
.execute(pool)
.await;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS workspace_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"#,
)
.execute(pool)
.await?;
set_schema_version(pool, "0.9.0").await?;
Ok(())
}
async fn upgrade_to_v0_11_0(pool: &SqlitePool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
current_task_id INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (current_task_id) REFERENCES tasks(id) ON DELETE SET NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_last_active ON sessions(last_active_at)")
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
dismissed BOOLEAN NOT NULL DEFAULT 0,
CHECK (type IN ('task_structure', 'event_synthesis', 'error'))
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_suggestions_active \
ON suggestions(dismissed, created_at) WHERE dismissed = 0",
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blocking_task_id INTEGER NOT NULL,
blocked_task_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (blocking_task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (blocked_task_id) REFERENCES tasks(id) ON DELETE CASCADE,
UNIQUE(blocking_task_id, blocked_task_id),
CHECK(blocking_task_id != blocked_task_id)
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_dependencies_blocking ON dependencies(blocking_task_id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_dependencies_blocked ON dependencies(blocked_task_id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_events_task_type_time \
ON events(task_id, log_type, timestamp)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_status_parent_priority \
ON tasks(status, parent_id, priority, id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_priority_complexity \
ON tasks(priority, complexity, id)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_doing_at \
ON tasks(first_doing_at) WHERE status = 'doing'",
)
.execute(pool)
.await?;
sqlx::query(
r#"
INSERT OR IGNORE INTO sessions (session_id, current_task_id, created_at, last_active_at)
SELECT '-1', CAST(value AS INTEGER), datetime('now'), datetime('now')
FROM workspace_state
WHERE key = 'current_task_id' AND value IS NOT NULL AND value != ''
"#,
)
.execute(pool)
.await?;
set_schema_version(pool, "0.11.0").await?;
Ok(())
}
async fn upgrade_to_v0_12_0(pool: &SqlitePool) -> Result<()> {
let _ = sqlx::query("ALTER TABLE tasks ADD COLUMN metadata TEXT DEFAULT '{}'")
.execute(pool)
.await;
set_schema_version(pool, "0.12.0").await?;
Ok(())
}
async fn upgrade_to_v0_13_0(pool: &SqlitePool) -> Result<()> {
let column_added = sqlx::query("ALTER TABLE tasks ADD COLUMN deleted_at DATETIME")
.execute(pool)
.await
.is_ok();
let _ = sqlx::query("DROP TRIGGER IF EXISTS tasks_au")
.execute(pool)
.await;
let _ = sqlx::query("DROP TRIGGER IF EXISTS tasks_au_active")
.execute(pool)
.await;
let _ = sqlx::query("DROP TRIGGER IF EXISTS tasks_au_softdelete")
.execute(pool)
.await;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_au_active
AFTER UPDATE ON tasks WHEN old.deleted_at IS NULL AND new.deleted_at IS NULL BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, name, spec)
VALUES('delete', old.id, old.name, old.spec);
INSERT INTO tasks_fts(rowid, name, spec) VALUES (new.id, new.name, new.spec);
END
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS tasks_au_softdelete
AFTER UPDATE ON tasks WHEN old.deleted_at IS NULL AND new.deleted_at IS NOT NULL BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, name, spec)
VALUES('delete', old.id, old.name, old.spec);
END
"#,
)
.execute(pool)
.await?;
if column_added {
let has_fts_data: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts")
.fetch_one(pool)
.await
.unwrap_or(0);
if has_fts_data > 0 {
sqlx::query("INSERT INTO tasks_fts(tasks_fts) VALUES('delete-all')")
.execute(pool)
.await?;
}
sqlx::query(
"INSERT INTO tasks_fts(rowid, name, spec) \
SELECT id, name, spec FROM tasks WHERE deleted_at IS NULL",
)
.execute(pool)
.await?;
}
set_schema_version(pool, "0.13.0").await?;
Ok(())
}
async fn upgrade_to_v0_14_0(pool: &SqlitePool) -> Result<()> {
let _ = sqlx::query("DROP INDEX IF EXISTS idx_tasks_status_parent_priority")
.execute(pool)
.await;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_status_parent_priority \
ON tasks(status, parent_id, priority, id) WHERE deleted_at IS NULL",
)
.execute(pool)
.await?;
let _ = sqlx::query("DROP INDEX IF EXISTS idx_tasks_priority_complexity")
.execute(pool)
.await;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_priority_complexity \
ON tasks(priority, complexity, id) WHERE deleted_at IS NULL",
)
.execute(pool)
.await?;
let _ = sqlx::query("DROP INDEX IF EXISTS idx_tasks_doing_at")
.execute(pool)
.await;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_tasks_doing_at \
ON tasks(first_doing_at) WHERE status = 'doing' AND deleted_at IS NULL",
)
.execute(pool)
.await?;
let needs_restrict: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_schema \
WHERE type='table' AND name='events' AND sql NOT LIKE '%ON DELETE RESTRICT%'",
)
.fetch_one(pool)
.await
.unwrap_or(0);
if needs_restrict > 0 {
let mut tx = pool.begin().await?;
sqlx::query("DROP TABLE IF EXISTS events_new")
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TABLE events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
log_type TEXT NOT NULL,
discussion_data TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE RESTRICT
)
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query("INSERT INTO events_new SELECT * FROM events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE events").execute(&mut *tx).await?;
sqlx::query("ALTER TABLE events_new RENAME TO events")
.execute(&mut *tx)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_task_id ON events(task_id)")
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_events_task_type_time \
ON events(task_id, log_type, timestamp)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, discussion_data)
VALUES (new.id, new.discussion_data);
END
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN
DELETE FROM events_fts WHERE rowid = old.id;
END
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, discussion_data)
VALUES('delete', old.id, old.discussion_data);
INSERT INTO events_fts(rowid, discussion_data)
VALUES (new.id, new.discussion_data);
END
"#,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
set_schema_version(pool, "0.14.0").await?;
Ok(())
}
async fn upgrade_to_v0_15_0(pool: &SqlitePool) -> Result<()> {
let _ = sqlx::query("DROP TRIGGER IF EXISTS tasks_au_active")
.execute(pool)
.await;
sqlx::query(
r#"CREATE TRIGGER IF NOT EXISTS tasks_au_active
AFTER UPDATE ON tasks WHEN old.deleted_at IS NULL AND new.deleted_at IS NULL BEGIN
INSERT INTO tasks_fts(tasks_fts, rowid, name, spec)
VALUES('delete', old.id, old.name, old.spec);
INSERT INTO tasks_fts(rowid, name, spec) VALUES (new.id, new.name, new.spec);
END"#,
)
.execute(pool)
.await
.map_err(crate::error::IntentError::DatabaseError)?;
set_schema_version(pool, "0.15.0").await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_create_pool_success() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
let result: i64 = sqlx::query_scalar("SELECT 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(result, 1);
}
#[tokio::test]
async fn test_run_migrations_creates_tables() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> =
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.contains(&"tasks".to_string()));
assert!(tables.contains(&"events".to_string()));
assert!(tables.contains(&"workspace_state".to_string()));
}
#[tokio::test]
async fn test_run_migrations_creates_fts_tables() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts'",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.contains(&"tasks_fts".to_string()));
assert!(tables.contains(&"events_fts".to_string()));
}
#[tokio::test]
async fn test_run_migrations_creates_triggers() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let triggers: Vec<String> =
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='trigger'")
.fetch_all(&pool)
.await
.unwrap();
assert!(triggers.contains(&"tasks_ai".to_string()));
assert!(triggers.contains(&"tasks_ad".to_string()));
assert!(triggers.contains(&"tasks_au_active".to_string()));
assert!(triggers.contains(&"tasks_au_softdelete".to_string()));
assert!(triggers.contains(&"events_ai".to_string()));
assert!(triggers.contains(&"events_ad".to_string()));
assert!(triggers.contains(&"events_au".to_string()));
}
#[tokio::test]
async fn test_run_migrations_idempotent() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> =
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table'")
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.len() >= 3);
}
#[tokio::test]
async fn test_fts_triggers_work() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query("INSERT INTO tasks (name, spec, status) VALUES (?, ?, ?)")
.bind("Test task")
.bind("Test spec")
.bind("todo")
.execute(&pool)
.await
.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'Test'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_workspace_state_table_structure() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query("INSERT INTO workspace_state (key, value) VALUES (?, ?)")
.bind("test_key")
.bind("test_value")
.execute(&pool)
.await
.unwrap();
let value: String = sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = ?")
.bind("test_key")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(value, "test_value");
}
#[tokio::test]
async fn test_task_status_constraint() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let result = sqlx::query("INSERT INTO tasks (name, status) VALUES (?, ?)")
.bind("Test")
.bind("invalid_status")
.execute(&pool)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_dependencies_table_created() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='table' AND name='dependencies'",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.contains(&"dependencies".to_string()));
}
#[tokio::test]
async fn test_dependencies_indexes_created() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let indexes: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_dependencies_blocking', 'idx_dependencies_blocked', 'idx_events_task_type_time')",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(indexes.contains(&"idx_dependencies_blocking".to_string()));
assert!(indexes.contains(&"idx_dependencies_blocked".to_string()));
assert!(indexes.contains(&"idx_events_task_type_time".to_string()));
}
#[tokio::test]
async fn test_dependencies_self_dependency_constraint() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query("INSERT INTO tasks (name, status) VALUES (?, ?)")
.bind("Task 1")
.bind("todo")
.execute(&pool)
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)",
)
.bind(1)
.bind(1)
.execute(&pool)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_dependencies_unique_constraint() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
for i in 1..=2 {
sqlx::query("INSERT INTO tasks (name, status) VALUES (?, ?)")
.bind(format!("Task {}", i))
.bind("todo")
.execute(&pool)
.await
.unwrap();
}
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(1)
.bind(2)
.execute(&pool)
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)",
)
.bind(1)
.bind(2)
.execute(&pool)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_dependencies_cascade_delete() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
for i in 1..=2 {
sqlx::query("INSERT INTO tasks (name, status) VALUES (?, ?)")
.bind(format!("Task {}", i))
.bind("todo")
.execute(&pool)
.await
.unwrap();
}
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(1)
.bind(2)
.execute(&pool)
.await
.unwrap();
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM dependencies")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(1)
.execute(&pool)
.await
.unwrap();
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM dependencies")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_soft_delete_preserves_events() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query("INSERT INTO tasks (name, status) VALUES ('audited task', 'todo')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO events (task_id, log_type, discussion_data) VALUES (1, 'decision', 'why we chose X')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE tasks SET deleted_at = datetime('now') WHERE id = 1")
.execute(&pool)
.await
.unwrap();
let active: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE id = 1 AND deleted_at IS NULL")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(active, 0, "task should be soft-deleted");
let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE task_id = 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(events, 1, "event must be retained after soft-delete");
}
#[tokio::test]
async fn test_physical_delete_blocked_when_task_has_events() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query("INSERT INTO tasks (name, status) VALUES ('task with history', 'todo')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO events (task_id, log_type, discussion_data) VALUES (1, 'decision', 'important decision')",
)
.execute(&pool)
.await
.unwrap();
let result = sqlx::query("DELETE FROM tasks WHERE id = 1")
.execute(&pool)
.await;
assert!(
result.is_err(),
"physical delete of task with events must be rejected by ON DELETE RESTRICT"
);
let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE task_id = 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(events, 1, "event must not have been deleted");
}
#[tokio::test]
async fn test_schema_version_tracking() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let version: String =
sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'schema_version'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(version, "0.15.0");
}
#[tokio::test]
async fn test_migration_idempotency_v0_11_0() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='table' AND name='dependencies'",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.contains(&"dependencies".to_string()));
let version: String =
sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'schema_version'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(version, "0.15.0");
}
#[tokio::test]
async fn test_sessions_table_created() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let tables: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(tables.contains(&"sessions".to_string()));
let indices: Vec<String> = sqlx::query_scalar(
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_sessions_last_active'",
)
.fetch_all(&pool)
.await
.unwrap();
assert!(indices.contains(&"idx_sessions_last_active".to_string()));
}
#[tokio::test]
async fn test_detect_schema_version_fresh() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
let version = detect_schema_version(&pool).await.unwrap();
assert_eq!(version, (0, 0, 0), "fresh database must return (0,0,0)");
}
#[tokio::test]
async fn test_detect_schema_version_after_migration() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
let version = detect_schema_version(&pool).await.unwrap();
assert_eq!(
version,
(0, 15, 0),
"after migration detect must return (0,15,0)"
);
}
#[tokio::test]
async fn test_migration_from_pre_version_db() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
sqlx::query(
r#"
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
spec TEXT,
status TEXT NOT NULL DEFAULT 'todo',
complexity INTEGER,
priority INTEGER DEFAULT 0,
first_todo_at DATETIME,
first_doing_at DATETIME,
first_done_at DATETIME,
active_form TEXT,
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE,
CHECK (status IN ('todo', 'doing', 'done'))
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
log_type TEXT NOT NULL,
discussion_data TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO tasks (name, status) VALUES ('legacy task', 'todo')")
.execute(&pool)
.await
.unwrap();
let version_before = detect_schema_version(&pool).await.unwrap();
assert_eq!(
version_before,
(0, 1, 0),
"old schema without owner should be detected as (0,1,0)"
);
run_migrations(&pool).await.unwrap();
let version: String =
sqlx::query_scalar("SELECT value FROM workspace_state WHERE key = 'schema_version'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(version, "0.15.0");
let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(task_count, 1, "legacy task must survive the upgrade");
assert!(
column_exists(&pool, "tasks", "owner").await,
"owner column must exist after upgrade"
);
assert!(
column_exists(&pool, "tasks", "metadata").await,
"metadata column must exist after upgrade"
);
assert!(
column_exists(&pool, "tasks", "deleted_at").await,
"deleted_at column must exist after upgrade"
);
assert!(
table_exists(&pool, "sessions").await,
"sessions table must exist after upgrade"
);
assert!(
table_exists(&pool, "dependencies").await,
"dependencies table must exist after upgrade"
);
}
#[tokio::test]
async fn test_fts_excludes_soft_deleted_task() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('unique_fts_target', 'some spec', 'todo')",
)
.execute(&pool)
.await
.unwrap();
let before: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'unique_fts_target'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(before, 1, "task must be in FTS before soft-delete");
sqlx::query(
"UPDATE tasks SET deleted_at = datetime('now') WHERE name = 'unique_fts_target'",
)
.execute(&pool)
.await
.unwrap();
let after: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'unique_fts_target'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(after, 0, "soft-deleted task must be removed from FTS");
}
#[tokio::test]
async fn test_fts_soft_delete_does_not_affect_siblings() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('keep_this_task', 'spec a', 'todo')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('delete_this_task', 'spec b', 'todo')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE tasks SET deleted_at = datetime('now') WHERE name = 'delete_this_task'",
)
.execute(&pool)
.await
.unwrap();
let deleted: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'delete_this_task'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(deleted, 0, "deleted task must not appear in FTS");
let kept: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'keep_this_task'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kept, 1, "surviving task must still be in FTS");
}
#[tokio::test]
async fn test_fts_update_of_soft_deleted_task_stays_excluded() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('archived_task', 'old spec', 'todo')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE tasks SET deleted_at = datetime('now') WHERE name = 'archived_task'")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE tasks SET name = 'archived_task_renamed', spec = 'new spec' \
WHERE name = 'archived_task'",
)
.execute(&pool)
.await
.unwrap();
let old_name: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'archived_task'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
old_name, 0,
"old name of soft-deleted task must not be in FTS"
);
let new_name: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'archived_task_renamed'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
new_name, 0,
"renamed soft-deleted task must not re-enter FTS"
);
}
#[tokio::test]
async fn test_fts_restore_does_not_corrupt_fts() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('restore_me', 'spec text', 'todo')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE tasks SET deleted_at = datetime('now') WHERE name = 'restore_me'")
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE tasks SET deleted_at = NULL WHERE name = 'restore_me'")
.execute(&pool)
.await
.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE tasks_fts MATCH 'restore_me'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count, 0,
"restored task is not in FTS until explicitly re-indexed"
);
sqlx::query(
"INSERT INTO tasks (name, spec, status) VALUES ('healthy_task', 'healthy spec', 'todo')",
)
.execute(&pool)
.await
.unwrap();
let healthy: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks_fts WHERE name MATCH 'healthy_task'")
.fetch_one(&pool)
.await
.expect("FTS must remain functional after restore — index is not corrupted");
assert_eq!(healthy, 1, "FTS index must still work after restore");
}
}