Skip to main content

zeph_memory/store/
session_digest.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `SQLite` CRUD for the `session_digest` table — upsert, load, delete.
5
6use 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/// A distilled session digest: key facts and outcomes for a single conversation.
14#[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    /// Upsert a session digest for `conversation_id`.
25    ///
26    /// Uses `INSERT ... ON CONFLICT ... DO UPDATE` to preserve the original `created_at`
27    /// and avoid resetting the auto-incremented `id` on updates.
28    ///
29    /// # Errors
30    ///
31    /// Returns an error if the database write fails.
32    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    /// Load the session digest for `conversation_id`, if it exists.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the database query fails.
60    pub async fn load_session_digest(
61        &self,
62        conversation_id: ConversationId,
63    ) -> Result<Option<SessionDigest>, MemoryError> {
64        let row = zeph_db::query_as::<_, (i64, i64, String, i64, String)>(sql!(
65            "SELECT id, conversation_id, digest, token_count, updated_at \
66             FROM session_digest WHERE conversation_id = ?"
67        ))
68        .bind(conversation_id.0)
69        .fetch_optional(&self.pool)
70        .await?;
71
72        Ok(row.map(
73            |(id, conv_id, digest, token_count, updated_at)| SessionDigest {
74                id,
75                conversation_id: ConversationId(conv_id),
76                digest,
77                token_count,
78                updated_at,
79            },
80        ))
81    }
82
83    /// Delete the session digest for `conversation_id`.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the database write fails.
88    pub async fn delete_session_digest(
89        &self,
90        conversation_id: ConversationId,
91    ) -> Result<(), MemoryError> {
92        zeph_db::query(sql!("DELETE FROM session_digest WHERE conversation_id = ?"))
93            .bind(conversation_id.0)
94            .execute(&self.pool)
95            .await?;
96        Ok(())
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::store::SqliteStore;
104
105    async fn make_store() -> SqliteStore {
106        SqliteStore::with_pool_size(":memory:", 1)
107            .await
108            .expect("in-memory store")
109    }
110
111    async fn insert_conversation(store: &SqliteStore) -> ConversationId {
112        zeph_db::query_scalar::<_, i64>(sql!(
113            "INSERT INTO conversations (created_at) VALUES (CURRENT_TIMESTAMP) RETURNING id"
114        ))
115        .fetch_one(&store.pool)
116        .await
117        .map(ConversationId)
118        .expect("insert conversation")
119    }
120
121    #[tokio::test]
122    async fn save_and_load_digest() {
123        let store = make_store().await;
124        let conv_id = insert_conversation(&store).await;
125
126        store
127            .save_session_digest(conv_id, "Key facts from session.", 5)
128            .await
129            .unwrap();
130
131        let digest = store
132            .load_session_digest(conv_id)
133            .await
134            .unwrap()
135            .expect("digest should exist");
136
137        assert_eq!(digest.conversation_id, conv_id);
138        assert_eq!(digest.digest, "Key facts from session.");
139        assert_eq!(digest.token_count, 5);
140    }
141
142    #[tokio::test]
143    async fn upsert_preserves_id_and_created_at() {
144        let store = make_store().await;
145        let conv_id = insert_conversation(&store).await;
146
147        store
148            .save_session_digest(conv_id, "first", 3)
149            .await
150            .unwrap();
151        let first = store.load_session_digest(conv_id).await.unwrap().unwrap();
152
153        store
154            .save_session_digest(conv_id, "updated", 7)
155            .await
156            .unwrap();
157        let second = store.load_session_digest(conv_id).await.unwrap().unwrap();
158
159        // id must NOT change on update (ON CONFLICT DO UPDATE, not INSERT OR REPLACE)
160        assert_eq!(first.id, second.id);
161        assert_eq!(second.digest, "updated");
162        assert_eq!(second.token_count, 7);
163    }
164
165    #[tokio::test]
166    async fn load_nonexistent_returns_none() {
167        let store = make_store().await;
168        let result = store
169            .load_session_digest(ConversationId(9999))
170            .await
171            .unwrap();
172        assert!(result.is_none());
173    }
174
175    #[tokio::test]
176    async fn delete_digest() {
177        let store = make_store().await;
178        let conv_id = insert_conversation(&store).await;
179
180        store
181            .save_session_digest(conv_id, "to delete", 2)
182            .await
183            .unwrap();
184        store.delete_session_digest(conv_id).await.unwrap();
185
186        let result = store.load_session_digest(conv_id).await.unwrap();
187        assert!(result.is_none());
188    }
189}