use crate::ChatDirection;
use crate::global_store;
use crate::turso::{self, Connection};
use anyhow::{Context, Result};
use std::path::Path;
global_store! {
pub static CHAT_HISTORY: ChatHistoryStore,
constructor = ChatHistoryStore::open,
}
const SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE,
user_name TEXT NOT NULL,
channel TEXT NOT NULL,
role TEXT NOT NULL,
direction TEXT NOT NULL,
content TEXT NOT NULL,
agent_role TEXT,
workspace TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_history_user ON chat_history(user_name, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_workspace ON chat_history(workspace, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_channel ON chat_history(channel, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_user_ws_id ON chat_history(user_name, workspace, id);
";
#[derive(Debug, Clone)]
pub struct ChatHistoryEntry {
pub id: i64,
pub message_id: String,
pub user_name: String,
pub content: String,
pub direction: ChatDirection,
pub agent_role: Option<String>,
pub workspace: String,
pub created_at: String,
}
const HISTORY_LIMIT: i64 = 100;
#[derive(Clone, Debug)]
pub struct ChatHistoryStore {
pub(crate) conn: Connection,
}
impl ChatHistoryStore {
pub async fn open(root: &Path) -> Result<Self> {
let db_path = root.join("db/chat_history.db");
let conn = turso::open_with_schema(&db_path, SCHEMA).await?;
let user_version: i64 = conn
.query_row("PRAGMA user_version", turso::params![], |row| {
row.get::<Option<i64>>(0)
})
.await
.context("Failed to read PRAGMA user_version")?
.unwrap_or(0);
if user_version < 1 {
let has_session_key = {
let rows = conn
.query(
"SELECT 1 FROM pragma_table_info('chat_history') \
WHERE name = 'session_key'",
turso::params![],
)
.await?;
!rows.is_empty()
};
if has_session_key {
conn.execute(
"DROP INDEX IF EXISTS idx_chat_history_session",
turso::params![],
)
.await?;
conn.execute(
"ALTER TABLE chat_history DROP COLUMN session_key",
turso::params![],
)
.await
.context(
"Failed to drop session_key column — verify the underlying \
engine supports ALTER TABLE DROP COLUMN",
)?;
}
conn.execute("PRAGMA user_version = 1", turso::params![])
.await
.context("Failed to set PRAGMA user_version = 1")?;
}
Ok(Self { conn })
}
#[allow(clippy::too_many_arguments)]
pub async fn insert(
&self,
message_id: &str,
user_name: &str,
channel: &str,
role: &str,
direction: &str,
content: &str,
agent_role: Option<&str>,
workspace: &str,
created_at: &str,
) -> Result<()> {
self.conn
.execute(
"INSERT OR IGNORE INTO chat_history \
(message_id, user_name, channel, role, direction, \
content, agent_role, workspace, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
turso::params![
message_id, user_name, channel, role, direction, content, agent_role,
workspace, created_at,
],
)
.await?;
Ok(())
}
pub async fn load_for_user(
&self,
user_name: &str,
workspace: &str,
) -> Result<Vec<ChatHistoryEntry>> {
let rows = self
.conn
.query(
"SELECT id, message_id, user_name, content, direction, agent_role, \
created_at, workspace \
FROM chat_history \
WHERE user_name = ?1 AND workspace = ?2 \
ORDER BY id DESC \
LIMIT ?3",
turso::params![user_name, workspace, HISTORY_LIMIT],
)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(ChatHistoryEntry {
id: row.get::<i64>(0)?,
message_id: row.get::<String>(1)?,
user_name: row.get::<String>(2)?,
content: row.get::<String>(3)?,
direction: match row.get::<String>(4)?.as_str() {
"agent" => ChatDirection::Agent,
_ => ChatDirection::User,
},
agent_role: row.get::<Option<String>>(5)?,
created_at: row.get::<String>(6)?,
workspace: row.get::<String>(7)?,
});
}
entries.reverse();
Ok(entries)
}
pub async fn load_older_for_user(
&self,
user_name: &str,
workspace: &str,
before_id: i64,
) -> Result<Vec<ChatHistoryEntry>> {
let limit = HISTORY_LIMIT + 1; let rows = self
.conn
.query(
"SELECT id, message_id, user_name, content, direction, agent_role, \
created_at, workspace \
FROM chat_history \
WHERE user_name = ?1 AND workspace = ?2 AND id < ?3 \
ORDER BY id DESC \
LIMIT ?4",
turso::params![user_name, workspace, before_id, limit],
)
.await?;
let mut entries = Vec::new();
for row in rows {
entries.push(ChatHistoryEntry {
id: row.get::<i64>(0)?,
message_id: row.get::<String>(1)?,
user_name: row.get::<String>(2)?,
content: row.get::<String>(3)?,
direction: match row.get::<String>(4)?.as_str() {
"agent" => ChatDirection::Agent,
_ => ChatDirection::User,
},
agent_role: row.get::<Option<String>>(5)?,
created_at: row.get::<String>(6)?,
workspace: row.get::<String>(7)?,
});
}
entries.reverse();
Ok(entries)
}
pub async fn delete_for_user(&self, user_name: &str, workspace: &str) -> Result<u64> {
let deleted = self
.conn
.execute(
"DELETE FROM chat_history WHERE user_name = ?1 AND workspace = ?2",
turso::params![user_name, workspace],
)
.await?;
Ok(deleted)
}
}
#[cfg(test)]
const OLD_SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL UNIQUE,
session_key TEXT NOT NULL,
user_name TEXT NOT NULL,
channel TEXT NOT NULL,
role TEXT NOT NULL,
direction TEXT NOT NULL,
content TEXT NOT NULL,
agent_role TEXT,
workspace TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_history_user ON chat_history(user_name, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_workspace ON chat_history(workspace, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_channel ON chat_history(channel, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_history_user_ws_id ON chat_history(user_name, workspace, id);
CREATE INDEX IF NOT EXISTS idx_chat_history_session ON chat_history(session_key, id);
";
#[cfg(test)]
mod tests {
use crate::chat_history::ChatHistoryStore;
use crate::turso;
use tempfile::TempDir;
fn test_setup() -> (TempDir, std::path::PathBuf) {
let tmp = TempDir::new().expect("failed to create test temp dir");
let root = tmp.path().to_path_buf();
(tmp, root)
}
#[tokio::test]
async fn test_migration_from_old_schema() {
let (_tmp, root) = test_setup();
let db_path = root.join("db/chat_history.db");
let conn = turso::open_with_schema(&db_path, super::OLD_SCHEMA)
.await
.expect("Failed to create legacy database");
let has_session_key = conn
.query(
"SELECT 1 FROM pragma_table_info('chat_history') WHERE name = 'session_key'",
turso::params![],
)
.await
.expect("Failed to check column existence");
assert!(
!has_session_key.is_empty(),
"session_key must exist in legacy schema"
);
drop(conn);
let store = ChatHistoryStore::open(&root)
.await
.expect("ChatHistoryStore::open should succeed on legacy database");
let rows = store
.conn
.query(
"SELECT 1 FROM pragma_table_info('chat_history') WHERE name = 'session_key'",
turso::params![],
)
.await
.expect("Failed to check column existence");
assert!(
rows.is_empty(),
"session_key should have been dropped by migration v1"
);
let version: i64 = store
.conn
.query_row("PRAGMA user_version", turso::params![], |row| {
row.get::<Option<i64>>(0)
})
.await
.expect("Failed to read PRAGMA user_version")
.unwrap_or(0);
assert_eq!(version, 1, "user_version should be 1 after migration");
store
.insert(
"msg-1", "user", "test", "user", "user", "hello", None, "ws", "now",
)
.await
.expect("insert should succeed after migration");
let history = store
.load_for_user("user", "ws")
.await
.expect("load should succeed");
assert_eq!(history.len(), 1);
assert_eq!(history[0].content, "hello");
drop(store);
let store2 = ChatHistoryStore::open(&root)
.await
.expect("Re-open should succeed");
let version2: i64 = store2
.conn
.query_row("PRAGMA user_version", turso::params![], |row| {
row.get::<Option<i64>>(0)
})
.await
.expect("Failed to read PRAGMA user_version")
.unwrap_or(0);
assert_eq!(version2, 1, "user_version should still be 1 after re-open");
}
#[tokio::test]
async fn test_fresh_schema() {
let (_tmp, root) = test_setup();
let store = ChatHistoryStore::open(&root)
.await
.expect("ChatHistoryStore::open should succeed on fresh database");
let rows = store
.conn
.query(
"SELECT 1 FROM pragma_table_info('chat_history') WHERE name = 'session_key'",
turso::params![],
)
.await
.expect("Failed to check column existence");
assert!(
rows.is_empty(),
"session_key should not exist in fresh schema"
);
let version: i64 = store
.conn
.query_row("PRAGMA user_version", turso::params![], |row| {
row.get::<Option<i64>>(0)
})
.await
.expect("Failed to read PRAGMA user_version")
.unwrap_or(0);
assert_eq!(version, 1, "user_version should be 1 on fresh database");
}
#[tokio::test]
async fn test_already_migrated() {
let (_tmp, root) = test_setup();
let db_path = root.join("db/chat_history.db");
let conn = turso::open_with_schema(&db_path, super::SCHEMA)
.await
.expect("Failed to create database");
conn.execute("PRAGMA user_version = 1", turso::params![])
.await
.expect("Failed to set PRAGMA user_version");
drop(conn);
let store = ChatHistoryStore::open(&root)
.await
.expect("ChatHistoryStore::open should succeed on pre-stamped database");
let version: i64 = store
.conn
.query_row("PRAGMA user_version", turso::params![], |row| {
row.get::<Option<i64>>(0)
})
.await
.expect("Failed to read PRAGMA user_version")
.unwrap_or(0);
assert_eq!(version, 1, "user_version should remain 1");
}
}