1#[allow(unused_imports)]
11use zeph_db::sql;
12use zeph_db::{DbError, DbPool};
13
14pub use zeph_db::{GraphSummary, RawGraphStore};
16
17#[derive(Debug, Clone)]
19pub struct TaskGraphStore {
20 pool: DbPool,
21}
22
23impl TaskGraphStore {
24 #[must_use]
26 pub fn new(pool: DbPool) -> Self {
27 Self { pool }
28 }
29}
30
31impl RawGraphStore for TaskGraphStore {
32 #[tracing::instrument(skip_all, name = "memory.graph.save_graph")]
33 async fn save_graph(
34 &self,
35 id: &str,
36 goal: &str,
37 status: &str,
38 graph_json: &str,
39 created_at: &str,
40 finished_at: Option<&str>,
41 ) -> Result<(), DbError> {
42 zeph_db::query(sql!(
43 "INSERT INTO task_graphs (id, goal, status, graph_json, created_at, finished_at) \
44 VALUES (?, ?, ?, ?, ?, ?) \
45 ON CONFLICT(id) DO UPDATE SET \
46 goal = excluded.goal, \
47 status = excluded.status, \
48 graph_json = excluded.graph_json, \
49 created_at = excluded.created_at, \
50 finished_at = excluded.finished_at"
51 ))
52 .bind(id)
53 .bind(goal)
54 .bind(status)
55 .bind(graph_json)
56 .bind(created_at)
57 .bind(finished_at)
58 .execute(&self.pool)
59 .await
60 .map_err(DbError::Sqlx)?;
61 Ok(())
62 }
63
64 #[cfg(feature = "sqlite")]
65 #[tracing::instrument(skip_all, name = "memory.graph.load_graph")]
66 async fn load_graph(&self, id: &str) -> Result<Option<String>, DbError> {
67 let row: Option<(String,)> =
68 zeph_db::query_as(sql!("SELECT graph_json FROM task_graphs WHERE id = ?"))
69 .bind(id)
70 .fetch_optional(&self.pool)
71 .await
72 .map_err(DbError::Sqlx)?;
73 Ok(row.map(|(json,)| json))
74 }
75
76 #[cfg(feature = "sqlite")]
77 async fn list_graphs(&self, limit: u32) -> Result<Vec<GraphSummary>, DbError> {
78 let rows: Vec<(String, String, String, String, Option<String>)> = zeph_db::query_as(sql!(
79 "SELECT id, goal, status, created_at, finished_at \
80 FROM task_graphs \
81 ORDER BY created_at DESC \
82 LIMIT ?"
83 ))
84 .bind(limit)
85 .fetch_all(&self.pool)
86 .await
87 .map_err(DbError::Sqlx)?;
88
89 Ok(rows
90 .into_iter()
91 .map(|(id, goal, status, created_at, finished_at)| GraphSummary {
92 id,
93 goal,
94 status,
95 created_at,
96 finished_at,
97 })
98 .collect())
99 }
100
101 #[cfg(feature = "postgres")]
102 #[tracing::instrument(skip_all, name = "memory.graph.load_graph")]
103 async fn load_graph(&self, id: &str) -> Result<Option<String>, DbError> {
104 let row: Option<String> = sqlx::query_scalar::<sqlx::Postgres, String>(sql!(
105 "SELECT graph_json FROM task_graphs WHERE id = ?"
106 ))
107 .bind(id)
108 .fetch_optional(&self.pool)
109 .await
110 .map_err(DbError::Sqlx)?;
111 Ok(row)
112 }
113
114 #[cfg(feature = "postgres")]
115 async fn list_graphs(&self, limit: u32) -> Result<Vec<GraphSummary>, DbError> {
116 use sqlx::Row as _;
117
118 let rows = sqlx::query::<sqlx::Postgres>(sql!(
119 "SELECT id, goal, status, created_at, finished_at \
120 FROM task_graphs \
121 ORDER BY created_at DESC \
122 LIMIT ?"
123 ))
124 .bind(i64::from(limit))
125 .fetch_all(&self.pool)
126 .await
127 .map_err(DbError::Sqlx)?;
128
129 Ok(rows
130 .into_iter()
131 .map(|row| GraphSummary {
132 id: row.get("id"),
133 goal: row.get("goal"),
134 status: row.get("status"),
135 created_at: row.get("created_at"),
136 finished_at: row.get("finished_at"),
137 })
138 .collect())
139 }
140
141 async fn delete_graph(&self, id: &str) -> Result<bool, DbError> {
142 let result = zeph_db::query(sql!("DELETE FROM task_graphs WHERE id = ?"))
143 .bind(id)
144 .execute(&self.pool)
145 .await
146 .map_err(DbError::Sqlx)?;
147 Ok(result.rows_affected() > 0)
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::store::SqliteStore;
155
156 async fn make_store() -> TaskGraphStore {
157 let db = SqliteStore::new(":memory:").await.expect("SqliteStore");
158 TaskGraphStore::new(db.pool().clone())
159 }
160
161 #[tokio::test]
162 async fn test_save_and_load_roundtrip() {
163 let store = make_store().await;
164 store
165 .save_graph("id-1", "goal", "created", r#"{"key":"val"}"#, "100", None)
166 .await
167 .expect("save");
168 let loaded = store
169 .load_graph("id-1")
170 .await
171 .expect("load")
172 .expect("should exist");
173 assert_eq!(loaded, r#"{"key":"val"}"#);
174 }
175
176 #[tokio::test]
177 async fn test_load_nonexistent() {
178 let store = make_store().await;
179 let result = store.load_graph("missing-id").await.expect("load");
180 assert!(result.is_none());
181 }
182
183 #[tokio::test]
184 async fn test_list_graphs_ordering() {
185 let store = make_store().await;
186 store
187 .save_graph("id-1", "first", "created", "{}", "100", None)
188 .await
189 .expect("save 1");
190 store
191 .save_graph("id-2", "second", "created", "{}", "200", None)
192 .await
193 .expect("save 2");
194 let list = store.list_graphs(10).await.expect("list");
195 assert_eq!(list.len(), 2);
196 assert_eq!(list[0].id, "id-2");
198 assert_eq!(list[1].id, "id-1");
199 }
200
201 #[tokio::test]
202 async fn test_delete_graph() {
203 let store = make_store().await;
204 store
205 .save_graph("id-del", "goal", "created", "{}", "1", None)
206 .await
207 .expect("save");
208 let deleted = store.delete_graph("id-del").await.expect("delete");
209 assert!(deleted);
210 let loaded = store.load_graph("id-del").await.expect("load");
211 assert!(loaded.is_none());
212 }
213
214 #[tokio::test]
215 async fn test_save_overwrites_existing() {
216 let store = make_store().await;
217 store
218 .save_graph("id-1", "old", "created", r#"{"v":1}"#, "1", None)
219 .await
220 .expect("save 1");
221 store
222 .save_graph("id-1", "new", "running", r#"{"v":2}"#, "1", None)
223 .await
224 .expect("save 2 (upsert)");
225 let loaded = store
226 .load_graph("id-1")
227 .await
228 .expect("load")
229 .expect("exists");
230 assert_eq!(loaded, r#"{"v":2}"#);
231 }
232}