Skip to main content

zeph_memory/
snapshot.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Memory snapshot export and import.
5//!
6//! A [`MemorySnapshot`] serializes the full set of conversations, messages, and
7//! summaries from `SQLite` into a JSON document suitable for backup or migration.
8//!
9//! Import is idempotent: conversations and messages already present in the target
10//! database are skipped and counted in [`ImportStats::skipped`].
11
12use serde::{Deserialize, Serialize};
13#[allow(unused_imports)]
14use zeph_db::sql;
15
16use zeph_db::ActiveDialect;
17
18use crate::error::MemoryError;
19use crate::store::SqliteStore;
20use crate::types::ConversationId;
21
22/// Complete serializable snapshot of the `SQLite` memory store.
23#[derive(Debug, Serialize, Deserialize)]
24pub struct MemorySnapshot {
25    /// Schema version for forward-compatibility checks.
26    pub version: u32,
27    /// RFC 3339 timestamp when the snapshot was created.
28    pub exported_at: String,
29    /// All conversations with their messages and summaries.
30    pub conversations: Vec<ConversationSnapshot>,
31}
32
33/// One conversation and all its associated data in a [`MemorySnapshot`].
34#[derive(Debug, Serialize, Deserialize)]
35pub struct ConversationSnapshot {
36    /// `SQLite` row ID of the conversation.
37    pub id: i64,
38    /// All messages in this conversation.
39    pub messages: Vec<MessageSnapshot>,
40    /// Compression summaries for this conversation.
41    pub summaries: Vec<SummarySnapshot>,
42}
43
44/// A single message as stored in a [`MemorySnapshot`].
45#[derive(Debug, Serialize, Deserialize)]
46pub struct MessageSnapshot {
47    /// `SQLite` row ID.
48    pub id: i64,
49    /// Parent conversation ID.
50    pub conversation_id: i64,
51    /// Message role (`"user"`, `"assistant"`, `"system"`).
52    pub role: String,
53    /// Flattened text content.
54    pub content: String,
55    /// JSON-encoded `Vec<MessagePart>` payload.
56    pub parts_json: String,
57    /// Unix timestamp (seconds since epoch).
58    pub created_at: i64,
59}
60
61/// A compression summary record in a [`MemorySnapshot`].
62#[derive(Debug, Serialize, Deserialize)]
63pub struct SummarySnapshot {
64    /// `SQLite` row ID.
65    pub id: i64,
66    /// Parent conversation ID.
67    pub conversation_id: i64,
68    /// Summary text.
69    pub content: String,
70    /// Inclusive lower bound of the summarised message range.
71    pub first_message_id: Option<i64>,
72    /// Inclusive upper bound of the summarised message range.
73    pub last_message_id: Option<i64>,
74    /// Estimated token count of the summary content.
75    pub token_estimate: i64,
76}
77
78/// Counters returned by [`import_snapshot`].
79#[derive(Debug, Default)]
80pub struct ImportStats {
81    /// Number of conversations inserted.
82    pub conversations_imported: usize,
83    /// Number of messages inserted.
84    pub messages_imported: usize,
85    /// Number of summaries inserted.
86    pub summaries_imported: usize,
87    /// Number of rows skipped because they already existed.
88    pub skipped: usize,
89}
90
91/// Export all conversations, messages and summaries from `SQLite` into a snapshot.
92///
93/// # Errors
94///
95/// Returns an error if any database query fails.
96pub async fn export_snapshot(sqlite: &SqliteStore) -> Result<MemorySnapshot, MemoryError> {
97    let conv_ids: Vec<(i64,)> =
98        zeph_db::query_as(sql!("SELECT id FROM conversations ORDER BY id ASC"))
99            .fetch_all(sqlite.pool())
100            .await?;
101
102    let exported_at = chrono_now();
103    let mut conversations = Vec::with_capacity(conv_ids.len());
104
105    for (cid_raw,) in conv_ids {
106        let cid = ConversationId(cid_raw);
107
108        let epoch_expr = <ActiveDialect as zeph_db::dialect::Dialect>::epoch_from_col("created_at");
109        let parts_select = <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("parts");
110        let msg_sql = zeph_db::rewrite_placeholders(&format!(
111            "SELECT id, role, content, {parts_select} AS parts, {epoch_expr} \
112            FROM messages WHERE conversation_id = ? ORDER BY id ASC"
113        ));
114        let msg_rows: Vec<(i64, String, String, String, i64)> =
115            zeph_db::query_as(sqlx::AssertSqlSafe(msg_sql))
116                .bind(cid)
117                .fetch_all(sqlite.pool())
118                .await?;
119
120        let messages = msg_rows
121            .into_iter()
122            .map(
123                |(id, role, content, parts_json, created_at)| MessageSnapshot {
124                    id,
125                    conversation_id: cid_raw,
126                    role,
127                    content,
128                    parts_json,
129                    created_at,
130                },
131            )
132            .collect();
133
134        let sum_rows = sqlite.load_summaries(cid).await?;
135        let summaries = sum_rows
136            .into_iter()
137            .map(
138                |(
139                    id,
140                    conversation_id,
141                    content,
142                    first_message_id,
143                    last_message_id,
144                    token_estimate,
145                )| {
146                    SummarySnapshot {
147                        id,
148                        conversation_id: conversation_id.0,
149                        content,
150                        first_message_id: first_message_id.map(|m| m.0),
151                        last_message_id: last_message_id.map(|m| m.0),
152                        token_estimate,
153                    }
154                },
155            )
156            .collect();
157
158        conversations.push(ConversationSnapshot {
159            id: cid_raw,
160            messages,
161            summaries,
162        });
163    }
164
165    Ok(MemorySnapshot {
166        version: 1,
167        exported_at,
168        conversations,
169    })
170}
171
172/// Import a snapshot into `SQLite`, skipping duplicate entries.
173///
174/// Returns stats about what was imported.
175///
176/// # Errors
177///
178/// Returns an error if any database operation fails.
179pub async fn import_snapshot(
180    sqlite: &SqliteStore,
181    snapshot: MemorySnapshot,
182) -> Result<ImportStats, MemoryError> {
183    if snapshot.version != 1 {
184        return Err(MemoryError::Snapshot(format!(
185            "unsupported snapshot version {}: only version 1 is supported",
186            snapshot.version
187        )));
188    }
189    let mut stats = ImportStats::default();
190
191    for conv in snapshot.conversations {
192        let exists: Option<(i64,)> =
193            zeph_db::query_as(sql!("SELECT id FROM conversations WHERE id = ?"))
194                .bind(conv.id)
195                .fetch_optional(sqlite.pool())
196                .await?;
197
198        if exists.is_none() {
199            zeph_db::query(sql!("INSERT INTO conversations (id) VALUES (?)"))
200                .bind(conv.id)
201                .execute(sqlite.pool())
202                .await?;
203            stats.conversations_imported += 1;
204        } else {
205            stats.skipped += 1;
206        }
207
208        for msg in conv.messages {
209            let json_cast = <ActiveDialect as zeph_db::dialect::Dialect>::JSON_CAST;
210            let msg_sql = zeph_db::rewrite_placeholders(&format!(
211                "{} INTO messages (id, conversation_id, role, content, parts) VALUES (?, ?, ?, ?, ?{json_cast}){}",
212                <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
213                <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
214            ));
215            let result = zeph_db::query(sqlx::AssertSqlSafe(msg_sql))
216                .bind(msg.id)
217                .bind(msg.conversation_id)
218                .bind(&msg.role)
219                .bind(&msg.content)
220                .bind(&msg.parts_json)
221                .execute(sqlite.pool())
222                .await?;
223
224            if result.rows_affected() > 0 {
225                stats.messages_imported += 1;
226            } else {
227                stats.skipped += 1;
228            }
229        }
230
231        for sum in conv.summaries {
232            let sum_sql = zeph_db::rewrite_placeholders(&format!(
233                "{} INTO summaries (id, conversation_id, content, first_message_id, last_message_id, token_estimate) VALUES (?, ?, ?, ?, ?, ?){}",
234                <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
235                <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
236            ));
237            let result = zeph_db::query(sqlx::AssertSqlSafe(sum_sql))
238                .bind(sum.id)
239                .bind(sum.conversation_id)
240                .bind(&sum.content)
241                .bind(sum.first_message_id)
242                .bind(sum.last_message_id)
243                .bind(sum.token_estimate)
244                .execute(sqlite.pool())
245                .await?;
246
247            if result.rows_affected() > 0 {
248                stats.summaries_imported += 1;
249            } else {
250                stats.skipped += 1;
251            }
252        }
253    }
254
255    Ok(stats)
256}
257
258fn chrono_now() -> String {
259    zeph_common::timestamp::utc_now_rfc3339()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[tokio::test]
267    async fn export_empty_database() {
268        let store = SqliteStore::new(":memory:").await.unwrap();
269        let snapshot = export_snapshot(&store).await.unwrap();
270        assert_eq!(snapshot.version, 1);
271        assert!(snapshot.conversations.is_empty());
272        assert!(!snapshot.exported_at.is_empty());
273    }
274
275    #[tokio::test]
276    async fn export_import_roundtrip() {
277        let src = SqliteStore::new(":memory:").await.unwrap();
278        let cid = src.create_conversation().await.unwrap();
279        src.save_message(cid, "user", "hello export").await.unwrap();
280        src.save_message(cid, "assistant", "hi import")
281            .await
282            .unwrap();
283
284        let snapshot = export_snapshot(&src).await.unwrap();
285        assert_eq!(snapshot.conversations.len(), 1);
286        assert_eq!(snapshot.conversations[0].messages.len(), 2);
287
288        let dst = SqliteStore::new(":memory:").await.unwrap();
289        let stats = import_snapshot(&dst, snapshot).await.unwrap();
290        assert_eq!(stats.conversations_imported, 1);
291        assert_eq!(stats.messages_imported, 2);
292        assert_eq!(stats.skipped, 0);
293
294        let history = dst.load_history(cid, 50).await.unwrap();
295        assert_eq!(history.len(), 2);
296        assert_eq!(history[0].content, "hello export");
297        assert_eq!(history[1].content, "hi import");
298    }
299
300    #[tokio::test]
301    async fn import_duplicate_skips() {
302        let src = SqliteStore::new(":memory:").await.unwrap();
303        let cid = src.create_conversation().await.unwrap();
304        src.save_message(cid, "user", "msg").await.unwrap();
305
306        let snapshot = export_snapshot(&src).await.unwrap();
307
308        let dst = SqliteStore::new(":memory:").await.unwrap();
309        let stats1 = import_snapshot(&dst, snapshot).await.unwrap();
310        assert_eq!(stats1.messages_imported, 1);
311
312        let snapshot2 = export_snapshot(&src).await.unwrap();
313        let stats2 = import_snapshot(&dst, snapshot2).await.unwrap();
314        assert_eq!(stats2.messages_imported, 0);
315        assert!(stats2.skipped > 0);
316    }
317
318    #[tokio::test]
319    async fn import_existing_conversation_increments_skipped_not_imported() {
320        let src = SqliteStore::new(":memory:").await.unwrap();
321        let cid = src.create_conversation().await.unwrap();
322        src.save_message(cid, "user", "only message").await.unwrap();
323
324        let snapshot = export_snapshot(&src).await.unwrap();
325
326        // Import once — conversation is new.
327        let dst = SqliteStore::new(":memory:").await.unwrap();
328        let stats1 = import_snapshot(&dst, snapshot).await.unwrap();
329        assert_eq!(stats1.conversations_imported, 1);
330        assert_eq!(stats1.messages_imported, 1);
331
332        // Import again with no new messages — conversation already exists, must be counted as skipped.
333        let snapshot2 = export_snapshot(&src).await.unwrap();
334        let stats2 = import_snapshot(&dst, snapshot2).await.unwrap();
335        assert_eq!(
336            stats2.conversations_imported, 0,
337            "existing conversation must not be counted as imported"
338        );
339        // The conversation itself contributes one skipped, plus the duplicate message.
340        assert!(
341            stats2.skipped >= 1,
342            "re-importing an existing conversation must increment skipped"
343        );
344    }
345
346    #[tokio::test]
347    async fn export_includes_summaries() {
348        let store = SqliteStore::new(":memory:").await.unwrap();
349        let cid = store.create_conversation().await.unwrap();
350        let m1 = store.save_message(cid, "user", "a").await.unwrap();
351        let m2 = store.save_message(cid, "assistant", "b").await.unwrap();
352        store
353            .save_summary(cid, "summary", Some(m1), Some(m2), 5)
354            .await
355            .unwrap();
356
357        let snapshot = export_snapshot(&store).await.unwrap();
358        assert_eq!(snapshot.conversations[0].summaries.len(), 1);
359        assert_eq!(snapshot.conversations[0].summaries[0].content, "summary");
360    }
361
362    #[test]
363    fn chrono_now_not_empty() {
364        let ts = chrono_now();
365        assert!(ts.contains('T'));
366        assert!(ts.ends_with('Z'));
367    }
368
369    #[test]
370    fn import_corrupt_json_returns_error() {
371        let result = serde_json::from_str::<MemorySnapshot>("not valid json at all {{{");
372        assert!(result.is_err());
373    }
374
375    #[tokio::test]
376    async fn import_unsupported_version_returns_error() {
377        let store = SqliteStore::new(":memory:").await.unwrap();
378        let snapshot = MemorySnapshot {
379            version: 99,
380            exported_at: "2026-01-01T00:00:00Z".into(),
381            conversations: vec![],
382        };
383        let err = import_snapshot(&store, snapshot).await.unwrap_err();
384        let msg = err.to_string();
385        assert!(msg.contains("unsupported snapshot version 99"));
386    }
387
388    #[tokio::test]
389    async fn import_partial_overlap_adds_new_messages() {
390        let src = SqliteStore::new(":memory:").await.unwrap();
391        let cid = src.create_conversation().await.unwrap();
392        src.save_message(cid, "user", "existing message")
393            .await
394            .unwrap();
395
396        let snapshot1 = export_snapshot(&src).await.unwrap();
397
398        let dst = SqliteStore::new(":memory:").await.unwrap();
399        let stats1 = import_snapshot(&dst, snapshot1).await.unwrap();
400        assert_eq!(stats1.messages_imported, 1);
401
402        src.save_message(cid, "assistant", "new reply")
403            .await
404            .unwrap();
405        let snapshot2 = export_snapshot(&src).await.unwrap();
406        let stats2 = import_snapshot(&dst, snapshot2).await.unwrap();
407
408        assert_eq!(
409            stats2.messages_imported, 1,
410            "only the new message should be imported"
411        );
412        // skipped includes the existing conversation (1) plus the duplicate message (1).
413        assert_eq!(
414            stats2.skipped, 2,
415            "existing conversation and duplicate message should be skipped"
416        );
417
418        let history = dst.load_history(cid, 50).await.unwrap();
419        assert_eq!(history.len(), 2);
420        assert_eq!(history[1].content, "new reply");
421    }
422}