use anyhow::{anyhow, Context, Result};
use rusqlite::Connection;
const SCHEMA_MARKER_TABLE: &str = "hosts";
const OLD_SCHEMA_MARKER_TABLE: &str = "sdk_sessions";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DbKind {
Empty,
Old { user_version: i64 },
Schema { user_version: i64 },
}
fn table_exists(conn: &Connection, name: &str) -> Result<bool> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
[name],
|row| row.get(0),
)
.with_context(|| format!("probe sqlite_master for table {name}"))?;
Ok(count == 1)
}
pub fn detect_db_kind(conn: &Connection) -> Result<DbKind> {
let user_version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap_or(0);
let has_schema = table_exists(conn, SCHEMA_MARKER_TABLE)?;
let has_old_schema = table_exists(conn, OLD_SCHEMA_MARKER_TABLE)?;
match (has_schema, has_old_schema) {
(true, false) => Ok(DbKind::Schema { user_version }),
(false, true) => Ok(DbKind::Old { user_version }),
(false, false) => Ok(DbKind::Empty),
(true, true) => Err(anyhow!(
"DB has both old (sdk_sessions) and schema (hosts) tables; \
refusing to operate on a mixed-schema database"
)),
}
}
pub fn refuse_old_schema_for_writes(conn: &Connection) -> Result<()> {
match detect_db_kind(conn)? {
DbKind::Schema { .. } | DbKind::Empty => Ok(()),
DbKind::Old { user_version } => Err(anyhow!(old_schema_refusal_message(user_version))),
}
}
pub fn old_schema_refusal_message(user_version: i64) -> String {
format!(
"Old schema detected (user_version={user_version}).\n\
Run `remem admin backup` then `remem admin reset-schema --confirm-destructive` to upgrade.\n\
Read-only commands remain available."
)
}
#[cfg(test)]
mod tests {
use super::*;
fn open_in_memory() -> Connection {
Connection::open_in_memory().expect("open in-memory sqlite")
}
fn install_old_schema_marker(conn: &Connection, user_version: i64) {
conn.execute_batch(&format!(
"CREATE TABLE sdk_sessions(id INTEGER PRIMARY KEY); \
PRAGMA user_version = {user_version};"
))
.expect("install old schema marker");
}
fn install_schema_marker(conn: &Connection, user_version: i64) {
conn.execute_batch(&format!(
"CREATE TABLE hosts(id INTEGER PRIMARY KEY); \
PRAGMA user_version = {user_version};"
))
.expect("install schema marker");
}
#[test]
fn empty_db_is_classified_as_empty() {
let conn = open_in_memory();
assert_eq!(detect_db_kind(&conn).unwrap(), DbKind::Empty);
}
#[test]
fn old_baseline_is_classified_as_old() {
let conn = open_in_memory();
install_old_schema_marker(&conn, 13);
assert_eq!(
detect_db_kind(&conn).unwrap(),
DbKind::Old { user_version: 13 }
);
}
#[test]
fn schema_baseline_is_classified_as_schema() {
let conn = open_in_memory();
install_schema_marker(&conn, 1);
assert_eq!(
detect_db_kind(&conn).unwrap(),
DbKind::Schema { user_version: 1 }
);
}
#[test]
fn mixed_old_and_schema_tables_returns_error() {
let conn = open_in_memory();
install_old_schema_marker(&conn, 13);
conn.execute_batch("CREATE TABLE hosts(id INTEGER PRIMARY KEY);")
.unwrap();
let err = detect_db_kind(&conn).unwrap_err().to_string();
assert!(err.contains("mixed-schema"), "got: {err}");
}
#[test]
fn refuse_passes_on_schema() {
let conn = open_in_memory();
install_schema_marker(&conn, 1);
refuse_old_schema_for_writes(&conn).expect("schema must pass");
}
#[test]
fn refuse_passes_on_empty() {
let conn = open_in_memory();
refuse_old_schema_for_writes(&conn).expect("empty must pass; caller will init");
}
#[test]
fn refuse_blocks_old_schema_with_fixed_message() {
let conn = open_in_memory();
install_old_schema_marker(&conn, 4);
let err = refuse_old_schema_for_writes(&conn).unwrap_err().to_string();
assert!(err.contains("Old schema detected"), "got: {err}");
assert!(err.contains("user_version=4"), "got: {err}");
assert!(err.contains("remem admin backup"), "got: {err}");
assert!(err.contains("remem admin reset-schema"), "got: {err}");
assert!(
err.contains("Read-only commands remain available"),
"got: {err}"
);
}
#[test]
fn old_schema_refusal_message_is_deterministic() {
let m1 = old_schema_refusal_message(4);
let m2 = old_schema_refusal_message(4);
assert_eq!(m1, m2);
assert!(m1.contains("user_version=4"));
}
}