use fsqlite::Connection;
use fsqlite::compat::*;
use fsqlite::migrate::{MigrationResult, MigrationRunner};
use fsqlite::params;
use fsqlite_types::value::SqliteValue;
#[test]
fn fresh_database_applies_all_migrations() {
let conn = Connection::open(":memory:").unwrap();
let result = MigrationRunner::new()
.add(
1,
"create_conversations",
"CREATE TABLE conversations (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
created_at INTEGER NOT NULL
);",
)
.add(
2,
"create_messages",
"CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT
);",
)
.add(
3,
"add_model_column",
"ALTER TABLE conversations ADD COLUMN model TEXT;",
)
.run(&conn)
.unwrap();
assert!(result.was_fresh, "should detect fresh database");
assert_eq!(result.applied, vec![1, 2, 3]);
assert_eq!(result.current, 3);
conn.execute_compat(
"INSERT INTO conversations (id, agent, created_at, model) VALUES (?1, ?2, ?3, ?4)",
params!["s-001", "claude", 1700000000_i64, "opus"],
)
.unwrap();
let row = conn
.query_row("SELECT model FROM conversations WHERE id = 's-001'")
.unwrap();
assert_eq!(row.get(0).unwrap(), &SqliteValue::Text("opus".into()));
}
#[test]
fn partial_resume_only_applies_new_migrations() {
let conn = Connection::open(":memory:").unwrap();
let runner_v2 = MigrationRunner::new()
.add(
1,
"create_items",
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);",
)
.add(
2,
"add_count",
"ALTER TABLE items ADD COLUMN count INTEGER DEFAULT 0;",
);
let r1 = runner_v2.run(&conn).unwrap();
assert_eq!(r1.applied, vec![1, 2]);
assert_eq!(r1.current, 2);
assert!(r1.was_fresh);
let runner_v3 = MigrationRunner::new()
.add(
1,
"create_items",
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);",
)
.add(
2,
"add_count",
"ALTER TABLE items ADD COLUMN count INTEGER DEFAULT 0;",
)
.add(
3,
"add_desc",
"ALTER TABLE items ADD COLUMN description TEXT;",
);
let r2 = runner_v3.run(&conn).unwrap();
assert_eq!(r2.applied, vec![3]);
assert_eq!(r2.current, 3);
assert!(!r2.was_fresh);
conn.execute("INSERT INTO items (id, name, description) VALUES (1, 'test', 'desc')")
.unwrap();
let row = conn
.query_row("SELECT description FROM items WHERE id = 1")
.unwrap();
assert_eq!(row.get(0).unwrap(), &SqliteValue::Text("desc".into()));
}
#[test]
fn idempotent_rerun_applies_nothing() {
let conn = Connection::open(":memory:").unwrap();
let runner = MigrationRunner::new()
.add(1, "create_t", "CREATE TABLE t (x INTEGER);")
.add(2, "create_u", "CREATE TABLE u (y TEXT);");
let r1 = runner.run(&conn).unwrap();
assert_eq!(r1.applied, vec![1, 2]);
let r2 = runner.run(&conn).unwrap();
assert!(r2.applied.is_empty(), "second run should apply nothing");
assert_eq!(r2.current, 2);
assert!(!r2.was_fresh);
}
#[test]
fn failed_migration_rolls_back() {
let conn = Connection::open(":memory:").unwrap();
let runner = MigrationRunner::new()
.add(1, "create_t", "CREATE TABLE t (x INTEGER);")
.add(2, "bad_sql", "THIS IS NOT VALID SQL;");
let result = runner.run(&conn);
assert!(result.is_err(), "invalid SQL should fail");
}
#[test]
fn multi_statement_migration() {
let conn = Connection::open(":memory:").unwrap();
let result = MigrationRunner::new()
.add(
1,
"create_schema",
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE roles (id INTEGER PRIMARY KEY, role TEXT);
CREATE INDEX idx_users_name ON users(name);",
)
.run(&conn)
.unwrap();
assert_eq!(result.applied, vec![1]);
conn.execute("INSERT INTO users (id, name) VALUES (1, 'alice')")
.unwrap();
conn.execute("INSERT INTO roles (id, role) VALUES (1, 'admin')")
.unwrap();
let rows = conn
.query("SELECT name FROM users WHERE name = 'alice'")
.unwrap();
assert_eq!(rows.len(), 1);
}
#[test]
fn empty_runner_on_fresh_db() {
let conn = Connection::open(":memory:").unwrap();
let result = MigrationRunner::new().run(&conn).unwrap();
assert!(result.applied.is_empty());
assert_eq!(result.current, 0);
assert!(result.was_fresh);
}
#[test]
fn migration_records_name_in_tracking_table() {
let conn = Connection::open(":memory:").unwrap();
MigrationRunner::new()
.add(1, "init_schema", "CREATE TABLE t (x INTEGER);")
.add(2, "add_index", "CREATE INDEX idx_t_x ON t(x);")
.run(&conn)
.unwrap();
let rows = conn
.query("SELECT version, name FROM _schema_migrations ORDER BY version")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get(0).unwrap(), &SqliteValue::Integer(1));
assert_eq!(
rows[0].get(1).unwrap(),
&SqliteValue::Text("init_schema".into())
);
assert_eq!(rows[1].get(0).unwrap(), &SqliteValue::Integer(2));
assert_eq!(
rows[1].get(1).unwrap(),
&SqliteValue::Text("add_index".into())
);
}
#[test]
fn migration_tracking_table_has_applied_at() {
let conn = Connection::open(":memory:").unwrap();
MigrationRunner::new()
.add(1, "init", "CREATE TABLE t (x INTEGER);")
.run(&conn)
.unwrap();
let rows = conn
.query("SELECT applied_at FROM _schema_migrations WHERE version = 1")
.unwrap();
assert_eq!(rows.len(), 1);
if let SqliteValue::Text(ts) = rows[0].get(0).unwrap() {
assert!(!ts.is_empty(), "applied_at should not be empty");
assert!(
ts.contains('T') || ts.contains('-'),
"applied_at should look like an ISO timestamp, got: {ts}"
);
} else {
panic!("applied_at should be text");
}
}
#[test]
#[should_panic(expected = "")]
fn panics_on_non_ascending_versions() {
MigrationRunner::new()
.add(2, "second", "CREATE TABLE b (y TEXT);")
.add(1, "first", "CREATE TABLE a (x INTEGER);");
}
#[test]
#[should_panic(expected = "")]
fn panics_on_duplicate_versions() {
MigrationRunner::new()
.add(1, "first", "CREATE TABLE a (x INTEGER);")
.add(1, "duplicate", "CREATE TABLE b (y TEXT);");
}
#[test]
fn cass_like_migration_sequence() {
let conn = Connection::open(":memory:").unwrap();
let result = MigrationRunner::new()
.add(
1,
"initial_schema",
"CREATE TABLE conversations (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
workspace TEXT,
project_dir TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER,
model TEXT,
title TEXT,
message_count INTEGER DEFAULT 0,
source_id TEXT DEFAULT 'local'
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL,
content TEXT,
timestamp INTEGER,
token_count INTEGER DEFAULT 0
);
CREATE INDEX idx_conv_agent ON conversations(agent);
CREATE INDEX idx_conv_created ON conversations(created_at);
CREATE INDEX idx_msg_conv ON messages(conversation_id);",
)
.add(
2,
"add_bookmarks",
"CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
message_index INTEGER,
note TEXT,
created_at INTEGER NOT NULL
);",
)
.add(
3,
"add_tags",
"ALTER TABLE conversations ADD COLUMN tags TEXT DEFAULT '';",
)
.run(&conn)
.unwrap();
assert!(result.was_fresh);
assert_eq!(result.applied, vec![1, 2, 3]);
assert_eq!(result.current, 3);
conn.execute_compat(
"INSERT INTO conversations (id, agent, created_at, title, tags) VALUES (?1, ?2, ?3, ?4, ?5)",
params!["s-001", "claude_code", 1700000000_i64, "Debug auth", "rust,auth"],
).unwrap();
conn.execute_compat(
"INSERT INTO messages (conversation_id, role, content, timestamp) VALUES (?1, ?2, ?3, ?4)",
params!["s-001", "user", "Why is auth broken?", 1700000000_i64],
)
.unwrap();
conn.execute_compat(
"INSERT INTO bookmarks (conversation_id, message_index, note, created_at) VALUES (?1, ?2, ?3, ?4)",
params!["s-001", 0_i64, "Key insight", 1700000001_i64],
).unwrap();
let title: String = conn
.query_row_map(
"SELECT title FROM conversations WHERE id = ?1",
params!["s-001"],
|row| row.get_typed(0),
)
.unwrap();
assert_eq!(title, "Debug auth");
let msg_count: i64 = conn
.query_row_map(
"SELECT COUNT(*) FROM messages WHERE conversation_id = ?1",
params!["s-001"],
|row| row.get_typed(0),
)
.unwrap();
assert_eq!(msg_count, 1);
let bm_note: String = conn
.query_row_map(
"SELECT note FROM bookmarks WHERE conversation_id = ?1",
params!["s-001"],
|row| row.get_typed(0),
)
.unwrap();
assert_eq!(bm_note, "Key insight");
}
#[test]
fn migration_result_fields_all_correct() {
let conn = Connection::open(":memory:").unwrap();
let r: MigrationResult = MigrationRunner::new()
.add(10, "v10", "CREATE TABLE t10 (x INTEGER);")
.add(20, "v20", "CREATE TABLE t20 (y TEXT);")
.run(&conn)
.unwrap();
assert!(r.was_fresh);
assert_eq!(r.applied, vec![10, 20]);
assert_eq!(r.current, 20);
}