1use 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#[derive(Debug, Serialize, Deserialize)]
24pub struct MemorySnapshot {
25 pub version: u32,
27 pub exported_at: String,
29 pub conversations: Vec<ConversationSnapshot>,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
35pub struct ConversationSnapshot {
36 pub id: i64,
38 pub messages: Vec<MessageSnapshot>,
40 pub summaries: Vec<SummarySnapshot>,
42}
43
44#[derive(Debug, Serialize, Deserialize)]
46pub struct MessageSnapshot {
47 pub id: i64,
49 pub conversation_id: i64,
51 pub role: String,
53 pub content: String,
55 pub parts_json: String,
57 pub created_at: i64,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
63pub struct SummarySnapshot {
64 pub id: i64,
66 pub conversation_id: i64,
68 pub content: String,
70 pub first_message_id: Option<i64>,
72 pub last_message_id: Option<i64>,
74 pub token_estimate: i64,
76}
77
78#[derive(Debug, Default)]
80pub struct ImportStats {
81 pub conversations_imported: usize,
83 pub messages_imported: usize,
85 pub summaries_imported: usize,
87 pub skipped: usize,
89}
90
91pub 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
172pub 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 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 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 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 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}