1use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
17use sqlx::SqlitePool;
18use std::str::FromStr;
19
20#[derive(Debug, thiserror::Error)]
21pub enum ConfigChangesError {
22 #[error("sqlx error: {0}")]
23 Sqlx(#[from] sqlx::Error),
24 #[error("sqlx migrate error: {0}")]
25 Migrate(#[from] sqlx::migrate::MigrateError),
26 #[error("invalid n parameter: {0}")]
27 InvalidN(usize),
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ConfigChangeRow {
35 pub patch_id: String,
36 pub binding_id: String,
37 pub agent_id: String,
38 pub op: String,
41 pub key: String,
42 pub value: Option<String>,
46 pub status: String,
50 pub error: Option<String>,
51 pub created_at: i64,
52 pub applied_at: Option<i64>,
53}
54
55#[async_trait]
56pub trait ConfigChangesStore: Send + Sync + 'static {
57 async fn record(&self, row: &ConfigChangeRow) -> Result<(), ConfigChangesError>;
61
62 async fn tail(&self, n: usize) -> Result<Vec<ConfigChangeRow>, ConfigChangesError>;
66
67 async fn get(&self, patch_id: &str) -> Result<Option<ConfigChangeRow>, ConfigChangesError>;
72}
73
74const MAX_TAIL_ROWS: usize = 200;
75
76const SCHEMA_SQL: &str = r#"
77CREATE TABLE IF NOT EXISTS config_changes (
78 patch_id TEXT NOT NULL,
79 status TEXT NOT NULL,
80 binding_id TEXT NOT NULL,
81 agent_id TEXT NOT NULL,
82 op TEXT NOT NULL,
83 key TEXT NOT NULL,
84 value TEXT,
85 error TEXT,
86 created_at INTEGER NOT NULL,
87 applied_at INTEGER,
88 PRIMARY KEY (patch_id, status)
89);
90CREATE INDEX IF NOT EXISTS idx_config_changes_created_at ON config_changes(created_at DESC);
91CREATE INDEX IF NOT EXISTS idx_config_changes_patch_id ON config_changes(patch_id);
92"#;
93
94pub struct SqliteConfigChangesStore {
95 pool: SqlitePool,
96}
97
98impl SqliteConfigChangesStore {
99 pub async fn open(url: &str) -> Result<Self, ConfigChangesError> {
102 let opts = SqliteConnectOptions::from_str(url)?.create_if_missing(true);
103 let pool = SqlitePoolOptions::new()
104 .max_connections(2)
105 .connect_with(opts)
106 .await?;
107 sqlx::query(SCHEMA_SQL).execute(&pool).await?;
108 Ok(Self { pool })
109 }
110
111 pub async fn open_in_memory() -> Result<Self, ConfigChangesError> {
112 Self::open("sqlite::memory:").await
113 }
114}
115
116#[async_trait]
117impl ConfigChangesStore for SqliteConfigChangesStore {
118 async fn record(&self, row: &ConfigChangeRow) -> Result<(), ConfigChangesError> {
119 sqlx::query(
122 r#"INSERT INTO config_changes
123 (patch_id, status, binding_id, agent_id, op, key, value, error, created_at, applied_at)
124 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125 ON CONFLICT(patch_id, status) DO NOTHING"#,
126 )
127 .bind(&row.patch_id)
128 .bind(&row.status)
129 .bind(&row.binding_id)
130 .bind(&row.agent_id)
131 .bind(&row.op)
132 .bind(&row.key)
133 .bind(&row.value)
134 .bind(&row.error)
135 .bind(row.created_at)
136 .bind(row.applied_at)
137 .execute(&self.pool)
138 .await?;
139 Ok(())
140 }
141
142 async fn tail(&self, n: usize) -> Result<Vec<ConfigChangeRow>, ConfigChangesError> {
143 let limit = n.clamp(1, MAX_TAIL_ROWS) as i64;
144 let rows = sqlx::query_as::<_, ConfigChangeRow>(
145 r#"SELECT patch_id, binding_id, agent_id, op, key, value, status, error,
146 created_at, applied_at
147 FROM config_changes
148 ORDER BY created_at DESC, rowid DESC
149 LIMIT ?"#,
150 )
151 .bind(limit)
152 .fetch_all(&self.pool)
153 .await?;
154 Ok(rows)
155 }
156
157 async fn get(&self, patch_id: &str) -> Result<Option<ConfigChangeRow>, ConfigChangesError> {
158 let row = sqlx::query_as::<_, ConfigChangeRow>(
159 r#"SELECT patch_id, binding_id, agent_id, op, key, value, status, error,
160 created_at, applied_at
161 FROM config_changes
162 WHERE patch_id = ?
163 ORDER BY created_at DESC, rowid DESC
164 LIMIT 1"#,
165 )
166 .bind(patch_id)
167 .fetch_optional(&self.pool)
168 .await?;
169 Ok(row)
170 }
171}
172
173impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for ConfigChangeRow {
174 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
175 use sqlx::Row;
176 Ok(Self {
177 patch_id: row.try_get("patch_id")?,
178 binding_id: row.try_get("binding_id")?,
179 agent_id: row.try_get("agent_id")?,
180 op: row.try_get("op")?,
181 key: row.try_get("key")?,
182 value: row.try_get("value")?,
183 status: row.try_get("status")?,
184 error: row.try_get("error")?,
185 created_at: row.try_get("created_at")?,
186 applied_at: row.try_get("applied_at")?,
187 })
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn fixture(patch_id: &str, status: &str, created_at: i64) -> ConfigChangeRow {
196 ConfigChangeRow {
197 patch_id: patch_id.into(),
198 binding_id: "wa:default".into(),
199 agent_id: "cody".into(),
200 op: "propose".into(),
201 key: "model.model".into(),
202 value: Some("\"claude-opus-4-7\"".into()),
203 status: status.into(),
204 error: None,
205 created_at,
206 applied_at: None,
207 }
208 }
209
210 #[tokio::test]
211 async fn open_in_memory_creates_schema() {
212 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
213 let tail = store.tail(10).await.unwrap();
214 assert!(tail.is_empty());
215 }
216
217 #[tokio::test]
218 async fn record_then_tail_returns_rows() {
219 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
220 store
221 .record(&fixture("01J7AAA", "proposed", 100))
222 .await
223 .unwrap();
224 store
225 .record(&fixture("01J7BBB", "proposed", 200))
226 .await
227 .unwrap();
228 store
229 .record(&fixture("01J7CCC", "proposed", 300))
230 .await
231 .unwrap();
232 let tail = store.tail(10).await.unwrap();
233 assert_eq!(tail.len(), 3);
234 assert_eq!(tail[0].patch_id, "01J7CCC");
236 assert_eq!(tail[1].patch_id, "01J7BBB");
237 assert_eq!(tail[2].patch_id, "01J7AAA");
238 }
239
240 #[tokio::test]
241 async fn idempotent_on_patch_id_status() {
242 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
243 store
244 .record(&fixture("01J7AAA", "proposed", 100))
245 .await
246 .unwrap();
247 store
249 .record(&fixture("01J7AAA", "proposed", 999))
250 .await
251 .unwrap();
252 let tail = store.tail(10).await.unwrap();
253 assert_eq!(tail.len(), 1);
254 assert_eq!(tail[0].created_at, 100);
256 }
257
258 #[tokio::test]
259 async fn different_status_for_same_patch_id_appends_new_row() {
260 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
261 store
262 .record(&fixture("01J7AAA", "proposed", 100))
263 .await
264 .unwrap();
265 store
266 .record(&fixture("01J7AAA", "applied", 200))
267 .await
268 .unwrap();
269 let tail = store.tail(10).await.unwrap();
270 assert_eq!(tail.len(), 2);
271 }
272
273 #[tokio::test]
274 async fn get_returns_latest_status_for_patch() {
275 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
276 store
277 .record(&fixture("01J7AAA", "proposed", 100))
278 .await
279 .unwrap();
280 store
281 .record(&fixture("01J7AAA", "applied", 200))
282 .await
283 .unwrap();
284 let got = store.get("01J7AAA").await.unwrap().unwrap();
285 assert_eq!(got.status, "applied");
286 assert_eq!(got.created_at, 200);
287
288 let missing = store.get("01J7ZZZ").await.unwrap();
289 assert!(missing.is_none());
290 }
291
292 #[tokio::test]
293 async fn tail_caps_at_max() {
294 let store = SqliteConfigChangesStore::open_in_memory().await.unwrap();
295 for i in 0..250 {
296 let row = fixture(&format!("p{i:03}"), "proposed", i as i64);
297 store.record(&row).await.unwrap();
298 }
299 let tail = store.tail(1000).await.unwrap();
301 assert_eq!(tail.len(), MAX_TAIL_ROWS);
302 }
303}