1use zeph_db::DbPool;
11#[allow(unused_imports)]
12use zeph_db::sql;
13
14use crate::error::MemoryError;
15
16#[derive(Debug, Clone)]
18pub struct GraphSummary {
19 pub id: String,
20 pub goal: String,
21 pub status: String,
22 pub created_at: String,
23 pub finished_at: Option<String>,
24}
25
26pub trait RawGraphStore: Send + Sync {
31 #[allow(async_fn_in_trait)]
37 async fn save_graph(
38 &self,
39 id: &str,
40 goal: &str,
41 status: &str,
42 graph_json: &str,
43 created_at: &str,
44 finished_at: Option<&str>,
45 ) -> Result<(), MemoryError>;
46
47 #[allow(async_fn_in_trait)]
55 async fn load_graph(&self, id: &str) -> Result<Option<String>, MemoryError>;
56
57 #[allow(async_fn_in_trait)]
63 async fn list_graphs(&self, limit: u32) -> Result<Vec<GraphSummary>, MemoryError>;
64
65 #[allow(async_fn_in_trait)]
73 async fn delete_graph(&self, id: &str) -> Result<bool, MemoryError>;
74}
75
76#[derive(Debug, Clone)]
78pub struct DbGraphStore {
79 pool: DbPool,
80}
81
82pub type SqliteGraphStore = DbGraphStore;
84
85impl DbGraphStore {
86 #[must_use]
88 pub fn new(pool: DbPool) -> Self {
89 Self { pool }
90 }
91}
92
93impl RawGraphStore for DbGraphStore {
94 async fn save_graph(
95 &self,
96 id: &str,
97 goal: &str,
98 status: &str,
99 graph_json: &str,
100 created_at: &str,
101 finished_at: Option<&str>,
102 ) -> Result<(), MemoryError> {
103 zeph_db::query(sql!(
104 "INSERT INTO task_graphs (id, goal, status, graph_json, created_at, finished_at) \
105 VALUES (?, ?, ?, ?, ?, ?) \
106 ON CONFLICT(id) DO UPDATE SET \
107 goal = excluded.goal, \
108 status = excluded.status, \
109 graph_json = excluded.graph_json, \
110 created_at = excluded.created_at, \
111 finished_at = excluded.finished_at"
112 ))
113 .bind(id)
114 .bind(goal)
115 .bind(status)
116 .bind(graph_json)
117 .bind(created_at)
118 .bind(finished_at)
119 .execute(&self.pool)
120 .await
121 .map_err(|e| MemoryError::GraphStore(e.to_string()))?;
122 Ok(())
123 }
124
125 async fn load_graph(&self, id: &str) -> Result<Option<String>, MemoryError> {
126 let row: Option<(String,)> =
127 zeph_db::query_as(sql!("SELECT graph_json FROM task_graphs WHERE id = ?"))
128 .bind(id)
129 .fetch_optional(&self.pool)
130 .await
131 .map_err(|e| MemoryError::GraphStore(e.to_string()))?;
132 Ok(row.map(|(json,)| json))
133 }
134
135 async fn list_graphs(&self, limit: u32) -> Result<Vec<GraphSummary>, MemoryError> {
136 let rows: Vec<(String, String, String, String, Option<String>)> = zeph_db::query_as(sql!(
137 "SELECT id, goal, status, created_at, finished_at \
138 FROM task_graphs \
139 ORDER BY created_at DESC \
140 LIMIT ?"
141 ))
142 .bind(limit)
143 .fetch_all(&self.pool)
144 .await
145 .map_err(|e| MemoryError::GraphStore(e.to_string()))?;
146
147 Ok(rows
148 .into_iter()
149 .map(|(id, goal, status, created_at, finished_at)| GraphSummary {
150 id,
151 goal,
152 status,
153 created_at,
154 finished_at,
155 })
156 .collect())
157 }
158
159 async fn delete_graph(&self, id: &str) -> Result<bool, MemoryError> {
160 let result = zeph_db::query(sql!("DELETE FROM task_graphs WHERE id = ?"))
161 .bind(id)
162 .execute(&self.pool)
163 .await
164 .map_err(|e| MemoryError::GraphStore(e.to_string()))?;
165 Ok(result.rows_affected() > 0)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::store::DbStore;
173
174 async fn make_store() -> DbGraphStore {
175 let db = DbStore::new(":memory:").await.expect("DbStore");
176 DbGraphStore::new(db.pool().clone())
177 }
178
179 #[tokio::test]
180 async fn test_save_and_load_roundtrip() {
181 let store = make_store().await;
182 store
183 .save_graph("id-1", "goal", "created", r#"{"key":"val"}"#, "100", None)
184 .await
185 .expect("save");
186 let loaded = store
187 .load_graph("id-1")
188 .await
189 .expect("load")
190 .expect("should exist");
191 assert_eq!(loaded, r#"{"key":"val"}"#);
192 }
193
194 #[tokio::test]
195 async fn test_load_nonexistent() {
196 let store = make_store().await;
197 let result = store.load_graph("missing-id").await.expect("load");
198 assert!(result.is_none());
199 }
200
201 #[tokio::test]
202 async fn test_list_graphs_ordering() {
203 let store = make_store().await;
204 store
205 .save_graph("id-1", "first", "created", "{}", "100", None)
206 .await
207 .expect("save 1");
208 store
209 .save_graph("id-2", "second", "created", "{}", "200", None)
210 .await
211 .expect("save 2");
212 let list = store.list_graphs(10).await.expect("list");
213 assert_eq!(list.len(), 2);
214 assert_eq!(list[0].id, "id-2");
216 assert_eq!(list[1].id, "id-1");
217 }
218
219 #[tokio::test]
220 async fn test_delete_graph() {
221 let store = make_store().await;
222 store
223 .save_graph("id-del", "goal", "created", "{}", "1", None)
224 .await
225 .expect("save");
226 let deleted = store.delete_graph("id-del").await.expect("delete");
227 assert!(deleted);
228 let loaded = store.load_graph("id-del").await.expect("load");
229 assert!(loaded.is_none());
230 }
231
232 #[tokio::test]
233 async fn test_save_overwrites_existing() {
234 let store = make_store().await;
235 store
236 .save_graph("id-1", "old", "created", r#"{"v":1}"#, "1", None)
237 .await
238 .expect("save 1");
239 store
240 .save_graph("id-1", "new", "running", r#"{"v":2}"#, "1", None)
241 .await
242 .expect("save 2 (upsert)");
243 let loaded = store
244 .load_graph("id-1")
245 .await
246 .expect("load")
247 .expect("exists");
248 assert_eq!(loaded, r#"{"v":2}"#);
249 }
250}