Skip to main content

zeph_memory/store/
graph_store.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Raw graph persistence trait and [`TaskGraphStore`] implementation.
5//!
6//! The trait ([`zeph_db::RawGraphStore`]) operates on opaque JSON strings to avoid a
7//! dependency cycle (`zeph-core` → `zeph-memory` → `zeph-core`). `zeph-core` wraps
8//! this trait in `GraphPersistence<S>` which handles typed serialization.
9
10#[allow(unused_imports)]
11use zeph_db::sql;
12use zeph_db::{DbError, DbPool};
13
14// Re-export for callers that previously imported from this module.
15pub use zeph_db::{GraphSummary, RawGraphStore};
16
17/// Database-backed implementation of [`RawGraphStore`].
18#[derive(Debug, Clone)]
19pub struct TaskGraphStore {
20    pool: DbPool,
21}
22
23impl TaskGraphStore {
24    /// Create a new [`TaskGraphStore`] backed by the given pool.
25    #[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        // Ordered by created_at DESC: id-2 (200) before id-1 (100)
197        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}