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>
936 lines
34 KiB
Rust
936 lines
34 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use diesel::prelude::*;
|
|
use diesel::sqlite::SqliteConnection;
|
|
use std::ops::DerefMut;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crate::database::models::{InsertPersona, Persona};
|
|
use crate::database::schema;
|
|
use crate::database::{DbError, DbErrorKind, connect};
|
|
use crate::otel::trace_db_call;
|
|
|
|
/// One row of the persona-chat transcript. Lives in
|
|
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
|
|
///
|
|
/// One persona conversation.
|
|
///
|
|
/// `turn_count` is the cumulative number of assistant turns on the active
|
|
/// branch — what the list screen shows as the length of a conversation. The
|
|
/// transcript itself is the `messages_json` blob, holding a serialized
|
|
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
|
|
/// exchange, empty until the first turn completes.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PersonaChatRow {
|
|
pub conversation_id: String,
|
|
pub persona_id: String,
|
|
pub title: String,
|
|
pub messages_json: String,
|
|
pub turn_count: i32,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
|
|
/// queries so their `select(...)` orders can never drift apart.
|
|
fn persona_chat_row(
|
|
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
|
|
String,
|
|
String,
|
|
String,
|
|
String,
|
|
i32,
|
|
i64,
|
|
i64,
|
|
),
|
|
) -> PersonaChatRow {
|
|
PersonaChatRow {
|
|
conversation_id,
|
|
persona_id,
|
|
title,
|
|
messages_json,
|
|
turn_count,
|
|
created_at,
|
|
updated_at,
|
|
}
|
|
}
|
|
|
|
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
|
/// allowed to flip `include_all_memories` but should reject name/prompt
|
|
/// edits at the handler layer (built-in copy lives in the migration).
|
|
pub struct PersonaPatch {
|
|
pub name: Option<String>,
|
|
pub system_prompt: Option<String>,
|
|
pub include_all_memories: Option<bool>,
|
|
pub reviewed_only_facts: Option<bool>,
|
|
pub allow_agent_corrections: Option<bool>,
|
|
}
|
|
|
|
/// One row of a bulk migration upload. Fields named to match the JSON
|
|
/// shape the mobile client uploads (`POST /personas/migrate`).
|
|
pub struct ImportPersona {
|
|
pub persona_id: String,
|
|
pub name: String,
|
|
pub system_prompt: String,
|
|
pub is_built_in: bool,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
pub trait PersonaDao: Sync + Send {
|
|
fn list_personas(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
) -> Result<Vec<Persona>, DbError>;
|
|
|
|
fn get_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
) -> Result<Option<Persona>, DbError>;
|
|
|
|
fn create_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
name: &str,
|
|
system_prompt: &str,
|
|
is_built_in: bool,
|
|
include_all_memories: bool,
|
|
) -> Result<Persona, DbError>;
|
|
|
|
fn update_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
patch: PersonaPatch,
|
|
) -> Result<Option<Persona>, DbError>;
|
|
|
|
fn delete_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
) -> Result<bool, DbError>;
|
|
|
|
/// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id)
|
|
/// — re-uploading the same set is a no-op. Returns the number of rows
|
|
/// actually inserted (skipped duplicates don't count).
|
|
fn bulk_import(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
personas: &[ImportPersona],
|
|
) -> Result<usize, DbError>;
|
|
|
|
// ── Persona-chat (open chat with persona) persistence ───────────
|
|
//
|
|
// Keyed on an opaque `conversation_id` so one persona can hold several
|
|
// separate conversations. `user_id` is checked on every read and write:
|
|
// the id is a bearer token for a transcript, so ownership can never be
|
|
// assumed from the id alone.
|
|
|
|
/// Fetch one conversation. None when it doesn't exist or belongs to
|
|
/// another user — the two are deliberately indistinguishable to callers.
|
|
fn get_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
) -> Result<Option<PersonaChatRow>, DbError>;
|
|
|
|
/// Every conversation this user has going, newest first. Backs the chat
|
|
/// list screen, so it returns the transcript blob too — the snippet is
|
|
/// derived from the tree rather than denormalized into its own column.
|
|
fn list_persona_chats(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
) -> Result<Vec<PersonaChatRow>, DbError>;
|
|
|
|
/// Start a new conversation with a persona, returning its id. Several
|
|
/// conversations with the same persona are expected, so this never
|
|
/// reuses an existing row.
|
|
fn create_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
persona_id: &str,
|
|
created_at: i64,
|
|
) -> Result<String, DbError>;
|
|
|
|
/// Replace a conversation's transcript. Called once per completed turn
|
|
/// with the full new `messages_json`; `turn_count` is the cumulative
|
|
/// assistant-turn count on the resulting active branch. Returns the
|
|
/// number of rows written — 0 means the conversation is gone or is not
|
|
/// this user's.
|
|
fn update_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
messages_json: &str,
|
|
turn_count: i32,
|
|
updated_at: i64,
|
|
) -> Result<usize, DbError>;
|
|
|
|
/// Name a conversation. Written once, after the first turn produces
|
|
/// enough of an exchange to summarize.
|
|
fn set_persona_chat_title(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
title: &str,
|
|
) -> Result<(), DbError>;
|
|
|
|
/// Delete a conversation outright. Backs both the list screen's delete
|
|
/// affordance and the reset endpoint.
|
|
fn delete_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
user_id: i32,
|
|
conversation_id: &str,
|
|
) -> Result<(), DbError>;
|
|
}
|
|
|
|
pub struct SqlitePersonaDao {
|
|
connection: Arc<Mutex<SqliteConnection>>,
|
|
}
|
|
|
|
impl Default for SqlitePersonaDao {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl SqlitePersonaDao {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
connection: Arc::new(Mutex::new(connect())),
|
|
}
|
|
}
|
|
|
|
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
|
|
Self { connection: conn }
|
|
}
|
|
}
|
|
|
|
impl PersonaDao for SqlitePersonaDao {
|
|
fn list_personas(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
) -> Result<Vec<Persona>, DbError> {
|
|
trace_db_call(cx, "query", "list_personas", |_span| {
|
|
use schema::personas::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
personas
|
|
.filter(user_id.eq(uid))
|
|
.order(created_at.asc())
|
|
.load::<Persona>(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn get_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
pid: &str,
|
|
) -> Result<Option<Persona>, DbError> {
|
|
trace_db_call(cx, "query", "get_persona", |_span| {
|
|
use schema::personas::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
personas
|
|
.filter(user_id.eq(uid))
|
|
.filter(persona_id.eq(pid))
|
|
.first::<Persona>(conn.deref_mut())
|
|
.optional()
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn create_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
pid: &str,
|
|
nm: &str,
|
|
prompt: &str,
|
|
builtin: bool,
|
|
include_all: bool,
|
|
) -> Result<Persona, DbError> {
|
|
trace_db_call(cx, "insert", "create_persona", |_span| {
|
|
use schema::personas::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
diesel::insert_into(personas)
|
|
.values(InsertPersona {
|
|
user_id: uid,
|
|
persona_id: pid,
|
|
name: nm,
|
|
system_prompt: prompt,
|
|
is_built_in: builtin,
|
|
include_all_memories: include_all,
|
|
created_at: now,
|
|
updated_at: now,
|
|
reviewed_only_facts: false,
|
|
allow_agent_corrections: false,
|
|
})
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
|
|
|
personas
|
|
.filter(user_id.eq(uid))
|
|
.filter(persona_id.eq(pid))
|
|
.first::<Persona>(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
|
}
|
|
|
|
fn update_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
pid: &str,
|
|
patch: PersonaPatch,
|
|
) -> Result<Option<Persona>, DbError> {
|
|
trace_db_call(cx, "update", "update_persona", |_span| {
|
|
use schema::personas::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
// Apply each field as its own UPDATE — keeps types simple
|
|
// (Diesel's tuple updates don't compose cleanly across optional
|
|
// columns) and matches the pattern already in use for entities
|
|
// (knowledge_dao.rs::update_entity).
|
|
if let Some(ref new_name) = patch.name {
|
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
|
.set((name.eq(new_name), updated_at.eq(now)))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update name error: {}", e))?;
|
|
}
|
|
if let Some(ref new_prompt) = patch.system_prompt {
|
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
|
.set((system_prompt.eq(new_prompt), updated_at.eq(now)))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update prompt error: {}", e))?;
|
|
}
|
|
if let Some(new_include_all) = patch.include_all_memories {
|
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
|
.set((include_all_memories.eq(new_include_all), updated_at.eq(now)))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
|
|
}
|
|
if let Some(new_reviewed_only) = patch.reviewed_only_facts {
|
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
|
.set((
|
|
reviewed_only_facts.eq(new_reviewed_only),
|
|
updated_at.eq(now),
|
|
))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update reviewed_only_facts error: {}", e))?;
|
|
}
|
|
if let Some(new_allow_corrections) = patch.allow_agent_corrections {
|
|
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
|
.set((
|
|
allow_agent_corrections.eq(new_allow_corrections),
|
|
updated_at.eq(now),
|
|
))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update allow_agent_corrections error: {}", e))?;
|
|
}
|
|
|
|
personas
|
|
.filter(user_id.eq(uid))
|
|
.filter(persona_id.eq(pid))
|
|
.first::<Persona>(conn.deref_mut())
|
|
.optional()
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
|
|
}
|
|
|
|
fn delete_persona(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
pid: &str,
|
|
) -> Result<bool, DbError> {
|
|
trace_db_call(cx, "delete", "delete_persona", |_span| {
|
|
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(tx)
|
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
|
Ok(n > 0)
|
|
})
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn bulk_import(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
rows: &[ImportPersona],
|
|
) -> Result<usize, DbError> {
|
|
trace_db_call(cx, "insert", "bulk_import_personas", |_span| {
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
let mut inserted = 0usize;
|
|
|
|
// INSERT OR IGNORE on the (user_id, persona_id) UNIQUE so
|
|
// re-running migrate is a no-op for personas already on the
|
|
// server.
|
|
for p in rows {
|
|
let n = diesel::sql_query(
|
|
"INSERT OR IGNORE INTO personas (user_id, persona_id, name, system_prompt, \
|
|
is_built_in, include_all_memories, created_at, updated_at) \
|
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
|
)
|
|
.bind::<diesel::sql_types::Integer, _>(uid)
|
|
.bind::<diesel::sql_types::Text, _>(&p.persona_id)
|
|
.bind::<diesel::sql_types::Text, _>(&p.name)
|
|
.bind::<diesel::sql_types::Text, _>(&p.system_prompt)
|
|
.bind::<diesel::sql_types::Bool, _>(p.is_built_in)
|
|
.bind::<diesel::sql_types::BigInt, _>(p.created_at)
|
|
.bind::<diesel::sql_types::BigInt, _>(now)
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
|
inserted += n;
|
|
}
|
|
Ok(inserted)
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
|
}
|
|
|
|
fn get_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
cid: &str,
|
|
) -> Result<Option<PersonaChatRow>, DbError> {
|
|
trace_db_call(cx, "query", "get_persona_chat", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
persona_chat_conversations
|
|
.filter(conversation_id.eq(cid))
|
|
// Scoped by user as well as id: the id is a bearer token for
|
|
// someone's transcript, so it never grants access on its own.
|
|
.filter(user_id.eq(uid))
|
|
.select((
|
|
conversation_id,
|
|
persona_id,
|
|
title,
|
|
messages_json,
|
|
turn_count,
|
|
created_at,
|
|
updated_at,
|
|
))
|
|
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
|
.optional()
|
|
.map(|opt| opt.map(persona_chat_row))
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn list_persona_chats(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
) -> Result<Vec<PersonaChatRow>, DbError> {
|
|
trace_db_call(cx, "query", "list_persona_chats", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
persona_chat_conversations
|
|
.filter(user_id.eq(uid))
|
|
.order(updated_at.desc())
|
|
.select((
|
|
conversation_id,
|
|
persona_id,
|
|
title,
|
|
messages_json,
|
|
turn_count,
|
|
created_at,
|
|
updated_at,
|
|
))
|
|
.load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
|
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
|
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn create_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
pid: &str,
|
|
created: i64,
|
|
) -> Result<String, DbError> {
|
|
trace_db_call(cx, "insert", "create_persona_chat", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
let new_id = uuid::Uuid::new_v4().to_string();
|
|
diesel::insert_into(persona_chat_conversations)
|
|
.values((
|
|
conversation_id.eq(&new_id),
|
|
user_id.eq(uid),
|
|
persona_id.eq(pid),
|
|
title.eq(""),
|
|
// The empty tree; the first turn seeds it with the
|
|
// persona's system prompt.
|
|
messages_json.eq("[]"),
|
|
turn_count.eq(0),
|
|
created_at.eq(created),
|
|
updated_at.eq(created),
|
|
))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
|
Ok(new_id)
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
|
}
|
|
|
|
fn update_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
cid: &str,
|
|
json: &str,
|
|
count: i32,
|
|
updated: i64,
|
|
) -> Result<usize, DbError> {
|
|
trace_db_call(cx, "update", "update_persona_chat", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
diesel::update(
|
|
persona_chat_conversations
|
|
.filter(conversation_id.eq(cid))
|
|
.filter(user_id.eq(uid)),
|
|
)
|
|
.set((
|
|
messages_json.eq(json),
|
|
turn_count.eq(count),
|
|
updated_at.eq(updated),
|
|
))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn set_persona_chat_title(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
cid: &str,
|
|
new_title: &str,
|
|
) -> Result<(), DbError> {
|
|
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
diesel::update(
|
|
persona_chat_conversations
|
|
.filter(conversation_id.eq(cid))
|
|
.filter(user_id.eq(uid)),
|
|
)
|
|
.set(title.eq(new_title))
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
|
Ok(())
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
|
|
fn delete_persona_chat(
|
|
&mut self,
|
|
cx: &opentelemetry::Context,
|
|
uid: i32,
|
|
cid: &str,
|
|
) -> Result<(), DbError> {
|
|
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
|
|
use schema::persona_chat_conversations::dsl::*;
|
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
|
diesel::delete(
|
|
persona_chat_conversations
|
|
.filter(conversation_id.eq(cid))
|
|
.filter(user_id.eq(uid)),
|
|
)
|
|
.execute(conn.deref_mut())
|
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
|
Ok(())
|
|
})
|
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::database::test::in_memory_db_connection;
|
|
|
|
fn dao_with_user(username: &str) -> (SqlitePersonaDao, i32) {
|
|
use crate::database::schema::users::dsl as u;
|
|
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
|
|
diesel::insert_into(u::users)
|
|
.values((u::username.eq(username), u::password.eq("x")))
|
|
.execute(conn.lock().unwrap().deref_mut())
|
|
.unwrap();
|
|
let user_id: i32 = u::users
|
|
.filter(u::username.eq(username))
|
|
.select(u::id)
|
|
.first(conn.lock().unwrap().deref_mut())
|
|
.unwrap();
|
|
(SqlitePersonaDao::from_connection(conn), user_id)
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_list_round_trip() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("alice");
|
|
|
|
// The migration seeds 3 built-ins for any existing user; alice
|
|
// was created post-migration so she starts empty.
|
|
let p = dao
|
|
.create_persona(&cx, uid, "custom-1", "Custom A", "prompt A", false, false)
|
|
.unwrap();
|
|
assert_eq!(p.persona_id, "custom-1");
|
|
assert_eq!(p.user_id, uid);
|
|
assert!(!p.is_built_in);
|
|
|
|
let list = dao.list_personas(&cx, uid).unwrap();
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0].persona_id, "custom-1");
|
|
}
|
|
|
|
#[test]
|
|
fn unique_constraint_blocks_duplicate_persona_id() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("bob");
|
|
|
|
dao.create_persona(&cx, uid, "x", "X", "p", false, false)
|
|
.unwrap();
|
|
let err = dao.create_persona(&cx, uid, "x", "X2", "p2", false, false);
|
|
assert!(
|
|
err.is_err(),
|
|
"second insert with same persona_id should fail"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_import_is_idempotent() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("carol");
|
|
|
|
let rows = vec![
|
|
ImportPersona {
|
|
persona_id: "custom-a".into(),
|
|
name: "A".into(),
|
|
system_prompt: "p1".into(),
|
|
is_built_in: false,
|
|
created_at: 1,
|
|
},
|
|
ImportPersona {
|
|
persona_id: "custom-b".into(),
|
|
name: "B".into(),
|
|
system_prompt: "p2".into(),
|
|
is_built_in: false,
|
|
created_at: 2,
|
|
},
|
|
];
|
|
|
|
let first = dao.bulk_import(&cx, uid, &rows).unwrap();
|
|
assert_eq!(first, 2);
|
|
let second = dao.bulk_import(&cx, uid, &rows).unwrap();
|
|
assert_eq!(second, 0, "re-import should insert nothing");
|
|
|
|
assert_eq!(dao.list_personas(&cx, uid).unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn dao_update_does_not_block_built_ins() {
|
|
// Documenting contract: the DAO is intentionally permissive —
|
|
// `update_persona` will apply name/system_prompt edits to ANY
|
|
// row, including built-ins. The guard against editing built-in
|
|
// identity (name + systemPrompt) lives in the HTTP handler
|
|
// (src/personas.rs::update_persona). If you find yourself
|
|
// wanting to add the guard here too, prefer that — defence in
|
|
// depth — but keep this test passing so anyone who removes
|
|
// the handler guard gets a failing call site, not silent data
|
|
// corruption.
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("eve");
|
|
|
|
dao.create_persona(&cx, uid, "default", "Default", "old", true, false)
|
|
.unwrap();
|
|
let updated = dao
|
|
.update_persona(
|
|
&cx,
|
|
uid,
|
|
"default",
|
|
PersonaPatch {
|
|
name: Some("Renamed".into()),
|
|
system_prompt: Some("new prompt".into()),
|
|
include_all_memories: None,
|
|
reviewed_only_facts: None,
|
|
allow_agent_corrections: None,
|
|
},
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(updated.name, "Renamed");
|
|
assert_eq!(updated.system_prompt, "new prompt");
|
|
assert!(
|
|
updated.is_built_in,
|
|
"is_built_in flag should be unchanged by patch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_toggles_include_all_memories() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("dan");
|
|
|
|
dao.create_persona(&cx, uid, "j", "Journal", "p", true, false)
|
|
.unwrap();
|
|
let updated = dao
|
|
.update_persona(
|
|
&cx,
|
|
uid,
|
|
"j",
|
|
PersonaPatch {
|
|
name: None,
|
|
system_prompt: None,
|
|
include_all_memories: Some(true),
|
|
reviewed_only_facts: None,
|
|
allow_agent_corrections: None,
|
|
},
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(updated.include_all_memories);
|
|
}
|
|
|
|
// ── Persona-chat DAO tests ─────────────────────────────────────
|
|
|
|
/// Second user, for the isolation tests.
|
|
fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
|
|
use crate::database::schema::users::dsl as u;
|
|
let conn = dao.connection.clone();
|
|
diesel::insert_into(u::users)
|
|
.values((u::username.eq(username), u::password.eq("x")))
|
|
.execute(conn.lock().unwrap().deref_mut())
|
|
.unwrap();
|
|
u::users
|
|
.filter(u::username.eq(username))
|
|
.select(u::id)
|
|
.first(conn.lock().unwrap().deref_mut())
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p1");
|
|
assert!(
|
|
dao.get_persona_chat(&cx, uid, "no-such-id")
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_create_then_get_round_trip() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p2");
|
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
|
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
|
assert_eq!(row.conversation_id, cid);
|
|
assert_eq!(row.persona_id, "journal");
|
|
assert_eq!(row.title, "", "unnamed until the first turn completes");
|
|
assert_eq!(row.turn_count, 0);
|
|
assert_eq!(row.created_at, 100);
|
|
assert_eq!(row.updated_at, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_create_makes_a_separate_row_each_time() {
|
|
// The whole point of the conversation_id key: one persona, several
|
|
// independent threads.
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p3");
|
|
let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
|
|
|
|
assert_ne!(first, second);
|
|
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_update_replaces_the_transcript() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p4");
|
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
|
|
let rows = dao
|
|
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
|
|
.unwrap();
|
|
assert_eq!(rows, 1);
|
|
let rows = dao
|
|
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
|
|
.unwrap();
|
|
assert_eq!(rows, 1);
|
|
|
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
|
assert_eq!(row.messages_json, "second");
|
|
assert_eq!(row.turn_count, 2);
|
|
assert_eq!(row.updated_at, 200);
|
|
assert_eq!(row.created_at, 100, "creation time is not disturbed");
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
|
|
// The turn loop treats 0 as "the conversation went away mid-turn"
|
|
// rather than silently succeeding.
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p5");
|
|
let rows = dao
|
|
.update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
|
|
.unwrap();
|
|
assert_eq!(rows, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_set_title_names_the_conversation() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p6");
|
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
|
|
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
|
|
.unwrap();
|
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
|
assert_eq!(row.title, "June recap");
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_list_orders_newest_first() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p7");
|
|
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
|
|
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
|
|
|
|
let ids: Vec<String> = dao
|
|
.list_persona_chats(&cx, uid)
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|r| r.conversation_id)
|
|
.collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec![newer, middle, older],
|
|
"ordered by updated_at descending so the list screen needs no re-sort"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p8");
|
|
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn persona_chat_delete_removes_the_conversation() {
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid) = dao_with_user("p9");
|
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
|
|
|
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
|
|
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
|
|
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
|
|
// holding one must not grant another user access to it.
|
|
let cx = opentelemetry::Context::new();
|
|
let (mut dao, uid1) = dao_with_user("owner");
|
|
let uid2 = second_user(&dao, "intruder");
|
|
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
|
|
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
|
|
"another user cannot read it"
|
|
);
|
|
assert_eq!(
|
|
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
|
|
.unwrap(),
|
|
0,
|
|
"another user cannot write it"
|
|
);
|
|
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
|
|
|
|
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
|
|
assert_eq!(row.messages_json, "private", "and cannot delete it");
|
|
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
|
|
}
|
|
}
|