From 330059fd2dc192f2f1a4c696df11d3716622c23a Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Fri, 13 Mar 2026 10:15:06 +0800 Subject: [PATCH] feat: persist conversation store --- conversations/src/context.rs | 42 +++++++++++ conversations/src/conversation.rs | 3 + conversations/src/conversation/group_test.rs | 4 + conversations/src/conversation/privatev1.rs | 4 + conversations/src/storage/db.rs | 74 ++++++++++++++++++- conversations/src/storage/migrations.rs | 4 + .../storage/migrations/003_conversations.sql | 9 +++ conversations/src/storage/types.rs | 8 ++ 8 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 conversations/src/storage/migrations/003_conversations.sql diff --git a/conversations/src/context.rs b/conversations/src/context.rs index af63bb2..46378f6 100644 --- a/conversations/src/context.rs +++ b/conversations/src/context.rs @@ -162,6 +162,12 @@ impl Context { } fn add_convo(&mut self, convo: Box) -> ConversationIdOwned { + // Persist conversation metadata to storage + let _ = self.storage.save_conversation( + convo.id(), + &convo.remote_id(), + convo.convo_type(), + ); self.store.insert_convo(convo) } @@ -298,4 +304,40 @@ mod tests { assert_eq!(content.data, b"hello after restart"); assert!(content.is_new_convo); } + + #[test] + fn conversation_metadata_persistence() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir + .path() + .join("test_convo_meta.db") + .to_string_lossy() + .to_string(); + let config = StorageConfig::File(db_path); + + // Create context, establish a conversation + let mut alice = Context::open("alice", config.clone()).unwrap(); + let mut bob = Context::new_with_name("bob"); + + let bundle = alice.create_intro_bundle().unwrap(); + let intro = Introduction::try_from(bundle.as_slice()).unwrap(); + let (_, payloads) = bob.create_private_convo(&intro, b"hi"); + + let payload = payloads.first().unwrap(); + let content = alice.handle_payload(&payload.data).unwrap().unwrap(); + assert!(content.is_new_convo); + + // Verify conversation metadata was persisted + let convos = alice.storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1); + assert_eq!(convos[0].convo_type, "private_v1"); + assert!(!convos[0].local_convo_id.is_empty()); + assert!(!convos[0].remote_convo_id.is_empty()); + + // Drop and reopen - metadata should still be there + drop(alice); + let alice2 = Context::open("alice", config).unwrap(); + let convos = alice2.storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1, "conversation metadata should persist"); + } } diff --git a/conversations/src/conversation.rs b/conversations/src/conversation.rs index a148c5a..329993b 100644 --- a/conversations/src/conversation.rs +++ b/conversations/src/conversation.rs @@ -27,6 +27,9 @@ pub trait Convo: Id + Debug { ) -> Result, ChatError>; fn remote_id(&self) -> String; + + /// Returns the conversation type identifier for storage. + fn convo_type(&self) -> &str; } pub struct ConversationStore { diff --git a/conversations/src/conversation/group_test.rs b/conversations/src/conversation/group_test.rs index e77984f..0ce4084 100644 --- a/conversations/src/conversation/group_test.rs +++ b/conversations/src/conversation/group_test.rs @@ -38,4 +38,8 @@ impl Convo for GroupTestConvo { fn remote_id(&self) -> String { self.id().to_string() } + + fn convo_type(&self) -> &str { + "group_test" + } } diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs index 0b8042e..5273bf1 100644 --- a/conversations/src/conversation/privatev1.rs +++ b/conversations/src/conversation/privatev1.rs @@ -209,6 +209,10 @@ impl Convo for PrivateV1Convo { fn remote_id(&self) -> String { self.remote_convo_id.clone() } + + fn convo_type(&self) -> &str { + "private_v1" + } } impl Debug for PrivateV1Convo { diff --git a/conversations/src/storage/db.rs b/conversations/src/storage/db.rs index 8eaefe9..b950e7b 100644 --- a/conversations/src/storage/db.rs +++ b/conversations/src/storage/db.rs @@ -4,7 +4,7 @@ use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params}; use zeroize::Zeroize; use super::migrations; -use super::types::IdentityRecord; +use super::types::{ConversationRecord, IdentityRecord}; use crate::crypto::PrivateKey; use crate::identity::Identity; @@ -110,6 +110,50 @@ impl ChatStorage { Ok(()) } + // ==================== Conversation Operations ==================== + + /// Saves conversation metadata. + pub fn save_conversation( + &mut self, + local_convo_id: &str, + remote_convo_id: &str, + convo_type: &str, + ) -> Result<(), StorageError> { + self.db.connection().execute( + "INSERT OR REPLACE INTO conversations (local_convo_id, remote_convo_id, convo_type) VALUES (?1, ?2, ?3)", + params![local_convo_id, remote_convo_id, convo_type], + )?; + Ok(()) + } + + /// Loads all conversation records. + pub fn load_conversations(&self) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare( + "SELECT local_convo_id, remote_convo_id, convo_type FROM conversations", + )?; + + let records = stmt + .query_map([], |row| { + Ok(ConversationRecord { + local_convo_id: row.get(0)?, + remote_convo_id: row.get(1)?, + convo_type: row.get(2)?, + }) + })? + .collect::, _>>()?; + + Ok(records) + } + + /// Removes a conversation by its local ID. + pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { + self.db.connection().execute( + "DELETE FROM conversations WHERE local_convo_id = ?1", + params![local_convo_id], + )?; + Ok(()) + } + // ==================== Identity Operations (continued) ==================== /// Loads the identity if it exists. @@ -194,4 +238,32 @@ mod tests { storage.remove_ephemeral_key(&hex1).unwrap(); assert!(storage.load_ephemeral_key(&hex1).unwrap().is_none()); } + + #[test] + fn test_conversation_roundtrip() { + let mut storage = ChatStorage::new(StorageConfig::InMemory).unwrap(); + + // Initially empty + let convos = storage.load_conversations().unwrap(); + assert!(convos.is_empty()); + + // Save conversations + storage + .save_conversation("local_1", "remote_1", "private_v1") + .unwrap(); + storage + .save_conversation("local_2", "remote_2", "private_v1") + .unwrap(); + + let convos = storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 2); + + // Remove one + storage.remove_conversation("local_1").unwrap(); + let convos = storage.load_conversations().unwrap(); + assert_eq!(convos.len(), 1); + assert_eq!(convos[0].local_convo_id, "local_2"); + assert_eq!(convos[0].remote_convo_id, "remote_2"); + assert_eq!(convos[0].convo_type, "private_v1"); + } } diff --git a/conversations/src/storage/migrations.rs b/conversations/src/storage/migrations.rs index 5de4737..848122d 100644 --- a/conversations/src/storage/migrations.rs +++ b/conversations/src/storage/migrations.rs @@ -16,6 +16,10 @@ pub fn get_migrations() -> Vec<(&'static str, &'static str)> { "002_ephemeral_keys", include_str!("migrations/002_ephemeral_keys.sql"), ), + ( + "003_conversations", + include_str!("migrations/003_conversations.sql"), + ), ] } diff --git a/conversations/src/storage/migrations/003_conversations.sql b/conversations/src/storage/migrations/003_conversations.sql new file mode 100644 index 0000000..71da87f --- /dev/null +++ b/conversations/src/storage/migrations/003_conversations.sql @@ -0,0 +1,9 @@ +-- Conversations metadata +-- Migration: 003_conversations + +CREATE TABLE IF NOT EXISTS conversations ( + local_convo_id TEXT PRIMARY KEY, + remote_convo_id TEXT NOT NULL, + convo_type TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); diff --git a/conversations/src/storage/types.rs b/conversations/src/storage/types.rs index c34f9be..58c21ce 100644 --- a/conversations/src/storage/types.rs +++ b/conversations/src/storage/types.rs @@ -22,6 +22,14 @@ impl From for Identity { } } +/// Record for storing conversation metadata. +#[derive(Debug)] +pub struct ConversationRecord { + pub local_convo_id: String, + pub remote_convo_id: String, + pub convo_type: String, +} + #[cfg(test)] mod tests { use super::*;