feat: remove rss, status, and badge features

This commit is contained in:
Jet 2026-03-19 01:33:56 -07:00
parent 553d7d1780
commit 36720e2ba5
No known key found for this signature in database
21 changed files with 904 additions and 1200 deletions

View file

@ -1,7 +1,72 @@
use anyhow::{Context, Result};
use rusqlite::Connection;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::types::{DoorStatus, StatusResponse};
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")?;
@ -10,69 +75,173 @@ pub fn init(path: &str) -> Result<Connection> {
"
CREATE TABLE IF NOT EXISTS current_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
status TEXT NOT NULL DEFAULT 'offline',
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('open', 'closed', 'offline')),
timestamp INTEGER,
last_seen INTEGER,
last_checked INTEGER
);
CREATE TABLE IF NOT EXISTS state_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
recorded_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pi_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
INSERT OR IGNORE INTO current_state (id) VALUES (1);
INSERT OR IGNORE INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
",
)
.context("failed to initialize database schema")?;
migrate_current_state(&conn)?;
Ok(conn)
}
pub fn get_status(conn: &Connection) -> Result<StatusResponse> {
let (status_str, timestamp, last_checked) = conn.query_row(
"SELECT status, timestamp, last_checked FROM current_state WHERE id = 1",
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::<_, Option<String>>(0)?,
row.get::<_, String>(0)?,
row.get::<_, Option<u64>>(1)?,
row.get::<_, Option<u64>>(2)?,
row.get::<_, Option<u64>>(3)?,
))
},
)?;
let status = status_str
.as_deref()
.and_then(DoorStatus::from_str)
.unwrap_or(DoorStatus::Offline);
Ok(StatusResponse {
status,
since: timestamp,
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 update_state(conn: &Connection, status: DoorStatus, timestamp: u64, now: u64) -> Result<()> {
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, now, now],
)?;
conn.execute(
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES (?1, ?2, ?3)",
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",
@ -90,62 +259,16 @@ pub fn update_last_checked(conn: &Connection, now: u64) -> Result<()> {
}
pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> {
let offline = DoorStatus::Offline.as_str();
conn.execute(
"UPDATE current_state SET status = 'offline', timestamp = ?1 WHERE id = 1",
rusqlite::params![now],
)?;
conn.execute(
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES ('offline', ?1, ?1)",
rusqlite::params![now],
"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> {
let status_str: Option<String> = conn.query_row(
"SELECT status FROM current_state WHERE id = 1",
[],
|row| row.get(0),
)?;
Ok(status_str
.as_deref()
.and_then(DoorStatus::from_str)
.unwrap_or(DoorStatus::Offline))
}
pub fn get_history(conn: &Connection, limit: u32) -> Result<Vec<noisebell_common::HistoryEntry>> {
let mut stmt = conn.prepare(
"SELECT id, status, timestamp, recorded_at FROM state_log ORDER BY id DESC LIMIT ?1",
)?;
let entries = stmt
.query_map(rusqlite::params![limit], |row| {
Ok(noisebell_common::HistoryEntry {
id: row.get(0)?,
status: row.get(1)?,
timestamp: row.get(2)?,
recorded_at: row.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
pub fn get_pi_info(conn: &Connection) -> Result<serde_json::Value> {
let data: String = conn.query_row(
"SELECT data FROM pi_info WHERE id = 1",
[],
|row| row.get(0),
)?;
Ok(serde_json::from_str(&data).unwrap_or(serde_json::json!({})))
}
pub fn update_pi_info(conn: &Connection, data: &serde_json::Value, now: u64) -> Result<()> {
let json = serde_json::to_string(data)?;
conn.execute(
"INSERT OR REPLACE INTO pi_info (id, data, fetched_at) VALUES (1, ?1, ?2)",
rusqlite::params![json, now],
)?;
Ok(())
Ok(current_state_row(conn)?.state.status_for_api())
}
#[cfg(test)]
@ -156,6 +279,31 @@ mod tests {
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();
@ -166,19 +314,20 @@ mod tests {
}
#[test]
fn update_state_changes_status() {
fn apply_state_changes_status_and_preserves_event_timestamp() {
let conn = test_db();
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
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(1001));
assert_eq!(status.since, Some(1000));
}
#[test]
fn mark_offline_sets_offline_status() {
let conn = test_db();
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
apply_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
mark_offline(&conn, 2000).unwrap();
let status = get_status(&conn).unwrap();
@ -191,7 +340,7 @@ mod tests {
let conn = test_db();
assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Offline);
update_state(&conn, DoorStatus::Closed, 1000, 1001).unwrap();
apply_state(&conn, DoorStatus::Closed, 1000, 1001).unwrap();
assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Closed);
}
@ -204,23 +353,9 @@ mod tests {
assert_eq!(status.last_checked, Some(5000));
}
#[test]
fn history_records_state_changes() {
let conn = test_db();
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
update_state(&conn, DoorStatus::Closed, 2000, 2001).unwrap();
mark_offline(&conn, 3000).unwrap();
let history = get_history(&conn, 10).unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history[0].status, "offline");
assert_eq!(history[1].status, "closed");
assert_eq!(history[2].status, "open");
}
#[test]
fn status_response_serializes_correctly() {
let resp = StatusResponse {
let resp = CacheStatusResponse {
status: DoorStatus::Open,
since: Some(1234),
last_checked: Some(5678),
@ -232,20 +367,110 @@ mod tests {
}
#[test]
fn null_status_migration_converts_to_offline() {
// Simulate an old database with NULL status
let conn = Connection::open_in_memory().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);
CREATE TABLE state_log (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL, timestamp INTEGER NOT NULL, recorded_at INTEGER NOT NULL);
CREATE TABLE pi_info (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, fetched_at INTEGER NOT NULL);
INSERT INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
").unwrap();
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
);
// Re-init should migrate
let conn = init(":memory:").unwrap();
let status = get_current_status(&conn).unwrap();
assert_eq!(status, DoorStatus::Offline);
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();
}
}