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::usage::{UsageContext, UsageUnit};
17use khive_storage::StorageCapability;
18use khive_storage::TextSearch;
19use khive_types::SubstrateKind;
20
21use crate::error::SqliteError;
22use crate::pool::ConnectionPool;
23use crate::sql_bridge::bind_params;
24use crate::writer_task::WriterTaskHandle;
25
26pub fn rowid_map_table(table: &str) -> String {
37 format!("{table}_rowids")
38}
39
40pub fn rowid_map_state_table(table: &str) -> String {
45 format!("{}_state", rowid_map_table(table))
46}
47
48pub const ROWID_MAP_BACKFILL_COMPLETE: &str = "complete";
52
53pub fn rowid_map_ddl(table: &str) -> String {
58 let map = rowid_map_table(table);
59 let state = rowid_map_state_table(table);
60 format!(
61 "CREATE TABLE IF NOT EXISTS {map} (\
62 namespace TEXT NOT NULL, \
63 subject_id TEXT NOT NULL, \
64 rowid INTEGER NOT NULL, \
65 PRIMARY KEY (namespace, subject_id)\
66 ) WITHOUT ROWID; \
67 CREATE TABLE IF NOT EXISTS {state} (\
68 key TEXT PRIMARY KEY, \
69 value TEXT NOT NULL\
70 ) WITHOUT ROWID"
71 )
72}
73
74pub fn delete_document_statement(table: &str, namespace: &str, subject_id: Uuid) -> SqlStatement {
97 let map = rowid_map_table(table);
98 SqlStatement {
99 sql: format!(
100 "DELETE FROM {table} WHERE rowid IN \
101 (SELECT rowid FROM {map} WHERE namespace = ?1 AND subject_id = ?2) \
102 AND namespace = ?1 AND subject_id = ?2"
103 ),
104 params: vec![
105 SqlValue::Text(namespace.to_string()),
106 SqlValue::Text(subject_id.to_string()),
107 ],
108 label: Some(format!("fts-delete-{table}")),
109 }
110}
111
112fn delete_document_statement_scan_fallback(
119 table: &str,
120 namespace: &str,
121 subject_id: Uuid,
122) -> SqlStatement {
123 SqlStatement {
124 sql: format!("DELETE FROM {table} WHERE namespace = ?1 AND subject_id = ?2"),
125 params: vec![
126 SqlValue::Text(namespace.to_string()),
127 SqlValue::Text(subject_id.to_string()),
128 ],
129 label: Some(format!("fts-delete-scan-fallback-{table}")),
130 }
131}
132
133pub fn delete_document_map_statement(
140 table: &str,
141 namespace: &str,
142 subject_id: Uuid,
143) -> SqlStatement {
144 let map = rowid_map_table(table);
145 SqlStatement {
146 sql: format!("DELETE FROM {map} WHERE namespace = ?1 AND subject_id = ?2"),
147 params: vec![
148 SqlValue::Text(namespace.to_string()),
149 SqlValue::Text(subject_id.to_string()),
150 ],
151 label: Some(format!("fts-delete-map-{table}")),
152 }
153}
154
155pub fn delete_document_statements(
163 table: &str,
164 namespace: &str,
165 subject_id: Uuid,
166) -> [SqlStatement; 2] {
167 [
168 delete_document_statement(table, namespace, subject_id),
169 delete_document_map_statement(table, namespace, subject_id),
170 ]
171}
172
173pub fn insert_document_statement(table: &str, document: &TextDocument) -> SqlStatement {
182 let tags_json = tags_to_json(&document.tags);
183 let metadata_json = document.metadata.as_ref().map(|v| v.to_string());
184 SqlStatement {
185 sql: format!(
186 "INSERT INTO {table} \
187 (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
188 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
189 ),
190 params: vec![
191 SqlValue::Text(document.subject_id.to_string()),
192 SqlValue::Text(document.kind.to_string()),
193 SqlValue::Text(document.title.clone().unwrap_or_default()),
194 SqlValue::Text(document.body.clone()),
195 SqlValue::Text(tags_json),
196 SqlValue::Text(document.namespace.clone()),
197 match metadata_json {
198 Some(m) => SqlValue::Text(m),
199 None => SqlValue::Null,
200 },
201 SqlValue::Integer(dt_to_micros(&document.updated_at)),
202 match &document.record_kind {
203 Some(kind) => SqlValue::Text(kind.clone()),
204 None => SqlValue::Null,
205 },
206 ],
207 label: Some(format!("fts-insert-{table}")),
208 }
209}
210
211pub fn insert_document_map_statement(
221 table: &str,
222 namespace: &str,
223 subject_id: Uuid,
224) -> SqlStatement {
225 let map = rowid_map_table(table);
226 SqlStatement {
227 sql: format!(
228 "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
229 VALUES (?1, ?2, last_insert_rowid())"
230 ),
231 params: vec![
232 SqlValue::Text(namespace.to_string()),
233 SqlValue::Text(subject_id.to_string()),
234 ],
235 label: Some(format!("fts-insert-map-{table}")),
236 }
237}
238
239pub fn insert_document_statements(table: &str, document: &TextDocument) -> [SqlStatement; 2] {
246 [
247 insert_document_statement(table, document),
248 insert_document_map_statement(table, &document.namespace, document.subject_id),
249 ]
250}
251
252#[cfg(test)]
257pub(crate) fn ensure_fts5_schema(
258 conn: &rusqlite::Connection,
259 table_key: &str,
260) -> Result<(), rusqlite::Error> {
261 let table_name = format!("fts_{}", table_key);
262 let ddl = format!(
263 "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(\
264 subject_id UNINDEXED, \
265 kind UNINDEXED, \
266 title, \
267 body, \
268 tags UNINDEXED, \
269 namespace UNINDEXED, \
270 metadata UNINDEXED, \
271 updated_at UNINDEXED, \
272 record_kind\
273 )",
274 table_name
275 );
276 conn.execute_batch(&ddl)?;
277 conn.execute_batch(&rowid_map_ddl(&table_name))
278}
279
280fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
281 StorageError::driver(StorageCapability::Text, op, e)
282}
283
284fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
285 e.into_storage_error(StorageCapability::Text, op)
286}
287
288fn count_fts_pass(context: Option<&UsageContext>) {
289 if let Some(context) = context {
290 context.add(UsageUnit::FtsPasses, 1);
291 }
292}
293
294pub struct Fts5TextSearch {
301 pool: Arc<ConnectionPool>,
302 is_file_backed: bool,
303 table_name: String,
304 writer_task: Option<WriterTaskHandle>,
305 scan_fallback: bool,
312}
313
314impl Fts5TextSearch {
315 pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
320 Self::new_with_mode(pool, is_file_backed, table_key, false)
321 }
322
323 pub(crate) fn new_scan_fallback(
332 pool: Arc<ConnectionPool>,
333 is_file_backed: bool,
334 table_key: String,
335 ) -> Self {
336 Self::new_with_mode(pool, is_file_backed, table_key, true)
337 }
338
339 fn new_with_mode(
340 pool: Arc<ConnectionPool>,
341 is_file_backed: bool,
342 table_key: String,
343 scan_fallback: bool,
344 ) -> Self {
345 let table_name = format!("fts_{}", table_key);
346 let writer_task = pool.writer_task_handle().ok().flatten();
352 Self {
353 pool,
354 is_file_backed,
355 table_name,
356 writer_task,
357 scan_fallback,
358 }
359 }
360
361 fn current_writer_task(
371 &self,
372 operation: &'static str,
373 ) -> Result<Option<WriterTaskHandle>, StorageError> {
374 self.pool
375 .writer_task_for_write(self.writer_task.as_ref(), operation)
376 }
377
378 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
386 where
387 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
388 R: Send + 'static,
389 {
390 if let Some(writer_task) = self.current_writer_task(op)? {
391 return writer_task
392 .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
393 .await;
394 }
395
396 self.pool
397 .record_direct_route(crate::timeout_sink::Site::DirectRouteFtsGeneralWrite);
398 self.with_writer_unmanaged(op, f).await
399 }
400
401 async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
405 where
406 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
407 R: Send + 'static,
408 {
409 let pool = Arc::clone(&self.pool);
410 let result = tokio::task::spawn_blocking(move || {
411 pool.execute_direct_transaction(StorageCapability::Text, op, move |conn| {
412 f(conn).map_err(|error| map_err(error, op))
413 })
414 })
415 .await
416 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?;
417 if let Err(err) = &result {
418 let msg = err.to_string();
419 if msg.contains("locked") || msg.contains("busy") {
420 if self.is_file_backed {
421 crate::timeout_sink::emit_timeout(
426 &crate::timeout_sink::db_label(&self.pool),
427 crate::timeout_sink::Site::StandaloneText,
428 &msg,
429 None,
430 );
431 }
432 let open: Vec<String> = khive_storage::tx_registry::snapshot()
433 .into_iter()
434 .map(|(age, label)| {
435 format!(
436 "{}@{}ms",
437 label.as_deref().unwrap_or("unlabeled"),
438 age.as_millis()
439 )
440 })
441 .collect();
442 tracing::warn!(
443 op,
444 open_tx_count = open.len(),
445 open_txs = %open.join(","),
446 "text write starved on the SQLite write lock; open registered \
447 transactions listed (empty means the holder issued no \
448 registered BEGIN IMMEDIATE in this process)"
449 );
450 }
451 }
452 result
453 }
454
455 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
456 where
457 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
458 R: Send + 'static,
459 {
460 super::run_pooled_store_read(
461 Arc::clone(&self.pool),
462 StorageCapability::Text,
463 op,
464 move |conn| f(conn).map_err(|error| map_err(error, op)),
465 )
466 .await
467 }
468}
469
470fn tags_to_json(tags: &[String]) -> String {
473 serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
474}
475
476fn tags_from_json(s: &str) -> Vec<String> {
477 serde_json::from_str(s).unwrap_or_default()
478}
479
480fn dt_to_micros(dt: &DateTime<Utc>) -> i64 {
481 dt.timestamp_micros()
482}
483
484fn micros_to_dt(micros: i64) -> DateTime<Utc> {
485 Utc.timestamp_micros(micros)
486 .single()
487 .unwrap_or_else(Utc::now)
488}
489
490fn sanitize_fts5_query(query: &str) -> String {
497 let spaced: String = query
501 .chars()
502 .map(|c| {
503 if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
504 ' '
505 } else {
506 c
507 }
508 })
509 .collect();
510
511 let sanitized: String = spaced
518 .chars()
519 .filter(|c| {
520 !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
521 })
522 .collect();
523
524 sanitized
526 .split_whitespace()
527 .filter(|t| {
528 !matches!(
529 t.to_ascii_uppercase().as_str(),
530 "AND" | "OR" | "NOT" | "NEAR"
531 )
532 })
533 .collect::<Vec<_>>()
534 .join(" ")
535}
536
537fn sanitize_fts5_query_legacy_merged(query: &str) -> String {
544 let spaced: String = query
545 .chars()
546 .map(|c| {
547 if matches!(c, '(' | ')' | ',' | ':' | '/') {
548 ' '
549 } else {
550 c
551 }
552 })
553 .collect();
554
555 let sanitized: String = spaced
556 .chars()
557 .filter(|c| {
558 !matches!(
559 c,
560 '*' | '"' | '\'' | '+' | '-' | '^' | '.' | '~' | '!' | '$' | '\0'
561 ) && !c.is_control()
562 })
563 .collect();
564
565 sanitized
566 .split_whitespace()
567 .filter(|t| {
568 !matches!(
569 t.to_ascii_uppercase().as_str(),
570 "AND" | "OR" | "NOT" | "NEAR"
571 )
572 })
573 .collect::<Vec<_>>()
574 .join(" ")
575}
576
577fn sanitize_fts5_phrase_literal(token: &str) -> Option<String> {
586 let mut literal = String::with_capacity(token.len());
587 for c in token
588 .chars()
589 .filter(|c| !matches!(c, '*' | '\0') && !c.is_control())
590 {
591 if c == '"' {
592 literal.push_str("\"\"");
593 } else {
594 literal.push(c);
595 }
596 }
597 if literal.is_empty() {
598 None
599 } else {
600 Some(literal)
601 }
602}
603
604const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
612
613fn is_fts5_bareword_safe(s: &str) -> bool {
617 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
618}
619
620fn sanitize_fts5_token_group(token: &str) -> Option<String> {
628 let split = sanitize_fts5_query(token);
629 let split_terms: Vec<&str> = split.split_whitespace().collect();
630 if split_terms.is_empty() {
631 return None;
632 }
633
634 let all_bareword_safe = split_terms.iter().all(|t| is_fts5_bareword_safe(t));
635 if split_terms.len() == 1 && is_fts5_bareword_safe(token) {
636 return Some(split_terms[0].to_string());
637 }
638
639 let has_trigram_unsafe_segment = split_terms
640 .iter()
641 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
642
643 let mut alternatives = Vec::new();
644 if all_bareword_safe && !has_trigram_unsafe_segment {
645 alternatives.push(format!("({})", split_terms.join(" ")));
646 }
647
648 let merged = sanitize_fts5_query_legacy_merged(token);
659 let merged_terms: Vec<&str> = merged.split_whitespace().collect();
660 let merged_all_bareword_safe =
661 !merged_terms.is_empty() && merged_terms.iter().all(|t| is_fts5_bareword_safe(t));
662 let merged_has_unsafe_segment = merged_terms.len() > 1
663 && merged_terms
664 .iter()
665 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
666 let phrase = sanitize_fts5_phrase_literal(token);
675 let duplicates_split = merged == split_terms.join(" ");
676 let duplicates_phrase = phrase.as_deref() == Some(merged.as_str());
677 if merged_all_bareword_safe
678 && !merged.is_empty()
679 && !duplicates_split
680 && !duplicates_phrase
681 && !merged_has_unsafe_segment
682 {
683 alternatives.push(merged);
684 }
685
686 if let Some(phrase) = phrase {
687 alternatives.push(format!("\"{}\"", phrase));
688 }
689
690 match alternatives.len() {
691 0 => None,
692 1 => alternatives.into_iter().next(),
693 _ => Some(format!("({})", alternatives.join(" OR "))),
694 }
695}
696
697fn join_plain_groups(groups: &[String]) -> String {
712 let mut expr = String::new();
713 for (i, group) in groups.iter().enumerate() {
714 if i > 0 {
715 let prev_compound = groups[i - 1].starts_with('(');
716 let this_compound = group.starts_with('(');
717 expr.push_str(if prev_compound || this_compound {
718 " AND "
719 } else {
720 " "
721 });
722 }
723 expr.push_str(group);
724 }
725 expr
726}
727
728fn build_match_expr(query: &str, mode: TextQueryMode) -> Option<String> {
745 match mode {
746 TextQueryMode::AnyTerm => {
747 let groups: Vec<String> = query
748 .split_whitespace()
749 .filter_map(sanitize_fts5_token_group)
750 .collect();
751 if groups.is_empty() {
752 None
753 } else {
754 Some(groups.join(" OR "))
755 }
756 }
757 TextQueryMode::Plain => {
758 let groups: Vec<String> = query
759 .split_whitespace()
760 .filter_map(sanitize_fts5_token_group)
761 .collect();
762 if groups.is_empty() {
763 None
764 } else {
765 Some(join_plain_groups(&groups))
766 }
767 }
768 TextQueryMode::Phrase => {
769 sanitize_fts5_phrase_literal(query).map(|literal| format!("\"{}\"", literal))
770 }
771 }
772}
773
774fn quote_fts5_phrase(value: &str) -> String {
775 format!("\"{}\"", value.replace('"', "\"\""))
776}
777
778fn record_kind_match_expr(filter: Option<&TextFilter>) -> Option<String> {
785 let record_kinds = &filter?.record_kinds;
786 if record_kinds.is_empty() || record_kinds.iter().any(|kind| kind.chars().count() < 3) {
787 return None;
788 }
789
790 let clauses: Vec<String> = record_kinds
791 .iter()
792 .map(|kind| format!("record_kind : {}", quote_fts5_phrase(kind)))
793 .collect();
794 if clauses.len() == 1 {
795 clauses.into_iter().next()
796 } else {
797 Some(format!("({})", clauses.join(" OR ")))
798 }
799}
800
801fn build_filtered_match_expr(
802 query: &str,
803 mode: TextQueryMode,
804 filter: Option<&TextFilter>,
805) -> Option<String> {
806 let query = format!("{{title body}} : ({})", build_match_expr(query, mode)?);
810 match record_kind_match_expr(filter) {
811 Some(classifier) => Some(format!("{classifier} AND ({query})")),
812 None => Some(query),
813 }
814}
815
816const LEXICAL_BM25_RANK: &str = "bm25(0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0)";
823
824fn build_filter_clause(
829 filter: &TextFilter,
830 table: &str,
831 start_idx: usize,
832) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
833 let mut conditions: Vec<String> = Vec::new();
834 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
835 let mut idx = start_idx;
836
837 if !filter.ids.is_empty() {
838 let placeholders: Vec<String> = filter
839 .ids
840 .iter()
841 .map(|_| {
842 let p = format!("?{}", idx);
843 idx += 1;
844 p
845 })
846 .collect();
847 conditions.push(format!(
848 "{}.subject_id IN ({})",
849 table,
850 placeholders.join(", ")
851 ));
852 for id in &filter.ids {
853 params.push(Box::new(id.to_string()));
854 }
855 }
856
857 if !filter.kinds.is_empty() {
858 let placeholders: Vec<String> = filter
859 .kinds
860 .iter()
861 .map(|_| {
862 let p = format!("?{}", idx);
863 idx += 1;
864 p
865 })
866 .collect();
867 conditions.push(format!("{}.kind IN ({})", table, placeholders.join(", ")));
868 for kind in &filter.kinds {
869 params.push(Box::new(kind.to_string()));
870 }
871 }
872
873 if !filter.record_kinds.is_empty() {
874 let placeholders: Vec<String> = filter
875 .record_kinds
876 .iter()
877 .map(|_| {
878 let p = format!("?{}", idx);
879 idx += 1;
880 p
881 })
882 .collect();
883 conditions.push(format!(
884 "{}.record_kind IN ({})",
885 table,
886 placeholders.join(", ")
887 ));
888 for kind in &filter.record_kinds {
889 params.push(Box::new(kind.clone()));
890 }
891 }
892
893 if !filter.namespaces.is_empty() {
894 let placeholders: Vec<String> = filter
895 .namespaces
896 .iter()
897 .map(|_| {
898 let p = format!("?{}", idx);
899 idx += 1;
900 p
901 })
902 .collect();
903 conditions.push(format!(
904 "{}.namespace IN ({})",
905 table,
906 placeholders.join(", ")
907 ));
908 for ns in &filter.namespaces {
909 params.push(Box::new(ns.clone()));
910 }
911 }
912
913 if conditions.is_empty() {
914 (String::new(), params)
915 } else {
916 (format!(" AND {}", conditions.join(" AND ")), params)
917 }
918}
919
920#[cfg(test)]
944pub(crate) mod delete_dml_test_seam {
945 use std::collections::HashSet;
946 use std::sync::Mutex;
947 use uuid::Uuid;
948
949 pub(crate) static TARGETS: Mutex<Option<HashSet<(String, Uuid)>>> = Mutex::new(None);
950
951 pub(crate) fn arm(namespace: &str, subject_id: Uuid) {
952 TARGETS
953 .lock()
954 .unwrap()
955 .get_or_insert_with(HashSet::new)
956 .insert((namespace.to_string(), subject_id));
957 }
958
959 pub(crate) fn disarm(namespace: &str, subject_id: Uuid) {
960 if let Some(targets) = TARGETS.lock().unwrap().as_mut() {
961 targets.remove(&(namespace.to_string(), subject_id));
962 }
963 }
964
965 pub(crate) fn matches(namespace: &str, subject_id: Uuid) -> bool {
966 TARGETS
967 .lock()
968 .unwrap()
969 .as_ref()
970 .is_some_and(|targets| targets.contains(&(namespace.to_string(), subject_id)))
971 }
972}
973
974fn delete_document_dml(
983 conn: &rusqlite::Connection,
984 table: &str,
985 namespace: &str,
986 subject_id: Uuid,
987) -> Result<bool, SqliteError> {
988 let [fts_statement, map_statement] = delete_document_statements(table, namespace, subject_id);
989
990 let mut stmt = conn.prepare(&fts_statement.sql)?;
991 bind_params(&mut stmt, &fts_statement.params)?;
992 let deleted = stmt.raw_execute()? > 0;
993 drop(stmt);
994
995 #[cfg(test)]
996 if delete_dml_test_seam::matches(namespace, subject_id) {
997 return Err(SqliteError::InvalidData(
998 "delete_document_dml test seam: forced failure between the FTS-row \
999 delete and the map-row delete"
1000 .to_string(),
1001 ));
1002 }
1003
1004 let mut map_stmt = conn.prepare(&map_statement.sql)?;
1005 bind_params(&mut map_stmt, &map_statement.params)?;
1006 map_stmt.raw_execute()?;
1007
1008 Ok(deleted)
1009}
1010
1011fn upsert_document_dml(
1017 conn: &rusqlite::Connection,
1018 table: &str,
1019 document: &TextDocument,
1020) -> Result<(), rusqlite::Error> {
1021 let statement = delete_document_statement(table, &document.namespace, document.subject_id);
1025 let mut stmt = conn.prepare(&statement.sql)?;
1026 bind_params(&mut stmt, &statement.params)?;
1027 stmt.raw_execute()?;
1028
1029 for statement in insert_document_statements(table, document) {
1030 let mut stmt = conn.prepare(&statement.sql)?;
1031 bind_params(&mut stmt, &statement.params)?;
1032 stmt.raw_execute()?;
1033 }
1034 Ok(())
1035}
1036
1037fn batch_upsert_documents_dml(
1046 conn: &rusqlite::Connection,
1047 table: &str,
1048 documents: &[TextDocument],
1049 attempted: u64,
1050) -> Result<BatchWriteSummary, rusqlite::Error> {
1051 let map = rowid_map_table(table);
1052 let del_sql = format!(
1059 "DELETE FROM {table} WHERE rowid IN \
1060 (SELECT rowid FROM {map} WHERE namespace = ?1 AND subject_id = ?2) \
1061 AND namespace = ?1 AND subject_id = ?2"
1062 );
1063 let ins_sql = format!(
1064 "INSERT INTO {} \
1065 (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
1066 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1067 table
1068 );
1069 let map_ins_sql = format!(
1072 "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
1073 VALUES (?1, ?2, last_insert_rowid())"
1074 );
1075
1076 let mut summary = BatchWriteSummary {
1077 attempted,
1078 ..BatchWriteSummary::default()
1079 };
1080
1081 for (index, doc) in documents.iter().enumerate() {
1082 conn.execute_batch("SAVEPOINT fts_upsert_doc")?;
1083 let id_str = doc.subject_id.to_string();
1084 let namespace = &doc.namespace;
1085 let result = (|| {
1086 conn.execute(&del_sql, rusqlite::params![namespace, &id_str])?;
1087
1088 let tags_json = tags_to_json(&doc.tags);
1089 let metadata_json: Option<String> = doc.metadata.as_ref().map(|v| v.to_string());
1090
1091 conn.execute(
1092 &ins_sql,
1093 rusqlite::params![
1094 &id_str,
1095 &doc.kind.to_string(),
1096 doc.title.as_deref().unwrap_or(""),
1097 &doc.body,
1098 &tags_json,
1099 namespace,
1100 &metadata_json,
1101 dt_to_micros(&doc.updated_at),
1102 &doc.record_kind,
1103 ],
1104 )?;
1105 conn.execute(&map_ins_sql, rusqlite::params![namespace, &id_str])?;
1106 Ok::<(), rusqlite::Error>(())
1107 })();
1108
1109 match result {
1110 Ok(()) => {
1111 conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc")?;
1112 summary.affected = summary.affected.saturating_add(1);
1113 }
1114 Err(e) => {
1115 let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT fts_upsert_doc");
1116 let _ = conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc");
1117 let (class, retryability) = super::classify_batch_sqlite_error(&e);
1118 summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
1119 }
1120 }
1121 }
1122
1123 Ok(summary)
1124}
1125
1126#[async_trait]
1127impl TextSearch for Fts5TextSearch {
1128 async fn upsert_document(&self, document: TextDocument) -> Result<(), StorageError> {
1129 let table = self.table_name.clone();
1130
1131 if let Some(writer_task) = self.current_writer_task("fts_upsert")? {
1139 let table2 = table.clone();
1140 return writer_task
1141 .send_bounded_at(
1142 move |conn| {
1143 upsert_document_dml(conn, &table2, &document)
1144 .map_err(|e| map_err(e, "fts_upsert"))
1145 },
1146 "fts_upsert",
1147 )
1148 .await;
1149 }
1150
1151 self.with_writer("fts_upsert", move |conn| {
1153 upsert_document_dml(conn, &table, &document)
1154 })
1155 .await
1156 }
1157
1158 async fn upsert_documents(
1159 &self,
1160 documents: Vec<TextDocument>,
1161 ) -> Result<BatchWriteSummary, StorageError> {
1162 let table = self.table_name.clone();
1163 let attempted = documents.len() as u64;
1164
1165 if let Some(writer_task) = self.current_writer_task("fts_upsert_batch")? {
1173 let table2 = table.clone();
1174 return writer_task
1175 .send_bounded_at(
1176 move |conn| {
1177 batch_upsert_documents_dml(conn, &table2, &documents, attempted)
1178 .map_err(|e| map_err(e, "fts_upsert_batch"))
1179 },
1180 "fts_upsert_batch",
1181 )
1182 .await;
1183 }
1184
1185 self.with_writer("fts_upsert_batch", move |conn| {
1187 batch_upsert_documents_dml(conn, &table, &documents, attempted)
1188 })
1189 .await
1190 }
1191
1192 async fn delete_document(
1193 &self,
1194 namespace: &str,
1195 subject_id: Uuid,
1196 ) -> Result<bool, StorageError> {
1197 let table = self.table_name.clone();
1198 let namespace = namespace.to_string();
1199
1200 if self.scan_fallback {
1201 let statement = delete_document_statement_scan_fallback(&table, &namespace, subject_id);
1202 return self
1203 .with_writer("fts_delete_scan_fallback", move |conn| {
1204 let mut stmt = conn.prepare(&statement.sql)?;
1205 bind_params(&mut stmt, &statement.params)?;
1206 Ok(stmt.raw_execute()? > 0)
1207 })
1208 .await;
1209 }
1210
1211 if let Some(writer_task) = self.current_writer_task("fts_delete")? {
1219 let table2 = table.clone();
1220 let namespace2 = namespace.clone();
1221 return writer_task
1222 .send_bounded_at(
1223 move |conn| {
1224 delete_document_dml(conn, &table2, &namespace2, subject_id)
1225 .map_err(|e| map_sqlite_err(e, "fts_delete"))
1226 },
1227 "fts_delete",
1228 )
1229 .await;
1230 }
1231
1232 self.with_writer("fts_delete", move |conn| {
1233 delete_document_dml(conn, &table, &namespace, subject_id).map_err(|error| match error {
1234 SqliteError::Rusqlite(inner) => inner,
1235 other => rusqlite::Error::InvalidParameterName(other.to_string()),
1236 })
1237 })
1238 .await
1239 }
1240
1241 async fn get_document(
1242 &self,
1243 namespace: &str,
1244 subject_id: Uuid,
1245 ) -> Result<Option<TextDocument>, StorageError> {
1246 let namespace = namespace.to_string();
1247 let table = self.table_name.clone();
1248 let scan_fallback = self.scan_fallback;
1249
1250 self.with_reader("fts_get", move |conn| {
1251 let sql = if scan_fallback {
1252 format!(
1253 "SELECT subject_id, kind, title, body, tags, namespace, \
1254 metadata, updated_at, record_kind \
1255 FROM {table} WHERE namespace = ?1 AND subject_id = ?2"
1256 )
1257 } else {
1258 let map = rowid_map_table(&table);
1259 format!(
1260 "SELECT t.subject_id, t.kind, t.title, t.body, t.tags, t.namespace, \
1261 t.metadata, t.updated_at, t.record_kind \
1262 FROM {table} AS t JOIN {map} AS m ON m.rowid = t.rowid \
1263 WHERE m.namespace = ?1 AND m.subject_id = ?2 \
1264 AND t.namespace = ?1 AND t.subject_id = ?2"
1265 )
1266 };
1267 let mut stmt = conn.prepare(&sql)?;
1268 let mut rows = stmt.query(rusqlite::params![namespace, subject_id.to_string()])?;
1269
1270 match rows.next()? {
1271 Some(row) => {
1272 let id_str: String = row.get(0)?;
1273 let kind_str: String = row.get(1)?;
1274 let title: String = row.get(2)?;
1275 let body: String = row.get(3)?;
1276 let tags_json: String = row.get(4)?;
1277 let ns: String = row.get(5)?;
1278 let metadata_json: Option<String> = row.get(6)?;
1279 let updated_at_micros: i64 = row.get(7)?;
1280 let record_kind: Option<String> = row.get(8)?;
1281
1282 let sid = Uuid::parse_str(&id_str).map_err(|e| {
1283 rusqlite::Error::FromSqlConversionFailure(
1284 0,
1285 rusqlite::types::Type::Text,
1286 Box::new(e),
1287 )
1288 })?;
1289
1290 let kind = kind_str.parse::<SubstrateKind>().map_err(|e| {
1291 rusqlite::Error::FromSqlConversionFailure(
1292 1,
1293 rusqlite::types::Type::Text,
1294 Box::new(e),
1295 )
1296 })?;
1297
1298 Ok(Some(TextDocument {
1299 subject_id: sid,
1300 kind,
1301 record_kind,
1302 title: if title.is_empty() { None } else { Some(title) },
1303 body,
1304 tags: tags_from_json(&tags_json),
1305 namespace: ns,
1306 metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
1307 updated_at: micros_to_dt(updated_at_micros),
1308 }))
1309 }
1310 None => Ok(None),
1311 }
1312 })
1313 .await
1314 }
1315
1316 async fn search(&self, request: TextSearchRequest) -> Result<Vec<TextSearchHit>, StorageError> {
1317 let table = self.table_name.clone();
1318 let usage = khive_storage::usage::current();
1319
1320 let result = self
1321 .with_reader("fts_search", move |conn| {
1322 let match_expr = match build_filtered_match_expr(
1323 &request.query,
1324 request.mode,
1325 request.filter.as_ref(),
1326 ) {
1327 Some(expr) => expr,
1328 None => return Ok(Vec::new()),
1329 };
1330
1331 let snippet_expr = if request.snippet_chars == 0 {
1338 "NULL AS snippet".to_string()
1339 } else {
1340 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1341 format!("snippet({table}, 3, '', '', '...', {chars})")
1342 };
1343
1344 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1345 build_filter_clause(filter, &table, 3)
1346 } else {
1347 (String::new(), Vec::new())
1348 };
1349
1350 let sql = format!(
1351 "SELECT subject_id, rank, title, {snippet_expr} \
1352 FROM {table} WHERE {table} MATCH ?1 \
1353 AND rank MATCH '{LEXICAL_BM25_RANK}'{filter_clause} \
1354 ORDER BY rank LIMIT ?2",
1355 );
1356
1357 let mut stmt = conn.prepare(&sql)?;
1358 count_fts_pass(usage.as_ref());
1359 stmt.raw_bind_parameter(1, &match_expr)?;
1360 stmt.raw_bind_parameter(2, request.top_k as i64)?;
1361
1362 for (i, param) in filter_params.iter().enumerate() {
1363 param
1364 .to_sql()
1365 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1366 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1367 }
1368
1369 let mut hits = Vec::new();
1370 let mut rows = stmt.raw_query();
1371 let mut rank_idx = 0u32;
1372
1373 while let Some(row) = rows.next()? {
1374 let id_str: String = row.get(0)?;
1375 let fts_rank: f64 = row.get(1)?;
1376 let title: String = row.get(2)?;
1377 let snippet: Option<String> = row.get(3)?;
1378
1379 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1380 rusqlite::Error::FromSqlConversionFailure(
1381 0,
1382 rusqlite::types::Type::Text,
1383 Box::new(e),
1384 )
1385 })?;
1386
1387 rank_idx += 1;
1388 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1389 }
1390
1391 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1394 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1395 let range = max_rank - min_rank;
1396
1397 let results = hits
1398 .into_iter()
1399 .map(|(subject_id, raw_rank, rank, title, snippet)| {
1400 let score = if range.abs() < 1e-12 {
1401 1.0
1402 } else {
1403 let t = (max_rank - raw_rank) / range;
1404 0.05 + 0.95 * t
1405 };
1406 TextSearchHit {
1407 subject_id,
1408 score: DeterministicScore::from_f64(score),
1409 rank,
1410 title: if title.is_empty() { None } else { Some(title) },
1411 snippet: snippet.filter(|s| !s.is_empty()),
1412 }
1413 })
1414 .collect();
1415
1416 Ok(results)
1417 })
1418 .await;
1419
1420 result
1421 }
1422
1423 async fn count(&self, filter: TextFilter) -> Result<u64, StorageError> {
1424 let table = self.table_name.clone();
1425
1426 self.with_reader("fts_count", move |conn| {
1427 let indexed_classifier = record_kind_match_expr(Some(&filter));
1428 let filter_start = if indexed_classifier.is_some() { 2 } else { 1 };
1429 let (filter_clause, filter_params) = build_filter_clause(&filter, &table, filter_start);
1430
1431 let sql = if indexed_classifier.is_some() {
1432 format!("SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{filter_clause}")
1433 } else if filter_clause.is_empty() {
1434 format!("SELECT COUNT(*) FROM {table}")
1435 } else {
1436 let where_part = filter_clause.trim_start_matches(" AND ");
1437 format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1438 };
1439
1440 let mut stmt = conn.prepare(&sql)?;
1441
1442 if let Some(classifier) = indexed_classifier {
1443 stmt.raw_bind_parameter(1, classifier)?;
1444 }
1445
1446 for (i, param) in filter_params.iter().enumerate() {
1447 param
1448 .to_sql()
1449 .map(|val| stmt.raw_bind_parameter(filter_start + i, val))
1450 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1451 }
1452
1453 let mut rows = stmt.raw_query();
1454 match rows.next()? {
1455 Some(row) => {
1456 let count: i64 = row.get(0)?;
1457 Ok(count as u64)
1458 }
1459 None => Ok(0),
1460 }
1461 })
1462 .await
1463 }
1464
1465 async fn stats(&self) -> Result<TextIndexStats, StorageError> {
1466 let table = self.table_name.clone();
1467
1468 self.with_reader("fts_stats", move |conn| {
1469 let sql = format!("SELECT COUNT(*) FROM {}", table);
1470 let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
1471
1472 Ok(TextIndexStats {
1473 document_count: count as u64,
1474 needs_rebuild: false,
1475 last_rebuild_at: None,
1476 })
1477 })
1478 .await
1479 }
1480
1481 async fn search_with_options(
1482 &self,
1483 request: TextSearchRequest,
1484 options: TextSearchOptions,
1485 ) -> Result<Vec<TextSearchHit>, StorageError> {
1486 match options.gather_mode {
1487 TextGatherMode::Ranked => self.search(request).await,
1488 TextGatherMode::Unranked => self.search_unranked(request).await,
1489 TextGatherMode::RankWithinCap => {
1490 let gather_limit = options
1491 .gather_limit
1492 .unwrap_or(request.top_k)
1493 .max(request.top_k);
1494 self.search_rank_within_cap(request, gather_limit).await
1495 }
1496 }
1497 }
1498
1499 async fn term_stats(
1500 &self,
1501 request: TextTermStatsRequest,
1502 ) -> Result<Vec<TextTermStats>, StorageError> {
1503 let table = self.table_name.clone();
1504
1505 self.with_reader("fts_term_stats", move |conn| {
1506 let filter = request.filter.as_ref();
1507 let indexed_classifier = record_kind_match_expr(filter);
1508
1509 let count_filter_start = if indexed_classifier.is_some() { 2 } else { 1 };
1510 let (count_filter_clause, count_filter_params) = if let Some(f) = filter {
1511 build_filter_clause(f, &table, count_filter_start)
1512 } else {
1513 (String::new(), Vec::new())
1514 };
1515
1516 let document_count: u64 = {
1517 let count_sql = if indexed_classifier.is_some() {
1518 format!(
1519 "SELECT COUNT(*) FROM {table} \
1520 WHERE {table} MATCH ?1{count_filter_clause}"
1521 )
1522 } else if count_filter_clause.is_empty() {
1523 format!("SELECT COUNT(*) FROM {table}")
1524 } else {
1525 let where_part = count_filter_clause.trim_start_matches(" AND ");
1526 format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1527 };
1528 let mut stmt = conn.prepare(&count_sql)?;
1529 if let Some(ref classifier) = indexed_classifier {
1530 stmt.raw_bind_parameter(1, classifier)?;
1531 }
1532 for (i, param) in count_filter_params.iter().enumerate() {
1533 param
1534 .to_sql()
1535 .map(|val| stmt.raw_bind_parameter(count_filter_start + i, val))
1536 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1537 }
1538 let mut rows = stmt.raw_query();
1539 match rows.next()? {
1540 Some(row) => {
1541 let c: i64 = row.get(0)?;
1542 c as u64
1543 }
1544 None => 0,
1545 }
1546 };
1547
1548 let mut results = Vec::with_capacity(request.terms.len());
1549 for term in &request.terms {
1550 let sanitized = sanitize_fts5_token_group(term).unwrap_or_default();
1552 if sanitized.is_empty() {
1553 results.push(TextTermStats {
1554 term: term.clone(),
1555 sanitized_term: sanitized,
1556 document_frequency: 0,
1557 document_count,
1558 inverse_document_frequency: 0.0,
1559 });
1560 continue;
1561 }
1562
1563 let (term_filter_clause, term_filter_params) = if let Some(f) = filter {
1565 build_filter_clause(f, &table, 2)
1566 } else {
1567 (String::new(), Vec::new())
1568 };
1569
1570 let text_term = format!("{{title body}} : ({sanitized})");
1571 let term_match = match &indexed_classifier {
1572 Some(classifier) => format!("{classifier} AND ({text_term})"),
1573 None => text_term,
1574 };
1575 let count_sql = format!(
1576 "SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{term_filter_clause}"
1577 );
1578 let mut stmt = conn.prepare(&count_sql)?;
1579 stmt.raw_bind_parameter(1, &term_match)?;
1580 for (i, param) in term_filter_params.iter().enumerate() {
1581 param
1582 .to_sql()
1583 .map(|val| stmt.raw_bind_parameter(2 + i, val))
1584 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1585 }
1586
1587 let df: u64 = {
1588 let mut rows = stmt.raw_query();
1589 match rows.next()? {
1590 Some(row) => {
1591 let c: i64 = row.get(0)?;
1592 c as u64
1593 }
1594 None => 0,
1595 }
1596 };
1597
1598 let idf = Fts5TextSearch::bm25_idf(df, document_count);
1599 results.push(TextTermStats {
1600 term: term.clone(),
1601 sanitized_term: sanitized,
1602 document_frequency: df,
1603 document_count,
1604 inverse_document_frequency: idf,
1605 });
1606 }
1607
1608 Ok(results)
1609 })
1610 .await
1611 }
1612
1613 async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<TextIndexStats, StorageError> {
1614 let table = self.table_name.clone();
1615
1616 self.with_writer("fts_rebuild", move |conn| {
1617 let sql = format!("INSERT INTO {}({}) VALUES('rebuild')", table, table);
1619 conn.execute(&sql, [])?;
1620
1621 let count_sql = format!("SELECT COUNT(*) FROM {}", table);
1622 let count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
1623
1624 Ok(TextIndexStats {
1625 document_count: count as u64,
1626 needs_rebuild: false,
1627 last_rebuild_at: Some(Utc::now()),
1628 })
1629 })
1630 .await
1631 }
1632}
1633
1634impl Fts5TextSearch {
1635 fn bm25_idf(df: u64, document_count: u64) -> f64 {
1637 let n = document_count as f64;
1638 let f = df as f64;
1639 ((n - f + 0.5) / (f + 0.5) + 1.0).ln()
1640 }
1641
1642 async fn search_unranked(
1644 &self,
1645 request: TextSearchRequest,
1646 ) -> Result<Vec<TextSearchHit>, StorageError> {
1647 let table = self.table_name.clone();
1648 let usage = khive_storage::usage::current();
1649
1650 self.with_reader("fts_search_unranked", move |conn| {
1651 let match_expr = match build_filtered_match_expr(
1652 &request.query,
1653 request.mode,
1654 request.filter.as_ref(),
1655 ) {
1656 Some(expr) => expr,
1657 None => return Ok(Vec::new()),
1658 };
1659
1660 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1661 build_filter_clause(filter, &table, 3)
1662 } else {
1663 (String::new(), Vec::new())
1664 };
1665
1666 let sql = format!(
1668 "SELECT subject_id, title \
1669 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
1670 LIMIT ?2",
1671 );
1672
1673 let mut stmt = conn.prepare(&sql)?;
1674 count_fts_pass(usage.as_ref());
1675 stmt.raw_bind_parameter(1, &match_expr)?;
1676 stmt.raw_bind_parameter(2, request.top_k as i64)?;
1677
1678 for (i, param) in filter_params.iter().enumerate() {
1679 param
1680 .to_sql()
1681 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1682 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1683 }
1684
1685 let mut results = Vec::new();
1686 let mut rows = stmt.raw_query();
1687 let mut rank_idx = 0u32;
1688
1689 while let Some(row) = rows.next()? {
1690 let id_str: String = row.get(0)?;
1691 let title: String = row.get(1)?;
1692
1693 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1694 rusqlite::Error::FromSqlConversionFailure(
1695 0,
1696 rusqlite::types::Type::Text,
1697 Box::new(e),
1698 )
1699 })?;
1700
1701 rank_idx += 1;
1702 results.push(TextSearchHit {
1703 subject_id,
1704 score: DeterministicScore::from_f64(1.0),
1705 rank: rank_idx,
1706 title: if title.is_empty() { None } else { Some(title) },
1707 snippet: None,
1708 });
1709 }
1710
1711 Ok(results)
1712 })
1713 .await
1714 }
1715
1716 async fn search_rank_within_cap(
1718 &self,
1719 request: TextSearchRequest,
1720 gather_limit: u32,
1721 ) -> Result<Vec<TextSearchHit>, StorageError> {
1722 let table = self.table_name.clone();
1723 let usage = khive_storage::usage::current();
1724
1725 self.with_reader("fts_search_rank_within_cap", move |conn| {
1726 let match_expr = match build_filtered_match_expr(
1727 &request.query,
1728 request.mode,
1729 request.filter.as_ref(),
1730 ) {
1731 Some(expr) => expr,
1732 None => return Ok(Vec::new()),
1733 };
1734
1735 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1736 build_filter_clause(filter, &table, 3)
1737 } else {
1738 (String::new(), Vec::new())
1739 };
1740
1741 let gather_sql = format!(
1743 "SELECT subject_id FROM {table} WHERE {table} MATCH ?1{filter_clause} LIMIT ?2"
1744 );
1745
1746 let mut stmt = conn.prepare(&gather_sql)?;
1747 count_fts_pass(usage.as_ref());
1748 stmt.raw_bind_parameter(1, &match_expr)?;
1749 stmt.raw_bind_parameter(2, gather_limit as i64)?;
1750 for (i, param) in filter_params.iter().enumerate() {
1751 param
1752 .to_sql()
1753 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1754 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1755 }
1756
1757 let mut gathered_ids: Vec<String> = Vec::new();
1758 let mut rows = stmt.raw_query();
1759 while let Some(row) = rows.next()? {
1760 gathered_ids.push(row.get::<_, String>(0)?);
1761 }
1762
1763 if gathered_ids.is_empty() {
1764 return Ok(Vec::new());
1765 }
1766
1767 let snippet_expr = if request.snippet_chars == 0 {
1769 "NULL AS snippet".to_string()
1770 } else {
1771 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1772 format!("snippet({table}, 3, '', '', '...', {chars})")
1773 };
1774
1775 let id_placeholders: Vec<String> = gathered_ids
1777 .iter()
1778 .enumerate()
1779 .map(|(i, _)| format!("?{}", 3 + i))
1780 .collect();
1781 let in_clause = id_placeholders.join(", ");
1782
1783 let rank_sql = format!(
1784 "SELECT subject_id, rank, title, {snippet_expr} \
1785 FROM {table} WHERE {table} MATCH ?1 \
1786 AND rank MATCH '{LEXICAL_BM25_RANK}' \
1787 AND subject_id IN ({in_clause}) \
1788 ORDER BY rank LIMIT ?2",
1789 );
1790
1791 let mut stmt2 = conn.prepare(&rank_sql)?;
1792 count_fts_pass(usage.as_ref());
1793 stmt2.raw_bind_parameter(1, &match_expr)?;
1794 stmt2.raw_bind_parameter(2, request.top_k as i64)?;
1795 for (i, id_str) in gathered_ids.iter().enumerate() {
1796 stmt2.raw_bind_parameter(3 + i, id_str.as_str())?;
1797 }
1798
1799 let mut hits = Vec::new();
1800 let mut rows2 = stmt2.raw_query();
1801 let mut rank_idx = 0u32;
1802
1803 while let Some(row) = rows2.next()? {
1804 let id_str: String = row.get(0)?;
1805 let fts_rank: f64 = row.get(1)?;
1806 let title: String = row.get(2)?;
1807 let snippet: Option<String> = row.get(3)?;
1808
1809 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1810 rusqlite::Error::FromSqlConversionFailure(
1811 0,
1812 rusqlite::types::Type::Text,
1813 Box::new(e),
1814 )
1815 })?;
1816
1817 rank_idx += 1;
1818 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1819 }
1820
1821 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1823 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1824 let range = max_rank - min_rank;
1825
1826 let results = hits
1827 .into_iter()
1828 .map(|(subject_id, raw_rank, rank, title, snippet)| {
1829 let score = if range.abs() < 1e-12 {
1830 1.0
1831 } else {
1832 let t = (max_rank - raw_rank) / range;
1833 0.05 + 0.95 * t
1834 };
1835 TextSearchHit {
1836 subject_id,
1837 score: DeterministicScore::from_f64(score),
1838 rank,
1839 title: if title.is_empty() { None } else { Some(title) },
1840 snippet: snippet.filter(|s| !s.is_empty()),
1841 }
1842 })
1843 .collect();
1844
1845 Ok(results)
1846 })
1847 .await
1848 }
1849
1850 #[allow(dead_code)]
1861 pub(crate) async fn rename_namespace(
1862 &self,
1863 old_namespace: &str,
1864 new_namespace: &str,
1865 ) -> Result<u64, StorageError> {
1866 if old_namespace == new_namespace {
1867 return Ok(0);
1868 }
1869 let table = self.table_name.clone();
1870 let old_ns = old_namespace.to_string();
1871 let new_ns = new_namespace.to_string();
1872
1873 if let Some(writer_task) = self.current_writer_task("fts_rename_namespace")? {
1881 let table2 = table.clone();
1882 return writer_task
1883 .send_bounded_at(
1884 move |conn| {
1885 rename_namespace_dml(conn, &table2, &old_ns, &new_ns)
1886 .map_err(|e| map_err(e, "fts_rename_namespace"))
1887 },
1888 "fts_rename_namespace",
1889 )
1890 .await;
1891 }
1892
1893 self.pool
1894 .record_direct_route(crate::timeout_sink::Site::DirectRouteFtsRenameNamespace);
1895
1896 self.with_writer_unmanaged("fts_rename_namespace", move |conn| {
1897 rename_namespace_dml(conn, &table, &old_ns, &new_ns)
1899 })
1900 .await
1901 }
1902}
1903
1904struct FtsRenameRow {
1905 subject_id: String,
1906 kind: String,
1907 title: String,
1908 body: String,
1909 tags: String,
1910 metadata: Option<String>,
1911 updated_at: i64,
1912 record_kind: Option<String>,
1913}
1914
1915fn rename_namespace_dml(
1923 conn: &rusqlite::Connection,
1924 table: &str,
1925 old_ns: &str,
1926 new_ns: &str,
1927) -> Result<u64, rusqlite::Error> {
1928 let sel_sql = format!(
1929 "SELECT subject_id, kind, title, body, tags, metadata, updated_at, record_kind \
1930 FROM {} WHERE namespace = ?1",
1931 table
1932 );
1933 let rows: Vec<FtsRenameRow> = {
1934 let mut stmt = conn.prepare(&sel_sql)?;
1935 let iter = stmt.query_map(rusqlite::params![old_ns], |row| {
1936 Ok(FtsRenameRow {
1937 subject_id: row.get(0)?,
1938 kind: row.get(1)?,
1939 title: row.get(2)?,
1940 body: row.get(3)?,
1941 tags: row.get(4)?,
1942 metadata: row.get(5)?,
1943 updated_at: row.get(6)?,
1944 record_kind: row.get(7)?,
1945 })
1946 })?;
1947 iter.collect::<Result<Vec<_>, _>>()?
1948 };
1949 let moved = rows.len() as u64;
1950 if moved == 0 {
1951 return Ok(0);
1952 }
1953
1954 let del_sql = format!("DELETE FROM {} WHERE namespace = ?1", table);
1955 conn.execute(&del_sql, rusqlite::params![old_ns])?;
1956
1957 let map = rowid_map_table(table);
1961 let map_del_sql = format!("DELETE FROM {map} WHERE namespace = ?1");
1962 conn.execute(&map_del_sql, rusqlite::params![old_ns])?;
1963
1964 let ins_sql = format!(
1965 "INSERT INTO {} \
1966 (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
1967 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1968 table
1969 );
1970 let map_ins_sql = format!(
1971 "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
1972 VALUES (?1, ?2, last_insert_rowid())"
1973 );
1974 for row in &rows {
1975 conn.execute(
1976 &ins_sql,
1977 rusqlite::params![
1978 row.subject_id,
1979 row.kind,
1980 row.title,
1981 row.body,
1982 row.tags,
1983 new_ns,
1984 row.metadata,
1985 row.updated_at,
1986 row.record_kind,
1987 ],
1988 )?;
1989 conn.execute(&map_ins_sql, rusqlite::params![new_ns, row.subject_id])?;
1990 }
1991
1992 Ok(moved)
1993}
1994
1995#[cfg(test)]
1996#[path = "text_tests.rs"]
1997mod tests;
1998
1999#[cfg(test)]
2000#[path = "text_busy_tests.rs"]
2001mod direct_busy_tests;