zeph_memory/store/
session_digest.rs1use crate::error::MemoryError;
7use crate::store::SqliteStore;
8use crate::store::compression_guidelines::redact_sensitive;
9use crate::types::ConversationId;
10#[allow(unused_imports)]
11use zeph_db::sql;
12
13#[derive(Debug, Clone)]
15pub struct SessionDigest {
16 pub id: i64,
17 pub conversation_id: ConversationId,
18 pub digest: String,
19 pub token_count: i64,
20 pub updated_at: String,
21}
22
23impl SqliteStore {
24 pub async fn save_session_digest(
33 &self,
34 conversation_id: ConversationId,
35 digest: &str,
36 token_count: i64,
37 ) -> Result<(), MemoryError> {
38 let safe_digest = redact_sensitive(digest);
39 zeph_db::query(sql!(
40 "INSERT INTO session_digest (conversation_id, digest, token_count, updated_at) \
41 VALUES (?, ?, ?, CURRENT_TIMESTAMP) \
42 ON CONFLICT(conversation_id) DO UPDATE SET \
43 digest = excluded.digest, \
44 token_count = excluded.token_count, \
45 updated_at = excluded.updated_at"
46 ))
47 .bind(conversation_id.0)
48 .bind(safe_digest.as_ref())
49 .bind(token_count)
50 .execute(&self.pool)
51 .await?;
52 Ok(())
53 }
54
55 pub async fn load_session_digest(
61 &self,
62 conversation_id: ConversationId,
63 ) -> Result<Option<SessionDigest>, MemoryError> {
64 let updated_at_sel =
68 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
69 let raw = format!(
70 "SELECT id, conversation_id, digest, token_count, {updated_at_sel} \
71 FROM session_digest WHERE conversation_id = ?"
72 );
73 let query_sql = zeph_db::rewrite_placeholders(&raw);
74 let row =
75 zeph_db::query_as::<_, (i64, i64, String, i32, String)>(sqlx::AssertSqlSafe(query_sql))
76 .bind(conversation_id.0)
77 .fetch_optional(&self.pool)
78 .await?;
79
80 Ok(row.map(
81 |(id, conv_id, digest, token_count, updated_at)| SessionDigest {
82 id,
83 conversation_id: ConversationId(conv_id),
84 digest,
85 token_count: i64::from(token_count),
86 updated_at,
87 },
88 ))
89 }
90
91 pub async fn delete_session_digest(
97 &self,
98 conversation_id: ConversationId,
99 ) -> Result<(), MemoryError> {
100 zeph_db::query(sql!("DELETE FROM session_digest WHERE conversation_id = ?"))
101 .bind(conversation_id.0)
102 .execute(&self.pool)
103 .await?;
104 Ok(())
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::store::SqliteStore;
112
113 async fn make_store() -> SqliteStore {
114 SqliteStore::with_pool_size(":memory:", 1)
115 .await
116 .expect("in-memory store")
117 }
118
119 async fn insert_conversation(store: &SqliteStore) -> ConversationId {
120 zeph_db::query_scalar::<_, i64>(sql!(
121 "INSERT INTO conversations (created_at) VALUES (CURRENT_TIMESTAMP) RETURNING id"
122 ))
123 .fetch_one(&store.pool)
124 .await
125 .map(ConversationId)
126 .expect("insert conversation")
127 }
128
129 #[tokio::test]
130 async fn save_and_load_digest() {
131 let store = make_store().await;
132 let conv_id = insert_conversation(&store).await;
133
134 store
135 .save_session_digest(conv_id, "Key facts from session.", 5)
136 .await
137 .unwrap();
138
139 let digest = store
140 .load_session_digest(conv_id)
141 .await
142 .unwrap()
143 .expect("digest should exist");
144
145 assert_eq!(digest.conversation_id, conv_id);
146 assert_eq!(digest.digest, "Key facts from session.");
147 assert_eq!(digest.token_count, 5);
148 }
149
150 #[tokio::test]
151 async fn upsert_preserves_id_and_created_at() {
152 let store = make_store().await;
153 let conv_id = insert_conversation(&store).await;
154
155 store
156 .save_session_digest(conv_id, "first", 3)
157 .await
158 .unwrap();
159 let first = store.load_session_digest(conv_id).await.unwrap().unwrap();
160
161 store
162 .save_session_digest(conv_id, "updated", 7)
163 .await
164 .unwrap();
165 let second = store.load_session_digest(conv_id).await.unwrap().unwrap();
166
167 assert_eq!(first.id, second.id);
169 assert_eq!(second.digest, "updated");
170 assert_eq!(second.token_count, 7);
171 }
172
173 #[tokio::test]
174 async fn load_nonexistent_returns_none() {
175 let store = make_store().await;
176 let result = store
177 .load_session_digest(ConversationId(9999))
178 .await
179 .unwrap();
180 assert!(result.is_none());
181 }
182
183 #[tokio::test]
184 async fn delete_digest() {
185 let store = make_store().await;
186 let conv_id = insert_conversation(&store).await;
187
188 store
189 .save_session_digest(conv_id, "to delete", 2)
190 .await
191 .unwrap();
192 store.delete_session_digest(conv_id).await.unwrap();
193
194 let result = store.load_session_digest(conv_id).await.unwrap();
195 assert!(result.is_none());
196 }
197}