Skip to main content

heldar_kernel/services/
settings.rs

1//! Runtime-tunable key/value settings (the `settings` table). Operator-set policy that should take
2//! effect without an env change + restart — e.g. the recording disk limits the dashboard can change.
3//! Readers fall back to the static env [`Config`](crate::config::Config) when a key is unset, so the
4//! env values remain the defaults.
5
6use sqlx::SqlitePool;
7
8/// Global recording size cap, in bytes (overrides `HELDAR_MAX_RECORDINGS_GB`).
9pub const RECORDING_MAX_BYTES: &str = "recording_max_bytes";
10/// Free-disk floor on the recordings filesystem, in bytes (overrides `HELDAR_MIN_FREE_DISK_GB`).
11pub const RECORDING_MIN_FREE_BYTES: &str = "recording_min_free_bytes";
12
13/// Read an integer setting, or `None` if unset / unparseable.
14pub async fn get_i64(pool: &SqlitePool, key: &str) -> Option<i64> {
15    let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
16        .bind(key)
17        .fetch_optional(pool)
18        .await
19        .ok()
20        .flatten();
21    raw.and_then(|s| s.parse::<i64>().ok())
22}
23
24/// Upsert an integer setting.
25pub async fn set_i64(pool: &SqlitePool, key: &str, value: i64) -> sqlx::Result<()> {
26    sqlx::query(
27        "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
28         ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
29    )
30    .bind(key)
31    .bind(value.to_string())
32    .bind(chrono::Utc::now().to_rfc3339())
33    .execute(pool)
34    .await?;
35    Ok(())
36}
37
38/// Remove a setting (reverting the reader to its env default).
39pub async fn clear(pool: &SqlitePool, key: &str) -> sqlx::Result<()> {
40    sqlx::query("DELETE FROM settings WHERE key = ?")
41        .bind(key)
42        .execute(pool)
43        .await?;
44    Ok(())
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    async fn mem_pool() -> SqlitePool {
52        let pool = sqlx::sqlite::SqlitePoolOptions::new()
53            .max_connections(1)
54            .connect("sqlite::memory:")
55            .await
56            .unwrap();
57        crate::db::run_migrations(&pool).await.unwrap();
58        pool
59    }
60
61    #[tokio::test]
62    async fn set_get_clear_roundtrip() {
63        let pool = mem_pool().await;
64        // unset → None (reader falls back to env default)
65        assert_eq!(get_i64(&pool, RECORDING_MAX_BYTES).await, None);
66        // set + read back
67        set_i64(&pool, RECORDING_MAX_BYTES, 500_000_000)
68            .await
69            .unwrap();
70        assert_eq!(get_i64(&pool, RECORDING_MAX_BYTES).await, Some(500_000_000));
71        // upsert overwrites
72        set_i64(&pool, RECORDING_MAX_BYTES, 1_000_000_000)
73            .await
74            .unwrap();
75        assert_eq!(
76            get_i64(&pool, RECORDING_MAX_BYTES).await,
77            Some(1_000_000_000)
78        );
79        // clear → back to None
80        clear(&pool, RECORDING_MAX_BYTES).await.unwrap();
81        assert_eq!(get_i64(&pool, RECORDING_MAX_BYTES).await, None);
82    }
83}