use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use noisebell_common::{CacheStatusResponse, DoorStatus}; use rusqlite::{Connection, OptionalExtension}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LiveDoorStatus { Open, Closed, } impl LiveDoorStatus { const fn into_door_status(self) -> DoorStatus { match self { Self::Open => DoorStatus::Open, Self::Closed => DoorStatus::Closed, } } } impl TryFrom for LiveDoorStatus { type Error = &'static str; fn try_from(value: DoorStatus) -> std::result::Result { match value { DoorStatus::Open => Ok(Self::Open), DoorStatus::Closed => Ok(Self::Closed), DoorStatus::Offline => Err("offline is not a live door state"), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CachedState { Unknown, Live { status: LiveDoorStatus, since: u64 }, Offline { since: u64 }, } impl CachedState { const fn status_for_api(self) -> DoorStatus { match self { Self::Unknown | Self::Offline { .. } => DoorStatus::Offline, Self::Live { status, .. } => status.into_door_status(), } } const fn since_for_api(self) -> Option { match self { Self::Unknown => None, Self::Live { since, .. } | Self::Offline { since } => Some(since), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CurrentStateRow { state: CachedState, last_seen: Option, last_checked: Option, } fn parse_status(status: &str, location: &str) -> Result { status .parse() .with_context(|| format!("invalid door status {status:?} in {location}")) } pub fn init(path: &str) -> Result { let conn = Connection::open(path).context("failed to open SQLite database")?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; conn.execute_batch( " CREATE TABLE IF NOT EXISTS current_state ( id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('open', 'closed', 'offline')), timestamp INTEGER, last_seen INTEGER, last_checked INTEGER ); INSERT OR IGNORE INTO current_state (id) VALUES (1); ", ) .context("failed to initialize database schema")?; migrate_current_state(&conn)?; Ok(conn) } fn current_state_has_column(conn: &Connection, column: &str) -> Result { let mut stmt = conn.prepare("PRAGMA table_info(current_state)")?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let name: String = row.get(1)?; if name == column { return Ok(true); } } Ok(false) } fn migrate_current_state(conn: &Connection) -> Result<()> { if !current_state_has_column(conn, "last_checked")? { conn.execute( "ALTER TABLE current_state ADD COLUMN last_checked INTEGER", [], ) .context("failed to add current_state.last_checked")?; } conn.execute( "UPDATE current_state SET status = 'offline' WHERE status IS NULL", [], ) .context("failed to backfill NULL current_state.status")?; validate_status_values(conn)?; Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApplyStateOutcome { Applied, Duplicate, Stale, } fn validate_status_column(conn: &Connection, table: &str) -> Result<()> { let query = format!( "SELECT status FROM {table} WHERE status IS NULL OR status NOT IN ('open', 'closed', 'offline') LIMIT 1" ); let invalid: Option> = conn .query_row(&query, [], |row| row.get(0)) .optional() .context(format!("failed to validate {table}.status"))?; if let Some(status) = invalid { match status { Some(status) => anyhow::bail!("invalid door status {status:?} in {table}.status"), None => anyhow::bail!("invalid NULL door status in {table}.status"), } } Ok(()) } fn validate_status_values(conn: &Connection) -> Result<()> { validate_status_column(conn, "current_state")?; Ok(()) } fn current_state_row(conn: &Connection) -> Result { let (status_str, since, last_seen, last_checked) = conn.query_row( "SELECT status, timestamp, last_seen, last_checked FROM current_state WHERE id = 1", [], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, )) }, )?; let status = parse_status(&status_str, "current_state.status")?; let state = match (status, since) { (DoorStatus::Open, Some(since)) => CachedState::Live { status: LiveDoorStatus::Open, since, }, (DoorStatus::Closed, Some(since)) => CachedState::Live { status: LiveDoorStatus::Closed, since, }, (DoorStatus::Offline, Some(since)) => CachedState::Offline { since }, (DoorStatus::Offline, None) => CachedState::Unknown, (DoorStatus::Open | DoorStatus::Closed, None) => { anyhow::bail!("live current_state.status must have a timestamp") } }; Ok(CurrentStateRow { state, last_seen, last_checked, }) } pub fn get_status(conn: &Connection) -> Result { let row = current_state_row(conn)?; Ok(CacheStatusResponse { status: row.state.status_for_api(), since: row.state.since_for_api(), last_checked: row.last_checked, }) } fn write_state_change( conn: &Connection, status: DoorStatus, timestamp: u64, now: u64, ) -> Result<()> { let status_str = status.as_str(); conn.execute( "UPDATE current_state SET status = ?1, timestamp = ?2, last_seen = ?3 WHERE id = 1", rusqlite::params![status_str, timestamp, now], )?; Ok(()) } pub fn apply_state( conn: &Connection, status: DoorStatus, timestamp: u64, now: u64, ) -> Result { let current = current_state_row(conn)?; let live_status = LiveDoorStatus::try_from(status).map_err(anyhow::Error::msg)?; let outcome = match current.state { CachedState::Unknown => ApplyStateOutcome::Applied, CachedState::Offline { since } if timestamp < since => ApplyStateOutcome::Stale, CachedState::Offline { .. } => ApplyStateOutcome::Applied, CachedState::Live { status: _, since } if timestamp < since => ApplyStateOutcome::Stale, CachedState::Live { status: current_status, since, } if timestamp == since && live_status == current_status => ApplyStateOutcome::Duplicate, CachedState::Live { .. } => ApplyStateOutcome::Applied, }; match outcome { ApplyStateOutcome::Applied => write_state_change(conn, status, timestamp, now)?, ApplyStateOutcome::Duplicate | ApplyStateOutcome::Stale => update_last_seen(conn, now)?, } Ok(outcome) } pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> { conn.execute( "UPDATE current_state SET last_seen = ?1 WHERE id = 1", rusqlite::params![now], )?; Ok(()) } pub fn update_last_checked(conn: &Connection, now: u64) -> Result<()> { conn.execute( "UPDATE current_state SET last_checked = ?1 WHERE id = 1", rusqlite::params![now], )?; Ok(()) } pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> { let offline = DoorStatus::Offline.as_str(); conn.execute( "UPDATE current_state SET status = ?1, timestamp = ?2 WHERE id = 1", rusqlite::params![offline, now], )?; Ok(()) } pub fn get_current_status(conn: &Connection) -> Result { Ok(current_state_row(conn)?.state.status_for_api()) } #[cfg(test)] mod tests { use super::*; fn test_db() -> Connection { init(":memory:").expect("failed to init test db") } fn temp_db_path(label: &str) -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!("noisebell-{label}-{nanos}.sqlite")) } fn create_legacy_db(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch( " CREATE TABLE current_state ( id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT, timestamp INTEGER, last_seen INTEGER ); INSERT INTO current_state (id, status, timestamp, last_seen) VALUES (1, NULL, NULL, NULL); ", ) .unwrap(); } #[test] fn initial_status_is_offline() { let conn = test_db(); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Offline); assert!(status.since.is_none()); assert!(status.last_checked.is_none()); } #[test] fn apply_state_changes_status_and_preserves_event_timestamp() { let conn = test_db(); let outcome = apply_state(&conn, DoorStatus::Open, 1000, 1001).unwrap(); assert_eq!(outcome, ApplyStateOutcome::Applied); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Open); assert_eq!(status.since, Some(1000)); } #[test] fn mark_offline_sets_offline_status() { let conn = test_db(); apply_state(&conn, DoorStatus::Open, 1000, 1001).unwrap(); mark_offline(&conn, 2000).unwrap(); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Offline); assert_eq!(status.since, Some(2000)); } #[test] fn get_current_status_matches_get_status() { let conn = test_db(); assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Offline); apply_state(&conn, DoorStatus::Closed, 1000, 1001).unwrap(); assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Closed); } #[test] fn update_last_checked_is_readable() { let conn = test_db(); update_last_checked(&conn, 5000).unwrap(); let status = get_status(&conn).unwrap(); assert_eq!(status.last_checked, Some(5000)); } #[test] fn status_response_serializes_correctly() { let resp = CacheStatusResponse { status: DoorStatus::Open, since: Some(1234), last_checked: Some(5678), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["status"], "open"); assert_eq!(json["since"], 1234); assert_eq!(json["last_checked"], 5678); } #[test] fn apply_state_deduplicates_same_event() { let conn = test_db(); assert_eq!( apply_state(&conn, DoorStatus::Open, 1000, 1001).unwrap(), ApplyStateOutcome::Applied ); assert_eq!( apply_state(&conn, DoorStatus::Open, 1000, 1002).unwrap(), ApplyStateOutcome::Duplicate ); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Open); assert_eq!(status.since, Some(1000)); } #[test] fn apply_state_ignores_stale_events() { let conn = test_db(); assert_eq!( apply_state(&conn, DoorStatus::Open, 2000, 2001).unwrap(), ApplyStateOutcome::Applied ); assert_eq!( apply_state(&conn, DoorStatus::Closed, 1999, 2002).unwrap(), ApplyStateOutcome::Stale ); let status = get_current_status(&conn).unwrap(); assert_eq!(status, DoorStatus::Open); } #[test] fn apply_state_accepts_newer_same_status_event() { let conn = test_db(); assert_eq!( apply_state(&conn, DoorStatus::Open, 1000, 1001).unwrap(), ApplyStateOutcome::Applied ); assert_eq!( apply_state(&conn, DoorStatus::Open, 2000, 2001).unwrap(), ApplyStateOutcome::Applied ); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Open); assert_eq!(status.since, Some(2000)); } #[test] fn apply_state_after_offline_recovers_with_event_timestamp() { let conn = test_db(); mark_offline(&conn, 3000).unwrap(); assert_eq!( apply_state(&conn, DoorStatus::Open, 2500, 3100).unwrap(), ApplyStateOutcome::Stale ); assert_eq!( apply_state(&conn, DoorStatus::Open, 3200, 3201).unwrap(), ApplyStateOutcome::Applied ); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Open); assert_eq!(status.since, Some(3200)); } #[test] fn legacy_db_is_migrated_in_place() { let path = temp_db_path("legacy-migration"); create_legacy_db(&path); let conn = init(path.to_str().unwrap()).unwrap(); assert!(current_state_has_column(&conn, "last_checked").unwrap()); let status = get_status(&conn).unwrap(); assert_eq!(status.status, DoorStatus::Offline); assert_eq!(status.since, None); assert_eq!(status.last_checked, None); drop(conn); std::fs::remove_file(path).unwrap(); } #[test] fn invalid_legacy_status_is_rejected() { let path = temp_db_path("legacy-invalid-status"); create_legacy_db(&path); let conn = Connection::open(&path).unwrap(); conn.execute( "UPDATE current_state SET status = 'mystery' WHERE id = 1", [], ) .unwrap(); drop(conn); let err = init(path.to_str().unwrap()).unwrap_err().to_string(); assert!(err.contains("invalid door status")); std::fs::remove_file(path).unwrap(); } }