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        // `updated_at` is `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project through
65        // `Dialect::select_as_text` so it decodes into the `String` field below.
66        // `token_count` is `INTEGER` (`INT4`) on Postgres, so it decodes as `i32`, not `i64`.
67        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    /// Delete the session digest for `conversation_id`.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the database write fails.
96    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        // id must NOT change on update (ON CONFLICT DO UPDATE, not INSERT OR REPLACE)
168        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}