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
9pub struct Database {
11 conn: Connection,
12}
13
14pub type MessageInsert = (
17 String,
18 String,
19 String,
20 Option<String>,
21 Option<String>,
22 String,
23);
24
25pub type BranchInsert = (Option<String>, Vec<String>);
28
29const 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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
768pub const FTS_SCAN_LIMIT: usize = 50_000;
770
771#[derive(Debug, Clone)]
773pub struct FtsPage<T> {
774 pub rows: Vec<T>,
776 pub matched: usize,
778 pub corpus: usize,
780}
781
782pub 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
812fn 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
825fn 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}
904fn 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 #[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 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 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 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 #[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 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 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 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 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 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}