context_forge/storage/
turso_searcher.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use tantivy::collector::TopDocs;
6use tantivy::query::{BooleanQuery, Occur, QueryParser, TermQuery};
7use tantivy::schema::{IndexRecordOption, OwnedValue};
8use tantivy::{TantivyDocument, Term};
9
10use crate::engine::MATCH_ALL_QUERY;
11use crate::entry::ScoredEntry;
12use crate::storage::fts_index::FtsIndex;
13use crate::storage::turso_storage::turso_row_to_entry;
14use crate::traits::Searcher;
15
16pub struct TursoSearcher {
21 db: Arc<turso::Database>,
22 fts: Arc<FtsIndex>,
23}
24
25impl TursoSearcher {
26 #[must_use]
28 pub(crate) fn new(db: Arc<turso::Database>, fts: Arc<FtsIndex>) -> Self {
29 Self { db, fts }
30 }
31}
32
33#[async_trait]
34impl Searcher for TursoSearcher {
35 async fn search(
36 &self,
37 query: &str,
38 scope: Option<&str>,
39 limit: usize,
40 ) -> crate::Result<Vec<ScoredEntry>> {
41 if query == MATCH_ALL_QUERY {
42 return self.search_all(scope, limit).await;
43 }
44
45 let searcher = self.fts.reader.searcher();
47 let (content_query, _errors) =
49 QueryParser::for_index(searcher.index(), vec![self.fts.content_field])
50 .parse_query_lenient(query);
51
52 let tantivy_query: Box<dyn tantivy::query::Query> = if let Some(s) = scope {
55 let scope_term = Term::from_field_text(self.fts.scope_field, s);
56 Box::new(BooleanQuery::new(vec![
57 (Occur::Must, content_query),
58 (
59 Occur::Must,
60 Box::new(TermQuery::new(scope_term, IndexRecordOption::Basic)),
61 ),
62 ]))
63 } else {
64 content_query
65 };
66
67 let top_docs = searcher
68 .search(&tantivy_query, &TopDocs::with_limit(limit))
69 .map_err(|e| crate::Error::Migration(e.to_string()))?;
70
71 if top_docs.is_empty() {
72 return Ok(Vec::new());
73 }
74
75 let mut scored_ids: Vec<(f32, String)> = Vec::with_capacity(top_docs.len());
77 for (score, doc_address) in top_docs {
78 let doc: TantivyDocument = searcher
79 .doc(doc_address)
80 .map_err(|e| crate::Error::Migration(e.to_string()))?;
81 if let Some(OwnedValue::Str(id_str)) = doc.get_first(self.fts.id_field) {
82 scored_ids.push((score, id_str.to_owned()));
83 }
84 }
85
86 if scored_ids.is_empty() {
87 return Ok(Vec::new());
88 }
89
90 let id_list: String = scored_ids
93 .iter()
94 .map(|(_, id)| format!("'{}'", id.replace('\'', "''")))
95 .collect::<Vec<_>>()
96 .join(", ");
97 let sql = format!(
98 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
99 FROM entries WHERE id IN ({id_list})"
100 );
101
102 let conn = self.db.connect()?;
103 conn.busy_timeout(Duration::from_secs(5))?;
104
105 let mut rows = conn.query(&sql, ()).await?;
106 let mut id_to_entry = std::collections::HashMap::new();
107 while let Some(row) = rows.next().await? {
108 let entry = turso_row_to_entry(&row)?;
109 id_to_entry.insert(entry.id.clone(), entry);
110 }
111
112 let mut result: Vec<ScoredEntry> = scored_ids
114 .into_iter()
115 .filter_map(|(score, id)| {
116 let entry = id_to_entry.remove(&id)?;
117 Some(ScoredEntry {
118 score: f64::from(score),
119 entry,
120 })
121 })
122 .collect();
123
124 result.sort_by(|a, b| {
126 b.score
127 .partial_cmp(&a.score)
128 .unwrap_or(std::cmp::Ordering::Equal)
129 });
130
131 Ok(result)
132 }
133}
134
135impl TursoSearcher {
136 async fn search_all(
137 &self,
138 scope: Option<&str>,
139 limit: usize,
140 ) -> crate::Result<Vec<ScoredEntry>> {
141 let conn = self.db.connect()?;
142 conn.busy_timeout(Duration::from_secs(5))?;
143 let scope_owned = scope.map(str::to_owned);
144
145 let mut rows = conn
146 .query(
147 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
148 FROM entries \
149 WHERE (?1 IS NULL OR scope = ?1) \
150 ORDER BY timestamp DESC \
151 LIMIT ?2",
152 (scope_owned, i64::try_from(limit).unwrap_or(i64::MAX)),
153 )
154 .await?;
155
156 let mut result = Vec::new();
157 while let Some(row) = rows.next().await? {
158 let entry = turso_row_to_entry(&row)?;
159 result.push(ScoredEntry { score: 1.0, entry });
160 }
161
162 Ok(result)
163 }
164}