Skip to main content

khive_db/stores/
text.rs

1//! FTS5-backed `TextSearch`: one virtual table per model, scores normalized to `(0.05, 1.0]`.
2
3use 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
25/// The exact `DELETE` this store's `delete_document` issues, for a given
26/// FTS table (ADR-099 B3 r6 structural cut — see `entity.rs`'s sibling
27/// block). `table` must already be a trusted, sanitized table name (this
28/// mirrors `delete_document`'s own pre-existing lack of a placeholder for
29/// table names — `format!` is required since table identifiers cannot be
30/// bound as SQL parameters).
31pub 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
42/// Build the `INSERT` half of the FTS delete-then-insert upsert.
43///
44/// `table` must be a trusted, sanitized table name because SQL identifiers
45/// cannot be bound as parameters.
46pub 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/// Ensure the FTS5 virtual table for `table_key` exists.
73///
74/// Used in tests to set up an in-memory FTS5 table without the full `StorageBackend`.
75#[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
105/// A TextSearch backed by SQLite FTS5 virtual tables.
106///
107/// Each instance manages one table: `fts_{table_key}`. Documents are stored
108/// with their metadata in UNINDEXED columns; only `title` and `body` are
109/// full-text indexed.
110pub 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    /// Create a new FTS5 text search instance.
119    ///
120    /// The FTS5 virtual table must already exist (created by `StorageBackend::text()`).
121    pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
122        let table_name = format!("fts_{}", table_key);
123        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
124        // policy): a missing writer task degrades to the legacy pool-mutex /
125        // standalone-connection path rather than failing construction.
126        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    /// Route a single-row write through the pool-wide `WriterTask` when
148    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
149    /// to the legacy standalone-connection / pool-mutex path (ADR-067
150    /// Component A, Fork C slice 2). See `crates/khive-db/docs/api/text.md`
151    /// for the per-caller routing rules (which methods bypass this via
152    /// `with_writer_unmanaged` and why).
153    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    /// Legacy standalone-connection / pool-mutex write path, bypassing the
168    /// WriterTask channel unconditionally regardless of
169    /// `KHIVE_WRITE_QUEUE`.
170    ///
171    /// Reserved for closures that manage their own transaction (a bare
172    /// `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`) — those cannot be sent through
173    /// the WriterTask channel, which already wraps every request in its own
174    /// transaction. `rename_namespace` is the only caller.
175    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
242// -- Helper functions --
243
244fn 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
262/// Sanitize an FTS5 query string to prevent driver errors from special
263/// chars: replace grouping/separator chars with spaces (splitting
264/// punctuated identifiers into terms), strip remaining FTS5 operator
265/// characters, then drop FTS5 keyword tokens (AND, OR, NOT, NEAR). See
266/// `crates/khive-db/docs/api/text.md` for the exact char sets and the
267/// issues (#388 and others) that grew them.
268fn sanitize_fts5_query(query: &str) -> String {
269    // Pass 1: replace grouping/separator chars with spaces to isolate tokens.
270    // Colon, hyphen, and dot are included here (not in Pass 2) so punctuated
271    // identifiers become separate terms rather than merged tokens.
272    let spaced: String = query
273        .chars()
274        .map(|c| {
275            if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
276                ' '
277            } else {
278                c
279            }
280        })
281        .collect();
282
283    // Pass 2: remove remaining FTS5 special chars and control characters.
284    // Single quote (apostrophe) is included because FTS5 Plain-mode queries treat
285    // it as a string-literal delimiter causing "syntax error near '''".
286    // Dollar sign is included (#388) because FTS5's MATCH parser rejects it
287    // unconditionally — `syntax error near "$"` — wherever it appears in the
288    // expression, e.g. "$prev.id" (a common agent query for DSL docs).
289    let sanitized: String = spaced
290        .chars()
291        .filter(|c| {
292            !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
293        })
294        .collect();
295
296    // Pass 3: filter FTS5 operator keywords.
297    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
309/// Legacy (pre-#397) sanitization: hyphen and dot are stripped outright
310/// (not space-split), so `khive-pack-memory` normalizes to the single merged
311/// bareword `khivepackmemory`. Used only to build the merged-form
312/// OR-alternative in [`sanitize_fts5_token_group`] — never as the sole
313/// sanitized query — so pre-#397-indexed content stays reachable. See
314/// `crates/khive-db/docs/api/text.md`.
315fn 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
349/// Escape a raw token for use inside a double-quoted FTS5 phrase. Embedded
350/// double quotes are doubled; the trailing-`*` prefix-query trigger and
351/// control characters are removed. Everything else, including punctuation,
352/// passes through literally, since FTS5 phrase text is matched
353/// against the column's own tokenization of that literal text (word-exact
354/// under `unicode61`, substring-exact under `trigram`), not re-sanitized.
355///
356/// Returns `None` if nothing survives the filter.
357fn 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
376/// Below this length, FTS5's built-in `trigram` tokenizer (the production
377/// default — `backend.rs::StorageBackend::text()`) produces zero tokens for
378/// a bareword MATCH term. Verified against a live `tokenize='trigram'`
379/// table: a term this short silently drops out of its AND clause instead of
380/// constraining the match, e.g. `2026 07 10` matches any row containing
381/// `2026` regardless of month/day. `sanitize_fts5_token_group` treats any
382/// split segment at or below this length as trigram-unsafe.
383const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
384
385/// FTS5 reserves punctuation in barewords for query syntax, including some
386/// forms that change query meaning without producing an error. Keep bareword
387/// alternatives to the conservative ASCII set and quote everything else.
388fn is_fts5_bareword_safe(s: &str) -> bool {
389    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
390}
391
392/// Sanitize a single whitespace-isolated raw token into an FTS5
393/// match-expression fragment, emitting additive OR-alternatives (split
394/// AND-group, legacy-merged form, quoted literal phrase) so the result is
395/// never a narrower match than any single safe form alone. Returns `None` if
396/// the token sanitizes to nothing. See
397/// `crates/khive-db/docs/api/text.md` for the full #397 trigram-safety
398/// rationale behind which alternatives get emitted and when.
399fn 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    // An operator-bearing token (e.g. `NEAR(alpha-beta,5)`) can make the
421    // legacy merge itself collapse to multiple space-separated terms rather
422    // than one bareword: pass 1 of `sanitize_fts5_query_legacy_merged` spaces
423    // out `(`, `)`, and `,` while pass 2 removes `-`/`.` outright, so
424    // `NEAR(alpha-beta,5)` merges to `"alphabeta 5"`, not one word. Pushed
425    // unguarded, that multi-term fragment carries the same trigram-unsafe
426    // `5` the split-group check above exists to exclude, and under FTS5's
427    // implicit-AND adjacency it silently drops, broadening the OR-alternative
428    // to any row containing `alphabeta`. Apply the same trigram-safety gate
429    // here whenever the merge is multi-term.
430    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    // Compare against the split AND-group's own space-joined content — the
439    // form it actually contributes to the expression — not the bare
440    // concatenation of its terms. For an ordinary punctuated identifier the
441    // concatenation always matches the merged bareword (both simply drop the
442    // separators), which suppressed this alternative unconditionally and cut
443    // off legacy-indexed content; a real duplicate only exists when the
444    // merge itself produced the same space-separated content the split group
445    // already emits (e.g. an operator token whose merge stays multi-term).
446    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
469/// Join Plain-mode per-token groups into one MATCH expression.
470///
471/// FTS5's implicit-AND adjacency rule (a bare space between two terms)
472/// applies only between two *plain* terms — verified against a live table:
473/// `GQA KV cache` (all bare) matches, but `GQA AND KV AND cache` does not,
474/// because FTS5's explicit `AND` treats a trigram-unsafe short bareword
475/// (`KV`, 2 chars, zero trigrams) as an unsatisfiable operand, while
476/// implicit adjacency instead lets it drop out harmlessly. So: use a bare
477/// space between two plain (unparenthesized) groups to preserve that
478/// existing leniency, and fall back to explicit `AND` only where at least
479/// one side is a parenthesized OR-group (`sanitize_fts5_token_group`'s
480/// output for punctuated tokens) — adjacency there without the operator is
481/// a MATCH-expression syntax error (`(a OR b) c` fails; `(a OR b) AND c`
482/// does not).
483fn 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
500/// Build the FTS5 MATCH expression for a query string under a given mode.
501///
502/// Centralizes the AnyTerm/Plain/Phrase branching previously duplicated
503/// across `search()`, `search_unranked()`, and `search_rank_within_cap()`.
504///
505/// AnyTerm and Plain both process the query token-by-token (split on
506/// whitespace) through [`sanitize_fts5_token_group`], so a punctuated
507/// identifier anywhere in the query gets its split/merged OR-alternative;
508/// AnyTerm joins the per-token groups with `OR`; Plain joins them via
509/// [`join_plain_groups`] (implicit-AND space where safe, explicit `AND`
510/// where a group is parenthesized). Phrase mode keeps the single-string
511/// literal behavior — a double-quoted FTS5 phrase cannot contain
512/// `OR`/parenthesized groups.
513///
514/// Returns `None` when the sanitized query is empty (caller short-circuits
515/// to an empty result set rather than sending an invalid MATCH expression).
516fn 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
546/// Build a WHERE clause fragment and params for a `TextFilter`.
547///
548/// Returns `(clause, params)` where clause is empty if no filters are active.
549/// Parameter indices start at `?{start_idx}`.
550fn 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
622/// DML-only single-document upsert shared by both the legacy (flag-off) and
623/// WriterTask-routed (flag-on) `upsert_document` paths (ADR-067 Component A).
624///
625/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
626/// enclosing transaction.
627fn 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
644/// DML-only batch upsert loop shared by both the legacy (flag-off) and
645/// WriterTask-routed (flag-on) `upsert_documents` paths (ADR-067 Component A).
646///
647/// Issues no OUTER `BEGIN` / `COMMIT` / `ROLLBACK` — the caller owns the
648/// enclosing transaction. The per-row named `SAVEPOINT fts_upsert_doc` is
649/// preserved unchanged: it is what gives this loop its partial-success
650/// semantics (one bad document does not abort the whole batch) independent
651/// of which outer transaction wraps the loop.
652fn 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        // ADR-067 Component A: when the write queue is enabled, route
729        // through the pool-wide WriterTask. DML-only closure — no BEGIN
730        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
731        // owns the transaction.
732        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        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
743        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK.
744        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        // ADR-067 Component A: when the write queue is enabled, route
771        // through the pool-wide WriterTask. DML-only closure (the per-row
772        // `SAVEPOINT fts_upsert_doc` is preserved unchanged — only the OUTER
773        // BEGIN IMMEDIATE/COMMIT is removed, since the WriterTask's run loop
774        // owns the enclosing transaction).
775        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        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
786        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
787        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            // Snippet column index 3 = body in the FTS5 schema.
890            // snippet_chars == 0 is the sentinel for "no snippet" — skip the
891            // snippet(...) call entirely and return NULL instead.  This avoids
892            // the ~12ms BM25 snippet computation on the hot recall path where
893            // snippets are unused.  Callers that need snippets (diagnostics) pass
894            // snippet_chars > 0 and get the same behaviour as before.
895            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            // Normalize scores within the result set to (0.05, 1.0].
948            // Best rank (most negative) maps to 1.0, worst to 0.05.
949            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            // Document count uses params starting at ?1 (no MATCH expression).
1055            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                // Keep document frequency aligned with the per-token search expression.
1088                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                // Per-term count: MATCH is ?1, so filter params start at ?2.
1101                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            // FTS5 rebuild command: repopulates the internal index structures.
1150            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    /// Robertson-Walker BM25 IDF: ln(((N - df + 0.5) / (df + 0.5)) + 1)
1168    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    /// Gather candidates without BM25 ranking; return with uniform score 1.0.
1175    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            // No rank column, no ORDER BY — avoids BM25 computation entirely.
1194            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    /// Two-stage gather: cheap unranked LIMIT gather_limit, then BM25-rank the subset.
1243    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            // Stage 1: cheap unranked gather of rowids.
1263            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            // Stage 2: BM25-rank only the gathered subset via subject_id IN (...).
1288            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            // Build IN clause for the gathered IDs.
1296            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            // Normalize scores within the ranked subset (same formula as search()).
1339            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    /// Move all FTS5 documents from `old_namespace` to `new_namespace` in a
1368    /// single transaction.
1369    ///
1370    /// FTS5 virtual tables do not support updating indexed columns (`title`,
1371    /// `body`) via UPDATE. The correct approach is read-then-delete-then-reinsert.
1372    ///
1373    /// Callers must invoke this after any SQL-level namespace change on the
1374    /// backing entity table so that FTS5 keyword search stays consistent with
1375    /// the entity store.
1376    // REASON: reserved for namespace migration operations
1377    #[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;