Skip to main content

whycodes_storage/
db.rs

1use crate::migrations::run_migrations;
2use crate::models::{
3    BranchRow, CodeChunkRow, MemoryRow, MessageRow, SessionChunkRow, SessionRow, SessionTextHit,
4    UsageTotals,
5};
6use rusqlite::Connection;
7use whycodes_core::types::Usage;
8
9/// Core database wrapper
10pub struct Database {
11    conn: Connection,
12}
13
14/// Message tuple accepted by [`Database::replace_messages`]:
15/// (msg id, role, content json, tool_call_id, name, created_at rfc3339).
16pub type MessageInsert = (
17    String,
18    String,
19    String,
20    Option<String>,
21    Option<String>,
22    String,
23);
24
25/// One stored branch accepted by [`Database::replace_transcript`]: its
26/// optional name and every message JSON from the root to its leaf.
27pub type BranchInsert = (Option<String>, Vec<String>);
28
29/// How long to wait for another process to release a database lock before
30/// giving up. Without this SQLite returns SQLITE_BUSY immediately, so two
31/// whycodes processes touching the same database can fail spuriously.
32const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
33
34impl Database {
35    pub fn open(path: &str) -> crate::error::Result<Self> {
36        let conn = Connection::open(path)?;
37        conn.busy_timeout(BUSY_TIMEOUT)?;
38
39        // Switching journal mode needs exclusive access, so it returns
40        // SQLITE_BUSY whenever another connection holds a transaction — and
41        // when that is a write transaction it returns immediately, without
42        // consulting the busy timeout set above. The mode only has to change
43        // once: it lives in the file header, so a failure here means either
44        // that another process already set WAL or that this connection runs in
45        // the default rollback mode. Neither is fatal, so do not propagate it.
46        let _ = conn.execute_batch("PRAGMA journal_mode=WAL;");
47
48        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
49        run_migrations(&conn)?;
50        Ok(Self { conn })
51    }
52
53    /// Drop the `sessions` table so a later `list_sessions` fails. Only
54    /// compiled for this crate's tests and for callers that opt into
55    /// `test-util` (completion coverage). Not part of the production API.
56    #[cfg(any(test, feature = "test-util"))]
57    pub fn drop_sessions_table_for_test(&self) -> crate::error::Result<()> {
58        self.conn.execute_batch("DROP TABLE sessions;")?;
59        Ok(())
60    }
61
62    /// Open an existing database read-only. Returns `Ok(None)` when the file
63    /// is missing so completion / debug paths never create `whycodes.db`.
64    pub fn open_existing_readonly(path: &str) -> crate::error::Result<Option<Self>> {
65        if !std::path::Path::new(path).is_file() {
66            return Ok(None);
67        }
68        let flags =
69            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX;
70        let conn = Connection::open_with_flags(path, flags)?;
71        conn.busy_timeout(BUSY_TIMEOUT)?;
72        Ok(Some(Self { conn }))
73    }
74
75    pub fn open_in_memory() -> crate::error::Result<Self> {
76        let conn = Connection::open_in_memory()?;
77        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
78        run_migrations(&conn)?;
79        Ok(Self { conn })
80    }
81
82    /// Insert or update a session row, preserving `created_at` on conflict.
83    pub fn upsert_session(
84        &self,
85        id: &str,
86        title: &str,
87        project_path: &str,
88        created_at: &str,
89        updated_at: &str,
90        usage: &Usage,
91    ) -> crate::error::Result<()> {
92        self.conn.execute(
93            "INSERT INTO sessions (
94                id, title, created_at, updated_at, project_path,
95                input_tokens, output_tokens,
96                cache_creation_input_tokens, cache_read_input_tokens
97             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
98             ON CONFLICT(id) DO UPDATE SET
99                title = excluded.title,
100                updated_at = excluded.updated_at,
101                project_path = excluded.project_path,
102                input_tokens = excluded.input_tokens,
103                output_tokens = excluded.output_tokens,
104                cache_creation_input_tokens = excluded.cache_creation_input_tokens,
105                cache_read_input_tokens = excluded.cache_read_input_tokens",
106            rusqlite::params![
107                id,
108                title,
109                created_at,
110                updated_at,
111                project_path,
112                usage.input_tokens as i64,
113                usage.output_tokens as i64,
114                usage.cache_creation_input_tokens.map(|v| v as i64),
115                usage.cache_read_input_tokens.map(|v| v as i64),
116            ],
117        )?;
118        Ok(())
119    }
120
121    /// Convenience for tests / simple creates (zero usage, now timestamps).
122    pub fn create_session(
123        &self,
124        id: &str,
125        title: &str,
126        project_path: &str,
127    ) -> crate::error::Result<()> {
128        let now = chrono::Utc::now().to_rfc3339();
129        self.upsert_session(id, title, project_path, &now, &now, &Usage::default())
130    }
131
132    pub fn get_session(&self, id: &str) -> crate::error::Result<Option<SessionRow>> {
133        let mut stmt = self.conn.prepare(
134            "SELECT id, title, created_at, updated_at, project_path,
135                    input_tokens, output_tokens,
136                    cache_creation_input_tokens, cache_read_input_tokens
137             FROM sessions WHERE id = ?1",
138        )?;
139        let mut rows = stmt.query_map(rusqlite::params![id], map_session_row)?;
140        Ok(rows.next().transpose()?)
141    }
142
143    pub fn list_sessions(&self) -> crate::error::Result<Vec<SessionRow>> {
144        let mut stmt = self.conn.prepare(
145            "SELECT id, title, created_at, updated_at, project_path,
146                    input_tokens, output_tokens,
147                    cache_creation_input_tokens, cache_read_input_tokens
148             FROM sessions ORDER BY updated_at DESC",
149        )?;
150        let rows = stmt.query_map([], map_session_row)?;
151        let mut result = Vec::new();
152        for row in rows {
153            result.push(row?);
154        }
155        Ok(result)
156    }
157
158    /// Sessions with a message containing every term (ASCII case-insensitive),
159    /// most recently updated first.
160    ///
161    /// A scan rather than an FTS index: every save rewrites a session's
162    /// messages, and an index made a 500-message save ~15x slower, while a
163    /// scan of a 120k-message database takes ~0.3 s at search time only.
164    pub fn search_sessions_text(
165        &self,
166        terms: &[String],
167        limit: usize,
168    ) -> crate::error::Result<Vec<SessionTextHit>> {
169        if terms.is_empty() || limit == 0 {
170            return Ok(Vec::new());
171        }
172        let mut sql = String::from(
173            "SELECT s.id, s.title, s.created_at, s.updated_at, s.project_path,
174                    s.input_tokens, s.output_tokens,
175                    s.cache_creation_input_tokens, s.cache_read_input_tokens,
176                    COUNT(*), MIN(m.content)
177             FROM messages m JOIN sessions s ON s.id = m.session_id
178             WHERE m.content LIKE ?1 ESCAPE '\\'",
179        );
180        for i in 2..=terms.len() {
181            sql.push_str(&format!(" AND m.content LIKE ?{i} ESCAPE '\\'"));
182        }
183        sql.push_str(&format!(
184            " GROUP BY s.id ORDER BY s.updated_at DESC LIMIT {limit}"
185        ));
186        let patterns: Vec<String> = terms.iter().map(|t| like_pattern(t)).collect();
187        let mut stmt = self.conn.prepare(&sql)?;
188        let rows = stmt.query_map(rusqlite::params_from_iter(&patterns), map_session_text_hit)?;
189        let mut result = Vec::new();
190        for row in rows {
191            result.push(row?);
192        }
193        Ok(result)
194    }
195
196    /// Sum provider-reported usage and message counts across all sessions.
197    pub fn usage_totals(&self) -> crate::error::Result<UsageTotals> {
198        let (session_count, input, output, cache_create, cache_read): (
199            i64,
200            i64,
201            i64,
202            Option<i64>,
203            Option<i64>,
204        ) = self.conn.query_row(
205            "SELECT
206                COUNT(*),
207                COALESCE(SUM(input_tokens), 0),
208                COALESCE(SUM(output_tokens), 0),
209                SUM(cache_creation_input_tokens),
210                SUM(cache_read_input_tokens)
211             FROM sessions",
212            [],
213            |row| {
214                Ok((
215                    row.get(0)?,
216                    row.get(1)?,
217                    row.get(2)?,
218                    row.get(3)?,
219                    row.get(4)?,
220                ))
221            },
222        )?;
223
224        let message_count: i64 =
225            self.conn
226                .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
227
228        // SUM of all-NULL cache columns is NULL; keep Option semantics.
229        let usage = Usage {
230            input_tokens: input as u64,
231            output_tokens: output as u64,
232            cache_creation_input_tokens: cache_create.map(|v| v as u64),
233            cache_read_input_tokens: cache_read.map(|v| v as u64),
234        };
235
236        Ok(UsageTotals {
237            session_count: session_count as usize,
238            message_count: message_count as usize,
239            usage,
240        })
241    }
242
243    pub fn update_title(&self, id: &str, title: &str) -> crate::error::Result<()> {
244        let now = chrono::Utc::now().to_rfc3339();
245        self.conn.execute(
246            "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
247            rusqlite::params![title, now, id],
248        )?;
249        Ok(())
250    }
251
252    pub fn delete_session(&self, id: &str) -> crate::error::Result<()> {
253        self.conn.execute(
254            "DELETE FROM messages WHERE session_id = ?1",
255            rusqlite::params![id],
256        )?;
257        delete_branches(&self.conn, id)?;
258        self.conn
259            .execute("DELETE FROM sessions WHERE id = ?1", rusqlite::params![id])?;
260        Ok(())
261    }
262
263    pub fn insert_message(
264        &self,
265        msg_id: &str,
266        session_id: &str,
267        role: &str,
268        content: &str,
269        tool_call_id: Option<&str>,
270        name: Option<&str>,
271    ) -> crate::error::Result<()> {
272        let now = chrono::Utc::now().to_rfc3339();
273        self.conn.execute(
274            "INSERT INTO messages (id, session_id, role, content, tool_call_id, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
275            rusqlite::params![msg_id, session_id, role, content, tool_call_id, name, now],
276        )?;
277        Ok(())
278    }
279
280    /// Drop all messages for a session (used before full re-persist).
281    pub fn delete_messages(&self, session_id: &str) -> crate::error::Result<()> {
282        self.conn.execute(
283            "DELETE FROM messages WHERE session_id = ?1",
284            rusqlite::params![session_id],
285        )?;
286        Ok(())
287    }
288
289    /// Replace every message for a session in one transaction.
290    ///
291    /// Full rewrite keeps the table honest across multi-turn persists; the
292    /// transaction avoids half-written transcripts if a process dies mid-loop.
293    pub fn replace_messages(
294        &self,
295        session_id: &str,
296        messages: &[MessageInsert],
297    ) -> crate::error::Result<()> {
298        let tx = self.conn.unchecked_transaction()?;
299        write_messages(&tx, session_id, messages)?;
300        tx.commit()?;
301        Ok(())
302    }
303
304    /// Replace the active path and every stored branch of a session in one
305    /// transaction, so a crash mid-save never leaves a switched-away path
306    /// unsaved: the old rows stay until the new ones commit.
307    pub fn replace_transcript(
308        &self,
309        session_id: &str,
310        messages: &[MessageInsert],
311        branches: &[BranchInsert],
312    ) -> crate::error::Result<()> {
313        let tx = self.conn.unchecked_transaction()?;
314        write_messages(&tx, session_id, messages)?;
315        delete_branches(&tx, session_id)?;
316        for (ord, (name, rows)) in branches.iter().enumerate() {
317            let ord = ord as i64;
318            tx.execute(
319                "INSERT INTO session_branches (session_id, ord, name) VALUES (?1, ?2, ?3)",
320                rusqlite::params![session_id, ord, name],
321            )?;
322            for (seq, content) in rows.iter().enumerate() {
323                let seq = seq as i64;
324                tx.execute(
325                    "INSERT INTO branch_messages (session_id, ord, seq, content) \
326                     VALUES (?1, ?2, ?3, ?4)",
327                    rusqlite::params![session_id, ord, seq, content],
328                )?;
329            }
330        }
331        tx.commit()?;
332        Ok(())
333    }
334
335    /// Stored branches of a session in save order. A session saved before
336    /// branches existed has none. Message rows whose branch header is missing
337    /// still come back, as an unnamed branch, rather than being dropped.
338    pub fn get_branches(&self, session_id: &str) -> crate::error::Result<Vec<BranchRow>> {
339        let mut branches = std::collections::BTreeMap::new();
340        let mut heads = self
341            .conn
342            .prepare("SELECT ord, name FROM session_branches WHERE session_id = ?1")?;
343        for head in heads.query_map(rusqlite::params![session_id], map_branch_head)? {
344            let (ord, name) = head?;
345            let row = BranchRow {
346                name,
347                messages: Vec::new(),
348            };
349            branches.insert(ord, row);
350        }
351        let mut rows = self.conn.prepare(
352            "SELECT ord, content FROM branch_messages WHERE session_id = ?1 ORDER BY ord, seq",
353        )?;
354        for row in rows.query_map(rusqlite::params![session_id], map_branch_message)? {
355            let (ord, content) = row?;
356            let branch: &mut BranchRow = branches.entry(ord).or_default();
357            branch.messages.push(content);
358        }
359        Ok(branches.into_values().collect())
360    }
361
362    /// Message counts for every session in one query (session picker).
363    pub fn message_counts_by_session(
364        &self,
365    ) -> crate::error::Result<std::collections::HashMap<String, usize>> {
366        let mut stmt = self
367            .conn
368            .prepare("SELECT session_id, COUNT(*) FROM messages GROUP BY session_id")?;
369        let rows = stmt.query_map([], map_session_message_count)?;
370        let mut map = std::collections::HashMap::new();
371        for row in rows {
372            let (id, n) = row?;
373            map.insert(id, n);
374        }
375        Ok(map)
376    }
377
378    pub fn get_messages(&self, session_id: &str) -> crate::error::Result<Vec<MessageRow>> {
379        let mut stmt = self.conn.prepare(
380            "SELECT id, session_id, role, content, tool_call_id, name, created_at FROM messages WHERE session_id = ?1 ORDER BY created_at ASC"
381        )?;
382        let rows = stmt.query_map(rusqlite::params![session_id], map_message_row)?;
383        let mut result = Vec::new();
384        for row in rows {
385            result.push(row?);
386        }
387        Ok(result)
388    }
389
390    pub fn message_count(&self, session_id: &str) -> crate::error::Result<usize> {
391        let count: i64 = self.conn.query_row(
392            "SELECT COUNT(*) FROM messages WHERE session_id = ?1",
393            rusqlite::params![session_id],
394            |row| row.get(0),
395        )?;
396        Ok(count as usize)
397    }
398
399    pub fn get_state(&self, key: &str) -> crate::error::Result<Option<String>> {
400        let mut stmt = self
401            .conn
402            .prepare("SELECT value FROM state WHERE key = ?1")?;
403        let mut rows = stmt.query_map(rusqlite::params![key], |row| row.get::<_, String>(0))?;
404        Ok(rows.next().transpose()?)
405    }
406
407    pub fn set_state(&self, key: &str, value: &str) -> crate::error::Result<()> {
408        self.conn.execute(
409            "INSERT OR REPLACE INTO state (key, value) VALUES (?1, ?2)",
410            rusqlite::params![key, value],
411        )?;
412        Ok(())
413    }
414
415    pub fn delete_state(&self, key: &str) -> crate::error::Result<()> {
416        self.conn
417            .execute("DELETE FROM state WHERE key = ?1", rusqlite::params![key])?;
418        Ok(())
419    }
420
421    // ── Memories (semantic / auto memory) ────────────────────────────────
422
423    pub fn insert_memory(
424        &self,
425        id: &str,
426        project_key: &str,
427        text: &str,
428        embedding: &[u8],
429        source_session: Option<&str>,
430    ) -> crate::error::Result<()> {
431        let now = chrono::Utc::now().to_rfc3339();
432        self.conn.execute(
433            "INSERT INTO memories (
434                id, project_key, text, embedding, source_session,
435                created_at, last_recalled_at, recall_count
436             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, 0)",
437            rusqlite::params![id, project_key, text, embedding, source_session, now],
438        )?;
439        Ok(())
440    }
441
442    pub fn get_memory(&self, id: &str) -> crate::error::Result<Option<MemoryRow>> {
443        let mut stmt = self.conn.prepare(
444            "SELECT id, project_key, text, embedding, source_session,
445                    created_at, last_recalled_at, recall_count
446             FROM memories WHERE id = ?1",
447        )?;
448        let mut rows = stmt.query_map(rusqlite::params![id], map_memory_row)?;
449        Ok(rows.next().transpose()?)
450    }
451
452    pub fn list_memories(
453        &self,
454        project_key: &str,
455        limit: usize,
456    ) -> crate::error::Result<Vec<MemoryRow>> {
457        let mut stmt = self.conn.prepare(
458            "SELECT id, project_key, text, embedding, source_session,
459                    created_at, last_recalled_at, recall_count
460             FROM memories WHERE project_key = ?1
461             ORDER BY created_at DESC
462             LIMIT ?2",
463        )?;
464        let rows = stmt.query_map(rusqlite::params![project_key, limit as i64], map_memory_row)?;
465        let mut result = Vec::new();
466        for row in rows {
467            result.push(row?);
468        }
469        Ok(result)
470    }
471
472    pub fn delete_memory(&self, id: &str) -> crate::error::Result<bool> {
473        let n = self
474            .conn
475            .execute("DELETE FROM memories WHERE id = ?1", rusqlite::params![id])?;
476        Ok(n > 0)
477    }
478
479    pub fn clear_memories(&self, project_key: &str) -> crate::error::Result<usize> {
480        let n = self.conn.execute(
481            "DELETE FROM memories WHERE project_key = ?1",
482            rusqlite::params![project_key],
483        )?;
484        Ok(n)
485    }
486
487    pub fn touch_memory_recall(&self, id: &str) -> crate::error::Result<()> {
488        let now = chrono::Utc::now().to_rfc3339();
489        self.conn.execute(
490            "UPDATE memories SET last_recalled_at = ?1, recall_count = recall_count + 1
491             WHERE id = ?2",
492            rusqlite::params![now, id],
493        )?;
494        Ok(())
495    }
496
497    pub fn count_memories(&self, project_key: &str) -> crate::error::Result<usize> {
498        let n: i64 = self.conn.query_row(
499            "SELECT COUNT(*) FROM memories WHERE project_key = ?1",
500            rusqlite::params![project_key],
501            |row| row.get(0),
502        )?;
503        Ok(n as usize)
504    }
505
506    // ── Code chunks (codebase RAG) ───────────────────────────────────────
507
508    #[allow(clippy::too_many_arguments)]
509    pub fn insert_code_chunk(
510        &self,
511        id: &str,
512        project_key: &str,
513        path: &str,
514        start_line: i64,
515        end_line: i64,
516        text: &str,
517        embedding: &[u8],
518    ) -> crate::error::Result<()> {
519        let now = chrono::Utc::now().to_rfc3339();
520        self.conn.execute(
521            "INSERT INTO code_chunks (
522                id, project_key, path, start_line, end_line, text, embedding, updated_at
523             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
524            rusqlite::params![
525                id,
526                project_key,
527                path,
528                start_line,
529                end_line,
530                text,
531                embedding,
532                now
533            ],
534        )?;
535        Ok(())
536    }
537
538    pub fn list_code_chunks(
539        &self,
540        project_key: &str,
541        limit: usize,
542    ) -> crate::error::Result<Vec<CodeChunkRow>> {
543        let mut stmt = self.conn.prepare(
544            "SELECT id, project_key, path, start_line, end_line, text, embedding, updated_at
545             FROM code_chunks WHERE project_key = ?1
546             ORDER BY path ASC, start_line ASC
547             LIMIT ?2",
548        )?;
549        let rows = stmt.query_map((project_key, limit as i64), map_code_chunk_row)?;
550        let mut result = Vec::new();
551        for row in rows {
552            result.push(row?);
553        }
554        Ok(result)
555    }
556
557    pub fn clear_code_chunks(&self, project_key: &str) -> crate::error::Result<usize> {
558        let n = self.conn.execute(
559            "DELETE FROM code_chunks WHERE project_key = ?1",
560            rusqlite::params![project_key],
561        )?;
562        Ok(n)
563    }
564
565    // ── Session chunks (turn RAG) ────────────────────────────────────────
566
567    pub fn insert_session_chunk(
568        &self,
569        id: &str,
570        project_key: &str,
571        session_id: &str,
572        turn_index: i64,
573        text: &str,
574        embedding: &[u8],
575    ) -> crate::error::Result<()> {
576        let now = chrono::Utc::now().to_rfc3339();
577        self.conn.execute(
578            "INSERT INTO session_chunks (
579                id, project_key, session_id, turn_index, text, embedding, created_at
580             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
581            rusqlite::params![
582                id,
583                project_key,
584                session_id,
585                turn_index,
586                text,
587                embedding,
588                now
589            ],
590        )?;
591        Ok(())
592    }
593
594    pub fn list_session_chunks(
595        &self,
596        project_key: &str,
597        limit: usize,
598    ) -> crate::error::Result<Vec<SessionChunkRow>> {
599        let mut stmt = self.conn.prepare(
600            "SELECT id, project_key, session_id, turn_index, text, embedding, created_at
601             FROM session_chunks WHERE project_key = ?1
602             ORDER BY created_at DESC
603             LIMIT ?2",
604        )?;
605        let rows = stmt.query_map((project_key, limit as i64), map_session_chunk_row)?;
606        let mut result = Vec::new();
607        for row in rows {
608            result.push(row?);
609        }
610        Ok(result)
611    }
612
613    // ── FTS5 candidate sets ──────────────────────────────────────────────
614
615    /// Up to `limit` facts that share a word with `query`, best BM25 first.
616    pub fn search_memories(
617        &self,
618        project_key: &str,
619        query: &str,
620        limit: usize,
621    ) -> crate::error::Result<FtsPage<MemoryRow>> {
622        self.fts_page(
623            "memories",
624            "t.id, t.project_key, t.text, t.embedding, t.source_session,
625             t.created_at, t.last_recalled_at, t.recall_count",
626            "t.created_at DESC",
627            (project_key, query, limit),
628            map_memory_row,
629        )
630    }
631
632    /// Up to `limit` code chunks that share a word with `query`.
633    pub fn search_code_chunks(
634        &self,
635        project_key: &str,
636        query: &str,
637        limit: usize,
638    ) -> crate::error::Result<FtsPage<CodeChunkRow>> {
639        self.fts_page(
640            "code_chunks",
641            "t.id, t.project_key, t.path, t.start_line, t.end_line, t.text,
642             t.embedding, t.updated_at",
643            "t.path ASC, t.start_line ASC",
644            (project_key, query, limit),
645            map_code_chunk_row,
646        )
647    }
648
649    /// Up to `limit` past session turns that share a word with `query`.
650    pub fn search_session_chunks(
651        &self,
652        project_key: &str,
653        query: &str,
654        limit: usize,
655    ) -> crate::error::Result<FtsPage<SessionChunkRow>> {
656        self.fts_page(
657            "session_chunks",
658            "t.id, t.project_key, t.session_id, t.turn_index, t.text,
659             t.embedding, t.created_at",
660            "t.created_at DESC",
661            (project_key, query, limit),
662            map_session_chunk_row,
663        )
664    }
665
666    /// `query` with no indexable word browses the bank in `browse_order`
667    /// instead of returning a silent empty page. A missing or broken index
668    /// logs and scans up to [`FTS_SCAN_LIMIT`] rows, as search did before
669    /// FTS. `table`, `cols`, and `browse_order` are internal constants;
670    /// `query` is only ever bound.
671    fn fts_page<T>(
672        &self,
673        table: &str,
674        cols: &str,
675        browse_order: &str,
676        (project_key, query, limit): (&str, &str, usize),
677        map: fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
678    ) -> crate::error::Result<FtsPage<T>> {
679        let count = format!("SELECT COUNT(*) FROM {table} WHERE project_key = ?1");
680        let corpus: i64 = self
681            .conn
682            .query_row(&count, [project_key], |row| row.get(0))?;
683        let corpus = corpus as usize;
684        let browse = |n: usize| self.browse(table, cols, browse_order, project_key, n, map);
685        let Some(fts_query) = fts_match_query(query) else {
686            let rows = browse(limit)?;
687            return Ok(FtsPage {
688                rows,
689                matched: corpus,
690                corpus,
691            });
692        };
693        match self.fts_rows(table, cols, (project_key, &fts_query, limit), map) {
694            Ok((rows, matched)) => Ok(FtsPage {
695                rows,
696                matched,
697                corpus,
698            }),
699            Err(e) => {
700                tracing::warn!(table, error = %e, "fts index unavailable; scanning the bank");
701                let rows = browse(FTS_SCAN_LIMIT)?;
702                Ok(FtsPage {
703                    matched: rows.len(),
704                    rows,
705                    corpus,
706                })
707            }
708        }
709    }
710
711    fn browse<T>(
712        &self,
713        table: &str,
714        cols: &str,
715        order: &str,
716        project_key: &str,
717        limit: usize,
718        map: fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
719    ) -> crate::error::Result<Vec<T>> {
720        let mut stmt = self.conn.prepare(&format!(
721            "SELECT {cols} FROM {table} t WHERE t.project_key = ?1 ORDER BY {order} LIMIT ?2"
722        ))?;
723        let rows = stmt.query_map((project_key, limit as i64), map)?;
724        Ok(rows.collect::<rusqlite::Result<Vec<T>>>()?)
725    }
726
727    /// Ranked FTS matches and their total count.
728    fn fts_rows<T>(
729        &self,
730        table: &str,
731        cols: &str,
732        (project_key, fts_query, limit): (&str, &str, usize),
733        map: fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
734    ) -> rusqlite::Result<(Vec<T>, usize)> {
735        let from = format!(
736            "FROM {table} t JOIN {table}_fts ON {table}_fts.rowid = t.rowid
737             WHERE {table}_fts MATCH ?1 AND t.project_key = ?2"
738        );
739        let matched: i64 = self.conn.query_row(
740            &format!("SELECT COUNT(*) {from}"),
741            (fts_query, project_key),
742            |row| row.get(0),
743        )?;
744        let mut stmt = self.conn.prepare(&format!(
745            "SELECT {cols} {from} ORDER BY bm25({table}_fts) LIMIT ?3"
746        ))?;
747        let rows = stmt.query_map((fts_query, project_key, limit as i64), map)?;
748        Ok((
749            rows.collect::<rusqlite::Result<Vec<T>>>()?,
750            matched as usize,
751        ))
752    }
753
754    /// Drop the FTS indexes and their triggers so a search hits the
755    /// missing-index fallback. Test-only, like `drop_sessions_table_for_test`.
756    #[cfg(any(test, feature = "test-util"))]
757    pub fn drop_fts_for_test(&self) -> crate::error::Result<()> {
758        for table in crate::migrations::FTS_TABLES {
759            self.conn.execute_batch(&format!(
760                "DROP TRIGGER {table}_fts_ai; DROP TRIGGER {table}_fts_ad;
761                 DROP TRIGGER {table}_fts_au; DROP TABLE {table}_fts;"
762            ))?;
763        }
764        Ok(())
765    }
766}
767
768/// Rows a search scans when the FTS index is missing or broken.
769pub const FTS_SCAN_LIMIT: usize = 50_000;
770
771/// Lexical candidates for one project bank.
772#[derive(Debug, Clone)]
773pub struct FtsPage<T> {
774    /// At most the requested limit, best BM25 rank first.
775    pub rows: Vec<T>,
776    /// Rows in the bank that match the query (the whole bank on a browse).
777    pub matched: usize,
778    /// Rows in the bank.
779    pub corpus: usize,
780}
781
782/// FTS5 `MATCH` string for free text: every word of two or more characters,
783/// quoted and OR-ed, so punctuation and FTS operators in user text are inert.
784/// `None` when nothing indexable is left.
785pub fn fts_match_query(text: &str) -> Option<String> {
786    let mut words: Vec<String> = Vec::new();
787    for word in text.split(|c: char| !c.is_alphanumeric()) {
788        let word = word.to_lowercase();
789        if word.chars().count() >= 2 && !words.contains(&word) && words.len() < 32 {
790            words.push(word);
791        }
792    }
793    if words.is_empty() {
794        return None;
795    }
796    let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect();
797    Some(quoted.join(" OR "))
798}
799
800fn map_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRow> {
801    Ok(MessageRow {
802        id: row.get(0)?,
803        session_id: row.get(1)?,
804        role: row.get(2)?,
805        content: row.get(3)?,
806        tool_call_id: row.get(4)?,
807        name: row.get(5)?,
808        created_at: row.get(6)?,
809    })
810}
811
812/// Delete every stored branch of a session (headers and messages).
813fn delete_branches(conn: &Connection, session_id: &str) -> crate::error::Result<()> {
814    conn.execute(
815        "DELETE FROM branch_messages WHERE session_id = ?1",
816        rusqlite::params![session_id],
817    )?;
818    conn.execute(
819        "DELETE FROM session_branches WHERE session_id = ?1",
820        rusqlite::params![session_id],
821    )?;
822    Ok(())
823}
824
825/// Replace the active-path rows of a session inside `tx`.
826fn write_messages(
827    tx: &rusqlite::Transaction<'_>,
828    session_id: &str,
829    messages: &[MessageInsert],
830) -> crate::error::Result<()> {
831    tx.execute(
832        "DELETE FROM messages WHERE session_id = ?1",
833        rusqlite::params![session_id],
834    )?;
835    for (msg_id, role, content, tool_call_id, name, created_at) in messages {
836        tx.execute(
837            "INSERT INTO messages (id, session_id, role, content, tool_call_id, name, created_at) \
838             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
839            rusqlite::params![
840                msg_id,
841                session_id,
842                role,
843                content,
844                tool_call_id,
845                name,
846                created_at
847            ],
848        )?;
849    }
850    Ok(())
851}
852
853fn map_branch_head(row: &rusqlite::Row<'_>) -> rusqlite::Result<(i64, Option<String>)> {
854    Ok((row.get(0)?, row.get(1)?))
855}
856
857fn map_branch_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<(i64, String)> {
858    Ok((row.get(0)?, row.get(1)?))
859}
860
861fn map_session_message_count(row: &rusqlite::Row<'_>) -> rusqlite::Result<(String, usize)> {
862    let id: String = row.get(0)?;
863    let n: i64 = row.get(1)?;
864    Ok((id, n as usize))
865}
866
867fn map_memory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRow> {
868    Ok(MemoryRow {
869        id: row.get(0)?,
870        project_key: row.get(1)?,
871        text: row.get(2)?,
872        embedding: row.get(3)?,
873        source_session: row.get(4)?,
874        created_at: row.get(5)?,
875        last_recalled_at: row.get(6)?,
876        recall_count: row.get(7)?,
877    })
878}
879
880fn map_session_chunk_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionChunkRow> {
881    Ok(SessionChunkRow {
882        id: row.get(0)?,
883        project_key: row.get(1)?,
884        session_id: row.get(2)?,
885        turn_index: row.get(3)?,
886        text: row.get(4)?,
887        embedding: row.get(5)?,
888        created_at: row.get(6)?,
889    })
890}
891
892fn map_code_chunk_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CodeChunkRow> {
893    Ok(CodeChunkRow {
894        id: row.get(0)?,
895        project_key: row.get(1)?,
896        path: row.get(2)?,
897        start_line: row.get(3)?,
898        end_line: row.get(4)?,
899        text: row.get(5)?,
900        embedding: row.get(6)?,
901        updated_at: row.get(7)?,
902    })
903}
904/// `%term%` with LIKE wildcards in the term escaped (`\` is the escape).
905/// `%term%` with LIKE wildcards in the term escaped (`\\` is the escape).
906fn like_pattern(term: &str) -> String {
907    let mut out = String::with_capacity(term.len() + 2);
908    out.push('%');
909    for c in term.chars() {
910        if matches!(c, '%' | '_' | '\\') {
911            out.push('\\');
912        }
913        out.push(c);
914    }
915    out.push('%');
916    out
917}
918
919fn map_session_text_hit(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionTextHit> {
920    let hits: i64 = row.get(9)?;
921    Ok(SessionTextHit {
922        session: map_session_row(row)?,
923        hits: hits as usize,
924        sample: row.get(10)?,
925    })
926}
927
928fn map_session_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRow> {
929    let input: i64 = row.get(5)?;
930    let output: i64 = row.get(6)?;
931    let cache_create: Option<i64> = row.get(7)?;
932    let cache_read: Option<i64> = row.get(8)?;
933    Ok(SessionRow {
934        id: row.get(0)?,
935        title: row.get(1)?,
936        created_at: row.get(2)?,
937        updated_at: row.get(3)?,
938        project_path: row.get(4)?,
939        usage: Usage {
940            input_tokens: input as u64,
941            output_tokens: output as u64,
942            cache_creation_input_tokens: cache_create.map(|v| v as u64),
943            cache_read_input_tokens: cache_read.map(|v| v as u64),
944        },
945    })
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    fn test_db() -> Database {
953        Database::open_in_memory().unwrap()
954    }
955
956    /// Opening a database while another connection holds a write transaction
957    /// must succeed. `PRAGMA journal_mode=WAL` returns SQLITE_BUSY in that
958    /// state without consulting the busy handler, and treating that as fatal
959    /// made concurrent whycodes processes fail with "database is locked".
960    #[test]
961    fn test_open_while_another_connection_holds_a_write_transaction() {
962        let path = std::env::temp_dir().join(format!("whycodes-test-{}.db", uuid::Uuid::new_v4()));
963        let path_str = path.to_string_lossy().to_string();
964
965        // Seed the schema with a plain connection, leaving the journal mode at
966        // the default. `Database::open` therefore has to switch it to WAL,
967        // which is the step that fails under contention — on a database that
968        // is already in WAL mode the pragma is a no-op and takes no lock.
969        let seed = Connection::open(&path_str).unwrap();
970        run_migrations(&seed).unwrap();
971        seed.execute_batch("BEGIN IMMEDIATE;").unwrap();
972
973        let opened = Database::open(&path_str);
974
975        let _ = seed.execute_batch("ROLLBACK;");
976        drop(seed);
977        for suffix in ["db", "db-wal", "db-shm"] {
978            let _ = std::fs::remove_file(path.with_extension(suffix));
979        }
980
981        opened.expect("open failed while another connection held a write transaction");
982    }
983
984    #[test]
985    fn test_create_and_get_session() {
986        let db = test_db();
987        db.create_session("s1", "Test", "/tmp").unwrap();
988        let s = db.get_session("s1").unwrap().unwrap();
989        assert_eq!(s.title, "Test");
990        assert!(s.usage.is_empty());
991    }
992
993    #[test]
994    fn test_upsert_preserves_created_at_and_stores_usage() {
995        let db = test_db();
996        let created = "2020-01-01T00:00:00+00:00";
997        let usage = Usage {
998            input_tokens: 10,
999            output_tokens: 20,
1000            cache_creation_input_tokens: Some(1),
1001            cache_read_input_tokens: Some(2),
1002        };
1003        db.upsert_session("s1", "Old", "/tmp", created, created, &usage)
1004            .unwrap();
1005
1006        let updated = "2021-06-15T12:00:00+00:00";
1007        let usage2 = Usage {
1008            input_tokens: 100,
1009            output_tokens: 200,
1010            cache_creation_input_tokens: Some(3),
1011            cache_read_input_tokens: Some(4),
1012        };
1013        db.upsert_session("s1", "New", "/proj", created, updated, &usage2)
1014            .unwrap();
1015
1016        let s = db.get_session("s1").unwrap().unwrap();
1017        assert_eq!(s.title, "New");
1018        assert_eq!(s.created_at, created);
1019        assert_eq!(s.updated_at, updated);
1020        assert_eq!(s.project_path, "/proj");
1021        assert_eq!(s.usage.input_tokens, 100);
1022        assert_eq!(s.usage.output_tokens, 200);
1023        assert_eq!(s.usage.cache_creation_input_tokens, Some(3));
1024        assert_eq!(s.usage.cache_read_input_tokens, Some(4));
1025    }
1026
1027    #[test]
1028    fn test_usage_totals() {
1029        let db = test_db();
1030        let now = chrono::Utc::now().to_rfc3339();
1031        db.upsert_session(
1032            "a",
1033            "A",
1034            "/a",
1035            &now,
1036            &now,
1037            &Usage {
1038                input_tokens: 10,
1039                output_tokens: 5,
1040                cache_creation_input_tokens: Some(1),
1041                cache_read_input_tokens: None,
1042            },
1043        )
1044        .unwrap();
1045        db.upsert_session(
1046            "b",
1047            "B",
1048            "/b",
1049            &now,
1050            &now,
1051            &Usage {
1052                input_tokens: 20,
1053                output_tokens: 7,
1054                cache_creation_input_tokens: None,
1055                cache_read_input_tokens: Some(4),
1056            },
1057        )
1058        .unwrap();
1059        db.insert_message("m1", "a", "user", "hi", None, None)
1060            .unwrap();
1061        db.insert_message("m2", "a", "assistant", "yo", None, None)
1062            .unwrap();
1063
1064        let t = db.usage_totals().unwrap();
1065        assert_eq!(t.session_count, 2);
1066        assert_eq!(t.message_count, 2);
1067        assert_eq!(t.usage.input_tokens, 30);
1068        assert_eq!(t.usage.output_tokens, 12);
1069        assert_eq!(t.usage.cache_creation_input_tokens, Some(1));
1070        assert_eq!(t.usage.cache_read_input_tokens, Some(4));
1071        assert_eq!(t.usage.total(), 30 + 12 + 1 + 4);
1072    }
1073
1074    #[test]
1075    fn test_list_sessions() {
1076        let db = test_db();
1077        db.create_session("a", "A", "/a").unwrap();
1078        db.create_session("b", "B", "/b").unwrap();
1079        assert_eq!(db.list_sessions().unwrap().len(), 2);
1080    }
1081
1082    #[test]
1083    fn test_update_title() {
1084        let db = test_db();
1085        db.create_session("s1", "Old", "/tmp").unwrap();
1086        db.update_title("s1", "New").unwrap();
1087        assert_eq!(db.get_session("s1").unwrap().unwrap().title, "New");
1088    }
1089
1090    #[test]
1091    fn test_delete_session() {
1092        let db = test_db();
1093        db.create_session("s1", "Test", "/tmp").unwrap();
1094        db.insert_message("m1", "s1", "user", "hi", None, None)
1095            .unwrap();
1096        db.delete_session("s1").unwrap();
1097        assert!(db.get_session("s1").unwrap().is_none());
1098        assert!(db.get_messages("s1").unwrap().is_empty());
1099    }
1100
1101    #[test]
1102    fn test_insert_and_get_messages() {
1103        let db = test_db();
1104        db.create_session("s1", "Test", "/tmp").unwrap();
1105        db.insert_message("m1", "s1", "user", "hello", None, None)
1106            .unwrap();
1107        db.insert_message("m2", "s1", "assistant", "hi", None, None)
1108            .unwrap();
1109        let msgs = db.get_messages("s1").unwrap();
1110        assert_eq!(msgs.len(), 2);
1111        assert_eq!(msgs[0].role, "user");
1112    }
1113
1114    #[test]
1115    fn test_delete_messages() {
1116        let db = test_db();
1117        db.create_session("s1", "Test", "/tmp").unwrap();
1118        db.insert_message("m1", "s1", "user", "hi", None, None)
1119            .unwrap();
1120        db.delete_messages("s1").unwrap();
1121        assert_eq!(db.message_count("s1").unwrap(), 0);
1122    }
1123
1124    #[test]
1125    fn test_message_count() {
1126        let db = test_db();
1127        db.create_session("s1", "Test", "/tmp").unwrap();
1128        assert_eq!(db.message_count("s1").unwrap(), 0);
1129        db.insert_message("m1", "s1", "user", "hi", None, None)
1130            .unwrap();
1131        assert_eq!(db.message_count("s1").unwrap(), 1);
1132    }
1133
1134    #[test]
1135    fn test_state_get_set_delete() {
1136        let db = test_db();
1137        assert!(db.get_state("k1").unwrap().is_none());
1138        db.set_state("k1", "v1").unwrap();
1139        assert_eq!(db.get_state("k1").unwrap().unwrap(), "v1");
1140        db.delete_state("k1").unwrap();
1141        assert!(db.get_state("k1").unwrap().is_none());
1142    }
1143
1144    #[test]
1145    fn test_nonexistent_session() {
1146        let db = test_db();
1147        assert!(db.get_session("no").unwrap().is_none());
1148    }
1149
1150    #[test]
1151    fn test_open_file_roundtrip_and_bad_paths() {
1152        let tmp = tempfile::tempdir().unwrap();
1153        let path = tmp.path().join("whycodes.db");
1154        let path_str = path.to_str().unwrap();
1155        let db = Database::open(path_str).unwrap();
1156        db.create_session("s1", "File", "/proj").unwrap();
1157        drop(db);
1158
1159        let db = Database::open(path_str).unwrap();
1160        assert_eq!(db.get_session("s1").unwrap().unwrap().title, "File");
1161
1162        assert!(Database::open(tmp.path().to_str().unwrap()).is_err());
1163        assert!(Database::open("/no/such/whycodes-storage-dir/db.sqlite").is_err());
1164    }
1165
1166    #[test]
1167    fn search_sessions_text_ands_terms_and_escapes_wildcards() {
1168        let db = test_db();
1169        db.create_session("s1", "Retry", "/tmp").unwrap();
1170        db.create_session("s2", "Other", "/tmp").unwrap();
1171        db.insert_message("a", "s1", "user", "fix the RETRY loop", None, None)
1172            .unwrap();
1173        db.insert_message("b", "s1", "assistant", "retry loop done", None, None)
1174            .unwrap();
1175        db.insert_message("c", "s2", "user", "retry only", None, None)
1176            .unwrap();
1177        db.insert_message("d", "s2", "user", r"50%_off \path", None, None)
1178            .unwrap();
1179        let terms = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1180
1181        let hits = db
1182            .search_sessions_text(&terms(&["retry", "loop"]), 10)
1183            .unwrap();
1184        assert_eq!(hits.len(), 1);
1185        assert_eq!(hits[0].session.title, "Retry");
1186        assert_eq!(hits[0].hits, 2);
1187        assert_eq!(hits[0].sample, "fix the RETRY loop");
1188        assert_eq!(
1189            db.search_sessions_text(&terms(&["retry"]), 1)
1190                .unwrap()
1191                .len(),
1192            1
1193        );
1194        assert_eq!(
1195            db.search_sessions_text(&terms(&["retry"]), 10)
1196                .unwrap()
1197                .len(),
1198            2
1199        );
1200        assert!(db.search_sessions_text(&[], 10).unwrap().is_empty());
1201        assert!(
1202            db.search_sessions_text(&terms(&["retry"]), 0)
1203                .unwrap()
1204                .is_empty()
1205        );
1206        for literal in ["50%_off", r"\path"] {
1207            assert_eq!(
1208                db.search_sessions_text(&terms(&[literal]), 10)
1209                    .unwrap()
1210                    .len(),
1211                1
1212            );
1213        }
1214        assert!(
1215            db.search_sessions_text(&terms(&["5%f"]), 10)
1216                .unwrap()
1217                .is_empty()
1218        );
1219        assert!(
1220            db.search_sessions_text(&terms(&["50_"]), 10)
1221                .unwrap()
1222                .is_empty()
1223        );
1224    }
1225
1226    #[test]
1227    fn test_replace_messages_and_counts() {
1228        let db = test_db();
1229        db.create_session("s1", "Test", "/tmp").unwrap();
1230        db.insert_message("old", "s1", "user", "stale", None, None)
1231            .unwrap();
1232        db.insert_message("tool1", "s1", "tool", "out", Some("call-1"), Some("bash"))
1233            .unwrap();
1234
1235        let created = "2020-01-01T00:00:00+00:00".to_string();
1236        db.replace_messages(
1237            "s1",
1238            &[
1239                (
1240                    "m1".into(),
1241                    "user".into(),
1242                    "hello".into(),
1243                    None,
1244                    None,
1245                    created.clone(),
1246                ),
1247                (
1248                    "m2".into(),
1249                    "tool".into(),
1250                    "done".into(),
1251                    Some("tc1".into()),
1252                    Some("grep".into()),
1253                    created,
1254                ),
1255            ],
1256        )
1257        .unwrap();
1258
1259        let msgs = db.get_messages("s1").unwrap();
1260        assert_eq!(msgs.len(), 2);
1261        assert_eq!(msgs[0].id, "m1");
1262        assert_eq!(msgs[1].tool_call_id.as_deref(), Some("tc1"));
1263        assert_eq!(msgs[1].name.as_deref(), Some("grep"));
1264
1265        let counts = db.message_counts_by_session().unwrap();
1266        assert_eq!(counts.get("s1"), Some(&2));
1267
1268        db.replace_messages("s1", &[]).unwrap();
1269        assert!(db.get_messages("s1").unwrap().is_empty());
1270        assert!(db.message_counts_by_session().unwrap().is_empty());
1271    }
1272
1273    #[test]
1274    fn test_replace_messages_duplicate_id_fails() {
1275        let db = test_db();
1276        db.create_session("s1", "Test", "/tmp").unwrap();
1277        let created = "2020-01-01T00:00:00+00:00".to_string();
1278        let err = db.replace_messages(
1279            "s1",
1280            &[
1281                (
1282                    "m1".into(),
1283                    "user".into(),
1284                    "a".into(),
1285                    None,
1286                    None,
1287                    created.clone(),
1288                ),
1289                ("m1".into(), "user".into(), "b".into(), None, None, created),
1290            ],
1291        );
1292        assert!(err.is_err());
1293    }
1294
1295    fn row(id: &str, content: &str) -> MessageInsert {
1296        let created = "2020-01-01T00:00:00+00:00".to_string();
1297        (
1298            id.into(),
1299            "user".into(),
1300            content.into(),
1301            None,
1302            None,
1303            created,
1304        )
1305    }
1306
1307    fn branch(name: Option<&str>, msgs: &[&str]) -> BranchInsert {
1308        (
1309            name.map(str::to_string),
1310            msgs.iter().map(|m| m.to_string()).collect(),
1311        )
1312    }
1313
1314    #[test]
1315    fn replace_transcript_round_trips_branches_in_order() {
1316        let db = test_db();
1317        db.create_session("s1", "Test", "/tmp").unwrap();
1318        db.create_session("s2", "Other", "/tmp").unwrap();
1319        db.replace_transcript(
1320            "s1",
1321            &[row("m1", "a"), row("m2", "b")],
1322            &[
1323                branch(Some("try"), &["a", "x"]),
1324                branch(None, &["a", "y", "z"]),
1325            ],
1326        )
1327        .unwrap();
1328        db.replace_transcript("s2", &[row("o1", "q")], &[branch(None, &["q", "r"])])
1329            .unwrap();
1330
1331        assert_eq!(db.get_messages("s1").unwrap().len(), 2);
1332        let branches = db.get_branches("s1").unwrap();
1333        assert_eq!(
1334            branches,
1335            vec![
1336                BranchRow {
1337                    name: Some("try".into()),
1338                    messages: vec!["a".into(), "x".into()],
1339                },
1340                BranchRow {
1341                    name: None,
1342                    messages: vec!["a".into(), "y".into(), "z".into()],
1343                },
1344            ]
1345        );
1346
1347        // A later save replaces the set; other sessions keep theirs.
1348        db.replace_transcript("s1", &[row("m1", "a")], &[branch(None, &["b"])])
1349            .unwrap();
1350        assert_eq!(db.get_branches("s1").unwrap().len(), 1);
1351        assert_eq!(db.get_branches("s2").unwrap().len(), 1);
1352        // The plain message replace leaves stored branches alone.
1353        db.replace_messages("s1", &[row("m9", "c")]).unwrap();
1354        assert_eq!(db.get_branches("s1").unwrap().len(), 1);
1355
1356        db.delete_session("s1").unwrap();
1357        assert!(db.get_branches("s1").unwrap().is_empty());
1358        assert_eq!(db.get_branches("s2").unwrap().len(), 1);
1359    }
1360
1361    #[test]
1362    fn get_branches_keeps_message_rows_without_a_header() {
1363        let db = test_db();
1364        db.conn
1365            .execute_batch(
1366                "INSERT INTO branch_messages (session_id, ord, seq, content)
1367                     VALUES ('s', 3, 1, 'second'), ('s', 3, 0, 'first');",
1368            )
1369            .unwrap();
1370        assert_eq!(
1371            db.get_branches("s").unwrap(),
1372            vec![BranchRow {
1373                name: None,
1374                messages: vec!["first".into(), "second".into()],
1375            }]
1376        );
1377    }
1378
1379    /// A database written by the schema before branches existed opens,
1380    /// keeps its transcript as the single active path, and can store
1381    /// branches afterwards.
1382    #[test]
1383    fn database_from_the_previous_schema_migrates_forward() {
1384        let tmp = tempfile::tempdir().unwrap();
1385        let path = tmp.path().join("old.db");
1386        {
1387            let conn = Connection::open(&path).unwrap();
1388            conn.execute_batch(
1389                "
1390                CREATE TABLE sessions (
1391                    id TEXT PRIMARY KEY, title TEXT, created_at TEXT,
1392                    updated_at TEXT, project_path TEXT,
1393                    input_tokens INTEGER NOT NULL DEFAULT 0,
1394                    output_tokens INTEGER NOT NULL DEFAULT 0,
1395                    cache_creation_input_tokens INTEGER,
1396                    cache_read_input_tokens INTEGER
1397                );
1398                CREATE TABLE messages (
1399                    id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
1400                    tool_call_id TEXT, name TEXT, created_at TEXT,
1401                    FOREIGN KEY (session_id) REFERENCES sessions(id)
1402                );
1403                CREATE INDEX idx_messages_session ON messages(session_id);
1404                CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT);
1405                INSERT INTO sessions (id, title, created_at, updated_at, project_path)
1406                    VALUES ('s', 'old', '2020-01-01T00:00:00+00:00',
1407                            '2020-01-01T00:00:00+00:00', '/p');
1408                INSERT INTO messages (id, session_id, role, content, created_at) VALUES
1409                    ('m1', 's', 'user', 'one', '2020-01-01T00:00:01+00:00'),
1410                    ('m2', 's', 'assistant', 'two', '2020-01-01T00:00:02+00:00');
1411                ",
1412            )
1413            .unwrap();
1414        }
1415        let db = Database::open(&path.to_string_lossy()).unwrap();
1416        let msgs = db.get_messages("s").unwrap();
1417        assert_eq!(
1418            msgs.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(),
1419            ["one", "two"]
1420        );
1421        assert!(db.get_branches("s").unwrap().is_empty());
1422        db.replace_transcript("s", &[row("m1", "one")], &[branch(None, &["one", "x"])])
1423            .unwrap();
1424        drop(db);
1425        // Reopening (migrations run again) keeps everything.
1426        let db = Database::open(&path.to_string_lossy()).unwrap();
1427        assert_eq!(db.get_messages("s").unwrap().len(), 1);
1428        assert_eq!(db.get_branches("s").unwrap()[0].messages, ["one", "x"]);
1429    }
1430
1431    #[test]
1432    fn branch_writes_and_reads_report_sql_errors() {
1433        let broken = |sql: &str| {
1434            let db = test_db();
1435            db.create_session("s", "t", "/p").unwrap();
1436            db.conn.execute_batch(sql).unwrap();
1437            db
1438        };
1439        let msgs = [row("m", "a")];
1440        let one = [branch(None, &["a"])];
1441
1442        // Header insert fails; the transaction leaves the old rows in place.
1443        let db = broken(
1444            "DROP TABLE session_branches;
1445             CREATE TABLE session_branches (session_id TEXT);",
1446        );
1447        db.replace_messages("s", &[row("old", "kept")]).unwrap();
1448        assert!(db.replace_transcript("s", &msgs, &one).is_err());
1449        assert_eq!(db.get_messages("s").unwrap()[0].content, "kept");
1450        assert!(db.get_branches("s").is_err());
1451
1452        let db = broken(
1453            "DROP TABLE branch_messages;
1454             CREATE TABLE branch_messages (session_id TEXT);",
1455        );
1456        assert!(db.replace_transcript("s", &msgs, &one).is_err());
1457        assert!(db.get_branches("s").is_err());
1458
1459        let db = broken("DROP TABLE branch_messages;");
1460        assert!(db.replace_transcript("s", &msgs, &[]).is_err());
1461        assert!(db.delete_session("s").is_err());
1462
1463        let db = broken("DROP TABLE session_branches;");
1464        assert!(db.replace_transcript("s", &msgs, &[]).is_err());
1465        assert!(db.get_branches("s").is_err());
1466
1467        let db = broken("DROP TABLE messages;");
1468        assert!(db.replace_transcript("s", &msgs, &[]).is_err());
1469    }
1470
1471    #[test]
1472    fn test_insert_message_foreign_key() {
1473        let db = test_db();
1474        assert!(
1475            db.insert_message("m1", "missing", "user", "hi", None, None)
1476                .is_err()
1477        );
1478    }
1479
1480    #[test]
1481    fn test_usage_totals_empty_database() {
1482        let db = test_db();
1483        let t = db.usage_totals().unwrap();
1484        assert_eq!(t.session_count, 0);
1485        assert_eq!(t.message_count, 0);
1486        assert!(t.usage.is_empty());
1487    }
1488
1489    #[test]
1490    fn test_memories_crud() {
1491        let db = test_db();
1492        let emb = [1_u8, 2, 3, 4];
1493        db.insert_memory("mem1", "proj", "note", &emb, Some("s1"))
1494            .unwrap();
1495        db.insert_memory("mem2", "proj", "other", &emb, None)
1496            .unwrap();
1497        db.insert_memory("mem3", "elsewhere", "x", &emb, None)
1498            .unwrap();
1499
1500        let got = db.get_memory("mem1").unwrap().unwrap();
1501        assert_eq!(got.project_key, "proj");
1502        assert_eq!(got.text, "note");
1503        assert_eq!(got.embedding, emb);
1504        assert_eq!(got.source_session.as_deref(), Some("s1"));
1505        assert!(got.last_recalled_at.is_none());
1506        assert_eq!(got.recall_count, 0);
1507        assert!(db.get_memory("missing").unwrap().is_none());
1508
1509        let listed = db.list_memories("proj", 1).unwrap();
1510        assert_eq!(listed.len(), 1);
1511        assert_eq!(db.count_memories("proj").unwrap(), 2);
1512
1513        db.touch_memory_recall("mem1").unwrap();
1514        let got = db.get_memory("mem1").unwrap().unwrap();
1515        assert_eq!(got.recall_count, 1);
1516        assert!(got.last_recalled_at.is_some());
1517
1518        assert!(db.delete_memory("mem2").unwrap());
1519        assert!(!db.delete_memory("mem2").unwrap());
1520        assert_eq!(db.count_memories("proj").unwrap(), 1);
1521        assert_eq!(db.clear_memories("proj").unwrap(), 1);
1522        assert_eq!(db.count_memories("proj").unwrap(), 0);
1523        assert!(db.list_memories("proj", 10).unwrap().is_empty());
1524    }
1525
1526    #[test]
1527    fn test_code_chunks() {
1528        let db = test_db();
1529        let emb = [9_u8, 8, 7];
1530        db.insert_code_chunk("c1", "proj", "src/a.rs", 1, 10, "fn a() {}", &emb)
1531            .unwrap();
1532        db.insert_code_chunk("c2", "proj", "src/b.rs", 2, 4, "fn b() {}", &emb)
1533            .unwrap();
1534        db.insert_code_chunk("c3", "other", "x.rs", 1, 1, "x", &emb)
1535            .unwrap();
1536
1537        let rows = db.list_code_chunks("proj", 10).unwrap();
1538        assert_eq!(rows.len(), 2);
1539        assert_eq!(rows[0].path, "src/a.rs");
1540        assert_eq!(rows[0].start_line, 1);
1541        assert_eq!(rows[0].end_line, 10);
1542        assert_eq!(rows[0].text, "fn a() {}");
1543        assert_eq!(rows[0].embedding, emb);
1544        assert_eq!(db.list_code_chunks("proj", 1).unwrap().len(), 1);
1545        assert_eq!(db.clear_code_chunks("proj").unwrap(), 2);
1546        assert!(db.list_code_chunks("proj", 10).unwrap().is_empty());
1547    }
1548
1549    #[test]
1550    fn test_session_chunks() {
1551        let db = test_db();
1552        let emb = [4_u8, 5];
1553        db.insert_session_chunk("sc1", "proj", "s1", 0, "turn0", &emb)
1554            .unwrap();
1555        db.insert_session_chunk("sc2", "proj", "s1", 1, "turn1", &emb)
1556            .unwrap();
1557        db.insert_session_chunk("sc3", "other", "s2", 0, "x", &emb)
1558            .unwrap();
1559
1560        let rows = db.list_session_chunks("proj", 10).unwrap();
1561        assert_eq!(rows.len(), 2);
1562        assert!(rows.iter().all(|r| r.project_key == "proj"));
1563        assert!(rows.iter().any(|r| r.turn_index == 0 && r.text == "turn0"));
1564        assert_eq!(rows.iter().find(|r| r.id == "sc1").unwrap().embedding, emb);
1565        assert_eq!(db.list_session_chunks("proj", 1).unwrap().len(), 1);
1566    }
1567
1568    #[test]
1569    fn test_sql_error_paths_after_dropping_schema() {
1570        let db = test_db();
1571        db.drop_sessions_table_for_test().unwrap();
1572        assert!(db.list_sessions().is_err());
1573        db.conn
1574            .execute_batch(
1575                "
1576                DROP TABLE IF EXISTS session_chunks;
1577                DROP TABLE IF EXISTS code_chunks;
1578                DROP TABLE IF EXISTS memories;
1579                DROP TABLE IF EXISTS messages;
1580                DROP TABLE IF EXISTS state;
1581                ",
1582            )
1583            .unwrap();
1584
1585        let usage = Usage::default();
1586        let now = "2020-01-01T00:00:00+00:00";
1587        assert!(db.upsert_session("s", "t", "/p", now, now, &usage).is_err());
1588        assert!(db.create_session("s", "t", "/p").is_err());
1589        assert!(db.get_session("s").is_err());
1590        assert!(db.list_sessions().is_err());
1591        assert!(db.usage_totals().is_err());
1592        assert!(db.update_title("s", "t").is_err());
1593        assert!(db.delete_session("s").is_err());
1594        assert!(
1595            db.insert_message("m", "s", "user", "hi", None, None)
1596                .is_err()
1597        );
1598        assert!(db.delete_messages("s").is_err());
1599        assert!(db.replace_messages("s", &[]).is_err());
1600        assert!(db.message_counts_by_session().is_err());
1601        assert!(db.get_messages("s").is_err());
1602        assert!(db.message_count("s").is_err());
1603        assert!(db.get_state("k").is_err());
1604        assert!(db.set_state("k", "v").is_err());
1605        assert!(db.delete_state("k").is_err());
1606        assert!(db.insert_memory("id", "pk", "t", &[], None).is_err());
1607        assert!(db.get_memory("id").is_err());
1608        assert!(db.list_memories("pk", 10).is_err());
1609        assert!(db.delete_memory("id").is_err());
1610        assert!(db.clear_memories("pk").is_err());
1611        assert!(db.touch_memory_recall("id").is_err());
1612        assert!(db.count_memories("pk").is_err());
1613        assert!(
1614            db.insert_code_chunk("id", "pk", "p.rs", 1, 2, "t", &[])
1615                .is_err()
1616        );
1617        assert!(db.list_code_chunks("pk", 10).is_err());
1618        assert!(db.clear_code_chunks("pk").is_err());
1619        assert!(
1620            db.insert_session_chunk("id", "pk", "s", 0, "t", &[])
1621                .is_err()
1622        );
1623        assert!(db.list_session_chunks("pk", 10).is_err());
1624    }
1625
1626    #[test]
1627    fn open_existing_readonly_none_when_missing() {
1628        let tmp = tempfile::tempdir().unwrap();
1629        let path = tmp.path().join("no-such.db");
1630        assert!(
1631            Database::open_existing_readonly(&path.to_string_lossy())
1632                .unwrap()
1633                .is_none()
1634        );
1635    }
1636
1637    #[test]
1638    fn open_existing_readonly_lists_sessions() {
1639        let tmp = tempfile::tempdir().unwrap();
1640        let path = tmp.path().join("whycodes.db");
1641        {
1642            let db = Database::open(path.to_str().unwrap()).unwrap();
1643            db.create_session("abc123456789", "t", "/p").unwrap();
1644        }
1645        let ro = Database::open_existing_readonly(path.to_str().unwrap())
1646            .unwrap()
1647            .expect("existing db");
1648        let sessions = ro.list_sessions().unwrap();
1649        assert_eq!(sessions.len(), 1);
1650        assert_eq!(sessions[0].id, "abc123456789");
1651    }
1652
1653    #[test]
1654    fn fts_match_query_quotes_words_and_drops_noise() {
1655        assert_eq!(
1656            fts_match_query("Bundled SQLite, bundled! a").as_deref(),
1657            Some("\"bundled\" OR \"sqlite\"")
1658        );
1659        // FTS operators in user text are quoted, not parsed.
1660        assert_eq!(
1661            fts_match_query("NOT x* OR \"y").as_deref(),
1662            Some("\"not\" OR \"or\"")
1663        );
1664        assert_eq!(fts_match_query("  ? ! a "), None);
1665        let many = (0..40)
1666            .map(|i| format!("w{i}"))
1667            .collect::<Vec<_>>()
1668            .join(" ");
1669        assert_eq!(fts_match_query(&many).unwrap().matches(" OR ").count(), 31);
1670    }
1671
1672    #[test]
1673    fn search_memories_ranks_matches_and_counts_without_loading_the_bank() {
1674        let db = test_db();
1675        db.insert_memory("m1", "p", "windows ci links bundled sqlite", b"", None)
1676            .unwrap();
1677        db.insert_memory("m2", "p", "sqlite sqlite sqlite wal mode", b"", None)
1678            .unwrap();
1679        db.insert_memory("m3", "p", "release notes live in docs", b"", None)
1680            .unwrap();
1681        db.insert_memory("m4", "other", "sqlite in another bank", b"", None)
1682            .unwrap();
1683
1684        let page = db.search_memories("p", "sqlite", 1).unwrap();
1685        assert_eq!((page.matched, page.corpus, page.rows.len()), (2, 3, 1));
1686        assert_eq!(page.rows[0].id, "m2", "bm25 ranks the denser match first");
1687
1688        let none = db.search_memories("p", "kubernetes", 10).unwrap();
1689        assert_eq!((none.matched, none.corpus), (0, 3));
1690        assert!(none.rows.is_empty());
1691
1692        // No indexable word: newest-first browse over the bank.
1693        let browse = db.search_memories("p", "?", 2).unwrap();
1694        assert_eq!(
1695            (browse.matched, browse.corpus, browse.rows.len()),
1696            (3, 3, 2)
1697        );
1698
1699        db.delete_memory("m2").unwrap();
1700        assert_eq!(db.search_memories("p", "sqlite", 5).unwrap().matched, 1);
1701        db.clear_memories("p").unwrap();
1702        assert_eq!(db.search_memories("p", "sqlite", 5).unwrap().corpus, 0);
1703    }
1704
1705    #[test]
1706    fn search_code_and_session_chunks_use_their_fts_index() {
1707        let db = test_db();
1708        db.insert_code_chunk("c1", "p", "b.rs", 1, 9, "fn list_memories()", b"")
1709            .unwrap();
1710        db.insert_code_chunk("c2", "p", "a.rs", 3, 7, "fn other()", b"")
1711            .unwrap();
1712        let code = db.search_code_chunks("p", "memories", 5).unwrap();
1713        assert_eq!((code.matched, code.corpus), (1, 2));
1714        assert_eq!(code.rows[0].path, "b.rs");
1715        let browse = db.search_code_chunks("p", "", 5).unwrap();
1716        assert_eq!(browse.rows[0].path, "a.rs", "browse is path order");
1717        db.clear_code_chunks("p").unwrap();
1718        assert_eq!(
1719            db.search_code_chunks("p", "memories", 5).unwrap().matched,
1720            0
1721        );
1722
1723        db.insert_session_chunk("s1", "p", "ses", 0, "User: retry loop", b"")
1724            .unwrap();
1725        db.insert_session_chunk("s2", "p", "ses", 1, "User: unrelated", b"")
1726            .unwrap();
1727        let sess = db.search_session_chunks("p", "retry", 5).unwrap();
1728        assert_eq!((sess.matched, sess.corpus), (1, 2));
1729        assert_eq!(sess.rows[0].id, "s1");
1730        assert_eq!(db.search_session_chunks("p", "", 5).unwrap().rows.len(), 2);
1731    }
1732
1733    #[test]
1734    fn search_without_fts_index_scans_the_bank() {
1735        let db = test_db();
1736        db.insert_memory("m1", "p", "sqlite", b"", None).unwrap();
1737        db.drop_fts_for_test().unwrap();
1738        // Writes still work once the triggers are gone with the index.
1739        db.insert_memory("m2", "p", "unrelated", b"", None).unwrap();
1740        let page = db.search_memories("p", "sqlite", 1).unwrap();
1741        assert_eq!((page.rows.len(), page.matched, page.corpus), (2, 2, 2));
1742    }
1743}