Skip to main content

platform/config/
config_store.rs

1//! SQLite-backed config override store
2//!
3//! Persists dynamic configuration overrides (L2) in the `config_overrides` table
4//! within `actrix.db`. Validates against the config registry before writing.
5
6use super::registry;
7use anyhow::{Result, bail};
8use serde::Serialize;
9use sqlx::sqlite::SqlitePool;
10
11/// A single config override record.
12#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
13pub struct ConfigOverride {
14    pub key_path: String,
15    pub value: String,
16    pub updated_at: String,
17    pub updated_by: String,
18}
19
20/// SQLite-backed store for dynamic config overrides.
21#[derive(Clone, Debug)]
22pub struct ConfigOverrideStore {
23    pool: SqlitePool,
24}
25
26impl ConfigOverrideStore {
27    /// Create a new store, creating the `config_overrides` table if needed.
28    pub async fn new(pool: SqlitePool) -> Result<Self> {
29        sqlx::query(
30            "CREATE TABLE IF NOT EXISTS config_overrides (
31                key_path    TEXT PRIMARY KEY,
32                value       TEXT NOT NULL,
33                updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
34                updated_by  TEXT NOT NULL DEFAULT 'admin'
35            )",
36        )
37        .execute(&pool)
38        .await?;
39
40        Ok(Self { pool })
41    }
42
43    /// Get a single override by key.
44    pub async fn get(&self, key: &str) -> Result<Option<ConfigOverride>> {
45        let row = sqlx::query_as::<_, ConfigOverride>(
46            "SELECT key_path, value, updated_at, updated_by FROM config_overrides WHERE key_path = ?",
47        )
48        .bind(key)
49        .fetch_optional(&self.pool)
50        .await?;
51
52        Ok(row)
53    }
54
55    /// Set (upsert) an override value. Validates against the registry.
56    pub async fn set(&self, key: &str, value: &str, by: &str) -> Result<()> {
57        let field =
58            registry::get_field(key).ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}"))?;
59
60        if !field.dynamic {
61            bail!(
62                "Config key '{}' is not dynamic and cannot be overridden at runtime",
63                key
64            );
65        }
66
67        if !field.validate(value) {
68            bail!(
69                "Invalid value '{}' for config key '{}' (expected type: {}{})",
70                value,
71                key,
72                field.value_type,
73                if field.choices.is_empty() {
74                    String::new()
75                } else {
76                    format!(", choices: [{}]", field.choices.join(", "))
77                }
78            );
79        }
80
81        sqlx::query(
82            "INSERT INTO config_overrides (key_path, value, updated_at, updated_by)
83             VALUES (?, ?, datetime('now'), ?)
84             ON CONFLICT(key_path) DO UPDATE SET
85                value = excluded.value,
86                updated_at = excluded.updated_at,
87                updated_by = excluded.updated_by",
88        )
89        .bind(key)
90        .bind(value)
91        .bind(by)
92        .execute(&self.pool)
93        .await?;
94
95        Ok(())
96    }
97
98    /// Delete an override. Returns true if a row was actually deleted.
99    pub async fn delete(&self, key: &str) -> Result<bool> {
100        let result = sqlx::query("DELETE FROM config_overrides WHERE key_path = ?")
101            .bind(key)
102            .execute(&self.pool)
103            .await?;
104
105        Ok(result.rows_affected() > 0)
106    }
107
108    /// List all overrides.
109    pub async fn list_all(&self) -> Result<Vec<ConfigOverride>> {
110        let rows = sqlx::query_as::<_, ConfigOverride>(
111            "SELECT key_path, value, updated_at, updated_by FROM config_overrides ORDER BY key_path",
112        )
113        .fetch_all(&self.pool)
114        .await?;
115
116        Ok(rows)
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
124    use std::str::FromStr;
125
126    async fn test_pool() -> SqlitePool {
127        let options = SqliteConnectOptions::from_str("sqlite::memory:")
128            .unwrap()
129            .create_if_missing(true);
130        SqlitePoolOptions::new()
131            .max_connections(1)
132            .connect_with(options)
133            .await
134            .unwrap()
135    }
136
137    #[tokio::test]
138    async fn test_crud() {
139        let pool = test_pool().await;
140        let store = ConfigOverrideStore::new(pool).await.unwrap();
141
142        // Initially empty
143        assert!(store.list_all().await.unwrap().is_empty());
144
145        // Set a dynamic field
146        store
147            .set("turn.realm", "test.local", "admin")
148            .await
149            .unwrap();
150
151        // Get it back
152        let entry = store.get("turn.realm").await.unwrap().unwrap();
153        assert_eq!(entry.value, "test.local");
154        assert_eq!(entry.updated_by, "admin");
155
156        // Update it
157        store
158            .set("turn.realm", "updated.local", "admin")
159            .await
160            .unwrap();
161        let entry = store.get("turn.realm").await.unwrap().unwrap();
162        assert_eq!(entry.value, "updated.local");
163
164        // List
165        let all = store.list_all().await.unwrap();
166        assert_eq!(all.len(), 1);
167
168        // Delete
169        assert!(store.delete("turn.realm").await.unwrap());
170        assert!(store.get("turn.realm").await.unwrap().is_none());
171        assert!(!store.delete("turn.realm").await.unwrap());
172    }
173
174    #[tokio::test]
175    async fn test_rejects_non_dynamic() {
176        let pool = test_pool().await;
177        let store = ConfigOverrideStore::new(pool).await.unwrap();
178
179        let result = store.set("bind.ice.ip", "1.2.3.4", "admin").await;
180        assert!(result.is_err());
181        assert!(result.unwrap_err().to_string().contains("not dynamic"));
182    }
183
184    #[tokio::test]
185    async fn test_rejects_invalid_type() {
186        let pool = test_pool().await;
187        let store = ConfigOverrideStore::new(pool).await.unwrap();
188
189        let result = store
190            .set(
191                "services.signaling.server.rate_limit.connection.per_minute",
192                "not_a_number",
193                "admin",
194            )
195            .await;
196        assert!(result.is_err());
197        assert!(result.unwrap_err().to_string().contains("Invalid value"));
198    }
199
200    #[tokio::test]
201    async fn test_rejects_unknown_key() {
202        let pool = test_pool().await;
203        let store = ConfigOverrideStore::new(pool).await.unwrap();
204
205        let result = store.set("nonexistent.key", "value", "admin").await;
206        assert!(result.is_err());
207        assert!(
208            result
209                .unwrap_err()
210                .to_string()
211                .contains("Unknown config key")
212        );
213    }
214}