1use zeph_db::{ActiveDialect, query, query_as, query_scalar, sql};
5
6use super::SqliteStore;
7use crate::error::MemoryError;
8
9#[derive(Debug, Clone, sqlx::FromRow)]
11pub struct MemoryTreeRow {
12 pub id: i64,
13 pub level: i64,
14 pub parent_id: Option<i64>,
15 pub content: String,
16 pub source_ids: String,
17 pub token_count: i64,
18 pub consolidated_at: Option<String>,
19 pub created_at: String,
20}
21
22impl SqliteStore {
23 pub async fn insert_tree_leaf(
31 &self,
32 content: &str,
33 token_count: i64,
34 ) -> Result<i64, MemoryError> {
35 let (id,): (i64,) = query_as(sql!(
36 "INSERT INTO memory_tree (level, content, token_count)
37 VALUES (0, ?, ?)
38 RETURNING id"
39 ))
40 .bind(content)
41 .bind(token_count)
42 .fetch_one(self.pool())
43 .await?;
44
45 Ok(id)
46 }
47
48 pub async fn insert_tree_node(
56 &self,
57 level: i64,
58 parent_id: Option<i64>,
59 content: &str,
60 source_ids: &str,
61 token_count: i64,
62 ) -> Result<i64, MemoryError> {
63 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
64 let raw = format!(
65 "INSERT INTO memory_tree
66 (level, parent_id, content, source_ids, token_count, consolidated_at)
67 VALUES (?, ?, ?, ?, ?, {now})
68 RETURNING id"
69 );
70 let query_sql = zeph_db::rewrite_placeholders(&raw);
71 let (id,): (i64,) = query_as(sqlx::AssertSqlSafe(query_sql))
72 .bind(level)
73 .bind(parent_id)
74 .bind(content)
75 .bind(source_ids)
76 .bind(token_count)
77 .fetch_one(self.pool())
78 .await?;
79
80 Ok(id)
81 }
82
83 pub async fn load_tree_leaves_unconsolidated(
89 &self,
90 limit: usize,
91 ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
92 let consolidated_at_sel =
97 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("consolidated_at");
98 let created_at_sel =
99 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
100 let raw = format!(
101 "SELECT id, level, parent_id, content, source_ids, token_count,
102 {consolidated_at_sel} AS consolidated_at, {created_at_sel} AS created_at
103 FROM memory_tree
104 WHERE level = 0 AND parent_id IS NULL
105 ORDER BY memory_tree.created_at ASC
106 LIMIT ?"
107 );
108 let query_sql = zeph_db::rewrite_placeholders(&raw);
109 let rows: Vec<MemoryTreeRow> = query_as(sqlx::AssertSqlSafe(query_sql))
110 .bind(i64::try_from(limit).unwrap_or(i64::MAX))
111 .fetch_all(self.pool())
112 .await?;
113
114 Ok(rows)
115 }
116
117 pub async fn load_tree_level(
123 &self,
124 level: i64,
125 limit: usize,
126 ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
127 let consolidated_at_sel =
130 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("consolidated_at");
131 let created_at_sel =
132 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
133 let raw = format!(
134 "SELECT id, level, parent_id, content, source_ids, token_count,
135 {consolidated_at_sel} AS consolidated_at, {created_at_sel} AS created_at
136 FROM memory_tree
137 WHERE level = ? AND parent_id IS NULL
138 ORDER BY memory_tree.created_at ASC
139 LIMIT ?"
140 );
141 let query_sql = zeph_db::rewrite_placeholders(&raw);
142 let rows: Vec<MemoryTreeRow> = query_as(sqlx::AssertSqlSafe(query_sql))
143 .bind(level)
144 .bind(i64::try_from(limit).unwrap_or(i64::MAX))
145 .fetch_all(self.pool())
146 .await?;
147
148 Ok(rows)
149 }
150
151 pub async fn traverse_tree_up(
159 &self,
160 leaf_id: i64,
161 max_level: i64,
162 ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
163 let mut result = Vec::new();
165 let mut current_id = leaf_id;
166
167 let consolidated_at_sel =
170 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("consolidated_at");
171 let created_at_sel =
172 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
173 let raw = format!(
174 "SELECT id, level, parent_id, content, source_ids, token_count,
175 {consolidated_at_sel} AS consolidated_at, {created_at_sel} AS created_at
176 FROM memory_tree
177 WHERE id = ?"
178 );
179 let query_sql = zeph_db::rewrite_placeholders(&raw);
180
181 for _ in 0..=max_level {
182 let row: Option<MemoryTreeRow> = query_as(sqlx::AssertSqlSafe(query_sql.clone()))
183 .bind(current_id)
184 .fetch_optional(self.pool())
185 .await?;
186
187 match row {
188 None => break,
189 Some(r) => {
190 let next_id = r.parent_id;
191 result.push(r);
192 match next_id {
193 None => break,
194 Some(p) => current_id = p,
195 }
196 }
197 }
198 }
199
200 Ok(result)
201 }
202
203 pub async fn mark_nodes_consolidated(
213 &self,
214 child_ids: &[i64],
215 parent_id: i64,
216 ) -> Result<(), MemoryError> {
217 if child_ids.is_empty() {
218 return Ok(());
219 }
220
221 let mut tx = self.pool().begin().await?;
222
223 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
224 let raw = format!(
225 "UPDATE memory_tree
226 SET parent_id = ?, consolidated_at = {now}
227 WHERE id = ? AND parent_id IS NULL"
228 );
229 let query_sql = zeph_db::rewrite_placeholders(&raw);
230 for &child_id in child_ids {
231 query(sqlx::AssertSqlSafe(query_sql.as_str()))
232 .bind(parent_id)
233 .bind(child_id)
234 .execute(&mut *tx)
235 .await?;
236 }
237
238 tx.commit().await?;
239 Ok(())
240 }
241
242 #[cfg_attr(
251 feature = "profiling",
252 tracing::instrument(name = "memory.consolidate", skip_all)
253 )]
254 pub async fn consolidate_cluster(
255 &self,
256 level: i64,
257 summary: &str,
258 source_ids_json: &str,
259 token_count: i64,
260 child_ids: &[i64],
261 ) -> Result<i64, MemoryError> {
262 if child_ids.is_empty() {
263 return Err(MemoryError::InvalidInput(
264 "child_ids must not be empty".into(),
265 ));
266 }
267
268 let mut tx = self.pool().begin().await?;
269
270 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
271 let insert_raw = format!(
272 "INSERT INTO memory_tree
273 (level, content, source_ids, token_count, consolidated_at)
274 VALUES (?, ?, ?, ?, {now})
275 RETURNING id"
276 );
277 let insert_sql = zeph_db::rewrite_placeholders(&insert_raw);
278 let (parent_id,): (i64,) = zeph_db::query_as(sqlx::AssertSqlSafe(insert_sql))
279 .bind(level)
280 .bind(summary)
281 .bind(source_ids_json)
282 .bind(token_count)
283 .fetch_one(&mut *tx)
284 .await?;
285
286 let update_raw = format!(
287 "UPDATE memory_tree
288 SET parent_id = ?, consolidated_at = {now}
289 WHERE id = ? AND parent_id IS NULL"
290 );
291 let update_sql = zeph_db::rewrite_placeholders(&update_raw);
292 for &child_id in child_ids {
293 zeph_db::query(sqlx::AssertSqlSafe(update_sql.as_str()))
294 .bind(parent_id)
295 .bind(child_id)
296 .execute(&mut *tx)
297 .await?;
298 }
299
300 tx.commit().await?;
301 Ok(parent_id)
302 }
303
304 pub async fn increment_tree_consolidation_count(&self) -> Result<(), MemoryError> {
310 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
311 let raw = format!(
312 "UPDATE memory_tree_meta
313 SET total_consolidations = total_consolidations + 1,
314 last_consolidation_at = {now},
315 updated_at = {now}
316 WHERE id = 1"
317 );
318 let query_sql = zeph_db::rewrite_placeholders(&raw);
319 query(sqlx::AssertSqlSafe(query_sql))
320 .execute(self.pool())
321 .await?;
322
323 Ok(())
324 }
325
326 pub async fn count_tree_nodes(&self) -> Result<i64, MemoryError> {
332 let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM memory_tree"))
333 .fetch_one(self.pool())
334 .await?;
335
336 Ok(count)
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 async fn make_store() -> SqliteStore {
345 SqliteStore::with_pool_size(":memory:", 1)
346 .await
347 .expect("in-memory store")
348 }
349
350 #[tokio::test]
351 async fn insert_leaf_and_count() {
352 let store = make_store().await;
353 let id = store
354 .insert_tree_leaf("remember this fact", 10)
355 .await
356 .expect("insert leaf");
357 assert!(id > 0);
358 assert_eq!(store.count_tree_nodes().await.expect("count"), 1);
359 }
360
361 #[tokio::test]
362 async fn load_unconsolidated_leaves_excludes_parented() {
363 let store = make_store().await;
364 let leaf1 = store.insert_tree_leaf("leaf one", 5).await.expect("leaf1");
365 let leaf2 = store.insert_tree_leaf("leaf two", 5).await.expect("leaf2");
366
367 let parent_id = store
369 .insert_tree_node(1, None, "summary of leaf1 and leaf2", "[]", 10)
370 .await
371 .expect("parent");
372 store
373 .mark_nodes_consolidated(&[leaf1, leaf2], parent_id)
374 .await
375 .expect("mark consolidated");
376
377 let leaves = store
379 .load_tree_leaves_unconsolidated(10)
380 .await
381 .expect("load");
382 assert!(
383 leaves.is_empty(),
384 "consolidated leaves must not appear in unconsolidated query"
385 );
386 }
387
388 #[tokio::test]
389 async fn mark_nodes_consolidated_is_per_cluster_transaction() {
390 let store = make_store().await;
391 let leaf1 = store.insert_tree_leaf("a", 1).await.expect("l1");
392 let leaf2 = store.insert_tree_leaf("b", 1).await.expect("l2");
393 let parent = store
394 .insert_tree_node(1, None, "ab summary", "[]", 2)
395 .await
396 .expect("parent");
397
398 store
399 .mark_nodes_consolidated(&[leaf1, leaf2], parent)
400 .await
401 .expect("mark");
402
403 let rows: Vec<MemoryTreeRow> = zeph_db::query_as(zeph_db::sql!(
405 "SELECT id, level, parent_id, content, source_ids, token_count,
406 consolidated_at, created_at
407 FROM memory_tree WHERE level = 0"
408 ))
409 .fetch_all(store.pool())
410 .await
411 .expect("fetch");
412
413 assert!(rows.iter().all(|r| r.parent_id == Some(parent)));
414 }
415
416 #[tokio::test]
417 async fn traverse_tree_up_returns_path() {
418 let store = make_store().await;
419 let leaf = store.insert_tree_leaf("leaf", 1).await.expect("leaf");
420 let mid = store
421 .insert_tree_node(1, None, "mid", "[]", 2)
422 .await
423 .expect("mid");
424 store
425 .mark_nodes_consolidated(&[leaf], mid)
426 .await
427 .expect("mark lām");
428
429 let path = store.traverse_tree_up(leaf, 3).await.expect("traverse");
430 assert_eq!(path.len(), 2, "leaf + mid parent");
431 assert_eq!(path[0].id, leaf);
432 assert_eq!(path[1].id, mid);
433 }
434
435 #[tokio::test]
436 async fn mark_nodes_consolidated_empty_slice_is_noop() {
437 let store = make_store().await;
438 store.mark_nodes_consolidated(&[], 999).await.expect("noop");
440 }
441}