From bbaa1f62a4000051ebe025d386f82e05c9d72f80 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 25 Aug 2026 18:39:06 -0400 Subject: [PATCH] fix: delete a persona's conversations along with the persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no FK between personas and persona_chat_conversations, and the chat list skips conversations whose persona is gone — so deleting a persona left its transcripts in the database, invisible and unreachable, forever. Both deletes now run in one transaction so the two tables cannot get out of step. Co-Authored-By: Claude Opus 5 --- src/database/persona_dao.rs | 54 +++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index f4a2939..23a8ec0 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -366,12 +366,28 @@ impl PersonaDao for SqlitePersonaDao { pid: &str, ) -> Result { trace_db_call(cx, "delete", "delete_persona", |_span| { - use schema::personas::dsl::*; let mut conn = self.connection.lock().expect("PersonaDao lock"); - let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) - .execute(conn.deref_mut()) - .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; - Ok(n > 0) + // One transaction so a persona and its conversations can't get + // out of step. There is no FK between the two tables, and the + // chat list skips conversations whose persona is gone — so + // without this the transcripts would linger invisibly forever. + conn.deref_mut().transaction::<_, anyhow::Error, _>(|tx| { + { + use schema::persona_chat_conversations::dsl::*; + diesel::delete( + persona_chat_conversations + .filter(user_id.eq(uid)) + .filter(persona_id.eq(pid)), + ) + .execute(tx) + .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; + } + use schema::personas::dsl::*; + let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid))) + .execute(tx) + .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; + Ok(n > 0) + }) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } @@ -861,6 +877,34 @@ mod tests { assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty()); } + #[test] + fn deleting_a_persona_takes_its_conversations_with_it() { + // No FK between the tables, and the chat list hides conversations + // whose persona is gone — so an orphan row would be an invisible, + // unreachable transcript sitting in the database forever. + let cx = opentelemetry::Context::new(); + let (mut dao, uid) = dao_with_user("p-cascade"); + dao.create_persona(&cx, uid, "custom-1", "Custom", "prompt", false, false) + .unwrap(); + dao.create_persona_chat(&cx, uid, "custom-1", 100).unwrap(); + dao.create_persona_chat(&cx, uid, "custom-1", 200).unwrap(); + let survivor = dao.create_persona_chat(&cx, uid, "other", 300).unwrap(); + + assert!(dao.delete_persona(&cx, uid, "custom-1").unwrap()); + + let remaining: Vec = dao + .list_persona_chats(&cx, uid) + .unwrap() + .into_iter() + .map(|r| r.conversation_id) + .collect(); + assert_eq!( + remaining, + vec![survivor], + "only the untouched persona's conversation survives" + ); + } + #[test] fn persona_chat_reads_and_writes_are_scoped_to_the_owner() { // A conversation_id is a bearer token for someone's transcript, so