1use crate::chat::message::{ContentBlock, Role};
38use rusqlite::{params, Connection};
39use serde::{Deserialize, Serialize};
40use std::sync::{Arc, Mutex};
41use std::time::{SystemTime, UNIX_EPOCH};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Thread {
45 pub id: String,
46 pub workspace_id: String,
47 pub title: String,
48 pub created_at: i64,
49 pub updated_at: i64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub(crate) struct StoredMessage {
54 pub thread_id: String,
55 pub seq: i64,
56 pub role: Role,
57 pub content: Vec<ContentBlock>,
58 pub created_at: i64,
59}
60
61#[derive(Debug, thiserror::Error)]
62pub(crate) enum StorageError {
63 #[error("sqlite error: {0}")]
64 Sqlite(String),
65 #[error("not found")]
66 NotFound,
67 #[error("serde error: {0}")]
68 Serde(String),
69 #[error("blocking task join error: {0}")]
72 Join(String),
73}
74
75impl From<rusqlite::Error> for StorageError {
76 fn from(e: rusqlite::Error) -> Self {
77 Self::Sqlite(e.to_string())
78 }
79}
80impl From<serde_json::Error> for StorageError {
81 fn from(e: serde_json::Error) -> Self {
82 Self::Serde(e.to_string())
83 }
84}
85impl From<tokio::task::JoinError> for StorageError {
86 fn from(e: tokio::task::JoinError) -> Self {
87 Self::Join(e.to_string())
88 }
89}
90
91#[derive(Clone)]
92pub(crate) struct ChatStorage {
93 db: Arc<Mutex<Connection>>,
94}
95
96fn now_ms() -> i64 {
97 SystemTime::now()
102 .duration_since(UNIX_EPOCH)
103 .expect("system clock must be at or after UNIX_EPOCH")
104 .as_millis() as i64
105}
106
107fn role_to_str(role: Role) -> &'static str {
108 match role {
109 Role::User => "user",
110 Role::Assistant => "assistant",
111 }
112}
113
114fn role_from_str(s: &str) -> Result<Role, StorageError> {
115 match s {
116 "user" => Ok(Role::User),
117 "assistant" => Ok(Role::Assistant),
118 other => Err(StorageError::Sqlite(format!("unknown role: {other}"))),
119 }
120}
121
122impl ChatStorage {
123 pub(crate) fn new(db: Arc<Mutex<Connection>>) -> Self {
124 Self { db }
125 }
126
127 async fn with_conn<F, R>(&self, f: F) -> Result<R, StorageError>
131 where
132 F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
133 R: Send + 'static,
134 {
135 let db = self.db.clone();
136 tokio::task::spawn_blocking(move || {
137 let conn = db
138 .lock()
139 .map_err(|e| StorageError::Sqlite(format!("mutex poisoned: {e}")))?;
140 f(&conn)
141 })
142 .await?
143 }
144
145 async fn with_conn_mut<F, R>(&self, f: F) -> Result<R, StorageError>
148 where
149 F: FnOnce(&mut Connection) -> Result<R, StorageError> + Send + 'static,
150 R: Send + 'static,
151 {
152 let db = self.db.clone();
153 tokio::task::spawn_blocking(move || {
154 let mut conn = db
155 .lock()
156 .map_err(|e| StorageError::Sqlite(format!("mutex poisoned: {e}")))?;
157 f(&mut conn)
158 })
159 .await?
160 }
161
162 pub(crate) fn init(conn: &Connection) -> Result<(), StorageError> {
168 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
170 conn.execute(
171 "CREATE TABLE IF NOT EXISTS chat_threads (
172 id TEXT PRIMARY KEY,
173 workspace_id TEXT NOT NULL,
174 title TEXT NOT NULL DEFAULT '',
175 created_at INTEGER NOT NULL,
176 updated_at INTEGER NOT NULL
177 )",
178 [],
179 )?;
180 conn.execute(
181 "CREATE INDEX IF NOT EXISTS idx_chat_threads_ws
182 ON chat_threads(workspace_id, updated_at DESC)",
183 [],
184 )?;
185 conn.execute(
186 "CREATE TABLE IF NOT EXISTS chat_messages (
187 thread_id TEXT NOT NULL,
188 seq INTEGER NOT NULL,
189 role TEXT NOT NULL,
190 content_json TEXT NOT NULL,
191 created_at INTEGER NOT NULL,
192 PRIMARY KEY (thread_id, seq),
193 FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE
194 )",
195 [],
196 )?;
197 Ok(())
198 }
199
200 #[allow(dead_code)]
201 pub(crate) async fn list_threads(
202 &self,
203 workspace_id: &str,
204 ) -> Result<Vec<Thread>, StorageError> {
205 let workspace_id = workspace_id.to_string();
206 self.with_conn(move |conn| {
207 let mut stmt = conn.prepare(
208 "SELECT id, workspace_id, title, created_at, updated_at
209 FROM chat_threads
210 WHERE workspace_id = ?1
211 ORDER BY updated_at DESC",
212 )?;
213 let rows = stmt.query_map(params![workspace_id], |row| {
214 Ok(Thread {
215 id: row.get(0)?,
216 workspace_id: row.get(1)?,
217 title: row.get(2)?,
218 created_at: row.get(3)?,
219 updated_at: row.get(4)?,
220 })
221 })?;
222 let mut out = Vec::new();
223 for r in rows {
224 out.push(r?);
225 }
226 Ok(out)
227 })
228 .await
229 }
230
231 pub(crate) async fn create_thread(
232 &self,
233 workspace_id: &str,
234 title: &str,
235 ) -> Result<Thread, StorageError> {
236 let workspace_id = workspace_id.to_string();
237 let title = title.to_string();
238 self.with_conn(move |conn| {
239 let id = uuid::Uuid::new_v4().to_string();
240 let now = now_ms();
241 conn.execute(
242 "INSERT INTO chat_threads (id, workspace_id, title, created_at, updated_at)
243 VALUES (?1, ?2, ?3, ?4, ?4)",
244 params![id, workspace_id, title, now],
245 )?;
246 Ok(Thread {
247 id,
248 workspace_id,
249 title,
250 created_at: now,
251 updated_at: now,
252 })
253 })
254 .await
255 }
256
257 pub(crate) async fn get_thread(&self, thread_id: &str) -> Result<Thread, StorageError> {
258 let thread_id = thread_id.to_string();
259 self.with_conn(move |conn| {
260 let mut stmt = conn.prepare(
261 "SELECT id, workspace_id, title, created_at, updated_at
262 FROM chat_threads
263 WHERE id = ?1",
264 )?;
265 let mut rows = stmt.query(params![thread_id])?;
266 match rows.next()? {
267 Some(row) => Ok(Thread {
268 id: row.get(0)?,
269 workspace_id: row.get(1)?,
270 title: row.get(2)?,
271 created_at: row.get(3)?,
272 updated_at: row.get(4)?,
273 }),
274 None => Err(StorageError::NotFound),
275 }
276 })
277 .await
278 }
279
280 #[allow(dead_code)]
281 pub(crate) async fn rename_thread(
282 &self,
283 thread_id: &str,
284 title: &str,
285 ) -> Result<(), StorageError> {
286 let thread_id = thread_id.to_string();
287 let title = title.to_string();
288 self.with_conn(move |conn| {
289 let now = now_ms();
290 let n = conn.execute(
291 "UPDATE chat_threads SET title = ?1, updated_at = ?2 WHERE id = ?3",
292 params![title, now, thread_id],
293 )?;
294 if n == 0 {
295 return Err(StorageError::NotFound);
296 }
297 Ok(())
298 })
299 .await
300 }
301
302 pub(crate) async fn delete_thread(&self, thread_id: &str) -> Result<(), StorageError> {
303 let thread_id = thread_id.to_string();
304 self.with_conn(move |conn| {
305 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
308 let n = conn.execute("DELETE FROM chat_threads WHERE id = ?1", params![thread_id])?;
309 if n == 0 {
310 return Err(StorageError::NotFound);
311 }
312 Ok(())
313 })
314 .await
315 }
316
317 pub(crate) async fn list_messages(
318 &self,
319 thread_id: &str,
320 ) -> Result<Vec<StoredMessage>, StorageError> {
321 let thread_id = thread_id.to_string();
322 self.with_conn(move |conn| {
323 let mut stmt = conn.prepare_cached(
324 "SELECT thread_id, seq, role, content_json, created_at
325 FROM chat_messages
326 WHERE thread_id = ?1
327 ORDER BY seq ASC",
328 )?;
329 let rows = stmt.query_map(params![thread_id], |row| {
330 let thread_id: String = row.get(0)?;
331 let seq: i64 = row.get(1)?;
332 let role_s: String = row.get(2)?;
333 let content_json: String = row.get(3)?;
334 let created_at: i64 = row.get(4)?;
335 Ok((thread_id, seq, role_s, content_json, created_at))
336 })?;
337 let mut out = Vec::new();
338 for r in rows {
339 let (thread_id, seq, role_s, content_json, created_at) = r?;
340 let role = role_from_str(&role_s)?;
341 let content: Vec<ContentBlock> = serde_json::from_str(&content_json)?;
342 out.push(StoredMessage {
343 thread_id,
344 seq,
345 role,
346 content,
347 created_at,
348 });
349 }
350 Ok(out)
351 })
352 .await
353 }
354
355 pub(crate) async fn append_message(
356 &self,
357 thread_id: &str,
358 role: Role,
359 content: &[ContentBlock],
360 ) -> Result<StoredMessage, StorageError> {
361 let thread_id = thread_id.to_string();
362 let content_vec = content.to_vec();
363 let content_json = serde_json::to_string(content)?;
364 let role_s = role_to_str(role);
365
366 self.with_conn_mut(move |conn| {
367 let now = now_ms();
368
369 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
372
373 let exists: i64 = tx.query_row(
377 "SELECT COUNT(1) FROM chat_threads WHERE id = ?1",
378 params![thread_id],
379 |row| row.get(0),
380 )?;
381 if exists == 0 {
382 return Err(StorageError::NotFound);
383 }
384
385 let next_seq: i64 = tx.query_row(
386 "SELECT COALESCE(MAX(seq) + 1, 0) FROM chat_messages WHERE thread_id = ?1",
387 params![thread_id],
388 |row| row.get(0),
389 )?;
390
391 {
392 let mut stmt = tx.prepare_cached(
393 "INSERT INTO chat_messages (thread_id, seq, role, content_json, created_at)
394 VALUES (?1, ?2, ?3, ?4, ?5)",
395 )?;
396 stmt.execute(params![thread_id, next_seq, role_s, content_json, now])?;
397 }
398
399 tx.execute(
400 "UPDATE chat_threads SET updated_at = ?1 WHERE id = ?2",
401 params![now, thread_id],
402 )?;
403
404 tx.commit()?;
405
406 Ok(StoredMessage {
407 thread_id,
408 seq: next_seq,
409 role,
410 content: content_vec,
411 created_at: now,
412 })
413 })
414 .await
415 }
416
417 pub(crate) async fn list_thread_summaries(
420 &self,
421 workspace_id: &str,
422 ) -> Result<Vec<ThreadSummary>, StorageError> {
423 let workspace_id = workspace_id.to_string();
424 self.with_conn(move |conn| {
425 let mut stmt = conn.prepare(
426 "SELECT t.id, t.title, t.created_at, t.updated_at,
427 COALESCE(COUNT(m.seq), 0) AS message_count
428 FROM chat_threads t
429 LEFT JOIN chat_messages m ON m.thread_id = t.id
430 WHERE t.workspace_id = ?1
431 GROUP BY t.id
432 ORDER BY t.updated_at DESC",
433 )?;
434 let rows = stmt.query_map(params![workspace_id], |row| {
435 Ok(ThreadSummary {
436 id: row.get(0)?,
437 title: row.get(1)?,
438 created_at: row.get(2)?,
439 updated_at: row.get(3)?,
440 message_count: row.get(4)?,
441 })
442 })?;
443 let mut out = Vec::new();
444 for r in rows {
445 out.push(r?);
446 }
447 Ok(out)
448 })
449 .await
450 }
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct ThreadSummary {
456 pub id: String,
457 pub title: String,
458 pub created_at: i64,
459 pub updated_at: i64,
460 pub message_count: i64,
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::chat::message::ContentBlock;
467 use std::sync::{Arc, Mutex};
468 use tempfile::NamedTempFile;
469
470 fn fresh_storage() -> (ChatStorage, NamedTempFile) {
471 let tmp = NamedTempFile::new().expect("tempfile");
472 let conn = Connection::open(tmp.path()).expect("open db");
473 ChatStorage::init(&conn).expect("init");
474 (ChatStorage::new(Arc::new(Mutex::new(conn))), tmp)
475 }
476
477 #[tokio::test]
478 async fn create_list_and_delete_cascades() {
479 let (store, _tmp) = fresh_storage();
480
481 let t1 = store.create_thread("ws1", "first").await.unwrap();
482 std::thread::sleep(std::time::Duration::from_millis(2));
484 let t2 = store.create_thread("ws1", "second").await.unwrap();
485 let _other = store.create_thread("ws2", "other-ws").await.unwrap();
486
487 let listed = store.list_threads("ws1").await.unwrap();
488 assert_eq!(listed.len(), 2);
489 assert_eq!(listed[0].id, t2.id);
491 assert_eq!(listed[1].id, t1.id);
492
493 store
496 .append_message(
497 &t1.id,
498 Role::User,
499 &[ContentBlock::Text { text: "hi".into() }],
500 )
501 .await
502 .unwrap();
503 store
504 .append_message(
505 &t1.id,
506 Role::Assistant,
507 &[ContentBlock::Text {
508 text: "hello".into(),
509 }],
510 )
511 .await
512 .unwrap();
513 assert_eq!(store.list_messages(&t1.id).await.unwrap().len(), 2);
514
515 store.delete_thread(&t1.id).await.unwrap();
516 assert_eq!(store.list_messages(&t1.id).await.unwrap().len(), 0);
517 assert!(matches!(
518 store.get_thread(&t1.id).await,
519 Err(StorageError::NotFound)
520 ));
521 assert!(matches!(
522 store.delete_thread(&t1.id).await,
523 Err(StorageError::NotFound)
524 ));
525
526 assert!(store.get_thread(&t2.id).await.is_ok());
528
529 let summaries = store.list_thread_summaries("ws1").await.unwrap();
531 assert_eq!(summaries.len(), 1);
532 assert_eq!(summaries[0].id, t2.id);
533 assert_eq!(summaries[0].message_count, 0);
534 }
535
536 #[tokio::test]
537 async fn append_and_list_messages_roundtrip_mixed_blocks() {
538 let (store, _tmp) = fresh_storage();
539 let t = store.create_thread("ws", "tools").await.unwrap();
540
541 let user_blocks = vec![ContentBlock::Text {
542 text: "use the search tool".into(),
543 }];
544 let assistant_blocks = vec![
545 ContentBlock::Text {
546 text: "ok, calling".into(),
547 },
548 ContentBlock::ToolUse {
549 id: "tu_1".into(),
550 name: "search".into(),
551 input: serde_json::json!({"q": "rust sqlite"}),
552 },
553 ];
554 let tool_result_blocks = vec![ContentBlock::ToolResult {
555 tool_use_id: "tu_1".into(),
556 content: "found 42 results".into(),
557 is_error: false,
558 }];
559
560 let m0 = store
561 .append_message(&t.id, Role::User, &user_blocks)
562 .await
563 .unwrap();
564 let m1 = store
565 .append_message(&t.id, Role::Assistant, &assistant_blocks)
566 .await
567 .unwrap();
568 let m2 = store
569 .append_message(&t.id, Role::User, &tool_result_blocks)
570 .await
571 .unwrap();
572
573 assert_eq!(m0.seq, 0);
574 assert_eq!(m1.seq, 1);
575 assert_eq!(m2.seq, 2);
576
577 let listed = store.list_messages(&t.id).await.unwrap();
578 assert_eq!(listed.len(), 3);
579
580 match &listed[0].content[0] {
582 ContentBlock::Text { text } => assert_eq!(text, "use the search tool"),
583 _ => panic!("expected Text block"),
584 }
585 match &listed[1].content[1] {
586 ContentBlock::ToolUse { id, name, input } => {
587 assert_eq!(id, "tu_1");
588 assert_eq!(name, "search");
589 assert_eq!(input["q"], "rust sqlite");
590 }
591 _ => panic!("expected ToolUse block"),
592 }
593 match &listed[2].content[0] {
594 ContentBlock::ToolResult {
595 tool_use_id,
596 content,
597 is_error,
598 } => {
599 assert_eq!(tool_use_id, "tu_1");
600 assert_eq!(content, "found 42 results");
601 assert!(!*is_error);
602 }
603 _ => panic!("expected ToolResult block"),
604 }
605
606 let summaries = store.list_thread_summaries("ws").await.unwrap();
608 assert_eq!(summaries.len(), 1);
609 assert_eq!(summaries[0].message_count, 3);
610
611 let refreshed = store.get_thread(&t.id).await.unwrap();
613 assert!(refreshed.updated_at >= refreshed.created_at);
614 }
615
616 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
617 async fn concurrent_appends_produce_contiguous_seqs() {
618 let (store, _tmp) = fresh_storage();
619 let t = store.create_thread("ws", "race").await.unwrap();
620 let thread_id = t.id.clone();
621
622 const WORKERS: usize = 4;
623 const PER_WORKER: usize = 5;
624
625 let mut handles = Vec::with_capacity(WORKERS);
626 for w in 0..WORKERS {
627 let store = store.clone();
628 let tid = thread_id.clone();
629 handles.push(tokio::spawn(async move {
630 for i in 0..PER_WORKER {
631 let blocks = vec![ContentBlock::Text {
632 text: format!("worker {w} msg {i}"),
633 }];
634 store
635 .append_message(&tid, Role::User, &blocks)
636 .await
637 .expect("append");
638 }
639 }));
640 }
641 for h in handles {
642 h.await.expect("worker panic");
643 }
644
645 let messages = store.list_messages(&thread_id).await.unwrap();
646 let total = WORKERS * PER_WORKER;
647 assert_eq!(messages.len(), total);
648 for (i, m) in messages.iter().enumerate() {
649 assert_eq!(m.seq, i as i64, "seqs must be contiguous 0..N");
650 }
651 }
652}