Skip to main content

markon_core/chat/
storage.rs

1//! SQLite-backed persistence for chat threads and messages.
2//!
3//! Reuses the existing `~/.markon/annotation.sqlite` connection (see
4//! [`crate::server`]) so users with `--shared-annotation` already have it
5//! open. When chat is enabled but shared-annotation is not, the server still
6//! opens the DB lazily — same path, same code path.
7//!
8//! Tables:
9//! ```sql
10//! CREATE TABLE IF NOT EXISTS chat_threads (
11//!     id           TEXT PRIMARY KEY,    -- uuid v4
12//!     workspace_id TEXT NOT NULL,
13//!     title        TEXT NOT NULL DEFAULT '',
14//!     created_at   INTEGER NOT NULL,    -- unix ms
15//!     updated_at   INTEGER NOT NULL
16//! );
17//! CREATE INDEX IF NOT EXISTS idx_chat_threads_ws
18//!     ON chat_threads(workspace_id, updated_at DESC);
19//!
20//! CREATE TABLE IF NOT EXISTS chat_messages (
21//!     thread_id    TEXT NOT NULL,
22//!     seq          INTEGER NOT NULL,    -- monotonic, starts at 0
23//!     role         TEXT NOT NULL,       -- 'user' | 'assistant'
24//!     content_json TEXT NOT NULL,       -- Vec<ContentBlock> JSON
25//!     created_at   INTEGER NOT NULL,
26//!     PRIMARY KEY (thread_id, seq),
27//!     FOREIGN KEY (thread_id) REFERENCES chat_threads(id) ON DELETE CASCADE
28//! );
29//! ```
30//!
31//! All per-request methods are `async` and offload the synchronous rusqlite
32//! work to `tokio::task::spawn_blocking`, so the tokio worker pool never sits
33//! on `Mutex<Connection>` across `.await` boundaries. Only [`ChatStorage::init`]
34//! stays synchronous: it runs once at boot against an owned `Connection`
35//! before the value is wrapped in `Arc<Mutex<…>>`.
36
37use 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    /// Blocking worker panicked or was cancelled — treated as a hard failure
70    /// because it means the connection's invariants may no longer hold.
71    #[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    // A clock reporting before UNIX_EPOCH is a real corruption — every
98    // chat row's created_at / updated_at would order against the rest
99    // of the timeline incorrectly. Better to fail loudly than to write
100    // 0 silently and watch threads sort to the year 1970.
101    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    /// Run `f` on the blocking pool with an exclusive `&Connection`. Keeps
128    /// the lock-and-unlock boilerplate out of every method body and ensures
129    /// the `MutexGuard` never crosses an `.await`.
130    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    /// Like [`with_conn`] but hands `f` an exclusive `&mut Connection`, which
146    /// `rusqlite::Connection::transaction*` requires.
147    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    /// Idempotent table creation — invoked once at server startup if either
163    /// `shared_annotation` or any workspace's `enable_chat` is set. Runs
164    /// synchronously because boot owns the `Connection` outright; only the
165    /// per-request paths (which share the connection through `Arc<Mutex<…>>`)
166    /// need to hop onto the blocking pool.
167    pub(crate) fn init(conn: &Connection) -> Result<(), StorageError> {
168        // Enable FK enforcement for cascade-delete on chat_messages.
169        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            // Ensure FK cascade is on for this connection — `init` set it, but
306            // `PRAGMA foreign_keys` is per-connection and cheap to re-assert.
307            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            // IMMEDIATE so SELECT MAX(seq)+INSERT is atomic against concurrent
370            // appends in other transactions on the same DB.
371            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
372
373            // Confirm the thread exists — otherwise we'd insert orphan rows
374            // that would only be caught at FK check time, and we want a clean
375            // error.
376            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    /// Joins thread + COUNT(messages); ordered by `updated_at DESC`.
418    /// This is what GET /api/chat/threads returns.
419    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/// Returned to the frontend by GET /api/chat/threads.
454#[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        // Sleep a millisecond so updated_at differs deterministically.
483        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        // updated_at DESC — most recent first.
490        assert_eq!(listed[0].id, t2.id);
491        assert_eq!(listed[1].id, t1.id);
492
493        // Append a couple of messages, then delete the thread and confirm the
494        // FK cascade dropped them.
495        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        // Sibling thread untouched.
527        assert!(store.get_thread(&t2.id).await.is_ok());
528
529        // Summary view reflects message counts.
530        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        // Roundtrip preserves block variants.
581        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        // Summary count matches.
607        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        // updated_at bumped past created_at by appends.
612        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}