fix: delete a persona's conversations along with the persona

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 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-08-25 18:39:06 -04:00
parent 65cceaa67c
commit bbaa1f62a4
+46 -2
View File
@@ -366,13 +366,29 @@ impl PersonaDao for SqlitePersonaDao {
pid: &str,
) -> Result<bool, DbError> {
trace_db_call(cx, "delete", "delete_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
// 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(conn.deref_mut())
.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<String> = 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