use crate::types::{Artifact, Result, Span, Session, Message, MessageRole, SessionWorkingSet, SessionWithMessages, WorkingSet, CompilerConfig};
use crate::index::VectorIndex;
use rusqlite::{params, Connection, OptionalExtension};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use sha2::{Digest, Sha256};
use serde::{Serialize, Deserialize};
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
vector_index: Arc<RwLock<Option<Arc<VectorIndex>>>>,
index_dirty: Arc<AtomicBool>,
db_path: PathBuf,
build_lock: Arc<Mutex<()>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum IndexLoadKind {
LoadedFromCache,
BuiltFromSpans,
CachedInMemory,
}
impl Database {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let db_path = path.as_ref().to_path_buf();
let conn = Connection::open(&db_path)?;
let schema_001 = r#"-- AvocadoDB Initial Schema
-- Phase 1: Simple SQLite-compatible schema for deterministic context compilation
-- Artifacts table: stores ingested documents
CREATE TABLE IF NOT EXISTS artifacts (
id TEXT PRIMARY KEY, -- UUID v4
path TEXT NOT NULL UNIQUE, -- File path or identifier
content TEXT NOT NULL, -- Full document text
content_hash TEXT NOT NULL, -- SHA256 of content
metadata TEXT, -- JSON string with arbitrary metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Spans table: stores document fragments with embeddings
CREATE TABLE IF NOT EXISTS spans (
id TEXT PRIMARY KEY, -- UUID v4
artifact_id TEXT NOT NULL, -- Foreign key to artifacts
start_line INTEGER NOT NULL, -- Starting line number (1-indexed)
end_line INTEGER NOT NULL, -- Ending line number (inclusive)
text TEXT NOT NULL, -- Actual span text
embedding BLOB, -- Serialized f32 vector (1536 dims for ada-002)
embedding_model TEXT, -- e.g., "text-embedding-ada-002"
token_count INTEGER, -- Estimated token count
metadata TEXT, -- JSON string
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_spans_artifact ON spans(artifact_id);
CREATE INDEX IF NOT EXISTS idx_spans_lines ON spans(artifact_id, start_line, end_line);
CREATE INDEX IF NOT EXISTS idx_artifacts_path ON artifacts(path);
CREATE INDEX IF NOT EXISTS idx_artifacts_hash ON artifacts(content_hash);
-- Enable WAL mode for better concurrency
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
"#;
let schema_without_pragma = schema_001
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.starts_with("PRAGMA") && !trimmed.starts_with("-- Enable WAL")
})
.collect::<Vec<_>>()
.join("\n");
conn.execute_batch(&schema_without_pragma)?;
let schema_002 = r#"-- AvocadoDB Session Management Schema
-- Phase 2, Priority 1: Session tracking for conversation history and agent memory
-- Sessions table: tracks conversation sessions
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- UUID v4
user_id TEXT, -- Optional user identifier
title TEXT, -- Optional session title (auto-generated or user-provided)
metadata TEXT, -- JSON string with arbitrary metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_message_at TIMESTAMP -- For sorting/filtering
);
-- Messages table: stores individual conversation turns
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, -- UUID v4
session_id TEXT NOT NULL, -- Foreign key to sessions
role TEXT NOT NULL, -- 'user', 'assistant', 'system', 'tool'
content TEXT NOT NULL, -- Message content
metadata TEXT, -- JSON string (tool calls, citations, etc.)
sequence_number INTEGER NOT NULL, -- Order within session (0-indexed)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- Working set associations: links compiled contexts to sessions
CREATE TABLE IF NOT EXISTS session_working_sets (
id TEXT PRIMARY KEY, -- UUID v4
session_id TEXT NOT NULL, -- Foreign key to sessions
message_id TEXT, -- Optional: which message triggered this compilation
working_set_id TEXT NOT NULL, -- Reference to working set (stored as JSON for now)
query TEXT NOT NULL, -- Query that generated this working set
config TEXT, -- JSON string of CompilerConfig used
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
);
-- Agents table: stores registered agents for multi-agent orchestration
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, -- UUID v4
name TEXT NOT NULL, -- Human-readable name (e.g., "moderator")
role TEXT NOT NULL, -- Agent's role/persona description
model TEXT NOT NULL, -- LLM model identifier
system_prompt TEXT, -- Optional system prompt / personality
did TEXT, -- Optional DID for decentralized identity
capabilities TEXT, -- JSON array of capabilities
metadata TEXT, -- JSON string with arbitrary metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Agent relations table: tracks agreements/disagreements between agents
CREATE TABLE IF NOT EXISTS agent_relations (
id TEXT PRIMARY KEY, -- UUID v4
session_id TEXT NOT NULL, -- Session where this occurred
message_id TEXT NOT NULL, -- Message that created this relation
from_agent_id TEXT NOT NULL, -- Agent who expressed the stance
to_agent_id TEXT NOT NULL, -- Agent being referenced
stance TEXT NOT NULL, -- 'agree', 'disagree', 'neutral', 'question'
target_message_id TEXT NOT NULL, -- Message being referenced
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (from_agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (to_agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, sequence_number);
CREATE INDEX IF NOT EXISTS idx_working_sets_session ON session_working_sets(session_id);
CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
CREATE INDEX IF NOT EXISTS idx_agent_relations_session ON agent_relations(session_id);
CREATE INDEX IF NOT EXISTS idx_agent_relations_from ON agent_relations(from_agent_id);
CREATE INDEX IF NOT EXISTS idx_agent_relations_to ON agent_relations(to_agent_id);
"#;
conn.execute_batch(schema_002)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", true)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
vector_index: Arc::new(RwLock::new(None)),
index_dirty: Arc::new(AtomicBool::new(true)),
db_path,
build_lock: Arc::new(Mutex::new(())),
})
}
pub fn insert_artifact(&self, artifact: &Artifact) -> Result<()> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
conn.execute(
"INSERT INTO artifacts (id, path, content, content_hash, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
artifact.id,
artifact.path,
artifact.content,
artifact.content_hash,
artifact.metadata.as_ref().map(|m| m.to_string()),
artifact.created_at.to_rfc3339(),
],
)?;
self.index_dirty.store(true, Ordering::Release);
let _ = std::fs::remove_dir_all(self.get_index_cache_dir());
Ok(())
}
pub fn insert_spans(&self, spans: &[Span]) -> Result<()> {
let mut conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let tx = conn.transaction()?;
for span in spans {
tx.execute(
"INSERT INTO spans (
id, artifact_id, start_line, end_line, text,
embedding, embedding_model, token_count, metadata
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
span.id,
span.artifact_id,
span.start_line as i64,
span.end_line as i64,
span.text,
span.embedding.as_ref().map(|e| serialize_embedding(e)),
span.embedding_model,
span.token_count as i64,
span.metadata.as_ref().map(|m| m.to_string()),
],
)?;
}
tx.commit()?;
self.index_dirty.store(true, Ordering::Release);
let _ = std::fs::remove_dir_all(self.get_index_cache_dir());
Ok(())
}
pub fn get_vector_index(&self) -> Result<Arc<VectorIndex>> {
Ok(self.get_vector_index_with_kind()?.0)
}
pub fn get_vector_index_with_kind(&self) -> Result<(Arc<VectorIndex>, IndexLoadKind)> {
if self.index_dirty.load(Ordering::Acquire) {
let _guard = self.build_lock.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Build lock poisoned: {}", e)))?;
if !self.index_dirty.load(Ordering::Acquire) {
let cached = self.vector_index.read()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Index lock poisoned: {}", e)))?;
let idx = cached.as_ref()
.cloned()
.ok_or_else(|| crate::types::Error::Other(anyhow::anyhow!("Index cache empty after build")))?
;
return Ok((idx, IndexLoadKind::CachedInMemory));
}
let cache_dir = self.get_index_cache_dir();
if let Ok(index) = self.load_index_from_disk(&cache_dir) {
let mut cached = self.vector_index.write()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Index lock poisoned: {}", e)))?;
*cached = Some(index.clone());
self.index_dirty.store(false, Ordering::Release);
return Ok((index, IndexLoadKind::LoadedFromCache));
}
let spans = self.get_all_spans()?;
let index = Arc::new(VectorIndex::build(spans));
let _ = self.save_index_to_disk(&cache_dir, &index);
let mut cached = self.vector_index.write()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Index lock poisoned: {}", e)))?;
*cached = Some(index.clone());
self.index_dirty.store(false, Ordering::Release);
Ok((index, IndexLoadKind::BuiltFromSpans))
} else {
let cached = self.vector_index.read()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Index lock poisoned: {}", e)))?;
let idx = cached.as_ref()
.cloned()
.ok_or_else(|| crate::types::Error::Other(anyhow::anyhow!("Index cache is None but not dirty - this should not happen")))?;
Ok((idx, IndexLoadKind::CachedInMemory))
}
}
fn get_index_cache_dir(&self) -> PathBuf {
let mut cache_dir = self.db_path.clone();
cache_dir.set_extension("sqlite.idx");
cache_dir
}
fn calculate_spans_hash(&self) -> Result<String> {
let spans = self.get_all_spans()?;
let mut hasher = Sha256::new();
for span in &spans {
hasher.update(span.id.as_bytes());
if let Some(emb) = &span.embedding {
hasher.update(&emb.len().to_le_bytes());
}
}
Ok(format!("{:x}", hasher.finalize()))
}
fn load_index_from_disk(&self, cache_dir: &Path) -> Result<Arc<VectorIndex>> {
match VectorIndex::load_from_disk(cache_dir) {
Ok(Some(index)) => {
let current_hash = self.calculate_spans_hash()?;
let cached_spans = index.spans();
let mut hasher = Sha256::new();
for span in cached_spans {
hasher.update(span.id.as_bytes());
if let Some(emb) = &span.embedding {
hasher.update(&emb.len().to_le_bytes());
}
}
let cached_hash = format!("{:x}", hasher.finalize());
if cached_hash == current_hash {
Ok(Arc::new(index))
} else {
Err(crate::types::Error::NotFound("Index cache is stale".to_string()))
}
}
Ok(None) => Err(crate::types::Error::NotFound("Index cache not found".to_string())),
Err(e) => Err(e),
}
}
fn save_index_to_disk(&self, cache_dir: &Path, index: &VectorIndex) -> Result<()> {
index.save_to_disk(cache_dir)
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Failed to save index to disk: {}", e)))?;
Ok(())
}
pub fn get_all_spans(&self) -> Result<Vec<Span>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, artifact_id, start_line, end_line, text,
embedding, embedding_model, token_count, metadata
FROM spans",
)?;
let spans = stmt
.query_map([], |row| {
Ok(Span {
id: row.get(0)?,
artifact_id: row.get(1)?,
start_line: row.get::<_, i64>(2)? as usize,
end_line: row.get::<_, i64>(3)? as usize,
text: row.get(4)?,
embedding: row
.get::<_, Option<Vec<u8>>>(5)?
.map(|bytes| deserialize_embedding(&bytes)),
embedding_model: row.get(6)?,
token_count: row.get::<_, i64>(7)? as usize,
metadata: row
.get::<_, Option<String>>(8)?
.and_then(|s| serde_json::from_str(&s).ok()),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(spans)
}
pub fn get_artifact(&self, artifact_id: &str) -> Result<Option<Artifact>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, path, content, content_hash, metadata, created_at
FROM artifacts WHERE id = ?1",
)?;
let artifact = stmt
.query_row(params![artifact_id], |row| {
Ok(Artifact {
id: row.get(0)?,
path: row.get(1)?,
content: row.get(2)?,
content_hash: row.get(3)?,
metadata: row
.get::<_, Option<String>>(4)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row
.get::<_, String>(5)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})
.optional()?;
Ok(artifact)
}
pub fn get_artifact_by_path(&self, path: &str) -> Result<Option<Artifact>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, path, content, content_hash, metadata, created_at
FROM artifacts WHERE path = ?1",
)?;
let artifact = stmt
.query_row(params![path], |row| {
Ok(Artifact {
id: row.get(0)?,
path: row.get(1)?,
content: row.get(2)?,
content_hash: row.get(3)?,
metadata: row
.get::<_, Option<String>>(4)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row
.get::<_, String>(5)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})
.optional()?;
Ok(artifact)
}
pub fn determine_ingest_action(&self, path: &str, content_hash: &str) -> Result<crate::types::IngestAction> {
match self.get_artifact_by_path(path)? {
Some(existing) => {
if existing.content_hash == content_hash {
Ok(crate::types::IngestAction::Skip {
artifact_id: existing.id,
reason: "Content unchanged (same hash)".to_string(),
})
} else {
Ok(crate::types::IngestAction::Update {
artifact_id: existing.id,
})
}
}
None => Ok(crate::types::IngestAction::Create),
}
}
pub fn delete_artifact(&self, artifact_id: &str) -> Result<usize> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let spans_deleted = conn.execute(
"DELETE FROM spans WHERE artifact_id = ?1",
params![artifact_id],
)?;
conn.execute(
"DELETE FROM artifacts WHERE id = ?1",
params![artifact_id],
)?;
self.index_dirty.store(true, std::sync::atomic::Ordering::Release);
let cache_dir = self.db_path.with_extension("sqlite.idx");
if cache_dir.exists() {
let _ = std::fs::remove_dir_all(&cache_dir);
}
Ok(spans_deleted)
}
pub fn search_spans(&self, query: &str, limit: usize) -> Result<Vec<Span>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, artifact_id, start_line, end_line, text,
embedding, embedding_model, token_count, metadata
FROM spans
WHERE text LIKE ?1
LIMIT ?2",
)?;
let pattern = format!("%{}%", query);
let spans = stmt
.query_map(params![pattern, limit as i64], |row| {
Ok(Span {
id: row.get(0)?,
artifact_id: row.get(1)?,
start_line: row.get::<_, i64>(2)? as usize,
end_line: row.get::<_, i64>(3)? as usize,
text: row.get(4)?,
embedding: row
.get::<_, Option<Vec<u8>>>(5)?
.map(|bytes| deserialize_embedding(&bytes)),
embedding_model: row.get(6)?,
token_count: row.get::<_, i64>(7)? as usize,
metadata: row
.get::<_, Option<String>>(8)?
.and_then(|s| serde_json::from_str(&s).ok()),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(spans)
}
pub fn get_stats(&self) -> Result<(usize, usize, usize)> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let artifacts_count: i64 = conn.query_row("SELECT COUNT(*) FROM artifacts", [], |row| {
row.get(0)
})?;
let spans_count: i64 = conn.query_row("SELECT COUNT(*) FROM spans", [], |row| row.get(0))?;
let total_tokens: i64 = conn
.query_row("SELECT COALESCE(SUM(token_count), 0) FROM spans", [], |row| {
row.get(0)
})?;
Ok((
artifacts_count as usize,
spans_count as usize,
total_tokens as usize,
))
}
pub fn clear(&self) -> Result<()> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
conn.execute("DELETE FROM spans", [])?;
conn.execute("DELETE FROM artifacts", [])?;
let mut cached = self.vector_index.write()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Index lock poisoned: {}", e)))?;
*cached = None;
self.index_dirty.store(true, Ordering::Release);
let _ = std::fs::remove_dir_all(self.get_index_cache_dir());
Ok(())
}
pub fn create_session(&self, user_id: Option<&str>, title: Option<&str>) -> Result<Session> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
conn.execute(
"INSERT INTO sessions (id, user_id, title, metadata, created_at, updated_at, last_message_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
user_id,
title,
None::<String>, now.to_rfc3339(),
now.to_rfc3339(),
None::<String>, ],
)?;
Ok(Session {
id,
user_id: user_id.map(|s| s.to_string()),
title: title.map(|s| s.to_string()),
metadata: None,
created_at: now,
updated_at: now,
last_message_at: None,
})
}
pub fn get_session(&self, session_id: &str) -> Result<Option<Session>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, user_id, title, metadata, created_at, updated_at, last_message_at
FROM sessions WHERE id = ?1",
)?;
let session = stmt.query_row(params![session_id], |row| {
Ok(Session {
id: row.get(0)?,
user_id: row.get(1)?,
title: row.get(2)?,
metadata: row.get::<_, Option<String>>(3)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(4)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
updated_at: row.get::<_, String>(5)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
last_message_at: row.get::<_, Option<String>>(6)?
.and_then(|s| s.parse().ok()),
})
}).optional()?;
Ok(session)
}
pub fn list_sessions(&self, user_id: Option<&str>, limit: Option<usize>) -> Result<Vec<Session>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let limit_val = limit.unwrap_or(100) as i64;
let mut sessions = Vec::new();
if let Some(uid) = user_id {
let mut stmt = conn.prepare(
"SELECT id, user_id, title, metadata, created_at, updated_at, last_message_at
FROM sessions WHERE user_id = ?1
ORDER BY updated_at DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![uid, limit_val], |row| {
Ok(Session {
id: row.get(0)?,
user_id: row.get(1)?,
title: row.get(2)?,
metadata: row.get::<_, Option<String>>(3)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(4)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
updated_at: row.get::<_, String>(5)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
last_message_at: row.get::<_, Option<String>>(6)?
.and_then(|s| s.parse().ok()),
})
})?;
for row in rows {
sessions.push(row?);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, user_id, title, metadata, created_at, updated_at, last_message_at
FROM sessions
ORDER BY updated_at DESC
LIMIT ?1"
)?;
let rows = stmt.query_map(params![limit_val], |row| {
Ok(Session {
id: row.get(0)?,
user_id: row.get(1)?,
title: row.get(2)?,
metadata: row.get::<_, Option<String>>(3)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(4)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
updated_at: row.get::<_, String>(5)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
last_message_at: row.get::<_, Option<String>>(6)?
.and_then(|s| s.parse().ok()),
})
})?;
for row in rows {
sessions.push(row?);
}
}
Ok(sessions)
}
pub fn update_session(
&self,
session_id: &str,
title: Option<&str>,
metadata: Option<&serde_json::Value>,
) -> Result<()> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let now = chrono::Utc::now();
conn.execute(
"UPDATE sessions
SET title = COALESCE(?1, title),
metadata = COALESCE(?2, metadata),
updated_at = ?3
WHERE id = ?4",
params![
title,
metadata.map(|m| m.to_string()),
now.to_rfc3339(),
session_id,
],
)?;
Ok(())
}
pub fn delete_session(&self, session_id: &str) -> Result<()> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
Ok(())
}
pub fn add_message(
&self,
session_id: &str,
role: MessageRole,
content: &str,
metadata: Option<&serde_json::Value>,
) -> Result<Message> {
let mut conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let tx = conn.transaction()?;
let sequence_number: i64 = tx.query_row(
"SELECT COALESCE(MAX(sequence_number), -1) + 1 FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
tx.execute(
"INSERT INTO messages (id, session_id, role, content, metadata, sequence_number, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
session_id,
role.as_str(),
content,
metadata.map(|m| m.to_string()),
sequence_number,
now.to_rfc3339(),
],
)?;
tx.execute(
"UPDATE sessions
SET last_message_at = ?1, updated_at = ?1
WHERE id = ?2",
params![now.to_rfc3339(), session_id],
)?;
tx.commit()?;
Ok(Message {
id,
session_id: session_id.to_string(),
role,
content: content.to_string(),
metadata: metadata.cloned(),
sequence_number: sequence_number as usize,
created_at: now,
})
}
pub fn get_messages(&self, session_id: &str, limit: Option<usize>) -> Result<Vec<Message>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut messages = Vec::new();
if let Some(lim) = limit {
let mut stmt = conn.prepare(
"SELECT id, session_id, role, content, metadata, sequence_number, created_at
FROM messages
WHERE session_id = ?1
ORDER BY sequence_number ASC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![session_id, lim as i64], |row| {
let role_str: String = row.get(2)?;
let role = MessageRole::from_str(&role_str)
.unwrap_or(MessageRole::User);
Ok(Message {
id: row.get(0)?,
session_id: row.get(1)?,
role,
content: row.get(3)?,
metadata: row.get::<_, Option<String>>(4)?
.and_then(|s| serde_json::from_str(&s).ok()),
sequence_number: row.get::<_, i64>(5)? as usize,
created_at: row.get::<_, String>(6)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?;
for row in rows {
messages.push(row?);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, session_id, role, content, metadata, sequence_number, created_at
FROM messages
WHERE session_id = ?1
ORDER BY sequence_number ASC"
)?;
let rows = stmt.query_map(params![session_id], |row| {
let role_str: String = row.get(2)?;
let role = MessageRole::from_str(&role_str)
.unwrap_or(MessageRole::User);
Ok(Message {
id: row.get(0)?,
session_id: row.get(1)?,
role,
content: row.get(3)?,
metadata: row.get::<_, Option<String>>(4)?
.and_then(|s| serde_json::from_str(&s).ok()),
sequence_number: row.get::<_, i64>(5)? as usize,
created_at: row.get::<_, String>(6)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?;
for row in rows {
messages.push(row?);
}
}
Ok(messages)
}
pub fn associate_working_set(
&self,
session_id: &str,
message_id: Option<&str>,
working_set: &WorkingSet,
query: &str,
config: &CompilerConfig,
) -> Result<SessionWorkingSet> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let id = uuid::Uuid::new_v4().to_string();
let working_set_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now();
conn.execute(
"INSERT INTO session_working_sets (id, session_id, message_id, working_set_id, query, config, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
session_id,
message_id,
working_set_id,
query,
serde_json::to_string(config)?,
now.to_rfc3339(),
],
)?;
Ok(SessionWorkingSet {
id,
session_id: session_id.to_string(),
message_id: message_id.map(|s| s.to_string()),
working_set: working_set.clone(),
query: query.to_string(),
config: config.clone(),
created_at: now,
})
}
pub fn get_session_full(&self, session_id: &str) -> Result<Option<SessionWithMessages>> {
let session = self.get_session(session_id)?;
if session.is_none() {
return Ok(None);
}
let session = session.unwrap();
let messages = self.get_messages(session_id, None)?;
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, session_id, message_id, working_set_id, query, config, created_at
FROM session_working_sets
WHERE session_id = ?1
ORDER BY created_at ASC",
)?;
let working_sets = stmt.query_map(params![session_id], |row| {
let config_str: String = row.get(5)?;
let config: CompilerConfig = serde_json::from_str(&config_str)
.unwrap_or_default();
let working_set = WorkingSet {
text: String::new(),
spans: Vec::new(),
citations: Vec::new(),
tokens_used: 0,
query: row.get::<_, String>(4)?,
compilation_time_ms: 0,
manifest: None,
explain: None,
};
Ok(SessionWorkingSet {
id: row.get(0)?,
session_id: row.get(1)?,
message_id: row.get(2)?,
working_set,
query: row.get(4)?,
config,
created_at: row.get::<_, String>(6)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(Some(SessionWithMessages {
session,
messages,
working_sets,
}))
}
pub fn register_agent(&self, agent: &crate::types::Agent) -> Result<crate::types::Agent> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
conn.execute(
"INSERT OR REPLACE INTO agents (id, name, role, model, system_prompt, did, capabilities, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
agent.id,
agent.name,
agent.role,
agent.model,
agent.system_prompt,
agent.did,
agent.capabilities.as_ref().map(|c| serde_json::to_string(c).ok()).flatten(),
agent.metadata.as_ref().map(|m| m.to_string()),
agent.created_at.to_rfc3339(),
],
)?;
Ok(agent.clone())
}
pub fn get_agent(&self, agent_id: &str) -> Result<Option<crate::types::Agent>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, name, role, model, system_prompt, did, capabilities, metadata, created_at
FROM agents WHERE id = ?1"
)?;
let result = stmt.query_row(params![agent_id], |row| {
Ok(crate::types::Agent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
model: row.get(3)?,
system_prompt: row.get(4)?,
did: row.get(5)?,
capabilities: row.get::<_, Option<String>>(6)?
.and_then(|s| serde_json::from_str(&s).ok()),
metadata: row.get::<_, Option<String>>(7)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(8)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
});
match result {
Ok(agent) => Ok(Some(agent)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn get_agent_by_name(&self, name: &str) -> Result<Option<crate::types::Agent>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, name, role, model, system_prompt, did, capabilities, metadata, created_at
FROM agents WHERE name = ?1"
)?;
let result = stmt.query_row(params![name], |row| {
Ok(crate::types::Agent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
model: row.get(3)?,
system_prompt: row.get(4)?,
did: row.get(5)?,
capabilities: row.get::<_, Option<String>>(6)?
.and_then(|s| serde_json::from_str(&s).ok()),
metadata: row.get::<_, Option<String>>(7)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(8)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
});
match result {
Ok(agent) => Ok(Some(agent)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn list_agents(&self) -> Result<Vec<crate::types::Agent>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT id, name, role, model, system_prompt, did, capabilities, metadata, created_at
FROM agents ORDER BY created_at"
)?;
let agents = stmt.query_map([], |row| {
Ok(crate::types::Agent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
model: row.get(3)?,
system_prompt: row.get(4)?,
did: row.get(5)?,
capabilities: row.get::<_, Option<String>>(6)?
.and_then(|s| serde_json::from_str(&s).ok()),
metadata: row.get::<_, Option<String>>(7)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(8)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(agents)
}
pub fn add_agent_relation(
&self,
session_id: &str,
message_id: &str,
from_agent_id: &str,
target_message_id: &str,
stance: crate::types::Stance,
) -> Result<crate::types::AgentRelation> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let to_agent_id: String = conn.query_row(
"SELECT json_extract(metadata, '$.agent_id') FROM messages WHERE id = ?1",
params![target_message_id],
|row| row.get(0),
).unwrap_or_else(|_| "unknown".to_string());
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
conn.execute(
"INSERT INTO agent_relations (id, session_id, message_id, from_agent_id, to_agent_id, stance, target_message_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id,
session_id,
message_id,
from_agent_id,
to_agent_id,
stance.as_str(),
target_message_id,
now.to_rfc3339(),
],
)?;
Ok(crate::types::AgentRelation {
id,
session_id: session_id.to_string(),
message_id: message_id.to_string(),
from_agent_id: from_agent_id.to_string(),
to_agent_id,
stance,
target_message_id: target_message_id.to_string(),
created_at: now,
})
}
pub fn get_agent_relations(&self, session_id: &str) -> Result<crate::types::AgentRelationSummary> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT
ar.id, ar.message_id, ar.target_message_id, ar.stance,
fa.name as from_name, fa.model as from_model,
ta.name as to_name, ta.model as to_model
FROM agent_relations ar
LEFT JOIN agents fa ON ar.from_agent_id = fa.id
LEFT JOIN agents ta ON ar.to_agent_id = ta.id
WHERE ar.session_id = ?1
ORDER BY ar.created_at"
)?;
let mut agreements = Vec::new();
let mut disagreements = Vec::new();
let mut questions = Vec::new();
let rows = stmt.query_map(params![session_id], |row| {
Ok((
row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, Option<String>>(4)?.unwrap_or_else(|| "unknown".to_string()), row.get::<_, Option<String>>(5)?.unwrap_or_else(|| "?".to_string()), row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "unknown".to_string()), row.get::<_, Option<String>>(7)?.unwrap_or_else(|| "?".to_string()), ))
})?;
for row in rows {
let (message_id, target_message_id, stance, from_name, from_model, to_name, to_model) = row?;
let entry = crate::types::AgentRelationEntry {
from_agent: from_name,
from_model,
to_agent: to_name,
to_model,
message_id,
target_message_id,
};
match stance.as_str() {
"agree" => agreements.push(entry),
"disagree" => disagreements.push(entry),
"question" => questions.push(entry),
_ => {} }
}
Ok(crate::types::AgentRelationSummary {
agreements,
disagreements,
questions,
})
}
pub fn get_session_agents(&self, session_id: &str) -> Result<Vec<crate::types::Agent>> {
let conn = self.conn.lock()
.map_err(|e| crate::types::Error::Other(anyhow::anyhow!("Database lock poisoned: {}", e)))?;
let mut stmt = conn.prepare(
"SELECT DISTINCT a.id, a.name, a.role, a.model, a.system_prompt, a.did, a.capabilities, a.metadata, a.created_at
FROM agents a
INNER JOIN messages m ON json_extract(m.metadata, '$.agent_id') = a.id
WHERE m.session_id = ?1
ORDER BY a.name"
)?;
let agents = stmt.query_map(params![session_id], |row| {
Ok(crate::types::Agent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
model: row.get(3)?,
system_prompt: row.get(4)?,
did: row.get(5)?,
capabilities: row.get::<_, Option<String>>(6)?
.and_then(|s| serde_json::from_str(&s).ok()),
metadata: row.get::<_, Option<String>>(7)?
.and_then(|s| serde_json::from_str(&s).ok()),
created_at: row.get::<_, String>(8)?
.parse()
.unwrap_or_else(|_| chrono::Utc::now()),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(agents)
}
pub async fn as_storage_backend(&self) -> Result<crate::storage::SqliteBackend> {
crate::storage::SqliteBackend::new(&self.db_path).await
}
}
fn serialize_embedding(embedding: &[f32]) -> Vec<u8> {
embedding.iter().flat_map(|f| f.to_le_bytes()).collect()
}
fn deserialize_embedding(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_database_creation() {
let db = Database::new(":memory:").unwrap();
let (artifacts, spans, tokens) = db.get_stats().unwrap();
assert_eq!(artifacts, 0);
assert_eq!(spans, 0);
assert_eq!(tokens, 0);
}
#[test]
fn test_insert_artifact() {
let db = Database::new(":memory:").unwrap();
let artifact = Artifact {
id: Uuid::new_v4().to_string(),
path: "test.txt".to_string(),
content: "Test content".to_string(),
content_hash: "hash123".to_string(),
metadata: None,
created_at: chrono::Utc::now(),
};
db.insert_artifact(&artifact).unwrap();
let (count, _, _) = db.get_stats().unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_embedding_serialization() {
let original = vec![1.0, 2.5, -3.14, 0.0];
let bytes = serialize_embedding(&original);
let restored = deserialize_embedding(&bytes);
assert_eq!(original.len(), restored.len());
for (a, b) in original.iter().zip(restored.iter()) {
assert!((a - b).abs() < 0.0001);
}
}
#[test]
fn test_create_session() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user123"), Some("Test Session")).unwrap();
assert!(!session.id.is_empty());
assert_eq!(session.user_id, Some("user123".to_string()));
assert_eq!(session.title, Some("Test Session".to_string()));
assert!(session.metadata.is_none());
assert!(session.last_message_at.is_none());
}
#[test]
fn test_get_session() {
let db = Database::new(":memory:").unwrap();
let created = db.create_session(Some("user456"), Some("Another Session")).unwrap();
let retrieved = db.get_session(&created.id).unwrap();
assert!(retrieved.is_some());
let session = retrieved.unwrap();
assert_eq!(session.id, created.id);
assert_eq!(session.user_id, created.user_id);
assert_eq!(session.title, created.title);
}
#[test]
fn test_get_nonexistent_session() {
let db = Database::new(":memory:").unwrap();
let result = db.get_session("nonexistent-id").unwrap();
assert!(result.is_none());
}
#[test]
fn test_list_sessions() {
let db = Database::new(":memory:").unwrap();
db.create_session(Some("user1"), Some("Session 1")).unwrap();
db.create_session(Some("user1"), Some("Session 2")).unwrap();
db.create_session(Some("user2"), Some("Session 3")).unwrap();
let all_sessions = db.list_sessions(None, None).unwrap();
assert_eq!(all_sessions.len(), 3);
let user1_sessions = db.list_sessions(Some("user1"), None).unwrap();
assert_eq!(user1_sessions.len(), 2);
let user2_sessions = db.list_sessions(Some("user2"), None).unwrap();
assert_eq!(user2_sessions.len(), 1);
let limited = db.list_sessions(None, Some(2)).unwrap();
assert_eq!(limited.len(), 2);
}
#[test]
fn test_update_session() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Original Title")).unwrap();
db.update_session(&session.id, Some("Updated Title"), None).unwrap();
let updated = db.get_session(&session.id).unwrap().unwrap();
assert_eq!(updated.title, Some("Updated Title".to_string()));
let metadata = serde_json::json!({"key": "value"});
db.update_session(&session.id, None, Some(&metadata)).unwrap();
let updated2 = db.get_session(&session.id).unwrap().unwrap();
assert!(updated2.metadata.is_some());
assert_eq!(updated2.metadata.unwrap()["key"], "value");
}
#[test]
fn test_delete_session() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("To Delete")).unwrap();
assert!(db.get_session(&session.id).unwrap().is_some());
db.delete_session(&session.id).unwrap();
assert!(db.get_session(&session.id).unwrap().is_none());
}
#[test]
fn test_add_message() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Chat Session")).unwrap();
let msg1 = db.add_message(&session.id, MessageRole::User, "Hello", None).unwrap();
assert_eq!(msg1.sequence_number, 0);
assert_eq!(msg1.content, "Hello");
assert_eq!(msg1.role.as_str(), "user");
let msg2 = db.add_message(&session.id, MessageRole::Assistant, "Hi there!", None).unwrap();
assert_eq!(msg2.sequence_number, 1);
assert_eq!(msg2.content, "Hi there!");
assert_eq!(msg2.role.as_str(), "assistant");
let updated_session = db.get_session(&session.id).unwrap().unwrap();
assert!(updated_session.last_message_at.is_some());
}
#[test]
fn test_add_message_with_metadata() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Chat Session")).unwrap();
let metadata = serde_json::json!({"tool": "search", "query": "test"});
let msg = db.add_message(&session.id, MessageRole::Tool, "Result", Some(&metadata)).unwrap();
assert!(msg.metadata.is_some());
assert_eq!(msg.metadata.unwrap()["tool"], "search");
}
#[test]
fn test_get_messages() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Chat Session")).unwrap();
db.add_message(&session.id, MessageRole::User, "Message 1", None).unwrap();
db.add_message(&session.id, MessageRole::Assistant, "Message 2", None).unwrap();
db.add_message(&session.id, MessageRole::User, "Message 3", None).unwrap();
let messages = db.get_messages(&session.id, None).unwrap();
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].sequence_number, 0);
assert_eq!(messages[1].sequence_number, 1);
assert_eq!(messages[2].sequence_number, 2);
let limited = db.get_messages(&session.id, Some(2)).unwrap();
assert_eq!(limited.len(), 2);
}
#[test]
fn test_message_ordering() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Chat Session")).unwrap();
db.add_message(&session.id, MessageRole::User, "First", None).unwrap();
db.add_message(&session.id, MessageRole::Assistant, "Second", None).unwrap();
db.add_message(&session.id, MessageRole::User, "Third", None).unwrap();
let messages = db.get_messages(&session.id, None).unwrap();
assert_eq!(messages[0].content, "First");
assert_eq!(messages[1].content, "Second");
assert_eq!(messages[2].content, "Third");
for (i, msg) in messages.iter().enumerate() {
assert_eq!(msg.sequence_number, i);
}
}
#[test]
fn test_associate_working_set() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Chat Session")).unwrap();
let message = db.add_message(&session.id, MessageRole::User, "Query", None).unwrap();
let working_set = WorkingSet {
text: "Test context".to_string(),
spans: Vec::new(),
citations: Vec::new(),
tokens_used: 100,
query: "test query".to_string(),
compilation_time_ms: 50,
manifest: None,
explain: None,
};
let config = CompilerConfig::default();
let sws = db.associate_working_set(
&session.id,
Some(&message.id),
&working_set,
"test query",
&config,
).unwrap();
assert_eq!(sws.session_id, session.id);
assert_eq!(sws.message_id, Some(message.id));
assert_eq!(sws.query, "test query");
assert_eq!(sws.working_set.text, "Test context");
}
#[test]
fn test_get_session_full() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("Full Session")).unwrap();
let msg1 = db.add_message(&session.id, MessageRole::User, "Hello", None).unwrap();
db.add_message(&session.id, MessageRole::Assistant, "Hi!", None).unwrap();
let working_set = WorkingSet {
text: "Context".to_string(),
spans: Vec::new(),
citations: Vec::new(),
tokens_used: 50,
query: "test".to_string(),
compilation_time_ms: 25,
manifest: None,
explain: None,
};
db.associate_working_set(
&session.id,
Some(&msg1.id),
&working_set,
"test",
&CompilerConfig::default(),
).unwrap();
let full = db.get_session_full(&session.id).unwrap();
assert!(full.is_some());
let swm = full.unwrap();
assert_eq!(swm.session.id, session.id);
assert_eq!(swm.messages.len(), 2);
assert_eq!(swm.working_sets.len(), 1);
}
#[test]
fn test_delete_session_cascade() {
let db = Database::new(":memory:").unwrap();
let session = db.create_session(Some("user1"), Some("To Delete")).unwrap();
db.add_message(&session.id, MessageRole::User, "Message 1", None).unwrap();
db.add_message(&session.id, MessageRole::Assistant, "Message 2", None).unwrap();
let messages_before = db.get_messages(&session.id, None).unwrap();
assert_eq!(messages_before.len(), 2);
db.delete_session(&session.id).unwrap();
let messages_after = db.get_messages(&session.id, None).unwrap();
assert_eq!(messages_after.len(), 0);
}
#[test]
fn test_message_role_conversion() {
assert_eq!(MessageRole::User.as_str(), "user");
assert_eq!(MessageRole::Assistant.as_str(), "assistant");
assert_eq!(MessageRole::System.as_str(), "system");
assert_eq!(MessageRole::Tool.as_str(), "tool");
assert!(matches!(MessageRole::from_str("user").unwrap(), MessageRole::User));
assert!(matches!(MessageRole::from_str("assistant").unwrap(), MessageRole::Assistant));
assert!(matches!(MessageRole::from_str("system").unwrap(), MessageRole::System));
assert!(matches!(MessageRole::from_str("tool").unwrap(), MessageRole::Tool));
assert!(MessageRole::from_str("invalid").is_err());
}
}