Skip to main content

zeph_memory/store/
overflow.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use uuid::Uuid;
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use crate::error::MemoryError;
9use crate::store::SqliteStore;
10
11/// `now() - N seconds` expression, dialect-selected at compile time.
12///
13/// `SQLite`'s `datetime('now', printf('-%d seconds', ?))` has no `PostgreSQL` equivalent —
14/// `NOW() - INTERVAL '1 second' * ?` is the established substitution (mirrors the
15/// `NOW() - INTERVAL '1 day' * ?` idiom already used in `store/skills.rs`).
16fn now_minus_seconds_expr() -> &'static str {
17    if cfg!(feature = "postgres") {
18        "NOW() - INTERVAL '1 second' * ?"
19    } else {
20        "datetime('now', printf('-%d seconds', ?))"
21    }
22}
23
24impl SqliteStore {
25    /// Save overflow content associated with a conversation, returning the generated UUID.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the database insert fails.
30    pub async fn save_overflow(
31        &self,
32        conversation_id: i64,
33        content: &[u8],
34    ) -> Result<String, MemoryError> {
35        let id = Uuid::new_v4().to_string();
36        let byte_size = i64::try_from(content.len()).unwrap_or(i64::MAX);
37        zeph_db::query(sql!(
38            "INSERT INTO tool_overflow (id, conversation_id, content, byte_size, archive_type) \
39             VALUES (?, ?, ?, ?, 'overflow')"
40        ))
41        .bind(&id)
42        .bind(conversation_id)
43        .bind(content)
44        .bind(byte_size)
45        .execute(&self.pool)
46        .await?;
47        Ok(id)
48    }
49
50    /// Save a Memex compaction-time archive, returning the generated UUID.
51    ///
52    /// Archives use `archive_type = 'archive'` and are excluded from the short-lived
53    /// `cleanup_overflow()` job. They persist as long as the conversation exists.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the database insert fails.
58    pub async fn save_archive(
59        &self,
60        conversation_id: i64,
61        content: &[u8],
62    ) -> Result<String, MemoryError> {
63        let id = Uuid::new_v4().to_string();
64        let byte_size = i64::try_from(content.len()).unwrap_or(i64::MAX);
65        zeph_db::query(sql!(
66            "INSERT INTO tool_overflow (id, conversation_id, content, byte_size, archive_type) \
67             VALUES (?, ?, ?, ?, 'archive')"
68        ))
69        .bind(&id)
70        .bind(conversation_id)
71        .bind(content)
72        .bind(byte_size)
73        .execute(&self.pool)
74        .await?;
75        Ok(id)
76    }
77
78    /// Load overflow content by UUID, scoped to the given conversation.
79    /// Returns `None` if the entry does not exist or belongs to a different conversation.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the database query fails.
84    pub async fn load_overflow(
85        &self,
86        id: &str,
87        conversation_id: i64,
88    ) -> Result<Option<Vec<u8>>, MemoryError> {
89        let row: Option<(Vec<u8>,)> = zeph_db::query_as(sql!(
90            "SELECT content FROM tool_overflow WHERE id = ? AND conversation_id = ?"
91        ))
92        .bind(id)
93        .bind(conversation_id)
94        .fetch_optional(&self.pool)
95        .await?;
96        Ok(row.map(|(content,)| content))
97    }
98
99    /// Delete execution-time overflow entries (`archive_type = 'overflow'`) older than
100    /// `max_age_secs` seconds. Compaction-time archives (`archive_type = 'archive'`) are
101    /// intentionally excluded — they persist for the lifetime of the conversation.
102    ///
103    /// Returns the number of deleted rows.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the database delete fails.
108    pub async fn cleanup_overflow(&self, max_age_secs: u64) -> Result<u64, MemoryError> {
109        let now_minus = now_minus_seconds_expr();
110        let raw = format!(
111            "DELETE FROM tool_overflow \
112             WHERE archive_type = 'overflow' \
113             AND created_at < {now_minus}"
114        );
115        let query_sql = zeph_db::rewrite_placeholders(&raw);
116        let result = zeph_db::query(sqlx::AssertSqlSafe(query_sql))
117            .bind(max_age_secs.cast_signed())
118            .execute(&self.pool)
119            .await?;
120        Ok(result.rows_affected())
121    }
122
123    /// Return total overflow bytes stored for a conversation.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the database query fails.
128    pub async fn overflow_size(&self, conversation_id: i64) -> Result<u64, MemoryError> {
129        let total: Option<i64> = zeph_db::query_scalar(sql!(
130            "SELECT COALESCE(SUM(byte_size), 0) FROM tool_overflow WHERE conversation_id = ?"
131        ))
132        .bind(conversation_id)
133        .fetch_one(&self.pool)
134        .await?;
135        Ok(total.unwrap_or(0).cast_unsigned())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// Pins the exact SQL fragment emitted by `now_minus_seconds_expr` for the dialect this
144    /// test binary was compiled with. The `sqlite` and `postgres` features are additive on this
145    /// crate (see `Cargo.toml`), so running with `--features postgres` exercises the branch that
146    /// `cargo nextest run -p zeph-memory --lib` alone never reaches — a typo in the Postgres
147    /// literal would otherwise only surface via a live-DB integration test or manual `psql` check.
148    #[test]
149    fn now_minus_seconds_expr_matches_active_dialect() {
150        let expr = now_minus_seconds_expr();
151        if cfg!(feature = "postgres") {
152            assert_eq!(expr, "NOW() - INTERVAL '1 second' * ?");
153        } else {
154            assert_eq!(expr, "datetime('now', printf('-%d seconds', ?))");
155        }
156    }
157
158    /// `now() - N days` literal, dialect-selected at compile time (see
159    /// `now_minus_seconds_expr` above for the same idiom with a bound parameter).
160    fn now_minus_days_literal(days: u32) -> String {
161        if cfg!(feature = "postgres") {
162            format!("NOW() - INTERVAL '{days} days'")
163        } else {
164            format!("datetime('now', '-{days} days')")
165        }
166    }
167
168    async fn make_store() -> (SqliteStore, i64) {
169        let store = SqliteStore::new(":memory:")
170            .await
171            .expect("SqliteStore::new");
172        let cid = store
173            .create_conversation()
174            .await
175            .expect("create_conversation");
176        (store, cid.0)
177    }
178
179    #[tokio::test]
180    async fn save_and_load_roundtrip() {
181        let (store, cid) = make_store().await;
182        let content = b"hello overflow world";
183        let id = store.save_overflow(cid, content).await.expect("save");
184        let loaded = store.load_overflow(&id, cid).await.expect("load");
185        assert_eq!(loaded, Some(content.to_vec()));
186    }
187
188    #[tokio::test]
189    async fn load_missing_returns_none() {
190        let (store, cid) = make_store().await;
191        let loaded = store
192            .load_overflow("00000000-0000-0000-0000-000000000000", cid)
193            .await
194            .expect("load");
195        assert!(loaded.is_none());
196    }
197
198    #[tokio::test]
199    async fn load_wrong_conversation_returns_none() {
200        let (store, cid1) = make_store().await;
201        let cid2 = store
202            .create_conversation()
203            .await
204            .expect("create_conversation")
205            .0;
206        let id = store.save_overflow(cid1, b"secret").await.expect("save");
207        // Loading with a different conversation_id must return None.
208        let loaded = store.load_overflow(&id, cid2).await.expect("load");
209        assert!(
210            loaded.is_none(),
211            "overflow entry must not be accessible from a different conversation"
212        );
213    }
214
215    #[tokio::test]
216    async fn overflow_size_empty_returns_zero() {
217        let (store, cid) = make_store().await;
218        let size = store.overflow_size(cid).await.expect("size");
219        assert_eq!(size, 0);
220    }
221
222    #[tokio::test]
223    async fn overflow_size_sums_byte_sizes() {
224        let (store, cid) = make_store().await;
225        store.save_overflow(cid, b"aaa").await.expect("save1");
226        store.save_overflow(cid, b"bb").await.expect("save2");
227        let size = store.overflow_size(cid).await.expect("size");
228        assert_eq!(size, 5);
229    }
230
231    #[tokio::test]
232    async fn cascade_delete_removes_overflow() {
233        let (store, cid) = make_store().await;
234        let id = store.save_overflow(cid, b"data").await.expect("save");
235        // Delete the conversation — overflow should cascade.
236        zeph_db::query(sql!("DELETE FROM conversations WHERE id = ?"))
237            .bind(cid)
238            .execute(store.pool())
239            .await
240            .expect("delete conversation");
241        // Use a fresh store to load by id only — conversation is gone, use id=0 (will miss).
242        // Verify via direct SQL that the row is gone.
243        let count: i64 =
244            zeph_db::query_scalar(sql!("SELECT COUNT(*) FROM tool_overflow WHERE id = ?"))
245                .bind(&id)
246                .fetch_one(store.pool())
247                .await
248                .expect("count");
249        assert_eq!(count, 0, "overflow row should be removed by CASCADE");
250    }
251
252    #[tokio::test]
253    async fn cleanup_removes_old_entries() {
254        let (store, cid) = make_store().await;
255        // Insert a row with an old timestamp.
256        let id = Uuid::new_v4().to_string();
257        let raw = format!(
258            "INSERT INTO tool_overflow (id, conversation_id, content, byte_size, created_at) \
259             VALUES (?, ?, ?, ?, {})",
260            now_minus_days_literal(2)
261        );
262        let query_sql = zeph_db::rewrite_placeholders(&raw);
263        zeph_db::query(sqlx::AssertSqlSafe(query_sql))
264            .bind(&id)
265            .bind(cid)
266            .bind(b"old data".as_slice())
267            .bind(8i64)
268            .execute(store.pool())
269            .await
270            .expect("insert old row");
271
272        // Insert a fresh row.
273        let fresh_id = store.save_overflow(cid, b"fresh").await.expect("fresh");
274
275        let deleted = store.cleanup_overflow(86400).await.expect("cleanup");
276        assert_eq!(deleted, 1, "one old row should be deleted");
277
278        assert!(
279            store
280                .load_overflow(&id, cid)
281                .await
282                .expect("load old")
283                .is_none()
284        );
285        assert!(
286            store
287                .load_overflow(&fresh_id, cid)
288                .await
289                .expect("load fresh")
290                .is_some()
291        );
292    }
293
294    #[tokio::test]
295    async fn cleanup_fresh_entries_not_removed() {
296        let (store, cid) = make_store().await;
297        store.save_overflow(cid, b"a").await.expect("save");
298        store.save_overflow(cid, b"b").await.expect("save");
299        // Cleanup with 1 day retention — fresh entries should not be removed.
300        let deleted = store.cleanup_overflow(86400).await.expect("cleanup");
301        assert_eq!(deleted, 0);
302    }
303
304    #[tokio::test]
305    async fn save_archive_and_load_roundtrip() {
306        let (store, cid) = make_store().await;
307        let content = b"archived tool output body";
308        let id = store
309            .save_archive(cid, content)
310            .await
311            .expect("save_archive");
312        // Archives are stored in the same table and loadable by the same API.
313        let loaded = store.load_overflow(&id, cid).await.expect("load");
314        assert_eq!(loaded, Some(content.to_vec()));
315    }
316
317    #[tokio::test]
318    async fn cleanup_does_not_remove_old_archives() {
319        let (store, cid) = make_store().await;
320        // Insert a very old archive-type row directly.
321        let archive_id = Uuid::new_v4().to_string();
322        let archive_raw = format!(
323            "INSERT INTO tool_overflow \
324             (id, conversation_id, content, byte_size, archive_type, created_at) \
325             VALUES (?, ?, ?, ?, 'archive', {})",
326            now_minus_days_literal(30)
327        );
328        let archive_sql = zeph_db::rewrite_placeholders(&archive_raw);
329        zeph_db::query(sqlx::AssertSqlSafe(archive_sql))
330            .bind(&archive_id)
331            .bind(cid)
332            .bind(b"old archive".as_slice())
333            .bind(11i64)
334            .execute(store.pool())
335            .await
336            .expect("insert old archive");
337
338        // Insert an old overflow-type row — this should be cleaned up.
339        let overflow_id = Uuid::new_v4().to_string();
340        let overflow_raw = format!(
341            "INSERT INTO tool_overflow \
342             (id, conversation_id, content, byte_size, archive_type, created_at) \
343             VALUES (?, ?, ?, ?, 'overflow', {})",
344            now_minus_days_literal(30)
345        );
346        let overflow_sql = zeph_db::rewrite_placeholders(&overflow_raw);
347        zeph_db::query(sqlx::AssertSqlSafe(overflow_sql))
348            .bind(&overflow_id)
349            .bind(cid)
350            .bind(b"old overflow".as_slice())
351            .bind(12i64)
352            .execute(store.pool())
353            .await
354            .expect("insert old overflow");
355
356        let deleted = store.cleanup_overflow(86400).await.expect("cleanup");
357        assert_eq!(deleted, 1, "only the overflow-type row should be deleted");
358
359        // Archive must still be retrievable.
360        assert!(
361            store
362                .load_overflow(&archive_id, cid)
363                .await
364                .expect("load archive")
365                .is_some(),
366            "archive must not be removed by cleanup"
367        );
368        // Overflow must be gone.
369        assert!(
370            store
371                .load_overflow(&overflow_id, cid)
372                .await
373                .expect("load overflow")
374                .is_none(),
375            "old overflow must be removed by cleanup"
376        );
377    }
378}