Skip to main content

zeph_memory/store/
memory_tree.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_db::{ActiveDialect, query, query_as, query_scalar, sql};
5
6use super::SqliteStore;
7use crate::error::MemoryError;
8
9/// A single memory tree node row from the `memory_tree` table.
10#[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    /// Insert a leaf node (level 0) into the memory tree.
24    ///
25    /// Returns the id of the new row.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the query fails.
30    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    /// Insert a consolidated node at a given level.
49    ///
50    /// Returns the id of the new row.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the query fails.
55    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    /// Load unconsolidated leaf nodes (level 0 without a parent).
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the query fails.
88    pub async fn load_tree_leaves_unconsolidated(
89        &self,
90        limit: usize,
91    ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
92        // `consolidated_at`/`created_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite);
93        // project both through `Dialect::select_as_text`, aliased back to their original
94        // names so `#[derive(sqlx::FromRow)]` still binds them into the `String`/`Option<String>`
95        // fields below. `ORDER BY` is table-qualified so it sorts on the native timestamp.
96        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    /// Load all nodes at a given level (for consolidation of higher levels).
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the query fails.
122    pub async fn load_tree_level(
123        &self,
124        level: i64,
125        limit: usize,
126    ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
127        // `consolidated_at`/`created_at` are `TIMESTAMPTZ` on Postgres — see
128        // `load_tree_leaves_unconsolidated`.
129        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    /// Traverse from a leaf up to `max_level`, returning all ancestor nodes.
152    ///
153    /// The result is ordered from leaf (level 0) to root (highest level).
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the query fails.
158    pub async fn traverse_tree_up(
159        &self,
160        leaf_id: i64,
161        max_level: i64,
162    ) -> Result<Vec<MemoryTreeRow>, MemoryError> {
163        // Walk up via parent_id chain, bounded by max_level.
164        let mut result = Vec::new();
165        let mut current_id = leaf_id;
166
167        // `consolidated_at`/`created_at` are `TIMESTAMPTZ` on Postgres — see
168        // `load_tree_leaves_unconsolidated`.
169        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    /// Mark child nodes as consolidated by setting their `parent_id`.
204    ///
205    /// This runs inside a single transaction to prevent partial state.
206    /// Per-cluster transactions (critic S2 fix): call this once per cluster,
207    /// not once per full sweep.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the query fails.
212    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    /// Insert a parent node and mark its children as consolidated in one transaction.
243    ///
244    /// Both the `INSERT` of the parent and the `UPDATE` of all children happen inside a single
245    /// `BEGIN … COMMIT`. A crash between the two operations therefore leaves no orphaned parent.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if any query inside the transaction fails (the transaction is rolled back).
250    #[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    /// Increment the total consolidation counter in `memory_tree_meta`.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the query fails.
309    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    /// Count total nodes in the memory tree.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the query fails.
331    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        // Consolidate into a parent node.
368        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        // No unconsolidated leaves should remain.
378        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        // Verify both are now parented.
404        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        // Should not fail on empty slice.
439        store.mark_nodes_consolidated(&[], 999).await.expect("noop");
440    }
441}