1use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::{DateTime, TimeZone, Utc};
7use uuid::Uuid;
8
9use khive_score::DeterministicScore;
10use khive_storage::error::StorageError;
11use khive_storage::types::{
12 BatchWriteSummary, IndexRebuildScope, SqlStatement, SqlValue, TextDocument, TextFilter,
13 TextGatherMode, TextIndexStats, TextQueryMode, TextSearchHit, TextSearchOptions,
14 TextSearchRequest, TextTermStats, TextTermStatsRequest,
15};
16use khive_storage::StorageCapability;
17use khive_storage::TextSearch;
18use khive_types::SubstrateKind;
19
20use crate::error::SqliteError;
21use crate::pool::ConnectionPool;
22use crate::sql_bridge::bind_params;
23use crate::writer_task::WriterTaskHandle;
24
25pub fn delete_document_statement(table: &str, namespace: &str, subject_id: Uuid) -> SqlStatement {
32 SqlStatement {
33 sql: format!("DELETE FROM {table} WHERE namespace = ?1 AND subject_id = ?2"),
34 params: vec![
35 SqlValue::Text(namespace.to_string()),
36 SqlValue::Text(subject_id.to_string()),
37 ],
38 label: Some(format!("fts-delete-{table}")),
39 }
40}
41
42pub fn insert_document_statement(table: &str, document: &TextDocument) -> SqlStatement {
47 let tags_json = tags_to_json(&document.tags);
48 let metadata_json = document.metadata.as_ref().map(|v| v.to_string());
49 SqlStatement {
50 sql: format!(
51 "INSERT INTO {table} \
52 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
53 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
54 ),
55 params: vec![
56 SqlValue::Text(document.subject_id.to_string()),
57 SqlValue::Text(document.kind.to_string()),
58 SqlValue::Text(document.title.clone().unwrap_or_default()),
59 SqlValue::Text(document.body.clone()),
60 SqlValue::Text(tags_json),
61 SqlValue::Text(document.namespace.clone()),
62 match metadata_json {
63 Some(m) => SqlValue::Text(m),
64 None => SqlValue::Null,
65 },
66 SqlValue::Integer(dt_to_micros(&document.updated_at)),
67 ],
68 label: Some(format!("fts-insert-{table}")),
69 }
70}
71
72#[cfg(test)]
76pub(crate) fn ensure_fts5_schema(
77 conn: &rusqlite::Connection,
78 table_key: &str,
79) -> Result<(), rusqlite::Error> {
80 let table_name = format!("fts_{}", table_key);
81 let ddl = format!(
82 "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(\
83 subject_id UNINDEXED, \
84 kind UNINDEXED, \
85 title, \
86 body, \
87 tags UNINDEXED, \
88 namespace UNINDEXED, \
89 metadata UNINDEXED, \
90 updated_at UNINDEXED\
91 )",
92 table_name
93 );
94 conn.execute_batch(&ddl)
95}
96
97fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
98 StorageError::driver(StorageCapability::Text, op, e)
99}
100
101fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
102 StorageError::driver(StorageCapability::Text, op, e)
103}
104
105pub struct Fts5TextSearch {
111 pool: Arc<ConnectionPool>,
112 is_file_backed: bool,
113 table_name: String,
114 writer_task: Option<WriterTaskHandle>,
115}
116
117impl Fts5TextSearch {
118 pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
122 let table_name = format!("fts_{}", table_key);
123 let writer_task = pool.writer_task_handle().ok().flatten();
127 Self {
128 pool,
129 is_file_backed,
130 table_name,
131 writer_task,
132 }
133 }
134
135 fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
136 self.pool
137 .open_standalone_writer()
138 .map_err(|e| map_sqlite_err(e, "open_fts_writer"))
139 }
140
141 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
142 self.pool
143 .open_standalone_reader()
144 .map_err(|e| map_sqlite_err(e, "open_fts_reader"))
145 }
146
147 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
154 where
155 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
156 R: Send + 'static,
157 {
158 if let Some(writer_task) = &self.writer_task {
159 return writer_task
160 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
161 .await;
162 }
163
164 self.with_writer_unmanaged(op, f).await
165 }
166
167 async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
176 where
177 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
178 R: Send + 'static,
179 {
180 let result = if self.is_file_backed {
181 let conn = self.open_standalone_writer()?;
182 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
183 .await
184 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
185 } else {
186 let pool = Arc::clone(&self.pool);
187 tokio::task::spawn_blocking(move || {
188 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
189 f(guard.conn()).map_err(|e| map_err(e, op))
190 })
191 .await
192 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
193 };
194 if let Err(err) = &result {
195 let msg = err.to_string();
196 if msg.contains("locked") || msg.contains("busy") {
197 let open: Vec<String> = khive_storage::tx_registry::snapshot()
198 .into_iter()
199 .map(|(age, label)| {
200 format!(
201 "{}@{}ms",
202 label.as_deref().unwrap_or("unlabeled"),
203 age.as_millis()
204 )
205 })
206 .collect();
207 tracing::warn!(
208 op,
209 open_tx_count = open.len(),
210 open_txs = %open.join(","),
211 "text write starved on the SQLite write lock; open registered \
212 transactions listed (empty means the holder issued no \
213 registered BEGIN IMMEDIATE in this process)"
214 );
215 }
216 }
217 result
218 }
219
220 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
221 where
222 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
223 R: Send + 'static,
224 {
225 if self.is_file_backed {
226 let conn = self.open_standalone_reader()?;
227 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
228 .await
229 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
230 } else {
231 let pool = Arc::clone(&self.pool);
232 tokio::task::spawn_blocking(move || {
233 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
234 f(guard.conn()).map_err(|e| map_err(e, op))
235 })
236 .await
237 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
238 }
239 }
240}
241
242fn tags_to_json(tags: &[String]) -> String {
245 serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
246}
247
248fn tags_from_json(s: &str) -> Vec<String> {
249 serde_json::from_str(s).unwrap_or_default()
250}
251
252fn dt_to_micros(dt: &DateTime<Utc>) -> i64 {
253 dt.timestamp_micros()
254}
255
256fn micros_to_dt(micros: i64) -> DateTime<Utc> {
257 Utc.timestamp_micros(micros)
258 .single()
259 .unwrap_or_else(Utc::now)
260}
261
262fn sanitize_fts5_query(query: &str) -> String {
269 let spaced: String = query
273 .chars()
274 .map(|c| {
275 if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
276 ' '
277 } else {
278 c
279 }
280 })
281 .collect();
282
283 let sanitized: String = spaced
290 .chars()
291 .filter(|c| {
292 !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
293 })
294 .collect();
295
296 sanitized
298 .split_whitespace()
299 .filter(|t| {
300 !matches!(
301 t.to_ascii_uppercase().as_str(),
302 "AND" | "OR" | "NOT" | "NEAR"
303 )
304 })
305 .collect::<Vec<_>>()
306 .join(" ")
307}
308
309fn sanitize_fts5_query_legacy_merged(query: &str) -> String {
316 let spaced: String = query
317 .chars()
318 .map(|c| {
319 if matches!(c, '(' | ')' | ',' | ':' | '/') {
320 ' '
321 } else {
322 c
323 }
324 })
325 .collect();
326
327 let sanitized: String = spaced
328 .chars()
329 .filter(|c| {
330 !matches!(
331 c,
332 '*' | '"' | '\'' | '+' | '-' | '^' | '.' | '~' | '!' | '$' | '\0'
333 ) && !c.is_control()
334 })
335 .collect();
336
337 sanitized
338 .split_whitespace()
339 .filter(|t| {
340 !matches!(
341 t.to_ascii_uppercase().as_str(),
342 "AND" | "OR" | "NOT" | "NEAR"
343 )
344 })
345 .collect::<Vec<_>>()
346 .join(" ")
347}
348
349fn sanitize_fts5_phrase_literal(token: &str) -> Option<String> {
358 let mut literal = String::with_capacity(token.len());
359 for c in token
360 .chars()
361 .filter(|c| !matches!(c, '*' | '\0') && !c.is_control())
362 {
363 if c == '"' {
364 literal.push_str("\"\"");
365 } else {
366 literal.push(c);
367 }
368 }
369 if literal.is_empty() {
370 None
371 } else {
372 Some(literal)
373 }
374}
375
376const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
384
385fn is_fts5_bareword_safe(s: &str) -> bool {
389 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
390}
391
392fn sanitize_fts5_token_group(token: &str) -> Option<String> {
400 let split = sanitize_fts5_query(token);
401 let split_terms: Vec<&str> = split.split_whitespace().collect();
402 if split_terms.is_empty() {
403 return None;
404 }
405
406 let all_bareword_safe = split_terms.iter().all(|t| is_fts5_bareword_safe(t));
407 if split_terms.len() == 1 && is_fts5_bareword_safe(token) {
408 return Some(split_terms[0].to_string());
409 }
410
411 let has_trigram_unsafe_segment = split_terms
412 .iter()
413 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
414
415 let mut alternatives = Vec::new();
416 if all_bareword_safe && !has_trigram_unsafe_segment {
417 alternatives.push(format!("({})", split_terms.join(" ")));
418 }
419
420 let merged = sanitize_fts5_query_legacy_merged(token);
431 let merged_terms: Vec<&str> = merged.split_whitespace().collect();
432 let merged_all_bareword_safe =
433 !merged_terms.is_empty() && merged_terms.iter().all(|t| is_fts5_bareword_safe(t));
434 let merged_has_unsafe_segment = merged_terms.len() > 1
435 && merged_terms
436 .iter()
437 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
438 let phrase = sanitize_fts5_phrase_literal(token);
447 let duplicates_split = merged == split_terms.join(" ");
448 let duplicates_phrase = phrase.as_deref() == Some(merged.as_str());
449 if merged_all_bareword_safe
450 && !merged.is_empty()
451 && !duplicates_split
452 && !duplicates_phrase
453 && !merged_has_unsafe_segment
454 {
455 alternatives.push(merged);
456 }
457
458 if let Some(phrase) = phrase {
459 alternatives.push(format!("\"{}\"", phrase));
460 }
461
462 match alternatives.len() {
463 0 => None,
464 1 => alternatives.into_iter().next(),
465 _ => Some(format!("({})", alternatives.join(" OR "))),
466 }
467}
468
469fn join_plain_groups(groups: &[String]) -> String {
484 let mut expr = String::new();
485 for (i, group) in groups.iter().enumerate() {
486 if i > 0 {
487 let prev_compound = groups[i - 1].starts_with('(');
488 let this_compound = group.starts_with('(');
489 expr.push_str(if prev_compound || this_compound {
490 " AND "
491 } else {
492 " "
493 });
494 }
495 expr.push_str(group);
496 }
497 expr
498}
499
500fn build_match_expr(query: &str, mode: TextQueryMode) -> Option<String> {
517 match mode {
518 TextQueryMode::AnyTerm => {
519 let groups: Vec<String> = query
520 .split_whitespace()
521 .filter_map(sanitize_fts5_token_group)
522 .collect();
523 if groups.is_empty() {
524 None
525 } else {
526 Some(groups.join(" OR "))
527 }
528 }
529 TextQueryMode::Plain => {
530 let groups: Vec<String> = query
531 .split_whitespace()
532 .filter_map(sanitize_fts5_token_group)
533 .collect();
534 if groups.is_empty() {
535 None
536 } else {
537 Some(join_plain_groups(&groups))
538 }
539 }
540 TextQueryMode::Phrase => {
541 sanitize_fts5_phrase_literal(query).map(|literal| format!("\"{}\"", literal))
542 }
543 }
544}
545
546fn build_filter_clause(
551 filter: &TextFilter,
552 table: &str,
553 start_idx: usize,
554) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
555 let mut conditions: Vec<String> = Vec::new();
556 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
557 let mut idx = start_idx;
558
559 if !filter.ids.is_empty() {
560 let placeholders: Vec<String> = filter
561 .ids
562 .iter()
563 .map(|_| {
564 let p = format!("?{}", idx);
565 idx += 1;
566 p
567 })
568 .collect();
569 conditions.push(format!(
570 "{}.subject_id IN ({})",
571 table,
572 placeholders.join(", ")
573 ));
574 for id in &filter.ids {
575 params.push(Box::new(id.to_string()));
576 }
577 }
578
579 if !filter.kinds.is_empty() {
580 let placeholders: Vec<String> = filter
581 .kinds
582 .iter()
583 .map(|_| {
584 let p = format!("?{}", idx);
585 idx += 1;
586 p
587 })
588 .collect();
589 conditions.push(format!("{}.kind IN ({})", table, placeholders.join(", ")));
590 for kind in &filter.kinds {
591 params.push(Box::new(kind.to_string()));
592 }
593 }
594
595 if !filter.namespaces.is_empty() {
596 let placeholders: Vec<String> = filter
597 .namespaces
598 .iter()
599 .map(|_| {
600 let p = format!("?{}", idx);
601 idx += 1;
602 p
603 })
604 .collect();
605 conditions.push(format!(
606 "{}.namespace IN ({})",
607 table,
608 placeholders.join(", ")
609 ));
610 for ns in &filter.namespaces {
611 params.push(Box::new(ns.clone()));
612 }
613 }
614
615 if conditions.is_empty() {
616 (String::new(), params)
617 } else {
618 (format!(" AND {}", conditions.join(" AND ")), params)
619 }
620}
621
622fn upsert_document_dml(
628 conn: &rusqlite::Connection,
629 table: &str,
630 document: &TextDocument,
631) -> Result<(), rusqlite::Error> {
632 let statements = [
633 delete_document_statement(table, &document.namespace, document.subject_id),
634 insert_document_statement(table, document),
635 ];
636 for statement in statements {
637 let mut stmt = conn.prepare(&statement.sql)?;
638 bind_params(&mut stmt, &statement.params)?;
639 stmt.raw_execute()?;
640 }
641 Ok(())
642}
643
644fn batch_upsert_documents_dml(
653 conn: &rusqlite::Connection,
654 table: &str,
655 documents: &[TextDocument],
656 attempted: u64,
657) -> Result<BatchWriteSummary, rusqlite::Error> {
658 let del_sql = format!(
659 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
660 table
661 );
662 let ins_sql = format!(
663 "INSERT INTO {} \
664 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
665 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
666 table
667 );
668
669 let mut affected = 0u64;
670 let mut failed = 0u64;
671 let mut first_error = String::new();
672
673 for doc in documents {
674 conn.execute_batch("SAVEPOINT fts_upsert_doc")?;
675 let id_str = doc.subject_id.to_string();
676 let namespace = &doc.namespace;
677 let result = (|| {
678 conn.execute(&del_sql, rusqlite::params![namespace, &id_str])?;
679
680 let tags_json = tags_to_json(&doc.tags);
681 let metadata_json: Option<String> = doc.metadata.as_ref().map(|v| v.to_string());
682
683 conn.execute(
684 &ins_sql,
685 rusqlite::params![
686 &id_str,
687 &doc.kind.to_string(),
688 doc.title.as_deref().unwrap_or(""),
689 &doc.body,
690 &tags_json,
691 namespace,
692 &metadata_json,
693 dt_to_micros(&doc.updated_at),
694 ],
695 )?;
696 Ok::<(), rusqlite::Error>(())
697 })();
698
699 match result {
700 Ok(()) => {
701 conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc")?;
702 affected += 1;
703 }
704 Err(e) => {
705 let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT fts_upsert_doc");
706 let _ = conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc");
707 if first_error.is_empty() {
708 first_error = e.to_string();
709 }
710 failed += 1;
711 }
712 }
713 }
714
715 Ok(BatchWriteSummary {
716 attempted,
717 affected,
718 failed,
719 first_error,
720 })
721}
722
723#[async_trait]
724impl TextSearch for Fts5TextSearch {
725 async fn upsert_document(&self, document: TextDocument) -> Result<(), StorageError> {
726 let table = self.table_name.clone();
727
728 if let Some(writer_task) = &self.writer_task {
733 let table2 = table.clone();
734 return writer_task
735 .send(move |conn| {
736 upsert_document_dml(conn, &table2, &document)
737 .map_err(|e| map_err(e, "fts_upsert"))
738 })
739 .await;
740 }
741
742 let origin = self.pool.origin();
745 self.with_writer("fts_upsert", move |conn| {
746 conn.execute_batch("BEGIN IMMEDIATE")?;
747 let _tx_handle = khive_storage::tx_registry::register_scoped(
748 Some("text_upsert_document".to_string()),
749 origin,
750 );
751
752 if let Err(e) = upsert_document_dml(conn, &table, &document) {
753 let _ = conn.execute_batch("ROLLBACK");
754 return Err(e);
755 }
756
757 conn.execute_batch("COMMIT")?;
758 Ok(())
759 })
760 .await
761 }
762
763 async fn upsert_documents(
764 &self,
765 documents: Vec<TextDocument>,
766 ) -> Result<BatchWriteSummary, StorageError> {
767 let table = self.table_name.clone();
768 let attempted = documents.len() as u64;
769
770 if let Some(writer_task) = &self.writer_task {
776 let table2 = table.clone();
777 return writer_task
778 .send(move |conn| {
779 batch_upsert_documents_dml(conn, &table2, &documents, attempted)
780 .map_err(|e| map_err(e, "fts_upsert_batch"))
781 })
782 .await;
783 }
784
785 let origin = self.pool.origin();
788 self.with_writer("fts_upsert_batch", move |conn| {
789 conn.execute_batch("BEGIN IMMEDIATE")?;
790 let _tx_handle = khive_storage::tx_registry::register_scoped(
791 Some("text_upsert_batch".to_string()),
792 origin,
793 );
794
795 let summary = batch_upsert_documents_dml(conn, &table, &documents, attempted)?;
796
797 conn.execute_batch("COMMIT")?;
798
799 Ok(summary)
800 })
801 .await
802 }
803
804 async fn delete_document(
805 &self,
806 namespace: &str,
807 subject_id: Uuid,
808 ) -> Result<bool, StorageError> {
809 let statement = delete_document_statement(&self.table_name, namespace, subject_id);
810
811 self.with_writer("fts_delete", move |conn| {
812 let mut stmt = conn.prepare(&statement.sql)?;
813 bind_params(&mut stmt, &statement.params)?;
814 Ok(stmt.raw_execute()? > 0)
815 })
816 .await
817 }
818
819 async fn get_document(
820 &self,
821 namespace: &str,
822 subject_id: Uuid,
823 ) -> Result<Option<TextDocument>, StorageError> {
824 let namespace = namespace.to_string();
825 let table = self.table_name.clone();
826
827 self.with_reader("fts_get", move |conn| {
828 let sql = format!(
829 "SELECT subject_id, kind, title, body, tags, namespace, metadata, updated_at \
830 FROM {} WHERE namespace = ?1 AND subject_id = ?2",
831 table
832 );
833 let mut stmt = conn.prepare(&sql)?;
834 let mut rows = stmt.query(rusqlite::params![namespace, subject_id.to_string()])?;
835
836 match rows.next()? {
837 Some(row) => {
838 let id_str: String = row.get(0)?;
839 let kind_str: String = row.get(1)?;
840 let title: String = row.get(2)?;
841 let body: String = row.get(3)?;
842 let tags_json: String = row.get(4)?;
843 let ns: String = row.get(5)?;
844 let metadata_json: Option<String> = row.get(6)?;
845 let updated_at_micros: i64 = row.get(7)?;
846
847 let sid = Uuid::parse_str(&id_str).map_err(|e| {
848 rusqlite::Error::FromSqlConversionFailure(
849 0,
850 rusqlite::types::Type::Text,
851 Box::new(e),
852 )
853 })?;
854
855 let kind = kind_str.parse::<SubstrateKind>().map_err(|e| {
856 rusqlite::Error::FromSqlConversionFailure(
857 1,
858 rusqlite::types::Type::Text,
859 Box::new(e),
860 )
861 })?;
862
863 Ok(Some(TextDocument {
864 subject_id: sid,
865 kind,
866 title: if title.is_empty() { None } else { Some(title) },
867 body,
868 tags: tags_from_json(&tags_json),
869 namespace: ns,
870 metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
871 updated_at: micros_to_dt(updated_at_micros),
872 }))
873 }
874 None => Ok(None),
875 }
876 })
877 .await
878 }
879
880 async fn search(&self, request: TextSearchRequest) -> Result<Vec<TextSearchHit>, StorageError> {
881 let table = self.table_name.clone();
882
883 self.with_reader("fts_search", move |conn| {
884 let match_expr = match build_match_expr(&request.query, request.mode) {
885 Some(expr) => expr,
886 None => return Ok(Vec::new()),
887 };
888
889 let snippet_expr = if request.snippet_chars == 0 {
896 "NULL AS snippet".to_string()
897 } else {
898 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
899 format!("snippet({table}, 3, '', '', '...', {chars})")
900 };
901
902 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
903 build_filter_clause(filter, &table, 3)
904 } else {
905 (String::new(), Vec::new())
906 };
907
908 let sql = format!(
909 "SELECT subject_id, rank, title, {snippet_expr} \
910 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
911 ORDER BY rank LIMIT ?2",
912 );
913
914 let mut stmt = conn.prepare(&sql)?;
915 stmt.raw_bind_parameter(1, &match_expr)?;
916 stmt.raw_bind_parameter(2, request.top_k as i64)?;
917
918 for (i, param) in filter_params.iter().enumerate() {
919 param
920 .to_sql()
921 .map(|val| stmt.raw_bind_parameter(3 + i, val))
922 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
923 }
924
925 let mut hits = Vec::new();
926 let mut rows = stmt.raw_query();
927 let mut rank_idx = 0u32;
928
929 while let Some(row) = rows.next()? {
930 let id_str: String = row.get(0)?;
931 let fts_rank: f64 = row.get(1)?;
932 let title: String = row.get(2)?;
933 let snippet: Option<String> = row.get(3)?;
934
935 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
936 rusqlite::Error::FromSqlConversionFailure(
937 0,
938 rusqlite::types::Type::Text,
939 Box::new(e),
940 )
941 })?;
942
943 rank_idx += 1;
944 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
945 }
946
947 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
950 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
951 let range = max_rank - min_rank;
952
953 let results = hits
954 .into_iter()
955 .map(|(subject_id, raw_rank, rank, title, snippet)| {
956 let score = if range.abs() < 1e-12 {
957 1.0
958 } else {
959 let t = (max_rank - raw_rank) / range;
960 0.05 + 0.95 * t
961 };
962 TextSearchHit {
963 subject_id,
964 score: DeterministicScore::from_f64(score),
965 rank,
966 title: if title.is_empty() { None } else { Some(title) },
967 snippet: snippet.filter(|s| !s.is_empty()),
968 }
969 })
970 .collect();
971
972 Ok(results)
973 })
974 .await
975 }
976
977 async fn count(&self, filter: TextFilter) -> Result<u64, StorageError> {
978 let table = self.table_name.clone();
979
980 self.with_reader("fts_count", move |conn| {
981 let (filter_clause, filter_params) = build_filter_clause(&filter, &table, 1);
982
983 let sql = if filter_clause.is_empty() {
984 format!("SELECT COUNT(*) FROM {}", table)
985 } else {
986 let where_part = filter_clause.trim_start_matches(" AND ");
987 format!("SELECT COUNT(*) FROM {} WHERE {}", table, where_part)
988 };
989
990 let mut stmt = conn.prepare(&sql)?;
991
992 for (i, param) in filter_params.iter().enumerate() {
993 param
994 .to_sql()
995 .map(|val| stmt.raw_bind_parameter(1 + i, val))
996 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
997 }
998
999 let mut rows = stmt.raw_query();
1000 match rows.next()? {
1001 Some(row) => {
1002 let count: i64 = row.get(0)?;
1003 Ok(count as u64)
1004 }
1005 None => Ok(0),
1006 }
1007 })
1008 .await
1009 }
1010
1011 async fn stats(&self) -> Result<TextIndexStats, StorageError> {
1012 let table = self.table_name.clone();
1013
1014 self.with_reader("fts_stats", move |conn| {
1015 let sql = format!("SELECT COUNT(*) FROM {}", table);
1016 let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
1017
1018 Ok(TextIndexStats {
1019 document_count: count as u64,
1020 needs_rebuild: false,
1021 last_rebuild_at: None,
1022 })
1023 })
1024 .await
1025 }
1026
1027 async fn search_with_options(
1028 &self,
1029 request: TextSearchRequest,
1030 options: TextSearchOptions,
1031 ) -> Result<Vec<TextSearchHit>, StorageError> {
1032 match options.gather_mode {
1033 TextGatherMode::Ranked => self.search(request).await,
1034 TextGatherMode::Unranked => self.search_unranked(request).await,
1035 TextGatherMode::RankWithinCap => {
1036 let gather_limit = options
1037 .gather_limit
1038 .unwrap_or(request.top_k)
1039 .max(request.top_k);
1040 self.search_rank_within_cap(request, gather_limit).await
1041 }
1042 }
1043 }
1044
1045 async fn term_stats(
1046 &self,
1047 request: TextTermStatsRequest,
1048 ) -> Result<Vec<TextTermStats>, StorageError> {
1049 let table = self.table_name.clone();
1050
1051 self.with_reader("fts_term_stats", move |conn| {
1052 let filter = request.filter.as_ref();
1053
1054 let (count_filter_clause, count_filter_params) = if let Some(f) = filter {
1056 build_filter_clause(f, &table, 1)
1057 } else {
1058 (String::new(), Vec::new())
1059 };
1060
1061 let document_count: u64 = {
1062 let count_sql = if count_filter_clause.is_empty() {
1063 format!("SELECT COUNT(*) FROM {table}")
1064 } else {
1065 let where_part = count_filter_clause.trim_start_matches(" AND ");
1066 format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1067 };
1068 let mut stmt = conn.prepare(&count_sql)?;
1069 for (i, param) in count_filter_params.iter().enumerate() {
1070 param
1071 .to_sql()
1072 .map(|val| stmt.raw_bind_parameter(1 + i, val))
1073 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1074 }
1075 let mut rows = stmt.raw_query();
1076 match rows.next()? {
1077 Some(row) => {
1078 let c: i64 = row.get(0)?;
1079 c as u64
1080 }
1081 None => 0,
1082 }
1083 };
1084
1085 let mut results = Vec::with_capacity(request.terms.len());
1086 for term in &request.terms {
1087 let sanitized = sanitize_fts5_token_group(term).unwrap_or_default();
1089 if sanitized.is_empty() {
1090 results.push(TextTermStats {
1091 term: term.clone(),
1092 sanitized_term: sanitized,
1093 document_frequency: 0,
1094 document_count,
1095 inverse_document_frequency: 0.0,
1096 });
1097 continue;
1098 }
1099
1100 let (term_filter_clause, term_filter_params) = if let Some(f) = filter {
1102 build_filter_clause(f, &table, 2)
1103 } else {
1104 (String::new(), Vec::new())
1105 };
1106
1107 let count_sql = format!(
1108 "SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{term_filter_clause}"
1109 );
1110 let mut stmt = conn.prepare(&count_sql)?;
1111 stmt.raw_bind_parameter(1, &sanitized)?;
1112 for (i, param) in term_filter_params.iter().enumerate() {
1113 param
1114 .to_sql()
1115 .map(|val| stmt.raw_bind_parameter(2 + i, val))
1116 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1117 }
1118
1119 let df: u64 = {
1120 let mut rows = stmt.raw_query();
1121 match rows.next()? {
1122 Some(row) => {
1123 let c: i64 = row.get(0)?;
1124 c as u64
1125 }
1126 None => 0,
1127 }
1128 };
1129
1130 let idf = Fts5TextSearch::bm25_idf(df, document_count);
1131 results.push(TextTermStats {
1132 term: term.clone(),
1133 sanitized_term: sanitized,
1134 document_frequency: df,
1135 document_count,
1136 inverse_document_frequency: idf,
1137 });
1138 }
1139
1140 Ok(results)
1141 })
1142 .await
1143 }
1144
1145 async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<TextIndexStats, StorageError> {
1146 let table = self.table_name.clone();
1147
1148 self.with_writer("fts_rebuild", move |conn| {
1149 let sql = format!("INSERT INTO {}({}) VALUES('rebuild')", table, table);
1151 conn.execute(&sql, [])?;
1152
1153 let count_sql = format!("SELECT COUNT(*) FROM {}", table);
1154 let count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
1155
1156 Ok(TextIndexStats {
1157 document_count: count as u64,
1158 needs_rebuild: false,
1159 last_rebuild_at: Some(Utc::now()),
1160 })
1161 })
1162 .await
1163 }
1164}
1165
1166impl Fts5TextSearch {
1167 fn bm25_idf(df: u64, document_count: u64) -> f64 {
1169 let n = document_count as f64;
1170 let f = df as f64;
1171 ((n - f + 0.5) / (f + 0.5) + 1.0).ln()
1172 }
1173
1174 async fn search_unranked(
1176 &self,
1177 request: TextSearchRequest,
1178 ) -> Result<Vec<TextSearchHit>, StorageError> {
1179 let table = self.table_name.clone();
1180
1181 self.with_reader("fts_search_unranked", move |conn| {
1182 let match_expr = match build_match_expr(&request.query, request.mode) {
1183 Some(expr) => expr,
1184 None => return Ok(Vec::new()),
1185 };
1186
1187 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1188 build_filter_clause(filter, &table, 3)
1189 } else {
1190 (String::new(), Vec::new())
1191 };
1192
1193 let sql = format!(
1195 "SELECT subject_id, title \
1196 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
1197 LIMIT ?2",
1198 );
1199
1200 let mut stmt = conn.prepare(&sql)?;
1201 stmt.raw_bind_parameter(1, &match_expr)?;
1202 stmt.raw_bind_parameter(2, request.top_k as i64)?;
1203
1204 for (i, param) in filter_params.iter().enumerate() {
1205 param
1206 .to_sql()
1207 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1208 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1209 }
1210
1211 let mut results = Vec::new();
1212 let mut rows = stmt.raw_query();
1213 let mut rank_idx = 0u32;
1214
1215 while let Some(row) = rows.next()? {
1216 let id_str: String = row.get(0)?;
1217 let title: String = row.get(1)?;
1218
1219 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1220 rusqlite::Error::FromSqlConversionFailure(
1221 0,
1222 rusqlite::types::Type::Text,
1223 Box::new(e),
1224 )
1225 })?;
1226
1227 rank_idx += 1;
1228 results.push(TextSearchHit {
1229 subject_id,
1230 score: DeterministicScore::from_f64(1.0),
1231 rank: rank_idx,
1232 title: if title.is_empty() { None } else { Some(title) },
1233 snippet: None,
1234 });
1235 }
1236
1237 Ok(results)
1238 })
1239 .await
1240 }
1241
1242 async fn search_rank_within_cap(
1244 &self,
1245 request: TextSearchRequest,
1246 gather_limit: u32,
1247 ) -> Result<Vec<TextSearchHit>, StorageError> {
1248 let table = self.table_name.clone();
1249
1250 self.with_reader("fts_search_rank_within_cap", move |conn| {
1251 let match_expr = match build_match_expr(&request.query, request.mode) {
1252 Some(expr) => expr,
1253 None => return Ok(Vec::new()),
1254 };
1255
1256 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1257 build_filter_clause(filter, &table, 3)
1258 } else {
1259 (String::new(), Vec::new())
1260 };
1261
1262 let gather_sql = format!(
1264 "SELECT subject_id FROM {table} WHERE {table} MATCH ?1{filter_clause} LIMIT ?2"
1265 );
1266
1267 let mut stmt = conn.prepare(&gather_sql)?;
1268 stmt.raw_bind_parameter(1, &match_expr)?;
1269 stmt.raw_bind_parameter(2, gather_limit as i64)?;
1270 for (i, param) in filter_params.iter().enumerate() {
1271 param
1272 .to_sql()
1273 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1274 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1275 }
1276
1277 let mut gathered_ids: Vec<String> = Vec::new();
1278 let mut rows = stmt.raw_query();
1279 while let Some(row) = rows.next()? {
1280 gathered_ids.push(row.get::<_, String>(0)?);
1281 }
1282
1283 if gathered_ids.is_empty() {
1284 return Ok(Vec::new());
1285 }
1286
1287 let snippet_expr = if request.snippet_chars == 0 {
1289 "NULL AS snippet".to_string()
1290 } else {
1291 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1292 format!("snippet({table}, 3, '', '', '...', {chars})")
1293 };
1294
1295 let id_placeholders: Vec<String> = gathered_ids
1297 .iter()
1298 .enumerate()
1299 .map(|(i, _)| format!("?{}", 3 + i))
1300 .collect();
1301 let in_clause = id_placeholders.join(", ");
1302
1303 let rank_sql = format!(
1304 "SELECT subject_id, rank, title, {snippet_expr} \
1305 FROM {table} WHERE {table} MATCH ?1 AND subject_id IN ({in_clause}) \
1306 ORDER BY rank LIMIT ?2"
1307 );
1308
1309 let mut stmt2 = conn.prepare(&rank_sql)?;
1310 stmt2.raw_bind_parameter(1, &match_expr)?;
1311 stmt2.raw_bind_parameter(2, request.top_k as i64)?;
1312 for (i, id_str) in gathered_ids.iter().enumerate() {
1313 stmt2.raw_bind_parameter(3 + i, id_str.as_str())?;
1314 }
1315
1316 let mut hits = Vec::new();
1317 let mut rows2 = stmt2.raw_query();
1318 let mut rank_idx = 0u32;
1319
1320 while let Some(row) = rows2.next()? {
1321 let id_str: String = row.get(0)?;
1322 let fts_rank: f64 = row.get(1)?;
1323 let title: String = row.get(2)?;
1324 let snippet: Option<String> = row.get(3)?;
1325
1326 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1327 rusqlite::Error::FromSqlConversionFailure(
1328 0,
1329 rusqlite::types::Type::Text,
1330 Box::new(e),
1331 )
1332 })?;
1333
1334 rank_idx += 1;
1335 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1336 }
1337
1338 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1340 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1341 let range = max_rank - min_rank;
1342
1343 let results = hits
1344 .into_iter()
1345 .map(|(subject_id, raw_rank, rank, title, snippet)| {
1346 let score = if range.abs() < 1e-12 {
1347 1.0
1348 } else {
1349 let t = (max_rank - raw_rank) / range;
1350 0.05 + 0.95 * t
1351 };
1352 TextSearchHit {
1353 subject_id,
1354 score: DeterministicScore::from_f64(score),
1355 rank,
1356 title: if title.is_empty() { None } else { Some(title) },
1357 snippet: snippet.filter(|s| !s.is_empty()),
1358 }
1359 })
1360 .collect();
1361
1362 Ok(results)
1363 })
1364 .await
1365 }
1366
1367 #[allow(dead_code)]
1378 pub(crate) async fn rename_namespace(
1379 &self,
1380 old_namespace: &str,
1381 new_namespace: &str,
1382 ) -> Result<u64, StorageError> {
1383 if old_namespace == new_namespace {
1384 return Ok(0);
1385 }
1386 let table = self.table_name.clone();
1387 let old_ns = old_namespace.to_string();
1388 let new_ns = new_namespace.to_string();
1389
1390 let origin = self.pool.origin();
1391 self.with_writer_unmanaged("fts_rename_namespace", move |conn| {
1392 let sel_sql = format!(
1393 "SELECT subject_id, kind, title, body, tags, metadata, updated_at \
1394 FROM {} WHERE namespace = ?1",
1395 table
1396 );
1397 struct Row {
1398 subject_id: String,
1399 kind: String,
1400 title: String,
1401 body: String,
1402 tags: String,
1403 metadata: Option<String>,
1404 updated_at: i64,
1405 }
1406 let rows: Vec<Row> = {
1407 let mut stmt = conn.prepare(&sel_sql)?;
1408 let iter = stmt.query_map(rusqlite::params![&old_ns], |row| {
1409 Ok(Row {
1410 subject_id: row.get(0)?,
1411 kind: row.get(1)?,
1412 title: row.get(2)?,
1413 body: row.get(3)?,
1414 tags: row.get(4)?,
1415 metadata: row.get(5)?,
1416 updated_at: row.get(6)?,
1417 })
1418 })?;
1419 iter.collect::<Result<Vec<_>, _>>()?
1420 };
1421 let moved = rows.len() as u64;
1422 if moved == 0 {
1423 return Ok(0u64);
1424 }
1425
1426 conn.execute_batch("BEGIN IMMEDIATE")?;
1427 let _tx_handle = khive_storage::tx_registry::register_scoped(
1428 Some("text_rename_namespace".to_string()),
1429 origin,
1430 );
1431
1432 let del_sql = format!("DELETE FROM {} WHERE namespace = ?1", table);
1433 if let Err(e) = conn.execute(&del_sql, rusqlite::params![&old_ns]) {
1434 let _ = conn.execute_batch("ROLLBACK");
1435 return Err(e);
1436 }
1437
1438 let ins_sql = format!(
1439 "INSERT INTO {} \
1440 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1441 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1442 table
1443 );
1444 for row in &rows {
1445 if let Err(e) = conn.execute(
1446 &ins_sql,
1447 rusqlite::params![
1448 row.subject_id,
1449 row.kind,
1450 row.title,
1451 row.body,
1452 row.tags,
1453 &new_ns,
1454 row.metadata,
1455 row.updated_at,
1456 ],
1457 ) {
1458 let _ = conn.execute_batch("ROLLBACK");
1459 return Err(e);
1460 }
1461 }
1462
1463 conn.execute_batch("COMMIT")?;
1464 Ok(moved)
1465 })
1466 .await
1467 }
1468}
1469
1470#[cfg(test)]
1471#[path = "text_tests.rs"]
1472mod tests;