1use std::collections::HashMap;
6use std::fs;
7use std::num::{NonZeroU64, NonZeroUsize};
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use anyhow::{anyhow, bail, Context, Result};
14use chrono::{DateTime, TimeZone, Utc};
15use fuzzy_matcher::skim::SkimMatcherV2;
16use fuzzy_matcher::FuzzyMatcher;
17use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
18use nucleo_matcher::{Config as NucleoConfig, Matcher as NucleoMatcher, Utf32Str};
19use rayon::prelude::*;
20use rusqlite::{params, Connection, ErrorCode, OptionalExtension};
21
22use crate::hashing::FramedSha256;
23use crate::message_search::selected_message_field_parts;
24use crate::models::{
25 AnalysisRequest, EditOp, FileCrossRef, FileEdit, FileEditSummary, FileQuery,
26 MessageClassificationMatch, MessageFilters, MessageHit, MessageSearchMode, ParsedSession,
27 ParserHealth, PlanningCount, Provider, ProviderParserHealth, Role, SearchExplain, SearchField,
28 SearchFilters, SearchHit, SessionRecord, SessionTimeProfile, SessionWithTranscript,
29};
30use crate::runtime::ExecutionRuntime;
31use crate::util::{
32 fold_caseless, snippet_from_match, snippet_from_match_while, UnicodeLowerNeedle, SIGMAS,
33};
34
35pub const SCHEMA_VERSION: i64 = 5;
62const PARSER_SCHEMA_VERSION: i64 = 5;
63const PRE_FTS_PARSER_SCHEMA_VERSION: i64 = 3;
64pub const MIN_READABLE_SCHEMA_VERSION: i64 = 2;
68
69#[derive(Debug, Clone, PartialEq, Eq)]
73pub(crate) enum SchemaState {
74 Missing,
75 Current,
76 Older {
77 current: i64,
78 required: i64,
79 },
80 Newer {
81 current: i64,
82 supported: i64,
83 },
84 UnknownProviders {
91 providers: Vec<String>,
92 },
93 RepairableLayout {
98 reason: String,
99 },
100 RecoveryRequired {
101 reason: String,
102 },
103}
104
105impl SchemaState {
106 pub(crate) fn from_version(current: i64) -> Self {
107 match current.cmp(&SCHEMA_VERSION) {
108 std::cmp::Ordering::Less => Self::Older {
109 current,
110 required: SCHEMA_VERSION,
111 },
112 std::cmp::Ordering::Equal => Self::Current,
113 std::cmp::Ordering::Greater => Self::Newer {
114 current,
115 supported: SCHEMA_VERSION,
116 },
117 }
118 }
119
120 pub(crate) fn unknown_providers_message(path: &Path, providers: &[String]) -> String {
123 format!(
124 "index {} contains sessions from provider(s) {} that this aise {} does not recognize, \
125 so a newer aise wrote it; upgrade aise (`aise package update`) before opening it",
126 path.display(),
127 providers.join(", "),
128 env!("CARGO_PKG_VERSION")
129 )
130 }
131}
132
133pub(crate) fn unknown_index_providers(conn: &Connection) -> Result<Vec<String>> {
137 let table_exists: bool = conn.query_row(
138 "select exists(select 1 from sqlite_schema where type = 'table' and name = 'sessions')",
139 [],
140 |row| row.get(0),
141 )?;
142 if !table_exists {
143 return Ok(Vec::new());
144 }
145 let mut statement = conn.prepare("select distinct provider from sessions order by provider")?;
146 let providers = statement
147 .query_map([], |row| row.get::<_, String>(0))?
148 .collect::<rusqlite::Result<Vec<_>>>()?;
149 Ok(providers
150 .into_iter()
151 .filter(|provider| Provider::from_db_str(provider).is_err())
152 .collect())
153}
154
155#[derive(Debug, Clone, Copy, Eq, PartialEq)]
156pub enum MessageOrder {
157 OldestFirst,
158 NewestFirst,
159}
160
161#[derive(Debug, Clone, Copy, Eq, PartialEq)]
162pub(crate) enum MessageBatchControl {
163 Continue,
164 Stop,
165}
166
167#[derive(Debug)]
168pub(crate) struct MessageBatchVisitOutcome {
169 pub(crate) rows_visited: usize,
170 pub(crate) exhausted: bool,
171 pub(crate) explain: Option<SearchExplain>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub(crate) enum SourceFileRefreshState {
176 Current,
177 ChangedSameParser,
178 ChangedParser,
179 New,
180}
181
182struct PreparedMessageQuery {
183 sql: String,
184 args: Vec<rusqlite::types::Value>,
185 explain: Option<SearchExplain>,
188}
189
190const TRIGRAM_PREFILTER_MIN_CORPUS: i64 = 50_000;
196
197const TRIGRAM_BASE_REBUILD_DELTA: i64 = TRIGRAM_PREFILTER_MIN_CORPUS;
203
204const FUZZY_SCORE_BATCH_SIZE: usize = 512;
209const SESSION_SCORE_BATCH_SIZE: usize = 128;
213const SESSION_SCORE_BATCH_BYTES: usize = 8 * 1024 * 1024;
214const FUZZY_BATCH_BYTES: usize = 8 * 1024 * 1024;
217
218const fn fuzzy_batch_is_full(rows: usize, bytes: usize) -> bool {
221 rows >= FUZZY_SCORE_BATCH_SIZE || bytes >= FUZZY_BATCH_BYTES
222}
223
224pub const DEFAULT_BUSY_TIMEOUT_MS: u64 = 5_000;
228
229pub const DEFAULT_AUTO_REINDEX_BUSY_TIMEOUT_MS: u64 = 10_000;
232
233pub const DEFAULT_AUTO_REINDEX_INTERVAL_MS: u64 = 1_500;
236
237const SQLITE_PAGE_CACHE_KIB: i64 = 64 * 1_024;
240
241const SQLITE_MMAP_BYTES: i64 = 256 * 1_024 * 1_024;
244
245pub(crate) const MESSAGE_KIND_INDEX: &str = "idx_messages_excluded_kinds";
266const MESSAGE_KIND_INDEX_COLUMNS: &str = "(kind, role, provider, session_id, ts, tool_name)";
267
268fn excluded_kinds_predicate() -> String {
273 let classes = crate::models::MessageKind::EXCLUDABLE_BY_DEFAULT
274 .iter()
275 .map(|kind| format!("'{}'", kind.as_str()))
276 .collect::<Vec<_>>()
277 .join(", ");
278 format!("kind in ({classes})")
279}
280
281type ProgressReporter = Box<dyn Fn(&str) + Send + Sync>;
283
284const SESSIONS_FTS_RETIRED_KEY: &str = "sessions_fts_retired";
286const AUTO_REINDEX_COMPLETED_MS_KEY: &str = "auto_reindex_completed_ms";
287const AUTO_REINDEX_COMPLETED_NS_KEY: &str = "auto_reindex_completed_ns";
288const AUTO_REINDEX_PARSER_CONTRACT_KEYS: [&str; 4] = [
289 "auto_reindex_parser_contract_0",
290 "auto_reindex_parser_contract_1",
291 "auto_reindex_parser_contract_2",
292 "auto_reindex_parser_contract_3",
293];
294const AUTO_REINDEX_FRESHNESS_SQL: &str =
295 "select completed.value, word0.value, word1.value, word2.value, word3.value
296 from index_metadata completed
297 join index_metadata word0 on word0.key = ?2
298 join index_metadata word1 on word1.key = ?3
299 join index_metadata word2 on word2.key = ?4
300 join index_metadata word3 on word3.key = ?5
301 where completed.key = ?1";
302const NANOSECONDS_PER_MILLISECOND: i64 = 1_000_000;
303const NANOSECONDS_PER_SECOND: i64 = 1_000_000_000;
304const CORRECTION_CANDIDATES_SQL: &str =
305 "select m.id, m.session_id, m.seq, m.provider, m.ts, m.content, m.authorship
306 from messages m
307 where m.record_relation = 'original'
308 and (
309 m.authorship = 'human'
310 or (
311 m.authorship = 'mixed'
312 and exists (
313 select 1
314 from message_content_parts p
315 where p.message_id = m.id and p.authorship = 'human'
316 )
317 )
318 )";
319const CORRECTION_HUMAN_PARTS_SQL: &str = "select m.id, p.start_char, p.end_char
320 from messages m
321 join message_content_parts p on p.message_id = m.id
322 where m.authorship = 'mixed'
323 and m.record_relation = 'original'
324 and p.authorship = 'human'";
325
326struct TrigramRebuild {
327 base_max: i64,
328 rebuilt: bool,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub(crate) struct StorageAllocation {
333 pub total_bytes: u64,
334 pub reclaimable_bytes: u64,
335}
336
337fn elapsed_ms(now_ms: i64, earlier_ms: i64) -> u64 {
338 now_ms.saturating_sub(earlier_ms).max(0) as u64
339}
340
341pub struct Db {
342 conn: Connection,
343 runtime: Arc<ExecutionRuntime>,
344 access_scope: crate::search_scope::EffectiveAccessScope,
345 prefilter_min_corpus: i64,
347 trigram_rebuild_delta: i64,
349 implicit_index_maintenance: bool,
352 progress: Option<ProgressReporter>,
357}
358
359pub(crate) struct AnalysisTraversalReceipt {
361 pub(crate) selected_sessions: u64,
362 pub(crate) messages_in_selected_sessions: u64,
363 pub(crate) analyzed_user_messages: u64,
364 pub(crate) has_more: bool,
365 pub(crate) last_selected_session_id: Option<String>,
366 pub(crate) max_selected_session_updated_at: Option<DateTime<Utc>>,
367 pub(crate) corpus_digest: String,
368}
369
370const QUERY_PROGRESS_HANDLER_OPCODES: i32 = 10_000;
371
372struct ProgressHandlerReset<'connection>(&'connection Connection);
373
374impl Drop for ProgressHandlerReset<'_> {
375 fn drop(&mut self) {
376 let cleared = self.0.progress_handler(0, None::<fn() -> bool>);
380 debug_assert!(
381 cleared.is_ok(),
382 "clearing the SQLite progress handler failed: {cleared:?}"
383 );
384 }
385}
386
387struct ReadSnapshotRollback<'connection> {
388 connection: &'connection Connection,
389 active: bool,
390}
391
392#[derive(Debug)]
398pub(crate) struct ReadSnapshotCleanupError {
399 operation: Option<String>,
400 cleanup: String,
401}
402
403impl ReadSnapshotCleanupError {
404 pub(crate) fn new(operation: Option<anyhow::Error>, cleanup: anyhow::Error) -> Self {
405 Self {
406 operation: operation.map(|error| format!("{error:#}")),
407 cleanup: format!("{cleanup:#}"),
408 }
409 }
410}
411
412impl std::fmt::Display for ReadSnapshotCleanupError {
413 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 match self.operation.as_deref() {
415 Some(operation) => write!(
416 formatter,
417 "message search failed ({operation}) and its read snapshot also failed to release: {}",
418 self.cleanup
419 ),
420 None => write!(
421 formatter,
422 "failed to release message-search read snapshot: {}",
423 self.cleanup
424 ),
425 }
426 }
427}
428
429impl std::error::Error for ReadSnapshotCleanupError {}
430
431impl<'connection> ReadSnapshotRollback<'connection> {
432 fn begin(connection: &'connection Connection) -> Result<Self> {
433 anyhow::ensure!(
434 connection.is_autocommit(),
435 "cannot start a read snapshot while another transaction is active"
436 );
437 connection.execute_batch("begin deferred")?;
438 Ok(Self {
439 connection,
440 active: true,
441 })
442 }
443
444 fn rollback(mut self) -> Result<()> {
445 self.connection.execute_batch("rollback")?;
446 self.active = false;
447 Ok(())
448 }
449}
450
451impl Drop for ReadSnapshotRollback<'_> {
452 fn drop(&mut self) {
453 if self.active {
454 let _ = self.connection.execute_batch("rollback");
455 }
456 }
457}
458
459#[derive(Debug)]
465pub(crate) struct QueryCancelled;
466
467impl std::fmt::Display for QueryCancelled {
468 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469 formatter.write_str("query cancelled")
470 }
471}
472
473impl std::error::Error for QueryCancelled {}
474
475pub(crate) struct QueryCancellation {
476 cancelled: Arc<AtomicBool>,
477 interrupt: Mutex<Option<rusqlite::InterruptHandle>>,
478}
479
480impl QueryCancellation {
481 pub(crate) fn new() -> Self {
482 Self {
483 cancelled: Arc::new(AtomicBool::new(false)),
484 interrupt: Mutex::new(None),
485 }
486 }
487
488 pub(crate) fn cancel(&self) {
489 self.cancelled.store(true, Ordering::Release);
490 let interrupt = self
491 .interrupt
492 .lock()
493 .unwrap_or_else(std::sync::PoisonError::into_inner);
494 if let Some(interrupt) = interrupt.as_ref() {
495 interrupt.interrupt();
496 }
497 }
498
499 pub(crate) fn is_cancelled(&self) -> bool {
500 self.cancelled.load(Ordering::Acquire)
501 }
502
503 pub(crate) fn ensure_active(&self) -> Result<()> {
504 if self.is_cancelled() {
505 return Err(QueryCancelled.into());
506 }
507 Ok(())
508 }
509
510 pub(crate) fn flag(&self) -> &AtomicBool {
511 self.cancelled.as_ref()
512 }
513
514 pub(crate) fn flag_arc(&self) -> Arc<AtomicBool> {
515 Arc::clone(&self.cancelled)
516 }
517
518 pub(crate) fn register(&self, connection: &Connection) {
519 let mut interrupt = self
520 .interrupt
521 .lock()
522 .unwrap_or_else(std::sync::PoisonError::into_inner);
523 *interrupt = Some(connection.get_interrupt_handle());
524 if self.is_cancelled() {
525 interrupt
526 .as_ref()
527 .expect("interrupt handle was installed above")
528 .interrupt();
529 }
530 }
531}
532
533#[cfg(test)]
534pub(crate) fn with_sqlite_query_timeout<T>(
535 connection: &Connection,
536 timeout_ms: Option<NonZeroU64>,
537 operation: &str,
538 recovery: &str,
539 run: impl FnOnce() -> Result<T>,
540) -> Result<T> {
541 with_sqlite_query_control(connection, timeout_ms, None, operation, recovery, run)
542}
543
544pub(crate) fn with_sqlite_query_control<T>(
545 connection: &Connection,
546 timeout_ms: Option<NonZeroU64>,
547 cancellation: Option<Arc<AtomicBool>>,
548 operation: &str,
549 recovery: &str,
550 run: impl FnOnce() -> Result<T>,
551) -> Result<T> {
552 if timeout_ms.is_none() && cancellation.is_none() {
555 return run();
556 }
557 let deadline = timeout_ms
558 .map(|timeout_ms| {
559 Instant::now()
560 .checked_add(Duration::from_millis(timeout_ms.get()))
561 .ok_or_else(|| anyhow!("{operation} timeout_ms is too large for this platform"))
562 })
563 .transpose()?;
564 let progress_cancellation = cancellation.clone();
565 connection
569 .progress_handler(
570 QUERY_PROGRESS_HANDLER_OPCODES,
571 Some(move || {
572 progress_cancellation
573 .as_ref()
574 .is_some_and(|flag| flag.load(Ordering::Acquire))
575 || deadline.is_some_and(|deadline| Instant::now() >= deadline)
576 }),
577 )
578 .with_context(|| format!("{operation} could not arm its query stop conditions"))?;
579 let reset = ProgressHandlerReset(connection);
580 let result = run();
581 drop(reset);
582 result.map_err(|error| {
583 if error.chain().any(|cause| {
584 matches!(
585 cause.downcast_ref::<rusqlite::Error>(),
586 Some(rusqlite::Error::SqliteFailure(inner, _))
587 if inner.code == rusqlite::ErrorCode::OperationInterrupted
588 )
589 }) {
590 if cancellation
591 .as_ref()
592 .is_some_and(|flag| flag.load(Ordering::Acquire))
593 {
594 anyhow!("{operation} was cancelled")
595 } else if let Some(timeout_ms) = timeout_ms {
596 anyhow!(
597 "{operation} timed out after {} ms; {recovery}",
598 timeout_ms.get()
599 )
600 } else {
601 error
602 }
603 } else {
604 error
605 }
606 })
607}
608
609macro_rules! session_record_columns {
610 () => {
611 "s.id, s.provider, s.provider_session_id, s.title, s.summary, s.cwd, s.repo_root, \
612 s.created_at, s.updated_at, s.last_message_at, s.preview_text, s.source_path, \
613 s.message_count, s.parse_version, s.raw_metadata_json, s.parse_warning, s.discovery_source, \
614 s.parent_session_id, s.agent_label"
615 };
616}
617
618impl Db {
619 pub fn open(path: &Path) -> Result<Self> {
620 Self::open_with_busy_timeout(path, DEFAULT_BUSY_TIMEOUT_MS)
621 }
622
623 pub fn open_with_busy_timeout(path: &Path, busy_timeout_ms: u64) -> Result<Self> {
624 let worker_threads = std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN);
625 Self::open_with_threads(path, busy_timeout_ms, worker_threads)
626 }
627
628 pub fn open_with_threads(
630 path: &Path,
631 busy_timeout_ms: u64,
632 worker_threads: NonZeroUsize,
633 ) -> Result<Self> {
634 if let Some(parent) = path.parent() {
635 fs::create_dir_all(parent)
636 .with_context(|| format!("failed to create {}", parent.display()))?;
637 }
638 let conn = Connection::open(path)?;
639 conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
640 crate::sql_functions::register(&conn)?;
641 let db = Self {
642 conn,
643 runtime: Arc::new(ExecutionRuntime::new(worker_threads)),
644 access_scope: crate::search_scope::EffectiveAccessScope::All,
645 prefilter_min_corpus: TRIGRAM_PREFILTER_MIN_CORPUS,
646 trigram_rebuild_delta: TRIGRAM_BASE_REBUILD_DELTA,
647 implicit_index_maintenance: true,
648 progress: None,
649 };
650 db.init()?;
651 Ok(db)
652 }
653
654 pub(crate) fn open_existing_read_only_with_threads(
661 path: &Path,
662 busy_timeout_ms: u64,
663 worker_threads: NonZeroUsize,
664 ) -> Result<Self> {
665 Self::open_existing_read_only_with_runtime(
666 path,
667 busy_timeout_ms,
668 Arc::new(ExecutionRuntime::new(worker_threads)),
669 )
670 }
671
672 pub(crate) fn open_existing_read_only_with_runtime(
678 path: &Path,
679 busy_timeout_ms: u64,
680 runtime: Arc<ExecutionRuntime>,
681 ) -> Result<Self> {
682 let flags = rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
683 | rusqlite::OpenFlags::SQLITE_OPEN_URI
684 | rusqlite::OpenFlags::SQLITE_OPEN_PRIVATE_CACHE;
685 let conn = Connection::open_with_flags(path, flags).with_context(|| {
691 format!(
692 "failed to open existing index {} read-only even though it was just verified to \
693 exist; check file permissions, or another process may have removed or \
694 relocated it in the meantime",
695 path.display()
696 )
697 })?;
698 conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
699 crate::sql_functions::register(&conn)?;
700 conn.pragma_update(None, "query_only", true)?;
701 conn.pragma_update(None, "foreign_keys", true)?;
702 conn.pragma_update(None, "temp_store", 2_i64)?;
703 conn.pragma_update(None, "cache_size", -SQLITE_PAGE_CACHE_KIB)?;
704 conn.pragma_update(None, "mmap_size", SQLITE_MMAP_BYTES)?;
705 Ok(Self {
706 conn,
707 runtime,
708 access_scope: crate::search_scope::EffectiveAccessScope::All,
709 prefilter_min_corpus: TRIGRAM_PREFILTER_MIN_CORPUS,
710 trigram_rebuild_delta: TRIGRAM_BASE_REBUILD_DELTA,
711 implicit_index_maintenance: false,
712 progress: None,
713 })
714 }
715
716 pub(crate) fn execution_runtime(&self) -> Arc<ExecutionRuntime> {
720 Arc::clone(&self.runtime)
721 }
722
723 pub fn worker_threads(&self) -> usize {
725 self.runtime.worker_threads()
726 }
727
728 #[cfg(test)]
729 fn worker_pool_build_count(&self) -> usize {
730 self.runtime.pool_build_count()
731 }
732
733 pub(crate) fn interrupt_handle(&self) -> rusqlite::InterruptHandle {
734 self.conn.get_interrupt_handle()
735 }
736
737 pub(crate) fn interrupt_while(&self, cancellation: Arc<AtomicBool>) -> Result<()> {
742 self.conn
743 .progress_handler(
744 QUERY_PROGRESS_HANDLER_OPCODES,
745 Some(move || cancellation.load(Ordering::Acquire)),
746 )
747 .context("could not arm query cancellation on this connection")
748 }
749
750 pub(crate) fn install_query_cancellation(
751 &self,
752 cancellation: &QueryCancellation,
753 ) -> Result<()> {
754 self.interrupt_while(cancellation.flag_arc())?;
755 cancellation.register(&self.conn);
756 Ok(())
757 }
758
759 pub(crate) fn with_read_snapshot<T>(&self, run: impl FnOnce() -> Result<T>) -> Result<T> {
760 if !self.conn.is_autocommit() {
761 return run();
762 }
763 let snapshot = ReadSnapshotRollback::begin(&self.conn)?;
764 let result = run();
765 let cleanup = snapshot.rollback();
766 match (result, cleanup) {
767 (Ok(value), Ok(())) => Ok(value),
768 (Err(error), Ok(())) => Err(error),
769 (Ok(_), Err(cleanup_error)) => {
770 Err(ReadSnapshotCleanupError::new(None, cleanup_error).into())
771 }
772 (Err(error), Err(cleanup_error)) => {
773 Err(ReadSnapshotCleanupError::new(Some(error), cleanup_error).into())
774 }
775 }
776 }
777
778 pub(crate) fn set_access_scope(
779 &mut self,
780 access_scope: crate::search_scope::EffectiveAccessScope,
781 ) {
782 self.access_scope = access_scope;
783 }
784
785 pub(crate) const fn access_scope(&self) -> &crate::search_scope::EffectiveAccessScope {
786 &self.access_scope
787 }
788
789 fn validate_access_scope(&self) -> Result<()> {
790 self.access_scope.validate_stable()
791 }
792
793 pub fn set_busy_timeout_ms(&self, busy_timeout_ms: u64) -> Result<()> {
794 self.conn
795 .busy_timeout(Duration::from_millis(busy_timeout_ms))?;
796 Ok(())
797 }
798
799 pub fn busy_timeout_ms(&self) -> Result<u64> {
800 let timeout: i64 = self
801 .conn
802 .query_row("pragma busy_timeout", [], |row| row.get(0))?;
803 Ok(timeout.max(0) as u64)
804 }
805
806 pub(crate) fn storage_allocation(&self) -> Result<StorageAllocation> {
808 let (page_size, page_count, freelist_count): (i64, i64, i64) = self.conn.query_row(
809 "select (select page_size from pragma_page_size),
810 (select page_count from pragma_page_count),
811 (select freelist_count from pragma_freelist_count)",
812 [],
813 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
814 )?;
815 if page_size < 0 || page_count < 0 || freelist_count < 0 {
816 return Err(anyhow::anyhow!(
817 "SQLite returned negative allocation metadata: page_size={page_size}, page_count={page_count}, freelist_count={freelist_count}"
818 ));
819 }
820 let page_size = page_size as u64;
821 let total_bytes = page_size.checked_mul(page_count as u64).ok_or_else(|| {
822 anyhow::anyhow!(
823 "SQLite allocation size overflow: page_size={page_size}, page_count={page_count}"
824 )
825 })?;
826 let reclaimable_bytes = page_size
827 .checked_mul(freelist_count as u64)
828 .ok_or_else(|| {
829 anyhow::anyhow!(
830 "SQLite reclaimable size overflow: page_size={page_size}, freelist_count={freelist_count}"
831 )
832 })?;
833 Ok(StorageAllocation {
834 total_bytes,
835 reclaimable_bytes,
836 })
837 }
838
839 pub fn with_busy_timeout_ms<T>(
840 &self,
841 busy_timeout_ms: u64,
842 f: impl FnOnce() -> Result<T>,
843 ) -> Result<T> {
844 let original = self.busy_timeout_ms()?;
845 self.set_busy_timeout_ms(busy_timeout_ms)?;
846 let result = f();
847 let restore = self.set_busy_timeout_ms(original);
848 match (result, restore) {
849 (Ok(value), Ok(())) => Ok(value),
850 (Err(err), Ok(())) => Err(err),
851 (Ok(_), Err(err)) => Err(err),
852 (Err(err), Err(restore_err)) => Err(err.context(format!(
856 "also failed to restore busy_timeout to its prior value ({original}ms): {restore_err:#}"
857 ))),
858 }
859 }
860
861 fn with_immediate_transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
862 self.conn.execute_batch("begin immediate")?;
863 let result = f();
864 match result {
865 Ok(value) => {
866 self.conn.execute_batch("commit")?;
867 Ok(value)
868 }
869 Err(err) => {
870 let _ = self.conn.execute_batch("rollback");
871 Err(err)
872 }
873 }
874 }
875
876 pub fn is_sqlite_busy_error(err: &anyhow::Error) -> bool {
877 err.chain().any(|source| {
878 source
879 .downcast_ref::<rusqlite::Error>()
880 .and_then(rusqlite::Error::sqlite_error_code)
881 .is_some_and(|code| {
882 matches!(code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
883 })
884 })
885 }
886
887 pub fn auto_reindex_is_fresh(&self, interval_ms: u64) -> Result<bool> {
888 self.auto_reindex_is_fresh_at(Utc::now().timestamp_millis(), interval_ms)
889 }
890
891 fn auto_reindex_is_fresh_at(&self, now_ms: i64, interval_ms: u64) -> Result<bool> {
892 let Some((completed_ms, parser_contract)) = self.auto_reindex_freshness_metadata()? else {
893 return Ok(false);
894 };
895 Ok(elapsed_ms(now_ms, completed_ms) < interval_ms
896 && parser_contract == crate::source::provider_parse_contract_fingerprint())
897 }
898
899 pub fn mark_auto_reindex_complete(&self) -> Result<()> {
900 self.mark_auto_reindex_complete_at(Utc::now())
901 }
902
903 pub fn auto_reindex_completed_at(&self) -> Result<Option<DateTime<Utc>>> {
904 if let Some(value) = self.index_metadata_i64(AUTO_REINDEX_COMPLETED_NS_KEY)? {
905 return Ok(DateTime::from_timestamp(
906 value.div_euclid(NANOSECONDS_PER_SECOND),
907 value.rem_euclid(NANOSECONDS_PER_SECOND) as u32,
908 ));
909 }
910 Ok(self
911 .index_metadata_i64(AUTO_REINDEX_COMPLETED_MS_KEY)?
912 .and_then(|value| Utc.timestamp_millis_opt(value).single()))
913 }
914
915 pub(crate) fn mark_auto_reindex_complete_at(&self, completed_at: DateTime<Utc>) -> Result<()> {
916 let completed_ms = completed_at.timestamp_millis();
917 let completed_ns = completed_at
918 .timestamp_nanos_opt()
919 .unwrap_or_else(|| completed_ms.saturating_mul(NANOSECONDS_PER_MILLISECOND));
920 let parser_contract = crate::source::provider_parse_contract_fingerprint();
921 self.conn.execute(
922 "insert into index_metadata (key, value) values
923 (?1, ?2), (?3, ?4), (?5, ?6), (?7, ?8), (?9, ?10), (?11, ?12)
924 on conflict(key) do update set value = excluded.value",
925 params![
926 AUTO_REINDEX_COMPLETED_MS_KEY,
927 completed_ms,
928 AUTO_REINDEX_COMPLETED_NS_KEY,
929 completed_ns,
930 AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
931 parser_contract[0],
932 AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
933 parser_contract[1],
934 AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
935 parser_contract[2],
936 AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
937 parser_contract[3],
938 ],
939 )?;
940 Ok(())
941 }
942
943 fn auto_reindex_freshness_metadata(&self) -> Result<Option<(i64, [i64; 4])>> {
949 self.conn
950 .query_row(
951 AUTO_REINDEX_FRESHNESS_SQL,
952 params![
953 AUTO_REINDEX_COMPLETED_MS_KEY,
954 AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
955 AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
956 AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
957 AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
958 ],
959 |row| {
960 Ok((
961 row.get(0)?,
962 [row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?],
963 ))
964 },
965 )
966 .optional()
967 .map_err(Into::into)
968 }
969
970 fn index_metadata_i64(&self, key: &str) -> Result<Option<i64>> {
971 self.conn
972 .query_row(
973 "select value from index_metadata where key = ?1",
974 [key],
975 |row| row.get(0),
976 )
977 .optional()
978 .map_err(Into::into)
979 }
980
981 pub(crate) fn set_implicit_index_maintenance(&mut self, enabled: bool) {
983 self.implicit_index_maintenance = enabled;
984 }
985
986 pub fn set_progress_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
990 self.progress = Some(Box::new(reporter));
991 }
992
993 fn report_progress(&self, message: &str) {
995 if let Some(reporter) = &self.progress {
996 reporter(message);
997 }
998 }
999
1000 fn init(&self) -> Result<()> {
1001 self.conn.execute_batch(
1002 "
1003 pragma journal_mode = wal;
1004 pragma foreign_keys = on;
1005 -- Read-perf tuning for the analytics/search workload over a multi-GB index:
1006 -- queries like `corrections` fetch thousands of message rows by rowid (the
1007 -- role/ts index doesn't cover `content`), which is random I/O across the file.
1008 -- A larger page cache + memory-mapped reads + in-memory temp store cut that
1009 -- cost; synchronous=normal is the documented-safe durability level under WAL.
1010 pragma synchronous = normal;
1011 pragma temp_store = memory;
1012 create table if not exists sessions (
1013 id text primary key,
1014 provider text not null,
1015 provider_session_id text not null,
1016 title text,
1017 summary text,
1018 cwd text,
1019 repo_root text,
1020 created_at text,
1021 updated_at text,
1022 last_message_at text,
1023 preview_text text not null,
1024 source_path text not null,
1025 message_count integer,
1026 parse_version text not null,
1027 raw_metadata_json text,
1028 parse_warning text,
1029 discovery_source text not null,
1030 parent_session_id text,
1031 agent_label text
1032 );
1033 create table if not exists transcripts (
1034 session_id text primary key references sessions(id) on delete cascade,
1035 transcript_text text not null
1036 );
1037 create table if not exists files_seen (
1038 provider text not null,
1039 source_path text not null,
1040 mtime_ns integer not null,
1041 size_bytes integer not null,
1042 last_indexed_at text not null,
1043 content_hash text,
1044 parse_version text not null default '',
1045 -- Incremental tail-parse checkpoint (§7): byte offset (at a newline boundary)
1046 -- up to which the file is parsed, and a fingerprint of the file's leading bytes
1047 -- used to detect rewrite/rotation. NULL = no checkpoint → always a full parse.
1048 tail_byte_offset integer,
1049 prefix_fingerprint text,
1050 primary key(provider, source_path)
1051 );
1052 create table if not exists index_metadata (
1053 key text primary key,
1054 value integer not null
1055 );
1056 create index if not exists idx_sessions_provider on sessions(provider);
1057 create index if not exists idx_sessions_updated_at on sessions(updated_at desc);
1058 create index if not exists idx_sessions_provider_id on sessions(provider_session_id);
1059 create table if not exists messages (
1060 id integer primary key,
1061 session_id text not null references sessions(id) on delete cascade,
1062 provider text not null,
1063 seq integer not null,
1064 role text not null,
1065 ts text,
1066 tool_name text,
1067 kind text not null default 'unknown',
1068 tool_call_id text,
1069 is_compaction integer not null default 0,
1070 content text not null,
1071 authorship text not null default 'unknown',
1072 correlation_authority text,
1073 correlation_scope text,
1074 correlation_id text,
1075 record_relation text not null default 'unknown'
1076 check (record_relation in ('original', 'mirror', 'unknown')),
1077 check (
1078 (correlation_authority is null and correlation_scope is null and correlation_id is null)
1079 or
1080 (correlation_authority is not null and correlation_scope is not null and correlation_id is not null)
1081 )
1082 );
1083 -- Bare ts index for date-range message filters that span all roles; the
1084 -- composites below lead with role/session_id and so cannot serve a bare
1085 -- `ts between ? and ?` scan.
1086 create index if not exists idx_messages_ts on messages(ts);
1087 -- Composite indexes serve the hot filter + ORDER BY combinations straight from
1088 -- the index (no temp B-tree sort) and, by leftmost-prefix, subsume a bare
1089 -- (session_id) or (role) index: (session_id, seq) covers `where session_id=?`
1090 -- [+ `order by seq`] (message search / get / context); (role, ts) covers
1091 -- `where role=?` [+ `order by ts`] (corrections / planning / stats). Older
1092 -- branch builds created standalone (session_id) and (role) indexes before these
1093 -- composites existed — drop them so every index converges on this final shape;
1094 -- they were pure write-amplification (an upstream index never had them).
1095 drop index if exists idx_messages_session;
1096 drop index if exists idx_messages_role;
1097 create index if not exists idx_messages_session_seq on messages(session_id, seq);
1098 create index if not exists idx_messages_provider_session_seq
1099 on messages(provider, session_id, seq);
1100 create index if not exists idx_messages_role_ts on messages(role, ts);
1101 create index if not exists idx_messages_tool_name
1102 on messages(tool_name) where tool_name is not null;
1103 create table if not exists file_edits (
1104 id integer primary key,
1105 session_id text not null references sessions(id) on delete cascade,
1106 provider text not null,
1107 seq integer not null,
1108 ts text,
1109 tool text not null,
1110 file_path text not null,
1111 file_name text not null,
1112 new_content text,
1113 edits_json text
1114 );
1115 create index if not exists idx_file_edits_session on file_edits(session_id);
1116 create index if not exists idx_file_edits_provider on file_edits(provider);
1117 create index if not exists idx_file_edits_path on file_edits(file_path);
1118 create index if not exists idx_file_edits_name on file_edits(file_name);
1119 ",
1120 )?;
1121 self.conn
1122 .pragma_update(None, "cache_size", -SQLITE_PAGE_CACHE_KIB)?;
1123 self.conn
1124 .pragma_update(None, "mmap_size", SQLITE_MMAP_BYTES)?;
1125 for (name, definition) in [
1129 ("kind", "kind text not null default 'unknown'"),
1130 ("tool_call_id", "tool_call_id text"),
1131 ("authorship", "authorship text not null default 'unknown'"),
1132 ("correlation_authority", "correlation_authority text"),
1133 ("correlation_scope", "correlation_scope text"),
1134 ("correlation_id", "correlation_id text"),
1135 (
1136 "record_relation",
1137 "record_relation text not null default 'unknown'",
1138 ),
1139 ] {
1140 let exists: bool = self.conn.query_row(
1141 "select exists(select 1 from pragma_table_info('messages') where name = ?1)",
1142 params![name],
1143 |row| row.get(0),
1144 )?;
1145 if !exists {
1146 self.conn
1147 .execute_batch(&format!("alter table messages add column {definition}"))?;
1148 }
1149 }
1150 self.conn.execute_batch(
1151 "drop index if exists idx_messages_authorship_role_provider_ts;
1152 create index if not exists idx_messages_tool_calls
1153 on messages(session_id, seq) where kind = 'tool_call';
1154 create index if not exists idx_messages_provider_session_seq
1155 on messages(provider, session_id, seq);
1156 create index if not exists idx_messages_session_authorship_relation_seq
1157 on messages(session_id, authorship, record_relation, seq);
1158 create index if not exists idx_messages_authorship_relation_ts
1159 on messages(authorship, record_relation, ts desc, session_id, seq);
1160 create index if not exists idx_messages_correlation
1161 on messages(correlation_authority, correlation_scope, correlation_id)
1162 where correlation_id is not null;
1163 create table if not exists message_content_parts (
1164 message_id integer not null references messages(id) on delete cascade,
1165 ordinal integer not null check (ordinal >= 0),
1166 start_char integer not null check (start_char >= 0),
1167 end_char integer not null check (end_char > start_char),
1168 authorship text not null,
1169 origin text not null,
1170 primary key(message_id, ordinal)
1171 ) without rowid;
1172 create trigger if not exists messages_correlation_all_or_none_insert
1173 before insert on messages
1174 when ((new.correlation_authority is not null)
1175 + (new.correlation_scope is not null)
1176 + (new.correlation_id is not null)) not in (0, 3)
1177 begin
1178 select raise(abort, 'message correlation authority, scope, and id must be all null or all non-null');
1179 end;
1180 create trigger if not exists messages_correlation_all_or_none_update
1181 before update of correlation_authority, correlation_scope, correlation_id on messages
1182 when ((new.correlation_authority is not null)
1183 + (new.correlation_scope is not null)
1184 + (new.correlation_id is not null)) not in (0, 3)
1185 begin
1186 select raise(abort, 'message correlation authority, scope, and id must be all null or all non-null');
1187 end;",
1188 )?;
1189 let retired: bool = self.conn.query_row(
1202 "select exists(select 1 from index_metadata where key = ?1)",
1203 params![SESSIONS_FTS_RETIRED_KEY],
1204 |row| row.get(0),
1205 )?;
1206 if !retired {
1207 self.conn
1208 .execute_batch("drop table if exists sessions_fts")?;
1209 self.conn.execute(
1210 "insert into index_metadata (key, value) values (?1, 1)
1211 on conflict(key) do update set value = excluded.value",
1212 params![SESSIONS_FTS_RETIRED_KEY],
1213 )?;
1214 }
1215 self.conn.execute_batch(
1216 "create virtual table if not exists sessions_fts using fts5(
1217 title, summary, preview_text, transcript_text
1218 )",
1219 )?;
1220 crate::fts::install_released_message_word_index(&self.conn)?;
1229 let messages_exist: bool =
1242 self.conn
1243 .query_row("select exists(select 1 from messages)", [], |row| {
1244 row.get(0)
1245 })?;
1246 if !messages_exist {
1250 self.ensure_message_kind_index()?;
1251 }
1252 if self.schema_version()? == 0 && !messages_exist {
1256 let tx = self.conn.unchecked_transaction()?;
1264 crate::fts::install_target_message_search_indexes(&tx)?;
1265 tx.execute_batch(&format!("pragma user_version = {SCHEMA_VERSION}"))?;
1266 tx.commit()?;
1267 }
1268 let indexed_messages_exist: bool = self.conn.query_row(
1269 "select exists(select 1 from messages_fts_docsize)",
1270 [],
1271 |row| row.get(0),
1272 )?;
1273 if messages_exist && !indexed_messages_exist {
1274 self.conn
1275 .execute_batch("insert into messages_fts(messages_fts) values('rebuild')")?;
1276 }
1277 if self.schema_version()? < 4 {
1288 crate::trigram_index::ensure_schema(&self.conn)?;
1289 self.conn.execute_batch(
1292 "drop trigger if exists messages_tri_ai;
1293 drop trigger if exists messages_tri_ad;
1294 drop trigger if exists messages_tri_au;
1295 drop table if exists messages_trigram_terms;
1296 drop table if exists messages_trigram_vocab;
1297 drop table if exists messages_trigram;",
1298 )?;
1299 }
1300 self.conn.execute_batch(
1303 "create virtual table if not exists messages_vocab
1304 using fts5vocab('messages_fts', 'row');",
1305 )?;
1306 let schema_version = self.schema_version()?;
1316 let message_search_layout_problem = match schema_version {
1317 4 => crate::indexer::message_search_layout_problem(&self.conn)?,
1318 SCHEMA_VERSION => crate::indexer::current_schema_layout_problem(&self.conn)?,
1319 _ => None,
1320 };
1321 if message_search_layout_problem.is_some() && crate::indexer::base_data_intact(&self.conn)?
1322 {
1323 self.migrate_message_search_schema_exclusive()?;
1324 }
1325 self.ensure_column("files_seen", "tail_byte_offset", "tail_byte_offset integer")?;
1330 self.ensure_column(
1331 "files_seen",
1332 "prefix_fingerprint",
1333 "prefix_fingerprint text",
1334 )?;
1335 let parse_version_added = self.ensure_column(
1336 "files_seen",
1337 "parse_version",
1338 "parse_version text not null default ''",
1339 )?;
1340 if parse_version_added {
1341 self.backfill_source_parse_versions()?;
1342 }
1343 self.ensure_column("sessions", "parent_session_id", "parent_session_id text")?;
1348 self.ensure_column("sessions", "agent_label", "agent_label text")?;
1349 Ok(())
1350 }
1351
1352 fn ensure_column(&self, table: &str, column: &str, column_decl: &str) -> Result<bool> {
1356 let present = self
1357 .conn
1358 .prepare(&format!("pragma table_info({table})"))?
1359 .query_map([], |row| row.get::<_, String>(1))?
1360 .filter_map(Result::ok)
1361 .any(|name| name == column);
1362 if !present {
1363 self.conn
1364 .execute_batch(&format!("alter table {table} add column {column_decl}"))?;
1365 }
1366 Ok(!present)
1367 }
1368
1369 fn backfill_source_parse_versions(&self) -> Result<()> {
1370 self.conn.execute_batch(
1371 "with source_versions as (
1372 select provider, source_path, min(parse_version) as parse_version
1373 from sessions
1374 group by provider, source_path
1375 having count(distinct parse_version) = 1
1376 )
1377 update files_seen
1378 set parse_version = coalesce((
1379 select source_versions.parse_version
1380 from source_versions
1381 where source_versions.provider = files_seen.provider
1382 and source_versions.source_path = files_seen.source_path
1383 ), '')
1384 where parse_version = ''",
1385 )?;
1386 Ok(())
1387 }
1388
1389 pub(crate) fn ensure_trigram_base(&self) -> Result<i64> {
1397 anyhow::ensure!(
1398 self.schema_version()? < 4,
1399 "custom trigram maintenance is unavailable on schema v4; SQLite FTS5 maintains messages_trigram incrementally"
1400 );
1401 if !crate::trigram_index::schema_is_compatible(&self.conn)? {
1402 anyhow::ensure!(
1407 self.implicit_index_maintenance,
1408 "substring/regex search index has an incompatible schema and automatic \
1409 maintenance is disabled; rerun without --index-refresh existing-only to rebuild it"
1410 );
1411 return self
1412 .rebuild_trigram_base_with_writer_lock()
1413 .map(|rebuild| rebuild.base_max);
1414 }
1415 let base_max = crate::trigram_index::base_max_id(&self.conn)?;
1416 if !self.implicit_index_maintenance {
1417 return Ok(base_max);
1418 }
1419 let max_id: i64 =
1420 self.conn
1421 .query_row("select coalesce(max(id), 0) from messages", [], |row| {
1422 row.get(0)
1423 })?;
1424 if (base_max == 0 && max_id > 0) || (max_id - base_max) > self.trigram_rebuild_delta {
1425 return match self.rebuild_trigram_base_with_writer_lock() {
1426 Ok(rebuild) => Ok(rebuild.base_max),
1427 Err(err) if Self::is_sqlite_busy_error(&err) => {
1428 self.report_progress(
1429 "substring/regex search index is already being updated; scanning the unindexed delta directly",
1430 );
1431 Ok(base_max)
1432 }
1433 Err(err) => Err(err),
1434 };
1435 }
1436 Ok(base_max)
1437 }
1438
1439 fn rebuild_trigram_base_with_writer_lock(&self) -> Result<TrigramRebuild> {
1440 self.with_immediate_transaction(|| {
1441 let schema_compatible = crate::trigram_index::schema_is_compatible(&self.conn)?;
1442 if !schema_compatible {
1443 self.report_progress(
1444 "substring/regex search index had an incompatible schema; rebuilding it from the message table",
1445 );
1446 self.conn.execute_batch(
1447 "drop table if exists trigram_postings; drop table if exists trigram_meta;",
1448 )?;
1449 crate::trigram_index::ensure_schema(&self.conn)?;
1450 }
1451 let base_max = crate::trigram_index::base_max_id(&self.conn)?;
1452 let max_id: i64 =
1453 self.conn
1454 .query_row("select coalesce(max(id), 0) from messages", [], |row| {
1455 row.get(0)
1456 })?;
1457 if schema_compatible
1458 && !((base_max == 0 && max_id > 0)
1459 || (max_id - base_max) > self.trigram_rebuild_delta)
1460 {
1461 return Ok(TrigramRebuild {
1462 base_max,
1463 rebuilt: false,
1464 });
1465 }
1466
1467 let count: i64 = self
1473 .conn
1474 .query_row("select count(*) from messages", [], |row| row.get(0))?;
1475 self.report_progress(&format!(
1476 "building substring/regex search index in parallel (one-time over {count} messages)…"
1477 ));
1478 let base_max = crate::trigram_index::build_in_current_transaction(
1479 &self.conn,
1480 &self.runtime,
1481 )?;
1482 Ok(TrigramRebuild {
1483 base_max,
1484 rebuilt: true,
1485 })
1486 })
1487 .and_then(|rebuild| {
1488 if rebuild.rebuilt {
1489 self.checkpoint_truncate()?;
1491 }
1492 Ok(rebuild)
1493 })
1494 }
1495
1496 fn stage_candidates(
1502 &self,
1503 base_max: i64,
1504 candidates: &std::collections::HashSet<i64>,
1505 ) -> Result<()> {
1506 self.conn.execute_batch(
1507 "create temp table if not exists _trigram_cand (id integer primary key);
1508 delete from _trigram_cand;",
1509 )?;
1510 let tx = self.conn.unchecked_transaction()?;
1514 {
1515 let mut stmt = tx.prepare("insert or ignore into _trigram_cand(id) values (?1)")?;
1516 for id in candidates {
1517 stmt.execute([id])?;
1518 }
1519 }
1520 tx.execute(
1521 "insert or ignore into _trigram_cand(id) select id from messages where id > ?1",
1522 [base_max],
1523 )?;
1524 tx.commit()?;
1525 Ok(())
1526 }
1527
1528 pub fn checkpoint_truncate(&self) -> Result<()> {
1533 self.conn
1535 .query_row("pragma wal_checkpoint(truncate)", [], |_| Ok(()))
1536 .or_else(|err| match err {
1537 rusqlite::Error::QueryReturnedNoRows => Ok(()),
1538 other => Err(other),
1539 })?;
1540 Ok(())
1541 }
1542
1543 #[cfg(test)]
1544 pub(crate) fn execute_batch_for_test(&self, sql: &str) -> Result<()> {
1545 self.conn.execute_batch(sql)?;
1546 Ok(())
1547 }
1548
1549 #[cfg(test)]
1550 pub(crate) fn wal_checkpoint_busy_for_test(&self) -> Result<i64> {
1551 self.conn
1552 .query_row("pragma wal_checkpoint(passive)", [], |row| row.get(0))
1553 .map_err(Into::into)
1554 }
1555
1556 pub fn optimize_fts(&self) -> Result<()> {
1564 self.conn
1565 .execute_batch("insert into messages_fts(messages_fts) values('optimize');")?;
1566 if self.schema_version()? >= 4 {
1567 self.conn.execute_batch(
1568 "insert into messages_trigram(messages_trigram) values('optimize');",
1569 )?;
1570 }
1571 Ok(())
1572 }
1573
1574 pub fn vacuum(&self) -> Result<()> {
1580 self.conn.execute_batch("vacuum")?;
1581 Ok(())
1582 }
1583
1584 pub fn needs_backfill(&self) -> Result<bool> {
1588 Ok(self.schema_version()? < PARSER_SCHEMA_VERSION)
1589 }
1590
1591 pub fn schema_is_readable(&self) -> Result<bool> {
1594 Ok(match self.schema_state()? {
1595 SchemaState::Current => true,
1596 SchemaState::Older { current, .. } => current >= MIN_READABLE_SCHEMA_VERSION,
1597 SchemaState::Missing
1598 | SchemaState::Newer { .. }
1599 | SchemaState::UnknownProviders { .. }
1600 | SchemaState::RepairableLayout { .. }
1601 | SchemaState::RecoveryRequired { .. } => false,
1602 })
1603 }
1604
1605 pub(crate) fn schema_state(&self) -> Result<SchemaState> {
1606 match SchemaState::from_version(self.schema_version()?) {
1607 SchemaState::Current => {
1608 let providers = unknown_index_providers(&self.conn)?;
1609 Ok(if providers.is_empty() {
1610 SchemaState::Current
1611 } else {
1612 SchemaState::UnknownProviders { providers }
1613 })
1614 }
1615 other => Ok(other),
1616 }
1617 }
1618
1619 pub fn schema_version(&self) -> Result<i64> {
1620 self.conn
1621 .query_row("pragma user_version", [], |row| row.get(0))
1622 .map_err(Into::into)
1623 }
1624
1625 pub(crate) fn migrate_message_search_schema_exclusive(&self) -> Result<()> {
1629 let schema_version = self.schema_version()?;
1630 if schema_version == SCHEMA_VERSION
1634 && crate::indexer::current_schema_layout_problem(&self.conn)?.is_none()
1635 {
1636 return Ok(());
1637 }
1638 if schema_version == 4
1639 && crate::indexer::message_search_layout_problem(&self.conn)?.is_none()
1640 {
1641 return Ok(());
1642 }
1643 let count: i64 = self
1648 .conn
1649 .query_row("select count(*) from messages", [], |row| row.get(0))?;
1650 self.report_progress(&format!(
1651 "rebuilding message-search indexes in place (one-time over {count} messages)…"
1652 ));
1653 let target_version = if schema_version == 4 {
1657 4
1658 } else {
1659 SCHEMA_VERSION
1660 };
1661 crate::fts::migrate_message_search_schema_offline(&self.conn, target_version)
1662 }
1663
1664 pub fn mark_schema_current(&self) -> Result<()> {
1672 let target = if self.schema_version()? >= 4 {
1673 PARSER_SCHEMA_VERSION
1674 } else {
1675 PRE_FTS_PARSER_SCHEMA_VERSION
1676 };
1677 self.conn
1678 .execute_batch(&format!("pragma user_version = {target}"))?;
1679 Ok(())
1680 }
1681
1682 pub fn clear_all(&self) -> Result<()> {
1687 self.with_immediate_transaction(|| {
1691 self.conn.execute_batch(
1692 "
1693 delete from transcripts;
1694 delete from messages;
1695 delete from file_edits;
1696 delete from sessions;
1697 delete from files_seen;
1698 ",
1699 )?;
1700 Ok(())
1701 })
1702 }
1703
1704 pub(crate) fn clear_trigram_base(&self) -> Result<()> {
1705 if self.schema_version()? >= 4 {
1706 return Ok(());
1707 }
1708 self.conn.execute_batch(
1709 "
1710 delete from trigram_postings;
1711 delete from trigram_meta;
1712 ",
1713 )?;
1714 Ok(())
1715 }
1716
1717 pub fn is_file_current(
1718 &self,
1719 provider: Provider,
1720 path: &str,
1721 mtime_ns: i64,
1722 size: i64,
1723 parse_version: &str,
1724 ) -> Result<bool> {
1725 Ok(
1726 self.source_file_refresh_state(provider, path, mtime_ns, size, parse_version)?
1727 == SourceFileRefreshState::Current,
1728 )
1729 }
1730
1731 pub(crate) fn source_file_refresh_state(
1738 &self,
1739 provider: Provider,
1740 path: &str,
1741 mtime_ns: i64,
1742 size: i64,
1743 parse_version: &str,
1744 ) -> Result<SourceFileRefreshState> {
1745 Ok(match self.file_seen_state(provider, path)? {
1746 None => SourceFileRefreshState::New,
1747 Some((_, _, stored_version)) if stored_version != parse_version => {
1748 SourceFileRefreshState::ChangedParser
1749 }
1750 Some((stored_mtime, stored_size, _))
1751 if stored_mtime == mtime_ns && stored_size == size =>
1752 {
1753 SourceFileRefreshState::Current
1754 }
1755 Some(_) => SourceFileRefreshState::ChangedSameParser,
1756 })
1757 }
1758
1759 fn file_seen_state(
1760 &self,
1761 provider: Provider,
1762 path: &str,
1763 ) -> Result<Option<(i64, i64, String)>> {
1764 self.conn
1765 .query_row(
1766 "select mtime_ns, size_bytes, parse_version from files_seen
1767 where provider = ?1 and source_path = ?2",
1768 params![provider.as_str(), path],
1769 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1770 )
1771 .optional()
1772 .map_err(Into::into)
1773 }
1774
1775 pub(crate) fn indexed_source_identities(
1776 &self,
1777 ) -> Result<Vec<(Provider, String, usize, String)>> {
1778 let mut stmt = self.conn.prepare(
1779 "select provider, source_path, count(*), min(id) from sessions
1780 group by provider, source_path",
1781 )?;
1782 let rows = stmt
1783 .query_map([], |row| {
1784 Ok((
1785 decode_db_enum(
1786 row.get::<_, String>(0)?,
1787 0,
1788 "sessions.provider",
1789 Provider::from_db_str,
1790 )?,
1791 row.get::<_, String>(1)?,
1792 row.get::<_, i64>(2)? as usize,
1793 row.get::<_, String>(3)?,
1794 ))
1795 })?
1796 .collect::<rusqlite::Result<Vec<_>>>()?;
1797 Ok(rows)
1798 }
1799
1800 pub(crate) fn exact_session_source_path(&self, id: &str) -> Result<Option<String>> {
1801 self.conn
1802 .query_row(
1803 "select source_path from sessions where id = ?1",
1804 params![id],
1805 |row| row.get(0),
1806 )
1807 .optional()
1808 .map_err(Into::into)
1809 }
1810
1811 pub fn upsert_session(
1812 &self,
1813 parsed: &ParsedSession,
1814 mtime_ns: i64,
1815 size_bytes: i64,
1816 ) -> Result<()> {
1817 self.upsert_session_with_mode(parsed, mtime_ns, size_bytes, true, &[])
1818 }
1819
1820 pub fn replace_session(
1825 &self,
1826 parsed: &ParsedSession,
1827 mtime_ns: i64,
1828 size_bytes: i64,
1829 ) -> Result<()> {
1830 self.upsert_session_with_mode(parsed, mtime_ns, size_bytes, false, &[])
1831 }
1832
1833 pub(crate) fn upsert_session_reconciling_sources(
1834 &self,
1835 parsed: &ParsedSession,
1836 mtime_ns: i64,
1837 size_bytes: i64,
1838 source_aliases: &[String],
1839 allow_append_optimization: bool,
1840 ) -> Result<()> {
1841 self.upsert_session_with_mode(
1842 parsed,
1843 mtime_ns,
1844 size_bytes,
1845 allow_append_optimization,
1846 source_aliases,
1847 )
1848 }
1849
1850 fn upsert_session_with_mode(
1851 &self,
1852 parsed: &ParsedSession,
1853 mtime_ns: i64,
1854 size_bytes: i64,
1855 allow_append_optimization: bool,
1856 source_aliases: &[String],
1857 ) -> Result<()> {
1858 let tx = self.conn.unchecked_transaction()?;
1859 let session = &parsed.session;
1860 for alias in source_aliases
1865 .iter()
1866 .filter(|alias| alias.as_str() != session.source_path)
1867 {
1868 tx.execute(
1869 "delete from sessions where provider = ?1 and source_path = ?2",
1870 params![session.provider.as_str(), alias],
1871 )?;
1872 tx.execute(
1873 "delete from files_seen where provider = ?1 and source_path = ?2",
1874 params![session.provider.as_str(), alias],
1875 )?;
1876 }
1877 tx.execute(
1878 "delete from sessions
1879 where provider = ?1 and source_path = ?2 and id != ?3",
1880 params![session.provider.as_str(), session.source_path, session.id],
1881 )?;
1882 tx.execute(
1883 "
1884 insert into sessions (
1885 id, provider, provider_session_id, title, summary, cwd, repo_root, created_at,
1886 updated_at, last_message_at, preview_text, source_path, message_count, parse_version,
1887 raw_metadata_json, parse_warning, discovery_source, parent_session_id, agent_label
1888 ) values (
1889 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
1890 ?9, ?10, ?11, ?12, ?13, ?14,
1891 ?15, ?16, ?17, ?18, ?19
1892 )
1893 on conflict(id) do update set
1894 provider = excluded.provider,
1895 provider_session_id = excluded.provider_session_id,
1896 title = excluded.title,
1897 summary = excluded.summary,
1898 cwd = excluded.cwd,
1899 repo_root = excluded.repo_root,
1900 created_at = excluded.created_at,
1901 updated_at = excluded.updated_at,
1902 last_message_at = excluded.last_message_at,
1903 preview_text = excluded.preview_text,
1904 source_path = excluded.source_path,
1905 message_count = excluded.message_count,
1906 parse_version = excluded.parse_version,
1907 raw_metadata_json = excluded.raw_metadata_json,
1908 parse_warning = excluded.parse_warning,
1909 discovery_source = excluded.discovery_source,
1910 parent_session_id = excluded.parent_session_id,
1911 agent_label = excluded.agent_label
1912 ",
1913 params![
1914 session.id,
1915 session.provider.as_str(),
1916 session.provider_session_id,
1917 session.title,
1918 session.summary,
1919 session.cwd,
1920 session.repo_root,
1921 session.created_at.map(|value| value.to_rfc3339()),
1922 session.updated_at.map(|value| value.to_rfc3339()),
1923 session.last_message_at.map(|value| value.to_rfc3339()),
1924 session.preview_text,
1925 session.source_path,
1926 session.message_count,
1927 session.parse_version,
1928 session.raw_metadata_json,
1929 session.parse_warning,
1930 session.discovery_source,
1931 session.parent_session_id,
1932 session.agent_label,
1933 ],
1934 )?;
1935 tx.execute(
1936 "
1937 insert into transcripts (session_id, transcript_text)
1938 values (?1, ?2)
1939 on conflict(session_id) do update set transcript_text = excluded.transcript_text
1940 ",
1941 params![session.id, parsed.transcript_text],
1942 )?;
1943 tx.execute(
1944 "
1945 insert into files_seen (
1946 provider, source_path, mtime_ns, size_bytes, last_indexed_at,
1947 content_hash, parse_version
1948 )
1949 values (?1, ?2, ?3, ?4, ?5, null, ?6)
1950 on conflict(provider, source_path) do update set
1951 mtime_ns = excluded.mtime_ns,
1952 size_bytes = excluded.size_bytes,
1953 last_indexed_at = excluded.last_indexed_at,
1954 parse_version = excluded.parse_version
1955 ",
1956 params![
1957 session.provider.as_str(),
1958 session.source_path,
1959 mtime_ns,
1960 size_bytes,
1961 Utc::now().to_rfc3339(),
1962 session.parse_version,
1963 ],
1964 )?;
1965 let existing_count: i64 = tx.query_row(
1975 "select count(*) from messages where session_id = ?1",
1976 params![session.id],
1977 |row| row.get(0),
1978 )?;
1979 let parsed_count = parsed.messages.len() as i64;
1980 let append_from: Option<usize> =
1981 if allow_append_optimization && existing_count > 0 && parsed_count > existing_count {
1982 let boundary = &parsed.messages[(existing_count - 1) as usize];
1983 let existing_boundary: Option<String> = tx
1984 .query_row(
1985 "select content from messages where session_id = ?1 and seq = ?2",
1986 params![session.id, boundary.seq],
1987 |row| row.get(0),
1988 )
1989 .optional()?;
1990 (existing_boundary.as_deref() == Some(boundary.content.as_str()))
1991 .then_some(existing_count as usize)
1992 } else {
1993 None
1994 };
1995 let new_messages = match append_from {
1996 Some(start) => &parsed.messages[start..],
1997 None => {
1998 tx.execute(
1999 "delete from messages where session_id = ?1",
2000 params![session.id],
2001 )?;
2002 &parsed.messages[..]
2003 }
2004 };
2005 insert_messages(&tx, session, new_messages.iter().map(|m| (m.seq, m)))?;
2007 tx.execute(
2010 "delete from file_edits where session_id = ?1",
2011 params![session.id],
2012 )?;
2013 insert_file_edits(&tx, session, parsed.file_edits.iter().map(|e| (e.seq, e)))?;
2014 tx.commit()?;
2015 Ok(())
2016 }
2017
2018 pub fn purge_injected_messages(&self) -> Result<usize> {
2027 let deleted = self.conn.execute(
2028 "delete from messages where role = 'user' and (\
2029 content like '<local-command-stdout>%' \
2030 or content like '<local-command-stderr>%' \
2031 or content like '<local-command-caveat>%' \
2032 or content like '<environment_context>%' \
2033 or content like '<turn_aborted>%')",
2034 [],
2035 )?;
2036 Ok(deleted)
2037 }
2038
2039 pub fn file_checkpoint(
2046 &self,
2047 provider: Provider,
2048 source_path: &str,
2049 required_parse_version: &str,
2050 ) -> Result<Option<(i64, String)>> {
2051 let row = self
2052 .conn
2053 .query_row(
2054 "select tail_byte_offset, prefix_fingerprint from files_seen
2055 where provider = ?1 and source_path = ?2 and parse_version = ?3",
2056 params![provider.as_str(), source_path, required_parse_version],
2057 |row| {
2058 Ok((
2059 row.get::<_, Option<i64>>(0)?,
2060 row.get::<_, Option<String>>(1)?,
2061 ))
2062 },
2063 )
2064 .optional()?;
2065 Ok(match row {
2066 Some((Some(offset), Some(fingerprint))) => Some((offset, fingerprint)),
2067 _ => None,
2068 })
2069 }
2070
2071 pub fn set_file_checkpoint(
2076 &self,
2077 provider: Provider,
2078 source_path: &str,
2079 tail_byte_offset: i64,
2080 prefix_fingerprint: &str,
2081 ) -> Result<()> {
2082 self.conn.execute(
2083 "update files_seen set tail_byte_offset = ?3, prefix_fingerprint = ?4
2084 where provider = ?1 and source_path = ?2",
2085 params![
2086 provider.as_str(),
2087 source_path,
2088 tail_byte_offset,
2089 prefix_fingerprint
2090 ],
2091 )?;
2092 Ok(())
2093 }
2094
2095 pub fn append_tail(
2105 &self,
2106 tail: &crate::tail::TailParse,
2107 mtime_ns: i64,
2108 size_bytes: i64,
2109 ) -> Result<()> {
2110 let session = &tail.session;
2111 let tx = self.conn.unchecked_transaction()?;
2112
2113 let existing_count: i64 = tx.query_row(
2115 "select count(*) from messages where session_id = ?1",
2116 params![session.id],
2117 |row| row.get(0),
2118 )?;
2119 insert_messages(
2121 &tx,
2122 session,
2123 tail.new_messages
2124 .iter()
2125 .enumerate()
2126 .map(|(i, m)| (existing_count + i as i64, m)),
2127 )?;
2128
2129 let existing_edit_seq: i64 = tx.query_row(
2131 "select coalesce(max(seq), -1) from file_edits where session_id = ?1",
2132 params![session.id],
2133 |row| row.get(0),
2134 )?;
2135 insert_file_edits(
2136 &tx,
2137 session,
2138 tail.new_file_edits
2139 .iter()
2140 .enumerate()
2141 .map(|(i, e)| (existing_edit_seq + 1 + i as i64, e)),
2142 )?;
2143
2144 let new_count = existing_count + tail.new_messages.len() as i64;
2147 tx.execute(
2148 "update sessions set
2149 updated_at = case when ?2 is not null and ?2 > coalesce(updated_at, '') then ?2
2150 else updated_at end,
2151 last_message_at = case when ?3 is not null and ?3 > coalesce(last_message_at, '') then ?3
2152 else last_message_at end,
2153 title = ?4,
2154 preview_text = ?5,
2155 cwd = coalesce(cwd, ?6),
2156 message_count = ?7
2157 where id = ?1",
2158 params![
2159 session.id,
2160 session.updated_at.map(|value| value.to_rfc3339()),
2161 session.last_message_at.map(|value| value.to_rfc3339()),
2162 session.title,
2163 session.preview_text,
2164 session.cwd,
2165 new_count,
2166 ],
2167 )?;
2168
2169 if !tail.new_transcript.is_empty() {
2172 tx.execute(
2173 "update transcripts set transcript_text =
2174 case when transcript_text = '' then ?2
2175 else transcript_text || char(10) || char(10) || ?2 end
2176 where session_id = ?1",
2177 params![session.id, tail.new_transcript],
2178 )?;
2179 }
2180 tx.execute(
2182 "update files_seen set
2183 mtime_ns = ?3, size_bytes = ?4, last_indexed_at = ?5,
2184 tail_byte_offset = ?6, prefix_fingerprint = ?7, parse_version = ?8
2185 where provider = ?1 and source_path = ?2",
2186 params![
2187 session.provider.as_str(),
2188 session.source_path,
2189 mtime_ns,
2190 size_bytes,
2191 Utc::now().to_rfc3339(),
2192 tail.new_tail_offset,
2193 tail.new_fingerprint,
2194 session.parse_version,
2195 ],
2196 )?;
2197 tx.commit()?;
2198 Ok(())
2199 }
2200
2201 pub fn message_count(&self) -> Result<i64> {
2203 Ok(self
2204 .conn
2205 .query_row("select count(*) from messages", [], |row| row.get(0))?)
2206 }
2207
2208 pub fn has_sessions(&self) -> Result<bool> {
2210 Ok(self
2211 .conn
2212 .query_row("select exists(select 1 from sessions limit 1)", [], |row| {
2213 row.get(0)
2214 })?)
2215 }
2216
2217 pub fn messages_fts_count(&self) -> Result<i64> {
2222 Ok(self
2223 .conn
2224 .query_row("select count(*) from messages_fts_docsize", [], |row| {
2225 row.get(0)
2226 })?)
2227 }
2228
2229 pub fn message_role_counts(&self, filters: &MessageFilters) -> Result<Vec<(String, i64)>> {
2232 use rusqlite::types::Value;
2233
2234 self.validate_access_scope()?;
2235
2236 let mut sql = String::from("select m.role, count(*) from messages m where 1 = 1");
2237 let mut args: Vec<Value> = Vec::new();
2238 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2239 sql.push_str(" group by m.role order by m.role");
2240
2241 let mut stmt = self.conn.prepare(&sql)?;
2242 let rows = stmt
2243 .query_map(rusqlite::params_from_iter(args.iter()), |row| {
2244 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2245 })?
2246 .collect::<rusqlite::Result<Vec<_>>>()?;
2247 Ok(rows)
2248 }
2249
2250 pub fn vocabulary(
2269 &self,
2270 trigram: bool,
2271 limit: usize,
2272 prefix: Option<&str>,
2273 ) -> Result<Vec<(String, i64, i64)>> {
2274 use rusqlite::types::Value;
2275
2276 let lim: i64 = if limit == 0 { -1 } else { limit as i64 };
2277 let schema_version = self.schema_version()?;
2278 let (source, term, doc, total) = if trigram && schema_version >= 4 {
2281 ("messages_trigram_terms", "term", "doc", "doc")
2285 } else if trigram {
2286 self.ensure_trigram_base()?;
2287 ("trigram_postings", "tg", "df", "df")
2288 } else {
2289 ("messages_vocab", "term", "doc", "cnt")
2290 };
2291
2292 let folded = prefix
2293 .map(fold_caseless)
2294 .filter(|prefix| !prefix.is_empty());
2295 let mut args: Vec<Value> = vec![Value::Integer(lim)];
2296 let mut filter = String::new();
2297 if let Some(prefix) = folded {
2298 filter = format!(" where {term} >= ?{}", args.len() + 1);
2299 if let Some(upper) = prefix_upper_bound(&prefix) {
2300 args.push(Value::Text(prefix));
2301 args.push(Value::Text(upper));
2302 filter.push_str(&format!(" and {term} < ?{}", args.len()));
2303 } else {
2304 args.push(Value::Text(prefix));
2305 }
2306 }
2307
2308 let sql = format!(
2309 "select {term}, {doc}, {total} from {source}{filter} \
2310 order by {total} desc, {term} limit ?1"
2311 );
2312 let mut stmt = self.conn.prepare(&sql)?;
2313 let rows = stmt
2314 .query_map(rusqlite::params_from_iter(args.iter()), |row| {
2315 Ok((
2316 row.get::<_, String>(0)?,
2317 row.get::<_, i64>(1)?,
2318 row.get::<_, i64>(2)?,
2319 ))
2320 })?
2321 .collect::<rusqlite::Result<Vec<_>>>()?;
2322 Ok(rows)
2323 }
2324
2325 pub fn search_messages(
2334 &self,
2335 query: &str,
2336 filters: &MessageFilters,
2337 ) -> Result<Vec<MessageHit>> {
2338 Ok(self.search_messages_with_explain(query, filters, false)?.0)
2339 }
2340
2341 pub(crate) fn search_message_plan(
2342 &self,
2343 plan: &crate::message_search::MessageRetrievalPlan,
2344 include_explain: bool,
2345 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2346 let (query, filters, order) = Self::message_plan_execution(plan)?;
2347 self.search_messages_with_explain_order(query, &filters, include_explain, order)
2348 }
2349
2350 pub(crate) fn visit_message_plan_batches(
2351 &self,
2352 plan: &crate::message_search::MessageRetrievalPlan,
2353 include_explain: bool,
2354 batch_rows: NonZeroUsize,
2355 visitor: impl FnMut(Vec<MessageHit>) -> Result<MessageBatchControl>,
2356 ) -> Result<MessageBatchVisitOutcome> {
2357 let (query, filters, order) = Self::message_plan_execution(plan)?;
2358 self.visit_messages_in_batches(query, &filters, include_explain, order, batch_rows, visitor)
2359 }
2360
2361 fn message_plan_execution(
2362 plan: &crate::message_search::MessageRetrievalPlan,
2363 ) -> Result<(&str, MessageFilters, MessageOrder)> {
2364 use crate::message_search::{MatchWindow, MessageQuery, ResolvedExtent};
2365
2366 let query = plan.query.text().unwrap_or("");
2367 let match_mode = match &plan.query {
2368 MessageQuery::All | MessageQuery::Literal(_) => MessageSearchMode::Literal,
2369 MessageQuery::Regex(_) => MessageSearchMode::Regex,
2370 MessageQuery::Fuzzy(_) => MessageSearchMode::Fuzzy,
2371 };
2372 let (limit, offset) = match plan.extent {
2373 ResolvedExtent::Page { limit, offset } => (
2374 limit
2375 .get()
2376 .checked_add(1)
2377 .ok_or_else(|| anyhow!("message page limit plus look-ahead overflows"))?,
2378 offset,
2379 ),
2380 ResolvedExtent::AllResults { offset } => (0, offset),
2381 };
2382 let filters = MessageFilters {
2383 role: plan.predicates.role,
2384 kinds: plan.predicates.kinds.clone(),
2385 field: Some(plan.target.field()),
2386 argument_path: plan
2387 .target
2388 .argument_path()
2389 .map(|pointer| pointer.as_str().to_string()),
2390 providers: plan.predicates.providers.clone(),
2391 session_id: plan.predicates.session_id.clone(),
2392 workspace_path_prefix: plan.predicates.workspace_path_prefix.clone(),
2393 transcript_path_prefix: plan.predicates.transcript_path_prefix.clone(),
2394 exclude_workspace_path_prefixes: plan
2395 .predicates
2396 .exclude_workspace_path_prefixes
2397 .clone(),
2398 exclude_transcript_path_prefixes: plan
2399 .predicates
2400 .exclude_transcript_path_prefixes
2401 .clone(),
2402 exclude_session_ids: plan.predicates.exclude_session_ids.clone(),
2403 since: plan.predicates.time.since(),
2404 until: plan.predicates.time.until(),
2405 seq_from: plan.predicates.sequence.and_then(|range| range.from()),
2406 seq_to: plan.predicates.sequence.and_then(|range| range.to()),
2407 match_mode,
2408 tool: plan.predicates.tool_name_contains.clone(),
2409 no_compaction: !plan.predicates.include_compaction,
2410 limit,
2411 offset,
2412 ..Default::default()
2413 };
2414 let order = if plan.match_window == Some(MatchWindow::Latest) {
2415 MessageOrder::NewestFirst
2416 } else {
2417 MessageOrder::OldestFirst
2418 };
2419 Ok((query, filters, order))
2420 }
2421
2422 pub(crate) fn search_messages_ordered(
2423 &self,
2424 query: &str,
2425 filters: &MessageFilters,
2426 order: MessageOrder,
2427 ) -> Result<Vec<MessageHit>> {
2428 anyhow::ensure!(
2429 order == MessageOrder::OldestFirst || filters.session_id.is_some(),
2430 "newest-first message order requires session_id because sequence numbers are session-local"
2431 );
2432 Ok(self
2433 .search_messages_with_explain_order(query, filters, false, order)?
2434 .0)
2435 }
2436
2437 pub fn read_session_messages(
2452 &self,
2453 filters: &MessageFilters,
2454 order: MessageOrder,
2455 ) -> Result<Vec<MessageHit>> {
2456 anyhow::ensure!(
2457 filters.session_id.is_some(),
2458 "read_session_messages requires filters.session_id because sequence numbers are session-local"
2459 );
2460 let mut hits = self.search_messages_ordered("", filters, order)?;
2461 if order == MessageOrder::NewestFirst {
2462 hits.reverse();
2464 }
2465 Ok(hits)
2466 }
2467
2468 pub fn search_messages_with_explain(
2477 &self,
2478 query: &str,
2479 filters: &MessageFilters,
2480 include_explain: bool,
2481 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2482 self.search_messages_with_explain_order(
2483 query,
2484 filters,
2485 include_explain,
2486 MessageOrder::OldestFirst,
2487 )
2488 }
2489
2490 fn prepare_non_fuzzy_message_query(
2494 &self,
2495 query: &str,
2496 filters: &MessageFilters,
2497 include_explain: bool,
2498 order: MessageOrder,
2499 ) -> Result<PreparedMessageQuery> {
2500 use rusqlite::types::Value;
2501
2502 debug_assert_ne!(filters.match_mode, MessageSearchMode::Fuzzy);
2503 let field = filters.field.unwrap_or(SearchField::Content);
2504 let mut sql = String::from(
2505 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
2506 from messages m where 1 = 1",
2507 );
2508 let mut args = Vec::new();
2509 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2510
2511 let explain = if field == SearchField::Content {
2512 let literal_query =
2513 filters.match_mode == MessageSearchMode::Literal && !query.is_empty();
2514 if literal_query {
2515 sql.push_str(" and unicode_lower_contains(m.content, ?)");
2516 args.push(Value::Text(fold_caseless(query)));
2517 }
2518 if filters.match_mode == MessageSearchMode::Regex {
2519 regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
2520 sql.push_str(" and rust_regexp(?, m.content)");
2521 args.push(Value::Text(query.to_string()));
2522 }
2523 let prefilter_pattern = if filters.match_mode == MessageSearchMode::Regex {
2524 Some(query.to_string())
2525 } else if literal_query {
2526 Some(regex::escape(query))
2527 } else {
2528 None
2529 };
2530 if self.schema_version()? >= 4 {
2531 let prefilter = prefilter_pattern
2532 .as_deref()
2533 .and_then(crate::trigram::trigram_prefilter);
2534 if let Some(fts_query) = &prefilter {
2535 sql.push_str(
2536 " and m.id in (
2537 select rowid from messages_trigram
2538 where messages_trigram match ?
2539 )",
2540 );
2541 args.push(Value::Text(fts_query.clone()));
2542 }
2543 if include_explain {
2544 Some(SearchExplain {
2545 prefilter: prefilter.clone(),
2546 candidates: prefilter
2547 .as_deref()
2548 .map(|fts_query| self.fts5_candidate_count(filters, fts_query))
2549 .transpose()?,
2550 prefilter_skipped: prefilter
2551 .is_none()
2552 .then(|| "no required literal of at least three characters".into()),
2553 corpus: self.filtered_corpus_count(filters)?,
2554 })
2555 } else {
2556 None
2557 }
2558 } else {
2559 let (use_trigram_candidates, explain) = self.prepare_content_prefilter(
2560 prefilter_pattern.as_deref(),
2561 filters,
2562 include_explain,
2563 )?;
2564 if use_trigram_candidates {
2565 sql.push_str(" and m.id in (select id from _trigram_cand)");
2566 }
2567 explain
2568 }
2569 } else {
2570 let mut derived_explain = None;
2571 match (field, filters.match_mode) {
2572 (SearchField::ToolName, MessageSearchMode::Literal) => {
2573 sql.push_str(" and unicode_lower_contains(m.tool_name, ?)");
2574 args.push(Value::Text(fold_caseless(query)));
2575 }
2576 (SearchField::ToolName, MessageSearchMode::Regex) => {
2577 regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
2578 sql.push_str(" and rust_regexp(?, m.tool_name)");
2579 args.push(Value::Text(query.to_string()));
2580 }
2581 (SearchField::ToolArgument, mode) => {
2582 if mode == MessageSearchMode::Regex {
2596 regex::Regex::new(query)
2597 .map_err(|error| anyhow!("invalid regex: {error}"))?;
2598 }
2599 let prefilter_pattern = match mode {
2600 MessageSearchMode::Literal if !query.is_empty() => {
2601 Some(regex::escape(query))
2602 }
2603 MessageSearchMode::Regex => Some(query.to_string()),
2604 MessageSearchMode::Literal => None,
2605 MessageSearchMode::Fuzzy => unreachable!("fuzzy query rejected above"),
2606 };
2607 let prefilter = (self.schema_version()? >= 4)
2608 .then(|| {
2609 prefilter_pattern
2610 .as_deref()
2611 .and_then(json_projection_prefilter_groups)
2612 .map(|groups| crate::trigram::render_prefilter_groups(&groups))
2613 })
2614 .flatten();
2615 if let Some(fts_query) = &prefilter {
2616 sql.push_str(
2617 " and m.id in (
2618 select rowid from messages_trigram
2619 where messages_trigram match ?
2620 )",
2621 );
2622 args.push(Value::Text(fts_query.clone()));
2623 }
2624 sql.push_str(" and m.kind = 'tool_call'");
2625 match mode {
2626 MessageSearchMode::Literal => {
2627 sql.push_str(
2628 " and unicode_lower_contains(rust_json_pointer(?, m.content), ?)",
2629 );
2630 args.push(Value::Text(
2631 filters.argument_path.clone().unwrap_or_default(),
2632 ));
2633 args.push(Value::Text(fold_caseless(query)));
2634 }
2635 MessageSearchMode::Regex => {
2636 sql.push_str(" and rust_regexp(?, rust_json_pointer(?, m.content))");
2637 args.push(Value::Text(query.to_string()));
2638 args.push(Value::Text(
2639 filters.argument_path.clone().unwrap_or_default(),
2640 ));
2641 }
2642 MessageSearchMode::Fuzzy => unreachable!("fuzzy query rejected above"),
2643 }
2644 if include_explain {
2645 let corpus =
2651 self.corpus_count_with_base("m.kind = 'tool_call'", filters)?;
2652 let candidates = prefilter
2653 .as_deref()
2654 .map(|fts_query| {
2655 self.fts5_candidate_count_with_base(
2656 "m.kind = 'tool_call'",
2657 filters,
2658 fts_query,
2659 )
2660 })
2661 .transpose()?;
2662 derived_explain = Some(SearchExplain {
2663 prefilter: prefilter.clone(),
2664 candidates,
2665 prefilter_skipped: prefilter.is_none().then(|| {
2666 "no JSON-stable required literal of at least three characters"
2667 .to_string()
2668 }),
2669 corpus,
2670 });
2671 }
2672 }
2673 (_, MessageSearchMode::Fuzzy) => unreachable!("fuzzy query rejected above"),
2674 (SearchField::Content, _) => unreachable!("content handled above"),
2675 }
2676 if include_explain {
2677 match derived_explain {
2678 Some(explain) => Some(explain),
2679 None => Some(SearchExplain {
2683 prefilter: None,
2684 candidates: None,
2685 prefilter_skipped: Some(
2686 "tool name verified in SQLite without a prefilter".to_string(),
2687 ),
2688 corpus: self.corpus_count_with_base("m.tool_name is not null", filters)?,
2689 }),
2690 }
2691 } else {
2692 None
2693 }
2694 };
2695
2696 sql.push_str(if order == MessageOrder::NewestFirst {
2697 " order by m.session_id, m.seq desc"
2698 } else {
2699 " order by m.session_id, m.seq"
2700 });
2701 if filters.limit > 0 {
2702 sql.push_str(" limit ?");
2703 args.push(Value::Integer(filters.limit as i64));
2704 if filters.offset > 0 {
2705 sql.push_str(" offset ?");
2706 args.push(Value::Integer(filters.offset as i64));
2707 }
2708 } else if filters.offset > 0 {
2709 sql.push_str(" limit -1 offset ?");
2710 args.push(Value::Integer(filters.offset as i64));
2711 }
2712 Ok(PreparedMessageQuery { sql, args, explain })
2713 }
2714
2715 fn search_messages_with_explain_order(
2716 &self,
2717 query: &str,
2718 filters: &MessageFilters,
2719 include_explain: bool,
2720 order: MessageOrder,
2721 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2722 self.validate_access_scope()?;
2723 filters.validate(query)?;
2724 let field = filters.field.unwrap_or(SearchField::Content);
2725 if filters.match_mode != MessageSearchMode::Fuzzy {
2726 let prepared =
2727 self.prepare_non_fuzzy_message_query(query, filters, include_explain, order)?;
2728 let hits = self.query_message_hits(&prepared.sql, &prepared.args)?;
2729 let explain = prepared.explain.clone();
2730 return Ok((hits, explain));
2731 }
2732 self.with_read_snapshot(|| {
2746 self.fuzzy_search_in_snapshot(query, filters, field, include_explain, order)
2747 })
2748 }
2749
2750 fn fuzzy_search_in_snapshot(
2751 &self,
2752 query: &str,
2753 filters: &MessageFilters,
2754 field: SearchField,
2755 include_explain: bool,
2756 order: MessageOrder,
2757 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2758 use rusqlite::types::Value;
2759
2760 debug_assert!(
2761 !self.conn.is_autocommit(),
2762 "every statement of one fuzzy search must share one read snapshot"
2763 );
2764 if field != SearchField::Content {
2765 return self.search_derived_message_field(
2766 query,
2767 filters,
2768 field,
2769 include_explain,
2770 order,
2771 );
2772 }
2773
2774 let mut sql = String::from(
2775 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
2776 from messages m where 1 = 1",
2777 );
2778 let mut args: Vec<Value> = Vec::new();
2779 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2780 sql.push_str(if order == MessageOrder::NewestFirst {
2781 " order by m.session_id, m.seq desc"
2782 } else {
2783 " order by m.session_id, m.seq"
2784 });
2785 let ranked_limit = match self.fuzzy_ranking_window("1 = 1", filters)? {
2786 FuzzyRankingWindow::Rank { limit, .. } => limit,
2787 FuzzyRankingWindow::PastCorpus { corpus } => {
2788 return Ok((
2789 Vec::new(),
2790 include_explain.then(|| empty_past_corpus(corpus)),
2791 ))
2792 }
2793 };
2794 let pattern = Pattern::new(
2795 query,
2796 CaseMatching::Ignore,
2797 Normalization::Smart,
2798 AtomKind::Fuzzy,
2799 );
2800 let query_lower = fold_caseless(query);
2801 let mut stmt = self.conn.prepare(&sql)?;
2802 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
2803 let mut batch = Vec::with_capacity(FUZZY_SCORE_BATCH_SIZE);
2804 let mut batch_bytes = 0_usize;
2805 let mut top = Vec::new();
2806 let mut corpus = 0_i64;
2807 let mut matched = 0_i64;
2808 for row in rows {
2809 let hit = row?;
2810 corpus += 1;
2811 batch_bytes = batch_bytes.saturating_add(hit.content.len());
2812 batch.push(hit);
2813 if fuzzy_batch_is_full(batch.len(), batch_bytes) {
2814 batch_bytes = 0;
2815 let scored = self.runtime.install(|| {
2816 score_fuzzy_message_hits(&pattern, &query_lower, std::mem::take(&mut batch))
2817 })?;
2818 matched += scored.len() as i64;
2819 top.extend(scored);
2820 if top.len() >= top_k_compaction_threshold(ranked_limit) {
2821 retain_top_fuzzy_hits(&mut top, ranked_limit);
2822 }
2823 }
2824 }
2825 if !batch.is_empty() {
2826 let scored = self
2827 .runtime
2828 .install(|| score_fuzzy_message_hits(&pattern, &query_lower, batch))?;
2829 matched += scored.len() as i64;
2830 top.extend(scored);
2831 }
2832 let hits = finish_fuzzy_hits(top, ranked_limit, filters.offset);
2833 let explain = include_explain.then(|| scored_whole_corpus(matched, corpus));
2834 Ok((hits, explain))
2835 }
2836
2837 pub(crate) fn visit_messages_in_batches(
2845 &self,
2846 query: &str,
2847 filters: &MessageFilters,
2848 include_explain: bool,
2849 order: MessageOrder,
2850 batch_rows: NonZeroUsize,
2851 mut visitor: impl FnMut(Vec<MessageHit>) -> Result<MessageBatchControl>,
2852 ) -> Result<MessageBatchVisitOutcome> {
2853 self.validate_access_scope()?;
2854 filters.validate(query)?;
2855 anyhow::ensure!(
2856 filters.match_mode != MessageSearchMode::Fuzzy,
2857 "batched exhaustive traversal supports literal, regex, and queryless searches; pass a finite limit to the bounded fuzzy search"
2858 );
2859 anyhow::ensure!(
2860 order == MessageOrder::OldestFirst || filters.session_id.is_some(),
2861 "newest-first message order requires session_id because sequence numbers are session-local"
2862 );
2863
2864 let legacy_prepared = if self.schema_version()? < 4 {
2870 anyhow::ensure!(
2871 !include_explain,
2872 "batched message-search receipts require database schema 4 so planner counts and results share one snapshot; run `aise reindex --full` before requesting a receipt"
2873 );
2874 Some(self.prepare_non_fuzzy_message_query(query, filters, false, order)?)
2875 } else {
2876 None
2877 };
2878
2879 let owned_snapshot = self
2883 .conn
2884 .is_autocommit()
2885 .then(|| ReadSnapshotRollback::begin(&self.conn))
2886 .transpose()?;
2887 let initial_prepared = match legacy_prepared {
2888 Some(prepared) => prepared,
2889 None => self.prepare_non_fuzzy_message_query(query, filters, include_explain, order)?,
2890 };
2891 let prepared = if order == MessageOrder::NewestFirst {
2892 let boundary = {
2898 let mut statement = self.conn.prepare(&initial_prepared.sql)?;
2899 let mut rows = statement.query_map(
2900 rusqlite::params_from_iter(initial_prepared.args.iter()),
2901 row_to_message_hit,
2902 )?;
2903 let boundary = rows.next().transpose()?;
2904 drop(rows);
2905 drop(statement);
2906 boundary
2907 };
2908 let Some(boundary) = boundary else {
2909 let explain = initial_prepared.explain.clone();
2910 if let Some(snapshot) = owned_snapshot {
2911 snapshot.rollback()?;
2912 }
2913 return Ok(MessageBatchVisitOutcome {
2914 rows_visited: 0,
2915 exhausted: true,
2916 explain,
2917 });
2918 };
2919 let mut chronological_filters = filters.clone();
2920 chronological_filters.limit = 0;
2921 chronological_filters.offset = 0;
2922 chronological_filters.seq_to = Some(
2923 chronological_filters
2924 .seq_to
2925 .map_or(boundary.seq, |current| current.min(boundary.seq)),
2926 );
2927 let mut chronological = self.prepare_non_fuzzy_message_query(
2928 query,
2929 &chronological_filters,
2930 false,
2931 MessageOrder::OldestFirst,
2932 )?;
2933 chronological.explain = initial_prepared.explain;
2934 chronological
2935 } else {
2936 initial_prepared
2937 };
2938 let mut statement = self.conn.prepare(&prepared.sql)?;
2939 let mut rows = statement.query_map(
2940 rusqlite::params_from_iter(prepared.args.iter()),
2941 row_to_message_hit,
2942 )?;
2943 let mut batch = Vec::new();
2944 batch.try_reserve_exact(batch_rows.get()).map_err(|error| {
2945 anyhow!(
2946 "batch_rows {} cannot be allocated: {error}; pass a smaller positive row count",
2947 batch_rows
2948 )
2949 })?;
2950 let mut rows_visited = 0_usize;
2951 let mut exhausted = false;
2952
2953 loop {
2954 match rows.next() {
2955 Some(row) => {
2956 batch.push(row?);
2957 rows_visited = rows_visited
2958 .checked_add(1)
2959 .ok_or_else(|| anyhow!("batched message row count overflow"))?;
2960 if batch.len() == batch_rows.get()
2961 && visitor(std::mem::take(&mut batch))? == MessageBatchControl::Stop
2962 {
2963 break;
2964 }
2965 if batch.capacity() < batch_rows.get() {
2966 batch
2967 .try_reserve_exact(batch_rows.get() - batch.capacity())
2968 .map_err(|error| {
2969 anyhow!(
2970 "batch_rows {} cannot be allocated: {error}; pass a smaller positive row count",
2971 batch_rows
2972 )
2973 })?;
2974 }
2975 }
2976 None => {
2977 exhausted = true;
2978 if !batch.is_empty() {
2979 let _ = visitor(batch)?;
2980 }
2981 break;
2982 }
2983 }
2984 }
2985
2986 drop(rows);
2987 drop(statement);
2988 if let Some(snapshot) = owned_snapshot {
2989 snapshot.rollback()?;
2990 }
2991 Ok(MessageBatchVisitOutcome {
2992 rows_visited,
2993 exhausted,
2994 explain: exhausted.then(|| prepared.explain.clone()).flatten(),
2995 })
2996 }
2997
2998 fn fts5_candidate_count(&self, filters: &MessageFilters, fts_query: &str) -> Result<i64> {
2999 self.fts5_candidate_count_with_base("1 = 1", filters, fts_query)
3000 }
3001
3002 fn fts5_candidate_count_with_base(
3005 &self,
3006 base_predicate: &str,
3007 filters: &MessageFilters,
3008 fts_query: &str,
3009 ) -> Result<i64> {
3010 use rusqlite::types::Value;
3011 let mut sql = format!("select count(*) from messages m where {base_predicate}");
3012 let mut args = Vec::new();
3013 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3014 sql.push_str(
3015 " and m.id in (
3016 select rowid from messages_trigram where messages_trigram match ?
3017 )",
3018 );
3019 args.push(Value::Text(fts_query.to_string()));
3020 Ok(self
3021 .conn
3022 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3023 row.get(0)
3024 })?)
3025 }
3026
3027 fn search_derived_message_field(
3028 &self,
3029 query: &str,
3030 filters: &MessageFilters,
3031 field: SearchField,
3032 include_explain: bool,
3033 order: MessageOrder,
3034 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3035 if field == SearchField::ToolName
3036 && filters.match_mode == MessageSearchMode::Fuzzy
3037 && self.schema_version()? >= 4
3038 {
3039 return self.search_tool_name_fuzzy_indexed(query, filters, include_explain);
3042 }
3043 let mut sql = String::from(
3044 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
3045 from messages m where 1 = 1",
3046 );
3047 let mut args = Vec::new();
3048 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3049 debug_assert_eq!(filters.match_mode, MessageSearchMode::Fuzzy);
3052 let base_predicate = fuzzy_derived_scan_predicate(field);
3056 sql.push_str(" and ");
3057 sql.push_str(base_predicate);
3058 sql.push_str(if order == MessageOrder::NewestFirst {
3059 " order by m.session_id, m.seq desc"
3060 } else {
3061 " order by m.session_id, m.seq"
3062 });
3063 let pattern = Pattern::new(
3064 query,
3065 CaseMatching::Ignore,
3066 Normalization::Smart,
3067 AtomKind::Fuzzy,
3068 );
3069 let query_lower = fold_caseless(query);
3070 let ranked_limit = match self.fuzzy_ranking_window(base_predicate, filters)? {
3071 FuzzyRankingWindow::Rank { limit, .. } => limit,
3072 FuzzyRankingWindow::PastCorpus { corpus } => {
3073 return Ok((
3074 Vec::new(),
3075 include_explain.then(|| empty_past_corpus(corpus)),
3076 ))
3077 }
3078 };
3079 let mut top = Vec::new();
3080 let mut corpus = 0_i64;
3081 let mut matched = 0_i64;
3082 let mut stmt = self.conn.prepare(&sql)?;
3083 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3084 let mut batch = Vec::with_capacity(FUZZY_SCORE_BATCH_SIZE);
3085 let mut batch_bytes = 0_usize;
3086 for row in rows {
3087 let hit = row?;
3088 corpus += 1;
3089 batch_bytes = batch_bytes.saturating_add(hit.content.len());
3090 batch.push(hit);
3091 if fuzzy_batch_is_full(batch.len(), batch_bytes) {
3092 let scored = self.runtime.install(|| {
3093 score_fuzzy_derived_hits(
3094 &pattern,
3095 &query_lower,
3096 field,
3097 filters.argument_path.as_deref(),
3098 std::mem::take(&mut batch),
3099 )
3100 })?;
3101 batch_bytes = 0;
3102 matched += scored.len() as i64;
3103 top.extend(scored);
3104 if top.len() >= top_k_compaction_threshold(ranked_limit) {
3105 retain_top_fuzzy_hits(&mut top, ranked_limit);
3106 }
3107 }
3108 }
3109 if !batch.is_empty() {
3110 let scored = self.runtime.install(|| {
3111 score_fuzzy_derived_hits(
3112 &pattern,
3113 &query_lower,
3114 field,
3115 filters.argument_path.as_deref(),
3116 batch,
3117 )
3118 })?;
3119 matched += scored.len() as i64;
3120 top.extend(scored);
3121 }
3122 let hits = finish_fuzzy_hits(top, ranked_limit, filters.offset);
3123 let explain = include_explain.then(|| scored_whole_corpus(matched, corpus));
3124 Ok((hits, explain))
3125 }
3126
3127 fn global_tool_name_vocabulary(&self) -> Result<Vec<String>> {
3134 let mut stmt = self.conn.prepare(
3135 "with recursive names(name) as (
3136 select min(tool_name) from messages where tool_name is not null
3137 union all
3138 select (select min(tool_name) from messages where tool_name > names.name)
3139 from names where name is not null
3140 )
3141 select name from names where name is not null",
3142 )?;
3143 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
3144 rows.collect::<rusqlite::Result<Vec<_>>>()
3145 .map_err(Into::into)
3146 }
3147
3148 #[cfg(test)]
3149 fn search_tool_name_fuzzy_direct_for_test(
3150 &self,
3151 query: &str,
3152 filters: &MessageFilters,
3153 ) -> Result<Vec<MessageHit>> {
3154 let mut sql = String::from(
3155 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3156 m.kind, m.tool_call_id, m.content
3157 from messages m
3158 where m.tool_name is not null",
3159 );
3160 let mut args = Vec::new();
3161 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3162 sql.push_str(" order by m.session_id, m.seq");
3163 let rows = self.query_message_hits(&sql, &args)?;
3164 let pattern = Pattern::new(
3165 query,
3166 CaseMatching::Ignore,
3167 Normalization::Smart,
3168 AtomKind::Fuzzy,
3169 );
3170 let scored = score_fuzzy_derived_hits(
3171 &pattern,
3172 &fold_caseless(query),
3173 SearchField::ToolName,
3174 None,
3175 rows,
3176 );
3177 Ok(finish_fuzzy_hits(
3178 scored,
3179 fuzzy_ranked_limit(filters)?,
3180 filters.offset,
3181 ))
3182 }
3183
3184 fn search_tool_name_fuzzy_indexed(
3185 &self,
3186 query: &str,
3187 filters: &MessageFilters,
3188 include_explain: bool,
3189 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3190 use rusqlite::types::Value;
3191
3192 debug_assert!(
3204 !self.conn.is_autocommit(),
3205 "the fuzzy tool-name statements must share one read snapshot"
3206 );
3207 let (ranked_limit, counted_corpus) =
3208 match self.fuzzy_ranking_window("m.tool_name is not null", filters)? {
3209 FuzzyRankingWindow::Rank { limit, corpus } => (limit, corpus),
3210 FuzzyRankingWindow::PastCorpus { corpus } => {
3211 return Ok((
3212 Vec::new(),
3213 include_explain.then(|| empty_past_corpus(corpus)),
3214 ))
3215 }
3216 };
3217 let names = self.global_tool_name_vocabulary()?;
3218
3219 let pattern = Pattern::new(
3220 query,
3221 CaseMatching::Ignore,
3222 Normalization::Smart,
3223 AtomKind::Fuzzy,
3224 );
3225 let query_lower = fold_caseless(query);
3226 let query_needle = UnicodeLowerNeedle::from_lowered(&query_lower);
3227 let mut matcher = NucleoMatcher::new(NucleoConfig::DEFAULT);
3228 let mut utf32_buf = Vec::new();
3229 let mut matching_names = HashMap::new();
3230 for name in names {
3231 utf32_buf.clear();
3232 if let Some(score) = pattern.score(Utf32Str::new(&name, &mut utf32_buf), &mut matcher) {
3233 let exact_phrase = query_needle.contains(&name);
3234 matching_names.insert(name, (score, exact_phrase));
3235 }
3236 }
3237
3238 let explained_matching_rows = if include_explain && !matching_names.is_empty() {
3239 let selected_names = serde_json::to_string(&matching_names.keys().collect::<Vec<_>>())?;
3240 let mut sql = String::from(
3241 "select count(*) from json_each(?) selected
3242 join messages m on m.tool_name = selected.value
3243 where m.tool_name is not null",
3244 );
3245 let mut args = vec![Value::Text(selected_names)];
3246 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3247 Some(
3248 self.conn
3249 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3250 row.get::<_, i64>(0)
3251 })?,
3252 )
3253 } else {
3254 None
3255 };
3256
3257 let mut scored_names = matching_names.into_iter().collect::<Vec<_>>();
3262 scored_names.sort_by(|(left_name, left), (right_name, right)| {
3263 right
3264 .0
3265 .cmp(&left.0)
3266 .then_with(|| right.1.cmp(&left.1))
3267 .then_with(|| left_name.cmp(right_name))
3268 });
3269 let mut top = Vec::new();
3273 let mut matched = 0_i64;
3274 for tier in scored_names.chunk_by(|left, right| left.1 == right.1) {
3275 if top.len() == ranked_limit {
3276 break;
3277 }
3278 let selected_names =
3279 serde_json::to_string(&tier.iter().map(|(name, _)| name).collect::<Vec<_>>())?;
3280 let remaining = ranked_limit - top.len();
3281 let mut sql = String::from(
3282 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3283 m.kind, m.tool_call_id, m.content
3284 from json_each(?) selected
3285 join messages m on m.tool_name = selected.value
3286 where m.tool_name is not null",
3287 );
3288 let mut args = vec![Value::Text(selected_names)];
3289 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3290 sql.push_str(" order by m.session_id, m.seq limit ?");
3291 args.push(Value::Integer(remaining as i64));
3292 let mut stmt = self.conn.prepare(&sql)?;
3293 let rows =
3294 stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3295 for row in rows {
3296 let mut hit = row?;
3297 hit.fuzzy_score = Some(tier[0].1 .0);
3298 top.push((hit, tier[0].1 .1));
3299 matched += 1;
3300 }
3301 }
3302 let hits = top
3303 .into_iter()
3304 .skip(filters.offset)
3305 .map(|(hit, _)| hit)
3306 .collect();
3307 let explain = include_explain
3308 .then(|| {
3309 let corpus = match counted_corpus {
3311 Some(corpus) => corpus,
3312 None => self.corpus_count_with_base("m.tool_name is not null", filters)?,
3313 };
3314 Ok::<_, anyhow::Error>(SearchExplain {
3315 prefilter: None,
3316 candidates: Some(explained_matching_rows.unwrap_or(matched)),
3317 prefilter_skipped: Some(
3318 "complete filtered tool-name vocabulary scored before matching rows are loaded"
3319 .to_string(),
3320 ),
3321 corpus,
3322 })
3323 })
3324 .transpose()?;
3325 Ok((hits, explain))
3326 }
3327
3328 fn query_message_hits(
3339 &self,
3340 sql: &str,
3341 args: &[rusqlite::types::Value],
3342 ) -> Result<Vec<MessageHit>> {
3343 let mut stmt = self.conn.prepare(sql)?;
3344 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3345 rows.collect::<rusqlite::Result<Vec<_>>>()
3346 .map_err(Into::into)
3347 }
3348
3349 fn filtered_corpus_count(&self, filters: &MessageFilters) -> Result<i64> {
3368 self.corpus_count_with_base("1 = 1", filters)
3369 }
3370
3371 fn fuzzy_ranking_window(
3395 &self,
3396 base_predicate: &str,
3397 filters: &MessageFilters,
3398 ) -> Result<FuzzyRankingWindow> {
3399 let requested = fuzzy_ranked_limit(filters)?;
3400 if requested <= FUZZY_SCORE_BATCH_SIZE {
3401 return Ok(FuzzyRankingWindow::Rank {
3402 limit: requested,
3403 corpus: None,
3404 });
3405 }
3406 let corpus = self.corpus_count_with_base(base_predicate, filters)?;
3407 let corpus_rows = usize::try_from(corpus).unwrap_or(usize::MAX);
3408 if filters.offset > 0 && filters.offset >= corpus_rows {
3414 return Ok(FuzzyRankingWindow::PastCorpus { corpus });
3415 }
3416 Ok(FuzzyRankingWindow::Rank {
3417 limit: requested.min(corpus_rows),
3418 corpus: Some(corpus),
3419 })
3420 }
3421
3422 fn corpus_count_with_base(
3425 &self,
3426 base_predicate: &str,
3427 filters: &MessageFilters,
3428 ) -> Result<i64> {
3429 let mut corpus = 0_i64;
3430 for (sql, args, sign) in self.corpus_count_statements(base_predicate, filters) {
3431 let count: i64 =
3432 self.conn
3433 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3434 row.get(0)
3435 })?;
3436 corpus += sign * count;
3437 }
3438 Ok(corpus)
3439 }
3440
3441 fn corpus_count_statements(
3445 &self,
3446 base_predicate: &str,
3447 filters: &MessageFilters,
3448 ) -> Vec<(String, Vec<rusqlite::types::Value>, i64)> {
3449 let build = |class: ClassSelection<'_>| {
3450 let mut sql = format!("select count(*) from messages m where {base_predicate}");
3451 if matches!(class, ClassSelection::Only(_)) {
3452 sql.push_str(&format!(" and m.{}", excluded_kinds_predicate()));
3455 }
3456 let mut args = Vec::new();
3457 append_message_filters_with_class(
3458 &mut sql,
3459 &mut args,
3460 filters,
3461 &self.access_scope,
3462 class,
3463 );
3464 if filters.tool.is_some() {
3465 sql.push_str(" and m.tool_name is not null");
3469 }
3470 (sql, args)
3471 };
3472 let excludable = crate::models::MessageKind::EXCLUDABLE_BY_DEFAULT;
3473 match filters.kind_predicate() {
3474 crate::models::KindPredicate::AllExcept(excluded) => {
3475 debug_assert!(excluded.iter().all(|kind| excludable.contains(kind)));
3476 let (total_sql, total_args) = build(ClassSelection::Unrestricted);
3477 let (excluded_sql, excluded_args) = build(ClassSelection::Only(&excluded));
3478 vec![
3479 (total_sql, total_args, 1),
3480 (excluded_sql, excluded_args, -1),
3481 ]
3482 }
3483 crate::models::KindPredicate::Only(kinds)
3488 if !kinds.is_empty() && kinds.iter().all(|kind| excludable.contains(kind)) =>
3489 {
3490 let (sql, args) = build(ClassSelection::Only(&kinds));
3491 vec![(sql, args, 1)]
3492 }
3493 crate::models::KindPredicate::Only(_) => {
3494 let (sql, args) = build(ClassSelection::FromFilters);
3495 vec![(sql, args, 1)]
3496 }
3497 }
3498 }
3499
3500 pub(crate) fn message_kind_index_exists(&self) -> Result<bool> {
3502 Ok(self.conn.query_row(
3503 "select exists(select 1 from sqlite_schema where type = 'index' and name = ?1)",
3504 params![MESSAGE_KIND_INDEX],
3505 |row| row.get(0),
3506 )?)
3507 }
3508
3509 pub(crate) fn ensure_message_kind_index(&self) -> Result<bool> {
3518 if self.message_kind_index_exists()? {
3519 return Ok(false);
3520 }
3521 self.report_progress(
3522 "building the message-class index once (one scan of every indexed message) so \
3523 receipts and class-filtered counts read the index instead of every message row",
3524 );
3525 let started = std::time::Instant::now();
3526 self.conn.execute_batch(&format!(
3527 "create index if not exists {MESSAGE_KIND_INDEX} on messages{MESSAGE_KIND_INDEX_COLUMNS} \
3528 where {}",
3529 excluded_kinds_predicate()
3530 ))?;
3531 self.report_progress(&format!(
3532 "built the message-class index in {:.1?}",
3533 started.elapsed()
3534 ));
3535 Ok(true)
3536 }
3537
3538 fn filtered_corpus_reaches(&self, filters: &MessageFilters, threshold: i64) -> Result<bool> {
3542 use rusqlite::types::Value;
3543 anyhow::ensure!(threshold > 0, "corpus probe threshold must be positive");
3544 let mut inner = String::from("select 1 from messages m where 1 = 1");
3545 let mut args: Vec<Value> = Vec::new();
3546 append_message_filters(&mut inner, &mut args, filters, &self.access_scope);
3547 inner.push_str(" limit 1 offset ?");
3548 args.push(Value::Integer(threshold - 1));
3549 let sql = format!("select exists({inner})");
3550 Ok(self
3551 .conn
3552 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3553 row.get(0)
3554 })?)
3555 }
3556
3557 fn corpus_count(&self, filters: &MessageFilters, cached: Option<i64>) -> Result<i64> {
3558 cached.map_or_else(|| self.filtered_corpus_count(filters), Ok)
3559 }
3560
3561 fn staged_candidate_count(&self, filters: &MessageFilters) -> Result<i64> {
3562 use rusqlite::types::Value;
3563 let mut sql = String::from("select count(*) from messages m where 1 = 1");
3564 let mut args: Vec<Value> = Vec::new();
3565 append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3566 sql.push_str(" and m.id in (select id from _trigram_cand)");
3567 Ok(self
3568 .conn
3569 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3570 row.get(0)
3571 })?)
3572 }
3573
3574 fn prepare_content_prefilter(
3580 &self,
3581 pattern: Option<&str>,
3582 filters: &MessageFilters,
3583 include_explain: bool,
3584 ) -> Result<(bool, Option<SearchExplain>)> {
3585 let Some(pattern) = pattern else {
3586 let explain = include_explain
3587 .then(|| {
3588 self.filtered_corpus_count(filters)
3589 .map(|corpus| SearchExplain {
3590 prefilter: None,
3591 candidates: None,
3592 prefilter_skipped: None,
3593 corpus,
3594 })
3595 })
3596 .transpose()?;
3597 return Ok((false, explain));
3598 };
3599
3600 let corpus = if include_explain {
3601 Some(self.filtered_corpus_count(filters)?)
3602 } else {
3603 None
3604 };
3605 if pattern.contains(SIGMAS) {
3613 let explain = if include_explain {
3614 Some(SearchExplain {
3615 prefilter: None,
3616 candidates: None,
3617 prefilter_skipped: Some(
3618 "the pre-v4 compatibility index spells sigma by position, so this query scans directly"
3619 .into(),
3620 ),
3621 corpus: self.corpus_count(filters, corpus)?,
3622 })
3623 } else {
3624 None
3625 };
3626 return Ok((false, explain));
3627 }
3628 let Some(groups) = crate::trigram::trigram_prefilter_groups(pattern) else {
3629 let explain = if include_explain {
3630 Some(SearchExplain {
3631 prefilter: None,
3632 candidates: None,
3633 prefilter_skipped: None,
3634 corpus: self.corpus_count(filters, corpus)?,
3635 })
3636 } else {
3637 None
3638 };
3639 return Ok((false, explain));
3640 };
3641
3642 let use_prefilter = !filters.narrows_corpus()
3648 || if let Some(corpus) = corpus {
3649 corpus >= self.prefilter_min_corpus
3650 } else {
3651 self.filtered_corpus_reaches(filters, self.prefilter_min_corpus)?
3652 };
3653 let prefilter = include_explain.then(|| crate::trigram::render_prefilter_groups(&groups));
3654 if !use_prefilter {
3655 let explain = if include_explain {
3656 Some(SearchExplain {
3657 prefilter,
3658 candidates: None,
3659 prefilter_skipped: Some(format!(
3660 "structured filters reduced the corpus below the pre-v4 compatibility prefilter threshold ({})",
3661 self.prefilter_min_corpus
3662 )),
3663 corpus: self.corpus_count(filters, corpus)?,
3664 })
3665 } else {
3666 None
3667 };
3668 return Ok((false, explain));
3669 }
3670
3671 let base_max = self.ensure_trigram_base()?;
3674 let candidates = crate::trigram_index::candidates(&self.conn, &groups)?;
3675 self.stage_candidates(base_max, &candidates)?;
3676 let explain = if include_explain {
3677 Some(SearchExplain {
3678 prefilter,
3679 candidates: Some(self.staged_candidate_count(filters)?),
3680 prefilter_skipped: None,
3681 corpus: self.corpus_count(filters, corpus)?,
3682 })
3683 } else {
3684 None
3685 };
3686 Ok((true, explain))
3687 }
3688
3689 pub fn explain_message_search(
3694 &self,
3695 query: &str,
3696 filters: &MessageFilters,
3697 ) -> Result<SearchExplain> {
3698 let (_, explain) = self.search_messages_with_explain(query, filters, true)?;
3699 explain.context("message search explanation was not produced")
3700 }
3701
3702 pub fn message_context(
3706 &self,
3707 session_id: &str,
3708 seq: i64,
3709 before: i64,
3710 after: i64,
3711 ) -> Result<Vec<MessageHit>> {
3712 use rusqlite::types::Value;
3713
3714 self.validate_access_scope()?;
3715 anyhow::ensure!(before >= 0, "context before must be non-negative");
3716 anyhow::ensure!(after >= 0, "context after must be non-negative");
3717 let mut sql = String::from(
3718 "select session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, content from messages
3719 where session_id = ? and seq between ? and ?",
3720 );
3721 let mut args = vec![
3725 Value::Text(session_id.to_string()),
3726 Value::Integer(seq.saturating_sub(before)),
3727 Value::Integer(seq.saturating_add(after)),
3728 ];
3729 push_access_scope(&mut sql, &mut args, "session_id", &self.access_scope);
3730 sql.push_str(" order by seq");
3731 let mut stmt = self.conn.prepare(&sql)?;
3732 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3733 rows.collect::<rusqlite::Result<Vec<_>>>()
3734 .map_err(Into::into)
3735 }
3736
3737 pub(crate) fn message_context_windows(
3740 &self,
3741 anchors: &[(String, i64)],
3742 before: i64,
3743 after: i64,
3744 ) -> Result<Vec<Vec<MessageHit>>> {
3745 use rusqlite::types::Value;
3746
3747 self.validate_access_scope()?;
3748 anyhow::ensure!(before >= 0, "context before must be non-negative");
3749 anyhow::ensure!(after >= 0, "context after must be non-negative");
3750 let mut windows = vec![Vec::new(); anchors.len()];
3751 if anchors.is_empty() {
3752 return Ok(windows);
3753 }
3754 let bounds = anchors
3755 .iter()
3756 .map(|(session_id, seq)| {
3757 (
3758 session_id,
3759 seq.saturating_sub(before),
3760 seq.saturating_add(after),
3761 )
3762 })
3763 .collect::<Vec<_>>();
3764 let mut sql = String::from(
3765 "with anchors(ord, session_id, lower_seq, upper_seq) as materialized (
3766 select cast(key as integer),
3767 json_extract(value, '$[0]'),
3768 json_extract(value, '$[1]'),
3769 json_extract(value, '$[2]')
3770 from json_each(?)
3771 )
3772 select a.ord, m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3773 m.kind, m.tool_call_id, m.content
3774 from anchors a
3775 join messages m on m.session_id = a.session_id
3776 and m.seq between a.lower_seq and a.upper_seq
3777 where 1 = 1",
3778 );
3779 let mut args = vec![Value::Text(serde_json::to_string(&bounds)?)];
3780 push_access_scope(&mut sql, &mut args, "m.session_id", &self.access_scope);
3781 sql.push_str(" order by a.ord, m.seq");
3782 let mut stmt = self.conn.prepare(&sql)?;
3783 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3784 Ok((row.get::<_, i64>(0)?, row_to_message_hit_at(row, 1)?))
3789 })?;
3790 for row in rows {
3791 let (ord, hit) = row?;
3792 let window = usize::try_from(ord)
3795 .ok()
3796 .and_then(|ordinal| windows.get_mut(ordinal))
3797 .with_context(|| {
3798 format!(
3799 "context window ordinal {ord} falls outside the {} anchors queried",
3800 anchors.len()
3801 )
3802 })?;
3803 window.push(hit);
3804 }
3805 Ok(windows)
3806 }
3807
3808 pub fn session_metadata(
3814 &self,
3815 ids: &[String],
3816 ) -> Result<std::collections::HashMap<String, crate::models::SessionMeta>> {
3817 use crate::models::SessionMeta;
3818 use rusqlite::types::Value;
3819 use std::collections::HashMap;
3820 self.validate_access_scope()?;
3821 let mut map = HashMap::new();
3822 if ids.is_empty() {
3823 return Ok(map);
3824 }
3825 let mut sql = String::from(
3826 "select s.id, s.provider_session_id, s.cwd, s.repo_root, s.title, s.updated_at,
3827 s.last_message_at, s.message_count, s.parse_warning, s.parent_session_id,
3828 s.agent_label
3829 from json_each(?) requested
3830 join sessions s on s.id = requested.value
3831 where 1 = 1",
3832 );
3833 let mut args = vec![Value::Text(serde_json::to_string(ids)?)];
3834 push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
3835 let mut stmt = self.conn.prepare(&sql)?;
3836 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3837 Ok((
3838 row.get::<_, String>(0)?,
3839 SessionMeta {
3840 provider_session_id: row.get(1)?,
3841 cwd: row.get(2)?,
3842 repo_root: row.get(3)?,
3843 title: row.get(4)?,
3844 updated_at: row
3845 .get::<_, Option<String>>(5)?
3846 .as_deref()
3847 .and_then(crate::util::parse_datetime),
3848 last_message_at: row
3849 .get::<_, Option<String>>(6)?
3850 .as_deref()
3851 .and_then(crate::util::parse_datetime),
3852 message_count: row.get(7)?,
3853 parse_warning: row.get(8)?,
3854 parent_session_id: row.get(9)?,
3855 agent_label: row.get(10)?,
3856 },
3857 ))
3858 })?;
3859 for row in rows {
3860 let (id, meta) = row?;
3861 map.insert(id, meta);
3862 }
3863 Ok(map)
3864 }
3865
3866 pub fn session_raw_provider_metadata(
3873 &self,
3874 ids: &[String],
3875 ) -> Result<std::collections::BTreeMap<String, Box<serde_json::value::RawValue>>> {
3876 use rusqlite::types::Value;
3877 use std::collections::BTreeMap;
3878
3879 self.validate_access_scope()?;
3880 let mut map = BTreeMap::new();
3881 if ids.is_empty() {
3882 return Ok(map);
3883 }
3884 let mut sql = String::from(
3885 "select s.id, s.raw_metadata_json
3886 from json_each(?) requested
3887 join sessions s on s.id = requested.value
3888 where s.raw_metadata_json is not null",
3889 );
3890 let mut args = vec![Value::Text(serde_json::to_string(ids)?)];
3891 push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
3892 let mut statement = self.conn.prepare(&sql)?;
3893 let rows = statement.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3894 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3895 })?;
3896 for row in rows {
3897 let (session_id, raw_json) = row?;
3898 let raw = serde_json::value::RawValue::from_string(raw_json).map_err(|error| {
3899 anyhow::anyhow!(
3900 "session {session_id} contains invalid raw provider metadata JSON: {error}"
3901 )
3902 })?;
3903 map.insert(session_id, raw);
3904 }
3905 Ok(map)
3906 }
3907
3908 pub(crate) fn search_attributable_human_messages(
3919 &self,
3920 query: &str,
3921 filters: &MessageFilters,
3922 ) -> Result<Vec<MessageHit>> {
3923 use rusqlite::types::Value;
3924
3925 self.validate_access_scope()?;
3926 if filters.match_mode == MessageSearchMode::Fuzzy {
3927 bail!("attributable human-message analytics do not support fuzzy matching");
3928 }
3929 if filters.field.unwrap_or(SearchField::Content) != SearchField::Content {
3930 bail!("attributable human-message analytics search message content only");
3931 }
3932 if filters.match_mode == MessageSearchMode::Regex {
3933 regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
3934 }
3935
3936 let mut inner = String::from(
3937 "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind,
3938 m.tool_call_id,
3939 case
3940 when m.authorship = 'human' then m.content
3941 else (
3942 select group_concat(fragment, '')
3943 from (
3944 select substr(m.content, p.start_char + 1,
3945 p.end_char - p.start_char) as fragment
3946 from message_content_parts p
3947 where p.message_id = m.id and p.authorship = 'human'
3948 order by p.ordinal
3949 )
3950 )
3951 end as content
3952 from messages m
3953 where m.record_relation = 'original'
3954 and (
3955 m.authorship = 'human'
3956 or (
3957 m.authorship = 'mixed'
3958 and exists (
3959 select 1 from message_content_parts p
3960 where p.message_id = m.id and p.authorship = 'human'
3961 )
3962 )
3963 )",
3964 );
3965 let mut args: Vec<Value> = Vec::new();
3966 append_message_filters(&mut inner, &mut args, filters, &self.access_scope);
3967 let mut sql = format!("select * from ({inner}) attributable where 1 = 1");
3968 if !query.is_empty() {
3969 match filters.match_mode {
3970 MessageSearchMode::Literal => {
3971 sql.push_str(" and unicode_lower_contains(attributable.content, ?)");
3972 args.push(Value::Text(fold_caseless(query)));
3973 }
3974 MessageSearchMode::Regex => {
3975 sql.push_str(" and rust_regexp(?, attributable.content)");
3976 args.push(Value::Text(query.to_string()));
3977 }
3978 MessageSearchMode::Fuzzy => unreachable!("fuzzy rejected above"),
3979 }
3980 }
3981 sql.push_str(" order by attributable.session_id, attributable.seq");
3982 if filters.limit > 0 {
3983 sql.push_str(" limit ? offset ?");
3984 args.push(Value::Integer(filters.limit as i64));
3985 args.push(Value::Integer(filters.offset as i64));
3986 } else if filters.offset > 0 {
3987 sql.push_str(" limit -1 offset ?");
3988 args.push(Value::Integer(filters.offset as i64));
3989 }
3990 let mut stmt = self.conn.prepare(&sql)?;
3991 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3992 rows.collect::<rusqlite::Result<Vec<_>>>()
3993 .map_err(Into::into)
3994 }
3995
3996 pub(crate) fn find_corrections(
4009 &self,
4010 policies: &crate::corrections::ResolvedCorrectionPolicySet,
4011 filters: &MessageFilters,
4012 ) -> Result<Vec<MessageClassificationMatch>> {
4013 self.validate_access_scope()?;
4014 if policies.is_empty() {
4019 return Ok(Vec::new());
4020 }
4021 self.find_corrections_with_after_candidates(policies, filters, || {})
4022 }
4023
4024 fn find_corrections_with_after_candidates(
4025 &self,
4026 policies: &crate::corrections::ResolvedCorrectionPolicySet,
4027 filters: &MessageFilters,
4028 after_candidates: impl FnOnce(),
4029 ) -> Result<Vec<MessageClassificationMatch>> {
4030 use rusqlite::types::Value;
4031
4032 let mut filters = filters.clone();
4033 filters.role = Some(Role::User);
4034 filters
4047 .session_kinds
4048 .get_or_insert_with(|| vec![crate::models::SessionKind::User]);
4049
4050 struct CorrectionCandidate {
4051 message_id: i64,
4052 session_id: String,
4053 message_seq: i64,
4054 provider: Provider,
4055 ts: Option<String>,
4056 content: String,
4057 human_parts: Option<Vec<(usize, usize, usize)>>,
4059 }
4060
4061 fn resolve_human_part_bytes(
4062 message_id: i64,
4063 content: &str,
4064 ranges: &[(usize, usize)],
4065 ) -> Result<Vec<(usize, usize, usize)>> {
4066 let mut resolved = Vec::with_capacity(ranges.len());
4067 let mut range_index = 0;
4068 let mut start_byte = None;
4069 for (char_index, byte_index) in content
4070 .char_indices()
4071 .map(|(byte_index, _)| byte_index)
4072 .chain(std::iter::once(content.len()))
4073 .enumerate()
4074 {
4075 while let Some(&(start_char, end_char)) = ranges.get(range_index) {
4076 if start_byte.is_none() && char_index == start_char {
4077 start_byte = Some(byte_index);
4078 }
4079 if char_index == end_char {
4080 let start_byte = start_byte.take().ok_or_else(|| {
4081 anyhow!(
4082 "message {message_id} human content part starts beyond its content"
4083 )
4084 })?;
4085 resolved.push((start_char, start_byte, byte_index));
4086 range_index += 1;
4087 continue;
4088 }
4089 break;
4090 }
4091 }
4092 if range_index != ranges.len() {
4093 bail!(
4094 "message {message_id} human content part ends beyond its content; \
4095 reindex the source transcript"
4096 );
4097 }
4098 Ok(resolved)
4099 }
4100
4101 let rows = self.with_read_snapshot(|| {
4104 let mut sql = String::from(CORRECTION_CANDIDATES_SQL);
4108 let mut args: Vec<Value> = Vec::new();
4109 append_message_filters(&mut sql, &mut args, &filters, &self.access_scope);
4110 sql.push_str(" order by m.ts desc, m.session_id asc, m.seq asc");
4114
4115 let mut stmt = self.conn.prepare(&sql)?;
4116 let mut rows: Vec<CorrectionCandidate> = stmt
4119 .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4120 let authorship = row.get::<_, String>(6)?;
4121 Ok(CorrectionCandidate {
4122 message_id: row.get(0)?,
4123 session_id: row.get(1)?,
4124 message_seq: row.get(2)?,
4125 provider: decode_db_enum(
4126 row.get::<_, String>(3)?,
4127 3,
4128 "messages.provider",
4129 Provider::from_db_str,
4130 )?,
4131 ts: row.get(4)?,
4132 content: row.get(5)?,
4133 human_parts: (authorship == "mixed").then(Vec::new),
4134 })
4135 })?
4136 .collect::<rusqlite::Result<Vec<_>>>()?;
4137 drop(stmt);
4138 after_candidates();
4139
4140 if rows.iter().any(|row| row.human_parts.is_some()) {
4145 let mut parts_sql = String::from(CORRECTION_HUMAN_PARTS_SQL);
4146 let mut parts_args: Vec<Value> = Vec::new();
4147 append_message_filters(
4148 &mut parts_sql,
4149 &mut parts_args,
4150 &filters,
4151 &self.access_scope,
4152 );
4153 parts_sql.push_str(" order by m.id, p.ordinal");
4154 let mut human_parts_by_message: HashMap<i64, Vec<(usize, usize)>> = HashMap::new();
4155 let mut parts_stmt = self.conn.prepare(&parts_sql)?;
4156 let part_rows =
4157 parts_stmt.query_map(rusqlite::params_from_iter(parts_args.iter()), |row| {
4158 Ok((
4159 row.get::<_, i64>(0)?,
4160 row.get::<_, i64>(1)?,
4161 row.get::<_, i64>(2)?,
4162 ))
4163 })?;
4164 for part in part_rows {
4165 let (message_id, start_char, end_char) = part?;
4166 let start_char = usize::try_from(start_char).with_context(|| {
4167 format!(
4168 "message {message_id} has a negative human content-part start coordinate"
4169 )
4170 })?;
4171 let end_char = usize::try_from(end_char).with_context(|| {
4172 format!(
4173 "message {message_id} has a negative human content-part end coordinate"
4174 )
4175 })?;
4176 human_parts_by_message
4177 .entry(message_id)
4178 .or_default()
4179 .push((start_char, end_char));
4180 }
4181 for row in &mut rows {
4182 if let Some(parts) = &mut row.human_parts {
4183 let ranges = human_parts_by_message
4184 .remove(&row.message_id)
4185 .unwrap_or_default();
4186 *parts =
4187 resolve_human_part_bytes(row.message_id, &row.content, &ranges)?;
4188 }
4189 }
4190 }
4191 Ok(rows)
4192 })?;
4193
4194 let classify = |row: CorrectionCandidate| -> Option<MessageClassificationMatch> {
4201 let CorrectionCandidate {
4202 session_id,
4203 message_seq,
4204 provider,
4205 ts,
4206 content,
4207 human_parts,
4208 ..
4209 } = row;
4210 let (identity, hit) = match &human_parts {
4211 Some(parts) => policies.classify_fragments(parts.iter().map(
4212 |(start_char, start_byte, end_byte)| {
4213 (*start_char, &content[*start_byte..*end_byte])
4214 },
4215 )),
4216 None => policies.classify(&content),
4217 }?;
4218 let ts = ts.as_deref().and_then(|value| {
4219 chrono::DateTime::parse_from_rfc3339(value)
4220 .ok()
4221 .map(|dt| dt.with_timezone(&Utc))
4222 });
4223 Some(MessageClassificationMatch {
4224 session_id,
4225 message_seq,
4226 provider,
4227 ts,
4228 policy_name: identity.name.clone(),
4229 category: hit.category.to_string(),
4230 matched_text: hit.matched_text,
4231 match_start_char: hit.match_start_char,
4232 match_end_char_exclusive: hit.match_end_char_exclusive,
4233 content,
4234 })
4235 };
4236
4237 use rayon::prelude::*;
4249 let mut out: Vec<MessageClassificationMatch> = if self.runtime.worker_threads() <= 1 {
4250 rows.into_iter().filter_map(classify).collect()
4251 } else {
4252 self.runtime
4253 .install(|| rows.into_par_iter().filter_map(classify).collect())?
4254 };
4255 if filters.offset > 0 {
4262 out.drain(..filters.offset.min(out.len()));
4263 }
4264 if filters.limit > 0 {
4265 out.truncate(filters.limit);
4266 }
4267 Ok(out)
4268 }
4269
4270 pub fn planning_usage(
4275 &self,
4276 filters: &MessageFilters,
4277 command_filters: &[regex::Regex],
4278 ) -> Result<Vec<PlanningCount>> {
4279 use rusqlite::types::Value;
4280 use std::collections::{HashMap, HashSet};
4281
4282 self.validate_access_scope()?;
4283 let mut sql = String::from(
4284 "select m.session_id, s.repo_root, s.cwd, m.content from messages m \
4285 join sessions s on s.id = m.session_id where 1 = 1",
4286 );
4287 let mut args: Vec<Value> = Vec::new();
4288 let mut filters = filters.clone();
4289 filters.role = Some(Role::Slash);
4290 append_message_filters(&mut sql, &mut args, &filters, &self.access_scope);
4291
4292 let mut stmt = self.conn.prepare(&sql)?;
4293 let raw = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
4294 Ok((
4295 row.get::<_, String>(0)?,
4296 row.get::<_, Option<String>>(1)?,
4297 row.get::<_, Option<String>>(2)?,
4298 row.get::<_, String>(3)?,
4299 ))
4300 })?;
4301
4302 let mut agg: HashMap<String, (i64, HashSet<String>, HashSet<String>)> = HashMap::new();
4304 for row in raw {
4305 let (session_id, repo_root, cwd, content) = row?;
4306 if let Some(command) = crate::util::slash_command_token(&content) {
4307 if !command_filters.is_empty()
4308 && !command_filters.iter().any(|re| re.is_match(&command))
4309 {
4310 continue;
4311 }
4312 let project = repo_root.or(cwd).unwrap_or_default();
4313 let entry = agg.entry(command).or_default();
4314 entry.0 += 1;
4315 entry.1.insert(session_id);
4316 if !project.is_empty() {
4317 entry.2.insert(project);
4318 }
4319 }
4320 }
4321 let mut counts: Vec<PlanningCount> = agg
4322 .into_iter()
4323 .map(|(command, (count, sessions, projects))| PlanningCount {
4324 command,
4325 count,
4326 unique_sessions: sessions.len() as i64,
4327 unique_projects: projects.len() as i64,
4328 })
4329 .collect();
4330 counts.sort_by(|a, b| {
4331 b.count
4332 .cmp(&a.count)
4333 .then_with(|| a.command.cmp(&b.command))
4334 });
4335 if filters.limit > 0 {
4336 counts.truncate(filters.limit);
4337 }
4338 Ok(counts)
4339 }
4340
4341 pub fn file_search(&self, query: &FileQuery) -> Result<Vec<FileEditSummary>> {
4344 use rusqlite::types::Value;
4345
4346 self.validate_access_scope()?;
4347 let mut sql = String::from(
4348 "select file_path, file_name, count(*) as edits, \
4349 count(distinct session_id) as sessions, max(ts) as last_edited \
4350 from file_edits where 1 = 1",
4351 );
4352 let mut args: Vec<Value> = Vec::new();
4353 if let Some(pattern) = &query.pattern {
4354 let (col, like) = glob_clause(pattern);
4355 sql.push_str(&format!(" and {col} like ? escape '\\'"));
4356 args.push(Value::Text(like));
4357 }
4358 push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4359 push_ts_window(&mut sql, &mut args, "ts", query.since, query.until);
4360 sql.push_str(" group by file_path");
4361 let mut having: Vec<&str> = Vec::new();
4362 if let Some(min) = query.min_edits {
4363 having.push("count(*) >= ?");
4364 args.push(Value::Integer(min));
4365 }
4366 if let Some(max) = query.max_edits {
4367 having.push("count(*) <= ?");
4368 args.push(Value::Integer(max));
4369 }
4370 if !having.is_empty() {
4371 sql.push_str(" having ");
4372 sql.push_str(&having.join(" and "));
4373 }
4374 sql.push_str(" order by edits desc, last_edited desc");
4375 if query.limit > 0 || query.offset > 0 {
4376 sql.push_str(" limit ? offset ?");
4377 args.push(Value::Integer(if query.limit == 0 {
4378 -1
4379 } else {
4380 query.limit as i64
4381 }));
4382 args.push(Value::Integer(query.offset as i64));
4383 }
4384
4385 let mut stmt = self.conn.prepare(&sql)?;
4386 let rows = stmt
4387 .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4388 Ok(FileEditSummary {
4389 file_path: row.get(0)?,
4390 file_name: row.get(1)?,
4391 edits: row.get(2)?,
4392 sessions: row.get(3)?,
4393 last_edited: row
4394 .get::<_, Option<String>>(4)?
4395 .as_deref()
4396 .and_then(crate::util::parse_datetime),
4397 })
4398 })?
4399 .collect::<rusqlite::Result<Vec<_>>>()?;
4400 Ok(rows)
4401 }
4402
4403 pub fn file_cross_ref(&self, query: &FileQuery) -> Result<Vec<FileCrossRef>> {
4405 use rusqlite::types::Value;
4406
4407 self.validate_access_scope()?;
4408 let mut sql = String::from(
4409 "select file_path, session_id, provider, count(*) as edits \
4410 from file_edits where 1 = 1",
4411 );
4412 let mut args: Vec<Value> = Vec::new();
4413 if let Some(pattern) = &query.pattern {
4414 let (col, like) = glob_clause(pattern);
4415 sql.push_str(&format!(" and {col} like ? escape '\\'"));
4416 args.push(Value::Text(like));
4417 }
4418 push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4419 push_ts_window(&mut sql, &mut args, "ts", query.since, query.until);
4420 sql.push_str(" group by file_path, session_id order by file_path, edits desc");
4421 if query.limit > 0 || query.offset > 0 {
4422 sql.push_str(" limit ? offset ?");
4423 args.push(Value::Integer(if query.limit == 0 {
4424 -1
4425 } else {
4426 query.limit as i64
4427 }));
4428 args.push(Value::Integer(query.offset as i64));
4429 }
4430
4431 let mut stmt = self.conn.prepare(&sql)?;
4432 let rows = stmt
4433 .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4434 let provider: String = row.get(2)?;
4435 Ok(FileCrossRef {
4436 file_path: row.get(0)?,
4437 session_id: row.get(1)?,
4438 provider: decode_db_enum(
4439 provider,
4440 2,
4441 "file_edits.provider",
4442 Provider::from_db_str,
4443 )?,
4444 edits: row.get(3)?,
4445 })
4446 })?
4447 .collect::<rusqlite::Result<Vec<_>>>()?;
4448 Ok(rows)
4449 }
4450
4451 pub fn file_edits_for(
4456 &self,
4457 file: &str,
4458 session_id: Option<&str>,
4459 ) -> Result<Vec<(String, Provider, FileEdit)>> {
4460 let session_id = session_id
4461 .map(|id| self.resolve_session_record(id).map(|session| session.id))
4462 .transpose()?;
4463 self.file_edits_for_query(
4464 file,
4465 &FileQuery {
4466 session_id,
4467 ..Default::default()
4468 },
4469 )
4470 }
4471
4472 pub fn file_edits_for_query(
4473 &self,
4474 file: &str,
4475 query: &FileQuery,
4476 ) -> Result<Vec<(String, Provider, FileEdit)>> {
4477 self.file_edits_for_scoped(file, query)
4478 }
4479
4480 pub fn file_edits_for_session_id(
4481 &self,
4482 file: &str,
4483 session_id: &str,
4484 ) -> Result<Vec<(String, Provider, FileEdit)>> {
4485 self.file_edits_for_scoped(
4486 file,
4487 &FileQuery {
4488 session_id: Some(session_id.to_string()),
4489 ..Default::default()
4490 },
4491 )
4492 }
4493
4494 fn file_edits_for_scoped(
4495 &self,
4496 file: &str,
4497 query: &FileQuery,
4498 ) -> Result<Vec<(String, Provider, FileEdit)>> {
4499 use rusqlite::types::Value;
4500
4501 self.validate_access_scope()?;
4502 let mut sql = String::from(
4503 "select session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json \
4504 from file_edits where (file_name = ? or file_path = ? or file_path like ?)",
4505 );
4506 let mut args: Vec<Value> = vec![
4507 Value::Text(file.to_string()),
4508 Value::Text(file.to_string()),
4509 Value::Text(format!("%/{file}")),
4510 ];
4511 push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4512 sql.push_str(" order by session_id, seq");
4513
4514 let mut stmt = self.conn.prepare(&sql)?;
4515 let raw = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
4516 Ok((
4517 row.get::<_, String>(0)?,
4518 row.get::<_, String>(1)?,
4519 row.get::<_, i64>(2)?,
4520 row.get::<_, Option<String>>(3)?,
4521 row.get::<_, String>(4)?,
4522 row.get::<_, String>(5)?,
4523 row.get::<_, String>(6)?,
4524 row.get::<_, Option<String>>(7)?,
4525 row.get::<_, Option<String>>(8)?,
4526 ))
4527 })?;
4528
4529 let mut out = Vec::new();
4530 for row in raw {
4531 let (
4532 session_id,
4533 provider,
4534 seq,
4535 ts,
4536 tool,
4537 file_path,
4538 file_name,
4539 new_content,
4540 edits_json,
4541 ) = row?;
4542 let edits: Vec<EditOp> = match edits_json.as_deref() {
4545 Some(json) => serde_json::from_str(json).with_context(|| {
4546 format!("corrupt edits_json for {file_path} in session {session_id}")
4547 })?,
4548 None => Vec::new(),
4549 };
4550 out.push((
4551 session_id,
4552 decode_db_enum(provider, 1, "file_edits.provider", Provider::from_db_str)?,
4553 FileEdit {
4554 seq,
4555 ts: ts.as_deref().and_then(crate::util::parse_datetime),
4556 tool,
4557 file_path,
4558 file_name,
4559 new_content,
4560 edits,
4561 },
4562 ));
4563 }
4564 Ok(out)
4565 }
4566
4567 pub fn file_edit_count(&self) -> Result<i64> {
4569 Ok(self
4570 .conn
4571 .query_row("select count(*) from file_edits", [], |row| row.get(0))?)
4572 }
4573
4574 pub fn list_recent(&self, filters: &SearchFilters) -> Result<Vec<SessionRecord>> {
4575 self.list_recent_page(filters, 0)
4576 }
4577
4578 pub fn list_recent_page(
4579 &self,
4580 filters: &SearchFilters,
4581 offset: usize,
4582 ) -> Result<Vec<SessionRecord>> {
4583 self.validate_access_scope()?;
4584 let mut sql = format!(
4585 "select {} from sessions s where 1 = 1",
4586 session_record_columns!()
4587 );
4588 let mut params_vec = Vec::new();
4589 push_session_filters(&mut sql, &mut params_vec, filters, &self.access_scope);
4590 use std::fmt::Write as _;
4591 sql.push_str(" order by s.updated_at desc, s.id asc");
4592 if filters.limit != 0 {
4593 write!(sql, " limit {}", filters.limit)?;
4594 if offset != 0 {
4595 write!(sql, " offset {offset}")?;
4596 }
4597 } else if offset != 0 {
4598 write!(sql, " limit -1 offset {offset}")?;
4599 }
4600
4601 let mut stmt = self.conn.prepare(&sql)?;
4602 let rows = stmt.query_map(
4603 rusqlite::params_from_iter(params_vec.iter()),
4604 row_to_session_record,
4605 )?;
4606 let mut results = Vec::new();
4607 for row in rows {
4608 results.push(row?);
4609 }
4610 Ok(results)
4611 }
4612
4613 pub(crate) fn analysis_documents(
4614 &self,
4615 filters: &SearchFilters,
4616 cursor: Option<&crate::models::AnalysisCursor>,
4617 ) -> Result<crate::models::AnalysisDocumentPage> {
4618 self.validate_access_scope()?;
4619 if filters.limit == 0 {
4620 return Err(anyhow!(
4621 "analysis document page limit must be greater than zero"
4622 ));
4623 }
4624 let transaction = self.conn.unchecked_transaction()?;
4627 analysis_document_page(
4628 &transaction,
4629 filters,
4630 cursor,
4631 filters.limit,
4632 &self.access_scope,
4633 )
4634 }
4635
4636 pub(crate) fn visit_analysis_sessions(
4644 &self,
4645 request: &AnalysisRequest,
4646 session_batch_size: std::num::NonZeroUsize,
4647 mut visitor: impl FnMut(
4648 crate::models::SessionRecord,
4649 i64,
4650 i64,
4651 &mut dyn Iterator<Item = Result<(i64, String)>>,
4652 ) -> Result<()>,
4653 ) -> Result<AnalysisTraversalReceipt> {
4654 self.validate_access_scope()?;
4655 let transaction = self.conn.unchecked_transaction()?;
4656 let mut count_stmt = transaction.prepare(ANALYSIS_MESSAGE_COUNTS_SQL)?;
4657 let mut human_message_stmt =
4658 transaction.prepare(ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL)?;
4659 let mut cursor = None;
4660 let mut selected_sessions = 0_u64;
4661 let mut messages_in_selected_sessions = 0_u64;
4662 let mut analyzed_user_messages = 0_u64;
4663 let mut has_more = false;
4664 let mut last_selected_session_id = None;
4665 let mut max_selected_session_updated_at = None;
4666 let mut corpus_digest = FramedSha256::new(b"aise-analysis-corpus-v1");
4667 loop {
4668 let remaining = match request.selection().max_sessions() {
4669 None => usize::MAX,
4670 Some(max_sessions) => max_sessions
4671 .get()
4672 .saturating_sub(selected_sessions.try_into().unwrap_or(usize::MAX)),
4673 };
4674 if remaining == 0 {
4675 break;
4676 }
4677 let limit = session_batch_size.get().min(remaining);
4678 let (sessions, next_cursor) = analysis_session_page(
4679 &transaction,
4680 request.scope(),
4681 cursor.as_ref(),
4682 limit,
4683 &self.access_scope,
4684 )?;
4685 if sessions.is_empty() {
4686 if next_cursor.is_some() {
4687 bail!(
4692 "internal error: an analysis page returned a pagination cursor but no \
4693 documents; this indicates a bug in aise's pagination logic — please \
4694 file an issue at https://github.com/ahundt/ai-session-search/issues \
4695 with the filters/query that triggered it, or send a pull request fixing \
4696 analysis_session_page's cursor logic in db.rs"
4697 );
4698 }
4699 break;
4700 }
4701 let page_len = sessions.len();
4702 for session in sessions {
4703 let (message_count, human_message_count) = count_stmt
4704 .query_row([&session.id], |row| {
4705 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
4706 })?;
4707 corpus_digest.update_u8(0);
4708 corpus_digest.update_json(&session)?;
4709 corpus_digest.update_i64(message_count);
4710 corpus_digest.update_i64(human_message_count);
4711 messages_in_selected_sessions = messages_in_selected_sessions
4712 .checked_add(u64::try_from(message_count)?)
4713 .ok_or_else(|| anyhow!("analysis total message count overflow"))?;
4714 analyzed_user_messages = analyzed_user_messages
4715 .checked_add(u64::try_from(human_message_count)?)
4716 .ok_or_else(|| anyhow!("analysis human-authored message count overflow"))?;
4717 last_selected_session_id = Some(session.id.clone());
4718 if let Some(updated_at) = session.updated_at.or(session.created_at) {
4719 max_selected_session_updated_at = Some(
4720 max_selected_session_updated_at
4721 .map_or(updated_at, |current: DateTime<Utc>| current.max(updated_at)),
4722 );
4723 }
4724 let session_id = session.id.clone();
4725 let mut rows = human_message_stmt
4726 .query_map([&session.id], |row| {
4727 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
4728 })?
4729 .map(|row| {
4730 let (seq, content) = row.map_err(anyhow::Error::from)?;
4731 corpus_digest.update_u8(1);
4732 corpus_digest.update_bytes(session_id.as_bytes());
4733 corpus_digest.update_i64(seq);
4734 corpus_digest.update_bytes(content.as_bytes());
4735 Ok((seq, content))
4736 });
4737 visitor(session, message_count, human_message_count, &mut rows)?;
4738 for unread in rows {
4739 unread?;
4740 }
4741 }
4742 selected_sessions = selected_sessions
4743 .checked_add(page_len as u64)
4744 .ok_or_else(|| anyhow!("analysis document count overflow"))?;
4745 if request
4746 .selection()
4747 .max_sessions()
4748 .is_some_and(|maximum| selected_sessions >= maximum.get() as u64)
4749 {
4750 has_more = next_cursor.is_some();
4751 break;
4752 }
4753 let Some(next_cursor) = next_cursor else {
4754 break;
4755 };
4756 if cursor
4757 .as_ref()
4758 .is_some_and(|previous: &crate::models::AnalysisCursor| {
4759 previous.as_str() >= next_cursor.as_str()
4760 })
4761 {
4762 bail!(
4765 "internal error: analysis pagination did not advance to a new cursor; this \
4766 indicates a bug in aise's pagination logic — please file an issue at \
4767 https://github.com/ahundt/ai-session-search/issues with the filters/query \
4768 that triggered it, or send a pull request fixing analysis_session_page's \
4769 cursor logic in db.rs"
4770 );
4771 }
4772 cursor = Some(next_cursor);
4773 }
4774 Ok(AnalysisTraversalReceipt {
4775 selected_sessions,
4776 messages_in_selected_sessions,
4777 analyzed_user_messages,
4778 has_more,
4779 last_selected_session_id,
4780 max_selected_session_updated_at,
4781 corpus_digest: corpus_digest.finish(),
4782 })
4783 }
4784
4785 pub fn search(
4786 &self,
4787 query: &str,
4788 filters: &SearchFilters,
4789 current_repo: Option<&str>,
4790 scoring: &crate::config::ScoringConfig,
4791 ) -> Result<Vec<SearchHit>> {
4792 self.search_with_cancellation(query, filters, current_repo, scoring, None)
4793 }
4794
4795 pub(crate) fn search_cancellable(
4796 &self,
4797 query: &str,
4798 filters: &SearchFilters,
4799 current_repo: Option<&str>,
4800 scoring: &crate::config::ScoringConfig,
4801 cancellation: &QueryCancellation,
4802 ) -> Result<Vec<SearchHit>> {
4803 self.search_with_cancellation(query, filters, current_repo, scoring, Some(cancellation))
4804 }
4805
4806 fn search_with_cancellation(
4807 &self,
4808 query: &str,
4809 filters: &SearchFilters,
4810 current_repo: Option<&str>,
4811 scoring: &crate::config::ScoringConfig,
4812 cancellation: Option<&QueryCancellation>,
4813 ) -> Result<Vec<SearchHit>> {
4814 self.validate_access_scope()?;
4815 if let Some(cancellation) = cancellation {
4816 cancellation.ensure_active()?;
4817 }
4818 let query_lower = fold_caseless(query);
4819 let tokens: Vec<&str> = query_lower.split_whitespace().collect();
4820 let query_needle = UnicodeLowerNeedle::from_lowered(&query_lower);
4821 let token_needles = tokens
4822 .iter()
4823 .map(|token| UnicodeLowerNeedle::from_lowered(token))
4824 .collect::<Vec<_>>();
4825 let context = SessionScoreContext {
4826 query,
4827 query_needle: &query_needle,
4828 tokens: &tokens,
4829 token_needles: &token_needles,
4830 current_repo,
4831 scoring,
4832 cancellation,
4833 now: Utc::now(),
4834 };
4835 let mut hits = Vec::new();
4836 let ranked_limit = filters.limit;
4837 let mut sql = format!(
4838 "select {}, coalesce(t.transcript_text, '') as transcript_text
4839 from sessions s
4840 left join transcripts t on t.session_id = s.id
4841 where 1 = 1",
4842 session_record_columns!()
4843 );
4844 let mut params_vec = Vec::new();
4845 push_session_filters(&mut sql, &mut params_vec, filters, &self.access_scope);
4846 sql.push_str(" order by s.id asc");
4847 let mut stmt = self.conn.prepare(&sql)?;
4848 let candidates = stmt.query_map(
4849 rusqlite::params_from_iter(params_vec.iter()),
4850 row_to_session_with_transcript,
4851 )?;
4852
4853 let mut batch = Vec::with_capacity(SESSION_SCORE_BATCH_SIZE);
4854 let mut batch_bytes = 0_usize;
4855 for record in candidates {
4856 if let Some(cancellation) = cancellation {
4857 cancellation.ensure_active()?;
4858 }
4859 let record = record?;
4860 batch_bytes = batch_bytes.saturating_add(session_search_record_bytes(&record));
4861 batch.push(record);
4862 if batch.len() == SESSION_SCORE_BATCH_SIZE || batch_bytes >= SESSION_SCORE_BATCH_BYTES {
4863 let scored = self
4864 .runtime
4865 .install(|| score_session_records(std::mem::take(&mut batch), &context))??;
4866 if let Some(cancellation) = cancellation {
4867 cancellation.ensure_active()?;
4868 }
4869 batch_bytes = 0;
4870 hits.extend(scored);
4871 if ranked_limit > 0 && hits.len() >= top_k_compaction_threshold(ranked_limit) {
4872 retain_top_session_hits(&mut hits, ranked_limit);
4873 }
4874 }
4875 }
4876 if !batch.is_empty() {
4877 hits.extend(
4878 self.runtime
4879 .install(|| score_session_records(batch, &context))??,
4880 );
4881 }
4882 if let Some(cancellation) = cancellation {
4883 cancellation.ensure_active()?;
4884 }
4885
4886 if ranked_limit > 0 {
4887 retain_top_session_hits(&mut hits, ranked_limit);
4888 }
4889 if let Some(cancellation) = cancellation {
4890 cancellation.ensure_active()?;
4891 }
4892 hits.sort_by(compare_session_hits);
4893 if let Some(cancellation) = cancellation {
4894 cancellation.ensure_active()?;
4895 }
4896 Ok(hits)
4897 }
4898
4899 pub fn resolve_session(&self, value: &str) -> Result<SessionWithTranscript> {
4900 let exact = self.session_resolution_matches(
4901 RESOLVE_SESSION_EXACT_SQL,
4902 value,
4903 row_to_session_with_transcript,
4904 )?;
4905 if !exact.is_empty() {
4906 return unique_session_match(value, exact, |session| &session.session.id);
4907 }
4908 let prefix = literal_like_prefix_pattern(value);
4909 let matches = self.session_resolution_matches(
4910 RESOLVE_SESSION_PREFIX_SQL,
4911 &prefix,
4912 row_to_session_with_transcript,
4913 )?;
4914 unique_session_match(value, matches, |session| &session.session.id)
4915 }
4916
4917 pub fn resolve_session_record(&self, value: &str) -> Result<SessionRecord> {
4918 let exact = self.session_resolution_matches(
4919 RESOLVE_SESSION_RECORD_EXACT_SQL,
4920 value,
4921 row_to_session_record,
4922 )?;
4923 if !exact.is_empty() {
4924 return unique_session_match(value, exact, |session| &session.id);
4925 }
4926 let prefix = literal_like_prefix_pattern(value);
4927 let matches = self.session_resolution_matches(
4928 RESOLVE_SESSION_RECORD_PREFIX_SQL,
4929 &prefix,
4930 row_to_session_record,
4931 )?;
4932 unique_session_match(value, matches, |session| &session.id)
4933 }
4934
4935 pub(crate) fn inspect_session_transcript<T>(
4940 &self,
4941 value: &str,
4942 cancellation: &QueryCancellation,
4943 inspect: impl FnOnce(&SessionRecord, &str) -> Result<T>,
4944 ) -> Result<T> {
4945 self.with_read_snapshot(|| {
4946 let session = self.resolve_session_record(value)?;
4947 cancellation.ensure_active()?;
4948 let mut statement = self.conn.prepare(
4949 "select coalesce(transcript_text, '') from transcripts where session_id = ?1",
4950 )?;
4951 let mut rows = statement.query([&session.id])?;
4952 let transcript = match rows.next()? {
4953 Some(row) => row.get_ref(0)?.as_str()?,
4954 None => "",
4955 };
4956 cancellation.ensure_active()?;
4957 let result = inspect(&session, transcript);
4958 drop(rows);
4959 drop(statement);
4960 result
4961 })
4962 }
4963
4964 fn session_resolution_matches<T>(
4971 &self,
4972 base_sql: &str,
4973 value: &str,
4974 mapper: fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
4975 ) -> Result<Vec<T>> {
4976 self.access_scope.validate_stable()?;
4977 let mut sql = base_sql.to_string();
4978 let mut args = vec![rusqlite::types::Value::Text(value.to_string())];
4979 push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
4980 let mut stmt = self.conn.prepare(&sql)?;
4981 let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), mapper)?;
4982 let mut matches = Vec::new();
4983 for row in rows {
4984 matches.push(row?);
4985 }
4986 Ok(matches)
4987 }
4988
4989 pub fn count_parse_warnings(&self) -> Result<i64> {
4990 self.conn
4991 .query_row(
4992 "select count(*) from sessions where parse_warning is not null and parse_warning != ''",
4993 [],
4994 |row| row.get(0),
4995 )
4996 .map_err(Into::into)
4997 }
4998
4999 pub fn parser_health(&self) -> Result<ParserHealth> {
5000 let schema_version: i64 = self
5001 .conn
5002 .query_row("pragma user_version", [], |row| row.get(0))?;
5003 let mut stmt = self.conn.prepare(
5004 "select provider, parse_version, count(*) from sessions
5005 group by provider, parse_version",
5006 )?;
5007 let grouped = stmt
5008 .query_map([], |row| {
5009 Ok((
5010 row.get::<_, String>(0)?,
5011 row.get::<_, String>(1)?,
5012 row.get::<_, i64>(2)?,
5013 ))
5014 })?
5015 .collect::<rusqlite::Result<Vec<_>>>()?;
5016
5017 let providers = crate::source::PROVIDERS
5018 .into_iter()
5019 .map(|provider| {
5020 let expected = crate::util::provider_parse_version(provider);
5021 let mut indexed_sessions = 0;
5022 let mut current_sessions = 0;
5023 for (stored_provider, parse_version, count) in &grouped {
5024 if stored_provider == provider.as_str() {
5025 indexed_sessions += count;
5026 if parse_version == expected {
5027 current_sessions += count;
5028 }
5029 }
5030 }
5031 ProviderParserHealth {
5032 provider,
5033 expected_parse_version: expected.to_string(),
5034 indexed_sessions,
5035 current_sessions,
5036 stale_sessions: indexed_sessions - current_sessions,
5037 }
5038 })
5039 .collect::<Vec<_>>();
5040 let indexed_sessions = providers.iter().map(|item| item.indexed_sessions).sum();
5041 let current_sessions = providers.iter().map(|item| item.current_sessions).sum();
5042
5043 Ok(ParserHealth {
5044 schema_version,
5045 expected_schema_version: SCHEMA_VERSION,
5046 schema_current: schema_version == SCHEMA_VERSION,
5047 indexed_sessions,
5048 current_sessions,
5049 stale_sessions: indexed_sessions - current_sessions,
5050 parse_warnings: self.count_parse_warnings()?,
5051 providers,
5052 })
5053 }
5054
5055 pub(crate) fn stale_session_sources(&self) -> Result<Vec<(Provider, String)>> {
5056 let mut stmt = self
5057 .conn
5058 .prepare("select provider, parse_version, source_path from sessions")?;
5059 let rows = stmt
5060 .query_map([], |row| {
5061 Ok((
5062 row.get::<_, String>(0)?,
5063 row.get::<_, String>(1)?,
5064 row.get::<_, String>(2)?,
5065 ))
5066 })?
5067 .collect::<rusqlite::Result<Vec<_>>>()?;
5068 rows.into_iter()
5069 .filter_map(|(provider, parse_version, source_path)| {
5070 let provider =
5071 match decode_db_enum(provider, 0, "sessions.provider", Provider::from_db_str) {
5072 Ok(provider) => provider,
5073 Err(error) => return Some(Err(error.into())),
5074 };
5075 (parse_version != crate::util::provider_parse_version(provider))
5076 .then_some(Ok((provider, source_path)))
5077 })
5078 .collect()
5079 }
5080
5081 pub fn session_time_profile(&self, session_id: &str) -> Result<SessionTimeProfile> {
5082 use rusqlite::types::Value;
5083
5084 self.validate_access_scope()?;
5085 let mut sql = String::from(
5086 "with ordered as (
5087 select ts, kind, lag(ts) over (order by seq) as previous_ts
5088 from messages where session_id = ?",
5089 );
5090 let mut args = vec![Value::Text(session_id.to_string())];
5091 push_access_scope(&mut sql, &mut args, "session_id", &self.access_scope);
5092 sql.push_str(
5093 ") select count(*), count(ts), min(ts), max(ts),
5094 max(case when previous_ts is null or ts is null then null
5095 else unixepoch(ts) - unixepoch(previous_ts) end),
5096 coalesce(sum(kind = 'tool_call'), 0),
5097 coalesce(sum(kind = 'tool_result'), 0)
5098 from ordered",
5099 );
5100 let row = self
5101 .conn
5102 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
5103 Ok((
5104 row.get::<_, i64>(0)?,
5105 row.get::<_, i64>(1)?,
5106 row.get::<_, Option<String>>(2)?,
5107 row.get::<_, Option<String>>(3)?,
5108 row.get::<_, Option<i64>>(4)?,
5109 row.get::<_, i64>(5)?,
5110 row.get::<_, i64>(6)?,
5111 ))
5112 })?;
5113 let parse_ts = |value: Option<String>| {
5114 value.and_then(|value| {
5115 chrono::DateTime::parse_from_rfc3339(&value)
5116 .ok()
5117 .map(|timestamp| timestamp.with_timezone(&Utc))
5118 })
5119 };
5120 let first_timestamp = parse_ts(row.2);
5121 let last_timestamp = parse_ts(row.3);
5122 Ok(SessionTimeProfile {
5123 messages: row.0,
5124 timestamped_messages: row.1,
5125 undated_messages: row.0 - row.1,
5126 observed_span_seconds: first_timestamp
5127 .zip(last_timestamp)
5128 .map(|(first, last)| (last - first).num_seconds()),
5129 first_timestamp,
5130 last_timestamp,
5131 max_message_gap_seconds: row.4,
5132 tool_calls: row.5,
5133 tool_results: row.6,
5134 })
5135 }
5136
5137 pub fn counts_by_provider(&self) -> Result<HashMap<String, i64>> {
5138 let mut stmt = self
5139 .conn
5140 .prepare("select provider, count(*) from sessions group by provider")?;
5141 let rows = stmt.query_map([], |row| {
5142 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
5143 })?;
5144 let mut out = HashMap::new();
5145 for row in rows {
5146 let (provider, count) = row?;
5147 out.insert(provider, count);
5148 }
5149 Ok(out)
5150 }
5151}
5152
5153const ANALYSIS_MESSAGE_COUNTS_SQL: &str = "select count(*),
5154 coalesce(sum(
5155 case
5156 when m.record_relation = 'original'
5157 and (
5158 m.authorship = 'human'
5159 or (
5160 m.authorship = 'mixed'
5161 and exists (
5162 select 1
5163 from message_content_parts p
5164 where p.message_id = m.id and p.authorship = 'human'
5165 )
5166 )
5167 )
5168 then 1
5169 else 0
5170 end
5171 ), 0)
5172 from messages m
5173 where m.session_id = ?1";
5174
5175const ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL: &str = "select seq, group_concat(fragment, '')
5185 from (
5186 select m.id as message_id, m.seq as seq, -1 as ordinal, m.content as fragment
5187 from messages m
5188 where m.session_id = ?1
5189 and m.authorship = 'human'
5190 and m.record_relation = 'original'
5191 union all
5192 select m.id as message_id, m.seq as seq, p.ordinal as ordinal,
5193 substr(m.content, p.start_char + 1, p.end_char - p.start_char) as fragment
5194 from messages m
5195 join message_content_parts p on p.message_id = m.id
5196 where m.session_id = ?1
5197 and m.authorship = 'mixed'
5198 and m.record_relation = 'original'
5199 and p.authorship = 'human'
5200 order by seq, message_id, ordinal
5201 )
5202 group by message_id, seq
5203 order by seq";
5204
5205fn analysis_session_page(
5207 transaction: &rusqlite::Transaction<'_>,
5208 filters: &SearchFilters,
5209 cursor: Option<&crate::models::AnalysisCursor>,
5210 page_limit: usize,
5211 access: &crate::search_scope::EffectiveAccessScope,
5212) -> Result<(Vec<SessionRecord>, Option<crate::models::AnalysisCursor>)> {
5213 use crate::models::AnalysisCursor;
5214 use std::fmt::Write as _;
5215
5216 if page_limit == 0 {
5217 bail!("analysis document page limit must be greater than zero");
5218 }
5219 let fetch_limit = page_limit
5220 .checked_add(1)
5221 .ok_or_else(|| anyhow!("analysis document page limit is too large"))?;
5222 let mut sql = format!(
5223 "select {} from sessions s where 1 = 1",
5224 session_record_columns!()
5225 );
5226 let mut params_vec = Vec::new();
5227 push_session_filters(&mut sql, &mut params_vec, filters, access);
5228 if let Some(cursor) = cursor {
5229 sql.push_str(" and s.id > ? ");
5230 params_vec.push(cursor.as_str().to_string());
5231 }
5232 write!(sql, " order by s.id asc limit {fetch_limit}")?;
5233
5234 let mut session_stmt = transaction.prepare(&sql)?;
5235 let session_rows = session_stmt.query_map(
5236 rusqlite::params_from_iter(params_vec.iter()),
5237 row_to_session_record,
5238 )?;
5239 let mut sessions = Vec::new();
5240 for row in session_rows {
5241 sessions.push(row?);
5242 }
5243 let has_more = sessions.len() > page_limit;
5244 sessions.truncate(page_limit);
5245 let next_cursor = has_more
5246 .then(|| sessions.last().map(|session| session.id.clone()))
5247 .flatten()
5248 .map(AnalysisCursor::after);
5249 Ok((sessions, next_cursor))
5250}
5251
5252fn analysis_document_page(
5253 transaction: &rusqlite::Transaction<'_>,
5254 filters: &SearchFilters,
5255 cursor: Option<&crate::models::AnalysisCursor>,
5256 page_limit: usize,
5257 access: &crate::search_scope::EffectiveAccessScope,
5258) -> Result<crate::models::AnalysisDocumentPage> {
5259 use crate::models::{AnalysisDocument, AnalysisDocumentPage};
5260
5261 let (sessions, next_cursor) =
5262 analysis_session_page(transaction, filters, cursor, page_limit, access)?;
5263 let mut count_stmt = transaction.prepare(ANALYSIS_MESSAGE_COUNTS_SQL)?;
5264 let mut human_message_stmt = transaction.prepare(ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL)?;
5265 let mut documents = Vec::with_capacity(sessions.len());
5266 for session in sessions {
5267 let (message_count, human_message_count) = count_stmt.query_row([&session.id], |row| {
5268 Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
5269 })?;
5270 let rows = human_message_stmt.query_map([&session.id], |row| {
5271 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
5272 })?;
5273 let mut user_text = String::new();
5274 let mut first_user_text = None;
5275 for row in rows {
5276 let (_seq, content) = row?;
5277 if first_user_text.is_none() {
5278 first_user_text = Some(content.clone());
5279 }
5280 if !user_text.is_empty() {
5281 user_text.push(' ');
5282 }
5283 user_text.push_str(&content);
5284 }
5285 documents.push(AnalysisDocument {
5286 session,
5287 user_text,
5288 first_user_text,
5289 message_count,
5290 user_message_count: human_message_count,
5291 });
5292 }
5293 Ok(AnalysisDocumentPage {
5294 documents,
5295 next_cursor,
5296 })
5297}
5298
5299fn push_session_filters(
5300 sql: &mut String,
5301 params_vec: &mut Vec<String>,
5302 filters: &SearchFilters,
5303 access: &crate::search_scope::EffectiveAccessScope,
5304) {
5305 if let Some(provider) = filters.provider {
5306 sql.push_str(" and s.provider = ? ");
5307 params_vec.push(provider.as_str().to_string());
5308 }
5309 if let Some(path_prefix) = &filters.path_prefix {
5310 push_session_path_prefix(sql, params_vec, path_prefix);
5311 }
5312 push_session_access_scope(sql, params_vec, access);
5313 push_session_exclusions(sql, params_vec, filters);
5314 push_session_kinds(sql, filters);
5315 if let Some(parent_session_id) = &filters.parent_session_id {
5316 sql.push_str(" and s.parent_session_id = ? ");
5317 params_vec.push(parent_session_id.clone());
5318 }
5319 push_session_time_window(sql, params_vec, filters.since, filters.until);
5320 if filters.warnings_only {
5321 sql.push_str(" and s.parse_warning is not null and s.parse_warning != '' ");
5322 }
5323}
5324
5325fn until_bound_text(until: chrono::DateTime<Utc>) -> String {
5330 until.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
5331}
5332
5333fn prefix_upper_bound(prefix: &str) -> Option<String> {
5341 let mut head = prefix.to_string();
5342 while let Some(last) = head.pop() {
5343 let next = match u32::from(last) + 1 {
5345 0xD800 => 0xE000,
5346 code => code,
5347 };
5348 if let Some(next) = char::from_u32(next) {
5349 head.push(next);
5350 return Some(head);
5351 }
5352 }
5353 None
5354}
5355
5356fn push_path_prefix(
5364 sql: &mut String,
5365 args: &mut Vec<rusqlite::types::Value>,
5366 id_col: &str,
5367 path_prefix: Option<&str>,
5368) {
5369 use std::fmt::Write as _;
5370 if let Some(prefix) = path_prefix {
5371 let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5372 push_path_condition(sql, args, prefix);
5373 sql.push(')');
5374 }
5375}
5376
5377fn push_path_condition(sql: &mut String, args: &mut Vec<rusqlite::types::Value>, prefix: &str) {
5378 use rusqlite::types::Value;
5379 sql.push_str(
5380 "(coalesce(cwd, '') = ? or coalesce(cwd, '') like ? escape '\\' \
5381 or coalesce(repo_root, '') = ? or coalesce(repo_root, '') like ? escape '\\' \
5382 or coalesce(source_path, '') = ? or coalesce(source_path, '') like ? escape '\\')",
5383 );
5384 let (exact, child_pattern) = path_prefix_patterns(prefix);
5385 args.push(Value::Text(exact.clone()));
5386 args.push(Value::Text(child_pattern.clone()));
5387 args.push(Value::Text(exact.clone()));
5388 args.push(Value::Text(child_pattern.clone()));
5389 args.push(Value::Text(exact));
5390 args.push(Value::Text(child_pattern));
5391}
5392
5393#[derive(Clone, Copy)]
5394enum SessionPathDomain {
5395 Workspace,
5396 Transcript,
5397}
5398
5399fn push_domain_path_prefix(
5400 sql: &mut String,
5401 args: &mut Vec<rusqlite::types::Value>,
5402 id_col: &str,
5403 prefix: Option<&str>,
5404 domain: SessionPathDomain,
5405) {
5406 use std::fmt::Write as _;
5407 if let Some(prefix) = prefix {
5408 let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5409 push_domain_path_condition(sql, args, prefix, domain);
5410 sql.push(')');
5411 }
5412}
5413
5414fn push_domain_path_condition(
5415 sql: &mut String,
5416 args: &mut Vec<rusqlite::types::Value>,
5417 prefix: &str,
5418 domain: SessionPathDomain,
5419) {
5420 use rusqlite::types::Value;
5421 let (exact, child_pattern) = path_prefix_patterns(prefix);
5422 match domain {
5423 SessionPathDomain::Workspace => {
5424 sql.push_str(
5425 "(coalesce(cwd, '') = ? or coalesce(cwd, '') like ? escape '\\' \
5426 or coalesce(repo_root, '') = ? or coalesce(repo_root, '') like ? escape '\\')",
5427 );
5428 args.push(Value::Text(exact.clone()));
5429 args.push(Value::Text(child_pattern.clone()));
5430 args.push(Value::Text(exact));
5431 args.push(Value::Text(child_pattern));
5432 }
5433 SessionPathDomain::Transcript => {
5434 sql.push_str(
5435 "(coalesce(source_path, '') = ? or coalesce(source_path, '') like ? escape '\\')",
5436 );
5437 args.push(Value::Text(exact));
5438 args.push(Value::Text(child_pattern));
5439 }
5440 }
5441}
5442
5443fn push_access_scope(
5444 sql: &mut String,
5445 args: &mut Vec<rusqlite::types::Value>,
5446 id_col: &str,
5447 access: &crate::search_scope::EffectiveAccessScope,
5448) {
5449 use std::fmt::Write as _;
5450 let prefixes: Vec<&str> = access.workspace_prefixes().collect();
5451 if prefixes.is_empty() {
5452 return;
5453 }
5454 let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5455 for (index, prefix) in prefixes.into_iter().enumerate() {
5456 if index > 0 {
5457 sql.push_str(" or ");
5458 }
5459 push_domain_path_condition(sql, args, prefix, SessionPathDomain::Workspace);
5460 }
5461 sql.push(')');
5462}
5463
5464fn push_session_access_scope(
5465 sql: &mut String,
5466 args: &mut Vec<String>,
5467 access: &crate::search_scope::EffectiveAccessScope,
5468) {
5469 let prefixes: Vec<&str> = access.workspace_prefixes().collect();
5470 if prefixes.is_empty() {
5471 return;
5472 }
5473 sql.push_str(" and (");
5474 for (index, prefix) in prefixes.into_iter().enumerate() {
5475 if index > 0 {
5476 sql.push_str(" or ");
5477 }
5478 let (exact, child_pattern) = path_prefix_patterns(prefix);
5479 sql.push_str(
5480 "(coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\' \
5481 or coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\')",
5482 );
5483 args.push(exact.clone());
5484 args.push(child_pattern.clone());
5485 args.push(exact);
5486 args.push(child_pattern);
5487 }
5488 sql.push(')');
5489}
5490
5491fn push_exclude_domain_path_prefixes(
5492 sql: &mut String,
5493 args: &mut Vec<rusqlite::types::Value>,
5494 id_col: &str,
5495 prefixes: &[String],
5496 domain: SessionPathDomain,
5497) {
5498 use std::fmt::Write as _;
5499 if prefixes.is_empty() {
5500 return;
5501 }
5502 let _ = write!(sql, " and {id_col} not in (select id from sessions where ");
5503 for (index, prefix) in prefixes.iter().enumerate() {
5504 if index > 0 {
5505 sql.push_str(" or ");
5506 }
5507 push_domain_path_condition(sql, args, prefix, domain);
5508 }
5509 sql.push(')');
5510}
5511
5512fn push_exclude_path_prefixes(
5513 sql: &mut String,
5514 args: &mut Vec<rusqlite::types::Value>,
5515 id_col: &str,
5516 prefixes: &[String],
5517) {
5518 use std::fmt::Write as _;
5519 if prefixes.is_empty() {
5520 return;
5521 }
5522 let _ = write!(sql, " and {id_col} not in (select id from sessions where ");
5523 for (i, prefix) in prefixes.iter().enumerate() {
5524 if i > 0 {
5525 sql.push_str(" or ");
5526 }
5527 push_path_condition(sql, args, prefix);
5528 }
5529 sql.push(')');
5530}
5531
5532#[derive(Debug, Clone, Copy)]
5543enum ClassSelection<'a> {
5544 FromFilters,
5546 Unrestricted,
5548 Only(&'a [crate::models::MessageKind]),
5550}
5551
5552fn append_message_filters(
5553 sql: &mut String,
5554 args: &mut Vec<rusqlite::types::Value>,
5555 filters: &MessageFilters,
5556 access: &crate::search_scope::EffectiveAccessScope,
5557) {
5558 append_message_filters_with_class(sql, args, filters, access, ClassSelection::FromFilters);
5559}
5560
5561fn append_message_filters_with_class(
5562 sql: &mut String,
5563 args: &mut Vec<rusqlite::types::Value>,
5564 filters: &MessageFilters,
5565 access: &crate::search_scope::EffectiveAccessScope,
5566 class: ClassSelection<'_>,
5567) {
5568 use rusqlite::types::Value;
5569 if let Some(role) = filters.role {
5570 sql.push_str(" and m.role = ?");
5571 args.push(Value::Text(role.as_str().to_string()));
5572 }
5573 let predicate = match class {
5582 ClassSelection::FromFilters => Some(filters.kind_predicate()),
5583 ClassSelection::Unrestricted => None,
5584 ClassSelection::Only(kinds) => Some(crate::models::KindPredicate::Only(kinds.to_vec())),
5585 };
5586 match predicate {
5587 None => {}
5588 Some(crate::models::KindPredicate::AllExcept(excluded)) => {
5589 for kind in excluded {
5590 sql.push_str(" and m.kind != ?");
5591 args.push(Value::Text(kind.as_str().to_string()));
5592 }
5593 }
5594 Some(crate::models::KindPredicate::Only(kinds)) if kinds.is_empty() => {
5595 sql.push_str(" and 0");
5597 }
5598 Some(crate::models::KindPredicate::Only(kinds)) => {
5599 sql.push_str(" and m.kind in (");
5600 for (i, kind) in kinds.iter().enumerate() {
5601 if i > 0 {
5602 sql.push_str(", ");
5603 }
5604 sql.push('?');
5605 args.push(Value::Text(kind.as_str().to_string()));
5606 }
5607 sql.push(')');
5608 }
5609 }
5610 if let Some(providers) = &filters.providers {
5611 if providers.is_empty() {
5612 sql.push_str(" and 0");
5613 } else {
5614 sql.push_str(" and m.provider in (");
5615 for (index, provider) in providers.iter().enumerate() {
5616 if index > 0 {
5617 sql.push_str(", ");
5618 }
5619 sql.push('?');
5620 args.push(Value::Text(provider.as_str().to_string()));
5621 }
5622 sql.push(')');
5623 }
5624 }
5625 if let Some(session_id) = &filters.session_id {
5626 sql.push_str(" and m.session_id = ?");
5627 args.push(Value::Text(session_id.clone()));
5628 }
5629 push_message_session_kinds(sql, "m.session_id", filters.session_kinds.as_ref());
5630 push_access_scope(sql, args, "m.session_id", access);
5631 push_path_prefix(sql, args, "m.session_id", filters.path_prefix.as_deref());
5632 push_exclude_path_prefixes(sql, args, "m.session_id", &filters.exclude_path_prefixes);
5633 push_domain_path_prefix(
5634 sql,
5635 args,
5636 "m.session_id",
5637 filters.workspace_path_prefix.as_deref(),
5638 SessionPathDomain::Workspace,
5639 );
5640 push_domain_path_prefix(
5641 sql,
5642 args,
5643 "m.session_id",
5644 filters.transcript_path_prefix.as_deref(),
5645 SessionPathDomain::Transcript,
5646 );
5647 push_exclude_domain_path_prefixes(
5648 sql,
5649 args,
5650 "m.session_id",
5651 &filters.exclude_workspace_path_prefixes,
5652 SessionPathDomain::Workspace,
5653 );
5654 push_exclude_domain_path_prefixes(
5655 sql,
5656 args,
5657 "m.session_id",
5658 &filters.exclude_transcript_path_prefixes,
5659 SessionPathDomain::Transcript,
5660 );
5661 for session_id in &filters.exclude_session_ids {
5662 sql.push_str(" and m.session_id <> ?");
5663 args.push(Value::Text(session_id.clone()));
5664 }
5665 if let Some(tool) = &filters.tool {
5666 sql.push_str(" and unicode_lower_contains(m.tool_name, ?)");
5668 args.push(Value::Text(fold_caseless(tool)));
5669 }
5670 if let Some(seq_from) = filters.seq_from {
5671 sql.push_str(" and m.seq >= ?");
5672 args.push(Value::Integer(seq_from));
5673 }
5674 if let Some(seq_to) = filters.seq_to {
5675 sql.push_str(" and m.seq <= ?");
5676 args.push(Value::Integer(seq_to));
5677 }
5678 push_ts_window(sql, args, "m.ts", filters.since, filters.until);
5679 }
5684
5685fn insert_messages<'a>(
5692 tx: &rusqlite::Transaction<'_>,
5693 session: &SessionRecord,
5694 rows: impl Iterator<Item = (i64, &'a crate::models::Message)>,
5695) -> Result<()> {
5696 let mut stmt = tx.prepare(
5697 "insert into messages
5698 (session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, is_compaction,
5699 content, authorship, correlation_authority, correlation_scope, correlation_id,
5700 record_relation)
5701 values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
5702 )?;
5703 let mut part_stmt = tx.prepare(
5704 "insert into message_content_parts
5705 (message_id, ordinal, start_char, end_char, authorship, origin)
5706 values (?1, ?2, ?3, ?4, ?5, ?6)",
5707 )?;
5708 for (seq, message) in rows {
5709 if let Err(detail) = message.provenance.validate(&message.content) {
5710 bail!(
5711 "invalid provenance for message sequence {seq} in session '{}': {detail}",
5712 session.id
5713 );
5714 }
5715 let correlation = message.provenance.correlation_identity.as_ref();
5716 stmt.execute(params![
5717 session.id,
5718 session.provider.as_str(),
5719 seq,
5720 message.role.as_str(),
5721 message.ts.map(|ts| ts.to_rfc3339()),
5722 message.tool_name,
5723 message.kind.as_str(),
5724 message.tool_call_id,
5725 message.is_compaction as i64,
5726 message.content,
5727 message.provenance.authorship.as_str(),
5728 correlation.map(|value| value.authority.as_str()),
5729 correlation.map(|value| value.scope.as_str()),
5730 correlation.map(|value| value.id.as_str()),
5731 message.provenance.record_relation.as_str(),
5732 ])?;
5733 let message_id = tx.last_insert_rowid();
5734 for part in &message.provenance.content_parts {
5735 part_stmt.execute(params![
5736 message_id,
5737 i64::from(part.ordinal),
5738 i64::try_from(part.start_char).context("content-part start exceeds SQLite i64")?,
5739 i64::try_from(part.end_char).context("content-part end exceeds SQLite i64")?,
5740 part.authorship.as_str(),
5741 part.origin.as_str(),
5742 ])?;
5743 }
5744 }
5745 Ok(())
5746}
5747
5748fn insert_file_edits<'a>(
5752 tx: &rusqlite::Transaction<'_>,
5753 session: &SessionRecord,
5754 rows: impl Iterator<Item = (i64, &'a FileEdit)>,
5755) -> Result<()> {
5756 let mut stmt = tx.prepare(
5757 "insert into file_edits
5758 (session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json)
5759 values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
5760 )?;
5761 for (seq, edit) in rows {
5762 let edits_json = if edit.edits.is_empty() {
5763 None
5764 } else {
5765 Some(serde_json::to_string(&edit.edits)?)
5766 };
5767 stmt.execute(params![
5768 session.id,
5769 session.provider.as_str(),
5770 seq,
5771 edit.ts.map(|ts| ts.to_rfc3339()),
5772 edit.tool,
5773 edit.file_path,
5774 edit.file_name,
5775 edit.new_content,
5776 edits_json,
5777 ])?;
5778 }
5779 Ok(())
5780}
5781
5782fn push_ts_window(
5791 sql: &mut String,
5792 args: &mut Vec<rusqlite::types::Value>,
5793 col: &str,
5794 since: Option<chrono::DateTime<Utc>>,
5795 until: Option<chrono::DateTime<Utc>>,
5796) {
5797 use rusqlite::types::Value;
5798 use std::fmt::Write as _;
5799 if let Some(since) = since {
5802 let _ = write!(sql, " and {col} >= ?");
5803 args.push(Value::Text(since.to_rfc3339()));
5804 }
5805 if let Some(until) = until {
5806 let _ = write!(sql, " and {col} <= ?");
5807 args.push(Value::Text(until_bound_text(until)));
5808 }
5809}
5810
5811fn push_file_filters(
5812 sql: &mut String,
5813 args: &mut Vec<rusqlite::types::Value>,
5814 query: &FileQuery,
5815 access: &crate::search_scope::EffectiveAccessScope,
5816) {
5817 use rusqlite::types::Value;
5818 if let Some(provider) = query.provider {
5819 sql.push_str(" and provider = ?");
5820 args.push(Value::Text(provider.as_str().to_string()));
5821 }
5822 if let Some(session_id) = query.session_id.as_deref() {
5823 sql.push_str(" and session_id = ?");
5824 args.push(Value::Text(session_id.to_string()));
5825 }
5826 push_access_scope(sql, args, "session_id", access);
5827 push_path_prefix(sql, args, "session_id", query.path_prefix.as_deref());
5828 push_exclude_path_prefixes(sql, args, "session_id", &query.exclude_path_prefixes);
5829 for session_id in &query.exclude_session_ids {
5830 sql.push_str(" and session_id <> ?");
5831 args.push(Value::Text(session_id.clone()));
5832 }
5833}
5834
5835fn path_prefix_parts(prefix: &str) -> (String, String) {
5836 let bytes = prefix.as_bytes();
5837 let windows_style = prefix.starts_with(r"\\")
5838 || matches!(bytes, [drive, b':', b'\\' | b'/', ..] if drive.is_ascii_alphabetic());
5839 let separator = if windows_style { '\\' } else { '/' };
5840 let exact = prefix.trim_end_matches(separator).to_string();
5841 let child = format!("{exact}{separator}");
5842 (exact, child)
5843}
5844
5845fn path_prefix_patterns(prefix: &str) -> (String, String) {
5846 let (exact, child) = path_prefix_parts(prefix);
5847 (exact, literal_like_prefix_pattern(&child))
5848}
5849
5850fn literal_like_prefix_pattern(value: &str) -> String {
5855 let mut escaped = String::with_capacity(value.len() + 2);
5856 for ch in value.chars() {
5857 match ch {
5858 '%' | '_' | '\\' => {
5859 escaped.push('\\');
5860 escaped.push(ch);
5861 }
5862 other => escaped.push(other),
5863 }
5864 }
5865 escaped.push('%');
5866 escaped
5867}
5868
5869fn push_session_path_prefix(sql: &mut String, args: &mut Vec<String>, path_prefix: &str) {
5870 let (exact, child_pattern) = path_prefix_patterns(path_prefix);
5871 sql.push_str(
5872 " and ((coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\') \
5873 or (coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\') \
5874 or (coalesce(s.source_path, '') = ? or coalesce(s.source_path, '') like ? escape '\\')) ",
5875 );
5876 args.push(exact.clone());
5877 args.push(child_pattern.clone());
5878 args.push(exact.clone());
5879 args.push(child_pattern.clone());
5880 args.push(exact);
5881 args.push(child_pattern);
5882}
5883
5884fn push_session_exclusions(sql: &mut String, args: &mut Vec<String>, filters: &SearchFilters) {
5885 for session_id in &filters.exclude_session_ids {
5886 sql.push_str(" and s.id <> ? ");
5887 args.push(session_id.clone());
5888 }
5889 for prefix in &filters.exclude_path_prefixes {
5890 let (exact, child_pattern) = path_prefix_patterns(prefix);
5891 sql.push_str(
5892 " and not ((coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\') \
5893 or (coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\') \
5894 or (coalesce(s.source_path, '') = ? or coalesce(s.source_path, '') like ? escape '\\')) ",
5895 );
5896 args.push(exact.clone());
5897 args.push(child_pattern.clone());
5898 args.push(exact.clone());
5899 args.push(child_pattern.clone());
5900 args.push(exact);
5901 args.push(child_pattern);
5902 }
5903}
5904
5905fn session_kind_predicate(selected: &[crate::models::SessionKind], alias: &str) -> Option<String> {
5925 if selected.is_empty() {
5926 return Some("0".to_string());
5927 }
5928 if crate::models::SessionKind::all()
5929 .iter()
5930 .all(|kind| selected.contains(kind))
5931 {
5932 return None;
5933 }
5934 let mut predicate = String::from("(");
5935 for (index, kind) in selected.iter().enumerate() {
5936 if index > 0 {
5937 predicate.push_str(" or ");
5938 }
5939 predicate.push_str(&kind.sql_predicate(alias));
5940 }
5941 predicate.push(')');
5942 Some(predicate)
5943}
5944
5945fn push_session_kinds(sql: &mut String, filters: &SearchFilters) {
5946 if let Some(predicate) = session_kind_predicate(&filters.effective_session_kinds(), "s") {
5947 sql.push_str(" and ");
5948 sql.push_str(&predicate);
5949 sql.push(' ');
5950 }
5951}
5952
5953fn push_message_session_kinds(
5956 sql: &mut String,
5957 id_col: &str,
5958 session_kinds: Option<&Vec<crate::models::SessionKind>>,
5959) {
5960 use std::fmt::Write as _;
5961 let Some(selected) = session_kinds else {
5962 return;
5964 };
5965 let Some(predicate) = session_kind_predicate(selected, "sessions") else {
5966 return;
5967 };
5968 if predicate == "0" {
5969 sql.push_str(" and 0");
5970 return;
5971 }
5972 let _ = write!(
5973 sql,
5974 " and {id_col} in (select id from sessions where {predicate})"
5975 );
5976}
5977
5978fn push_session_time_window(
5985 sql: &mut String,
5986 args: &mut Vec<String>,
5987 since: Option<chrono::DateTime<Utc>>,
5988 until: Option<chrono::DateTime<Utc>>,
5989) {
5990 if let Some(since) = since {
5991 sql.push_str(" and coalesce(s.updated_at, s.created_at) >= ? ");
5992 args.push(since.to_rfc3339());
5993 }
5994 if let Some(until) = until {
5995 sql.push_str(" and coalesce(s.created_at, s.updated_at) <= ? ");
5996 args.push(until_bound_text(until));
5997 }
5998}
5999
6000fn row_to_message_hit(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageHit> {
6001 row_to_message_hit_at(row, 0)
6002}
6003
6004fn decode_db_enum<T>(
6005 value: String,
6006 column_index: usize,
6007 context: &'static str,
6008 decode: impl FnOnce(&str) -> std::result::Result<T, String>,
6009) -> rusqlite::Result<T> {
6010 decode(&value).map_err(|reason| {
6016 rusqlite::Error::FromSqlConversionFailure(
6017 column_index,
6018 rusqlite::types::Type::Text,
6019 std::io::Error::new(
6020 std::io::ErrorKind::InvalidData,
6021 format!("{reason} (while reading {context})"),
6022 )
6023 .into(),
6024 )
6025 })
6026}
6027
6028fn row_to_message_hit_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<MessageHit> {
6029 let ts: Option<String> = row.get(offset + 4)?;
6030 Ok(MessageHit {
6031 session_id: row.get(offset)?,
6032 provider: decode_db_enum(
6033 row.get::<_, String>(offset + 1)?,
6034 offset + 1,
6035 "messages.provider",
6036 Provider::from_db_str,
6037 )?,
6038 seq: row.get(offset + 2)?,
6039 role: decode_db_enum(
6040 row.get::<_, String>(offset + 3)?,
6041 offset + 3,
6042 "messages.role",
6043 Role::from_db_str,
6044 )?,
6045 ts: ts.and_then(|value| {
6046 chrono::DateTime::parse_from_rfc3339(&value)
6047 .ok()
6048 .map(|dt| dt.with_timezone(&Utc))
6049 }),
6050 tool_name: row.get(offset + 5)?,
6051 kind: crate::models::MessageKind::from_db_str(&row.get::<_, String>(offset + 6)?),
6052 tool_call_id: row.get(offset + 7)?,
6053 fuzzy_score: None,
6054 content: row.get(offset + 8)?,
6055 })
6056}
6057
6058fn json_projection_prefilter_groups(pattern: &str) -> Option<Vec<Vec<String>>> {
6072 let groups = crate::trigram::trigram_prefilter_groups(pattern)?;
6073 let groups = groups
6074 .into_iter()
6075 .map(|group| {
6076 group
6077 .into_iter()
6078 .filter(|trigram| {
6079 trigram.chars().all(|character| {
6080 !character.is_control() && !matches!(character, '"' | '\\')
6081 })
6082 })
6083 .collect::<Vec<_>>()
6084 })
6085 .collect::<Vec<_>>();
6086 (!groups.is_empty() && groups.iter().all(|group| !group.is_empty())).then_some(groups)
6087}
6088
6089fn score_fuzzy_derived_hits(
6094 pattern: &Pattern,
6095 query_lower: &str,
6096 field: SearchField,
6097 argument_path: Option<&str>,
6098 hits: Vec<MessageHit>,
6099) -> Vec<(MessageHit, bool)> {
6100 let exact_phrase_needle = UnicodeLowerNeedle::from_lowered(query_lower);
6101 hits.into_par_iter()
6102 .map_init(
6103 || (NucleoMatcher::new(NucleoConfig::DEFAULT), Vec::new()),
6104 |(matcher, utf32_buf), mut hit| {
6105 let value = selected_message_field_parts(&hit, field, argument_path)?.into_owned();
6106 utf32_buf.clear();
6107 pattern
6108 .score(Utf32Str::new(&value, utf32_buf), matcher)
6109 .map(|score| {
6110 hit.fuzzy_score = Some(score);
6111 let exact_phrase = exact_phrase_needle.contains(&value);
6112 (hit, exact_phrase)
6113 })
6114 },
6115 )
6116 .filter_map(std::convert::identity)
6117 .collect()
6118}
6119
6120fn score_fuzzy_message_hits(
6121 pattern: &Pattern,
6122 query_lower: &str,
6123 hits: Vec<MessageHit>,
6124) -> Vec<(MessageHit, bool)> {
6125 let exact_phrase_needle = UnicodeLowerNeedle::from_lowered(query_lower);
6126 hits.into_par_iter()
6127 .map_init(
6128 || (NucleoMatcher::new(NucleoConfig::DEFAULT), Vec::new()),
6129 |(matcher, utf32_buf), mut hit| {
6130 let score = {
6131 let haystack = Utf32Str::new(&hit.content, utf32_buf);
6132 pattern.score(haystack, matcher)
6133 };
6134 score.map(|score| {
6135 hit.fuzzy_score = Some(score);
6136 let exact_phrase = exact_phrase_needle.contains(&hit.content);
6137 (hit, exact_phrase)
6138 })
6139 },
6140 )
6141 .filter_map(std::convert::identity)
6142 .collect()
6143}
6144
6145fn compare_fuzzy_hits(left: &(MessageHit, bool), right: &(MessageHit, bool)) -> std::cmp::Ordering {
6146 right
6147 .0
6148 .fuzzy_score
6149 .unwrap_or_default()
6150 .cmp(&left.0.fuzzy_score.unwrap_or_default())
6151 .then_with(|| right.1.cmp(&left.1))
6152 .then_with(|| left.0.session_id.cmp(&right.0.session_id))
6153 .then_with(|| left.0.seq.cmp(&right.0.seq))
6154}
6155
6156fn scored_whole_corpus(matched: i64, corpus: i64) -> SearchExplain {
6162 SearchExplain {
6163 prefilter: None,
6164 candidates: Some(matched),
6165 prefilter_skipped: Some(
6166 "complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows"
6167 .to_string(),
6168 ),
6169 corpus,
6170 }
6171}
6172
6173fn empty_past_corpus(corpus: i64) -> SearchExplain {
6179 SearchExplain {
6180 prefilter: None,
6181 candidates: None,
6182 prefilter_skipped: Some(
6183 "offset starts at or past the last eligible row, so no row was scored".to_string(),
6184 ),
6185 corpus,
6186 }
6187}
6188
6189const fn fuzzy_derived_scan_predicate(field: SearchField) -> &'static str {
6197 match field {
6198 SearchField::ToolArgument => "m.kind = 'tool_call'",
6199 SearchField::ToolName | SearchField::Content => "1 = 1",
6200 }
6201}
6202
6203enum FuzzyRankingWindow {
6207 Rank { limit: usize, corpus: Option<i64> },
6211 PastCorpus { corpus: i64 },
6215}
6216
6217fn fuzzy_ranked_limit(filters: &MessageFilters) -> Result<usize> {
6218 filters
6219 .offset
6220 .checked_add(filters.limit)
6221 .ok_or_else(|| anyhow!("fuzzy offset + limit exceeds the platform addressable range"))
6222}
6223
6224fn top_k_compaction_threshold(limit: usize) -> usize {
6228 limit.saturating_mul(2).max(FUZZY_SCORE_BATCH_SIZE)
6229}
6230
6231fn retain_top_fuzzy_hits(scored: &mut Vec<(MessageHit, bool)>, limit: usize) {
6232 if scored.len() > limit {
6233 scored.select_nth_unstable_by(limit, compare_fuzzy_hits);
6234 scored.truncate(limit);
6235 }
6236}
6237
6238fn finish_fuzzy_hits(
6239 mut scored: Vec<(MessageHit, bool)>,
6240 ranked_limit: usize,
6241 offset: usize,
6242) -> Vec<MessageHit> {
6243 retain_top_fuzzy_hits(&mut scored, ranked_limit);
6244 scored.sort_by(compare_fuzzy_hits);
6245 scored
6246 .into_iter()
6247 .skip(offset)
6248 .map(|(hit, _)| hit)
6249 .collect()
6250}
6251
6252fn session_search_record_bytes(record: &SessionWithTranscript) -> usize {
6253 record.transcript_text.len()
6254 + record.session.preview_text.len()
6255 + record.session.title.as_ref().map_or(0, String::len)
6256 + record.session.summary.as_ref().map_or(0, String::len)
6257 + record.session.cwd.as_ref().map_or(0, String::len)
6258 + record.session.repo_root.as_ref().map_or(0, String::len)
6259}
6260
6261struct SessionScoreContext<'a> {
6262 query: &'a str,
6263 query_needle: &'a UnicodeLowerNeedle,
6264 tokens: &'a [&'a str],
6265 token_needles: &'a [UnicodeLowerNeedle],
6266 current_repo: Option<&'a str>,
6267 scoring: &'a crate::config::ScoringConfig,
6268 cancellation: Option<&'a QueryCancellation>,
6269 now: DateTime<Utc>,
6270}
6271
6272fn session_contains(
6273 needle: &UnicodeLowerNeedle,
6274 value: &str,
6275 cancellation: Option<&QueryCancellation>,
6276) -> Result<bool> {
6277 needle
6278 .contains_while(value, || {
6279 cancellation.is_none_or(|token| !token.is_cancelled())
6280 })
6281 .ok_or_else(|| QueryCancelled.into())
6282}
6283
6284fn session_snippet(
6285 value: &str,
6286 query: &str,
6287 cancellation: Option<&QueryCancellation>,
6288) -> Result<String> {
6289 match cancellation {
6290 Some(token) => snippet_from_match_while(value, query, 160, || !token.is_cancelled())
6291 .ok_or_else(|| QueryCancelled.into()),
6292 None => Ok(snippet_from_match(value, query, 160)),
6293 }
6294}
6295
6296fn score_session_records(
6302 records: Vec<SessionWithTranscript>,
6303 context: &SessionScoreContext<'_>,
6304) -> Result<Vec<SearchHit>> {
6305 let SessionScoreContext {
6306 query,
6307 query_needle,
6308 tokens,
6309 token_needles,
6310 current_repo,
6311 scoring,
6312 cancellation,
6313 now,
6314 } = context;
6315 records
6316 .into_par_iter()
6317 .map(|record| -> Result<Option<SearchHit>> {
6318 if let Some(cancellation) = cancellation {
6319 cancellation.ensure_active()?;
6320 }
6321 let matcher = SkimMatcherV2::default().smart_case();
6322 let title = record.session.title.as_deref().unwrap_or_default();
6323 let summary = record.session.summary.as_deref().unwrap_or_default();
6324 let cwd = record.session.cwd.as_deref().unwrap_or_default();
6325 let repo_root = record.session.repo_root.as_deref().unwrap_or_default();
6326 let preview = record.session.preview_text.as_str();
6327 let transcript = record.transcript_text.as_str();
6328 let haystacks = [
6329 ("title", title),
6330 ("summary", summary),
6331 ("cwd", cwd),
6332 ("repo", repo_root),
6333 ("preview", preview),
6334 ("transcript", transcript),
6335 ];
6336
6337 let mut score = 0_i64;
6338 let mut best_source = "fuzzy".to_string();
6339 let mut best_source_score = i64::MIN;
6340 let mut best_snippet = session_snippet(preview, query, *cancellation)?;
6341 let mut term_coverage = vec![false; tokens.len()];
6342 let mut matched = false;
6343 for (source, value) in haystacks {
6344 let mut source_score = 0_i64;
6345 if session_contains(query_needle, value, *cancellation)? {
6346 matched = true;
6347 source_score += match source {
6348 "title" => scoring.title_score,
6349 "summary" => scoring.summary_score,
6350 "cwd" | "repo" => scoring.path_score,
6351 "preview" => scoring.preview_score,
6352 _ => scoring.other_score,
6353 };
6354 }
6355 for (index, token) in tokens.iter().enumerate() {
6356 if !token.is_empty()
6357 && session_contains(&token_needles[index], value, *cancellation)?
6358 {
6359 matched = true;
6360 source_score += scoring.token_bonus;
6361 term_coverage[index] = true;
6362 }
6363 }
6364 if matches!(source, "title" | "cwd" | "repo" | "preview") {
6365 if let Some(fuzzy_score) = matcher.fuzzy_match(value, query) {
6366 matched = true;
6367 source_score += fuzzy_score;
6368 }
6369 }
6370 score += source_score;
6371 if source_score > best_source_score {
6372 best_source_score = source_score;
6373 best_source = source.to_string();
6374 best_snippet = session_snippet(value, query, *cancellation)?;
6375 }
6376 }
6377 if !matched {
6378 return Ok(None);
6379 }
6380 if tokens.len() > 1 && term_coverage.iter().all(|matched| *matched) {
6381 score += scoring.all_tokens_bonus;
6382 }
6383 if let Some(updated_at) = record.session.updated_at {
6384 let age_days = (*now - updated_at)
6385 .num_days()
6386 .clamp(0, scoring.recency_max_days);
6387 score += (scoring.recency_max_days - age_days) * scoring.recency_weight;
6388 }
6389 if let (Some(current_repo), Some(repo_root)) =
6390 (current_repo, record.session.repo_root.as_deref())
6391 {
6392 if *current_repo == repo_root {
6393 score += scoring.current_repo_bonus;
6394 if best_source == "fuzzy" {
6395 best_source = "repo".to_string();
6396 best_snippet = snippet_from_match(repo_root, query, 160);
6397 }
6398 }
6399 }
6400 Ok(Some(SearchHit {
6401 session: record.session,
6402 score,
6403 match_source: best_source,
6404 match_snippet: best_snippet,
6405 }))
6406 })
6407 .filter_map(|scored| scored.transpose())
6412 .collect::<Result<Vec<_>>>()
6413}
6414
6415fn compare_session_hits(left: &SearchHit, right: &SearchHit) -> std::cmp::Ordering {
6416 right
6417 .score
6418 .cmp(&left.score)
6419 .then_with(|| right.session.updated_at.cmp(&left.session.updated_at))
6420 .then_with(|| left.session.id.cmp(&right.session.id))
6421}
6422
6423fn retain_top_session_hits(hits: &mut Vec<SearchHit>, limit: usize) {
6424 if hits.len() > limit {
6425 hits.select_nth_unstable_by(limit, compare_session_hits);
6426 hits.truncate(limit);
6427 }
6428}
6429
6430fn glob_clause(pattern: &str) -> (&'static str, String) {
6435 let mut like = String::with_capacity(pattern.len() + 1);
6436 for ch in pattern.chars() {
6437 match ch {
6438 '*' => like.push('%'),
6439 '?' => like.push('_'),
6440 '%' | '_' | '\\' => {
6441 like.push('\\');
6442 like.push(ch);
6443 }
6444 other => like.push(other),
6445 }
6446 }
6447 if pattern.contains('/') {
6448 ("file_path", format!("%{like}"))
6449 } else {
6450 ("file_name", like)
6451 }
6452}
6453
6454const RESOLVE_SESSION_EXACT_SQL: &str = concat!(
6455 "select ",
6456 session_record_columns!(),
6457 ", coalesce(t.transcript_text, '') as transcript_text \
6458 from sessions s \
6459 left join transcripts t on t.session_id = s.id \
6460 where (s.id = ?1 or s.provider_session_id = ?1)"
6461);
6462
6463const RESOLVE_SESSION_PREFIX_SQL: &str = concat!(
6464 "select ",
6465 session_record_columns!(),
6466 ", coalesce(t.transcript_text, '') as transcript_text \
6469 from sessions s \
6470 left join transcripts t on t.session_id = s.id \
6471 where (s.id like ?1 escape '\\' or s.provider_session_id like ?1 escape '\\')"
6472);
6473
6474const RESOLVE_SESSION_RECORD_EXACT_SQL: &str = concat!(
6475 "select ",
6476 session_record_columns!(),
6477 " from sessions s where (s.id = ?1 or s.provider_session_id = ?1)"
6478);
6479
6480const RESOLVE_SESSION_RECORD_PREFIX_SQL: &str = concat!(
6481 "select ",
6482 session_record_columns!(),
6483 " from sessions s where (s.id like ?1 escape '\\' or s.provider_session_id like ?1 escape '\\')"
6484);
6485
6486fn unique_session_match<T>(
6487 value: &str,
6488 mut matches: Vec<T>,
6489 id_of: impl Fn(&T) -> &str,
6490) -> Result<T> {
6491 if matches.len() > 1 {
6495 let is_exact =
6496 |id: &str| id == value || id.split_once(':').is_some_and(|(_, rest)| rest == value);
6497 let mut exact = matches
6498 .iter()
6499 .enumerate()
6500 .filter(|(_, m)| is_exact(id_of(m)));
6501 if let (Some((index, _)), None) = (exact.next(), exact.next()) {
6502 return Ok(matches.remove(index));
6503 }
6504 }
6505 match matches.len() {
6506 0 => Err(anyhow!(
6507 "no session matches '{value}' — run `aise list` to see recent session \
6508 ids, or `aise search <keywords>` to find one"
6509 )),
6510 1 => Ok(matches.remove(0)),
6511 _ => {
6512 let shown: Vec<&str> = matches.iter().take(8).map(id_of).collect();
6513 let more = matches.len().saturating_sub(shown.len());
6514 let suffix = if more > 0 {
6515 format!(" (+{more} more)")
6516 } else {
6517 String::new()
6518 };
6519 Err(anyhow!(
6520 "session prefix '{value}' is ambiguous — {} sessions match: {}{}. \
6521 Pass a longer prefix or the full id.",
6522 matches.len(),
6523 shown.join(", "),
6524 suffix
6525 ))
6526 }
6527 }
6528}
6529
6530fn row_to_session_with_transcript(
6531 row: &rusqlite::Row<'_>,
6532) -> rusqlite::Result<SessionWithTranscript> {
6533 Ok(SessionWithTranscript {
6534 session: row_to_session_record(row)?,
6535 transcript_text: row.get("transcript_text")?,
6540 })
6541}
6542
6543fn row_to_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
6544 let provider: String = row.get(1)?;
6545 Ok(SessionRecord {
6546 id: row.get(0)?,
6547 provider: decode_db_enum(provider, 1, "sessions.provider", Provider::from_db_str)?,
6548 provider_session_id: row.get(2)?,
6549 title: row.get(3)?,
6550 summary: row.get(4)?,
6551 cwd: row.get(5)?,
6552 repo_root: row.get(6)?,
6553 created_at: row
6554 .get::<_, Option<String>>(7)?
6555 .as_deref()
6556 .and_then(crate::util::parse_datetime),
6557 updated_at: row
6558 .get::<_, Option<String>>(8)?
6559 .as_deref()
6560 .and_then(crate::util::parse_datetime),
6561 last_message_at: row
6562 .get::<_, Option<String>>(9)?
6563 .as_deref()
6564 .and_then(crate::util::parse_datetime),
6565 preview_text: row.get(10)?,
6566 source_path: row.get(11)?,
6567 message_count: row.get(12)?,
6568 parse_version: row.get(13)?,
6569 raw_metadata_json: row.get(14)?,
6570 parse_warning: row.get(15)?,
6571 discovery_source: row.get(16)?,
6572 parent_session_id: row.get(17)?,
6573 agent_label: row.get(18)?,
6574 })
6575}
6576
6577#[cfg(test)]
6578mod tests {
6579 use super::*;
6580 use crate::models::SessionKind;
6581
6582 const TEST_BUSY_TIMEOUT_MS: u64 = 250;
6583 const TEST_NO_WAIT_BUSY_TIMEOUT_MS: u64 = 0;
6584
6585 #[test]
6586 fn message_row_rejects_corrupt_provider_and_role_with_recovery_context() {
6587 let connection = Connection::open_in_memory().unwrap();
6588 for (provider, role, bad_value, column, advice, forbidden) in [
6592 (
6593 "not-a-provider",
6594 "user",
6595 "not-a-provider",
6596 "messages.provider",
6597 "upgrade aise",
6598 "reindex --full",
6599 ),
6600 (
6601 "claude",
6602 "not-a-role",
6603 "not-a-role",
6604 "messages.role",
6605 "aise reindex --full",
6606 "upgrade aise",
6607 ),
6608 ] {
6609 let error = connection
6610 .query_row(
6611 "select 'session', ?1, 0, ?2, null, null, 'conversation', null, 'content'",
6612 params![provider, role],
6613 row_to_message_hit,
6614 )
6615 .unwrap_err();
6616 let error = error.to_string();
6617 assert!(error.contains(bad_value), "{error}");
6618 assert!(error.contains(column), "{error}");
6619 assert!(error.contains(advice), "{error}");
6620 assert!(!error.contains(forbidden), "{error}");
6621 }
6622 }
6623
6624 fn test_policies(
6631 categories: &[(&str, &[&str])],
6632 ) -> crate::corrections::ResolvedCorrectionPolicySet {
6633 let spec = crate::corrections::CorrectionPolicySpec {
6634 schema_version: crate::corrections::CORRECTION_POLICY_SCHEMA_VERSION,
6635 name: "test-policy".to_string(),
6636 version: "1.0.0".to_string(),
6637 categories: categories
6638 .iter()
6639 .map(
6640 |(name, patterns)| crate::corrections::CorrectionCategorySpec {
6641 name: (*name).to_string(),
6642 patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
6643 },
6644 )
6645 .collect(),
6646 };
6647 crate::corrections::ResolvedCorrectionPolicySet::from_policies(vec![spec
6648 .compile_in_memory(crate::corrections::CorrectionPolicySource::Embedded)
6650 .expect("test correction policy compiles")])
6651 }
6652
6653 fn default_policies() -> crate::corrections::ResolvedCorrectionPolicySet {
6655 crate::corrections::ResolvedCorrectionPolicySet::from_policies(vec![
6656 crate::corrections::embedded_policy().expect("the built-in capability compiles"),
6657 ])
6658 }
6659
6660 fn schema_fingerprint(conn: &Connection) -> Vec<(String, String, String, Option<String>)> {
6661 conn.prepare(
6662 "select type, name, tbl_name, sql
6663 from sqlite_schema
6664 where name not like 'sqlite_autoindex_%'
6665 order by type, name, tbl_name",
6666 )
6667 .unwrap()
6668 .query_map([], |row| {
6669 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
6670 })
6671 .unwrap()
6672 .collect::<rusqlite::Result<_>>()
6673 .unwrap()
6674 }
6675
6676 fn enable_v3_custom_trigram_compatibility(db: &Db) {
6677 db.conn
6678 .execute_batch(
6679 "drop trigger if exists messages_ai;
6680 drop trigger if exists messages_ad;
6681 drop trigger if exists messages_au;
6682 drop table if exists messages_trigram_terms;
6683 drop table if exists messages_trigram_vocab;
6684 drop table if exists messages_trigram;",
6685 )
6686 .unwrap();
6687 crate::fts::install_released_message_word_index(&db.conn).unwrap();
6688 crate::trigram_index::ensure_schema(&db.conn).unwrap();
6689 db.conn.pragma_update(None, "user_version", 3).unwrap();
6690 }
6691
6692 #[derive(Debug, Clone, Copy)]
6693 enum MessageContentMode {
6694 Literal,
6695 Regex,
6696 Fuzzy,
6697 }
6698
6699 impl MessageContentMode {
6700 fn search(
6701 self,
6702 db: &Db,
6703 pattern: &str,
6704 mut filters: MessageFilters,
6705 ) -> Result<Vec<MessageHit>> {
6706 filters.match_mode = match self {
6707 Self::Literal => MessageSearchMode::Literal,
6708 Self::Regex => MessageSearchMode::Regex,
6709 Self::Fuzzy => MessageSearchMode::Fuzzy,
6710 };
6711 db.search_messages(pattern, &filters)
6712 }
6713 }
6714
6715 const MESSAGE_CONTENT_MODE_CASES: [(MessageContentMode, &str); 3] = [
6716 (MessageContentMode::Literal, "shared needle"),
6717 (MessageContentMode::Regex, r"shared\s+needle"),
6718 (MessageContentMode::Fuzzy, "shared needle"),
6719 ];
6720
6721 fn hit_keys(hits: Vec<MessageHit>) -> Vec<(String, i64)> {
6722 hits.into_iter()
6723 .map(|hit| (hit.session_id, hit.seq))
6724 .collect()
6725 }
6726
6727 fn utc(value: &str) -> DateTime<Utc> {
6728 DateTime::parse_from_rfc3339(value)
6729 .unwrap()
6730 .with_timezone(&Utc)
6731 }
6732
6733 #[test]
6734 fn glob_clause_maps_basename_and_path() {
6735 assert_eq!(glob_clause("*.rs"), ("file_name", "%.rs".to_string()));
6737 assert_eq!(glob_clause("db.rs"), ("file_name", "db.rs".to_string()));
6738 assert_eq!(
6740 glob_clause("src/*.rs"),
6741 ("file_path", "%src/%.rs".to_string())
6742 );
6743 assert_eq!(glob_clause("a_b%c"), ("file_name", "a\\_b\\%c".to_string()));
6745 }
6746
6747 #[test]
6758 fn a_spawner_resolves_by_its_own_id_even_though_its_runs_extend_it() {
6759 let dir = tempfile::tempdir().unwrap();
6760 let db = Db::open(&dir.path().join("index.db")).unwrap();
6761 db.conn
6762 .execute_batch(
6763 "insert into sessions (
6764 id, provider, provider_session_id, updated_at, preview_text,
6765 source_path, parse_version, discovery_source, parent_session_id
6766 ) values
6767 ('claude:s6', 'claude', 's6', '2026-03-01T00:00:00+00:00', '', '/p', 'v1',
6768 'test', null),
6769 ('claude:s6/agent-a', 'claude', 's6/agent-a', '2026-03-02T00:00:00+00:00', '',
6770 '/a', 'v1', 'test', 'claude:s6'),
6771 ('claude:s6/agent-b', 'claude', 's6/agent-b', '2026-03-03T00:00:00+00:00', '',
6772 '/b', 'v1', 'test', 'claude:s6');",
6773 )
6774 .unwrap();
6775
6776 for spelling in ["s6", "claude:s6"] {
6777 assert_eq!(
6778 db.resolve_session_record(spelling).unwrap().id,
6779 "claude:s6",
6780 "the spawner's exact id must win over the runs that extend it: {spelling}"
6781 );
6782 }
6783 assert_eq!(
6785 db.resolve_session_record("s6/agent-a").unwrap().id,
6786 "claude:s6/agent-a"
6787 );
6788 let error = db
6790 .resolve_session_record("s6/agent-")
6791 .expect_err("a genuine prefix collision must still be refused")
6792 .to_string();
6793 assert!(error.contains("ambiguous"), "{error}");
6794 assert!(
6795 error.contains("claude:s6/agent-a") && error.contains("claude:s6/agent-b"),
6796 "the refusal must name the candidates: {error}"
6797 );
6798 }
6799
6800 #[test]
6806 fn session_date_window_intersects_known_span_and_falls_back_for_null_endpoints() {
6807 let dir = tempfile::tempdir().unwrap();
6808 let db = Db::open(&dir.path().join("index.db")).unwrap();
6809 db.conn
6810 .execute_batch(
6811 "insert into sessions (
6812 id, provider, provider_session_id, created_at, updated_at, preview_text,
6813 source_path, parse_version, discovery_source
6814 ) values
6815 ('claude:long-running', 'claude', 'long-running', '2026-01-10T00:00:00+00:00',
6816 '2026-03-10T00:00:00+00:00', '', '/long', 'v1', 'test'),
6817 ('claude:february', 'claude', 'february', '2026-02-05T00:00:00+00:00',
6818 '2026-02-06T00:00:00+00:00', '', '/february', 'v1', 'test'),
6819 ('claude:never-updated', 'claude', 'never-updated', '2026-03-11T00:00:00+00:00',
6820 null, '', '/never', 'v1', 'test'),
6821 ('claude:creation-missing', 'claude', 'creation-missing', null,
6822 '2026-01-20T00:00:00+00:00', '', '/creation-missing', 'v1', 'test');",
6823 )
6824 .unwrap();
6825
6826 let ids = |since: Option<&str>, until: Option<&str>| {
6827 let mut ids: Vec<String> = db
6828 .list_recent(&SearchFilters {
6829 since: since.and_then(crate::util::parse_datetime),
6830 until: until.and_then(crate::util::parse_datetime),
6831 ..SearchFilters::default()
6832 })
6833 .unwrap()
6834 .into_iter()
6835 .map(|session| session.id)
6836 .collect();
6837 ids.sort();
6838 ids
6839 };
6840
6841 assert_eq!(
6842 ids(
6843 Some("2026-01-01T00:00:00+00:00"),
6844 Some("2026-01-31T23:59:59+00:00")
6845 ),
6846 vec!["claude:creation-missing", "claude:long-running"],
6847 "January intersects the long-running span and both point fallbacks"
6848 );
6849 assert_eq!(
6850 ids(
6851 Some("2026-02-15T00:00:00+00:00"),
6852 Some("2026-02-15T00:00:00+00:00")
6853 ),
6854 vec!["claude:long-running"],
6855 "an exact instant inside the known span is included"
6856 );
6857 assert_eq!(
6858 ids(None, Some("2026-01-31T23:59:59+00:00")),
6859 vec!["claude:creation-missing", "claude:long-running"],
6860 "until compares the session start, not terminal activity"
6861 );
6862 assert_eq!(
6863 ids(Some("2026-03-01T00:00:00+00:00"), None),
6864 vec!["claude:long-running", "claude:never-updated"],
6865 "since compares the session end"
6866 );
6867 assert!(ids(Some("2026-03-12T00:00:00+00:00"), None).is_empty());
6868 }
6869
6870 #[test]
6874 fn session_kinds_select_user_started_runs_or_both() {
6875 let dir = tempfile::tempdir().unwrap();
6876 let db = Db::open(&dir.path().join("index.db")).unwrap();
6877 db.conn
6878 .execute_batch(
6879 "insert into sessions (
6880 id, provider, provider_session_id, updated_at, preview_text,
6881 source_path, parse_version, discovery_source, parent_session_id, agent_label
6882 ) values
6883 ('claude:parent', 'claude', 'parent', '2026-03-01T00:00:00+00:00', '',
6884 '/parent', 'v1', 'test', null, null),
6885 ('claude:parent/agent-a', 'claude', 'parent/agent-a', '2026-03-02T00:00:00+00:00',
6886 '', '/a', 'v1', 'test', 'claude:parent', 'Explore'),
6887 ('claude:other/agent-b', 'claude', 'other/agent-b', '2026-03-03T00:00:00+00:00',
6888 '', '/b', 'v1', 'test', 'claude:other', 'general-purpose');",
6889 )
6890 .unwrap();
6891
6892 let ids = |kinds: Option<Vec<SessionKind>>, parent: Option<&str>| {
6893 let mut ids: Vec<String> = db
6894 .list_recent(&SearchFilters {
6895 session_kinds: kinds,
6896 parent_session_id: parent.map(str::to_string),
6897 ..SearchFilters::default()
6898 })
6899 .unwrap()
6900 .into_iter()
6901 .map(|session| session.id)
6902 .collect();
6903 ids.sort();
6904 ids
6905 };
6906
6907 assert_eq!(
6908 ids(None, None),
6909 vec![
6910 "claude:other/agent-b",
6911 "claude:parent",
6912 "claude:parent/agent-a"
6913 ],
6914 "naming no class returns every class, so indexed work is never invisible"
6915 );
6916 assert_eq!(
6917 ids(Some(vec![SessionKind::User]), None),
6918 vec!["claude:parent"]
6919 );
6920 assert_eq!(
6921 ids(Some(vec![SessionKind::User, SessionKind::User]), None),
6922 vec!["claude:parent"],
6923 "repeating one set member must not make it equivalent to the complete set"
6924 );
6925 assert_eq!(
6926 ids(Some(vec![SessionKind::Subagent]), None),
6927 vec!["claude:other/agent-b", "claude:parent/agent-a"]
6928 );
6929 assert_eq!(
6930 ids(Some(vec![SessionKind::User, SessionKind::Subagent]), None),
6931 ids(None, None),
6932 "naming both classes is the default set spelled out"
6933 );
6934 assert!(
6935 ids(Some(Vec::new()), None).is_empty(),
6936 "deselecting every class matches nothing rather than silently matching all"
6937 );
6938
6939 assert_eq!(
6942 ids(None, Some("claude:parent")),
6943 vec!["claude:parent/agent-a"]
6944 );
6945 assert!(ids(None, Some("claude:parent/agent-a")).is_empty());
6946
6947 assert_eq!(
6953 ids(Some(vec![SessionKind::Subagent]), Some("claude:other")),
6954 vec!["claude:other/agent-b"],
6955 "a run whose parent is not indexed is still a session"
6956 );
6957 }
6958
6959 #[test]
6960 fn list_recent_is_sql_bounded_and_does_not_read_transcripts() {
6961 let dir = tempfile::tempdir().unwrap();
6962 let db = Db::open(&dir.path().join("index.db")).unwrap();
6963 db.conn
6964 .execute_batch(
6965 "insert into sessions (
6966 id, provider, provider_session_id, updated_at, preview_text,
6967 source_path, parse_version, discovery_source
6968 ) values
6969 ('claude:old', 'claude', 'old', '2026-01-01T00:00:00+00:00', '', '/old', 'v1', 'test'),
6970 ('claude:middle', 'claude', 'middle', '2026-02-01T00:00:00+00:00', '', '/middle', 'v1', 'test'),
6971 ('claude:new', 'claude', 'new', '2026-03-01T00:00:00+00:00', '', '/new', 'v1', 'test');
6972 drop table transcripts;",
6973 )
6974 .unwrap();
6975
6976 let mut filters = SearchFilters {
6977 limit: 2,
6978 ..SearchFilters::default()
6979 };
6980 let sessions = db.list_recent(&filters).unwrap();
6981
6982 assert_eq!(
6983 sessions
6984 .into_iter()
6985 .map(|session| session.id)
6986 .collect::<Vec<_>>(),
6987 ["claude:new", "claude:middle"]
6988 );
6989
6990 filters.limit = 0;
6991 assert_eq!(
6992 db.list_recent(&filters)
6993 .unwrap()
6994 .into_iter()
6995 .map(|session| session.id)
6996 .collect::<Vec<_>>(),
6997 ["claude:new", "claude:middle", "claude:old"]
6998 );
6999 }
7000
7001 #[test]
7002 fn analysis_documents_keyset_pages_normalized_user_text_and_empty_sessions() {
7003 let dir = tempfile::tempdir().unwrap();
7004 let db = Db::open(&dir.path().join("index.db")).unwrap();
7005 db.conn
7006 .execute_batch(
7007 "insert into sessions (
7008 id, provider, provider_session_id, preview_text,
7009 source_path, parse_version, discovery_source
7010 ) values
7011 ('claude:a', 'claude', 'a', '', '/a', 'v1', 'test'),
7012 ('claude:b', 'claude', 'b', '', '/b', 'v1', 'test'),
7013 ('codex:c', 'codex', 'c', '', '/c', 'v1', 'test');
7014 insert into messages (
7015 session_id, provider, seq, role, content, authorship, record_relation
7016 ) values
7017 ('claude:a', 'claude', 0, 'user', 'first request', 'human', 'original'),
7018 ('claude:a', 'claude', 1, 'assistant', 'answer', 'agent', 'original'),
7019 ('claude:a', 'claude', 2, 'user', 'second request', 'human', 'original'),
7020 ('codex:c', 'codex', 0, 'user', 'other provider', 'human', 'original');
7021 drop table transcripts;",
7022 )
7023 .unwrap();
7024 let filters = SearchFilters {
7025 provider: Some(Provider::Claude),
7026 limit: 1,
7027 ..SearchFilters::default()
7028 };
7029
7030 let first = db.analysis_documents(&filters, None).unwrap();
7031 assert_eq!(first.documents.len(), 1);
7032 assert_eq!(first.documents[0].session.id, "claude:a");
7033 assert_eq!(first.documents[0].message_count, 3);
7034 assert_eq!(first.documents[0].user_message_count, 2);
7035 assert_eq!(first.documents[0].user_text, "first request second request");
7036 assert_eq!(
7037 first.documents[0].first_user_text.as_deref(),
7038 Some("first request")
7039 );
7040
7041 let second = db
7042 .analysis_documents(&filters, first.next_cursor.as_ref())
7043 .unwrap();
7044 assert_eq!(second.documents.len(), 1);
7045 assert_eq!(second.documents[0].session.id, "claude:b");
7046 assert_eq!(second.documents[0].message_count, 0);
7047 assert_eq!(second.documents[0].user_message_count, 0);
7048 assert!(second.documents[0].user_text.is_empty());
7049 assert!(second.documents[0].first_user_text.is_none());
7050 assert!(second.next_cursor.is_none());
7051 }
7052
7053 #[test]
7054 fn analysis_selects_original_human_authorship_and_human_parts_of_mixed_messages() {
7055 let dir = tempfile::tempdir().unwrap();
7056 let db = Db::open(&dir.path().join("index.db")).unwrap();
7057 db.conn
7058 .execute_batch(
7059 "insert into sessions (
7060 id, provider, provider_session_id, preview_text,
7061 source_path, parse_version, discovery_source
7062 ) values ('claude:authorship', 'claude', 'authorship', '', '/authorship', 'v1', 'test');
7063 insert into messages (
7064 id, session_id, provider, seq, role, kind, content, authorship, record_relation
7065 ) values
7066 (101, 'claude:authorship', 'claude', 0, 'user', 'conversation',
7067 'human original', 'human', 'original'),
7068 (102, 'claude:authorship', 'claude', 1, 'user', 'conversation',
7069 'agent delegation', 'agent', 'original'),
7070 (103, 'claude:authorship', 'claude', 2, 'user', 'conversation',
7071 'human α\ngenerated β\nhuman γ', 'mixed', 'original'),
7072 (104, 'claude:authorship', 'claude', 3, 'user', 'conversation',
7073 'mirrored human', 'human', 'mirror'),
7074 (105, 'claude:authorship', 'claude', 4, 'user', 'conversation',
7075 'legacy uncertainty', 'unknown', 'unknown');
7076 insert into message_content_parts (
7077 message_id, ordinal, start_char, end_char, authorship, origin
7078 ) values
7079 (103, 0, 0, 8, 'human', 'direct_input'),
7080 (103, 1, 8, 20, 'generated', 'tool_payload'),
7081 (103, 2, 20, 27, 'human', 'direct_input');",
7082 )
7083 .unwrap();
7084
7085 let filters = SearchFilters {
7086 limit: 1,
7087 ..SearchFilters::default()
7088 };
7089 let page = db.analysis_documents(&filters, None).unwrap();
7090 let document = &page.documents[0];
7091 assert_eq!(document.message_count, 5);
7092 assert_eq!(document.user_message_count, 2);
7093 assert_eq!(
7094 document.user_text, "human original human α\nhuman γ",
7095 "analysis must exclude agent, generated, mirrored, and unknown-attribution text"
7096 );
7097 assert_eq!(document.first_user_text.as_deref(), Some("human original"));
7098
7099 let request = crate::models::AnalysisRequest::new(
7100 SearchFilters::default(),
7101 crate::models::AnalysisSessionSelection::AllEligible,
7102 )
7103 .unwrap();
7104 let mut chunks = Vec::new();
7105 let receipt = db
7106 .visit_analysis_sessions(
7107 &request,
7108 std::num::NonZeroUsize::new(1).unwrap(),
7109 |_session, _message_count, human_message_count, messages| {
7110 assert_eq!(human_message_count, 2);
7111 chunks.extend(messages.collect::<Result<Vec<_>>>()?);
7112 Ok(())
7113 },
7114 )
7115 .unwrap();
7116 assert_eq!(
7117 chunks,
7118 [
7119 (0, "human original".to_string()),
7120 (2, "human α\nhuman γ".to_string())
7121 ]
7122 );
7123 assert_eq!(receipt.analyzed_user_messages, 2);
7124 }
7125
7126 #[test]
7127 fn analysis_visit_keeps_one_snapshot_across_bounded_pages() {
7128 let dir = tempfile::tempdir().unwrap();
7129 let path = dir.path().join("index.db");
7130 let db = Db::open(&path).unwrap();
7131 let writer = Db::open(&path).unwrap();
7132 for (id, source) in [
7133 ("claude:a", "/fixture/a.jsonl"),
7134 ("claude:z", "/fixture/z.jsonl"),
7135 ] {
7136 let mut parsed =
7137 crate::util::minimal_record(Provider::Claude, Path::new(source), "test".into());
7138 parsed.session.id = id.into();
7139 parsed.session.provider_session_id = id.into();
7140 parsed.messages.push(crate::models::Message {
7141 seq: 0,
7142 role: crate::models::Role::User,
7143 ts: None,
7144 tool_name: None,
7145 kind: crate::models::MessageKind::Conversation,
7146 tool_call_id: None,
7147 is_compaction: false,
7148 content: format!("content for {id}"),
7149 provenance: crate::models::MessageProvenance::default(),
7150 });
7151 db.upsert_session(&parsed, 0, 0).unwrap();
7152 }
7153 let mut late = crate::util::minimal_record(
7154 Provider::Claude,
7155 Path::new("/fixture/m.jsonl"),
7156 "test".into(),
7157 );
7158 late.session.id = "claude:m".into();
7159 late.session.provider_session_id = "claude:m".into();
7160 let request = crate::models::AnalysisRequest::new(
7161 SearchFilters::default(),
7162 crate::models::AnalysisSessionSelection::AllEligible,
7163 )
7164 .unwrap();
7165 let mut seen = Vec::new();
7166 let receipt = db
7167 .visit_analysis_sessions(
7168 &request,
7169 std::num::NonZeroUsize::new(1).unwrap(),
7170 |session, _message_count, _user_message_count, _chunks| {
7171 seen.push(session.id);
7172 if seen.len() == 1 {
7173 writer.upsert_session(&late, 0, 0)?;
7174 }
7175 Ok(())
7176 },
7177 )
7178 .unwrap();
7179
7180 assert_eq!(receipt.selected_sessions, 2);
7181 assert!(!receipt.has_more);
7182 assert_eq!(seen, ["claude:a", "claude:z"]);
7183 assert_eq!(
7184 receipt.last_selected_session_id.as_deref(),
7185 Some("claude:z")
7186 );
7187 assert_eq!(
7188 db.resolve_session_record("claude:m").unwrap().id,
7189 "claude:m"
7190 );
7191 let original_population = crate::models::AnalysisRequest::new(
7192 SearchFilters {
7193 exclude_session_ids: vec!["claude:m".to_owned()],
7194 ..SearchFilters::default()
7195 },
7196 crate::models::AnalysisSessionSelection::AllEligible,
7197 )
7198 .unwrap();
7199 let consumed = db
7200 .visit_analysis_sessions(
7201 &original_population,
7202 std::num::NonZeroUsize::new(1).unwrap(),
7203 |_session, _message_count, _user_message_count, chunks| {
7204 for chunk in chunks {
7205 chunk?;
7206 }
7207 Ok(())
7208 },
7209 )
7210 .unwrap();
7211 assert_eq!(
7212 receipt.corpus_digest, consumed.corpus_digest,
7213 "the owner must hash unread human-authored message rows after a successful visitor"
7214 );
7215 let later = db
7216 .visit_analysis_sessions(
7217 &request,
7218 std::num::NonZeroUsize::new(1).unwrap(),
7219 |_session, _message_count, _user_message_count, _chunks| Ok(()),
7220 )
7221 .unwrap();
7222 assert_eq!(later.selected_sessions, 3);
7223 assert_ne!(
7224 receipt.corpus_digest, later.corpus_digest,
7225 "the earlier receipt must describe only its original read snapshot"
7226 );
7227 }
7228
7229 #[test]
7230 fn analysis_documents_rejects_unbounded_pages() {
7231 let dir = tempfile::tempdir().unwrap();
7232 let db = Db::open(&dir.path().join("index.db")).unwrap();
7233 let error = db
7234 .analysis_documents(
7237 &SearchFilters {
7238 limit: 0,
7239 ..SearchFilters::default()
7240 },
7241 None,
7242 )
7243 .unwrap_err();
7244 assert!(error.to_string().contains("greater than zero"));
7245 }
7246
7247 #[test]
7248 fn planning_usage_optionally_filters_by_command_patterns() {
7249 let dir = tempfile::tempdir().unwrap();
7250 let db = Db::open(&dir.path().join("index.db")).unwrap();
7251 db.conn
7252 .execute(
7253 "insert into sessions (id, provider, provider_session_id, preview_text, \
7254 source_path, parse_version, discovery_source) \
7255 values ('s1','claude','s1','','/p','1','test')",
7256 [],
7257 )
7258 .unwrap();
7259 db.conn
7260 .execute(
7261 "insert into sessions (id, provider, provider_session_id, preview_text, \
7262 source_path, parse_version, discovery_source) \
7263 values ('s2','codex','s2','','/p2','1','test')",
7264 [],
7265 )
7266 .unwrap();
7267 let slash = |id: i64, session_id: &str, provider: &str, seq: i64, content: &str| {
7268 db.conn
7269 .execute(
7270 "insert into messages (id, session_id, provider, seq, role, content) \
7271 values (?1,?2,?3,?4,'slash',?5)",
7272 params![id, session_id, provider, seq, content],
7273 )
7274 .unwrap();
7275 };
7276 slash(1, "s1", "claude", 0, "/cmd-a make a plan");
7277 slash(2, "s1", "claude", 1, "/cmd-b");
7278 slash(3, "s1", "claude", 2, "/cmd-a refine it");
7279 slash(4, "s2", "codex", 0, "/cmd-c ship the fix");
7280
7281 let all = db.planning_usage(&MessageFilters::default(), &[]).unwrap();
7283 assert_eq!(all.len(), 3, "all distinct commands counted");
7284
7285 let only = db
7287 .planning_usage(
7288 &MessageFilters::default(),
7289 &[regex::Regex::new(r"^/cmd-a$").unwrap()],
7290 )
7291 .unwrap();
7292 assert_eq!(only.len(), 1);
7293 assert_eq!(only[0].command, "/cmd-a");
7294 assert_eq!(only[0].count, 2);
7295
7296 let command_token_only = db
7297 .planning_usage(
7298 &MessageFilters::default(),
7299 &[regex::Regex::new(r"^/cmd-b$").unwrap()],
7300 )
7301 .unwrap();
7302 assert_eq!(
7303 command_token_only
7304 .iter()
7305 .map(|row| row.command.as_str())
7306 .collect::<Vec<_>>(),
7307 vec!["/cmd-b"],
7308 "planning filters command tokens, not arbitrary slash-message body text"
7309 );
7310
7311 let codex = db
7313 .planning_usage(
7314 &MessageFilters {
7315 providers: Some(vec![Provider::Codex]),
7316 ..Default::default()
7317 },
7318 &[],
7319 )
7320 .unwrap();
7321 assert_eq!(codex.len(), 1);
7322 assert_eq!(codex[0].command, "/cmd-c");
7323 }
7324
7325 #[test]
7326 fn search_messages_uses_exact_literal_substring_semantics() {
7327 let dir = tempfile::tempdir().unwrap();
7328 let db = Db::open(&dir.path().join("index.db")).unwrap();
7329 db.conn
7330 .execute(
7331 "insert into sessions (id, provider, provider_session_id, preview_text, \
7332 source_path, parse_version, discovery_source) \
7333 values ('s1','claude','s1','','/p','1','test')",
7334 [],
7335 )
7336 .unwrap();
7337 let insert = |id: i64, seq: i64, content: &str| {
7338 db.conn
7339 .execute(
7340 "insert into messages (id, session_id, provider, seq, role, content) \
7341 values (?1,'s1','claude',?2,'user',?3)",
7342 params![id, seq, content],
7343 )
7344 .unwrap();
7345 };
7346 insert(1, 0, "please handle the error");
7347 insert(2, 1, "the handler crashed");
7348 insert(3, 2, "we mishandled the input");
7349 insert(4, 3, "error handling code here");
7350 insert(5, 4, "use a => b arrow");
7351 insert(6, 5, "literal /goal command");
7352 insert(7, 6, "plain goal token");
7353 insert(8, 7, "compile C++ today");
7354 insert(9, 8, "flag --path passed");
7355 insert(10, 9, "CAFÉ diagnostic");
7356 insert(11, 10, "İstanbul diagnostic");
7357
7358 let seqs = |query: &str| -> Vec<i64> {
7359 let mut v: Vec<i64> = db
7360 .search_messages(query, &MessageFilters::default())
7361 .unwrap()
7362 .into_iter()
7363 .map(|h| h.seq)
7364 .collect();
7365 v.sort();
7366 v
7367 };
7368 assert_eq!(seqs("handle"), vec![0, 1, 2]);
7369 assert_eq!(seqs("handled"), vec![2]);
7370 assert_eq!(seqs("error handling"), vec![3]);
7372 assert_eq!(seqs("=>"), vec![4]);
7373 assert_eq!(seqs("/goal"), vec![5]);
7374 assert_eq!(seqs("goal"), vec![5, 6]);
7375 assert_eq!(seqs("C++"), vec![7]);
7376 assert_eq!(seqs("--path"), vec![8]);
7377 assert_eq!(seqs("café"), vec![9]);
7378 assert_eq!(seqs("İS"), vec![10]);
7379 assert_eq!(
7380 seqs("İST"),
7381 vec![10],
7382 "the trigram candidate gate must remain a superset of Unicode lowercase matching"
7383 );
7384 assert_eq!(seqs("").len(), 11);
7386 let re = db
7388 .search_messages(
7389 "h.ndler",
7390 &MessageFilters {
7391 match_mode: MessageSearchMode::Regex,
7392 ..Default::default()
7393 },
7394 )
7395 .unwrap();
7396 assert_eq!(re.into_iter().map(|h| h.seq).collect::<Vec<_>>(), vec![1]);
7397 }
7398
7399 #[test]
7400 fn fuzzy_message_search_ranks_approximate_matches_without_changing_literal_search() {
7401 let dir = tempfile::tempdir().unwrap();
7402 let db = Db::open(&dir.path().join("index.db")).unwrap();
7403 db.conn
7404 .execute(
7405 "insert into sessions (id, provider, provider_session_id, preview_text, \
7406 source_path, parse_version, discovery_source) \
7407 values ('s1','claude','s1','','/p','1','test')",
7408 [],
7409 )
7410 .unwrap();
7411 for (seq, content) in [
7412 (
7413 0,
7414 "please avoid magic values and keep settings configurable",
7415 ),
7416 (1, "hard-coded timeout should move into config"),
7417 (2, "unrelated transcript text"),
7418 (3, "magic numbers should move into named values"),
7419 ] {
7420 db.conn
7421 .execute(
7422 "insert into messages (session_id, provider, seq, role, content) \
7423 values ('s1','claude',?1,'user',?2)",
7424 params![seq, content],
7425 )
7426 .unwrap();
7427 }
7428
7429 let literal = db
7430 .search_messages("magic config", &MessageFilters::default())
7431 .unwrap();
7432 assert!(literal.is_empty(), "literal search remains exact");
7433
7434 let fuzzy = db
7435 .search_messages(
7436 "magic config",
7437 &MessageFilters {
7438 match_mode: MessageSearchMode::Fuzzy,
7439 limit: 10,
7440 ..Default::default()
7441 },
7442 )
7443 .unwrap();
7444 assert_eq!(fuzzy.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
7445 assert!(fuzzy[0].fuzzy_score.is_some());
7446
7447 let fuzzy_phrase = db
7448 .search_messages(
7449 "magic values",
7450 &MessageFilters {
7451 match_mode: MessageSearchMode::Fuzzy,
7452 limit: 10,
7453 ..Default::default()
7454 },
7455 )
7456 .unwrap();
7457 assert_eq!(fuzzy_phrase[0].seq, 0, "exact phrase wins fuzzy ties");
7458 }
7459
7460 #[test]
7461 fn schema_v4_fuzzy_search_filters_structurally_before_exhaustive_scoring() {
7462 let dir = tempfile::tempdir().unwrap();
7463 let path = dir.path().join("index.db");
7464 {
7465 let db = Db::open(&path).unwrap();
7466 for (id, provider, seq, role, content) in [
7467 (
7468 "s1",
7469 "claude",
7470 0,
7471 "user",
7472 "incremental trigram acceleration",
7473 ),
7474 ("s1", "claude", 1, "assistant", "unrelated response"),
7475 ("s2", "codex", 0, "user", "incremental index design"),
7476 ] {
7477 db.conn
7478 .execute(
7479 "insert or ignore into sessions
7480 (id, provider, provider_session_id, preview_text, source_path,
7481 parse_version, discovery_source)
7482 values (?1, ?2, ?1, '', '/p', '1', 'test')",
7483 params![id, provider],
7484 )
7485 .unwrap();
7486 db.conn
7487 .execute(
7488 "insert into messages
7489 (session_id, provider, seq, role, content)
7490 values (?1, ?2, ?3, ?4, ?5)",
7491 params![id, provider, seq, role, content],
7492 )
7493 .unwrap();
7494 }
7495 }
7496
7497 let db = Db::open(&path).unwrap();
7498 let (hits, explain) = db
7499 .search_messages_with_explain(
7500 "incrmental trigram",
7501 &MessageFilters {
7502 providers: Some(vec![Provider::Claude]),
7503 role: Some(Role::User),
7504 match_mode: MessageSearchMode::Fuzzy,
7505 limit: 10,
7506 ..Default::default()
7507 },
7508 true,
7509 )
7510 .unwrap();
7511 assert_eq!(hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
7512 assert!(hits[0].fuzzy_score.is_some());
7513 let explain = explain.unwrap();
7514 assert_eq!(explain.prefilter.as_deref(), None);
7515 assert_eq!(
7516 explain.prefilter_skipped.as_deref(),
7517 Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
7518 );
7519 assert_eq!(explain.corpus, 1);
7520 }
7521
7522 #[test]
7523 fn exhaustive_fuzzy_ranking_keeps_a_best_hit_beyond_the_old_candidate_cap() {
7524 let dir = tempfile::tempdir().unwrap();
7525 let db = Db::open(&dir.path().join("index.db")).unwrap();
7526 db.conn
7527 .execute(
7528 "insert into sessions (id, provider, provider_session_id, preview_text,
7529 source_path, parse_version, discovery_source)
7530 values ('s1','claude','s1','','/p','1','test')",
7531 [],
7532 )
7533 .unwrap();
7534 let tx = db.conn.unchecked_transaction().unwrap();
7535 {
7536 let mut insert = tx
7537 .prepare(
7538 "insert into messages(session_id, provider, seq, role, content)
7539 values ('s1','claude',?1,'user',?2)",
7540 )
7541 .unwrap();
7542 for seq in 0..1_300_i64 {
7543 insert
7544 .execute(params![
7545 seq,
7546 format!("magic values configurable filler {seq}")
7547 ])
7548 .unwrap();
7549 }
7550 insert.execute(params![1_300_i64, "magic config"]).unwrap();
7551 }
7552 tx.commit().unwrap();
7553
7554 let hits = db
7555 .search_messages(
7556 "magic config",
7557 &MessageFilters {
7558 match_mode: MessageSearchMode::Fuzzy,
7559 limit: 1,
7560 ..Default::default()
7561 },
7562 )
7563 .unwrap();
7564
7565 assert_eq!(hits.len(), 1);
7566 assert_eq!(hits[0].seq, 1_300);
7567
7568 let first_two = db
7569 .search_messages(
7570 "magic config",
7571 &MessageFilters {
7572 match_mode: MessageSearchMode::Fuzzy,
7573 limit: 2,
7574 ..Default::default()
7575 },
7576 )
7577 .unwrap();
7578 let second = db
7579 .search_messages(
7580 "magic config",
7581 &MessageFilters {
7582 match_mode: MessageSearchMode::Fuzzy,
7583 limit: 1,
7584 offset: 1,
7585 ..Default::default()
7586 },
7587 )
7588 .unwrap();
7589 assert_eq!(second.len(), 1);
7590 assert_eq!(second[0].session_id, first_two[1].session_id);
7591 assert_eq!(second[0].seq, first_two[1].seq);
7592 assert_eq!(second[0].fuzzy_score, first_two[1].fuzzy_score);
7593 }
7594
7595 #[test]
7596 fn search_messages_path_prefix_scopes_by_session_root_and_metadata_enriches() {
7597 let dir = tempfile::tempdir().unwrap();
7598 let db = Db::open(&dir.path().join("index.db")).unwrap();
7599 let session = |id: &str, cwd: &str, repo: &str, title: &str| {
7600 db.conn
7601 .execute(
7602 "insert into sessions (id, provider, provider_session_id, title, cwd, \
7603 repo_root, preview_text, source_path, parse_version, discovery_source) \
7604 values (?1,'claude',?1,?2,?3,?4,'','/p','1','test')",
7605 params![id, title, cwd, repo],
7606 )
7607 .unwrap();
7608 };
7609 session("a", "/Users/x/proj-a", "/Users/x/proj-a", "Proj A");
7610 session("b", "/Users/x/proj-b", "/Users/x/proj-b", "Proj B");
7611 let msg = |id: i64, sid: &str| {
7612 db.conn
7613 .execute(
7614 "insert into messages (id, session_id, provider, seq, role, content) \
7615 values (?1,?2,'claude',0,'user','shared keyword here')",
7616 params![id, sid],
7617 )
7618 .unwrap();
7619 };
7620 msg(1, "a");
7621 msg(2, "b");
7622
7623 let scoped = db
7626 .search_messages(
7627 "keyword",
7628 &MessageFilters {
7629 path_prefix: Some("/Users/x/proj-a".into()),
7630 ..Default::default()
7631 },
7632 )
7633 .unwrap();
7634 assert_eq!(
7635 scoped
7636 .iter()
7637 .map(|h| h.session_id.clone())
7638 .collect::<Vec<_>>(),
7639 vec!["a"],
7640 "only the proj-a session matches the path prefix"
7641 );
7642
7643 assert_eq!(
7645 db.search_messages("keyword", &MessageFilters::default())
7646 .unwrap()
7647 .len(),
7648 2
7649 );
7650
7651 let meta = db
7653 .session_metadata(&["a".to_string(), "b".to_string()])
7654 .unwrap();
7655 assert_eq!(meta["a"].cwd.as_deref(), Some("/Users/x/proj-a"));
7656 assert_eq!(meta["a"].repo_root.as_deref(), Some("/Users/x/proj-a"));
7657 assert_eq!(meta["a"].title.as_deref(), Some("Proj A"));
7658 assert_eq!(meta["b"].title.as_deref(), Some("Proj B"));
7659 }
7660
7661 #[test]
7662 fn session_metadata_batches_use_one_bind_and_indexed_sql_above_parameter_limits() {
7663 let dir = tempfile::tempdir().unwrap();
7664 let db = Db::open(&dir.path().join("index.db")).unwrap();
7665 let ids = (0..1_200)
7666 .map(|index| format!("codex:metadata-{index}"))
7667 .collect::<Vec<_>>();
7668 {
7669 let transaction = db.conn.unchecked_transaction().unwrap();
7670 {
7671 let mut insert = transaction
7672 .prepare(
7673 "insert into sessions (
7674 id, provider, provider_session_id, title, preview_text, source_path,
7675 parse_version, discovery_source, raw_metadata_json
7676 ) values (?1, 'codex', ?2, ?2, '', ?3, '1', 'test', ?4)",
7677 )
7678 .unwrap();
7679 for (index, id) in ids.iter().enumerate() {
7680 let provider_session_id = format!("metadata-{index}");
7681 insert
7682 .execute(params![
7683 id,
7684 provider_session_id,
7685 format!("/transcripts/{provider_session_id}.jsonl"),
7686 format!(r#"{{"index":{index}}}"#),
7687 ])
7688 .unwrap();
7689 }
7690 }
7691 transaction.commit().unwrap();
7692 }
7693
7694 let metadata = db.session_metadata(&ids).unwrap();
7695 let raw_metadata = db.session_raw_provider_metadata(&ids).unwrap();
7696 assert_eq!(metadata.len(), ids.len());
7697 assert_eq!(raw_metadata.len(), ids.len());
7698 assert_eq!(
7699 metadata[&ids[1_199]].title.as_deref(),
7700 Some("metadata-1199")
7701 );
7702
7703 let plan = {
7704 let mut statement = db
7705 .conn
7706 .prepare(
7707 "explain query plan
7708 select s.id
7709 from json_each(?) requested
7710 join sessions s on s.id = requested.value",
7711 )
7712 .unwrap();
7713 statement
7714 .query_map([serde_json::to_string(&ids).unwrap()], |row| {
7715 row.get::<_, String>(3)
7716 })
7717 .unwrap()
7718 .collect::<rusqlite::Result<Vec<_>>>()
7719 .unwrap()
7720 .join(" | ")
7721 };
7722 assert!(
7723 plan.contains("sqlite_autoindex_sessions_1"),
7724 "session metadata join must probe indexed sessions.id: {plan}"
7725 );
7726 }
7727
7728 #[test]
7729 fn search_messages_excludes_paths_and_sessions_before_limit() {
7730 let dir = tempfile::tempdir().unwrap();
7731 let db = Db::open(&dir.path().join("index.db")).unwrap();
7732 let session = |id: &str, cwd: &str, repo: &str, source_path: &str| {
7733 db.conn
7734 .execute(
7735 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7736 preview_text, source_path, parse_version, discovery_source) \
7737 values (?1,'claude',?1,?2,?3,'',?4,'1','test')",
7738 params![id, cwd, repo, source_path],
7739 )
7740 .unwrap();
7741 };
7742 session("a", "/Users/x/proj-a", "/Users/x", "/logs/a.jsonl");
7743 session("b", "/Users/x/proj-b", "/Users/x", "/logs/b.jsonl");
7744 session("c", "/Users/x/proj-c", "/Users/x", "/tmp/noisy/c.jsonl");
7745 for (id, seq) in [("a", 0), ("b", 0), ("c", 0)] {
7746 db.conn
7747 .execute(
7748 "insert into messages (session_id, provider, seq, role, content) \
7749 values (?1,'claude',?2,'user','shared needle')",
7750 params![id, seq],
7751 )
7752 .unwrap();
7753 }
7754
7755 for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7756 let hits = mode
7757 .search(
7758 &db,
7759 pattern,
7760 MessageFilters {
7761 path_prefix: Some("/Users/x".into()),
7762 exclude_path_prefixes: vec!["/Users/x/proj-a".into(), "/tmp/noisy".into()],
7763 limit: 1,
7764 ..Default::default()
7765 },
7766 )
7767 .unwrap();
7768 assert_eq!(
7769 hits.iter()
7770 .map(|hit| hit.session_id.as_str())
7771 .collect::<Vec<_>>(),
7772 vec!["b"],
7773 "{mode:?}: exclusions apply before limit, including source_path exclusions"
7774 );
7775 }
7776
7777 for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7778 let hits = mode
7779 .search(
7780 &db,
7781 pattern,
7782 MessageFilters {
7783 exclude_session_ids: vec!["b".into()],
7784 limit: 10,
7785 ..Default::default()
7786 },
7787 )
7788 .unwrap();
7789 assert_eq!(
7790 hits.iter()
7791 .map(|hit| hit.session_id.as_str())
7792 .collect::<Vec<_>>(),
7793 vec!["a", "c"],
7794 "{mode:?}: session exclusions apply before content matching"
7795 );
7796 }
7797 }
7798
7799 #[test]
7800 fn search_messages_content_modes_share_structural_filters() {
7801 let dir = tempfile::tempdir().unwrap();
7802 let db = Db::open(&dir.path().join("index.db")).unwrap();
7803 let session = |id: &str, provider: Provider, cwd: &str, repo: &str, source_path: &str| {
7804 db.conn
7805 .execute(
7806 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7807 preview_text, source_path, parse_version, discovery_source) \
7808 values (?1,?2,?1,?3,?4,'',?5,'1','test')",
7809 params![id, provider.as_str(), cwd, repo, source_path],
7810 )
7811 .unwrap();
7812 };
7813 session(
7814 "target",
7815 Provider::Claude,
7816 "/Users/x/proj-a",
7817 "/Users/x/proj-a",
7818 "/logs/target.jsonl",
7819 );
7820 session(
7821 "wrong-provider",
7822 Provider::Codex,
7823 "/Users/x/proj-a",
7824 "/Users/x/proj-a",
7825 "/logs/wrong-provider.jsonl",
7826 );
7827 session(
7828 "wrong-path",
7829 Provider::Claude,
7830 "/Users/x/proj-b",
7831 "/Users/x/proj-b",
7832 "/logs/wrong-path.jsonl",
7833 );
7834 let msg =
7835 |id: i64, session_id: &str, provider: Provider, seq: i64, role: Role, ts: &str| {
7836 db.conn
7837 .execute(
7838 "insert into messages (id, session_id, provider, seq, role, ts, content) \
7839 values (?1,?2,?3,?4,?5,?6,'shared needle phrase')",
7840 params![id, session_id, provider.as_str(), seq, role.as_str(), ts],
7841 )
7842 .unwrap();
7843 };
7844 msg(
7845 1,
7846 "target",
7847 Provider::Claude,
7848 10,
7849 Role::User,
7850 "2026-01-02T00:00:00Z",
7851 );
7852 msg(
7853 2,
7854 "target",
7855 Provider::Claude,
7856 11,
7857 Role::Assistant,
7858 "2026-01-02T00:00:00Z",
7859 );
7860 msg(
7861 3,
7862 "wrong-provider",
7863 Provider::Codex,
7864 10,
7865 Role::User,
7866 "2026-01-02T00:00:00Z",
7867 );
7868 msg(
7869 4,
7870 "wrong-path",
7871 Provider::Claude,
7872 10,
7873 Role::User,
7874 "2026-01-02T00:00:00Z",
7875 );
7876 msg(
7877 5,
7878 "target",
7879 Provider::Claude,
7880 9,
7881 Role::User,
7882 "2025-12-31T00:00:00Z",
7883 );
7884
7885 let filters = MessageFilters {
7886 role: Some(Role::User),
7887 providers: Some(vec![Provider::Claude]),
7888 path_prefix: Some("/Users/x/proj-a".into()),
7889 since: Some(utc("2026-01-01T00:00:00Z")),
7890 until: Some(utc("2026-01-31T00:00:00Z")),
7891 seq_from: Some(10),
7892 seq_to: Some(10),
7893 limit: 10,
7894 ..Default::default()
7895 };
7896
7897 for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7898 assert_eq!(
7899 hit_keys(mode.search(&db, pattern, filters.clone()).unwrap()),
7900 vec![("target".to_string(), 10)],
7901 "{mode:?}: content mode must compose with role/provider/path/time/seq filters"
7902 );
7903 }
7904 }
7905
7906 #[test]
7907 fn read_session_messages_selects_by_order_within_range_and_offset() {
7908 let dir = tempfile::tempdir().unwrap();
7909 let db = Db::open(&dir.path().join("index.db")).unwrap();
7910 db.conn
7911 .execute(
7912 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7913 preview_text, source_path, parse_version, discovery_source) \
7914 values ('claude:s','claude','s','/p','/p','','/p','1','test')",
7915 [],
7916 )
7917 .unwrap();
7918 let msg = |seq: i64, role: &str, content: &str| {
7919 db.conn
7920 .execute(
7921 "insert into messages (session_id, provider, seq, role, content) \
7922 values ('claude:s','claude',?1,?2,?3)",
7923 params![seq, role, content],
7924 )
7925 .unwrap();
7926 };
7927 msg(0, "user", "first user turn");
7929 msg(1, "assistant", "assistant reply");
7930 msg(2, "user", "second user turn");
7931 msg(3, "slash", "/cmd make a plan");
7932
7933 let seqs = |hits: Vec<MessageHit>| hits.iter().map(|h| h.seq).collect::<Vec<i64>>();
7934 let filters = |mutate: &dyn Fn(&mut MessageFilters)| {
7935 let mut f = MessageFilters {
7936 session_id: Some("claude:s".to_string()),
7937 ..Default::default()
7938 };
7939 mutate(&mut f);
7940 f
7941 };
7942
7943 assert_eq!(
7945 seqs(
7946 db.read_session_messages(&filters(&|f| f.limit = 2), MessageOrder::OldestFirst)
7947 .unwrap()
7948 ),
7949 vec![0, 1]
7950 );
7951 assert_eq!(
7954 seqs(
7955 db.read_session_messages(&filters(&|f| f.limit = 2), MessageOrder::NewestFirst)
7956 .unwrap()
7957 ),
7958 vec![2, 3]
7959 );
7960 assert_eq!(
7962 seqs(
7963 db.read_session_messages(&filters(&|f| f.limit = 0), MessageOrder::NewestFirst)
7964 .unwrap()
7965 ),
7966 vec![0, 1, 2, 3]
7967 );
7968 assert_eq!(
7970 seqs(
7971 db.read_session_messages(
7972 &filters(&|f| {
7973 f.role = Some(Role::User);
7974 f.limit = 1;
7975 }),
7976 MessageOrder::NewestFirst
7977 )
7978 .unwrap()
7979 ),
7980 vec![2]
7981 );
7982 assert_eq!(
7984 seqs(
7985 db.read_session_messages(
7986 &filters(&|f| {
7987 f.seq_from = Some(1);
7988 f.seq_to = Some(2);
7989 }),
7990 MessageOrder::OldestFirst
7991 )
7992 .unwrap()
7993 ),
7994 vec![1, 2]
7995 );
7996 assert_eq!(
7998 seqs(
7999 db.read_session_messages(
8000 &filters(&|f| {
8001 f.offset = 1;
8002 f.limit = 2;
8003 }),
8004 MessageOrder::OldestFirst
8005 )
8006 .unwrap()
8007 ),
8008 vec![1, 2]
8009 );
8010 }
8011
8012 #[test]
8013 fn read_session_messages_requires_a_session_id() {
8014 let dir = tempfile::tempdir().unwrap();
8015 let db = Db::open(&dir.path().join("index.db")).unwrap();
8016 assert!(db
8018 .read_session_messages(&MessageFilters::default(), MessageOrder::NewestFirst)
8019 .is_err());
8020 }
8021
8022 #[test]
8023 fn read_session_messages_edge_cases_from_the_premortem_catalog() {
8024 let dir = tempfile::tempdir().unwrap();
8027 let db = Db::open(&dir.path().join("index.db")).unwrap();
8028 db.conn
8029 .execute(
8030 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8031 preview_text, source_path, parse_version, discovery_source) \
8032 values ('claude:s','claude','s','/p','/p','','/p','1','test')",
8033 [],
8034 )
8035 .unwrap();
8036 db.conn
8038 .execute(
8039 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8040 preview_text, source_path, parse_version, discovery_source) \
8041 values ('claude:one','claude','one','/p','/p','','/p','1','test')",
8042 [],
8043 )
8044 .unwrap();
8045 db.conn
8046 .execute(
8047 "insert into messages (session_id, provider, seq, role, content) \
8048 values ('claude:one','claude',0,'user','only turn')",
8049 [],
8050 )
8051 .unwrap();
8052 for seq in 0..6 {
8053 db.conn
8054 .execute(
8055 "insert into messages (session_id, provider, seq, role, content) \
8056 values ('claude:s','claude',?1,'user',?2)",
8057 params![seq, format!("turn {seq}")],
8058 )
8059 .unwrap();
8060 }
8061 let seqs = |hits: Vec<MessageHit>| hits.iter().map(|h| h.seq).collect::<Vec<i64>>();
8062 let f = |mutate: &dyn Fn(&mut MessageFilters)| {
8063 let mut m = MessageFilters {
8064 session_id: Some("claude:s".to_string()),
8065 ..Default::default()
8066 };
8067 mutate(&mut m);
8068 m
8069 };
8070
8071 assert_eq!(
8073 seqs(
8074 db.read_session_messages(
8075 &f(&|m| {
8076 m.seq_from = Some(1);
8077 m.seq_to = Some(3);
8078 }),
8079 MessageOrder::OldestFirst
8080 )
8081 .unwrap()
8082 ),
8083 vec![1, 2, 3]
8084 );
8085 assert_eq!(
8087 seqs(
8088 db.read_session_messages(
8089 &f(&|m| {
8090 m.seq_from = Some(1);
8091 m.seq_to = Some(4);
8092 m.limit = 2;
8093 }),
8094 MessageOrder::NewestFirst
8095 )
8096 .unwrap()
8097 ),
8098 vec![3, 4]
8099 );
8100 assert_eq!(
8102 seqs(
8103 db.read_session_messages(
8104 &f(&|m| {
8105 m.seq_from = Some(1);
8106 m.seq_to = Some(4);
8107 m.limit = 2;
8108 }),
8109 MessageOrder::OldestFirst
8110 )
8111 .unwrap()
8112 ),
8113 vec![1, 2]
8114 );
8115 assert_eq!(
8118 seqs(
8119 db.read_session_messages(
8120 &f(&|m| {
8121 m.offset = 1;
8122 m.limit = 2;
8123 }),
8124 MessageOrder::NewestFirst
8125 )
8126 .unwrap()
8127 ),
8128 vec![3, 4]
8129 );
8130 assert_eq!(
8132 seqs(
8133 db.read_session_messages(&f(&|m| m.offset = 99), MessageOrder::OldestFirst)
8134 .unwrap()
8135 ),
8136 Vec::<i64>::new()
8137 );
8138 assert_eq!(
8140 seqs(
8141 db.read_session_messages(
8142 &f(&|m| {
8143 m.seq_from = Some(100);
8144 m.seq_to = Some(200);
8145 }),
8146 MessageOrder::OldestFirst
8147 )
8148 .unwrap()
8149 ),
8150 Vec::<i64>::new()
8151 );
8152 assert!(db
8155 .read_session_messages(
8156 &f(&|m| {
8157 m.seq_from = Some(4);
8158 m.seq_to = Some(1);
8159 }),
8160 MessageOrder::OldestFirst
8161 )
8162 .is_err());
8163 assert_eq!(
8165 seqs(
8166 db.read_session_messages(
8167 &f(&|m| m.role = Some(Role::Assistant)),
8168 MessageOrder::NewestFirst
8169 )
8170 .unwrap()
8171 ),
8172 Vec::<i64>::new()
8173 );
8174 let one = |order| {
8176 db.read_session_messages(
8177 &MessageFilters {
8178 session_id: Some("claude:one".to_string()),
8179 ..Default::default()
8180 },
8181 order,
8182 )
8183 .unwrap()
8184 };
8185 assert_eq!(seqs(one(MessageOrder::OldestFirst)), vec![0]);
8186 assert_eq!(seqs(one(MessageOrder::NewestFirst)), vec![0]);
8187 }
8188
8189 #[test]
8190 fn path_prefix_matches_directory_boundary_and_escapes_like_metacharacters() {
8191 let dir = tempfile::tempdir().unwrap();
8192 let db = Db::open(&dir.path().join("index.db")).unwrap();
8193 let session = |id: &str, cwd: &str| {
8194 db.conn
8195 .execute(
8196 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8197 preview_text, source_path, parse_version, discovery_source) \
8198 values (?1,'claude',?1,?2,?2,'','/p','1','test')",
8199 params![id, cwd],
8200 )
8201 .unwrap();
8202 };
8203 session("root", "/tmp/proj");
8204 session("child", "/tmp/proj/sub");
8205 session("sibling", "/tmp/project2");
8206 session("under", "/tmp/proj_under");
8207 session("percent", "/tmp/proj%literal");
8208 let msg = |id: i64, sid: &str| {
8209 db.conn
8210 .execute(
8211 "insert into messages (id, session_id, provider, seq, role, content) \
8212 values (?1,?2,'claude',0,'user','needle')",
8213 params![id, sid],
8214 )
8215 .unwrap();
8216 };
8217 msg(1, "root");
8218 msg(2, "child");
8219 msg(3, "sibling");
8220 msg(4, "under");
8221 msg(5, "percent");
8222
8223 let ids = |prefix: &str| -> Vec<String> {
8224 let mut ids: Vec<String> = db
8225 .search_messages(
8226 "needle",
8227 &MessageFilters {
8228 path_prefix: Some(prefix.into()),
8229 ..Default::default()
8230 },
8231 )
8232 .unwrap()
8233 .into_iter()
8234 .map(|h| h.session_id)
8235 .collect();
8236 ids.sort();
8237 ids
8238 };
8239
8240 assert_eq!(ids("/tmp/proj"), vec!["child", "root"]);
8241 assert_eq!(ids("/tmp/proj%literal"), vec!["percent"]);
8242
8243 session("windows-root", r"C:\Users\x\proj");
8244 session("windows-child", r"C:\Users\x\proj\sub");
8245 session("windows-sibling", r"C:\Users\x\project2");
8246 msg(6, "windows-root");
8247 msg(7, "windows-child");
8248 msg(8, "windows-sibling");
8249 assert_eq!(
8250 ids(r"C:\Users\x\proj"),
8251 vec!["windows-child", "windows-root"]
8252 );
8253 }
8254
8255 #[test]
8256 fn list_recent_returns_newest_sessions_in_exact_directory_or_descendants() {
8257 let dir = tempfile::tempdir().unwrap();
8258 let db = Db::open(&dir.path().join("index.db")).unwrap();
8259 db.conn
8260 .execute_batch(
8261 "insert into sessions (
8262 id, provider, provider_session_id, cwd, updated_at, preview_text,
8263 source_path, parse_version, discovery_source
8264 ) values
8265 ('claude:root-old', 'claude', 'root-old', '/work/project',
8266 '2026-01-01T00:00:00+00:00', '', '/root-old', 'v1', 'test'),
8267 ('claude:child-new', 'claude', 'child-new', '/work/project/sub/dir',
8268 '2026-03-01T00:00:00+00:00', '', '/child-new', 'v1', 'test'),
8269 ('claude:sibling-newer', 'claude', 'sibling-newer', '/work/project-other',
8270 '2026-04-01T00:00:00+00:00', '', '/sibling-newer', 'v1', 'test');",
8271 )
8272 .unwrap();
8273
8274 let query = |limit| SearchFilters {
8275 path_prefix: Some("/work/project".to_string()),
8276 limit,
8277 ..SearchFilters::default()
8278 };
8279 assert_eq!(
8280 db.list_recent(&query(1))
8281 .unwrap()
8282 .into_iter()
8283 .map(|session| session.id)
8284 .collect::<Vec<_>>(),
8285 ["claude:child-new"],
8286 "limit applies after exact-directory/descendant filtering and newest-first ordering"
8287 );
8288 assert_eq!(
8289 db.list_recent(&query(0))
8290 .unwrap()
8291 .into_iter()
8292 .map(|session| session.id)
8293 .collect::<Vec<_>>(),
8294 ["claude:child-new", "claude:root-old"]
8295 );
8296 }
8297
8298 #[test]
8299 fn restricted_fuzzy_search_scores_only_authorized_messages() {
8300 use crate::config::{SearchScopeConfig, SearchScopeMode};
8301 use crate::search_scope::{EffectiveAccessScope, TrustedAccessInputs};
8302
8303 let dir = tempfile::tempdir().unwrap();
8304 let allowed = dir.path().join("allowed");
8305 let hidden = dir.path().join("hidden");
8306 fs::create_dir_all(&allowed).unwrap();
8307 fs::create_dir_all(&hidden).unwrap();
8308 let mut db = Db::open(&dir.path().join("index.db")).unwrap();
8309 db.mark_schema_current().unwrap();
8310 let transaction = db.conn.unchecked_transaction().unwrap();
8311 {
8312 let mut insert_session = transaction
8313 .prepare(
8314 "insert into sessions (
8315 id, provider, provider_session_id, cwd, repo_root, preview_text,
8316 source_path, parse_version, discovery_source
8317 ) values (?, 'claude', ?, ?, ?, '', '/fixture', '1', 'test')",
8318 )
8319 .unwrap();
8320 let mut insert_message = transaction
8321 .prepare(
8322 "insert into messages (
8323 id, session_id, provider, seq, role, kind, content
8324 ) values (?, ?, 'claude', 0, 'user', 'conversation', 'needle')",
8325 )
8326 .unwrap();
8327 for index in 0..1_201_i64 {
8328 let id = format!("hidden-{index:04}");
8329 let workspace = hidden.to_string_lossy();
8330 insert_session
8331 .execute(params![id, id, workspace.as_ref(), workspace.as_ref()])
8332 .unwrap();
8333 insert_message.execute(params![index + 1, id]).unwrap();
8334 }
8335 let allowed_id = "allowed";
8336 let workspace = allowed.to_string_lossy();
8337 insert_session
8338 .execute(params![
8339 allowed_id,
8340 allowed_id,
8341 workspace.as_ref(),
8342 workspace.as_ref()
8343 ])
8344 .unwrap();
8345 insert_message
8346 .execute(params![1_202_i64, allowed_id])
8347 .unwrap();
8348 }
8349 transaction.commit().unwrap();
8350
8351 db.set_access_scope(
8352 EffectiveAccessScope::resolve(
8353 &SearchScopeConfig {
8354 mode: SearchScopeMode::AllowedRoots,
8355 roots: vec![allowed.to_string_lossy().into_owned()],
8356 include_invocation_directory: false,
8357 },
8358 TrustedAccessInputs::default(),
8359 )
8360 .unwrap(),
8361 );
8362 let hits = db
8363 .search_messages(
8364 "needle",
8365 &MessageFilters {
8366 match_mode: MessageSearchMode::Fuzzy,
8367 limit: 1,
8368 ..Default::default()
8369 },
8370 )
8371 .unwrap();
8372 assert_eq!(hits.len(), 1);
8373 assert_eq!(hits[0].session_id, "allowed");
8374 }
8375
8376 #[test]
8377 fn exact_session_filter_does_not_merge_substring_matches() {
8378 let dir = tempfile::tempdir().unwrap();
8379 let db = Db::open(&dir.path().join("index.db")).unwrap();
8380 for id in ["abc", "xabcx"] {
8381 db.conn
8382 .execute(
8383 "insert into sessions (id, provider, provider_session_id, preview_text, \
8384 source_path, parse_version, discovery_source) \
8385 values (?1,'claude',?1,'','/p','1','test')",
8386 params![id],
8387 )
8388 .unwrap();
8389 }
8390 db.conn
8391 .execute(
8392 "insert into messages (id, session_id, provider, seq, role, content) \
8393 values (1,'abc','claude',0,'user','same')",
8394 [],
8395 )
8396 .unwrap();
8397 db.conn
8398 .execute(
8399 "insert into messages (id, session_id, provider, seq, role, content) \
8400 values (2,'xabcx','claude',0,'user','same')",
8401 [],
8402 )
8403 .unwrap();
8404
8405 let exact = db
8406 .search_messages(
8407 "",
8408 &MessageFilters {
8409 session_id: Some("abc".into()),
8410 ..Default::default()
8411 },
8412 )
8413 .unwrap();
8414 assert_eq!(exact.len(), 1);
8415 assert_eq!(exact[0].session_id, "abc");
8416 }
8417
8418 #[test]
8419 fn open_with_busy_timeout_sets_sqlite_busy_timeout() {
8420 let dir = tempfile::tempdir().unwrap();
8421 let db =
8422 Db::open_with_busy_timeout(&dir.path().join("index.db"), TEST_BUSY_TIMEOUT_MS).unwrap();
8423 assert_eq!(db.busy_timeout_ms().unwrap(), TEST_BUSY_TIMEOUT_MS);
8424 }
8425
8426 #[test]
8427 fn scoped_busy_timeout_restores_previous_value() {
8428 let dir = tempfile::tempdir().unwrap();
8429 let db =
8430 Db::open_with_busy_timeout(&dir.path().join("index.db"), TEST_BUSY_TIMEOUT_MS).unwrap();
8431 let observed = db
8432 .with_busy_timeout_ms(TEST_NO_WAIT_BUSY_TIMEOUT_MS, || db.busy_timeout_ms())
8433 .unwrap();
8434 assert_eq!(observed, TEST_NO_WAIT_BUSY_TIMEOUT_MS);
8435 assert_eq!(db.busy_timeout_ms().unwrap(), TEST_BUSY_TIMEOUT_MS);
8436 }
8437
8438 #[test]
8439 fn sqlite_busy_error_detection_matches_locked_writer() {
8440 let dir = tempfile::tempdir().unwrap();
8441 let path = dir.path().join("index.db");
8442 let writer = Db::open(&path).unwrap();
8443 let contender = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
8444
8445 writer.conn.execute_batch("begin immediate").unwrap();
8446 let err = contender
8447 .conn
8448 .execute(
8449 "insert into sessions (id, provider, provider_session_id, preview_text, \
8450 source_path, parse_version, discovery_source) \
8451 values ('busy','claude','busy','','/p','1','test')",
8452 [],
8453 )
8454 .unwrap_err();
8455 let err = anyhow::Error::from(err);
8456 assert!(Db::is_sqlite_busy_error(&err));
8457 writer.conn.execute_batch("rollback").unwrap();
8458 }
8459
8460 #[test]
8461 fn auto_reindex_completion_timestamp_controls_shared_freshness_window() {
8462 const COMPLETED_MS: i64 = 20_000;
8463 const INTERVAL_MS: u64 = 1_000;
8464
8465 let dir = tempfile::tempdir().unwrap();
8466 let path = dir.path().join("index.db");
8467 let first = Db::open(&path).unwrap();
8468 let second = Db::open(&path).unwrap();
8469
8470 assert!(!second
8471 .auto_reindex_is_fresh_at(COMPLETED_MS, INTERVAL_MS)
8472 .unwrap());
8473 let completed_at = Utc.timestamp_millis_opt(COMPLETED_MS).single().unwrap()
8474 + chrono::Duration::nanoseconds(456_789);
8475 first.mark_auto_reindex_complete_at(completed_at).unwrap();
8476 assert_eq!(
8477 second.auto_reindex_completed_at().unwrap(),
8478 Some(completed_at)
8479 );
8480 assert!(second
8481 .auto_reindex_is_fresh_at(COMPLETED_MS + 999, INTERVAL_MS)
8482 .unwrap());
8483 assert!(!second
8484 .auto_reindex_is_fresh_at(COMPLETED_MS + 1_000, INTERVAL_MS)
8485 .unwrap());
8486 }
8487
8488 #[test]
8489 fn auto_reindex_freshness_uses_only_metadata_primary_keys() {
8490 let dir = tempfile::tempdir().unwrap();
8491 let db = Db::open(&dir.path().join("index.db")).unwrap();
8492 db.mark_auto_reindex_complete().unwrap();
8493 let plan = db
8494 .conn
8495 .prepare(&format!("explain query plan {AUTO_REINDEX_FRESHNESS_SQL}"))
8496 .unwrap()
8497 .query_map(
8498 params![
8499 AUTO_REINDEX_COMPLETED_MS_KEY,
8500 AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
8501 AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
8502 AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
8503 AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
8504 ],
8505 |row| row.get::<_, String>(3),
8506 )
8507 .unwrap()
8508 .collect::<rusqlite::Result<Vec<_>>>()
8509 .unwrap()
8510 .join("\n");
8511
8512 assert!(!plan.contains("SCAN"), "{plan}");
8513 assert_eq!(
8514 plan.matches("sqlite_autoindex_index_metadata_1").count(),
8515 5,
8516 "{plan}"
8517 );
8518 }
8519
8520 #[test]
8521 fn message_search_filters_by_session_local_seq_range() {
8522 let dir = tempfile::tempdir().unwrap();
8523 let db = Db::open(&dir.path().join("index.db")).unwrap();
8524 for id in ["claude:s1", "claude:s2"] {
8525 db.conn
8526 .execute(
8527 "insert into sessions (id, provider, provider_session_id, preview_text, \
8528 source_path, parse_version, discovery_source) \
8529 values (?1,'claude',?1,'','/p','1','test')",
8530 params![id],
8531 )
8532 .unwrap();
8533 for seq in 0..5 {
8534 db.conn
8535 .execute(
8536 "insert into messages (session_id, provider, seq, role, content) \
8537 values (?1,'claude',?2,'user',?3)",
8538 params![id, seq, format!("needle {id} {seq}")],
8539 )
8540 .unwrap();
8541 }
8542 }
8543
8544 let bounded = db
8545 .search_messages(
8546 "needle",
8547 &MessageFilters {
8548 session_id: Some("claude:s1".into()),
8549 seq_from: Some(1),
8550 seq_to: Some(3),
8551 ..Default::default()
8552 },
8553 )
8554 .unwrap();
8555 assert_eq!(
8556 bounded.iter().map(|h| h.seq).collect::<Vec<_>>(),
8557 vec![1, 2, 3]
8558 );
8559 assert!(bounded.iter().all(|h| h.session_id == "claude:s1"));
8560
8561 let open_ended = db
8562 .search_messages(
8563 "",
8564 &MessageFilters {
8565 session_id: Some("claude:s2".into()),
8566 seq_from: Some(3),
8567 ..Default::default()
8568 },
8569 )
8570 .unwrap();
8571 assert_eq!(
8572 open_ended.iter().map(|h| h.seq).collect::<Vec<_>>(),
8573 vec![3, 4]
8574 );
8575 }
8576
8577 #[test]
8578 fn find_corrections_honors_path_prefix() {
8579 let dir = tempfile::tempdir().unwrap();
8582 let db = Db::open(&dir.path().join("index.db")).unwrap();
8583 let session = |id: &str, cwd: &str| {
8584 db.conn
8585 .execute(
8586 "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8587 preview_text, source_path, parse_version, discovery_source) \
8588 values (?1,'claude',?1,?2,?2,'','/p','1','test')",
8589 params![id, cwd],
8590 )
8591 .unwrap();
8592 };
8593 session("a", "/Users/x/proj-a");
8594 session("b", "/Users/x/proj-b");
8595 let user_msg = |id: i64, sid: &str| {
8596 db.conn
8597 .execute(
8598 "insert into messages (
8599 id, session_id, provider, seq, role, content,
8600 authorship, record_relation
8601 ) values (
8602 ?1,?2,'claude',0,'user','that is wrong, please revert',
8603 'human','original'
8604 )",
8605 params![id, sid],
8606 )
8607 .unwrap();
8608 };
8609 user_msg(1, "a");
8610 user_msg(2, "b");
8611
8612 let policies = test_policies(&[("misc", &["wrong"])]);
8613 let scoped = db
8614 .find_corrections(
8615 &policies,
8616 &MessageFilters {
8617 path_prefix: Some("/Users/x/proj-a".into()),
8618 ..Default::default()
8619 },
8620 )
8621 .unwrap();
8622 assert_eq!(
8623 scoped
8624 .iter()
8625 .map(|c| c.session_id.clone())
8626 .collect::<Vec<_>>(),
8627 vec!["a"],
8628 "path_prefix must scope corrections to the matching session"
8629 );
8630 assert_eq!(
8632 db.find_corrections(&policies, &MessageFilters::default())
8633 .unwrap()
8634 .len(),
8635 2
8636 );
8637 }
8638
8639 #[test]
8640 fn analytics_queries_honor_exact_session_id() {
8641 let dir = tempfile::tempdir().unwrap();
8642 let db = Db::open(&dir.path().join("index.db")).unwrap();
8643 let session = |id: &str| {
8644 db.conn
8645 .execute(
8646 "insert into sessions (id, provider, provider_session_id, preview_text, \
8647 source_path, parse_version, discovery_source) \
8648 values (?1,'claude',?1,'','/p','1','test')",
8649 params![id],
8650 )
8651 .unwrap();
8652 };
8653 session("s1");
8654 session("s10");
8655 let message = |id: i64, sid: &str, seq: i64, role: &str, content: &str| {
8656 db.conn
8657 .execute(
8658 "insert into messages (
8659 id, session_id, provider, seq, role, content,
8660 authorship, record_relation
8661 ) values (?1,?2,'claude',?3,?4,?5,'human','original')",
8662 params![id, sid, seq, role, content],
8663 )
8664 .unwrap();
8665 };
8666 message(1, "s1", 0, "user", "that is wrong");
8667 message(2, "s1", 1, "slash", "/cmd-a");
8668 message(3, "s10", 0, "user", "that is wrong");
8669 message(4, "s10", 1, "slash", "/cmd-b");
8670
8671 let filters = MessageFilters {
8672 session_id: Some("s1".into()),
8673 ..Default::default()
8674 };
8675 let policies = test_policies(&[("misc", &["wrong"])]);
8676 assert_eq!(
8677 db.find_corrections(&policies, &filters)
8678 .unwrap()
8679 .iter()
8680 .map(|row| row.session_id.as_str())
8681 .collect::<Vec<_>>(),
8682 vec!["s1"],
8683 "exact session_id must not match s10"
8684 );
8685 assert_eq!(
8686 db.message_role_counts(&filters).unwrap(),
8687 vec![("slash".to_string(), 1), ("user".to_string(), 1)]
8688 );
8689 let planning = db.planning_usage(&filters, &[]).unwrap();
8690 assert_eq!(planning.len(), 1);
8691 assert_eq!(planning[0].command, "/cmd-a");
8692 }
8693
8694 #[test]
8695 fn search_messages_filters_by_tool_name_and_surfaces_it() {
8696 let dir = tempfile::tempdir().unwrap();
8697 let db = Db::open(&dir.path().join("index.db")).unwrap();
8698 db.conn
8699 .execute(
8700 "insert into sessions (id, provider, provider_session_id, preview_text, \
8701 source_path, parse_version, discovery_source) \
8702 values ('s1','claude','s1','','/p','1','test')",
8703 [],
8704 )
8705 .unwrap();
8706 let insert = |id: i64, seq: i64, role: &str, tool: Option<&str>, content: &str| {
8707 db.conn
8708 .execute(
8709 "insert into messages (id, session_id, provider, seq, role, tool_name, content) \
8710 values (?1,'s1','claude',?2,?3,?4,?5)",
8711 params![id, seq, role, tool, content],
8712 )
8713 .unwrap();
8714 };
8715 insert(1, 0, "user", None, "run the build");
8716 insert(2, 1, "tool", Some("Bash"), "build ok");
8717 insert(3, 2, "tool", Some("Edit"), "edited the file");
8718 insert(4, 3, "tool", Some("ÄTool"), "unicode tool");
8719
8720 let tools = db
8722 .search_messages(
8723 "",
8724 &MessageFilters {
8725 role: Some(Role::Tool),
8726 ..Default::default()
8727 },
8728 )
8729 .unwrap();
8730 let bash = tools
8731 .iter()
8732 .find(|h| h.seq == 1)
8733 .expect("Bash tool message");
8734 assert_eq!(bash.tool_name.as_deref(), Some("Bash"));
8735
8736 let only = db
8738 .search_messages(
8739 "",
8740 &MessageFilters {
8741 tool: Some("bash".into()),
8742 ..Default::default()
8743 },
8744 )
8745 .unwrap();
8746 assert_eq!(only.len(), 1);
8747 assert_eq!(only[0].seq, 1);
8748 let unicode = db
8749 .search_messages(
8750 "ät",
8751 &MessageFilters {
8752 field: Some(SearchField::ToolName),
8753 limit: 1,
8754 ..Default::default()
8755 },
8756 )
8757 .unwrap();
8758 assert_eq!(unicode.len(), 1);
8759 assert_eq!(unicode[0].tool_name.as_deref(), Some("ÄTool"));
8760 db.conn
8761 .execute_batch(
8762 "with recursive n(value) as (
8763 values(1) union all select value + 1 from n where value < 100
8764 )
8765 insert into messages (
8766 session_id, provider, seq, role, tool_name, content
8767 )
8768 select 's1', 'claude', 100 + value, 'tool',
8769 printf('other_tool_%03d', value), 'unrelated'
8770 from n;",
8771 )
8772 .unwrap();
8773 let (fuzzy, explain) = db
8774 .search_messages_with_explain(
8775 "edt",
8776 &MessageFilters {
8777 field: Some(SearchField::ToolName),
8778 match_mode: MessageSearchMode::Fuzzy,
8779 limit: 5,
8780 ..Default::default()
8781 },
8782 true,
8783 )
8784 .unwrap();
8785 assert_eq!(fuzzy.len(), 1);
8786 assert_eq!(fuzzy[0].tool_name.as_deref(), Some("Edit"));
8787 let explain = explain.unwrap();
8788 assert_eq!(explain.prefilter, None);
8789 assert_eq!(explain.candidates, Some(1));
8790 assert_eq!(
8791 explain.prefilter_skipped.as_deref(),
8792 Some("complete filtered tool-name vocabulary scored before matching rows are loaded")
8793 );
8794 assert!(explain.candidates.unwrap() < explain.corpus);
8795 let vocabulary_plan = db
8796 .conn
8797 .prepare(
8798 "explain query plan select tool_name from messages
8799 where tool_name is not null order by tool_name",
8800 )
8801 .unwrap()
8802 .query_map([], |row| row.get::<_, String>(3))
8803 .unwrap()
8804 .collect::<rusqlite::Result<Vec<_>>>()
8805 .unwrap()
8806 .join("\n");
8807 assert!(
8808 vocabulary_plan.contains("idx_messages_tool_name"),
8809 "{vocabulary_plan}"
8810 );
8811 let none = db
8812 .search_messages(
8813 "",
8814 &MessageFilters {
8815 tool: Some("zzz".into()),
8816 ..Default::default()
8817 },
8818 )
8819 .unwrap();
8820 assert!(
8821 none.is_empty(),
8822 "unknown tool matches nothing (incl. NULL-tool rows)"
8823 );
8824 }
8825
8826 #[test]
8827 #[ignore = "deterministic release-mode tool-name vocabulary performance measurement"]
8828 fn fuzzy_tool_name_vocabulary_benchmark() {
8829 let dir = tempfile::tempdir().unwrap();
8830 let db = Db::open(&dir.path().join("index.db")).unwrap();
8831 db.conn
8832 .execute_batch(
8833 "insert into sessions (
8834 id, provider, provider_session_id, preview_text, source_path,
8835 parse_version, discovery_source
8836 ) values ('s1','claude','s1','','/p','1','test')",
8837 )
8838 .unwrap();
8839 let tx = db.conn.unchecked_transaction().unwrap();
8840 {
8841 let mut insert = tx
8842 .prepare(
8843 "insert into messages (
8844 session_id, provider, seq, role, tool_name, content
8845 ) values ('s1', 'claude', ?1, 'tool', ?2, ?3)",
8846 )
8847 .unwrap();
8848 for seq in 0..250_000_i64 {
8849 let name = if seq % 25_000 == 0 {
8850 "exec_command".to_string()
8851 } else {
8852 format!("unrelated_tool_{:03}", seq % 250)
8853 };
8854 insert
8855 .execute(params![seq, name, "x".repeat(2_048)])
8856 .unwrap();
8857 }
8858 }
8859 tx.commit().unwrap();
8860 let filters = MessageFilters {
8861 field: Some(SearchField::ToolName),
8862 match_mode: MessageSearchMode::Fuzzy,
8863 limit: 20,
8864 ..Default::default()
8865 };
8866 let measure = |optimized: bool| {
8867 let search = || {
8868 if optimized {
8869 db.search_messages("exec", &filters).unwrap()
8870 } else {
8871 db.search_tool_name_fuzzy_direct_for_test("exec", &filters)
8872 .unwrap()
8873 }
8874 };
8875 let expected = serde_json::to_vec(&search()).unwrap();
8876 let digest = crate::hashing::sha256(&expected);
8877 let mut elapsed = Vec::new();
8878 for _ in 0..7 {
8879 let started = Instant::now();
8880 assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
8881 elapsed.push(started.elapsed().as_micros());
8882 }
8883 elapsed.sort_unstable();
8884 (
8885 elapsed[elapsed.len() / 2],
8886 elapsed[elapsed.len() - 1],
8887 digest,
8888 )
8889 };
8890 let direct = measure(false);
8891 let optimized = measure(true);
8892 assert_eq!(direct.2, optimized.2);
8893 eprintln!(
8894 "tool_name_vocabulary_benchmark rows=250000 vocabulary=251 direct_median_us={} direct_p95_us={} optimized_median_us={} optimized_p95_us={} digest=sha256:{}",
8895 direct.0, direct.1, optimized.0, optimized.1, direct.2
8896 );
8897 }
8898
8899 #[test]
8900 fn global_tool_name_vocabulary_matches_distinct_index_scan() {
8901 let dir = tempfile::tempdir().unwrap();
8902 let db = Db::open(&dir.path().join("index.db")).unwrap();
8903 db.conn
8904 .execute_batch(
8905 "insert into sessions (
8906 id, provider, provider_session_id, preview_text, source_path,
8907 parse_version, discovery_source
8908 ) values ('s1','claude','s1','','/p','1','test');
8909 insert into messages (
8910 session_id, provider, seq, role, tool_name, content
8911 ) values
8912 ('s1', 'claude', 0, 'tool', 'ÄTool', ''),
8913 ('s1', 'claude', 1, 'tool', 'exec_command', ''),
8914 ('s1', 'claude', 2, 'tool', 'exec_command', ''),
8915 ('s1', 'claude', 3, 'user', null, '');",
8916 )
8917 .unwrap();
8918 let expected = db
8919 .conn
8920 .prepare(
8921 "select distinct tool_name from messages
8922 where tool_name is not null order by tool_name",
8923 )
8924 .unwrap()
8925 .query_map([], |row| row.get::<_, String>(0))
8926 .unwrap()
8927 .collect::<rusqlite::Result<Vec<_>>>()
8928 .unwrap();
8929 assert_eq!(db.global_tool_name_vocabulary().unwrap(), expected);
8930 }
8931
8932 #[test]
8933 fn fuzzy_tool_name_vocabulary_path_matches_direct_row_scoring() {
8934 let dir = tempfile::tempdir().unwrap();
8935 let db = Db::open(&dir.path().join("index.db")).unwrap();
8936 db.conn
8937 .execute_batch(
8938 "insert into sessions (
8939 id, provider, provider_session_id, preview_text, source_path,
8940 parse_version, discovery_source
8941 ) values
8942 ('s1','claude','s1','','/p','1','test'),
8943 ('s2','codex','s2','','/q','1','test');
8944 insert into messages (
8945 session_id, provider, seq, role, tool_name, content
8946 ) values
8947 ('s1', 'claude', 0, 'tool', 'exec_command', 'large ignored payload a'),
8948 ('s1', 'claude', 1, 'tool', 'Edit', 'large ignored payload b'),
8949 ('s1', 'claude', 2, 'tool', 'mcp:memory:execute', 'large ignored payload c'),
8950 ('s1', 'claude', 3, 'tool', 'ExecCommand', 'equal-score spelling payload'),
8951 ('s2', 'codex', 0, 'tool', 'exec_command', 'large ignored payload d'),
8952 ('s2', 'codex', 1, 'tool', 'write_stdin', 'large ignored payload e');",
8953 )
8954 .unwrap();
8955 let filters = MessageFilters {
8956 field: Some(SearchField::ToolName),
8957 match_mode: MessageSearchMode::Fuzzy,
8958 providers: Some(vec![Provider::Claude]),
8959 limit: 2,
8960 offset: 1,
8961 ..Default::default()
8962 };
8963 let optimized = db.search_messages("exec", &filters).unwrap();
8964 let direct = db
8965 .search_tool_name_fuzzy_direct_for_test("exec", &filters)
8966 .unwrap();
8967 assert_eq!(
8968 optimized
8969 .iter()
8970 .map(|hit| (&hit.session_id, hit.seq, hit.fuzzy_score))
8971 .collect::<Vec<_>>(),
8972 direct
8973 .iter()
8974 .map(|hit| (&hit.session_id, hit.seq, hit.fuzzy_score))
8975 .collect::<Vec<_>>()
8976 );
8977 }
8978
8979 #[test]
8980 fn fuzzy_tool_name_searches_the_complete_filtered_vocabulary() {
8981 let dir = tempfile::tempdir().unwrap();
8982 let db = Db::open(&dir.path().join("index.db")).unwrap();
8983 db.conn
8984 .execute_batch(
8985 "insert into sessions (
8986 id, provider, provider_session_id, preview_text, source_path,
8987 parse_version, discovery_source
8988 ) values ('s1','claude','s1','','/p','1','test')",
8989 )
8990 .unwrap();
8991 let tx = db.conn.unchecked_transaction().unwrap();
8992 {
8993 let mut insert = tx
8994 .prepare(
8995 "insert into messages (
8996 session_id, provider, seq, role, tool_name, content
8997 ) values ('s1', 'claude', ?1, 'tool', ?2, '')",
8998 )
8999 .unwrap();
9000 for seq in 0..=10_000_usize {
9001 insert
9002 .execute(params![seq as i64, format!("tool_{seq:05}")])
9003 .unwrap();
9004 }
9005 }
9006 tx.commit().unwrap();
9007
9008 db.conn
9009 .execute(
9010 "insert into messages (
9011 session_id, provider, seq, role, tool_name, content
9012 ) values ('s1', 'claude', ?1, 'tool', 'tol', '')",
9013 params![10_001_i64],
9014 )
9015 .unwrap();
9016
9017 let (hits, explain) = db
9018 .search_messages_with_explain(
9019 "tol",
9020 &MessageFilters {
9021 field: Some(SearchField::ToolName),
9022 match_mode: MessageSearchMode::Fuzzy,
9023 limit: 1,
9024 ..Default::default()
9025 },
9026 true,
9027 )
9028 .unwrap();
9029
9030 assert_eq!(hits.len(), 1);
9031 assert_eq!(hits[0].tool_name.as_deref(), Some("tol"));
9032 let explain = explain.unwrap();
9033 assert_eq!(explain.corpus, 10_002);
9034 assert_eq!(explain.candidates, Some(10_002));
9035 }
9036
9037 #[test]
9043 fn tool_name_literal_receipt_reports_the_named_tool_corpus_independent_of_the_page() {
9044 let dir = tempfile::tempdir().unwrap();
9045 let db = Db::open(&dir.path().join("index.db")).unwrap();
9046 db.conn
9047 .execute_batch(
9048 "insert into sessions (
9049 id, provider, provider_session_id, preview_text, source_path,
9050 parse_version, discovery_source
9051 ) values ('s1','claude','s1','','/p','1','test');
9052 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
9053 values ('s1', 'claude', 0, 'tool', 'tool_call', 'exec_command', ''),
9054 ('s1', 'claude', 1, 'tool', 'tool_call', 'exec_command', ''),
9055 ('s1', 'claude', 2, 'tool', 'tool_call', 'apply_patch', ''),
9056 ('s1', 'claude', 3, 'user', 'conversation', null, 'no tool');",
9057 )
9058 .unwrap();
9059 for limit in [1, 2, 10] {
9060 let (hits, explain) = db
9061 .search_messages_with_explain(
9062 "exec",
9063 &MessageFilters {
9064 field: Some(SearchField::ToolName),
9065 limit,
9066 ..Default::default()
9067 },
9068 true,
9069 )
9070 .unwrap();
9071 assert_eq!(hits.len(), 2.min(limit));
9072 let explain = explain.unwrap();
9073 assert_eq!(
9074 explain.corpus, 3,
9075 "limit {limit}: the denominator is every named-tool row the filters admit"
9076 );
9077 assert_eq!(explain.candidates, None, "nothing was prefiltered");
9078 assert!(explain.prefilter_skipped.is_some());
9079 }
9080 }
9081
9082 #[test]
9085 fn fuzzy_batches_score_at_the_row_cap_or_the_byte_cap_whichever_comes_first() {
9086 assert!(fuzzy_batch_is_full(FUZZY_SCORE_BATCH_SIZE, 0));
9087 assert!(fuzzy_batch_is_full(3, FUZZY_BATCH_BYTES));
9088 assert!(fuzzy_batch_is_full(3, FUZZY_BATCH_BYTES + 1));
9089 assert!(!fuzzy_batch_is_full(
9090 FUZZY_SCORE_BATCH_SIZE - 1,
9091 FUZZY_BATCH_BYTES - 1
9092 ));
9093 assert!(!fuzzy_batch_is_full(1, 1));
9094 }
9095
9096 #[test]
9102 fn fuzzy_tool_name_offset_beyond_the_corpus_returns_empty_without_reserving_the_offset() {
9103 let dir = tempfile::tempdir().unwrap();
9104 let db = Db::open(&dir.path().join("index.db")).unwrap();
9105 db.conn
9106 .execute_batch(
9107 "insert into sessions (
9108 id, provider, provider_session_id, preview_text, source_path,
9109 parse_version, discovery_source
9110 ) values ('s1','claude','s1','','/p','1','test');
9111 insert into messages (session_id, provider, seq, role, tool_name, content)
9112 values ('s1', 'claude', 0, 'tool', 'exec_command', '');",
9113 )
9114 .unwrap();
9115 for offset in [100_000_000_000_000_000_usize, 1_000_000_000] {
9116 let hits = db
9117 .search_messages(
9118 "exec",
9119 &MessageFilters {
9120 field: Some(SearchField::ToolName),
9121 match_mode: MessageSearchMode::Fuzzy,
9122 limit: 1,
9123 offset,
9124 ..Default::default()
9125 },
9126 )
9127 .unwrap();
9128 assert!(hits.is_empty(), "offset {offset} lies past the only row");
9129 }
9130 }
9131
9132 #[test]
9141 fn a_fuzzy_window_past_the_corpus_is_answered_without_scoring_any_row() {
9142 let dir = tempfile::tempdir().unwrap();
9143 let db = Db::open(&dir.path().join("index.db")).unwrap();
9144 db.conn
9145 .execute_batch(
9146 "insert into sessions (
9147 id, provider, provider_session_id, preview_text, source_path,
9148 parse_version, discovery_source
9149 ) values ('s1','claude','s1','','/p','1','test');",
9150 )
9151 .unwrap();
9152 for seq in 0..4 {
9153 db.conn
9154 .execute(
9155 "insert into messages (session_id, provider, seq, role, tool_name, content) \
9156 values ('s1', 'claude', ?, 'tool', 'exec_command', 'exec command output')",
9157 [seq],
9158 )
9159 .unwrap();
9160 }
9161
9162 for field in [
9163 None,
9164 Some(SearchField::ToolName),
9165 Some(SearchField::Content),
9166 ] {
9167 let filters = |offset: usize| MessageFilters {
9168 field,
9169 match_mode: MessageSearchMode::Fuzzy,
9170 limit: 2,
9171 offset,
9172 ..Default::default()
9173 };
9174
9175 let (hits, explain) = db
9178 .search_messages_with_explain("exec", &filters(1_000_000_000), true)
9179 .unwrap();
9180 assert!(hits.is_empty(), "{field:?}");
9181 let explain = explain.expect("an explained request returns a receipt");
9182 assert_eq!(explain.corpus, 4, "{field:?}");
9183 assert_eq!(
9184 explain.candidates, None,
9185 "{field:?}: no row scored, so no candidate count was measured"
9186 );
9187
9188 let (hits, explain) = db
9190 .search_messages_with_explain("exec", &filters(0), true)
9191 .unwrap();
9192 assert_eq!(hits.len(), 2, "{field:?}");
9193 assert_eq!(
9194 explain.expect("receipt").candidates,
9195 Some(4),
9196 "{field:?}: every row scored"
9197 );
9198 }
9199 }
9200
9201 #[test]
9210 fn the_fuzzy_window_boundary_keeps_the_last_row_and_drops_the_first_one_past_it() {
9211 let dir = tempfile::tempdir().unwrap();
9212 let db = Db::open(&dir.path().join("index.db")).unwrap();
9213 db.conn
9214 .execute_batch(
9215 "insert into sessions (
9216 id, provider, provider_session_id, preview_text, source_path,
9217 parse_version, discovery_source
9218 ) values ('s1','claude','s1','','/p','1','test');",
9219 )
9220 .unwrap();
9221 let corpus = 4_usize;
9222 for seq in 0..corpus as i64 {
9225 db.conn
9226 .execute(
9227 "insert into messages (session_id, provider, seq, role, content) \
9228 values ('s1', 'claude', ?, 'user', 'exec command output')",
9229 [seq],
9230 )
9231 .unwrap();
9232 }
9233
9234 let filters = |offset: usize| MessageFilters {
9235 match_mode: MessageSearchMode::Fuzzy,
9236 limit: FUZZY_SCORE_BATCH_SIZE + 100,
9237 offset,
9238 ..Default::default()
9239 };
9240
9241 let (hits, explain) = db
9243 .search_messages_with_explain("exec", &filters(corpus - 1), true)
9244 .unwrap();
9245 assert_eq!(hits.len(), 1, "the last row is inside the corpus");
9246 assert_eq!(explain.expect("receipt").candidates, Some(corpus as i64));
9247
9248 let (hits, explain) = db
9250 .search_messages_with_explain("exec", &filters(corpus), true)
9251 .unwrap();
9252 assert!(hits.is_empty(), "one past the last row is an empty page");
9253 let explain = explain.expect("receipt");
9254 assert_eq!(explain.corpus, corpus as i64);
9255 assert_eq!(
9256 explain.candidates, None,
9257 "an empty page from the count scored no row"
9258 );
9259 }
9260
9261 #[test]
9269 fn a_literal_search_finds_greek_text_ending_in_sigma() {
9270 let dir = tempfile::tempdir().unwrap();
9271 let db = Db::open(&dir.path().join("index.db")).unwrap();
9272 db.conn
9273 .execute_batch(
9274 "insert into sessions (
9275 id, provider, provider_session_id, preview_text, source_path,
9276 parse_version, discovery_source
9277 ) values ('s1','claude','s1','','/p','1','test');
9278 insert into messages (session_id, provider, seq, role, kind, content)
9279 values ('s1','claude',0,'user','message','η ΟΔΟΣΣ που ψαχνω'),
9280 ('s1','claude',1,'user','message','η οδοσς που ψαχνω επισης');",
9281 )
9282 .unwrap();
9283
9284 for query in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ"] {
9287 let hits = db
9288 .search_messages(
9289 query,
9290 &MessageFilters {
9291 match_mode: MessageSearchMode::Literal,
9292 limit: 10,
9293 ..Default::default()
9294 },
9295 )
9296 .unwrap();
9297 assert_eq!(hits.len(), 2, "query {query:?} finds both spellings");
9298 }
9299 }
9300
9301 #[test]
9310 fn a_pre_v4_literal_search_finds_greek_text_ending_in_sigma() {
9311 let dir = tempfile::tempdir().unwrap();
9312 let db = Db::open(&dir.path().join("index.db")).unwrap();
9313 enable_v3_custom_trigram_compatibility(&db);
9314 seed_messages(
9315 &db,
9316 &[
9317 ("user", "η ΟΔΟΣΣ που ψαχνω"),
9318 ("user", "η οδοσς που ψαχνω επισης"),
9319 ],
9320 );
9321
9322 for query in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ"] {
9323 let (hits, explain) = db
9324 .search_messages_with_explain(
9325 query,
9326 &MessageFilters {
9327 match_mode: MessageSearchMode::Literal,
9328 limit: 10,
9329 ..Default::default()
9330 },
9331 true,
9332 )
9333 .unwrap();
9334 assert_eq!(hits.len(), 2, "query {query:?} finds both spellings");
9335 let explain = explain.expect("receipt");
9338 assert_eq!(explain.prefilter, None, "query {query:?} indexed nothing");
9339 assert!(
9340 explain
9341 .prefilter_skipped
9342 .as_deref()
9343 .is_some_and(|reason| reason.contains("sigma")),
9344 "query {query:?} reports why it scanned: {:?}",
9345 explain.prefilter_skipped
9346 );
9347 }
9348 }
9349
9350 #[test]
9359 fn a_tool_argument_window_counts_only_tool_call_rows() {
9360 let dir = tempfile::tempdir().unwrap();
9361 let db = Db::open(&dir.path().join("index.db")).unwrap();
9362 db.conn
9363 .execute_batch(
9364 "insert into sessions (
9365 id, provider, provider_session_id, preview_text, source_path,
9366 parse_version, discovery_source
9367 ) values ('s1','claude','s1','','/p','1','test');
9368 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
9369 values ('s1','claude',0,'tool','tool_call','exec_command','{\"args\":{\"cmd\":\"needle\"}}');
9370 insert into messages (session_id, provider, seq, role, kind, content)
9371 values ('s1','claude',1,'user','message','needle in ordinary prose'),
9372 ('s1','claude',2,'assistant','message','needle again'),
9373 ('s1','claude',3,'user','message','needle once more');",
9374 )
9375 .unwrap();
9376
9377 let filters = |offset: usize| MessageFilters {
9378 field: Some(SearchField::ToolArgument),
9379 argument_path: Some("/cmd".to_string()),
9380 match_mode: MessageSearchMode::Fuzzy,
9381 limit: 1,
9382 offset,
9383 ..Default::default()
9384 };
9385
9386 let (hits, explain) = db
9388 .search_messages_with_explain("needle", &filters(0), true)
9389 .unwrap();
9390 assert_eq!(hits.len(), 1, "only the tool call has an argument");
9391 assert_eq!(
9392 explain.expect("receipt").corpus,
9393 1,
9394 "corpus is the rows the searched field admits"
9395 );
9396
9397 let (hits, explain) = db
9399 .search_messages_with_explain("needle", &filters(1_000_000_000), true)
9400 .unwrap();
9401 assert!(hits.is_empty());
9402 let explain = explain.expect("an explained request returns a receipt");
9403 assert_eq!(explain.corpus, 1, "ordinary rows are not tool arguments");
9404 assert_eq!(
9405 explain.candidates, None,
9406 "no row scored, so no candidate count was measured"
9407 );
9408 }
9409
9410 #[test]
9418 fn an_empty_corpus_reports_the_same_receipt_whatever_the_page_size() {
9419 let dir = tempfile::tempdir().unwrap();
9420 let db = Db::open(&dir.path().join("index.db")).unwrap();
9421
9422 let receipt = |limit: usize| {
9423 let (hits, explain) = db
9424 .search_messages_with_explain(
9425 "anything",
9426 &MessageFilters {
9427 match_mode: MessageSearchMode::Fuzzy,
9428 limit,
9429 ..Default::default()
9430 },
9431 true,
9432 )
9433 .unwrap();
9434 assert!(hits.is_empty(), "limit {limit}");
9435 explain.expect("an explained request returns a receipt")
9436 };
9437
9438 let small = receipt(100);
9441 let large = receipt(FUZZY_SCORE_BATCH_SIZE + 1);
9442 assert_eq!(small.corpus, 0);
9443 assert_eq!(large.corpus, 0);
9444 assert_eq!(small.candidates, Some(0));
9445 assert_eq!(
9446 large.candidates, small.candidates,
9447 "an empty corpus scored nothing either way; the count is 0, not absent"
9448 );
9449 assert_eq!(large.prefilter_skipped, small.prefilter_skipped);
9450 }
9451
9452 #[test]
9453 fn date_filter_until_covers_sub_second_tail_of_final_second() {
9454 let dir = tempfile::tempdir().unwrap();
9455 let db = Db::open(&dir.path().join("index.db")).unwrap();
9456 db.conn
9457 .execute(
9458 "insert into sessions (id, provider, provider_session_id, preview_text, \
9459 source_path, parse_version, discovery_source) \
9460 values ('s1','claude','s1','','/p','1','test')",
9461 [],
9462 )
9463 .unwrap();
9464 db.conn
9466 .execute(
9467 "insert into messages (id, session_id, provider, seq, role, ts, content) \
9468 values (1,'s1','claude',0,'user','2026-01-15T23:59:59.123456789+00:00','late')",
9469 [],
9470 )
9471 .unwrap();
9472 let until =
9473 crate::dates::parse_bound("2026-01-15", crate::dates::Bound::End, Utc::now()).unwrap();
9474 let hits = db
9475 .search_messages(
9476 "",
9477 &MessageFilters {
9478 until: Some(until),
9479 ..Default::default()
9480 },
9481 )
9482 .unwrap();
9483 assert_eq!(
9484 hits.len(),
9485 1,
9486 "an inclusive --until must cover sub-second timestamps in its final second"
9487 );
9488 }
9489
9490 #[test]
9491 fn date_filter_preserves_exact_fractional_upper_bound() {
9492 let dir = tempfile::tempdir().unwrap();
9493 let db = Db::open(&dir.path().join("index.db")).unwrap();
9494 db.conn
9495 .execute(
9496 "insert into sessions (id, provider, provider_session_id, preview_text, \
9497 source_path, parse_version, discovery_source) \
9498 values ('s1','claude','s1','','/p','1','test')",
9499 [],
9500 )
9501 .unwrap();
9502 for (id, seq, ts) in [
9503 (1, 0, "2026-07-07T02:53:22.357999999+00:00"),
9504 (2, 1, "2026-07-07T02:53:22.358000000+00:00"),
9505 (3, 2, "2026-07-07T02:53:22.358000001+00:00"),
9506 ] {
9507 db.conn
9508 .execute(
9509 "insert into messages (id, session_id, provider, seq, role, ts, content) \
9510 values (?1,'s1','claude',?2,'user',?3,'event')",
9511 rusqlite::params![id, seq, ts],
9512 )
9513 .unwrap();
9514 }
9515 let until = crate::dates::parse_bound(
9516 "2026-07-07T02:53:22.358Z",
9517 crate::dates::Bound::End,
9518 Utc::now(),
9519 )
9520 .unwrap();
9521 let hits = db
9522 .search_messages(
9523 "",
9524 &MessageFilters {
9525 until: Some(until),
9526 ..Default::default()
9527 },
9528 )
9529 .unwrap();
9530 assert_eq!(
9531 hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
9532 vec![0, 1],
9533 "an exact fractional --until must exclude later events in the same second"
9534 );
9535 }
9536
9537 #[test]
9538 fn date_filter_keeps_messages_with_unknown_timestamp() {
9539 use chrono::TimeZone;
9540 let dir = tempfile::tempdir().unwrap();
9541 let db = Db::open(&dir.path().join("index.db")).unwrap();
9542 db.conn
9543 .execute(
9544 "insert into sessions (id, provider, provider_session_id, preview_text, \
9545 source_path, parse_version, discovery_source) \
9546 values ('s1','claude','s1','','/p','1','test')",
9547 [],
9548 )
9549 .unwrap();
9550 db.conn
9552 .execute(
9553 "insert into messages (id, session_id, provider, seq, role, ts, content) \
9554 values (1,'s1','claude',0,'user',NULL,'undated correction')",
9555 [],
9556 )
9557 .unwrap();
9558 let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap();
9559 let until = Utc
9560 .with_ymd_and_hms(2026, 12, 31, 23, 59, 59)
9561 .single()
9562 .unwrap();
9563 let hits = db
9564 .search_messages(
9565 "zebracode",
9566 &MessageFilters {
9567 since: Some(since),
9568 until: Some(until),
9569 ..Default::default()
9570 },
9571 )
9572 .unwrap();
9573 assert!(
9574 hits.is_empty(),
9575 "a NULL-timestamp message must not match every date filter; index a fallback timestamp instead"
9576 );
9577 }
9578
9579 #[test]
9580 fn session_search_zero_is_unlimited_and_large_limit_does_not_overflow() {
9581 let dir = tempfile::tempdir().unwrap();
9582 let db = Db::open(&dir.path().join("index.db")).unwrap();
9583 for id in ["s1", "s2"] {
9584 db.conn
9585 .execute(
9586 "insert into sessions (id, provider, provider_session_id, preview_text, \
9587 source_path, parse_version, discovery_source) \
9588 values (?1,'claude',?1,'alpha preview',?2,'1','test')",
9589 params![id, format!("/{id}.jsonl")],
9590 )
9591 .unwrap();
9592 }
9593
9594 let scoring = crate::config::ScoringConfig::default();
9595 let mut filters = SearchFilters::default();
9596
9597 assert_eq!(
9598 db.search("alpha", &filters, None, &scoring).unwrap().len(),
9599 2
9600 );
9601
9602 filters.limit = 1;
9603 assert_eq!(
9604 db.search("alpha", &filters, None, &scoring).unwrap().len(),
9605 1
9606 );
9607
9608 filters.limit = usize::MAX;
9609 assert_eq!(
9610 db.search("alpha", &filters, None, &scoring).unwrap().len(),
9611 2
9612 );
9613 }
9614
9615 #[test]
9616 fn session_search_requires_a_text_match_before_ranking_bonuses() {
9617 let dir = tempfile::tempdir().unwrap();
9618 let db = Db::open(&dir.path().join("index.db")).unwrap();
9619 db.conn
9620 .execute(
9621 "insert into sessions (
9622 id, provider, provider_session_id, cwd, repo_root, preview_text,
9623 source_path, updated_at, parse_version, discovery_source
9624 ) values ('s1','claude','s1','/repo','/repo','ordinary text','/p',?1,'1','test')",
9625 params![Utc::now().to_rfc3339()],
9626 )
9627 .unwrap();
9628
9629 let hits = db
9630 .search(
9631 "⸘⸘⸘",
9632 &SearchFilters::default(),
9633 Some("/repo"),
9634 &crate::config::ScoringConfig::default(),
9635 )
9636 .unwrap();
9637
9638 assert!(
9639 hits.is_empty(),
9640 "recency and repository bonuses must not admit an unrelated session"
9641 );
9642 }
9643
9644 #[test]
9645 fn session_search_combines_query_term_coverage_across_one_session() {
9646 let dir = tempfile::tempdir().unwrap();
9647 let db = Db::open(&dir.path().join("index.db")).unwrap();
9648 db.conn
9649 .execute(
9650 "insert into sessions (
9651 id, provider, provider_session_id, title, summary, preview_text,
9652 source_path, parse_version, discovery_source
9653 ) values ('s1','claude','s1','alpha','beta','','/p','1','test')",
9654 [],
9655 )
9656 .unwrap();
9657 let scoring = crate::config::ScoringConfig {
9658 title_score: 0,
9659 summary_score: 0,
9660 path_score: 0,
9661 preview_score: 0,
9662 other_score: 0,
9663 token_bonus: 0,
9664 all_tokens_bonus: 150,
9665 recency_weight: 0,
9666 current_repo_bonus: 0,
9667 ..Default::default()
9668 };
9669
9670 let hits = db
9671 .search("alpha beta", &SearchFilters::default(), None, &scoring)
9672 .unwrap();
9673
9674 assert_eq!(hits.len(), 1);
9675 assert_eq!(hits[0].session.id, "s1");
9676 assert_eq!(hits[0].score, 150);
9677 }
9678
9679 #[test]
9680 fn session_search_positive_limit_is_a_prefix_of_unlimited_ranking() {
9681 let dir = tempfile::tempdir().unwrap();
9682 let db = Db::open(&dir.path().join("index.db")).unwrap();
9683 for (id, title, preview) in [("s1", "", "alpha"), ("s2", "alpha", "")] {
9684 db.conn
9685 .execute(
9686 "insert into sessions (
9687 id, provider, provider_session_id, title, preview_text,
9688 source_path, parse_version, discovery_source
9689 ) values (?1,'claude',?1,?2,?3,?4,'1','test')",
9690 params![id, title, preview, format!("/{id}.jsonl")],
9691 )
9692 .unwrap();
9693 }
9694 let scoring = crate::config::ScoringConfig {
9695 recency_weight: 0,
9696 ..Default::default()
9697 };
9698 let all_filters = SearchFilters {
9699 limit: 0,
9700 ..Default::default()
9701 };
9702 let all = db.search("alpha", &all_filters, None, &scoring).unwrap();
9703 let one_filter = SearchFilters {
9704 limit: 1,
9705 ..all_filters
9706 };
9707 let one = db.search("alpha", &one_filter, None, &scoring).unwrap();
9708
9709 assert_eq!(one.len(), 1);
9710 assert_eq!(one[0].session.id, all[0].session.id);
9711 assert_eq!(one[0].session.id, "s2");
9712 }
9713
9714 #[test]
9715 fn session_search_uses_the_application_worker_runtime_without_changing_ranking() {
9716 let root = tempfile::tempdir().unwrap();
9717 let one = Db::open_with_threads(
9718 &root.path().join("one.db"),
9719 TEST_BUSY_TIMEOUT_MS,
9720 NonZeroUsize::new(1).unwrap(),
9721 )
9722 .unwrap();
9723 let four = Db::open_with_threads(
9724 &root.path().join("four.db"),
9725 TEST_BUSY_TIMEOUT_MS,
9726 NonZeroUsize::new(4).unwrap(),
9727 )
9728 .unwrap();
9729 for db in [&one, &four] {
9730 for index in 0..1_100 {
9731 let id = format!("s{index:04}");
9732 db.conn
9733 .execute(
9734 "insert into sessions (
9735 id, provider, provider_session_id, title, summary, cwd, repo_root,
9736 preview_text, source_path, updated_at, parse_version, discovery_source
9737 ) values (?1, 'claude', ?1, ?2, ?3, ?4, ?4, ?5, ?6, ?7, '1', 'test')",
9738 params![
9739 id,
9740 (index % 7 == 0).then_some("database migration"),
9741 format!("summary {index}"),
9742 format!("/repo/{index}"),
9743 format!("preview database work item {index}"),
9744 format!("/{index}.jsonl"),
9745 format!("2026-01-{:02}T00:00:00Z", index % 28 + 1),
9746 ],
9747 )
9748 .unwrap();
9749 db.conn
9750 .execute(
9751 "insert into transcripts(session_id, transcript_text) values (?1, ?2)",
9752 params![
9753 format!("s{index:04}"),
9754 format!(
9755 "transcript {index} discusses database schema migration planning"
9756 )
9757 ],
9758 )
9759 .unwrap();
9760 }
9761 }
9762 let filters = SearchFilters {
9763 limit: 40,
9764 ..Default::default()
9765 };
9766 let scoring = crate::config::ScoringConfig {
9767 recency_weight: 0,
9768 ..Default::default()
9769 };
9770 let identities = |hits: Vec<SearchHit>| {
9771 hits.into_iter()
9772 .map(|hit| {
9773 (
9774 hit.session.id,
9775 hit.score,
9776 hit.match_source,
9777 hit.match_snippet,
9778 )
9779 })
9780 .collect::<Vec<_>>()
9781 };
9782 assert_eq!(one.worker_pool_build_count(), 0);
9783 assert_eq!(four.worker_pool_build_count(), 0);
9784 let expected = identities(
9785 one.search("databse migraton", &filters, Some("/repo/7"), &scoring)
9786 .unwrap(),
9787 );
9788 let actual = identities(
9789 four.search("databse migraton", &filters, Some("/repo/7"), &scoring)
9790 .unwrap(),
9791 );
9792 assert_eq!(
9793 actual, expected,
9794 "worker count must not change session ranking"
9795 );
9796 assert_eq!(one.worker_pool_build_count(), 1);
9797 assert_eq!(four.worker_pool_build_count(), 1);
9798 }
9799
9800 #[test]
9804 #[ignore = "deterministic release-mode session-search performance measurement"]
9805 fn session_search_parallel_scoring_benchmark() {
9806 let root = tempfile::tempdir().unwrap();
9807 let path = root.path().join("index.db");
9808 {
9809 let db =
9810 Db::open_with_threads(&path, TEST_BUSY_TIMEOUT_MS, NonZeroUsize::new(1).unwrap())
9811 .unwrap();
9812 let transcript_padding = "database schema migration planning ".repeat(250);
9813 let tx = db.conn.unchecked_transaction().unwrap();
9814 {
9815 let mut session = tx
9816 .prepare(
9817 "insert into sessions (
9818 id, provider, provider_session_id, title, preview_text, source_path,
9819 parse_version, discovery_source
9820 ) values (?1, 'claude', ?1, ?2, ?3, ?4, '1', 'test')",
9821 )
9822 .unwrap();
9823 let mut transcript = tx
9824 .prepare("insert into transcripts(session_id, transcript_text) values (?1, ?2)")
9825 .unwrap();
9826 for index in 0..4_000 {
9827 let id = format!("s{index:04}");
9828 session
9829 .execute(params![
9830 id,
9831 (index % 11 == 0).then_some("database migration"),
9832 format!("preview {index}"),
9833 format!("/{index}.jsonl"),
9834 ])
9835 .unwrap();
9836 transcript
9837 .execute(params![
9838 format!("s{index:04}"),
9839 format!("{transcript_padding}{index}")
9840 ])
9841 .unwrap();
9842 }
9843 }
9844 tx.commit().unwrap();
9845 }
9846 let filters = SearchFilters {
9847 limit: 50,
9848 ..Default::default()
9849 };
9850 let scoring = crate::config::ScoringConfig {
9851 recency_weight: 0,
9852 ..Default::default()
9853 };
9854 let measure = |threads| {
9855 let db = Db::open_with_threads(
9856 &path,
9857 TEST_BUSY_TIMEOUT_MS,
9858 NonZeroUsize::new(threads).unwrap(),
9859 )
9860 .unwrap();
9861 let search = || {
9862 db.search("databse migraton", &filters, None, &scoring)
9863 .unwrap()
9864 };
9865 let expected = serde_json::to_vec(&search()).unwrap();
9866 let digest = crate::hashing::sha256(&expected);
9867 let mut elapsed = Vec::new();
9868 for _ in 0..7 {
9869 let started = Instant::now();
9870 assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
9871 elapsed.push(started.elapsed().as_micros());
9872 }
9873 elapsed.sort_unstable();
9874 (
9875 elapsed[elapsed.len() / 2],
9876 elapsed[elapsed.len() - 1],
9877 digest,
9878 )
9879 };
9880 let one = measure(1);
9881 let four = measure(4);
9882 assert_eq!(one.2, four.2);
9883 eprintln!(
9884 "session_parallel_benchmark sessions=4000 transcript_bytes_per_session={} one_worker_median_us={} one_worker_p95_us={} four_worker_median_us={} four_worker_p95_us={} digest=sha256:{}",
9885 "database schema migration planning ".len() * 250,
9886 one.0,
9887 one.1,
9888 four.0,
9889 four.1,
9890 one.2
9891 );
9892 }
9893
9894 #[test]
9898 #[ignore = "deterministic release-mode temporal predicate measurement"]
9899 fn session_temporal_overlap_predicate_benchmark() {
9900 let dir = tempfile::tempdir().unwrap();
9901 let db = Db::open(&dir.path().join("index.db")).unwrap();
9902 let tx = db.conn.unchecked_transaction().unwrap();
9903 {
9904 let mut insert = tx
9905 .prepare(
9906 "insert into sessions (id, provider, provider_session_id, created_at, updated_at, preview_text, source_path, parse_version, discovery_source) values (?1, 'claude', ?1, ?2, ?3, '', ?4, '1', 'test')",
9907 )
9908 .unwrap();
9909 for index in 0..100_000 {
9910 let created = if index % 2 == 0 {
9911 "2026-01-01T00:00:00+00:00"
9912 } else {
9913 "2025-12-01T00:00:00+00:00"
9914 };
9915 insert
9916 .execute(params![
9917 format!("s{index:06}"),
9918 created,
9919 "2026-01-15T00:00:00+00:00",
9920 format!("/{index}.jsonl")
9921 ])
9922 .unwrap();
9923 }
9924 }
9925 tx.commit().unwrap();
9926 let measure = |predicate: &str| {
9927 let sql = format!("select count(*) from sessions s where {predicate}");
9928 let expected: i64 = db.conn.query_row(&sql, [], |row| row.get(0)).unwrap();
9929 let mut elapsed = Vec::new();
9930 for _ in 0..9 {
9931 let started = Instant::now();
9932 assert_eq!(
9933 db.conn
9934 .query_row::<i64, _, _>(&sql, [], |row| row.get(0))
9935 .unwrap(),
9936 expected
9937 );
9938 elapsed.push(started.elapsed().as_micros());
9939 }
9940 elapsed.sort_unstable();
9941 let plan = db
9942 .conn
9943 .prepare(&format!("explain query plan {sql}"))
9944 .unwrap()
9945 .query_map([], |row| row.get::<_, String>(3))
9946 .unwrap()
9947 .collect::<rusqlite::Result<Vec<_>>>()
9948 .unwrap()
9949 .join(" | ");
9950 assert!(
9951 !plan.contains("messages") && !plan.contains("transcripts"),
9952 "{plan}"
9953 );
9954 (
9955 elapsed[elapsed.len() / 2],
9956 elapsed[elapsed.len() - 1],
9957 expected,
9958 plan,
9959 )
9960 };
9961 let terminal = measure(
9962 "coalesce(s.updated_at, s.created_at) >= '2025-12-15T00:00:00+00:00' and coalesce(s.updated_at, s.created_at) <= '2025-12-31T23:59:59+00:00'",
9963 );
9964 let overlap = measure(
9965 "coalesce(s.updated_at, s.created_at) >= '2025-12-01T00:00:00+00:00' and coalesce(s.created_at, s.updated_at) <= '2025-12-31T23:59:59+00:00'",
9966 );
9967 eprintln!(
9968 "session_temporal_predicate_benchmark sessions=100000 terminal_median_us={} terminal_p95_us={} terminal_matches={} overlap_median_us={} overlap_p95_us={} overlap_matches={} terminal_plan={:?} overlap_plan={:?}",
9969 terminal.0, terminal.1, terminal.2, overlap.0, overlap.1, overlap.2, terminal.3, overlap.3
9970 );
9971 }
9972
9973 #[test]
9974 fn message_update_keeps_fts_in_sync() {
9975 let dir = tempfile::tempdir().unwrap();
9979 let db = Db::open(&dir.path().join("index.db")).unwrap();
9980 db.conn
9981 .execute(
9982 "insert into sessions (id, provider, provider_session_id, preview_text, \
9983 source_path, parse_version, discovery_source) \
9984 values ('s1','claude','s1','','/p','1','test')",
9985 [],
9986 )
9987 .unwrap();
9988 db.conn
9989 .execute(
9990 "insert into messages (id, session_id, provider, seq, role, content) \
9991 values (1,'s1','claude',0,'user','alpha original')",
9992 [],
9993 )
9994 .unwrap();
9995 let count = |term: &str| -> i64 {
9996 db.conn
9997 .query_row(
9998 "select count(*) from messages_fts where messages_fts match ?1",
9999 params![term],
10000 |r| r.get(0),
10001 )
10002 .unwrap()
10003 };
10004 assert_eq!(count("original"), 1, "inserted content is searchable");
10005 db.conn
10006 .execute("update messages set content='beta updated' where id=1", [])
10007 .unwrap();
10008 assert_eq!(count("updated"), 1, "new content searchable after update");
10009 assert_eq!(count("original"), 0, "stale term dropped after update");
10010 }
10011
10012 #[test]
10018 fn open_retires_the_unread_session_fts_copy_once_and_keeps_the_empty_table_for_older_builds() {
10019 let dir = tempfile::tempdir().unwrap();
10020 let path = dir.path().join("index.db");
10021 drop(Db::open(&path).unwrap());
10022 let fill = || {
10023 rusqlite::Connection::open(&path)
10024 .unwrap()
10025 .execute_batch(
10026 "insert into sessions_fts(rowid, title, summary, preview_text, transcript_text)
10027 values (1, 'older build wrote this', '', '', '');",
10028 )
10029 .unwrap();
10030 };
10031 let fts_rows = |db: &Db| -> i64 {
10032 db.conn
10033 .query_row("select count(*) from sessions_fts", [], |row| row.get(0))
10034 .unwrap()
10035 };
10036 let db = Db::open(&path).unwrap();
10038 assert_eq!(fts_rows(&db), 0);
10039 assert!(
10040 crate::indexer::current_schema_layout_problem(&db.conn)
10041 .unwrap()
10042 .is_none(),
10043 "the empty table satisfies the layout check an older build performs"
10044 );
10045 seed_messages(&db, &[("user", "hello")]);
10046 assert_eq!(fts_rows(&db), 0, "no write path fills it");
10047 db.clear_all().unwrap();
10048 drop(db);
10049 fill();
10051 let reopened = Db::open(&path).unwrap();
10052 assert_eq!(
10053 fts_rows(&reopened),
10054 1,
10055 "a refill by an older writer is left alone after the one-time retirement"
10056 );
10057 drop(reopened);
10058 rusqlite::Connection::open(&path)
10060 .unwrap()
10061 .execute_batch("delete from index_metadata where key = 'sessions_fts_retired';")
10062 .unwrap();
10063 let retired = Db::open(&path).unwrap();
10064 assert_eq!(
10065 fts_rows(&retired),
10066 0,
10067 "the earlier build's copy is emptied once"
10068 );
10069 }
10070
10071 #[test]
10072 fn resolve_session_errors_are_actionable() {
10073 let dir = tempfile::tempdir().unwrap();
10074 let db = Db::open(&dir.path().join("index.db")).unwrap();
10075 let insert = |id: &str| {
10076 db.conn
10077 .execute(
10078 "insert into sessions (id, provider, provider_session_id, preview_text, \
10079 source_path, parse_version, discovery_source) \
10080 values (?1,'claude',?1,'','/p','1','test')",
10081 params![id],
10082 )
10083 .unwrap();
10084 };
10085 insert("claude:abc123");
10086 insert("claude:abc456");
10087
10088 let err = db.resolve_session("zzz").unwrap_err().to_string();
10090 assert!(err.contains("no session matches"));
10091 assert!(err.contains("aise list") || err.contains("aise search"));
10092 let err = db.resolve_session_record("zzz").unwrap_err().to_string();
10093 assert!(err.contains("no session matches"));
10094 assert!(err.contains("aise list") || err.contains("aise search"));
10095
10096 let err = db.resolve_session("claude:abc").unwrap_err().to_string();
10098 assert!(err.contains("ambiguous"));
10099 assert!(
10100 err.contains("claude:abc123") && err.contains("claude:abc456"),
10101 "ambiguous error must list candidates: {err}"
10102 );
10103 let err = db
10104 .resolve_session_record("claude:abc")
10105 .unwrap_err()
10106 .to_string();
10107 assert!(err.contains("ambiguous"));
10108 assert!(
10109 err.contains("claude:abc123") && err.contains("claude:abc456"),
10110 "ambiguous error must list candidates: {err}"
10111 );
10112 }
10113
10114 #[test]
10115 fn exact_session_resolution_uses_indexes_and_bare_collisions_name_canonical_candidates() {
10116 let dir = tempfile::tempdir().unwrap();
10117 let db = Db::open(&dir.path().join("index.db")).unwrap();
10118 for provider in [Provider::Claude, Provider::Codex] {
10119 db.conn
10120 .execute(
10121 "insert into sessions (id, provider, provider_session_id, preview_text, \
10122 source_path, parse_version, discovery_source) \
10123 values (?1, ?2, 'shared-native-id', '', '/p', '1', 'test')",
10124 params![
10125 format!("{}:shared-native-id", provider.as_str()),
10126 provider.as_str()
10127 ],
10128 )
10129 .unwrap();
10130 }
10131
10132 for canonical in ["claude:shared-native-id", "codex:shared-native-id"] {
10133 assert_eq!(db.resolve_session_record(canonical).unwrap().id, canonical);
10134 }
10135 let collision = db
10136 .resolve_session_record("shared-native-id")
10137 .unwrap_err()
10138 .to_string();
10139 assert!(collision.contains("ambiguous"), "{collision}");
10140 assert!(collision.contains("claude:shared-native-id"), "{collision}");
10141 assert!(collision.contains("codex:shared-native-id"), "{collision}");
10142
10143 let mut statement = db
10144 .conn
10145 .prepare(&format!(
10146 "explain query plan {RESOLVE_SESSION_RECORD_EXACT_SQL}"
10147 ))
10148 .unwrap();
10149 let plan = statement
10150 .query_map(["claude:shared-native-id"], |row| row.get::<_, String>(3))
10151 .unwrap()
10152 .collect::<rusqlite::Result<Vec<_>>>()
10153 .unwrap()
10154 .join("\n");
10155 assert!(plan.contains("SEARCH"), "{plan}");
10156 assert!(!plan.contains("SCAN s"), "{plan}");
10157 }
10158
10159 #[test]
10160 fn session_prefix_resolution_treats_sql_wildcards_as_literal_text() {
10161 let dir = tempfile::tempdir().unwrap();
10162 let db = Db::open(&dir.path().join("index.db")).unwrap();
10163 for id in ["claude:percent", "claude:under_score"] {
10164 db.conn
10165 .execute(
10166 "insert into sessions (id, provider, provider_session_id, preview_text, \
10167 source_path, parse_version, discovery_source) \
10168 values (?1, 'claude', ?1, '', '/p', '1', 'test')",
10169 params![id],
10170 )
10171 .unwrap();
10172 }
10173 for literal in ["%", "_"] {
10174 let error = db.resolve_session_record(literal).unwrap_err().to_string();
10175 assert!(error.contains("no session matches"), "{error}");
10176 }
10177 }
10178
10179 #[test]
10180 fn open_adds_typed_message_columns_to_version_one_database() {
10181 let dir = tempfile::tempdir().unwrap();
10182 let path = dir.path().join("index.db");
10183 {
10184 let legacy = Connection::open(&path).unwrap();
10185 legacy
10186 .execute_batch(
10187 "create table messages (
10188 id integer primary key,
10189 session_id text not null,
10190 provider text not null,
10191 seq integer not null,
10192 role text not null,
10193 ts text,
10194 tool_name text,
10195 is_compaction integer not null default 0,
10196 content text not null
10197 );
10198 insert into messages
10199 (session_id, provider, seq, role, content)
10200 values ('claude:legacy', 'claude', 0, 'user', 'legacy text');
10201 pragma user_version = 1;",
10202 )
10203 .unwrap();
10204 }
10205
10206 let db = Db::open(&path).unwrap();
10207 let columns = db
10208 .conn
10209 .prepare("pragma table_info(messages)")
10210 .unwrap()
10211 .query_map([], |row| row.get::<_, String>(1))
10212 .unwrap()
10213 .collect::<rusqlite::Result<Vec<_>>>()
10214 .unwrap();
10215 assert!(columns.iter().any(|column| column == "kind"));
10216 assert!(columns.iter().any(|column| column == "tool_call_id"));
10217 assert!(columns.iter().any(|column| column == "authorship"));
10218 assert!(columns
10219 .iter()
10220 .any(|column| column == "correlation_authority"));
10221 assert!(columns.iter().any(|column| column == "correlation_scope"));
10222 assert!(columns.iter().any(|column| column == "correlation_id"));
10223 assert!(columns.iter().any(|column| column == "record_relation"));
10224 let legacy_authorship: String = db
10225 .conn
10226 .query_row("select authorship from messages limit 1", [], |row| {
10227 row.get(0)
10228 })
10229 .unwrap();
10230 assert_eq!(legacy_authorship, "unknown");
10231 let parts_table_exists: bool = db
10232 .conn
10233 .query_row(
10234 "select exists(
10235 select 1 from sqlite_schema
10236 where type = 'table' and name = 'message_content_parts'
10237 )",
10238 [],
10239 |row| row.get(0),
10240 )
10241 .unwrap();
10242 assert!(parts_table_exists);
10243 assert!(db.needs_backfill().unwrap());
10244 }
10245
10246 #[test]
10247 fn message_provenance_round_trips_without_copying_part_text() {
10248 use crate::models::{
10249 ContentPartAuthorship, ContentPartOrigin, MessageAuthorship, MessageContentPart,
10250 MessageCorrelationAuthority, MessageCorrelationIdentity, MessageProvenance,
10251 MessageRecordRelation,
10252 };
10253
10254 let dir = tempfile::tempdir().unwrap();
10255 let db = Db::open(&dir.path().join("index.db")).unwrap();
10256 let mut parsed = crate::util::minimal_record(
10257 Provider::GeminiCli,
10258 Path::new("/fixture/mixed.json"),
10259 String::new(),
10260 );
10261 parsed.session.id = "gemini-cli:mixed".to_string();
10262 parsed.session.provider_session_id = "mixed".to_string();
10263 parsed.messages = vec![crate::models::Message {
10264 seq: 0,
10265 role: Role::User,
10266 ts: None,
10267 tool_name: None,
10268 kind: crate::models::MessageKind::Conversation,
10269 tool_call_id: None,
10270 is_compaction: false,
10271 content: "askAI".to_string(),
10272 provenance: MessageProvenance {
10273 authorship: MessageAuthorship::Mixed,
10274 record_relation: MessageRecordRelation::Mirror,
10275 correlation_identity: Some(MessageCorrelationIdentity {
10276 authority: MessageCorrelationAuthority::Google,
10277 scope: "conversation_event".to_string(),
10278 id: "event-7".to_string(),
10279 }),
10280 content_parts: vec![
10281 MessageContentPart {
10282 ordinal: 0,
10283 start_char: 0,
10284 end_char: 3,
10285 authorship: ContentPartAuthorship::Human,
10286 origin: ContentPartOrigin::DirectInput,
10287 },
10288 MessageContentPart {
10289 ordinal: 1,
10290 start_char: 3,
10291 end_char: 5,
10292 authorship: ContentPartAuthorship::Generated,
10293 origin: ContentPartOrigin::QuotedContent,
10294 },
10295 ],
10296 },
10297 }];
10298
10299 db.upsert_session(&parsed, 0, 0).unwrap();
10300 let stored: (String, String, String, String, String) = db
10301 .conn
10302 .query_row(
10303 "select authorship, correlation_authority, correlation_scope,
10304 correlation_id, record_relation
10305 from messages where session_id = ?1 and seq = 0",
10306 [&parsed.session.id],
10307 |row| {
10308 Ok((
10309 row.get(0)?,
10310 row.get(1)?,
10311 row.get(2)?,
10312 row.get(3)?,
10313 row.get(4)?,
10314 ))
10315 },
10316 )
10317 .unwrap();
10318 assert_eq!(
10319 stored,
10320 (
10321 "mixed".to_string(),
10322 "google".to_string(),
10323 "conversation_event".to_string(),
10324 "event-7".to_string(),
10325 "mirror".to_string(),
10326 )
10327 );
10328 let parts = db
10329 .conn
10330 .prepare(
10331 "select ordinal, start_char, end_char, authorship, origin
10332 from message_content_parts order by ordinal",
10333 )
10334 .unwrap()
10335 .query_map([], |row| {
10336 Ok((
10337 row.get::<_, i64>(0)?,
10338 row.get::<_, i64>(1)?,
10339 row.get::<_, i64>(2)?,
10340 row.get::<_, String>(3)?,
10341 row.get::<_, String>(4)?,
10342 ))
10343 })
10344 .unwrap()
10345 .collect::<rusqlite::Result<Vec<_>>>()
10346 .unwrap();
10347 assert_eq!(
10348 parts,
10349 vec![
10350 (0, 0, 3, "human".to_string(), "direct_input".to_string()),
10351 (
10352 1,
10353 3,
10354 5,
10355 "generated".to_string(),
10356 "quoted_content".to_string()
10357 ),
10358 ]
10359 );
10360 let part_columns = db
10361 .conn
10362 .prepare("pragma table_info(message_content_parts)")
10363 .unwrap()
10364 .query_map([], |row| row.get::<_, String>(1))
10365 .unwrap()
10366 .collect::<rusqlite::Result<Vec<_>>>()
10367 .unwrap();
10368 assert!(!part_columns.iter().any(|column| column == "content"));
10369 }
10370
10371 #[test]
10372 fn invalid_message_provenance_rolls_back_the_whole_session_upsert() {
10373 use crate::models::{MessageAuthorship, MessageProvenance};
10374
10375 let dir = tempfile::tempdir().unwrap();
10376 let db = Db::open(&dir.path().join("index.db")).unwrap();
10377 let mut parsed = crate::util::minimal_record(
10378 Provider::Claude,
10379 Path::new("/fixture/invalid.jsonl"),
10380 String::new(),
10381 );
10382 parsed.session.id = "claude:invalid-provenance".to_string();
10383 parsed.session.provider_session_id = "invalid-provenance".to_string();
10384 parsed.messages = vec![crate::models::Message {
10385 seq: 0,
10386 role: Role::User,
10387 ts: None,
10388 tool_name: None,
10389 kind: crate::models::MessageKind::Conversation,
10390 tool_call_id: None,
10391 is_compaction: false,
10392 content: "text".to_string(),
10393 provenance: MessageProvenance {
10394 authorship: MessageAuthorship::Mixed,
10395 ..Default::default()
10396 },
10397 }];
10398
10399 let error = db.upsert_session(&parsed, 0, 0).unwrap_err().to_string();
10400 assert!(error.contains("message sequence 0"), "{error}");
10401 assert!(error.contains("claude:invalid-provenance"), "{error}");
10402 assert!(error.contains("requires content_parts"), "{error}");
10403 assert_eq!(
10404 db.conn
10405 .query_row(
10406 "select count(*) from sessions where id = ?1",
10407 [&parsed.session.id],
10408 |row| row.get::<_, i64>(0),
10409 )
10410 .unwrap(),
10411 0,
10412 "the transaction must not publish a session whose message provenance is invalid"
10413 );
10414 }
10415
10416 #[test]
10417 fn replacing_a_mixed_message_cascades_its_part_ranges() {
10418 use crate::models::{
10419 ContentPartAuthorship, ContentPartOrigin, MessageAuthorship, MessageContentPart,
10420 MessageProvenance,
10421 };
10422
10423 let dir = tempfile::tempdir().unwrap();
10424 let db = Db::open(&dir.path().join("index.db")).unwrap();
10425 let mut parsed = parsed_with_messages("claude:parts-cascade", &["ab"]);
10426 parsed.messages[0].provenance = MessageProvenance {
10427 authorship: MessageAuthorship::Mixed,
10428 content_parts: vec![
10429 MessageContentPart {
10430 ordinal: 0,
10431 start_char: 0,
10432 end_char: 1,
10433 authorship: ContentPartAuthorship::Human,
10434 origin: ContentPartOrigin::DirectInput,
10435 },
10436 MessageContentPart {
10437 ordinal: 1,
10438 start_char: 1,
10439 end_char: 2,
10440 authorship: ContentPartAuthorship::Generated,
10441 origin: ContentPartOrigin::QuotedContent,
10442 },
10443 ],
10444 ..Default::default()
10445 };
10446 db.upsert_session(&parsed, 0, 0).unwrap();
10447 assert_eq!(
10448 db.conn
10449 .query_row("select count(*) from message_content_parts", [], |row| {
10450 row.get::<_, i64>(0)
10451 })
10452 .unwrap(),
10453 2
10454 );
10455
10456 parsed.messages[0].provenance = MessageProvenance {
10457 authorship: MessageAuthorship::Human,
10458 ..Default::default()
10459 };
10460 db.replace_session(&parsed, 1, 1).unwrap();
10461 assert_eq!(
10462 db.conn
10463 .query_row("select count(*) from message_content_parts", [], |row| {
10464 row.get::<_, i64>(0)
10465 })
10466 .unwrap(),
10467 0
10468 );
10469 }
10470
10471 #[test]
10472 fn provenance_queries_use_session_and_behavioral_analysis_indexes() {
10473 let dir = tempfile::tempdir().unwrap();
10474 let db = Db::open(&dir.path().join("index.db")).unwrap();
10475 for (sql, expected_index) in [
10476 (
10477 "select seq from messages
10478 where session_id = 'claude:s' and authorship = 'human'
10479 and record_relation = 'original'
10480 order by seq",
10481 "idx_messages_session_authorship_relation_seq",
10482 ),
10483 (
10484 "select session_id, seq from messages
10485 where authorship = 'human' and record_relation = 'original'
10486 order by ts desc, session_id, seq",
10487 "idx_messages_authorship_relation_ts",
10488 ),
10489 ] {
10490 let plan = db
10491 .conn
10492 .prepare(&format!("explain query plan {sql}"))
10493 .unwrap()
10494 .query_map([], |row| row.get::<_, String>(3))
10495 .unwrap()
10496 .collect::<rusqlite::Result<Vec<_>>>()
10497 .unwrap()
10498 .join("\n");
10499 assert!(plan.contains(expected_index), "{plan}");
10500 assert!(!plan.contains("SCAN messages"), "{plan}");
10501 }
10502
10503 for sql in [
10504 ANALYSIS_MESSAGE_COUNTS_SQL,
10505 ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL,
10506 ] {
10507 let plan = db
10508 .conn
10509 .prepare(&format!("explain query plan {sql}"))
10510 .unwrap()
10511 .query_map(["claude:s"], |row| row.get::<_, String>(3))
10512 .unwrap()
10513 .collect::<rusqlite::Result<Vec<_>>>()
10514 .unwrap()
10515 .join("\n");
10516 assert!(
10517 plan.contains("idx_messages_session_authorship_relation_seq"),
10518 "analysis must select candidate messages through the composite provenance index:\n{plan}"
10519 );
10520 assert!(
10521 !plan.contains("SCAN m"),
10522 "analysis must not full-scan messages for one session:\n{plan}"
10523 );
10524 }
10525 }
10526
10527 #[test]
10528 fn correction_candidate_and_part_queries_use_provenance_indexes() {
10529 let dir = tempfile::tempdir().unwrap();
10530 let db = Db::open(&dir.path().join("index.db")).unwrap();
10531 let candidates = format!(
10532 "explain query plan {CORRECTION_CANDIDATES_SQL}
10533 order by m.ts desc, m.session_id asc, m.seq asc"
10534 );
10535 let candidate_plan = db
10536 .conn
10537 .prepare(&candidates)
10538 .unwrap()
10539 .query_map([], |row| row.get::<_, String>(3))
10540 .unwrap()
10541 .collect::<rusqlite::Result<Vec<_>>>()
10542 .unwrap()
10543 .join("\n");
10544 assert!(
10545 candidate_plan.contains("idx_messages_authorship_relation_ts"),
10546 "{candidate_plan}"
10547 );
10548 assert!(!candidate_plan.contains("SCAN m"), "{candidate_plan}");
10549
10550 let parts = format!(
10551 "explain query plan {CORRECTION_HUMAN_PARTS_SQL}
10552 order by m.id, p.ordinal"
10553 );
10554 let parts_plan = db
10555 .conn
10556 .prepare(&parts)
10557 .unwrap()
10558 .query_map([], |row| row.get::<_, String>(3))
10559 .unwrap()
10560 .collect::<rusqlite::Result<Vec<_>>>()
10561 .unwrap()
10562 .join("\n");
10563 assert!(
10564 parts_plan.contains("idx_messages_authorship_relation_ts"),
10565 "{parts_plan}"
10566 );
10567 assert!(
10568 parts_plan.contains("SEARCH p USING PRIMARY KEY"),
10569 "{parts_plan}"
10570 );
10571 assert!(!parts_plan.contains("SCAN m"), "{parts_plan}");
10572 assert!(!parts_plan.contains("SCAN p"), "{parts_plan}");
10573 }
10574
10575 #[test]
10576 fn tool_argument_search_uses_explicit_json_pointer_and_excludes_results() {
10577 let dir = tempfile::tempdir().unwrap();
10578 let db = Db::open(&dir.path().join("index.db")).unwrap();
10579 seed_messages(&db, &[("tool", "call"), ("tool", "cargo test output")]);
10580 db.conn
10581 .execute_batch(
10582 r#"update messages set
10583 kind = 'tool_call', tool_name = 'exec_command',
10584 content = '{"args":{"cmd":"cargo test","request":{"path":"src/lib.rs"}},"kind":"tool_call","tool_name":"exec_command"}'
10585 where seq = 0;
10586 update messages set kind = 'tool_result', tool_name = 'exec_command'
10587 where seq = 1;"#,
10588 )
10589 .unwrap();
10590
10591 let hits = db
10592 .search_messages(
10593 "cargo test",
10594 &MessageFilters {
10595 field: Some(SearchField::ToolArgument),
10596 argument_path: Some("/cmd".to_string()),
10597 ..Default::default()
10598 },
10599 )
10600 .unwrap();
10601 assert_eq!(hits.len(), 1);
10602 assert_eq!(hits[0].seq, 0);
10603
10604 let nested = db
10605 .search_messages(
10606 "src/lib.rs",
10607 &MessageFilters {
10608 field: Some(SearchField::ToolArgument),
10609 argument_path: Some("/request/path".to_string()),
10610 ..Default::default()
10611 },
10612 )
10613 .unwrap();
10614 assert_eq!(nested.len(), 1);
10615
10616 db.conn
10617 .execute(
10618 "insert into messages (
10619 session_id, provider, seq, role, kind, content
10620 ) values (
10621 'claude:s1', 'claude', 2, 'user', 'conversation',
10622 '{\"args\":{\"cmd\":\"cargo test\"}}'
10623 )",
10624 [],
10625 )
10626 .unwrap();
10627 for (query, match_mode) in [
10628 ("", MessageSearchMode::Literal),
10629 ("cargo test", MessageSearchMode::Literal),
10630 (r"cargo t.st", MessageSearchMode::Regex),
10631 ("crgo tst", MessageSearchMode::Fuzzy),
10632 ] {
10633 let hits = db
10634 .search_messages(
10635 query,
10636 &MessageFilters {
10637 kinds: Some(vec![
10638 crate::models::MessageKind::Conversation,
10639 crate::models::MessageKind::ToolCall,
10640 ]),
10641 field: Some(SearchField::ToolArgument),
10642 argument_path: Some("/cmd".to_string()),
10643 match_mode,
10644 limit: 10,
10645 ..Default::default()
10646 },
10647 )
10648 .unwrap();
10649 assert_eq!(
10650 hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
10651 vec![0],
10652 "tool-argument {match_mode:?} search must intersect an explicit kind union with tool_call"
10653 );
10654 }
10655
10656 db.conn
10657 .execute_batch(
10658 r#"with recursive n(value) as (
10659 values(1) union all select value + 1 from n where value < 100
10660 )
10661 insert into messages (
10662 session_id, provider, seq, role, tool_name, kind, content
10663 )
10664 select 'claude:s1', 'claude', 10 + value, 'tool', 'exec_command',
10665 'tool_call',
10666 printf('{"args":{"cmd":"unrelated payload %d"}}', value)
10667 from n;"#,
10668 )
10669 .unwrap();
10670 for (query, match_mode) in [
10671 ("cargo test", MessageSearchMode::Literal),
10672 (r"cargo t.st", MessageSearchMode::Regex),
10673 ] {
10674 let filters = MessageFilters {
10675 field: Some(SearchField::ToolArgument),
10676 argument_path: Some("/cmd".to_string()),
10677 match_mode,
10678 ..Default::default()
10679 };
10680 let (hits, explain) = db
10681 .search_messages_with_explain(query, &filters, true)
10682 .unwrap();
10683 assert_eq!(hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
10684 let explain = explain.unwrap();
10685 assert!(
10686 explain.prefilter.is_some(),
10687 "tool-argument {match_mode:?} search should prefilter raw tool-call JSON: {explain:?}"
10688 );
10689 assert!(
10696 explain.corpus >= 101,
10697 "corpus is the structural tool-call denominator: {explain:?}"
10698 );
10699 let candidates = explain.candidates.unwrap();
10700 assert!(
10701 candidates >= hits.len() as i64 && candidates <= explain.corpus,
10702 "candidates are the prefilter superset of the hits: {explain:?}"
10703 );
10704 let summary = explain.summary(true);
10705 assert!(
10706 !summary.contains("low selectivity"),
10707 "one hit in a hundred is not low selectivity: {summary}"
10708 );
10709 }
10710
10711 db.conn
10712 .execute(
10713 "update messages set content = ?1 where seq = 0",
10714 [crate::util::tool_call_message_content(
10715 "exec_command",
10716 serde_json::json!({"cmd": "quoted \"target\" and slash \\ value"}),
10717 )],
10718 )
10719 .unwrap();
10720 for (query, expected, expect_prefilter) in [
10721 ("quoted \"target\"", vec![0], true),
10722 (r#"quoted \"target\""#, Vec::new(), true),
10723 ("slash \\ value", vec![0], true),
10724 ("d\\ ", Vec::new(), false),
10725 ] {
10726 let filters = MessageFilters {
10727 field: Some(SearchField::ToolArgument),
10728 argument_path: Some("/cmd".to_string()),
10729 ..Default::default()
10730 };
10731 let (hits, explain) = db
10732 .search_messages_with_explain(query, &filters, true)
10733 .unwrap();
10734 assert_eq!(
10735 hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
10736 expected,
10737 "JSON escaping must not create a trigram false negative for {query:?}"
10738 );
10739 assert_eq!(
10740 explain.unwrap().prefilter.is_some(),
10741 expect_prefilter,
10742 "only JSON-stable required trigrams may prefilter {query:?}"
10743 );
10744 }
10745
10746 db.conn
10747 .execute(
10748 "update messages set content = ?1 where seq = 0",
10749 [crate::util::tool_call_message_content(
10750 "exec_command",
10751 serde_json::json!({"cmd": "cargo test"}),
10752 )],
10753 )
10754 .unwrap();
10755
10756 db.conn
10759 .execute(
10760 "insert into messages (
10761 session_id, provider, seq, role, tool_name, kind, content
10762 ) values ('claude:s1', 'claude', 500, 'tool', 'exec_command', 'tool_call', ?1)",
10763 [crate::util::tool_call_message_content(
10764 "exec_command",
10765 serde_json::json!({"cmd": "incremental trigram target"}),
10766 )],
10767 )
10768 .unwrap();
10769 let incremental_filters = MessageFilters {
10770 field: Some(SearchField::ToolArgument),
10771 argument_path: Some("/cmd".to_string()),
10772 ..Default::default()
10773 };
10774 let find_incremental = |query: &str| {
10775 db.search_messages(query, &incremental_filters)
10776 .unwrap()
10777 .into_iter()
10778 .map(|hit| hit.seq)
10779 .collect::<Vec<_>>()
10780 };
10781 assert_eq!(find_incremental("incremental trigram target"), vec![500]);
10782 db.conn
10783 .execute(
10784 "update messages set content = ?1 where seq = 500",
10785 [crate::util::tool_call_message_content(
10786 "exec_command",
10787 serde_json::json!({"cmd": "replacement trigram target"}),
10788 )],
10789 )
10790 .unwrap();
10791 assert!(find_incremental("incremental trigram target").is_empty());
10792 assert_eq!(find_incremental("replacement trigram target"), vec![500]);
10793 db.conn
10794 .execute("delete from messages where seq = 500", [])
10795 .unwrap();
10796 assert!(find_incremental("replacement trigram target").is_empty());
10797
10798 let fuzzy_filters = MessageFilters {
10799 field: Some(SearchField::ToolArgument),
10800 argument_path: Some("/cmd".to_string()),
10801 match_mode: MessageSearchMode::Fuzzy,
10802 limit: 5,
10803 ..Default::default()
10804 };
10805 let (fuzzy, explain) = db
10806 .search_messages_with_explain("crgo tst", &fuzzy_filters, true)
10807 .unwrap();
10808 assert_eq!(fuzzy.len(), 1);
10809 assert_eq!(fuzzy[0].seq, 0);
10810 let explain = explain.unwrap();
10811 assert_eq!(
10812 explain.prefilter_skipped.as_deref(),
10813 Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
10814 );
10815 assert!(
10816 explain.candidates.unwrap() < explain.corpus,
10817 "only matching tool-argument projections should count as fuzzy candidates: {explain:?}"
10818 );
10819
10820 let plan = db
10821 .conn
10822 .prepare(
10823 "explain query plan select session_id, seq from messages
10824 where kind = 'tool_call' order by session_id, seq",
10825 )
10826 .unwrap()
10827 .query_map([], |row| row.get::<_, String>(3))
10828 .unwrap()
10829 .collect::<rusqlite::Result<Vec<_>>>()
10830 .unwrap()
10831 .join("\n");
10832 assert!(plan.contains("idx_messages_tool_calls"), "{plan}");
10833
10834 let error = db
10835 .search_messages(
10836 "cargo",
10837 &MessageFilters {
10838 field: Some(SearchField::ToolArgument),
10839 argument_path: Some("cmd".to_string()),
10840 ..Default::default()
10841 },
10842 )
10843 .unwrap_err()
10844 .to_string();
10845 assert!(error.contains("RFC 6901"));
10846 }
10847
10848 #[test]
10849 fn tool_argument_trigram_prefilter_preserves_direct_scan_order_and_pages() {
10850 let dir = tempfile::tempdir().unwrap();
10851 let db = Db::open(&dir.path().join("index.db")).unwrap();
10852 seed_messages(&db, &[("tool", "placeholder")]);
10853 db.conn
10854 .execute_batch(
10855 r#"update messages set kind = 'tool_call', tool_name = 'exec_command',
10856 content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
10857 where seq = 0;
10858 with recursive n(value) as (
10859 values(1) union all select value + 1 from n where value < 250
10860 )
10861 insert into messages (
10862 session_id, provider, seq, role, tool_name, kind, content
10863 )
10864 select 'claude:s1', 'claude', value, 'tool', 'exec_command', 'tool_call',
10865 case when value in (70, 140, 210)
10866 then printf('{"args":{"cmd":"cargo test target %d"},"kind":"tool_call","tool_name":"exec_command"}', value)
10867 else printf('{"args":{"cmd":"unrelated payload %d"},"kind":"tool_call","tool_name":"exec_command"}', value)
10868 end
10869 from n;"#,
10870 )
10871 .unwrap();
10872
10873 for (query, mode) in [
10874 ("cargo test", MessageSearchMode::Literal),
10875 (r"cargo t.st", MessageSearchMode::Regex),
10876 ] {
10877 for (limit, offset) in [(0, 0), (2, 0), (2, 1), (1, 3)] {
10878 let filters = MessageFilters {
10879 field: Some(SearchField::ToolArgument),
10880 argument_path: Some("/cmd".to_string()),
10881 match_mode: mode,
10882 limit,
10883 offset,
10884 ..Default::default()
10885 };
10886 let accelerated = db.search_messages(query, &filters).unwrap();
10887 let predicate = match mode {
10888 MessageSearchMode::Literal => {
10889 "unicode_lower_contains(rust_json_pointer('/cmd', m.content), ?1)"
10890 }
10891 MessageSearchMode::Regex => {
10892 "rust_regexp(?1, rust_json_pointer('/cmd', m.content))"
10893 }
10894 MessageSearchMode::Fuzzy => unreachable!(),
10895 };
10896 let mut sql = format!(
10897 "select m.session_id, m.seq from messages m \
10898 where m.kind = 'tool_call' and {predicate} \
10899 order by m.session_id, m.seq"
10900 );
10901 if limit > 0 {
10902 sql.push_str(" limit ?2 offset ?3");
10903 } else if offset > 0 {
10904 sql.push_str(" limit -1 offset ?3");
10905 }
10906 let query_arg = if mode == MessageSearchMode::Literal {
10911 fold_caseless(query)
10912 } else {
10913 query.to_string()
10914 };
10915 let mut statement = db.conn.prepare(&sql).unwrap();
10916 let direct = if limit > 0 {
10917 statement
10918 .query_map(params![query_arg, limit as i64, offset as i64], |row| {
10919 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10920 })
10921 .unwrap()
10922 .collect::<rusqlite::Result<Vec<_>>>()
10923 .unwrap()
10924 } else if offset > 0 {
10925 statement
10926 .query_map(params![query_arg, limit as i64, offset as i64], |row| {
10927 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10928 })
10929 .unwrap()
10930 .collect::<rusqlite::Result<Vec<_>>>()
10931 .unwrap()
10932 } else {
10933 statement
10934 .query_map([query_arg], |row| {
10935 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10936 })
10937 .unwrap()
10938 .collect::<rusqlite::Result<Vec<_>>>()
10939 .unwrap()
10940 };
10941 assert_eq!(
10942 hit_keys(accelerated),
10943 direct,
10944 "accelerated {mode:?} page limit={limit} offset={offset} must equal the independent direct projection scan"
10945 );
10946 }
10947 }
10948 }
10949
10950 #[test]
10959 #[ignore = "deterministic release-mode performance measurement"]
10960 fn tool_argument_trigram_prefilter_benchmark() {
10961 let dir = tempfile::tempdir().unwrap();
10962 let db = Db::open(&dir.path().join("index.db")).unwrap();
10963 seed_messages(&db, &[("tool", "placeholder")]);
10964 let tx = db.conn.unchecked_transaction().unwrap();
10965 {
10966 let mut insert = tx
10967 .prepare(
10968 "insert into messages (session_id, provider, seq, role, tool_name, kind, content) \
10969 values ('claude:s1', 'claude', ?1, 'tool', 'exec_command', 'tool_call', ?2)",
10970 )
10971 .unwrap();
10972 for seq in 1..=25_000_i64 {
10973 let cmd = if seq % 5_000 == 0 {
10974 format!("selective trigram benchmark target {seq}")
10975 } else {
10976 format!("ordinary deterministic payload {seq} with JSON parsing padding")
10977 };
10978 insert
10979 .execute(params![
10980 seq,
10981 crate::util::tool_call_message_content(
10982 "exec_command",
10983 serde_json::json!({"cmd": cmd})
10984 )
10985 ])
10986 .unwrap();
10987 }
10988 }
10989 tx.commit().unwrap();
10990 let filters = MessageFilters {
10991 field: Some(SearchField::ToolArgument),
10992 argument_path: Some("/cmd".to_string()),
10993 ..Default::default()
10994 };
10995 let query = "selective trigram benchmark target";
10996 let accelerated = || db.search_messages(query, &filters).unwrap();
10997 let direct = || {
10998 let mut statement = db
10999 .conn
11000 .prepare(
11001 "select m.session_id, m.seq from messages m \
11002 where m.kind = 'tool_call' and \
11003 unicode_lower_contains(rust_json_pointer('/cmd', m.content), ?1) \
11004 order by m.session_id, m.seq",
11005 )
11006 .unwrap();
11007 statement
11008 .query_map([fold_caseless(query)], |row| {
11011 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11012 })
11013 .unwrap()
11014 .collect::<rusqlite::Result<Vec<_>>>()
11015 .unwrap()
11016 };
11017 let accelerated_keys = hit_keys(accelerated());
11018 let direct_keys = direct();
11019 assert_eq!(accelerated_keys, direct_keys);
11020 let digest = crate::hashing::sha256(&serde_json::to_vec(&direct_keys).unwrap());
11021 let mut accelerated_us = Vec::new();
11022 let mut direct_us = Vec::new();
11023 for _ in 0..7 {
11024 let started = Instant::now();
11025 assert_eq!(hit_keys(accelerated()), direct_keys);
11026 accelerated_us.push(started.elapsed().as_micros());
11027 let started = Instant::now();
11028 assert_eq!(direct(), direct_keys);
11029 direct_us.push(started.elapsed().as_micros());
11030 }
11031 accelerated_us.sort_unstable();
11032 direct_us.sort_unstable();
11033 let (_, explain) = db
11034 .search_messages_with_explain(query, &filters, true)
11035 .unwrap();
11036 let explain = explain.unwrap();
11037 eprintln!(
11038 "tool_argument_prefilter_benchmark rows={} candidates={} hits={} accelerated_median_us={} accelerated_p95_us={} direct_median_us={} direct_p95_us={} digest=sha256:{}",
11039 explain.corpus,
11040 explain.candidates.unwrap(),
11041 direct_keys.len(),
11042 accelerated_us[accelerated_us.len() / 2],
11043 accelerated_us[accelerated_us.len() - 1],
11044 direct_us[direct_us.len() / 2],
11045 direct_us[direct_us.len() - 1],
11046 digest
11047 );
11048 }
11049
11050 #[test]
11051 fn fuzzy_tool_argument_search_uses_workers_without_changing_scores_or_order() {
11052 let root = tempfile::tempdir().unwrap();
11053 let seed = |db: &Db| {
11054 seed_messages(db, &[("tool", "placeholder")]);
11055 let tx = db.conn.unchecked_transaction().unwrap();
11056 {
11057 let mut insert = tx
11058 .prepare(
11059 "insert into messages (
11060 session_id, provider, seq, role, tool_name, kind, content
11061 ) values ('claude:s1', 'claude', ?1, 'tool', 'exec_command',
11062 'tool_call', ?2)",
11063 )
11064 .unwrap();
11065 for seq in 1..=1_100_i64 {
11066 let cmd = if seq % 137 == 0 {
11067 format!("cargo test workspace target {seq}")
11068 } else {
11069 format!("ordinary tool argument payload {seq}")
11070 };
11071 insert
11072 .execute(params![
11073 seq,
11074 crate::util::tool_call_message_content(
11075 "exec_command",
11076 serde_json::json!({"cmd": cmd})
11077 )
11078 ])
11079 .unwrap();
11080 }
11081 }
11082 tx.commit().unwrap();
11083 };
11084 let one = Db::open_with_threads(
11085 &root.path().join("one.db"),
11086 TEST_BUSY_TIMEOUT_MS,
11087 NonZeroUsize::new(1).unwrap(),
11088 )
11089 .unwrap();
11090 let four = Db::open_with_threads(
11091 &root.path().join("four.db"),
11092 TEST_BUSY_TIMEOUT_MS,
11093 NonZeroUsize::new(4).unwrap(),
11094 )
11095 .unwrap();
11096 seed(&one);
11097 seed(&four);
11098 let filters = MessageFilters {
11099 field: Some(SearchField::ToolArgument),
11100 argument_path: Some("/cmd".to_string()),
11101 match_mode: MessageSearchMode::Fuzzy,
11102 limit: 20,
11103 ..Default::default()
11104 };
11105 let identity = |hits: Vec<MessageHit>| {
11106 hits.into_iter()
11107 .map(|hit| (hit.session_id, hit.seq, hit.fuzzy_score))
11108 .collect::<Vec<_>>()
11109 };
11110 assert_eq!(one.worker_pool_build_count(), 0);
11111 assert_eq!(four.worker_pool_build_count(), 0);
11112 let expected = identity(one.search_messages("crgo tst wrkspce", &filters).unwrap());
11113 let actual = identity(four.search_messages("crgo tst wrkspce", &filters).unwrap());
11114 assert_eq!(actual, expected);
11115 assert_eq!(one.worker_pool_build_count(), 1);
11116 assert_eq!(four.worker_pool_build_count(), 1);
11117 }
11118
11119 #[test]
11120 #[ignore = "deterministic release-mode derived fuzzy performance measurement"]
11121 fn fuzzy_tool_argument_parallel_scoring_benchmark() {
11122 let root = tempfile::tempdir().unwrap();
11123 let path = root.path().join("index.db");
11124 {
11125 let db =
11126 Db::open_with_threads(&path, TEST_BUSY_TIMEOUT_MS, NonZeroUsize::new(1).unwrap())
11127 .unwrap();
11128 seed_messages(&db, &[("tool", "placeholder")]);
11129 let tx = db.conn.unchecked_transaction().unwrap();
11130 {
11131 let mut insert = tx
11132 .prepare(
11133 "insert into messages (
11134 session_id, provider, seq, role, tool_name, kind, content
11135 ) values ('claude:s1', 'claude', ?1, 'tool', 'exec_command',
11136 'tool_call', ?2)",
11137 )
11138 .unwrap();
11139 for seq in 1..=30_000_i64 {
11140 let cmd = if seq % 3_000 == 0 {
11141 format!("cargo test workspace target {seq}")
11142 } else {
11143 format!("ordinary deterministic tool argument payload {seq}")
11144 };
11145 insert
11146 .execute(params![
11147 seq,
11148 crate::util::tool_call_message_content(
11149 "exec_command",
11150 serde_json::json!({"cmd": cmd})
11151 )
11152 ])
11153 .unwrap();
11154 }
11155 }
11156 tx.commit().unwrap();
11157 }
11158 let filters = MessageFilters {
11159 field: Some(SearchField::ToolArgument),
11160 argument_path: Some("/cmd".to_string()),
11161 match_mode: MessageSearchMode::Fuzzy,
11162 limit: 50,
11163 ..Default::default()
11164 };
11165 let measure = |threads| {
11166 let db = Db::open_with_threads(
11167 &path,
11168 TEST_BUSY_TIMEOUT_MS,
11169 NonZeroUsize::new(threads).unwrap(),
11170 )
11171 .unwrap();
11172 let search = || db.search_messages("crgo tst wrkspce", &filters).unwrap();
11173 let expected = serde_json::to_vec(&search()).unwrap();
11174 let digest = crate::hashing::sha256(&expected);
11175 let mut elapsed = Vec::new();
11176 for _ in 0..7 {
11177 let started = Instant::now();
11178 assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
11179 elapsed.push(started.elapsed().as_micros());
11180 }
11181 elapsed.sort_unstable();
11182 (
11183 elapsed[elapsed.len() / 2],
11184 elapsed[elapsed.len() - 1],
11185 digest,
11186 )
11187 };
11188 let one = measure(1);
11189 let four = measure(4);
11190 assert_eq!(one.2, four.2);
11191 eprintln!(
11192 "tool_argument_fuzzy_parallel_benchmark rows=30000 one_worker_median_us={} one_worker_p95_us={} four_worker_median_us={} four_worker_p95_us={} digest=sha256:{}",
11193 one.0, one.1, four.0, four.1, one.2
11194 );
11195 }
11196
11197 #[test]
11198 fn message_search_three_modes_by_three_fields_share_one_result_contract() {
11199 let dir = tempfile::tempdir().unwrap();
11200 let db = Db::open(&dir.path().join("index.db")).unwrap();
11201 seed_messages(
11202 &db,
11203 &[
11204 ("tool", "placeholder"),
11205 ("tool", "distractor"),
11206 ("tool", "second target"),
11207 ],
11208 );
11209 db.conn
11210 .execute_batch(
11211 r#"update messages set
11212 kind = 'tool_call', tool_name = 'exec_command',
11213 content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
11214 where seq = 0;
11215 update messages set
11216 kind = 'tool_call', tool_name = 'read_file',
11217 content = '{"args":{"cmd":"open notes.md"},"kind":"tool_call","tool_name":"read_file"}'
11218 where seq = 1;
11219 update messages set
11220 kind = 'tool_call', tool_name = 'exec_command',
11221 content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
11222 where seq = 2;"#,
11223 )
11224 .unwrap();
11225
11226 let cases = [
11227 (
11228 SearchField::Content,
11229 MessageSearchMode::Literal,
11230 "cargo test",
11231 ),
11232 (
11233 SearchField::Content,
11234 MessageSearchMode::Regex,
11235 r"cargo\s+test",
11236 ),
11237 (SearchField::Content, MessageSearchMode::Fuzzy, "crgo tst"),
11238 (SearchField::ToolName, MessageSearchMode::Literal, "exec"),
11239 (SearchField::ToolName, MessageSearchMode::Regex, r"^exec_"),
11240 (SearchField::ToolName, MessageSearchMode::Fuzzy, "excmd"),
11241 (
11242 SearchField::ToolArgument,
11243 MessageSearchMode::Literal,
11244 "cargo test",
11245 ),
11246 (
11247 SearchField::ToolArgument,
11248 MessageSearchMode::Regex,
11249 r"cargo\s+test",
11250 ),
11251 (
11252 SearchField::ToolArgument,
11253 MessageSearchMode::Fuzzy,
11254 "crgo tst",
11255 ),
11256 ];
11257
11258 for (field, match_mode, query) in cases {
11259 let (hits, explain) = db
11260 .search_messages_with_explain(
11261 query,
11262 &MessageFilters {
11263 kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11264 field: Some(field),
11265 argument_path: (field == SearchField::ToolArgument)
11266 .then(|| "/cmd".to_string()),
11267 match_mode,
11268 limit: 10,
11269 ..Default::default()
11270 },
11271 true,
11272 )
11273 .unwrap_or_else(|error| panic!("{field:?}/{match_mode:?}: {error:#}"));
11274 if match_mode == MessageSearchMode::Fuzzy {
11275 assert_eq!(
11276 hits[0].fuzzy_score, hits[1].fuzzy_score,
11277 "identical projected values must exercise the stable identity tie-break"
11278 );
11279 }
11280 assert_eq!(
11281 hit_keys(hits),
11282 vec![("claude:s1".into(), 0), ("claude:s1".into(), 2)],
11283 "{field:?}/{match_mode:?}"
11284 );
11285 assert!(explain.is_some(), "{field:?}/{match_mode:?}");
11286 let page = db
11287 .search_messages(
11288 query,
11289 &MessageFilters {
11290 kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11291 field: Some(field),
11292 argument_path: (field == SearchField::ToolArgument)
11293 .then(|| "/cmd".to_string()),
11294 match_mode,
11295 limit: 1,
11296 offset: 1,
11297 ..Default::default()
11298 },
11299 )
11300 .unwrap_or_else(|error| panic!("paged {field:?}/{match_mode:?}: {error:#}"));
11301 assert_eq!(
11302 hit_keys(page),
11303 vec![("claude:s1".into(), 2)],
11304 "paged {field:?}/{match_mode:?}"
11305 );
11306 }
11307 }
11308
11309 #[test]
11310 fn non_fuzzy_message_batches_match_materialized_search_across_fields_and_offsets() {
11311 let dir = tempfile::tempdir().unwrap();
11312 let db = Db::open(&dir.path().join("index.db")).unwrap();
11313 seed_messages(
11314 &db,
11315 &[
11316 ("user", "alpha needle"),
11317 ("tool", "placeholder"),
11318 ("user", "omega needle"),
11319 ],
11320 );
11321 db.conn
11322 .execute(
11323 "update messages
11324 set kind = 'tool_call',
11325 tool_name = 'exec_command',
11326 content = '{\"args\":{\"cmd\":\"cargo test needle\"}}'
11327 where seq = 1",
11328 [],
11329 )
11330 .unwrap();
11331
11332 for (field, match_mode, query, argument_path) in [
11333 (
11334 SearchField::Content,
11335 MessageSearchMode::Literal,
11336 "needle",
11337 None,
11338 ),
11339 (
11340 SearchField::Content,
11341 MessageSearchMode::Regex,
11342 "n[e]+dle",
11343 None,
11344 ),
11345 (SearchField::Content, MessageSearchMode::Literal, "", None),
11346 (
11347 SearchField::ToolName,
11348 MessageSearchMode::Literal,
11349 "exec",
11350 None,
11351 ),
11352 (
11353 SearchField::ToolArgument,
11354 MessageSearchMode::Literal,
11355 "cargo test",
11356 Some("/cmd".to_string()),
11357 ),
11358 ] {
11359 let filters = MessageFilters {
11360 field: Some(field),
11361 argument_path,
11362 match_mode,
11363 limit: 0,
11364 offset: 1,
11365 ..Default::default()
11366 };
11367 let materialized = db
11368 .search_messages_ordered(query, &filters, MessageOrder::OldestFirst)
11369 .unwrap();
11370 for batch_size in [1, 2, 17] {
11371 let mut batched = Vec::new();
11372 let outcome = db
11373 .visit_messages_in_batches(
11374 query,
11375 &filters,
11376 true,
11377 MessageOrder::OldestFirst,
11378 NonZeroUsize::new(batch_size).unwrap(),
11379 |batch| {
11380 assert!(batch.len() <= batch_size);
11381 batched.extend(batch);
11382 Ok(MessageBatchControl::Continue)
11383 },
11384 )
11385 .unwrap_or_else(|error| {
11386 panic!("{field:?}/{match_mode:?}/batch={batch_size}: {error:#}")
11387 });
11388 assert!(outcome.exhausted);
11389 assert_eq!(outcome.rows_visited, materialized.len());
11390 assert!(outcome.explain.is_some());
11391 assert_eq!(
11392 hit_keys(batched),
11393 hit_keys(materialized.clone()),
11394 "{field:?}/{match_mode:?}/batch={batch_size}"
11395 );
11396 }
11397 }
11398 }
11399
11400 #[test]
11401 fn newest_edge_batches_stream_the_post_offset_selection_in_chronological_order() {
11402 let dir = tempfile::tempdir().unwrap();
11403 let db = Db::open(&dir.path().join("index.db")).unwrap();
11404 seed_messages(
11405 &db,
11406 &[
11407 ("user", "needle zero"),
11408 ("user", "needle one"),
11409 ("user", "needle two"),
11410 ("user", "needle three"),
11411 ("user", "needle four"),
11412 ],
11413 );
11414 let filters = MessageFilters {
11415 session_id: Some("claude:s1".into()),
11416 limit: 0,
11417 offset: 2,
11418 ..Default::default()
11419 };
11420 let (mut materialized, materialized_explain) = db
11421 .search_messages_with_explain_order("needle", &filters, true, MessageOrder::NewestFirst)
11422 .unwrap();
11423 materialized.reverse();
11424
11425 for batch_size in [1, 2, 17] {
11426 let mut batched = Vec::new();
11427 let outcome = db
11428 .visit_messages_in_batches(
11429 "needle",
11430 &filters,
11431 true,
11432 MessageOrder::NewestFirst,
11433 NonZeroUsize::new(batch_size).unwrap(),
11434 |batch| {
11435 batched.extend(batch);
11436 Ok(MessageBatchControl::Continue)
11437 },
11438 )
11439 .unwrap();
11440 assert!(outcome.exhausted);
11441 assert_eq!(hit_keys(batched), hit_keys(materialized.clone()));
11442 assert_eq!(
11443 outcome.explain.as_ref().map(|explain| explain.corpus),
11444 materialized_explain.as_ref().map(|explain| explain.corpus)
11445 );
11446 assert_eq!(
11447 outcome
11448 .explain
11449 .as_ref()
11450 .and_then(|explain| explain.candidates),
11451 materialized_explain
11452 .as_ref()
11453 .and_then(|explain| explain.candidates)
11454 );
11455 }
11456 }
11457
11458 #[test]
11459 fn non_fuzzy_message_batches_hold_one_snapshot_and_stop_without_draining() {
11460 let dir = tempfile::tempdir().unwrap();
11461 let path = dir.path().join("index.db");
11462 let db = Db::open(&path).unwrap();
11463 let writer = Db::open(&path).unwrap();
11464 seed_messages(
11465 &db,
11466 &[
11467 ("user", "needle zero"),
11468 ("user", "needle one"),
11469 ("user", "needle two"),
11470 ],
11471 );
11472 let filters = MessageFilters {
11473 limit: 0,
11474 ..Default::default()
11475 };
11476 let mut seen = Vec::new();
11477 let snapshot = db
11478 .visit_messages_in_batches(
11479 "needle",
11480 &filters,
11481 false,
11482 MessageOrder::OldestFirst,
11483 NonZeroUsize::new(1).unwrap(),
11484 |batch| {
11485 seen.extend(batch.into_iter().map(|hit| hit.seq));
11486 if seen.len() == 1 {
11487 writer
11488 .conn
11489 .execute(
11490 "insert into messages (
11491 session_id, provider, seq, role, content
11492 ) values ('claude:s1', 'claude', 3, 'user', 'needle late')",
11493 [],
11494 )
11495 .unwrap();
11496 }
11497 Ok(MessageBatchControl::Continue)
11498 },
11499 )
11500 .unwrap();
11501 assert!(snapshot.exhausted);
11502 assert_eq!(seen, [0, 1, 2]);
11503 assert_eq!(db.search_messages("needle", &filters).unwrap().len(), 4);
11504
11505 let mut delivered = 0;
11506 let stopped = db
11507 .visit_messages_in_batches(
11508 "needle",
11509 &filters,
11510 false,
11511 MessageOrder::OldestFirst,
11512 NonZeroUsize::new(2).unwrap(),
11513 |batch| {
11514 delivered += batch.len();
11515 Ok(MessageBatchControl::Stop)
11516 },
11517 )
11518 .unwrap();
11519 assert_eq!(delivered, 2);
11520 assert_eq!(stopped.rows_visited, 2);
11521 assert!(!stopped.exhausted);
11522 assert!(stopped.explain.is_none());
11523 let checkpoint_busy: i64 = writer
11524 .conn
11525 .query_row("pragma wal_checkpoint(passive)", [], |row| row.get(0))
11526 .expect("early stop drops the read transaction without draining unread rows");
11527 assert_eq!(checkpoint_busy, 0);
11528 }
11529
11530 #[test]
11531 fn non_fuzzy_message_batches_finish_legacy_index_maintenance_before_the_snapshot() {
11532 let dir = tempfile::tempdir().unwrap();
11533 let db = Db::open(&dir.path().join("index.db")).unwrap();
11534 enable_v3_custom_trigram_compatibility(&db);
11535 seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
11536 let filters = MessageFilters {
11537 match_mode: MessageSearchMode::Regex,
11538 ..Default::default()
11539 };
11540 let mut seen = Vec::new();
11541
11542 let outcome = db
11543 .visit_messages_in_batches(
11544 "ECONNRESET",
11545 &filters,
11546 false,
11547 MessageOrder::OldestFirst,
11548 NonZeroUsize::new(1).unwrap(),
11549 |batch| {
11550 seen.extend(batch);
11551 Ok(MessageBatchControl::Continue)
11552 },
11553 )
11554 .unwrap();
11555
11556 assert!(outcome.exhausted);
11557 assert_eq!(seen.len(), 1);
11558 assert!(seen[0].content.contains("ECONNRESET"));
11559 assert!(
11560 crate::trigram_index::base_max_id(&db.conn).unwrap() > 0,
11561 "legacy trigram maintenance must finish before the read transaction starts"
11562 );
11563
11564 let error = db
11565 .visit_messages_in_batches(
11566 "ECONNRESET",
11567 &filters,
11568 true,
11569 MessageOrder::OldestFirst,
11570 NonZeroUsize::new(1).unwrap(),
11571 |_| Ok(MessageBatchControl::Continue),
11572 )
11573 .unwrap_err()
11574 .to_string();
11575 assert!(
11576 error.contains("planner counts and results share one snapshot"),
11577 "{error}"
11578 );
11579 }
11580
11581 #[test]
11582 fn message_search_validation_is_identical_for_all_three_fields() {
11583 let dir = tempfile::tempdir().unwrap();
11584 let db = Db::open(&dir.path().join("index.db")).unwrap();
11585 for field in [
11586 SearchField::Content,
11587 SearchField::ToolName,
11588 SearchField::ToolArgument,
11589 ] {
11590 let filters = |match_mode, limit, offset| MessageFilters {
11591 field: Some(field),
11592 argument_path: (field == SearchField::ToolArgument).then(|| "/cmd".into()),
11593 match_mode,
11594 limit,
11595 offset,
11596 ..Default::default()
11597 };
11598 let malformed = db
11599 .search_messages("[", &filters(MessageSearchMode::Regex, 10, 0))
11600 .unwrap_err()
11601 .to_string();
11602 assert!(
11603 malformed.contains("invalid regex"),
11604 "{field:?}: {malformed}"
11605 );
11606
11607 let short = db
11608 .search_messages("ab", &filters(MessageSearchMode::Fuzzy, 10, 0))
11609 .unwrap_err()
11610 .to_string();
11611 assert!(
11612 short.contains("at least 3 characters"),
11613 "{field:?}: {short}"
11614 );
11615
11616 let unlimited = db
11617 .search_messages("abc", &filters(MessageSearchMode::Fuzzy, 0, 0))
11618 .unwrap_err()
11619 .to_string();
11620 assert!(
11621 unlimited.contains("finite non-zero limit"),
11622 "{field:?}: {unlimited}"
11623 );
11624
11625 let large_offset =
11626 db.search_messages("abc", &filters(MessageSearchMode::Fuzzy, 2, 9_999));
11627 assert!(
11628 large_offset.is_ok(),
11629 "{field:?}: finite fuzzy offsets have no arbitrary result window: {large_offset:?}"
11630 );
11631 }
11632 }
11633
11634 #[test]
11635 fn tool_argument_search_handles_json_shapes_unicode_punctuation_and_large_values() {
11636 let dir = tempfile::tempdir().unwrap();
11637 let db = Db::open(&dir.path().join("index.db")).unwrap();
11638 seed_messages(
11639 &db,
11640 &[
11641 ("tool", "malformed"),
11642 ("tool", "missing args"),
11643 ("tool", "scalar args"),
11644 ("tool", "null"),
11645 ("tool", "array"),
11646 ("tool", "nested"),
11647 ("tool", "large"),
11648 ],
11649 );
11650 let large = format!("{} cargo test --workspace", "x".repeat(128 * 1024));
11651 let rows = [
11652 "{malformed".to_string(),
11653 r#"{"kind":"tool_call"}"#.to_string(),
11654 r#"{"args":"scalar","kind":"tool_call"}"#.to_string(),
11655 r#"{"args":{"cmd":null},"kind":"tool_call"}"#.to_string(),
11656 r#"{"args":{"cmd":["café","C++","--path"]},"kind":"tool_call"}"#.to_string(),
11657 r#"{"args":{"request":{"path":"src/quoted file.rs"}},"kind":"tool_call"}"#.to_string(),
11658 serde_json::json!({"args": {"cmd": large}, "kind": "tool_call"}).to_string(),
11659 ];
11660 for (seq, content) in rows.iter().enumerate() {
11661 db.conn
11662 .execute(
11663 "update messages set kind='tool_call', tool_name='ÄTool::C++', content=?1
11664 where seq=?2",
11665 params![content, seq as i64],
11666 )
11667 .unwrap();
11668 }
11669 let argument = |path: &str, mode| MessageFilters {
11670 field: Some(SearchField::ToolArgument),
11671 argument_path: Some(path.into()),
11672 match_mode: mode,
11673 limit: 10,
11674 ..Default::default()
11675 };
11676
11677 assert_eq!(
11678 hit_keys(
11679 db.search_messages("café", &argument("/cmd", MessageSearchMode::Literal))
11680 .unwrap()
11681 ),
11682 vec![("claude:s1".into(), 4)]
11683 );
11684 assert_eq!(
11685 hit_keys(
11686 db.search_messages(
11687 r#"C\+\+.*--path"#,
11688 &argument("/cmd", MessageSearchMode::Regex),
11689 )
11690 .unwrap()
11691 ),
11692 vec![("claude:s1".into(), 4)]
11693 );
11694 assert_eq!(
11695 hit_keys(
11696 db.search_messages(
11697 "quoted file.rs",
11698 &argument("/request/path", MessageSearchMode::Literal),
11699 )
11700 .unwrap()
11701 ),
11702 vec![("claude:s1".into(), 5)]
11703 );
11704 assert_eq!(
11705 hit_keys(
11706 db.search_messages("null", &argument("/cmd", MessageSearchMode::Literal))
11707 .unwrap()
11708 ),
11709 vec![("claude:s1".into(), 3)]
11710 );
11711 assert_eq!(
11712 hit_keys(
11713 db.search_messages(
11714 "cargo test --workspace",
11715 &argument("/cmd", MessageSearchMode::Literal),
11716 )
11717 .unwrap()
11718 ),
11719 vec![("claude:s1".into(), 6)]
11720 );
11721 assert!(
11722 db.search_messages("scalar", &argument("/cmd", MessageSearchMode::Literal))
11723 .unwrap()
11724 .is_empty(),
11725 "a pointer below scalar args and malformed/missing envelopes must project NULL"
11726 );
11727
11728 for mode in [MessageSearchMode::Literal, MessageSearchMode::Regex] {
11729 let query = if mode == MessageSearchMode::Literal {
11730 "ätool::c++"
11731 } else {
11732 r#"(?i)^ätool::c\+\+$"#
11733 };
11734 assert_eq!(
11735 db.search_messages(
11736 query,
11737 &MessageFilters {
11738 field: Some(SearchField::ToolName),
11739 match_mode: mode,
11740 limit: 10,
11741 ..Default::default()
11742 },
11743 )
11744 .unwrap()
11745 .len(),
11746 7,
11747 "{mode:?} must preserve Unicode and punctuation in tool names"
11748 );
11749 }
11750 }
11751
11752 #[test]
11753 fn fuzzy_content_reports_complete_filtered_corpus_scoring() {
11754 let dir = tempfile::tempdir().unwrap();
11755 let db = Db::open(&dir.path().join("index.db")).unwrap();
11756 db.conn
11757 .execute(
11758 "insert into sessions (
11759 id, provider, provider_session_id, preview_text, source_path,
11760 parse_version, discovery_source
11761 ) values ('s1','claude','s1','','/p','1','test')",
11762 [],
11763 )
11764 .unwrap();
11765 let tx = db.conn.unchecked_transaction().unwrap();
11766 {
11767 let mut insert = tx
11768 .prepare(
11769 "insert into messages(session_id, provider, seq, role, content)
11770 values ('s1','claude',?1,'user',?2)",
11771 )
11772 .unwrap();
11773 for seq in 0..1_205_i64 {
11774 insert
11775 .execute(params![seq, format!("abc candidate {seq:04}")])
11776 .unwrap();
11777 }
11778 }
11779 tx.commit().unwrap();
11780
11781 let (hits, explain) = db
11782 .search_messages_with_explain(
11783 "abc",
11784 &MessageFilters {
11785 match_mode: MessageSearchMode::Fuzzy,
11786 limit: 1,
11787 ..Default::default()
11788 },
11789 true,
11790 )
11791 .unwrap();
11792
11793 assert_eq!(hits.len(), 1);
11794 let explain = explain.unwrap();
11795 assert_eq!(
11796 explain.prefilter_skipped.as_deref(),
11797 Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
11798 );
11799 assert_eq!(explain.candidates, Some(1_205));
11800 assert_eq!(explain.corpus, 1_205);
11801 }
11802
11803 #[test]
11804 fn v4_prefilter_work_stays_selective_across_one_two_four_x_corpora() {
11805 for corpus in [200_i64, 400, 800] {
11806 let dir = tempfile::tempdir().unwrap();
11807 let db = Db::open(&dir.path().join("index.db")).unwrap();
11808 db.conn
11809 .execute(
11810 "insert into sessions (
11811 id, provider, provider_session_id, preview_text, source_path,
11812 parse_version, discovery_source
11813 ) values ('s1','claude','s1','','/p','1','test')",
11814 [],
11815 )
11816 .unwrap();
11817 let tx = db.conn.unchecked_transaction().unwrap();
11818 {
11819 let mut insert = tx
11820 .prepare(
11821 "insert into messages(session_id, provider, seq, role, content)
11822 values ('s1','claude',?1,'user',?2)",
11823 )
11824 .unwrap();
11825 insert
11826 .execute(params![0_i64, "rare fuzzy anchor C++ --path"])
11827 .unwrap();
11828 for seq in 1..corpus {
11829 insert
11830 .execute(params![seq, format!("unrelated filler row {seq:04}")])
11831 .unwrap();
11832 }
11833 }
11834 tx.commit().unwrap();
11835
11836 for (mode, query) in [
11837 (MessageSearchMode::Literal, "fuzzy anchor C++"),
11838 (MessageSearchMode::Regex, r"fuzzy\s+anchor\s+C\+\+"),
11839 (MessageSearchMode::Fuzzy, "fzzy anchr C++"),
11840 ] {
11841 let (hits, explain) = db
11842 .search_messages_with_explain(
11843 query,
11844 &MessageFilters {
11845 match_mode: mode,
11846 limit: 10,
11847 ..Default::default()
11848 },
11849 true,
11850 )
11851 .unwrap();
11852 assert_eq!(hit_keys(hits), vec![("s1".into(), 0)], "{corpus}/{mode:?}");
11853 let explain = explain.unwrap();
11854 assert_eq!(explain.corpus, corpus, "{corpus}/{mode:?}");
11855 assert!(
11856 explain.candidates.is_some_and(|count| count < corpus / 4),
11857 "candidate work must stay selective as the unrelated corpus scales: \
11858 corpus={corpus} mode={mode:?} explain={explain:?}"
11859 );
11860 }
11861 }
11862 }
11863
11864 #[test]
11865 fn message_kind_call_id_and_offset_share_one_query_contract() {
11866 let dir = tempfile::tempdir().unwrap();
11867 let db = Db::open(&dir.path().join("index.db")).unwrap();
11868 seed_messages(&db, &[("tool", "first call"), ("tool", "first result")]);
11869 db.conn
11870 .execute_batch(
11871 "update messages set kind = 'tool_call', tool_call_id = 'call-1' where seq = 0;
11872 update messages set kind = 'tool_result', tool_call_id = 'call-1' where seq = 1;",
11873 )
11874 .unwrap();
11875
11876 let calls = db
11877 .search_messages(
11878 "",
11879 &MessageFilters {
11880 kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11881 ..Default::default()
11882 },
11883 )
11884 .unwrap();
11885 assert_eq!(calls.len(), 1);
11886 assert_eq!(calls[0].kind, crate::models::MessageKind::ToolCall);
11887 assert_eq!(calls[0].tool_call_id.as_deref(), Some("call-1"));
11888
11889 let page = db
11890 .search_messages(
11891 "",
11892 &MessageFilters {
11893 limit: 1,
11894 offset: 1,
11895 ..Default::default()
11896 },
11897 )
11898 .unwrap();
11899 assert_eq!(page.len(), 1);
11900 assert_eq!(page[0].seq, 1);
11901 assert_eq!(page[0].kind, crate::models::MessageKind::ToolResult);
11902 }
11903
11904 #[test]
11905 fn message_context_rejects_negative_window_bounds() {
11906 let dir = tempfile::tempdir().unwrap();
11907 let db = Db::open(&dir.path().join("index.db")).unwrap();
11908 let error = db
11909 .message_context("missing", 0, -1, 0)
11910 .unwrap_err()
11911 .to_string();
11912 assert!(error.contains("before must be non-negative"), "{error}");
11913 let error = db
11914 .message_context("missing", 0, 0, -1)
11915 .unwrap_err()
11916 .to_string();
11917 assert!(error.contains("after must be non-negative"), "{error}");
11918 }
11919
11920 #[test]
11921 fn query_timeout_interrupts_expensive_work_and_resets_the_handler() {
11922 let dir = tempfile::tempdir().unwrap();
11923 let db = Db::open(&dir.path().join("index.db")).unwrap();
11924
11925 let error = with_sqlite_query_timeout(
11926 &db.conn,
11927 std::num::NonZeroU64::new(1),
11928 "read-only SQL query",
11929 "increase timeout_ms or pass 0 for no timeout",
11930 || {
11931 db.conn
11932 .query_row(
11933 "with recursive n(x) as (values(0) union all select x + 1 from n where x < 100000000) select sum(x) from n",
11934 [],
11935 |row| row.get::<_, i64>(0),
11936 )
11937 .map_err(Into::into)
11938 },
11939 )
11940 .unwrap_err()
11941 .to_string();
11942 assert!(error.contains("timed out after 1 ms"), "{error}");
11943
11944 assert_eq!(
11945 db.conn
11946 .query_row("select 1", [], |row| row.get::<_, i64>(0))
11947 .unwrap(),
11948 1,
11949 "the RAII guard must clear SQLite's progress handler after interruption"
11950 );
11951 }
11952
11953 #[test]
11954 fn persistent_interrupt_cancels_a_statement_started_after_the_flag_is_set() {
11955 let dir = tempfile::tempdir().unwrap();
11956 let db = Db::open(&dir.path().join("index.db")).unwrap();
11957 let cancellation = Arc::new(AtomicBool::new(false));
11958 db.interrupt_while(Arc::clone(&cancellation))
11959 .expect("arming cancellation is a precondition of this test");
11960
11961 cancellation.store(true, Ordering::Release);
11964 let error = db
11965 .conn
11966 .query_row(
11967 "with recursive n(value) as (
11968 values(1)
11969 union all
11970 select value + 1 from n where value < 1000000
11971 )
11972 select sum(value) from n",
11973 [],
11974 |row| row.get::<_, i64>(0),
11975 )
11976 .unwrap_err();
11977 assert!(matches!(
11978 error,
11979 rusqlite::Error::SqliteFailure(inner, _)
11980 if inner.code == rusqlite::ErrorCode::OperationInterrupted
11981 ));
11982 }
11983
11984 #[test]
11985 fn query_cancellation_registered_after_cancel_still_interrupts_the_next_statement() {
11986 let dir = tempfile::tempdir().unwrap();
11987 let db = Db::open(&dir.path().join("index.db")).unwrap();
11988 let cancellation = QueryCancellation::new();
11989
11990 cancellation.cancel();
11991 db.install_query_cancellation(&cancellation)
11992 .expect("arming cancellation is a precondition of this test");
11993
11994 let error = db
11995 .conn
11996 .query_row(
11997 "with recursive n(value) as (
11998 values(1)
11999 union all
12000 select value + 1 from n where value < 1000000
12001 )
12002 select sum(value) from n",
12003 [],
12004 |row| row.get::<_, i64>(0),
12005 )
12006 .unwrap_err();
12007 assert!(matches!(
12008 error,
12009 rusqlite::Error::SqliteFailure(inner, _)
12010 if inner.code == rusqlite::ErrorCode::OperationInterrupted
12011 ));
12012 }
12013
12014 #[test]
12015 fn message_batch_allocation_failure_names_the_unit_and_recovery() {
12016 let dir = tempfile::tempdir().unwrap();
12017 let db = Db::open(&dir.path().join("index.db")).unwrap();
12018 seed_messages(&db, &[("user", "needle")]);
12019 let error = db
12020 .visit_messages_in_batches(
12021 "needle",
12022 &MessageFilters {
12023 limit: 0,
12024 ..Default::default()
12025 },
12026 false,
12027 MessageOrder::OldestFirst,
12028 NonZeroUsize::new(usize::MAX).unwrap(),
12029 |_| Ok(MessageBatchControl::Continue),
12030 )
12031 .unwrap_err()
12032 .to_string();
12033 assert!(error.contains("batch_rows"));
12034 assert!(error.contains(&usize::MAX.to_string()));
12035 assert!(error.contains("pass a smaller positive row count"));
12036 }
12037
12038 #[test]
12039 fn query_timeout_never_panics_when_deadline_exceeds_platform_instant_range() {
12040 let dir = tempfile::tempdir().unwrap();
12041 let db = Db::open(&dir.path().join("index.db")).unwrap();
12042
12043 let result = with_sqlite_query_timeout(
12044 &db.conn,
12045 std::num::NonZeroU64::new(u64::MAX),
12046 "read-only SQL query",
12047 "increase timeout_ms or pass 0 for no timeout",
12048 || {
12049 db.conn
12050 .query_row("select 1", [], |row| row.get::<_, i64>(0))
12051 .map_err(Into::into)
12052 },
12053 );
12054
12055 match result {
12056 Ok(value) => assert_eq!(value, 1),
12057 Err(error) => assert!(
12058 error.to_string().contains("timeout_ms is too large"),
12059 "{error}"
12060 ),
12061 }
12062 assert_eq!(
12063 db.conn
12064 .query_row("select 2", [], |row| row.get::<_, i64>(0))
12065 .unwrap(),
12066 2,
12067 "an unrepresentable timeout must not leave a progress handler installed"
12068 );
12069 }
12070
12071 #[test]
12072 fn message_context_windows_batch_preserves_anchor_order_and_empty_windows() {
12073 let dir = tempfile::tempdir().unwrap();
12074 let db = Db::open(&dir.path().join("index.db")).unwrap();
12075 seed_messages(
12076 &db,
12077 &[
12078 ("user", "zero"),
12079 ("assistant", "one"),
12080 ("user", "two"),
12081 ("assistant", "three"),
12082 ],
12083 );
12084 let anchors = vec![
12085 ("claude:s1".to_string(), 3),
12086 ("missing".to_string(), 10),
12087 ("claude:s1".to_string(), 0),
12088 ];
12089
12090 let windows = db.message_context_windows(&anchors, 1, 1).unwrap();
12091
12092 assert_eq!(windows.len(), 3);
12093 assert_eq!(
12094 windows[0].iter().map(|hit| hit.seq).collect::<Vec<_>>(),
12095 vec![2, 3]
12096 );
12097 assert!(windows[1].is_empty());
12098 assert_eq!(
12099 windows[2].iter().map(|hit| hit.seq).collect::<Vec<_>>(),
12100 vec![0, 1]
12101 );
12102 }
12103
12104 #[test]
12105 fn session_time_profile_is_bounded_and_uses_typed_message_metadata() {
12106 let dir = tempfile::tempdir().unwrap();
12107 let db = Db::open(&dir.path().join("index.db")).unwrap();
12108 seed_messages(
12109 &db,
12110 &[
12111 ("user", "start"),
12112 ("tool", "call"),
12113 ("tool", "result"),
12114 ("assistant", "end"),
12115 ],
12116 );
12117 db.conn
12118 .execute_batch(
12119 "update messages set ts = '2026-07-10T10:00:00Z' where seq = 0;
12120 update messages set ts = '2026-07-10T10:00:05Z', kind = 'tool_call' where seq = 1;
12121 update messages set ts = '2026-07-10T10:00:20Z', kind = 'tool_result' where seq = 2;",
12122 )
12123 .unwrap();
12124
12125 let profile = db.session_time_profile("claude:s1").unwrap();
12126 assert_eq!(profile.messages, 4);
12127 assert_eq!(profile.timestamped_messages, 3);
12128 assert_eq!(profile.undated_messages, 1);
12129 assert_eq!(profile.observed_span_seconds, Some(20));
12130 assert_eq!(profile.max_message_gap_seconds, Some(15));
12131 assert_eq!((profile.tool_calls, profile.tool_results), (1, 1));
12132 }
12133
12134 #[test]
12135 fn session_time_profile_uses_intermediate_events_across_months() {
12136 let dir = tempfile::tempdir().unwrap();
12137 let db = Db::open(&dir.path().join("index.db")).unwrap();
12138 seed_messages(
12139 &db,
12140 &[
12141 ("user", "january"),
12142 ("assistant", "february"),
12143 ("user", "april"),
12144 ("assistant", "undated tail"),
12145 ],
12146 );
12147 db.conn
12148 .execute_batch(
12149 "update sessions
12150 set created_at = '2026-04-30T00:00:00Z',
12151 updated_at = '2026-04-30T00:00:01Z',
12152 last_message_at = '2026-04-30T00:00:01Z'
12153 where id = 'claude:s1';
12154 update messages set ts = '2026-01-01T00:00:00.100Z' where seq = 0;
12155 update messages set ts = '2026-02-01T00:00:00.200Z' where seq = 1;
12156 update messages set ts = '2026-04-30T00:00:00.300Z' where seq = 2;
12157 update messages set ts = null where seq = 3;",
12158 )
12159 .unwrap();
12160
12161 let profile = db.session_time_profile("claude:s1").unwrap();
12162
12163 assert_eq!(profile.timestamped_messages, 3);
12164 assert_eq!(profile.undated_messages, 1);
12165 assert_eq!(
12166 profile.first_timestamp.unwrap().to_rfc3339(),
12167 "2026-01-01T00:00:00.100+00:00"
12168 );
12169 assert_eq!(
12170 profile.last_timestamp.unwrap().to_rfc3339(),
12171 "2026-04-30T00:00:00.300+00:00"
12172 );
12173 assert_eq!(profile.observed_span_seconds, Some(10_281_600));
12174 assert_eq!(profile.max_message_gap_seconds, Some(7_603_200));
12175 }
12176
12177 #[test]
12178 fn parser_health_uses_shared_provider_versions_and_consistent_totals() {
12179 let dir = tempfile::tempdir().unwrap();
12180 let db = Db::open(&dir.path().join("index.db")).unwrap();
12181 db.mark_schema_current().unwrap();
12182 db.conn
12183 .execute_batch(
12184 "insert into sessions(id, provider, provider_session_id, preview_text, source_path, parse_version, parse_warning, discovery_source) values
12185 ('claude:current','claude','current','','/a','claude-v4',null,'jsonl'),
12186 ('claude:stale','claude','stale','','/b','claude-v1','old parser','jsonl');",
12187 )
12188 .unwrap();
12189 db.conn
12190 .execute(
12191 "insert into sessions(id, provider, provider_session_id, preview_text, source_path, parse_version, discovery_source)
12192 values ('codex:current','codex','current','','/c',?1,'jsonl')",
12193 [crate::util::provider_parse_version(Provider::Codex)],
12194 )
12195 .unwrap();
12196
12197 let health = db.parser_health().unwrap();
12198 assert!(health.schema_current);
12199 assert_eq!(health.indexed_sessions, 3);
12200 assert_eq!(health.current_sessions, 2);
12201 assert_eq!(health.stale_sessions, 1);
12202 assert_eq!(health.parse_warnings, 1);
12203 let claude = health
12204 .providers
12205 .iter()
12206 .find(|item| item.provider == Provider::Claude)
12207 .unwrap();
12208 assert_eq!(claude.expected_parse_version, "claude-v4");
12209 assert_eq!((claude.current_sessions, claude.stale_sessions), (1, 1));
12210 assert_eq!(
12211 db.stale_session_sources().unwrap(),
12212 vec![(Provider::Claude, "/b".to_string())]
12213 );
12214 }
12215
12216 #[test]
12217 fn complete_parse_replaces_superseded_identity_for_the_same_source() {
12218 let dir = tempfile::tempdir().unwrap();
12219 let db = Db::open(&dir.path().join("index.db")).unwrap();
12220 let source = std::path::Path::new("/fixture/changing-identity.jsonl");
12221 let mut old = crate::util::minimal_record(Provider::ClaudeDesktop, source, String::new());
12222 old.session.id = "claude-desktop:old".into();
12223 old.session.provider_session_id = "old".into();
12224 old.session.parse_version = "claude-desktop-local-agent-v1".into();
12225 old.messages = vec![crate::models::Message {
12226 seq: 0,
12227 role: Role::User,
12228 kind: crate::models::MessageKind::Conversation,
12229 ts: None,
12230 tool_name: None,
12231 tool_call_id: None,
12232 is_compaction: false,
12233 content: "obsolete".into(),
12234 provenance: crate::models::MessageProvenance::default(),
12235 }];
12236 db.upsert_session(&old, 1, 1).unwrap();
12237
12238 let mut current =
12239 crate::util::minimal_record(Provider::ClaudeDesktop, source, String::new());
12240 current.session.id = "claude-desktop:current".into();
12241 current.session.provider_session_id = "current".into();
12242 current.messages = vec![crate::models::Message {
12243 seq: 0,
12244 role: Role::User,
12245 kind: crate::models::MessageKind::Conversation,
12246 ts: None,
12247 tool_name: None,
12248 tool_call_id: None,
12249 is_compaction: false,
12250 content: "replacement".into(),
12251 provenance: crate::models::MessageProvenance::default(),
12252 }];
12253 db.upsert_session(¤t, 2, 2).unwrap();
12254
12255 assert!(db.resolve_session_record("claude-desktop:old").is_err());
12256 assert_eq!(
12257 db.resolve_session_record("claude-desktop:current")
12258 .unwrap()
12259 .source_path,
12260 source.to_string_lossy()
12261 );
12262 assert!(db
12263 .search_messages("obsolete", &MessageFilters::default())
12264 .unwrap()
12265 .is_empty());
12266 assert!(db
12267 .is_file_current(
12268 Provider::ClaudeDesktop,
12269 &source.to_string_lossy(),
12270 2,
12271 2,
12272 crate::util::provider_parse_version(Provider::ClaudeDesktop),
12273 )
12274 .unwrap());
12275 }
12276
12277 #[test]
12278 fn source_checkpoint_version_backfill_requires_a_matching_source_path() {
12279 let dir = tempfile::tempdir().unwrap();
12280 let db = Db::open(&dir.path().join("index.db")).unwrap();
12281 let source = dir.path().join("session.jsonl");
12282 let parsed = crate::util::minimal_record(Provider::Claude, &source, String::new());
12283 db.upsert_session(&parsed, 10, 20).unwrap();
12284 db.conn
12285 .execute("update files_seen set parse_version = ''", [])
12286 .unwrap();
12287
12288 db.backfill_source_parse_versions().unwrap();
12289 assert!(db
12290 .is_file_current(
12291 Provider::Claude,
12292 &source.to_string_lossy(),
12293 10,
12294 20,
12295 crate::util::provider_parse_version(Provider::Claude),
12296 )
12297 .unwrap());
12298
12299 db.conn
12300 .execute_batch(
12301 "update sessions set source_path = '/different/source.jsonl';
12302 update files_seen set parse_version = '';",
12303 )
12304 .unwrap();
12305 db.backfill_source_parse_versions().unwrap();
12306 assert!(!db
12307 .is_file_current(
12308 Provider::Claude,
12309 &source.to_string_lossy(),
12310 10,
12311 20,
12312 crate::util::provider_parse_version(Provider::Claude),
12313 )
12314 .unwrap());
12315 }
12316
12317 #[test]
12318 fn complete_parse_reconciles_verified_source_aliases() {
12319 let dir = tempfile::tempdir().unwrap();
12320 let db = Db::open(&dir.path().join("index.db")).unwrap();
12321 let alias = std::path::Path::new("/fixture/alias/session.jsonl");
12322 let canonical = std::path::Path::new("/fixture/canonical/session.jsonl");
12323 let mut old = crate::util::minimal_record(Provider::Claude, alias, String::new());
12324 old.session.id = "claude:old-alias".into();
12325 old.session.provider_session_id = "old-alias".into();
12326 db.upsert_session(&old, 1, 1).unwrap();
12327 let mut current = crate::util::minimal_record(Provider::Claude, canonical, String::new());
12328 current.session.id = "claude:current".into();
12329 current.session.provider_session_id = "current".into();
12330
12331 db.upsert_session_reconciling_sources(
12332 ¤t,
12333 2,
12334 2,
12335 &[alias.to_string_lossy().into_owned()],
12336 true,
12337 )
12338 .unwrap();
12339
12340 assert!(db.resolve_session_record("claude:old-alias").is_err());
12341 assert_eq!(db.indexed_source_identities().unwrap().len(), 1);
12342 assert_eq!(
12343 db.indexed_source_identities().unwrap()[0].1,
12344 canonical.to_string_lossy()
12345 );
12346 }
12347
12348 #[test]
12349 fn schema_backfill_flag_round_trips() {
12350 let dir = tempfile::tempdir().unwrap();
12351 let db = Db::open(&dir.path().join("index.db")).unwrap();
12352 db.conn.pragma_update(None, "user_version", 2).unwrap();
12353 assert!(db.needs_backfill().unwrap());
12354 db.mark_schema_current().unwrap();
12355 assert!(
12356 db.needs_backfill().unwrap(),
12357 "parser rows are backfilled, but the pre-v4 FTS layout is not yet current"
12358 );
12359 assert_eq!(db.schema_version().unwrap(), PRE_FTS_PARSER_SCHEMA_VERSION);
12360 db.migrate_message_search_schema_exclusive().unwrap();
12361 assert!(!db.needs_backfill().unwrap());
12362 assert_eq!(db.schema_version().unwrap(), SCHEMA_VERSION);
12363 }
12364
12365 #[test]
12366 fn mark_schema_current_preserves_the_v4_fts_gate_before_promoting_schema_5() {
12367 let dir = tempfile::tempdir().unwrap();
12375 for start in [0_i64, 1, 2, PRE_FTS_PARSER_SCHEMA_VERSION] {
12376 let path = dir.path().join(format!("v{start}.db"));
12377 let db = Db::open(&path).unwrap();
12378 db.conn.pragma_update(None, "user_version", start).unwrap();
12379 db.mark_schema_current().unwrap();
12380 assert!(
12381 db.schema_version().unwrap() < SCHEMA_VERSION,
12382 "start={start}: mark_schema_current must not promote a pre-v{SCHEMA_VERSION} index to current",
12383 );
12384 }
12385 let v4 = Db::open(&dir.path().join("v4.db")).unwrap();
12386 v4.conn.pragma_update(None, "user_version", 4).unwrap();
12387 v4.mark_schema_current().unwrap();
12388 assert_eq!(v4.schema_version().unwrap(), SCHEMA_VERSION);
12389 }
12390
12391 #[test]
12392 fn clear_all_is_atomic_and_rolls_back_on_failure() {
12393 let dir = tempfile::tempdir().unwrap();
12397 let db = Db::open(&dir.path().join("index.db")).unwrap();
12398 db.conn
12399 .execute_batch(
12400 "insert into sessions (id, provider, provider_session_id, preview_text, source_path,
12401 parse_version, discovery_source)
12402 values ('s', 'claude', 's', '', '/s.jsonl', 'test', 'fixture');
12403 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12404 values ('s', 'claude', 0, 'user', 'message', null, 'keep me');",
12405 )
12406 .unwrap();
12407 db.conn.execute_batch("drop table files_seen;").unwrap();
12408
12409 assert!(
12410 db.clear_all().is_err(),
12411 "clear_all should surface the failing delete"
12412 );
12413 let (sessions, messages): (i64, i64) = db
12414 .conn
12415 .query_row(
12416 "select (select count(*) from sessions), (select count(*) from messages)",
12417 [],
12418 |row| Ok((row.get(0)?, row.get(1)?)),
12419 )
12420 .unwrap();
12421 assert_eq!(
12422 (sessions, messages),
12423 (1, 1),
12424 "clear_all must roll back all deletes when one fails"
12425 );
12426 }
12427
12428 #[test]
12429 fn direct_db_open_self_enforces_v4_layout_after_triggers_dropped() {
12430 let dir = tempfile::tempdir().unwrap();
12435 let path = dir.path().join("index.db");
12436 {
12437 let db = Db::open(&path).unwrap();
12438 db.conn
12439 .execute_batch(
12440 "insert into sessions (id, provider, provider_session_id, preview_text,
12441 source_path, parse_version, discovery_source)
12442 values ('s', 'claude', 's', '', '/s.jsonl', 'test', 'fixture');
12443 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12444 values ('s', 'claude', 0, 'user', 'message', null, 'existing-row');
12445 drop trigger messages_ai;
12446 drop trigger messages_ad;
12447 drop trigger messages_au;",
12448 )
12449 .unwrap();
12450 }
12451
12452 let db = Db::open(&path).unwrap();
12454 assert!(
12455 crate::indexer::current_schema_layout_problem(&db.conn)
12456 .unwrap()
12457 .is_none(),
12458 "Db::open must self-enforce a consistent v4 layout"
12459 );
12460 let terms_before: i64 = db
12465 .conn
12466 .query_row("select count(*) from messages_trigram_terms", [], |row| {
12467 row.get(0)
12468 })
12469 .unwrap();
12470 db.conn
12471 .execute_batch(
12472 "insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12473 values ('s', 'claude', 1, 'user', 'message', null, 'freshtrigramrow');",
12474 )
12475 .unwrap();
12476 let terms_after: i64 = db
12477 .conn
12478 .query_row("select count(*) from messages_trigram_terms", [], |row| {
12479 row.get(0)
12480 })
12481 .unwrap();
12482 assert!(
12483 terms_after > terms_before,
12484 "new insert must be maintained in messages_trigram (terms {terms_before} -> {terms_after})"
12485 );
12486 }
12487
12488 #[test]
12489 fn open_drops_legacy_fts5_trigram_and_self_heals() {
12490 let dir = tempfile::tempdir().unwrap();
12495 let path = dir.path().join("index.db");
12496 {
12497 let db = Db::open(&path).unwrap();
12498 db.conn
12499 .execute_batch(
12500 "drop trigger messages_ai;
12501 drop trigger messages_ad;
12502 drop trigger messages_au;
12503 drop table messages_trigram_terms;
12504 drop table messages_trigram_vocab;
12505 drop table messages_trigram;
12506 create virtual table messages_trigram using fts5(content, \
12507 content='messages', content_rowid='id', tokenize='trigram', detail='none');",
12508 )
12509 .unwrap();
12510 crate::fts::install_released_message_word_index(&db.conn).unwrap();
12511 db.conn.pragma_update(None, "user_version", 3).unwrap();
12512 }
12513 let db = Db::open(&path).unwrap();
12514 let legacy: i64 = db
12515 .conn
12516 .query_row(
12517 "select count(*) from sqlite_master where name='messages_trigram'",
12518 [],
12519 |r| r.get(0),
12520 )
12521 .unwrap();
12522 assert_eq!(legacy, 0, "legacy FTS5 messages_trigram dropped on open");
12523 let custom: i64 = db
12524 .conn
12525 .query_row(
12526 "select count(*) from sqlite_master where name='trigram_postings'",
12527 [],
12528 |r| r.get(0),
12529 )
12530 .unwrap();
12531 assert_eq!(custom, 1, "custom trigram_postings present after open");
12532 seed_messages(&db, &[("user", "an econnreset row")]);
12533 let hits = db
12534 .search_messages(
12535 "econnreset",
12536 &MessageFilters {
12537 match_mode: MessageSearchMode::Regex,
12538 ..Default::default()
12539 },
12540 )
12541 .unwrap();
12542 assert_eq!(hits.len(), 1, "regex search works after the self-heal");
12543 }
12544
12545 #[test]
12546 fn messages_indexes_drop_redundant_singles_and_keep_composites() {
12547 let dir = tempfile::tempdir().unwrap();
12548 let path = dir.path().join("index.db");
12549 let count_index = |db: &Db, name: &str| -> i64 {
12550 db.conn
12551 .query_row(
12552 "select count(*) from sqlite_master where type='index' and name=?1",
12553 [name],
12554 |row| row.get(0),
12555 )
12556 .unwrap()
12557 };
12558
12559 {
12561 let db = Db::open(&path).unwrap();
12562 db.conn
12563 .execute_batch(
12564 "create index if not exists idx_messages_session on messages(session_id);
12565 create index if not exists idx_messages_role on messages(role);",
12566 )
12567 .unwrap();
12568 assert_eq!(count_index(&db, "idx_messages_session"), 1, "precondition");
12569 assert_eq!(count_index(&db, "idx_messages_role"), 1, "precondition");
12570 }
12571
12572 let db = Db::open(&path).unwrap();
12575 assert_eq!(
12576 count_index(&db, "idx_messages_session"),
12577 0,
12578 "redundant (session_id) dropped"
12579 );
12580 assert_eq!(
12581 count_index(&db, "idx_messages_role"),
12582 0,
12583 "redundant (role) dropped"
12584 );
12585 for idx in [
12586 "idx_messages_session_seq",
12587 "idx_messages_role_ts",
12588 "idx_messages_ts",
12589 ] {
12590 assert_eq!(count_index(&db, idx), 1, "{idx} must exist");
12591 }
12592 }
12593
12594 #[test]
12595 fn hot_message_queries_use_indexes_not_full_scans() {
12596 let dir = tempfile::tempdir().unwrap();
12601 let db = Db::open(&dir.path().join("index.db")).unwrap();
12602 db.conn
12603 .execute_batch(
12604 "insert into sessions(id, provider, provider_session_id, preview_text, \
12605 source_path, parse_version, discovery_source) \
12606 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
12607 )
12608 .unwrap();
12609 {
12610 let tx = db.conn.unchecked_transaction().unwrap();
12611 {
12612 let mut stmt = tx
12613 .prepare(
12614 "insert into messages(session_id, provider, seq, role, ts, content) \
12615 values('claude:s1','claude',?1,?2,?3,?4)",
12616 )
12617 .unwrap();
12618 for i in 0..2000i64 {
12619 let role = if i % 7 == 0 { "user" } else { "assistant" };
12620 let ts = format!("2026-06-{:02}T00:00:00+00:00", (i % 28) + 1);
12621 stmt.execute(params![i, role, ts, format!("message number {i} alpha")])
12622 .unwrap();
12623 }
12624 }
12625 tx.commit().unwrap();
12626 }
12627 db.conn.execute_batch("analyze").unwrap();
12628
12629 let plan = |sql: &str| -> String {
12631 let mut stmt = db
12632 .conn
12633 .prepare(&format!("explain query plan {sql}"))
12634 .unwrap();
12635 let rows = stmt.query_map([], |r| r.get::<_, String>(3)).unwrap();
12636 rows.filter_map(Result::ok).collect::<Vec<_>>().join(" | ")
12637 };
12638
12639 let p = plan(
12643 "select m.id from messages_fts f join messages m on m.id = f.rowid \
12644 where messages_fts match 'alpha'",
12645 );
12646 assert!(
12647 p.contains("VIRTUAL TABLE INDEX"),
12648 "content search must be driven by the messages_fts index: {p}"
12649 );
12650 assert!(
12651 p.contains("USING INTEGER PRIMARY KEY") && !p.contains("SCAN m "),
12652 "messages rows must be reached by rowid from the FTS matches, not scanned: {p}"
12653 );
12654
12655 let p = plan("select content from messages where role = 'user' order by ts desc");
12657 assert!(
12658 p.contains("idx_messages_role_ts"),
12659 "role/ts query must use the composite: {p}"
12660 );
12661
12662 let p = plan(
12664 "select content from messages where session_id = 'claude:s1' \
12665 and seq between 10 and 20 order by seq",
12666 );
12667 assert!(
12668 p.contains("idx_messages_session_seq"),
12669 "session/seq query must use the composite: {p}"
12670 );
12671
12672 let p = plan(
12675 "select content from messages where provider = 'claude' \
12676 order by session_id, seq limit 2",
12677 );
12678 assert!(
12679 p.contains("idx_messages_provider_session_seq") && !p.contains("USE TEMP B-TREE"),
12680 "provider-scoped pages must use the ordered provider composite without a sort: {p}"
12681 );
12682 }
12683
12684 #[test]
12685 fn messages_fts_is_rebuilt_when_empty_but_messages_exist() {
12686 let dir = tempfile::tempdir().unwrap();
12687 let path = dir.path().join("index.db");
12688 {
12691 let db = Db::open(&path).unwrap();
12692 db.conn
12693 .execute_batch(
12694 "insert into sessions(id, provider, provider_session_id, preview_text, \
12695 source_path, parse_version, discovery_source) \
12696 values('claude:s1','claude','s1','','/x','claude-v1','jsonl'); \
12697 insert into messages(session_id, provider, seq, role, content) \
12698 values('claude:s1','claude',0,'user','findthisneedle');",
12699 )
12700 .unwrap();
12701 let hit: i64 = db
12702 .conn
12703 .query_row(
12704 "select count(*) from messages_fts where messages_fts match 'findthisneedle'",
12705 [],
12706 |r| r.get(0),
12707 )
12708 .unwrap();
12709 assert_eq!(hit, 1, "precondition: triggers index the inserted message");
12710 db.conn
12711 .execute_batch(
12712 "drop trigger messages_ai; drop trigger messages_ad; drop trigger messages_au; \
12713 drop table messages_fts;",
12714 )
12715 .unwrap();
12716 }
12717 let db = Db::open(&path).unwrap();
12720 let hit: i64 = db
12721 .conn
12722 .query_row(
12723 "select count(*) from messages_fts where messages_fts match 'findthisneedle'",
12724 [],
12725 |r| r.get(0),
12726 )
12727 .unwrap();
12728 assert_eq!(
12729 hit, 1,
12730 "messages_fts rebuilt on open when empty but messages exist"
12731 );
12732 }
12733
12734 #[test]
12735 fn messages_fts_count_reports_index_documents_not_content_rows() {
12736 let dir = tempfile::tempdir().unwrap();
12737 let db = Db::open(&dir.path().join("index.db")).unwrap();
12738 db.conn
12739 .execute_batch(
12740 "insert into sessions(id, provider, provider_session_id, preview_text, \
12741 source_path, parse_version, discovery_source) \
12742 values('claude:s1','claude','s1','','/x','claude-v1','jsonl'); \
12743 insert into messages(session_id, provider, seq, role, content) \
12744 values('claude:s1','claude',0,'user','indexedtoken');",
12745 )
12746 .unwrap();
12747 assert_eq!(db.message_count().unwrap(), 1);
12748 assert_eq!(db.messages_fts_count().unwrap(), 1);
12749
12750 db.conn
12754 .execute("delete from messages_fts_docsize", [])
12755 .unwrap();
12756 let external_content_rows: i64 = db
12757 .conn
12758 .query_row("select count(*) from messages_fts", [], |r| r.get(0))
12759 .unwrap();
12760 assert_eq!(external_content_rows, 1);
12761 assert_eq!(
12762 db.messages_fts_count().unwrap(),
12763 0,
12764 "helper must report indexed docs, not external content rows"
12765 );
12766 }
12767
12768 #[test]
12769 fn substring_search_matches_inside_tokens_via_custom_index() {
12770 let dir = tempfile::tempdir().unwrap();
12774 let db = Db::open(&dir.path().join("index.db")).unwrap();
12775 seed_messages(
12776 &db,
12777 &[
12778 ("user", "the socket failed with ECONNRESET) today"),
12779 ("user", "you forgot the tests again"),
12780 ("assistant", "an unrelated message"),
12781 ],
12782 );
12783 let find = |needle: &str| -> usize {
12784 db.search_messages(
12785 needle,
12786 &MessageFilters {
12787 match_mode: MessageSearchMode::Regex,
12788 ..Default::default()
12789 },
12790 )
12791 .unwrap()
12792 .len()
12793 };
12794 assert_eq!(find("ECONNRESET"), 1, "substring inside a token");
12796 assert_eq!(find("you forgot"), 1, "multi-word phrase substring");
12797 assert_eq!(find("nonexistent_zz"), 0, "no false positives");
12798 }
12799
12800 #[test]
12801 fn trigram_base_rebuild_restores_searchability_including_short_docs() {
12802 let dir = tempfile::tempdir().unwrap();
12807 let db = Db::open(&dir.path().join("index.db")).unwrap();
12808 enable_v3_custom_trigram_compatibility(&db);
12809 seed_messages(
12810 &db,
12811 &[
12812 ("user", "alpha contains zebracode here"),
12813 ("user", "bravo contains zebracode too"),
12814 ("user", "charlie has zebracode as well"),
12815 ("user", "ok"), ],
12817 );
12818 let hits = db
12819 .search_messages(
12820 "zebracode",
12821 &MessageFilters {
12822 match_mode: MessageSearchMode::Regex,
12823 ..Default::default()
12824 },
12825 )
12826 .unwrap();
12827 assert_eq!(
12828 hits.len(),
12829 3,
12830 "every zebracode row searchable after base build"
12831 );
12832 assert_eq!(
12833 crate::trigram_index::base_max_id(&db.conn).unwrap(),
12834 4,
12835 "base covers all messages including the zero-trigram short doc"
12836 );
12837 }
12838
12839 #[test]
12840 fn messages_fts_updates_are_transactional_with_messages() {
12841 let dir = tempfile::tempdir().unwrap();
12845 let db = Db::open(&dir.path().join("index.db")).unwrap();
12846 db.conn
12847 .execute_batch(
12848 "insert into sessions(id, provider, provider_session_id, preview_text, \
12849 source_path, parse_version, discovery_source) \
12850 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
12851 )
12852 .unwrap();
12853 let before = db.messages_fts_count().unwrap();
12854 {
12855 let tx = db.conn.unchecked_transaction().unwrap();
12858 tx.execute(
12859 "insert into messages(session_id, provider, seq, role, content) \
12860 values('claude:s1','claude',0,'user','rollbackme token here')",
12861 [],
12862 )
12863 .unwrap();
12864 }
12865 assert_eq!(
12866 db.messages_fts_count().unwrap(),
12867 before,
12868 "rolled-back insert leaves no messages_fts entry"
12869 );
12870 let hit: i64 = db
12871 .conn
12872 .query_row(
12873 "select count(*) from messages_fts where messages_fts match 'rollbackme'",
12874 [],
12875 |r| r.get(0),
12876 )
12877 .unwrap();
12878 assert_eq!(
12879 hit, 0,
12880 "rolled-back content is not searchable via messages_fts"
12881 );
12882 }
12883
12884 #[test]
12885 fn storage_allocation_reports_freed_pages_without_scanning_content() {
12886 let dir = tempfile::tempdir().unwrap();
12887 let db = Db::open(&dir.path().join("index.db")).unwrap();
12888 db.conn
12889 .execute_batch(
12890 "create table allocation_probe (payload blob not null);
12891 insert into allocation_probe(payload) values(zeroblob(2097152));
12892 delete from allocation_probe;",
12893 )
12894 .unwrap();
12895
12896 let allocation = db.storage_allocation().unwrap();
12897 assert!(allocation.total_bytes >= allocation.reclaimable_bytes);
12898 assert!(
12899 allocation.reclaimable_bytes > 0,
12900 "deleted overflow pages must be reported as reclaimable"
12901 );
12902 }
12903
12904 #[test]
12905 fn replacement_freelist_compaction_preserves_rows_fts_and_search_results() {
12906 let dir = tempfile::tempdir().unwrap();
12907 let db = Db::open(&dir.path().join("index.db")).unwrap();
12908 let large_suffix = " allocation-padding".repeat(32_768);
12909
12910 for index in 0..6 {
12911 let id = format!("claude:allocation-{index}");
12912 let content = format!("original-token-{index}{large_suffix}");
12913 let mut parsed = parsed_with_messages(&id, &[&content]);
12914 parsed.session.provider_session_id = format!("allocation-{index}");
12915 parsed.session.source_path = format!("/allocation/{index}.jsonl");
12916 db.replace_session(&parsed, index, index).unwrap();
12917 }
12918 assert_eq!(db.message_count().unwrap(), 6);
12919 assert_eq!(db.messages_fts_count().unwrap(), 6);
12920
12921 for index in 0..3 {
12922 let id = format!("claude:allocation-{index}");
12923 let content = format!("replacement-token-{index}");
12924 let mut parsed = parsed_with_messages(&id, &[&content]);
12925 parsed.session.provider_session_id = format!("allocation-{index}");
12926 parsed.session.source_path = format!("/allocation/{index}.jsonl");
12927 db.replace_session(&parsed, 10 + index, 10 + index).unwrap();
12928 }
12929 db.optimize_fts().unwrap();
12930
12931 let before = db.storage_allocation().unwrap();
12932 assert!(before.reclaimable_bytes > 0);
12933 assert_eq!(db.message_count().unwrap(), 6);
12934 assert_eq!(db.messages_fts_count().unwrap(), 6);
12935 for index in 0..3 {
12936 assert_eq!(
12937 db.search_messages(
12938 &format!("replacement-token-{index}"),
12939 &MessageFilters::default(),
12940 )
12941 .unwrap()
12942 .len(),
12943 1
12944 );
12945 }
12946
12947 db.vacuum().unwrap();
12948 db.checkpoint_truncate().unwrap();
12949
12950 let after = db.storage_allocation().unwrap();
12951 assert_eq!(after.reclaimable_bytes, 0);
12952 assert!(after.total_bytes < before.total_bytes);
12953 assert_eq!(db.message_count().unwrap(), 6);
12954 assert_eq!(db.messages_fts_count().unwrap(), 6);
12955 for index in 0..3 {
12956 assert_eq!(
12957 db.search_messages(
12958 &format!("replacement-token-{index}"),
12959 &MessageFilters::default(),
12960 )
12961 .unwrap()
12962 .len(),
12963 1
12964 );
12965 }
12966 }
12967
12968 #[test]
12969 fn session_replacement_rolls_back_every_index_when_sqlite_is_full() {
12970 let dir = tempfile::tempdir().unwrap();
12971 let db = Db::open(&dir.path().join("index.db")).unwrap();
12972 let mut original = parsed_with_messages("claude:s1", &["original durable token"]);
12973 original.session.title = Some("original title".into());
12974 db.replace_session(&original, 1, 1).unwrap();
12975 db.checkpoint_truncate().unwrap();
12976
12977 let original_max_pages: i64 = db
12978 .conn
12979 .query_row("pragma max_page_count", [], |row| row.get(0))
12980 .unwrap();
12981 let current_pages: i64 = db
12982 .conn
12983 .query_row("pragma page_count", [], |row| row.get(0))
12984 .unwrap();
12985 let free_pages: i64 = db
12986 .conn
12987 .query_row("pragma freelist_count", [], |row| row.get(0))
12988 .unwrap();
12989 let page_size: i64 = db
12990 .conn
12991 .query_row("pragma page_size", [], |row| row.get(0))
12992 .unwrap();
12993 db.conn
12994 .pragma_update(None, "max_page_count", current_pages)
12995 .unwrap();
12996
12997 let mut replacement = original.clone();
12998 replacement.session.title = Some("replacement title".into());
12999 const REQUIRED_GROWTH_PAGES: i64 = 2;
13000 const REPLACEMENT_TOKEN: &str = "replacement-full-token ";
13001 let required_bytes = (free_pages + REQUIRED_GROWTH_PAGES) * page_size;
13002 let repeats = required_bytes as usize / REPLACEMENT_TOKEN.len() + 1;
13003 replacement.transcript_text = REPLACEMENT_TOKEN.repeat(repeats);
13004 replacement.messages[0].content = replacement.transcript_text.clone();
13005 let error = db.replace_session(&replacement, 2, 2).unwrap_err();
13006
13007 db.conn
13008 .pragma_update(None, "max_page_count", original_max_pages)
13009 .unwrap();
13010 assert!(error.chain().any(|cause| {
13011 cause
13012 .downcast_ref::<rusqlite::Error>()
13013 .and_then(rusqlite::Error::sqlite_error_code)
13014 == Some(ErrorCode::DiskFull)
13015 }));
13016
13017 let persisted = db.resolve_session("claude:s1").unwrap();
13018 assert_eq!(persisted.session.title.as_deref(), Some("original title"));
13019 assert_eq!(persisted.transcript_text, "original durable token");
13020 assert_eq!(
13021 db.search_messages("original durable token", &MessageFilters::default())
13022 .unwrap()
13023 .len(),
13024 1
13025 );
13026 assert!(db
13027 .search_messages("replacement-full-token", &MessageFilters::default())
13028 .unwrap()
13029 .is_empty());
13030 }
13031
13032 #[test]
13033 fn generated_trigram_query_is_a_superset_in_the_custom_index() {
13034 let dir = tempfile::tempdir().unwrap();
13037 let db = Db::open(&dir.path().join("index.db")).unwrap();
13038 db.conn
13039 .execute_batch(
13040 "insert into sessions(id, provider, provider_session_id, preview_text, \
13041 source_path, parse_version, discovery_source) \
13042 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
13043 )
13044 .unwrap();
13045 let rows = [
13046 "you forgot the tests",
13047 "well You Forgot it",
13048 "no, that's wrong",
13049 "we also need more coverage",
13050 "socket hang up ECONNRESET here",
13051 "please stop doing that",
13052 "totally unrelated message",
13053 "scatter the cats", ];
13055 {
13056 let tx = db.conn.unchecked_transaction().unwrap();
13057 {
13058 let mut stmt = tx
13059 .prepare(
13060 "insert into messages(session_id, provider, seq, role, content) \
13061 values('claude:s1','claude',?1,'user',?2)",
13062 )
13063 .unwrap();
13064 for (i, row) in rows.iter().enumerate() {
13065 stmt.execute(params![i as i64, row]).unwrap();
13066 }
13067 }
13068 tx.commit().unwrap();
13069 }
13070 crate::trigram_index::build(&db.conn, &db.runtime).unwrap();
13071 let patterns = [
13072 r"\byou forgot\b",
13073 r"\bno,?\s+that'?s\b",
13074 r"\balso need\b",
13075 "ECONNRESET",
13076 r"\bstop doing\b",
13077 r"\bcat\b", ];
13079 for pat in patterns {
13080 let regex = regex::Regex::new(&format!("(?i){pat}")).unwrap();
13081 let expected: Vec<i64> = {
13083 let mut stmt = db.conn.prepare("select id, content from messages").unwrap();
13084 let iter = stmt
13085 .query_map([], |row| {
13086 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
13087 })
13088 .unwrap();
13089 iter.filter_map(Result::ok)
13090 .filter(|(_, c)| regex.is_match(c))
13091 .map(|(id, _)| id)
13092 .collect()
13093 };
13094 if let Some(groups) = crate::trigram::trigram_prefilter_groups(pat) {
13095 let candidates = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
13096 for id in &expected {
13097 assert!(
13098 candidates.contains(id),
13099 "SUPERSET VIOLATION: {pat:?} -> {groups:?} missed id {id}",
13100 );
13101 }
13102 }
13103 }
13105 }
13106
13107 #[test]
13108 fn detail_mode_comparison_like_on_external_content() {
13109 let dir = tempfile::tempdir().unwrap();
13121 let size_of = |variant: &str, detail_clause: &str| -> (i64, rusqlite::Connection) {
13122 let conn =
13123 rusqlite::Connection::open(dir.path().join(format!("{variant}.db"))).unwrap();
13124 conn.execute_batch(
13125 "create table messages(id integer primary key, content text not null);",
13126 )
13127 .unwrap();
13128 {
13129 let tx = conn.unchecked_transaction().unwrap();
13130 {
13131 let mut stmt = tx
13132 .prepare("insert into messages(content) values(?1)")
13133 .unwrap();
13134 stmt.execute(["the socket failed with ECONNRESET) today"])
13135 .unwrap();
13136 stmt.execute(["you forgot the tests again"]).unwrap();
13137 stmt.execute(["an unrelated assistant message"]).unwrap();
13138 for i in 0..3000 {
13140 stmt.execute([format!(
13141 "filler row {i} lorem ipsum dolor sit amet consectetur adipiscing"
13142 )])
13143 .unwrap();
13144 }
13145 }
13146 tx.commit().unwrap();
13147 }
13148 conn.execute_batch(&format!(
13149 "create virtual table tri using fts5(content, content='messages', \
13150 content_rowid='id', tokenize='trigram'{detail_clause}); \
13151 insert into tri(tri) values('rebuild');",
13152 ))
13153 .unwrap();
13154 let pages: i64 = conn
13155 .query_row("pragma page_count", [], |r| r.get(0))
13156 .unwrap();
13157 let page_size: i64 = conn
13158 .query_row("pragma page_size", [], |r| r.get(0))
13159 .unwrap();
13160 (pages * page_size, conn)
13161 };
13162
13163 let (full_bytes, full) = size_of("full", "");
13164 let (none_bytes, none) = size_of("none", ", detail='none'");
13165
13166 let full_match = |q: &str| -> i64 {
13168 full.query_row("select count(*) from tri where tri match ?1", [q], |r| {
13169 r.get(0)
13170 })
13171 .unwrap()
13172 };
13173 assert_eq!(
13174 full_match("\"econnreset\""),
13175 1,
13176 "detail=full MATCH substring-in-token"
13177 );
13178 assert_eq!(
13179 full_match("\"you forgot\""),
13180 1,
13181 "detail=full MATCH multi-word phrase"
13182 );
13183
13184 let none_like = |needle: &str| -> i64 {
13188 none.query_row(
13189 "select count(*) from tri where content like ?1 escape '\\'",
13190 [format!("%{needle}%")],
13191 |r| r.get(0),
13192 )
13193 .unwrap()
13194 };
13195 assert_eq!(
13196 none_like("econnreset"),
13197 1,
13198 "detail=none LIKE substring-in-token MUST work on external content",
13199 );
13200 assert_eq!(
13201 none_like("you forgot"),
13202 1,
13203 "detail=none LIKE multi-word phrase MUST work on external content",
13204 );
13205 assert_eq!(
13207 none_like("zzqqxx_absent"),
13208 0,
13209 "detail=none LIKE rejects absent substring"
13210 );
13211
13212 let none_match = |needle: &str| -> i64 {
13218 let query = crate::trigram::trigram_prefilter(needle)
13219 .unwrap_or_else(|| panic!("needle {needle:?} must be prefilterable"));
13220 none.query_row(
13221 "select count(*) from tri where tri match ?1",
13222 [query],
13223 |r| r.get(0),
13224 )
13225 .unwrap()
13226 };
13227 assert!(
13228 none_match("econnreset") >= 1,
13229 "detail=none AND-of-trigrams MATCH finds substring-in-token on external content",
13230 );
13231 assert!(
13232 none_match("you forgot") >= 1,
13233 "detail=none AND-of-trigrams MATCH finds multi-word substring on external content",
13234 );
13235 assert_eq!(
13236 none_match("zzqqwwxx"),
13237 0,
13238 "detail=none AND-of-trigrams MATCH rejects an absent needle",
13239 );
13240
13241 let plan: String = {
13243 let mut stmt = none
13244 .prepare(
13245 "explain query plan select rowid from tri \
13246 where content like '%econnreset%' escape '\\'",
13247 )
13248 .unwrap();
13249 let rows = stmt
13250 .query_map([], |r| r.get::<_, String>(3))
13251 .unwrap()
13252 .filter_map(Result::ok)
13253 .collect::<Vec<_>>()
13254 .join(" | ");
13255 rows
13256 };
13257 eprintln!(
13259 "[#230] index size: detail=full={}KB detail=none={}KB (full is {:.1}x none)",
13260 full_bytes / 1024,
13261 none_bytes / 1024,
13262 full_bytes as f64 / none_bytes.max(1) as f64,
13263 );
13264 eprintln!("[#230] detail=none LIKE query plan: {plan}");
13265 assert!(
13266 !plan.to_lowercase().contains("scan messages"),
13267 "detail=none LIKE should not linear-scan the messages table; plan was: {plan}",
13268 );
13269 }
13270
13271 #[test]
13272 fn regex_search_composes_with_role_and_session_scope() {
13273 let dir = tempfile::tempdir().unwrap();
13277 let db = Db::open(&dir.path().join("index.db")).unwrap();
13278 db.conn
13279 .execute_batch(
13280 "insert into sessions(id, provider, provider_session_id, preview_text, \
13281 source_path, parse_version, discovery_source) values \
13282 ('claude:a','claude','a','','/x','v1','jsonl'), \
13283 ('claude:b','claude','b','','/x','v1','jsonl');",
13284 )
13285 .unwrap();
13286 {
13287 let tx = db.conn.unchecked_transaction().unwrap();
13288 {
13289 let mut stmt = tx
13290 .prepare(
13291 "insert into messages(session_id, provider, seq, role, content) \
13292 values(?1,'claude',?2,?3,?4)",
13293 )
13294 .unwrap();
13295 stmt.execute(params!["claude:a", 0, "user", "needle_xyz in a user"])
13296 .unwrap();
13297 stmt.execute(params![
13298 "claude:a",
13299 1,
13300 "assistant",
13301 "needle_xyz in a assistant"
13302 ])
13303 .unwrap();
13304 stmt.execute(params!["claude:b", 0, "user", "needle_xyz in b user"])
13305 .unwrap();
13306 }
13307 tx.commit().unwrap();
13308 }
13309 let count = |role: Option<Role>, session_id: Option<&str>| -> usize {
13310 db.search_messages(
13311 "needle_xyz",
13312 &MessageFilters {
13313 match_mode: MessageSearchMode::Regex,
13314 role,
13315 session_id: session_id.map(str::to_string),
13316 ..Default::default()
13317 },
13318 )
13319 .unwrap()
13320 .len()
13321 };
13322 assert_eq!(count(None, None), 3, "unscoped: all three rows");
13323 assert_eq!(
13324 count(Some(Role::User), None),
13325 2,
13326 "role scope narrows to user rows"
13327 );
13328 assert_eq!(
13329 count(None, Some("claude:a")),
13330 2,
13331 "session scope narrows to session a"
13332 );
13333 assert_eq!(
13334 count(Some(Role::User), Some("claude:a")),
13335 1,
13336 "role+session scope composes",
13337 );
13338 }
13339
13340 #[test]
13341 fn trigram_search_correct_across_all_providers() {
13342 let dir = tempfile::tempdir().unwrap();
13346 let db = Db::open(&dir.path().join("index.db")).unwrap();
13347 for p in [
13348 "claude",
13349 "claude-desktop",
13350 "codex",
13351 "cursor",
13352 "antigravity",
13353 "pi",
13354 ] {
13355 db.conn
13356 .execute(
13357 "insert into sessions(id, provider, provider_session_id, preview_text, \
13358 source_path, parse_version, discovery_source) \
13359 values(?1, ?2, 's', '', '/x', 'v1', 'jsonl')",
13360 params![format!("{p}:s"), p],
13361 )
13362 .unwrap();
13363 }
13364 let rows: &[(&str, &str, &str)] = &[
13367 (
13368 "claude",
13369 "tool",
13370 r#"{"type":"tool_result","content":"net error ECONNRESET) deploy"}"#,
13371 ),
13372 (
13373 "claude-desktop",
13374 "assistant",
13375 "Desktop local agent saw ECONNRESET too",
13376 ),
13377 (
13378 "codex",
13379 "assistant",
13380 "```rust\nconnect()?; // ECONNRESET retry\n```",
13381 ),
13382 ("cursor", "user", "hey you forgot the ECONNRESET retry path"),
13383 (
13384 "antigravity",
13385 "assistant",
13386 "MODEL: ECONNRESET observed — naïve café résumé",
13387 ),
13388 ("pi", "user", "ECONNRESET again"),
13389 ];
13390 {
13391 let tx = db.conn.unchecked_transaction().unwrap();
13392 {
13393 let mut stmt = tx
13394 .prepare(
13395 "insert into messages(session_id, provider, seq, role, content) \
13396 values(?1,?2,?3,?4,?5)",
13397 )
13398 .unwrap();
13399 for (i, (p, role, content)) in rows.iter().enumerate() {
13400 stmt.execute(params![format!("{p}:s"), p, i as i64, role, content])
13401 .unwrap();
13402 }
13403 }
13404 tx.commit().unwrap();
13405 }
13406 let providers_for = |query: &str, filters: MessageFilters| -> Vec<String> {
13409 let mut got: Vec<String> = db
13410 .search_messages(query, &filters)
13411 .unwrap()
13412 .into_iter()
13413 .map(|h| h.provider.as_str().to_string())
13414 .collect();
13415 got.sort();
13416 got
13417 };
13418 let all = providers_for(
13419 "ECONNRESET",
13420 MessageFilters {
13421 match_mode: MessageSearchMode::Regex,
13422 ..Default::default()
13423 },
13424 );
13425 assert_eq!(
13426 all.len(),
13427 6,
13428 "every provider's ECONNRESET found regardless of content shape"
13429 );
13430 let claude = providers_for(
13431 "ECONNRESET",
13432 MessageFilters {
13433 match_mode: MessageSearchMode::Regex,
13434 providers: Some(vec![Provider::Claude]),
13435 ..Default::default()
13436 },
13437 );
13438 assert_eq!(
13439 claude,
13440 vec!["claude"],
13441 "provider scope restricts to the claude message"
13442 );
13443 let claude_desktop = providers_for(
13444 "ECONNRESET",
13445 MessageFilters {
13446 match_mode: MessageSearchMode::Regex,
13447 providers: Some(vec![Provider::ClaudeDesktop]),
13448 ..Default::default()
13449 },
13450 );
13451 assert_eq!(
13452 claude_desktop,
13453 vec!["claude-desktop"],
13454 "provider scope restricts to the claude-desktop message"
13455 );
13456 let forgot = providers_for(
13458 r"\byou forgot\b",
13459 MessageFilters {
13460 match_mode: MessageSearchMode::Regex,
13461 ..Default::default()
13462 },
13463 );
13464 assert_eq!(
13465 forgot,
13466 vec!["cursor"],
13467 "you-forgot regex selects exactly cursor"
13468 );
13469 }
13470
13471 #[cfg(test)]
13473 fn seed_messages(db: &Db, rows: &[(&str, &str)]) {
13474 db.conn
13475 .execute_batch(
13476 "insert into sessions(id, provider, provider_session_id, preview_text, \
13477 source_path, parse_version, discovery_source) \
13478 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13479 )
13480 .unwrap();
13481 let tx = db.conn.unchecked_transaction().unwrap();
13482 {
13483 let mut stmt = tx
13484 .prepare(
13485 "insert into messages(session_id, provider, seq, role, content) \
13486 values('claude:s1','claude',?1,?2,?3)",
13487 )
13488 .unwrap();
13489 for (i, (role, content)) in rows.iter().enumerate() {
13490 stmt.execute(params![i as i64, role, content]).unwrap();
13491 }
13492 }
13493 tx.commit().unwrap();
13494 }
13495
13496 #[test]
13497 fn purge_injected_messages_removes_only_leading_marker_user_rows() {
13498 let dir = tempfile::tempdir().unwrap();
13499 let db = Db::open(&dir.path().join("index.db")).unwrap();
13500 seed_messages(
13501 &db,
13502 &[
13503 (
13504 "user",
13505 "<local-command-stdout>Set model to Opus</local-command-stdout>",
13506 ), ("user", "<local-command-stderr>boom</local-command-stderr>"), (
13509 "user",
13510 "<environment_context>\n<current_date>2026</current_date>",
13511 ), (
13513 "user",
13514 "<turn_aborted>\nThe user interrupted the previous turn on purpose.",
13515 ), ("user", "fix the failing login test before release"), ("user", "what does <local-command-stdout> mean in the logs"), (
13519 "user",
13520 "what does <turn_aborted> mean in a Codex transcript",
13521 ), (
13523 "assistant",
13524 "<local-command-stdout>tool output</local-command-stdout>",
13525 ), ],
13527 );
13528 let before = db.message_count().unwrap();
13529 let purged = db.purge_injected_messages().unwrap();
13530 assert_eq!(purged, 4, "the four leading-marker USER rows are deleted");
13531 assert_eq!(db.message_count().unwrap(), before - 4);
13532 assert_eq!(
13534 db.messages_fts_count().unwrap(),
13535 db.message_count().unwrap()
13536 );
13537 let users: Vec<String> = db
13538 .search_messages(
13539 "",
13540 &MessageFilters {
13541 role: Some(Role::User),
13542 ..Default::default()
13543 },
13544 )
13545 .unwrap()
13546 .into_iter()
13547 .map(|h| h.content)
13548 .collect();
13549 assert!(
13550 users
13551 .iter()
13552 .any(|c| c.contains("fix the failing login test")),
13553 "real prompt kept"
13554 );
13555 assert!(
13556 users
13557 .iter()
13558 .any(|c| c.starts_with("what does <local-command-stdout>")),
13559 "a non-leading mention is kept"
13560 );
13561 assert_eq!(
13562 users.len(),
13563 3,
13564 "exactly the three legitimate user messages remain"
13565 );
13566 }
13567
13568 #[test]
13569 fn regex_search_lazily_builds_custom_trigram_base_and_is_correct() {
13570 let dir = tempfile::tempdir().unwrap();
13574 let db = Db::open(&dir.path().join("index.db")).unwrap();
13575 enable_v3_custom_trigram_compatibility(&db);
13576 seed_messages(&db, &[("user", "socket failed with ECONNRESET) today")]);
13577 assert_eq!(
13579 crate::trigram_index::base_max_id(&db.conn).unwrap(),
13580 0,
13581 "precondition: custom trigram base not built before first regex use"
13582 );
13583 let filters = MessageFilters {
13584 match_mode: MessageSearchMode::Regex,
13585 ..Default::default()
13586 };
13587 let hits = db.search_messages("ECONNRESET", &filters).unwrap();
13588 assert_eq!(
13589 hits.len(),
13590 1,
13591 "regex search returns the match despite an unbuilt index"
13592 );
13593 assert!(
13594 crate::trigram_index::base_max_id(&db.conn).unwrap() > 0,
13595 "regex search lazily built the custom trigram base index"
13596 );
13597 }
13598
13599 #[test]
13600 fn lazy_trigram_build_busy_writer_falls_back_to_delta_scan() {
13601 let dir = tempfile::tempdir().unwrap();
13602 let path = dir.path().join("index.db");
13603 let writer = Db::open(&path).unwrap();
13604 let reader = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
13605 seed_messages(
13606 &writer,
13607 &[
13608 (
13609 "user",
13610 "the deploy hit ECONNRESET while the trigram base was empty",
13611 ),
13612 ("assistant", "ack"),
13613 ],
13614 );
13615 assert_eq!(
13616 crate::trigram_index::base_max_id(&reader.conn).unwrap(),
13617 0,
13618 "precondition: custom trigram base starts empty"
13619 );
13620
13621 writer.conn.execute_batch("begin immediate").unwrap();
13622 let filters = MessageFilters {
13623 match_mode: MessageSearchMode::Regex,
13624 ..Default::default()
13625 };
13626 let hits = reader.search_messages("ECONNRESET", &filters).unwrap();
13627 writer.conn.execute_batch("rollback").unwrap();
13628
13629 assert_eq!(hits.len(), 1, "busy lazy build must not drop regex hits");
13630 assert_eq!(
13631 crate::trigram_index::base_max_id(&reader.conn).unwrap(),
13632 0,
13633 "busy fallback serves the existing base and leaves rebuild for a later query"
13634 );
13635 }
13636
13637 #[test]
13638 fn progress_reporter_fires_on_lazy_build_only_and_is_silent_when_unset() {
13639 use std::sync::atomic::{AtomicU32, Ordering};
13640 use std::sync::Arc;
13641 let dir = tempfile::tempdir().unwrap();
13643 let silent = Db::open(&dir.path().join("a.db")).unwrap();
13644 enable_v3_custom_trigram_compatibility(&silent);
13645 seed_messages(&silent, &[("user", "econnreset here")]);
13646 assert_eq!(silent.ensure_trigram_base().unwrap(), 1);
13647
13648 let dir2 = tempfile::tempdir().unwrap();
13650 let mut db = Db::open(&dir2.path().join("b.db")).unwrap();
13651 enable_v3_custom_trigram_compatibility(&db);
13652 let calls = Arc::new(AtomicU32::new(0));
13653 let counter = Arc::clone(&calls);
13654 db.set_progress_reporter(move |_msg| {
13655 counter.fetch_add(1, Ordering::Relaxed);
13656 });
13657 seed_messages(&db, &[("user", "econnreset here")]);
13658 assert_eq!(db.ensure_trigram_base().unwrap(), 1);
13659 assert_eq!(
13660 calls.load(Ordering::Relaxed),
13661 1,
13662 "reporter fires once for the one-time build"
13663 );
13664 db.ensure_trigram_base().unwrap();
13665 assert_eq!(
13666 calls.load(Ordering::Relaxed),
13667 1,
13668 "no report when the base is already current"
13669 );
13670 }
13671
13672 #[test]
13673 fn search_messages_regex_prunes_lookaround_and_falls_back() {
13674 let dir = tempfile::tempdir().unwrap();
13678 let db = Db::open(&dir.path().join("index.db")).unwrap();
13679 seed_messages(
13680 &db,
13681 &[
13682 ("user", "socket failed with ECONNRESET) today"),
13683 ("user", "you forgot the tests"),
13684 ("assistant", "scatter the cats"), ("user", "a cat sat here"), ("assistant", "totally unrelated text 1234"),
13687 ],
13688 );
13689 let run = |pattern: &str| -> Vec<String> {
13690 let filters = MessageFilters {
13691 match_mode: MessageSearchMode::Regex,
13692 ..Default::default()
13693 };
13694 let mut got: Vec<String> = db
13695 .search_messages(pattern, &filters)
13696 .unwrap()
13697 .into_iter()
13698 .map(|h| h.content)
13699 .collect();
13700 got.sort();
13701 got
13702 };
13703 assert_eq!(
13704 run(r"\bcat\b"),
13705 vec!["a cat sat here".to_string()],
13706 "look-around pruned"
13707 );
13708 assert_eq!(
13709 run("ECONNRESET"),
13710 vec!["socket failed with ECONNRESET) today".to_string()],
13711 "substring inside a token",
13712 );
13713 assert_eq!(
13714 run(r"\byou forgot\b"),
13715 vec!["you forgot the tests".to_string()],
13716 "phrase"
13717 );
13718 assert_eq!(
13719 run(r"\d{4}"),
13720 vec!["totally unrelated text 1234".to_string()],
13721 "non-prefilterable pattern falls back to scan, still correct",
13722 );
13723 assert!(run(r"[0-9]{9}").is_empty(), "non-prefilterable, no match");
13724 }
13725
13726 #[test]
13727 fn search_messages_provider_filter_scopes() {
13728 let dir = tempfile::tempdir().unwrap();
13730 let db = Db::open(&dir.path().join("index.db")).unwrap();
13731 for p in ["claude", "codex"] {
13732 db.conn
13733 .execute(
13734 "insert into sessions(id, provider, provider_session_id, preview_text, \
13735 source_path, parse_version, discovery_source) \
13736 values(?1, ?2, 's', '', '/x', 'v1', 'jsonl')",
13737 params![format!("{p}:s"), p],
13738 )
13739 .unwrap();
13740 db.conn
13741 .execute(
13742 "insert into messages(session_id, provider, seq, role, content) \
13743 values(?1, ?2, 0, 'user', 'shared ECONNRESET token')",
13744 params![format!("{p}:s"), p],
13745 )
13746 .unwrap();
13747 }
13748 let scoped = |provider: Option<Provider>| -> usize {
13749 let filters = MessageFilters {
13750 match_mode: MessageSearchMode::Regex,
13751 providers: provider.map(|provider| vec![provider]),
13752 ..Default::default()
13753 };
13754 db.search_messages("ECONNRESET", &filters).unwrap().len()
13755 };
13756 assert_eq!(scoped(None), 2, "unscoped: both providers");
13757 assert_eq!(scoped(Some(Provider::Claude)), 1, "scoped to claude");
13758 assert_eq!(scoped(Some(Provider::Codex)), 1, "scoped to codex");
13759 }
13760
13761 #[test]
13766 fn find_corrections_requires_human_authorship_after_session_kind_selection() {
13767 let dir = tempfile::tempdir().unwrap();
13768 let db = Db::open(&dir.path().join("index.db")).unwrap();
13769 db.conn
13770 .execute_batch(
13771 "insert into sessions(id, provider, provider_session_id, preview_text, \
13772 source_path, parse_version, discovery_source) \
13773 values('claude:human','claude','human','','/x','v1','jsonl'); \
13774 insert into sessions(id, provider, provider_session_id, preview_text, \
13775 source_path, parse_version, discovery_source, parent_session_id) \
13776 values('claude:spawned','claude','spawned','','/x','v1','jsonl','claude:human');",
13777 )
13778 .unwrap();
13779 db.conn
13780 .execute_batch(
13781 "insert into messages(
13782 session_id, provider, seq, role, content, authorship, record_relation
13783 ) values
13784 ('claude:human','claude',0,'user','don''t forget to check the tests',
13785 'human','original'),
13786 ('claude:spawned','claude',0,'user','don''t forget to check the tests',
13787 'agent','original'),
13788 ('claude:spawned','claude',1,'user','you forgot the human follow-up',
13789 'human','original');",
13790 )
13791 .unwrap();
13792
13793 let policies = default_policies();
13794 let matches_of = |filters: &MessageFilters| -> Vec<(String, i64)> {
13795 let mut matches: Vec<(String, i64)> = db
13796 .find_corrections(&policies, filters)
13797 .unwrap()
13798 .into_iter()
13799 .map(|hit| (hit.session_id, hit.message_seq))
13800 .collect();
13801 matches.sort();
13802 matches
13803 };
13804
13805 assert_eq!(
13806 matches_of(&MessageFilters::default()),
13807 [("claude:human".to_string(), 0)],
13808 "by default a correction is what a person said, so the delegation prompt is excluded"
13809 );
13810 assert_eq!(
13811 matches_of(&MessageFilters {
13812 session_kinds: Some(vec![crate::models::SessionKind::Subagent]),
13813 ..Default::default()
13814 }),
13815 [("claude:spawned".to_string(), 1)],
13816 "session scope reopens genuine human content without reclassifying an agent prompt"
13817 );
13818 assert_eq!(
13819 matches_of(&MessageFilters {
13820 session_kinds: Some(vec![
13821 crate::models::SessionKind::User,
13822 crate::models::SessionKind::Subagent,
13823 ]),
13824 ..Default::default()
13825 }),
13826 [
13827 ("claude:human".to_string(), 0),
13828 ("claude:spawned".to_string(), 1),
13829 ],
13830 "the union contains attributable human content from both session classes"
13831 );
13832 assert!(
13833 matches_of(&MessageFilters {
13834 session_kinds: Some(Vec::new()),
13835 ..Default::default()
13836 })
13837 .is_empty(),
13838 "an empty class set matches nothing rather than silently matching all"
13839 );
13840 }
13841
13842 #[test]
13845 fn find_corrections_honors_offset_before_limit_in_newest_first_order() {
13846 let dir = tempfile::tempdir().unwrap();
13847 let db = Db::open(&dir.path().join("index.db")).unwrap();
13848 db.conn
13849 .execute_batch(
13850 "insert into sessions(id, provider, provider_session_id, preview_text, \
13851 source_path, parse_version, discovery_source) \
13852 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13853 )
13854 .unwrap();
13855 for seq in 0..5i64 {
13856 db.conn
13857 .execute(
13858 "insert into messages(
13859 session_id, provider, seq, role, ts, content,
13860 authorship, record_relation
13861 ) values(
13862 'claude:s1','claude',?1,'user',?2,?3,'human','original'
13863 )",
13864 params![
13865 seq,
13866 format!("2026-01-0{}T00:00:00Z", seq + 1),
13867 format!("you forgot item {seq}")
13868 ],
13869 )
13870 .unwrap();
13871 }
13872
13873 let policies = default_policies();
13874 let contents = |offset: usize, limit: usize| -> Vec<String> {
13875 db.find_corrections(
13876 &policies,
13877 &MessageFilters {
13878 offset,
13879 limit,
13880 ..Default::default()
13881 },
13882 )
13883 .unwrap()
13884 .into_iter()
13885 .map(|hit| hit.content)
13886 .collect()
13887 };
13888
13889 assert_eq!(contents(0, 2), ["you forgot item 4", "you forgot item 3"]);
13891 assert_eq!(
13892 contents(2, 2),
13893 ["you forgot item 2", "you forgot item 1"],
13894 "offset skips the newest N, then limit takes: one page, not a different sort"
13895 );
13896 assert_eq!(
13897 contents(3, 0),
13898 ["you forgot item 1", "you forgot item 0"],
13899 "offset applies even when limit is 0 (unbounded)"
13900 );
13901 assert!(
13902 contents(99, 0).is_empty(),
13903 "an offset past the end is empty, not a panic or a wrap-around"
13904 );
13905 }
13906
13907 #[test]
13908 fn find_corrections_pages_timestamp_ties_and_nulls_in_one_stable_total_order() {
13909 let dir = tempfile::tempdir().unwrap();
13910 let db = Db::open(&dir.path().join("index.db")).unwrap();
13911 for session_id in ["claude:a", "claude:b"] {
13912 db.conn
13913 .execute(
13914 "insert into sessions(id, provider, provider_session_id, preview_text, \
13915 source_path, parse_version, discovery_source) \
13916 values(?1,'claude',?1,'','/x','v1','jsonl')",
13917 [session_id],
13918 )
13919 .unwrap();
13920 }
13921 for (session_id, seq, timestamp, content) in [
13922 (
13923 "claude:b",
13924 0_i64,
13925 Some("2026-01-01T00:00:00Z"),
13926 "you forgot b0",
13927 ),
13928 ("claude:a", 1, Some("2026-01-01T00:00:00Z"), "you forgot a1"),
13929 ("claude:a", 0, Some("2026-01-01T00:00:00Z"), "you forgot a0"),
13930 ("claude:b", 1, None, "you forgot b1-null"),
13931 ("claude:a", 2, None, "you forgot a2-null"),
13932 ] {
13933 db.conn
13934 .execute(
13935 "insert into messages(
13936 session_id, provider, seq, role, ts, content,
13937 authorship, record_relation
13938 ) values(?1,'claude',?2,'user',?3,?4,'human','original')",
13939 params![session_id, seq, timestamp, content],
13940 )
13941 .unwrap();
13942 }
13943
13944 let policies = default_policies();
13945 let page = |offset: usize, limit: usize| {
13946 db.find_corrections(
13947 &policies,
13948 &MessageFilters {
13949 offset,
13950 limit,
13951 ..Default::default()
13952 },
13953 )
13954 .unwrap()
13955 .into_iter()
13956 .map(|hit| hit.content)
13957 .collect::<Vec<_>>()
13958 };
13959 let first = page(0, 2);
13960 let second = page(2, 2);
13961 let third = page(4, 2);
13962 let combined = first
13963 .iter()
13964 .chain(&second)
13965 .chain(&third)
13966 .cloned()
13967 .collect::<Vec<_>>();
13968
13969 assert_eq!(
13970 combined,
13971 [
13972 "you forgot a0",
13973 "you forgot a1",
13974 "you forgot b0",
13975 "you forgot a2-null",
13976 "you forgot b1-null",
13977 ],
13978 "equal and missing timestamps use session id then sequence as stable tie-breakers"
13979 );
13980 assert_eq!(combined, page(0, 0), "pages must partition the full result");
13981 }
13982
13983 #[test]
13984 fn find_corrections_scans_user_rows_and_classifies() {
13985 let dir = tempfile::tempdir().unwrap();
13990 let db = Db::open(&dir.path().join("index.db")).unwrap();
13991 db.conn
13992 .execute_batch(
13993 "insert into sessions(id, provider, provider_session_id, preview_text, \
13994 source_path, parse_version, discovery_source) \
13995 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13996 )
13997 .unwrap();
13998 let rows = [
13999 ("user", "you forgot the unit tests again"), ("user", "we also need integration coverage"), ("user", "looks great, ship it"), ("assistant", "you forgot nothing, here is the fix"), ("user", "the deploy hit econnreset once more"), ];
14005 {
14006 let tx = db.conn.unchecked_transaction().unwrap();
14007 {
14008 let mut stmt = tx
14009 .prepare(
14010 "insert into messages(
14011 session_id, provider, seq, role, content, authorship, record_relation
14012 ) values('claude:s1','claude',?1,?2,?3,'human','original')",
14013 )
14014 .unwrap();
14015 for (i, (role, content)) in rows.iter().enumerate() {
14016 stmt.execute(params![i as i64, role, content]).unwrap();
14017 }
14018 }
14019 tx.commit().unwrap();
14020 }
14021 let policies = test_policies(&[
14022 ("skip_step", &[r"\byou forgot\b"]),
14023 ("incomplete", &[r"\balso need\b"]),
14024 ]);
14025 let scan = db
14026 .find_corrections(&policies, &MessageFilters::default())
14027 .unwrap();
14028 assert_eq!(
14029 scan.len(),
14030 2,
14031 "exactly the two user corrections, assistant ignored"
14032 );
14033 assert!(scan.iter().any(|c| c.category == "skip_step"));
14034 assert!(scan.iter().any(|c| c.category == "incomplete"));
14035 assert!(
14037 !scan
14038 .iter()
14039 .any(|c| c.content.contains("you forgot nothing")),
14040 "assistant-role match excluded by the role='user' filter",
14041 );
14042 }
14043
14044 #[test]
14045 fn find_corrections_classifies_only_original_human_content_with_absolute_coordinates() {
14046 let dir = tempfile::tempdir().unwrap();
14047 let db = Db::open(&dir.path().join("index.db")).unwrap();
14048 db.conn
14049 .execute_batch(
14050 "insert into sessions (
14051 id, provider, provider_session_id, preview_text,
14052 source_path, parse_version, discovery_source
14053 ) values (
14054 'claude:provenance', 'claude', 'provenance', '',
14055 '/provenance', 'claude-v4', 'test'
14056 );
14057 insert into messages (
14058 id, session_id, provider, seq, role, kind, content,
14059 authorship, record_relation
14060 ) values
14061 (201, 'claude:provenance', 'claude', 0, 'user', 'conversation',
14062 'human original you forgot', 'human', 'original'),
14063 (202, 'claude:provenance', 'claude', 1, 'user', 'conversation',
14064 'agent original you forgot', 'agent', 'original'),
14065 (203, 'claude:provenance', 'claude', 2, 'user', 'conversation',
14066 'human mirror you forgot', 'human', 'mirror');",
14067 )
14068 .unwrap();
14069
14070 let generated_prefix = "agent you forgot | ";
14071 let human_request = "human α you forgot the tests";
14072 let mixed_content = format!("{generated_prefix}{human_request}");
14073 db.conn
14074 .execute(
14075 "insert into messages (
14076 id, session_id, provider, seq, role, kind, content,
14077 authorship, record_relation
14078 ) values (
14079 204, 'claude:provenance', 'claude', 3, 'user', 'conversation', ?1,
14080 'mixed', 'original'
14081 )",
14082 [&mixed_content],
14083 )
14084 .unwrap();
14085 let generated_end = generated_prefix.chars().count() as i64;
14086 let mixed_end = mixed_content.chars().count() as i64;
14087 db.conn
14088 .execute(
14089 "insert into message_content_parts (
14090 message_id, ordinal, start_char, end_char, authorship, origin
14091 ) values
14092 (204, 0, 0, ?1, 'generated', 'generated_summary'),
14093 (204, 1, ?1, ?2, 'human', 'direct_input')",
14094 params![generated_end, mixed_end],
14095 )
14096 .unwrap();
14097
14098 let left = "left";
14099 let generated_bridge = " generated bridge ";
14100 let right = "right";
14101 let split_content = format!("{left}{generated_bridge}{right}");
14102 db.conn
14103 .execute(
14104 "insert into messages (
14105 id, session_id, provider, seq, role, kind, content,
14106 authorship, record_relation
14107 ) values (
14108 205, 'claude:provenance', 'claude', 4, 'user', 'conversation', ?1,
14109 'mixed', 'original'
14110 )",
14111 [&split_content],
14112 )
14113 .unwrap();
14114 let left_end = left.chars().count() as i64;
14115 let bridge_end = (left.chars().count() + generated_bridge.chars().count()) as i64;
14116 let split_end = split_content.chars().count() as i64;
14117 db.conn
14118 .execute(
14119 "insert into message_content_parts (
14120 message_id, ordinal, start_char, end_char, authorship, origin
14121 ) values
14122 (205, 0, 0, ?1, 'human', 'direct_input'),
14123 (205, 1, ?1, ?2, 'generated', 'generated_summary'),
14124 (205, 2, ?2, ?3, 'human', 'direct_input')",
14125 params![left_end, bridge_end, split_end],
14126 )
14127 .unwrap();
14128
14129 let policies = test_policies(&[
14130 ("skip_step", &[r"\byou forgot\b"]),
14131 ("cross_part", &[r"\bleft\s*right\b"]),
14132 ]);
14133 let hits = db
14134 .find_corrections(&policies, &MessageFilters::default())
14135 .unwrap();
14136
14137 assert_eq!(
14138 hits.iter().map(|hit| hit.message_seq).collect::<Vec<_>>(),
14139 [0, 3],
14140 "agent-authored originals, mirrors, and artificial cross-part matches are excluded"
14141 );
14142 let mixed_hit = &hits[1];
14143 assert_eq!(mixed_hit.content, mixed_content);
14144 assert_eq!(mixed_hit.matched_text, "you forgot");
14145 let expected_start = generated_prefix.chars().count() + "human α ".chars().count();
14146 assert_eq!(mixed_hit.match_start_char, expected_start);
14147 assert_eq!(
14148 mixed_hit.match_end_char_exclusive,
14149 expected_start + "you forgot".chars().count(),
14150 "mixed-message coordinates remain absolute in the original full content"
14151 );
14152 }
14153
14154 #[test]
14155 fn find_corrections_reads_candidates_and_parts_from_one_snapshot() {
14156 let dir = tempfile::tempdir().unwrap();
14157 let path = dir.path().join("index.db");
14158 let db = Db::open(&path).unwrap();
14159 let writer = Db::open(&path).unwrap();
14160 db.conn
14161 .execute_batch(
14162 "insert into sessions (
14163 id, provider, provider_session_id, preview_text,
14164 source_path, parse_version, discovery_source
14165 ) values (
14166 'claude:snapshot', 'claude', 'snapshot', '',
14167 '/snapshot', 'claude-v4', 'test'
14168 );
14169 insert into messages (
14170 id, session_id, provider, seq, role, kind, content,
14171 authorship, record_relation
14172 ) values (
14173 301, 'claude:snapshot', 'claude', 0, 'user', 'conversation',
14174 'human you forgot', 'mixed', 'original'
14175 );
14176 insert into message_content_parts (
14177 message_id, ordinal, start_char, end_char, authorship, origin
14178 ) values (
14179 301, 0, 0, 16, 'human', 'direct_input'
14180 );",
14181 )
14182 .unwrap();
14183
14184 let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14185 let hits = db
14186 .find_corrections_with_after_candidates(&policies, &MessageFilters::default(), || {
14187 writer
14188 .conn
14189 .execute_batch(
14190 "begin immediate;
14191 update messages
14192 set content = 'generated you forgot | human okay'
14193 where id = 301;
14194 delete from message_content_parts where message_id = 301;
14195 insert into message_content_parts (
14196 message_id, ordinal, start_char, end_char, authorship, origin
14197 ) values
14198 (301, 0, 0, 23, 'generated', 'generated_summary'),
14199 (301, 1, 23, 33, 'human', 'direct_input');
14200 commit;",
14201 )
14202 .unwrap();
14203 })
14204 .unwrap();
14205
14206 assert_eq!(hits.len(), 1);
14207 assert_eq!(hits[0].content, "human you forgot");
14208 assert_eq!(hits[0].matched_text, "you forgot");
14209 assert_eq!(
14210 writer
14211 .conn
14212 .query_row("select content from messages where id = 301", [], |row| {
14213 row.get::<_, String>(0)
14214 })
14215 .unwrap(),
14216 "generated you forgot | human okay",
14217 "the writer committed while the reader retained the older snapshot"
14218 );
14219 }
14220
14221 #[test]
14222 fn find_corrections_honors_provider_scope() {
14223 let dir = tempfile::tempdir().unwrap();
14224 let db = Db::open(&dir.path().join("index.db")).unwrap();
14225 for provider in [Provider::Claude, Provider::Codex] {
14226 let session_id = format!("{}:s1", provider.as_str());
14227 db.conn
14228 .execute(
14229 "insert into sessions(id, provider, provider_session_id, preview_text, \
14230 source_path, parse_version, discovery_source) \
14231 values(?1, ?2, 's1', '', '/x', 'v1', 'jsonl')",
14232 params![session_id, provider.as_str()],
14233 )
14234 .unwrap();
14235 db.conn
14236 .execute(
14237 "insert into messages(
14238 session_id, provider, seq, role, content, authorship, record_relation
14239 ) values(
14240 ?1, ?2, 0, 'user', 'you forgot the provider filter', 'human', 'original'
14241 )",
14242 params![session_id, provider.as_str()],
14243 )
14244 .unwrap();
14245 }
14246 let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14247
14248 let hits = db
14249 .find_corrections(
14250 &policies,
14251 &MessageFilters {
14252 providers: Some(vec![Provider::Claude]),
14253 ..Default::default()
14254 },
14255 )
14256 .unwrap();
14257
14258 assert_eq!(hits.len(), 1);
14259 assert_eq!(hits[0].provider, Provider::Claude);
14260 assert_eq!(hits[0].session_id, "claude:s1");
14261 }
14262
14263 #[test]
14264 fn find_corrections_parallel_matches_sequential() {
14265 let dir = tempfile::tempdir().unwrap();
14271 let db = Db::open(&dir.path().join("index.db")).unwrap();
14272 db.conn
14273 .execute_batch(
14274 "insert into sessions(id, provider, provider_session_id, preview_text, \
14275 source_path, parse_version, discovery_source) \
14276 values('claude:s1','claude','s1','','/x','v1','jsonl');",
14277 )
14278 .unwrap();
14279 let base = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z").unwrap();
14280 let n = 600i64;
14281 {
14282 let tx = db.conn.unchecked_transaction().unwrap();
14283 {
14284 let mut stmt = tx
14285 .prepare(
14286 "insert into messages(
14287 session_id, provider, seq, role, ts, content,
14288 authorship, record_relation
14289 ) values(
14290 'claude:s1','claude',?1,'user',?2,?3,'human','original'
14291 )",
14292 )
14293 .unwrap();
14294 for i in 0..n {
14295 let ts = (base + chrono::Duration::seconds(i)).to_rfc3339();
14296 let content = if i % 5 == 0 {
14297 format!("row-{i} you forgot the tests")
14298 } else {
14299 format!("row-{i} all good here")
14300 };
14301 stmt.execute(params![i, ts, content]).unwrap();
14302 }
14303 }
14304 tx.commit().unwrap();
14305 }
14306 let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14307
14308 let expected: Vec<i64> = (0..n).rev().filter(|i| i % 5 == 0).collect();
14311
14312 let all = db
14313 .find_corrections(&policies, &MessageFilters::default())
14314 .unwrap();
14315 assert_eq!(all.len(), expected.len(), "match count");
14316 for (hit, want_i) in all.iter().zip(&expected) {
14317 assert_eq!(hit.category, "skip_step");
14318 assert_eq!(hit.matched_text, "you forgot");
14319 assert!(
14320 hit.content.starts_with(&format!("row-{want_i} ")),
14321 "order mismatch: got {:?}, expected row-{want_i}",
14322 hit.content
14323 );
14324 }
14325
14326 let limited = db
14328 .find_corrections(
14329 &policies,
14330 &MessageFilters {
14331 limit: 10,
14332 ..Default::default()
14333 },
14334 )
14335 .unwrap();
14336 assert_eq!(limited.len(), 10);
14337 for (hit, want_i) in limited.iter().zip(expected.iter().take(10)) {
14338 assert!(hit.content.starts_with(&format!("row-{want_i} ")));
14339 }
14340 }
14341
14342 #[test]
14343 fn regex_search_corpus_gate_is_result_equivalent() {
14344 let dir = tempfile::tempdir().unwrap();
14350 let db = Db::open(&dir.path().join("index.db")).unwrap();
14351 seed_messages(
14352 &db,
14353 &[
14354 ("user", "the deploy hit ECONNRESET in prod"),
14355 ("assistant", "ECONNRESET means the socket closed"),
14356 ("tool", "stderr: ECONNRESET at line 42"),
14357 ("user", "an unrelated note about apples"),
14358 ],
14359 );
14360 let search = |role: Option<Role>| -> Vec<(Role, String)> {
14361 let filters = MessageFilters {
14362 match_mode: MessageSearchMode::Regex,
14363 role,
14364 ..Default::default()
14365 };
14366 db.search_messages("ECONNRESET", &filters)
14367 .unwrap()
14368 .into_iter()
14369 .map(|hit| (hit.role, hit.content))
14370 .collect()
14371 };
14372 let all = search(None);
14374 assert_eq!(
14375 all.len(),
14376 3,
14377 "ECONNRESET in the user + assistant + tool rows"
14378 );
14379 let user_only = search(Some(Role::User));
14381 let expected_user: Vec<(Role, String)> = all
14382 .iter()
14383 .filter(|(role, _)| *role == Role::User)
14384 .cloned()
14385 .collect();
14386 assert_eq!(
14387 user_only, expected_user,
14388 "gate's scan path agrees with the prefilter path restricted to role=user"
14389 );
14390 assert_eq!(user_only.len(), 1, "exactly the one user ECONNRESET row");
14391 }
14392
14393 #[test]
14394 fn explain_message_search_counts_candidates_within_corpus() {
14395 let dir = tempfile::tempdir().unwrap();
14396 let mut db = Db::open(&dir.path().join("index.db")).unwrap();
14397 db.prefilter_min_corpus = 1;
14398 db.upsert_session(
14400 &parsed_with_messages(
14401 "claude:s1",
14402 &[
14403 "zebracode appears here once",
14404 "common text alpha",
14405 "common text bravo",
14406 "common text charlie",
14407 ],
14408 ),
14409 1,
14410 100,
14411 )
14412 .unwrap();
14413
14414 let selective = MessageFilters {
14417 role: Some(Role::User),
14418 match_mode: MessageSearchMode::Regex,
14419 ..Default::default()
14420 };
14421 let ex = db
14422 .explain_message_search("(?i)zebra.ode", &selective)
14423 .unwrap();
14424 assert_eq!(
14425 ex.corpus, 4,
14426 "all four user messages form the selectivity denominator"
14427 );
14428 assert!(
14429 ex.prefilter.is_some(),
14430 "a >=3-char literal yields a trigram prefilter"
14431 );
14432 let candidates = ex
14433 .candidates
14434 .expect("the regex path reports a candidate count");
14435 assert!(
14436 candidates <= ex.corpus,
14437 "candidates are always a subset of the corpus"
14438 );
14439 assert_eq!(
14440 candidates, 1,
14441 "only the zebracode row survives the trigram prefilter"
14442 );
14443
14444 let no_anchor = MessageFilters {
14447 role: Some(Role::User),
14448 match_mode: MessageSearchMode::Regex,
14449 ..Default::default()
14450 };
14451 let ex2 = db.explain_message_search("a.b", &no_anchor).unwrap();
14452 assert!(ex2.prefilter.is_none(), "no >=3-char anchor → no prefilter");
14453 assert!(
14454 ex2.candidates.is_none(),
14455 "no prefilter → no candidate count"
14456 );
14457 assert_eq!(ex2.corpus, 4);
14458 }
14459
14460 #[test]
14461 fn schema_v4_search_explain_uses_sqlite_prefilter_without_legacy_threshold() {
14462 let dir = tempfile::tempdir().unwrap();
14463 let db = Db::open(&dir.path().join("index.db")).unwrap();
14464 db.upsert_session(
14465 &parsed_with_messages("claude:s1", &["zebracode appears here once"]),
14466 1,
14467 100,
14468 )
14469 .unwrap();
14470
14471 let filters = MessageFilters {
14472 role: Some(Role::User),
14473 match_mode: MessageSearchMode::Regex,
14474 ..Default::default()
14475 };
14476 let (hits, explain) = db
14477 .search_messages_with_explain("zebracode", &filters, true)
14478 .unwrap();
14479 let explain = explain.expect("explain requested");
14480
14481 assert_eq!(hits.len(), 1);
14482 assert!(explain.prefilter.is_some(), "anchor is available");
14483 assert_eq!(explain.candidates, Some(1));
14484 assert!(explain.prefilter_skipped.is_none());
14485 }
14486
14487 #[test]
14488 fn internal_message_order_is_typed_scoped_and_reversible() {
14489 let dir = tempfile::tempdir().unwrap();
14490 let db = Db::open(&dir.path().join("index.db")).unwrap();
14491 db.upsert_session(
14492 &parsed_with_messages("claude:ordered", &["zero", "one", "two", "three", "four"]),
14493 1,
14494 1,
14495 )
14496 .unwrap();
14497
14498 let newest = db
14499 .search_messages_ordered(
14500 "",
14501 &MessageFilters {
14502 session_id: Some("claude:ordered".to_string()),
14503 limit: 2,
14504 ..Default::default()
14505 },
14506 MessageOrder::NewestFirst,
14507 )
14508 .unwrap();
14509 assert_eq!(
14510 newest.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
14511 vec![4, 3]
14512 );
14513
14514 let oldest = db
14515 .search_messages(
14516 "",
14517 &MessageFilters {
14518 session_id: Some("claude:ordered".to_string()),
14519 limit: 2,
14520 ..Default::default()
14521 },
14522 )
14523 .unwrap();
14524 assert_eq!(
14525 oldest.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
14526 vec![0, 1]
14527 );
14528
14529 let error = db
14530 .search_messages_ordered("", &MessageFilters::default(), MessageOrder::NewestFirst)
14531 .unwrap_err();
14532 assert!(error
14533 .to_string()
14534 .contains("newest-first message order requires session_id"));
14535 }
14536
14537 #[cfg(test)]
14539 fn parsed_with_messages(id: &str, contents: &[&str]) -> crate::models::ParsedSession {
14540 use crate::models::{Message, ParsedSession, SessionRecord};
14541 let messages = contents
14542 .iter()
14543 .enumerate()
14544 .map(|(i, c)| Message {
14545 seq: i as i64,
14546 role: Role::User,
14547 ts: None,
14548 tool_name: None,
14549 kind: crate::models::MessageKind::Conversation,
14550 tool_call_id: None,
14551 is_compaction: false,
14552 content: c.to_string(),
14553 provenance: crate::models::MessageProvenance::default(),
14554 })
14555 .collect();
14556 ParsedSession {
14557 session: SessionRecord {
14558 id: id.to_string(),
14559 provider: Provider::Claude,
14560 provider_session_id: "s".into(),
14561 title: None,
14562 summary: None,
14563 cwd: None,
14564 repo_root: None,
14565 created_at: None,
14566 updated_at: None,
14567 last_message_at: None,
14568 preview_text: String::new(),
14569 source_path: "/x".into(),
14570 message_count: Some(contents.len() as i64),
14571 parse_version: "v1".into(),
14572 raw_metadata_json: None,
14573 parse_warning: None,
14574 discovery_source: "jsonl".into(),
14575 parent_session_id: None,
14578 agent_label: None,
14579 },
14580 transcript_text: contents.join("\n\n"),
14581 messages,
14582 file_edits: Vec::new(),
14583 }
14584 }
14585
14586 #[test]
14587 fn upsert_appends_only_new_messages_when_session_grows() {
14588 let dir = tempfile::tempdir().unwrap();
14594 let db = Db::open(&dir.path().join("index.db")).unwrap();
14595 let contents = |db: &Db| -> Vec<String> {
14596 let mut s = db
14597 .conn
14598 .prepare("select content from messages where session_id='claude:s1' order by seq")
14599 .unwrap();
14600 s.query_map([], |r| r.get(0))
14601 .unwrap()
14602 .filter_map(Result::ok)
14603 .collect()
14604 };
14605 let tagged = |db: &Db| -> i64 {
14606 db.conn
14607 .query_row(
14608 "select count(*) from messages where session_id='claude:s1' \
14609 and tool_name='SENTINEL'",
14610 [],
14611 |r| r.get(0),
14612 )
14613 .unwrap()
14614 };
14615 db.upsert_session(
14616 &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie"]),
14617 1,
14618 100,
14619 )
14620 .unwrap();
14621 db.conn
14623 .execute(
14624 "update messages set tool_name='SENTINEL' where session_id='claude:s1'",
14625 [],
14626 )
14627 .unwrap();
14628
14629 db.upsert_session(
14631 &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie", "delta", "echo"]),
14632 2,
14633 200,
14634 )
14635 .unwrap();
14636 assert_eq!(
14637 contents(&db),
14638 ["alpha", "bravo", "charlie", "delta", "echo"]
14639 );
14640 assert_eq!(
14641 tagged(&db),
14642 3,
14643 "prefix rows RETAINED the sentinel (appended, not re-indexed)"
14644 );
14645 let new_found = db
14648 .search_messages(
14649 "delta",
14650 &MessageFilters {
14651 match_mode: MessageSearchMode::Regex,
14652 ..Default::default()
14653 },
14654 )
14655 .unwrap();
14656 assert_eq!(new_found.len(), 1, "the appended message is searchable");
14657
14658 db.upsert_session(
14660 &parsed_with_messages("claude:s1", &["alpha", "bravo"]),
14661 3,
14662 60,
14663 )
14664 .unwrap();
14665 assert_eq!(
14666 contents(&db),
14667 ["alpha", "bravo"],
14668 "shrink re-replaces fully"
14669 );
14670 assert_eq!(tagged(&db), 0, "shrink did a full replace");
14671
14672 db.upsert_session(
14674 &parsed_with_messages("claude:s1", &["alpha", "boundary"]),
14675 4,
14676 70,
14677 )
14678 .unwrap();
14679 db.conn
14680 .execute(
14681 "update messages set tool_name='SENTINEL' where session_id='claude:s1'",
14682 [],
14683 )
14684 .unwrap();
14685 db.upsert_session(
14686 &parsed_with_messages("claude:s1", &["alpha", "CHANGED", "extra"]),
14687 5,
14688 90,
14689 )
14690 .unwrap();
14691 assert_eq!(
14692 contents(&db),
14693 ["alpha", "CHANGED", "extra"],
14694 "boundary content changed → full replace keeps content correct",
14695 );
14696 assert_eq!(tagged(&db), 0, "boundary mismatch forced a full replace");
14697 }
14698
14699 #[test]
14700 fn replace_session_rewrites_matching_prefix_metadata_on_full_reindex() {
14701 let dir = tempfile::tempdir().unwrap();
14702 let db = Db::open(&dir.path().join("index.db")).unwrap();
14703
14704 db.replace_session(
14705 &parsed_with_messages("claude:s1", &["alpha", "bravo"]),
14706 1,
14707 100,
14708 )
14709 .unwrap();
14710 db.conn
14711 .execute(
14712 "update messages set role='tool', tool_name='STALE' where session_id='claude:s1' and seq=0",
14713 [],
14714 )
14715 .unwrap();
14716
14717 db.replace_session(
14718 &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie"]),
14719 2,
14720 150,
14721 )
14722 .unwrap();
14723 let row: (String, Option<String>) = db
14724 .conn
14725 .query_row(
14726 "select role, tool_name from messages where session_id='claude:s1' and seq=0",
14727 [],
14728 |r| Ok((r.get(0)?, r.get(1)?)),
14729 )
14730 .unwrap();
14731 assert_eq!(
14732 row,
14733 ("user".to_string(), None),
14734 "full replace must repair stale prefix metadata even when content is unchanged"
14735 );
14736 }
14737
14738 #[test]
14739 fn tail_flow_appends_without_reparsing_prefix() {
14740 use crate::providers::claude::ClaudeAdapter;
14744 use std::io::Write;
14745
14746 let dir = tempfile::tempdir().unwrap();
14747 let file = dir.path().join("probe.jsonl");
14748 let line = |ts: &str, role: &str, text: &str| {
14749 format!(
14750 "{{\"type\":\"{role}\",\"sessionId\":\"probe\",\"timestamp\":\"{ts}\",\
14751 \"message\":{{\"role\":\"{role}\",\"content\":[{{\"type\":\"text\",\"text\":\"{text}\"}}]}}}}\n"
14752 )
14753 };
14754 let initial = format!(
14755 "{}{}",
14756 line("2026-06-01T10:00:00Z", "user", "first prompt"),
14757 line("2026-06-01T10:00:05Z", "assistant", "first reply"),
14758 );
14759 std::fs::write(&file, &initial).unwrap();
14760
14761 let claude = ClaudeAdapter::new(vec![dir.path().to_path_buf()]);
14762 let source = crate::models::SourceFile {
14763 provider: Provider::Claude,
14764 path: file.clone(),
14765 mtime_ns: 1,
14766 size_bytes: std::fs::metadata(&file).unwrap().len() as i64,
14767 };
14768 let source_path = crate::util::normalize_path(&file);
14769 let db = Db::open(&dir.path().join("index.db")).unwrap();
14770 let mut parsed = claude.parse(&source);
14771 crate::util::backfill_session_dates(&mut parsed.session, source.mtime_ns);
14772 db.upsert_session(&parsed, source.mtime_ns, source.size_bytes)
14773 .unwrap();
14774 db.set_file_checkpoint(
14775 Provider::Claude,
14776 &source_path,
14777 crate::tail::complete_prefix_offset(&file).unwrap(),
14778 &crate::tail::prefix_fingerprint(&file).unwrap(),
14779 )
14780 .unwrap();
14781 assert_eq!(db.message_count().unwrap(), 2);
14782
14783 db.conn
14784 .execute(
14785 "update messages set content='CORRUPTED_PROBE' \
14786 where session_id='claude:probe' and seq=0",
14787 [],
14788 )
14789 .unwrap();
14790
14791 std::fs::OpenOptions::new()
14793 .append(true)
14794 .open(&file)
14795 .unwrap()
14796 .write_all(line("2026-06-01T10:01:00Z", "user", "second prompt").as_bytes())
14797 .unwrap();
14798 let new_size = std::fs::metadata(&file).unwrap().len() as i64;
14799 let (offset, stored_fp) = db
14800 .file_checkpoint(
14801 Provider::Claude,
14802 &source_path,
14803 crate::util::provider_parse_version(Provider::Claude),
14804 )
14805 .unwrap()
14806 .unwrap();
14807 assert!(
14808 crate::tail::fingerprint_matches(&file, &stored_fp).unwrap(),
14809 "an append must keep the head fingerprint matching"
14810 );
14811 let tail = crate::tail::tail_parse(&file, offset, |cursor, path| {
14812 claude.parse_reader(cursor, path)
14813 })
14814 .unwrap()
14815 .expect("a new complete line was appended");
14816 db.append_tail(&tail, 2, new_size).unwrap();
14817
14818 assert_eq!(
14819 db.message_count().unwrap(),
14820 3,
14821 "the appended turn is indexed"
14822 );
14823 let seq2: String = db
14824 .conn
14825 .query_row(
14826 "select content from messages where session_id='claude:probe' and seq=2",
14827 [],
14828 |row| row.get(0),
14829 )
14830 .unwrap();
14831 assert_eq!(
14832 seq2, "second prompt",
14833 "new message appended at the next seq"
14834 );
14835 let seq0: String = db
14836 .conn
14837 .query_row(
14838 "select content from messages where session_id='claude:probe' and seq=0",
14839 [],
14840 |row| row.get(0),
14841 )
14842 .unwrap();
14843 assert_eq!(
14844 seq0, "CORRUPTED_PROBE",
14845 "tail append must NOT reparse/replace the prefix rows"
14846 );
14847 assert_eq!(
14848 db.messages_fts_count().unwrap(),
14849 db.message_count().unwrap(),
14850 "FTS in sync"
14851 );
14852 }
14853
14854 #[test]
14855 fn vocabulary_reports_term_frequencies() {
14856 let dir = tempfile::tempdir().unwrap();
14859 let db = Db::open(&dir.path().join("index.db")).unwrap();
14860 seed_messages(
14861 &db,
14862 &[
14863 ("user", "alpha alpha bravo"), ("user", "alpha charlie"), ],
14866 );
14867 let vocab = db.vocabulary(false, 0, None).unwrap();
14868 let alpha = vocab
14869 .iter()
14870 .find(|(t, _, _)| t == "alpha")
14871 .expect("alpha present");
14872 assert_eq!(alpha.1, 2, "alpha appears in 2 documents");
14873 assert_eq!(alpha.2, 3, "alpha occurs 3 times total");
14874 assert_eq!(vocab[0].0, "alpha", "most frequent term first");
14876 let schema_before = schema_fingerprint(&db.conn);
14878 let row_vocab_exists: bool = db
14879 .conn
14880 .query_row(
14881 "select exists(
14882 select 1 from sqlite_schema
14883 where type = 'table' and name = 'messages_trigram_terms'
14884 )",
14885 [],
14886 |row| row.get(0),
14887 )
14888 .unwrap();
14889 assert!(
14890 row_vocab_exists,
14891 "v4 must provide the row-level trigram vocabulary used for bounded output"
14892 );
14893 let tri = db.vocabulary(true, 0, None).unwrap();
14894 assert!(
14895 tri.iter().any(|(t, _, _)| t == "alp"),
14896 "trigram vocab has 3-gram terms"
14897 );
14898 assert_eq!(
14899 schema_fingerprint(&db.conn),
14900 schema_before,
14901 "reading v4 trigram vocabulary must not mutate any schema object"
14902 );
14903 }
14904
14905 #[test]
14913 fn a_vocabulary_prefix_ending_in_sigma_finds_the_term_the_index_folded_it_to() {
14914 let dir = tempfile::tempdir().unwrap();
14915 let db = Db::open(&dir.path().join("index.db")).unwrap();
14916 seed_messages(&db, &[("user", "ΟΔΟΣ ΟΔΟΣΣΑ")]);
14917
14918 let stored: Vec<String> = db
14920 .vocabulary(false, 0, Some("οδοσ"))
14921 .unwrap()
14922 .into_iter()
14923 .map(|(term, _, _)| term)
14924 .collect();
14925 assert_eq!(
14926 stored,
14927 vec!["οδοσ".to_string(), "οδοσσα".to_string()],
14928 "the folded spelling is what the index stores"
14929 );
14930
14931 for prefix in ["ΟΔΟΣ", "οδος", "Οδος"] {
14932 let terms: Vec<String> = db
14933 .vocabulary(false, 0, Some(prefix))
14934 .unwrap()
14935 .into_iter()
14936 .map(|(term, _, _)| term)
14937 .collect();
14938 assert_eq!(
14939 terms,
14940 vec!["οδοσ".to_string(), "οδοσσα".to_string()],
14941 "prefix {prefix:?} reaches the terms the index folded it to"
14942 );
14943 }
14944 }
14945
14946 #[test]
14947 fn vocabulary_prefix_selects_only_terms_starting_with_it() {
14948 let dir = tempfile::tempdir().unwrap();
14952 let db = Db::open(&dir.path().join("index.db")).unwrap();
14953 seed_messages(
14954 &db,
14955 &[
14956 ("user", "alpha alpha bravo alphabet"),
14957 ("user", "alpha charlie alphabet"),
14958 ],
14959 );
14960
14961 let terms = |prefix: Option<&str>, limit: usize| -> Vec<String> {
14962 db.vocabulary(false, limit, prefix)
14963 .unwrap()
14964 .into_iter()
14965 .map(|(term, _, _)| term)
14966 .collect()
14967 };
14968
14969 let mut matched = terms(Some("alph"), 0);
14970 matched.sort();
14971 assert_eq!(
14972 matched,
14973 vec!["alpha".to_string(), "alphabet".to_string()],
14974 "a prefix keeps every term extending it and drops the rest"
14975 );
14976 assert_eq!(
14977 terms(Some("alphabet"), 0),
14978 vec!["alphabet".to_string()],
14979 "a prefix equal to a whole term still matches that term"
14980 );
14981 assert!(
14982 terms(Some("zulu"), 0).is_empty(),
14983 "an unindexed prefix reports nothing rather than falling back to every term"
14984 );
14985
14986 let mut every = terms(None, 0);
14989 let mut empty = terms(Some(""), 0);
14990 every.sort();
14991 empty.sort();
14992 assert_eq!(every, empty, "an empty prefix selects every term");
14993
14994 assert_eq!(
14997 terms(Some("alph"), 1),
14998 vec!["alpha".to_string()],
14999 "limit applies after the prefix, most frequent first"
15000 );
15001
15002 let alpha = db.vocabulary(false, 0, Some("alpha")).unwrap();
15005 let alpha = alpha
15006 .iter()
15007 .find(|(term, _, _)| term == "alpha")
15008 .expect("alpha present");
15009 assert_eq!((alpha.1, alpha.2), (2, 3), "2 documents, 3 occurrences");
15010
15011 assert!(
15012 db.vocabulary(true, 0, Some("alp"))
15013 .unwrap()
15014 .iter()
15015 .all(|(term, _, _)| term.starts_with("alp")),
15016 "the prefix reaches the trigram source too, not only word tokens"
15017 );
15018 }
15019
15020 #[test]
15021 fn schema_v4_rejects_custom_trigram_maintenance_without_mutation() {
15022 let dir = tempfile::tempdir().unwrap();
15023 let db = Db::open(&dir.path().join("index.db")).unwrap();
15024 let schema_before = schema_fingerprint(&db.conn);
15025
15026 let error = db.ensure_trigram_base().unwrap_err().to_string();
15027
15028 assert!(error.contains("unavailable on schema v4"), "{error}");
15029 assert_eq!(schema_fingerprint(&db.conn), schema_before);
15030 }
15031
15032 #[test]
15033 fn ensure_trigram_base_self_heals_corrupt_metadata_instead_of_failing_every_search() {
15034 let dir = tempfile::tempdir().unwrap();
15038 let db = Db::open(&dir.path().join("index.db")).unwrap();
15039 enable_v3_custom_trigram_compatibility(&db);
15040 seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15041
15042 db.conn
15045 .execute_batch(
15046 "drop table trigram_meta; create table trigram_meta (key text primary key);",
15047 )
15048 .unwrap();
15049
15050 let base_max = db
15051 .ensure_trigram_base()
15052 .expect("corruption must self-heal, not propagate an error");
15053
15054 assert_eq!(
15055 base_max, 1,
15056 "self-heal rebuilds the base from the message table"
15057 );
15058 assert_eq!(
15059 crate::trigram_index::base_max_id(&db.conn).unwrap(),
15060 1,
15061 "trigram_meta is valid and queryable again after self-heal"
15062 );
15063 let groups = crate::trigram::trigram_prefilter_groups("ECONNRESET").unwrap();
15064 let cands = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
15065 assert!(
15066 cands.contains(&1),
15067 "the rebuilt index actually finds the seeded message, not just an empty shell"
15068 );
15069 }
15070
15071 #[test]
15072 fn ensure_trigram_base_does_not_repair_metadata_when_maintenance_is_disabled() {
15073 let dir = tempfile::tempdir().unwrap();
15074 let mut db = Db::open(&dir.path().join("index.db")).unwrap();
15075 enable_v3_custom_trigram_compatibility(&db);
15076 seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15077 db.conn
15078 .execute_batch(
15079 "drop table trigram_meta; create table trigram_meta (key text primary key);",
15080 )
15081 .unwrap();
15082 db.set_implicit_index_maintenance(false);
15083
15084 let error = db.ensure_trigram_base().unwrap_err().to_string();
15085 assert!(
15086 error.contains("automatic maintenance is disabled"),
15087 "{error}"
15088 );
15089 assert!(
15090 !crate::trigram_index::schema_is_compatible(&db.conn).unwrap(),
15091 "a maintenance-disabled read must not replace the malformed table"
15092 );
15093 }
15094
15095 #[test]
15096 fn read_only_open_does_not_repair_incompatible_trigram_tables() {
15097 let dir = tempfile::tempdir().unwrap();
15098 let path = dir.path().join("index.db");
15099 let db = Db::open(&path).unwrap();
15100 enable_v3_custom_trigram_compatibility(&db);
15101 seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15102 db.conn
15103 .execute_batch(
15104 "drop table trigram_meta; create table trigram_meta (key text primary key);",
15105 )
15106 .unwrap();
15107 let schema_before = schema_fingerprint(&db.conn);
15108 drop(db);
15109
15110 let read_only = Db::open_existing_read_only_with_threads(
15111 &path,
15112 TEST_BUSY_TIMEOUT_MS,
15113 NonZeroUsize::MIN,
15114 )
15115 .unwrap();
15116 let error = read_only.ensure_trigram_base().unwrap_err().to_string();
15117
15118 assert!(
15119 error.contains("automatic maintenance is disabled"),
15120 "{error}"
15121 );
15122 assert_eq!(
15123 schema_fingerprint(&read_only.conn),
15124 schema_before,
15125 "SQLite read-only mode must leave the incompatible derived schema unchanged"
15126 );
15127 }
15128
15129 #[test]
15130 fn concurrent_read_only_connections_share_one_fixed_worker_runtime() {
15131 let dir = tempfile::tempdir().unwrap();
15132 let path = dir.path().join("index.db");
15133 drop(Db::open(&path).unwrap());
15134 let runtime = Arc::new(ExecutionRuntime::new(
15135 NonZeroUsize::new(2).expect("nonzero worker count"),
15136 ));
15137
15138 let first = Db::open_existing_read_only_with_runtime(
15139 &path,
15140 TEST_BUSY_TIMEOUT_MS,
15141 Arc::clone(&runtime),
15142 )
15143 .unwrap();
15144 let second = Db::open_existing_read_only_with_runtime(
15145 &path,
15146 TEST_BUSY_TIMEOUT_MS,
15147 Arc::clone(&runtime),
15148 )
15149 .unwrap();
15150
15151 assert!(Arc::ptr_eq(&first.runtime, &runtime));
15152 assert!(Arc::ptr_eq(&second.runtime, &runtime));
15153 assert_eq!(first.worker_threads(), 2);
15154 assert_eq!(second.worker_threads(), 2);
15155 }
15156
15157 #[test]
15158 fn ensure_trigram_base_does_not_create_missing_tables_when_maintenance_is_disabled() {
15159 let dir = tempfile::tempdir().unwrap();
15160 let mut db = Db::open(&dir.path().join("index.db")).unwrap();
15161 enable_v3_custom_trigram_compatibility(&db);
15162 db.conn
15163 .execute_batch("drop table trigram_postings; drop table trigram_meta;")
15164 .unwrap();
15165 db.set_implicit_index_maintenance(false);
15166
15167 let error = db.ensure_trigram_base().unwrap_err().to_string();
15168 assert!(
15169 error.contains("automatic maintenance is disabled"),
15170 "{error}"
15171 );
15172 let derived_tables: i64 = db
15173 .conn
15174 .query_row(
15175 "select count(*) from sqlite_master where name in ('trigram_postings', 'trigram_meta')",
15176 [],
15177 |row| row.get(0),
15178 )
15179 .unwrap();
15180 assert_eq!(
15181 derived_tables, 0,
15182 "a read-only probe must not create schema"
15183 );
15184 }
15185
15186 #[test]
15187 fn ensure_trigram_base_rolls_back_schema_repair_when_rebuild_fails() {
15188 let dir = tempfile::tempdir().unwrap();
15189 let db = Db::open(&dir.path().join("index.db")).unwrap();
15190 enable_v3_custom_trigram_compatibility(&db);
15191 db.conn
15192 .execute_batch(
15193 "drop table trigram_meta;
15194 create table trigram_meta (key text primary key);
15195 drop table messages;",
15196 )
15197 .unwrap();
15198
15199 let error = db.ensure_trigram_base().unwrap_err().to_string();
15200 assert!(error.contains("no such table: messages"), "{error}");
15201 assert!(
15202 !crate::trigram_index::schema_is_compatible(&db.conn).unwrap(),
15203 "the failed transaction must restore the original incompatible schema"
15204 );
15205 }
15206
15207 #[test]
15208 fn ensure_trigram_base_busy_repair_leaves_incompatible_tables_untouched() {
15209 let dir = tempfile::tempdir().unwrap();
15210 let path = dir.path().join("index.db");
15211 let writer = Db::open(&path).unwrap();
15212 enable_v3_custom_trigram_compatibility(&writer);
15213 seed_messages(&writer, &[("user", "socket failed with ECONNRESET today")]);
15214 writer
15215 .conn
15216 .execute_batch(
15217 "drop table trigram_meta; create table trigram_meta (key text primary key);",
15218 )
15219 .unwrap();
15220 let schema_before = schema_fingerprint(&writer.conn);
15221
15222 let contender = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
15223 writer.conn.execute_batch("begin immediate").unwrap();
15224 let error = contender
15225 .ensure_trigram_base()
15226 .expect_err("a competing writer must prevent derived-table repair");
15227 writer.conn.execute_batch("rollback").unwrap();
15228
15229 assert!(Db::is_sqlite_busy_error(&error), "{error:#}");
15230 assert!(
15231 !crate::trigram_index::schema_is_compatible(&contender.conn).unwrap(),
15232 "a failed repair attempt must not replace either derived table"
15233 );
15234 assert_eq!(
15235 schema_fingerprint(&contender.conn),
15236 schema_before,
15237 "the failed repair must preserve the entire preexisting schema"
15238 );
15239 }
15240
15241 #[test]
15242 fn regex_prefilter_reaches_rows_by_primary_key_not_full_scan() {
15243 let dir = tempfile::tempdir().unwrap();
15247 let db = Db::open(&dir.path().join("index.db")).unwrap();
15248 enable_v3_custom_trigram_compatibility(&db);
15249 seed_messages(&db, &[("user", "socket failed with ECONNRESET) today")]);
15250 let base_max = db.ensure_trigram_base().unwrap();
15253 let groups = crate::trigram::trigram_prefilter_groups("ECONNRESET").unwrap();
15254 let cands = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
15255 db.stage_candidates(base_max, &cands).unwrap();
15256 let plan = {
15257 let mut stmt = db
15258 .conn
15259 .prepare(
15260 "explain query plan select m.id from messages m \
15261 where m.id in (select id from _trigram_cand) order by m.session_id, m.seq",
15262 )
15263 .unwrap();
15264 stmt.query_map([], |r| r.get::<_, String>(3))
15265 .unwrap()
15266 .filter_map(Result::ok)
15267 .collect::<Vec<_>>()
15268 .join(" | ")
15269 };
15270 assert!(
15271 plan.contains("PRIMARY KEY") || plan.contains("USING INTEGER PRIMARY KEY"),
15272 "messages must be reached by primary key, not scanned: {plan}",
15273 );
15274 assert!(
15275 !plan.contains("SCAN m "),
15276 "no full scan of the messages table: {plan}",
15277 );
15278 }
15279
15280 #[test]
15281 fn checkpoint_truncate_is_safe_and_preserves_data() {
15282 let dir = tempfile::tempdir().unwrap();
15285 let db = Db::open(&dir.path().join("index.db")).unwrap();
15286 seed_messages(&db, &[("user", "the deploy hit ECONNRESET) again")]);
15287 db.checkpoint_truncate().unwrap();
15288 db.checkpoint_truncate().unwrap();
15290 let hits = db
15291 .search_messages(
15292 "ECONNRESET",
15293 &MessageFilters {
15294 match_mode: MessageSearchMode::Regex,
15295 ..Default::default()
15296 },
15297 )
15298 .unwrap();
15299 assert_eq!(hits.len(), 1, "data intact and searchable after checkpoint");
15300 }
15301
15302 #[test]
15303 fn schema_state_classification_is_total_and_preserves_readable_older_policy() {
15304 assert_eq!(
15305 SchemaState::from_version(SCHEMA_VERSION),
15306 SchemaState::Current
15307 );
15308 assert_eq!(
15309 SchemaState::from_version(SCHEMA_VERSION - 1),
15310 SchemaState::Older {
15311 current: SCHEMA_VERSION - 1,
15312 required: SCHEMA_VERSION,
15313 }
15314 );
15315 assert_eq!(
15316 SchemaState::from_version(SCHEMA_VERSION + 1),
15317 SchemaState::Newer {
15318 current: SCHEMA_VERSION + 1,
15319 supported: SCHEMA_VERSION,
15320 }
15321 );
15322
15323 let dir = tempfile::tempdir().unwrap();
15324 let db = Db::open(&dir.path().join("index.db")).unwrap();
15325 db.conn
15326 .pragma_update(None, "user_version", MIN_READABLE_SCHEMA_VERSION)
15327 .unwrap();
15328 assert!(db.needs_backfill().unwrap());
15329 assert!(db.schema_is_readable().unwrap());
15330 }
15331
15332 #[test]
15333 fn corpus_threshold_probe_stops_at_the_decision_boundary() {
15334 let dir = tempfile::tempdir().unwrap();
15335 let db = Db::open(&dir.path().join("index.db")).unwrap();
15336 seed_messages(
15337 &db,
15338 &[
15339 ("user", "one"),
15340 ("user", "two"),
15341 ("user", "three"),
15342 ("user", "four"),
15343 ("assistant", "five"),
15344 ],
15345 );
15346 let filters = MessageFilters {
15347 role: Some(Role::User),
15348 ..Default::default()
15349 };
15350
15351 assert!(db.filtered_corpus_reaches(&filters, 3).unwrap());
15352 assert!(db.filtered_corpus_reaches(&filters, 4).unwrap());
15353 assert!(!db.filtered_corpus_reaches(&filters, 5).unwrap());
15354 assert_eq!(db.filtered_corpus_count(&filters).unwrap(), 4);
15355 }
15356
15357 fn seed_every_message_class(db: &Db) {
15362 seed_messages(db, &[("user", "one"), ("assistant", "two")]);
15363 db.conn
15364 .execute_batch(
15365 "update messages set kind = 'conversation', ts = '2026-08-01T00:00:00Z';
15366 insert into messages(session_id, provider, seq, role, kind, ts, tool_name, content) values
15367 ('claude:s1', 'claude', 10, 'user', 'harness_notice', '2026-08-02T00:00:00Z', null, 'notice'),
15368 ('claude:s1', 'claude', 11, 'user', 'compaction', '2026-08-02T00:00:00Z', null, 'summary'),
15369 ('claude:s1', 'claude', 12, 'assistant', 'message', '2026-08-03T00:00:00Z', null, 'legacy kind'),
15370 ('claude:s1', 'claude', 13, 'assistant', 'tool_call', '2026-08-03T00:00:00Z', 'Bash', 'ls'),
15371 ('claude:s1', 'claude', 14, 'tool', 'tool_result', '2026-08-03T00:00:00Z', 'Bash', 'ok');",
15372 )
15373 .unwrap();
15374 }
15375
15376 fn corpus_count_filter_cases() -> Vec<(&'static str, MessageFilters)> {
15377 vec![
15378 ("default", MessageFilters::default()),
15379 (
15380 "role",
15381 MessageFilters {
15382 role: Some(Role::User),
15383 ..Default::default()
15384 },
15385 ),
15386 (
15387 "provider",
15388 MessageFilters {
15389 providers: Some(vec![Provider::Claude]),
15390 ..Default::default()
15391 },
15392 ),
15393 (
15394 "since",
15395 MessageFilters {
15396 since: Some(utc("2026-08-02T12:00:00Z")),
15397 ..Default::default()
15398 },
15399 ),
15400 (
15401 "session",
15402 MessageFilters {
15403 session_id: Some("claude:s1".to_string()),
15404 ..Default::default()
15405 },
15406 ),
15407 (
15408 "workspace_path",
15409 MessageFilters {
15410 workspace_path_prefix: Some("/x".to_string()),
15411 ..Default::default()
15412 },
15413 ),
15414 (
15415 "tool",
15416 MessageFilters {
15417 tool: Some("bash".to_string()),
15418 ..Default::default()
15419 },
15420 ),
15421 (
15422 "no_compaction",
15423 MessageFilters {
15424 no_compaction: true,
15425 ..Default::default()
15426 },
15427 ),
15428 (
15429 "explicit_excludable_kind",
15430 MessageFilters {
15431 kinds: Some(vec![crate::models::MessageKind::HarnessNotice]),
15432 ..Default::default()
15433 },
15434 ),
15435 ]
15436 }
15437
15438 fn explicit_row_scanning_kind_filters() -> MessageFilters {
15441 MessageFilters {
15442 kinds: Some(vec![
15443 crate::models::MessageKind::Conversation,
15444 crate::models::MessageKind::ToolCall,
15445 ]),
15446 ..Default::default()
15447 }
15448 }
15449
15450 fn naive_corpus_count(db: &Db, filters: &MessageFilters) -> i64 {
15453 let mut sql = String::from("select count(*) from messages m where 1 = 1");
15454 let mut args = Vec::new();
15455 append_message_filters(&mut sql, &mut args, filters, &db.access_scope);
15456 db.conn
15457 .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
15458 row.get(0)
15459 })
15460 .unwrap()
15461 }
15462
15463 fn query_plan(db: &Db, sql: &str, args: &[rusqlite::types::Value]) -> String {
15464 let mut stmt = db
15465 .conn
15466 .prepare(&format!("explain query plan {sql}"))
15467 .unwrap();
15468 stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
15469 row.get::<_, String>(3)
15470 })
15471 .unwrap()
15472 .filter_map(Result::ok)
15473 .collect::<Vec<_>>()
15474 .join(" | ")
15475 }
15476
15477 #[test]
15478 fn corpus_count_equals_the_naive_count_for_every_class_predicate() {
15479 let dir = tempfile::tempdir().unwrap();
15480 let db = Db::open(&dir.path().join("index.db")).unwrap();
15481 seed_every_message_class(&db);
15482 for (name, filters) in corpus_count_filter_cases() {
15483 assert_eq!(
15484 db.filtered_corpus_count(&filters).unwrap(),
15485 naive_corpus_count(&db, &filters),
15486 "{name}: the decomposed count must equal the single-statement count"
15487 );
15488 }
15489 let explicit = explicit_row_scanning_kind_filters();
15490 assert_eq!(
15491 db.filtered_corpus_count(&explicit).unwrap(),
15492 naive_corpus_count(&db, &explicit),
15493 "explicit non-excludable classes"
15494 );
15495 assert_eq!(
15497 db.filtered_corpus_count(&MessageFilters::default())
15498 .unwrap(),
15499 6
15500 );
15501 }
15502
15503 #[test]
15504 fn corpus_count_statements_are_served_from_covering_indexes() {
15505 let dir = tempfile::tempdir().unwrap();
15510 let db = Db::open(&dir.path().join("index.db")).unwrap();
15511 seed_every_message_class(&db);
15512 let mut cases = corpus_count_filter_cases()
15515 .into_iter()
15516 .map(|(name, filters)| (name, "1 = 1", filters))
15517 .collect::<Vec<_>>();
15518 cases.push((
15519 "tool_argument",
15520 "m.kind = 'tool_call'",
15521 MessageFilters::default(),
15522 ));
15523 for (name, base, filters) in cases {
15524 let statements = db.corpus_count_statements(base, &filters);
15525 assert!(!statements.is_empty(), "{name}: no count statements");
15526 for (sql, args, _sign) in &statements {
15527 let plan = query_plan(&db, sql, args);
15528 for step in plan.split(" | ") {
15533 if step.contains("SCAN m") || step.contains("SEARCH m") {
15534 assert!(
15535 step.contains("USING COVERING INDEX"),
15536 "{name}: corpus count must not read message rows\nsql: {sql}\nplan: {plan}"
15537 );
15538 }
15539 }
15540 assert!(
15541 plan.contains("COVERING INDEX"),
15542 "{name}: corpus count must be index-only\nsql: {sql}\nplan: {plan}"
15543 );
15544 }
15545 }
15546 }
15547
15548 #[test]
15549 fn message_kind_index_is_created_free_on_an_empty_database_and_deferred_on_a_populated_one() {
15550 let dir = tempfile::tempdir().unwrap();
15551 let path = dir.path().join("index.db");
15552 let db = Db::open(&path).unwrap();
15553 assert!(
15554 db.message_kind_index_exists().unwrap(),
15555 "an empty database gets the class index at open, where building it is free"
15556 );
15557 seed_every_message_class(&db);
15558 db.conn
15559 .execute_batch(&format!("drop index {MESSAGE_KIND_INDEX}"))
15560 .unwrap();
15561 drop(db);
15562 let reopened = Db::open(&path).unwrap();
15567 assert!(
15568 !reopened.message_kind_index_exists().unwrap(),
15569 "open must defer the class index build on a populated database"
15570 );
15571 assert!(
15572 reopened.ensure_message_kind_index().unwrap(),
15573 "the maintenance path reports that it built the index"
15574 );
15575 assert!(reopened.message_kind_index_exists().unwrap());
15576 assert!(
15577 !reopened.ensure_message_kind_index().unwrap(),
15578 "a second call is a no-op"
15579 );
15580 for (name, filters) in corpus_count_filter_cases() {
15582 assert_eq!(
15583 reopened.filtered_corpus_count(&filters).unwrap(),
15584 naive_corpus_count(&reopened, &filters),
15585 "{name}"
15586 );
15587 }
15588 }
15589}