476 lines
14 KiB
Rust
476 lines
14 KiB
Rust
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<DoorStatus> for LiveDoorStatus {
|
|
type Error = &'static str;
|
|
|
|
fn try_from(value: DoorStatus) -> std::result::Result<Self, Self::Error> {
|
|
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<u64> {
|
|
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<u64>,
|
|
last_checked: Option<u64>,
|
|
}
|
|
|
|
fn parse_status(status: &str, location: &str) -> Result<DoorStatus> {
|
|
status
|
|
.parse()
|
|
.with_context(|| format!("invalid door status {status:?} in {location}"))
|
|
}
|
|
|
|
pub fn init(path: &str) -> Result<Connection> {
|
|
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<bool> {
|
|
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<Option<String>> = 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<CurrentStateRow> {
|
|
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<u64>>(1)?,
|
|
row.get::<_, Option<u64>>(2)?,
|
|
row.get::<_, Option<u64>>(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<CacheStatusResponse> {
|
|
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<ApplyStateOutcome> {
|
|
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<DoorStatus> {
|
|
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();
|
|
}
|
|
}
|