use rusqlite::Connection;
use crate::error::{FemindError, Result};
use crate::storage::schema;
pub const CURRENT_VERSION: u32 = schema::SCHEMA_VERSION;
pub fn migrate(conn: &Connection) -> Result<()> {
create_meta_table(conn)?;
let version = get_version(conn)?;
if version == 0 {
schema::create_schema(conn)?;
set_version(conn, CURRENT_VERSION)?;
return Ok(());
}
if version > CURRENT_VERSION {
return Err(FemindError::Migration(format!(
"database schema version ({version}) is newer than this build supports ({CURRENT_VERSION}). \
Upgrade femind to open this database."
)));
}
if version < CURRENT_VERSION {
run_migrations(conn, version)?;
}
Ok(())
}
fn create_meta_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS femind_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
fn table_exists(conn: &Connection, table_name: &str) -> Result<bool> {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table_name],
|row| row.get(0),
)?;
Ok(exists > 0)
}
fn read_version_from_table(conn: &Connection, table_name: &str) -> Result<Option<u32>> {
if !table_exists(conn, table_name)? {
return Ok(None);
}
let query = format!("SELECT value FROM {table_name} WHERE key = 'schema_version'");
let result = conn.query_row(&query, [], |row| row.get::<_, String>(0));
match result {
Ok(v) => v
.parse::<u32>()
.map(Some)
.map_err(|e| FemindError::Migration(format!("invalid schema version '{v}': {e}"))),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn get_version(conn: &Connection) -> Result<u32> {
if let Some(version) = read_version_from_table(conn, "femind_meta")? {
return Ok(version);
}
if let Some(version) = read_version_from_table(conn, "mindcore_meta")? {
return Ok(version);
}
Ok(0)
}
fn set_version(conn: &Connection, version: u32) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO femind_meta (key, value) VALUES ('schema_version', ?1)",
[version.to_string()],
)?;
Ok(())
}
type Migration = fn(&Connection) -> Result<()>;
const MIGRATIONS: &[Migration] = &[
];
fn run_migrations(conn: &Connection, from_version: u32) -> Result<()> {
for (i, migration) in MIGRATIONS.iter().enumerate() {
let migration_version = (i as u32) + 1;
if migration_version >= from_version && migration_version < CURRENT_VERSION {
tracing::info!(
from = migration_version,
to = migration_version + 1,
"running schema migration"
);
let tx = conn.unchecked_transaction().map_err(|e| {
FemindError::Migration(format!("failed to start migration transaction: {e}"))
})?;
migration(&tx)?;
set_version(&tx, migration_version + 1)?;
tx.commit()
.map_err(|e| FemindError::Migration(format!("migration commit failed: {e}")))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_fresh_database() {
let conn = Connection::open_in_memory().expect("open failed");
let result = migrate(&conn);
assert!(result.is_ok(), "migration failed: {result:?}");
let version = get_version(&conn).expect("get_version failed");
assert_eq!(version, CURRENT_VERSION);
}
#[test]
fn migrate_is_idempotent() {
let conn = Connection::open_in_memory().expect("open failed");
migrate(&conn).expect("first migration failed");
migrate(&conn).expect("second migration should succeed");
let version = get_version(&conn).expect("get_version failed");
assert_eq!(version, CURRENT_VERSION);
}
#[test]
fn migrate_creates_meta_table() {
let conn = Connection::open_in_memory().expect("open failed");
migrate(&conn).expect("migration failed");
let count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='femind_meta'",
[],
|row| row.get(0),
)
.expect("query failed");
assert_eq!(count, 1);
}
#[test]
fn migrate_reads_legacy_meta_table() {
let conn = Connection::open_in_memory().expect("open failed");
conn.execute_batch(
"CREATE TABLE mindcore_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO mindcore_meta (key, value) VALUES ('schema_version', '1');",
)
.expect("legacy meta table");
let version = get_version(&conn).expect("get_version failed");
assert_eq!(version, 1);
}
#[test]
fn migrate_creates_memories_table() {
let conn = Connection::open_in_memory().expect("open failed");
migrate(&conn).expect("migration failed");
let count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories'",
[],
|row| row.get(0),
)
.expect("query failed");
assert_eq!(count, 1);
}
#[test]
fn rejects_newer_version() {
let conn = Connection::open_in_memory().expect("open failed");
create_meta_table(&conn).expect("meta table failed");
set_version(&conn, CURRENT_VERSION + 1).expect("set version failed");
let result = migrate(&conn);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("newer than this build"),
"unexpected error: {err}"
);
}
#[test]
fn version_roundtrip() {
let conn = Connection::open_in_memory().expect("open failed");
create_meta_table(&conn).expect("meta table failed");
set_version(&conn, 42).expect("set failed");
let v = get_version(&conn).expect("get failed");
assert_eq!(v, 42);
}
}