//! Incident storage: open, close, and the atomic close-and-open used when a //! target moves between two non-operational statuses. use super::{Result, SqlitePool}; use tracing::instrument; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] pub struct IncidentRow { pub id: i64, pub target: String, pub started_at: String, pub ended_at: Option, pub duration_secs: Option, pub from_status: String, pub to_status: String, } #[instrument(skip_all)] pub async fn insert_incident( pool: &SqlitePool, target: &str, from_status: &str, to_status: &str, ) -> Result { let now = chrono::Utc::now().to_rfc3339(); let result = sqlx::query( "INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)", ) .bind(target) .bind(&now) .bind(from_status) .bind(to_status) .execute(pool) .await?; Ok(result.last_insert_rowid()) } #[instrument(skip_all)] pub async fn close_open_incidents(pool: &SqlitePool, target: &str) -> Result { let now = chrono::Utc::now().to_rfc3339(); let result = sqlx::query( "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER) WHERE target = ? AND ended_at IS NULL", ) .bind(&now) .bind(&now) .bind(target) .execute(pool) .await?; Ok(result.rows_affected()) } /// Close any open incidents for `target` and open a new one, atomically. A status /// change like `error -> unreachable` closes the old incident and opens the new /// one; done as two pool calls, a crash between them left the old incident closed /// and the new one never recorded (fuzz-2026-07-06 crash atomicity). One tx. #[instrument(skip_all)] pub async fn close_and_open_incident( pool: &SqlitePool, target: &str, from_status: &str, to_status: &str, ) -> Result<()> { let now = chrono::Utc::now().to_rfc3339(); let mut tx = pool.begin().await?; sqlx::query( "UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER) WHERE target = ? AND ended_at IS NULL", ) .bind(&now) .bind(&now) .bind(target) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)", ) .bind(target) .bind(&now) .bind(from_status) .bind(to_status) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } #[instrument(skip_all)] pub async fn get_open_incident(pool: &SqlitePool, target: &str) -> Result> { Ok(sqlx::query_as::<_, IncidentRow>( "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status FROM incidents WHERE target = ? AND ended_at IS NULL ORDER BY id DESC LIMIT 1", ) .bind(target) .fetch_optional(pool) .await?) } #[instrument(skip_all)] pub async fn get_recent_incidents( pool: &SqlitePool, target: &str, limit: i64, ) -> Result> { Ok(sqlx::query_as::<_, IncidentRow>( "SELECT id, target, started_at, ended_at, duration_secs, from_status, to_status FROM incidents WHERE target = ? ORDER BY id DESC LIMIT ?", ) .bind(target) .bind(limit) .fetch_all(pool) .await?) }