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