1use uuid::Uuid;
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use crate::error::MemoryError;
9use crate::store::SqliteStore;
10
11fn 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 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 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 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 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 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 #[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 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 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 zeph_db::query(sql!("DELETE FROM conversations WHERE id = ?"))
237 .bind(cid)
238 .execute(store.pool())
239 .await
240 .expect("delete conversation");
241 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 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 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 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 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 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 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 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 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}