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/// Ensure the FTS5 virtual table for `table_key` exists.
43///
44/// Used in tests to set up an in-memory FTS5 table without the full `StorageBackend`.
45#[cfg(test)]
46pub(crate) fn ensure_fts5_schema(
47    conn: &rusqlite::Connection,
48    table_key: &str,
49) -> Result<(), rusqlite::Error> {
50    let table_name = format!("fts_{}", table_key);
51    let ddl = format!(
52        "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(\
53         subject_id UNINDEXED, \
54         kind UNINDEXED, \
55         title, \
56         body, \
57         tags UNINDEXED, \
58         namespace UNINDEXED, \
59         metadata UNINDEXED, \
60         updated_at UNINDEXED\
61         )",
62        table_name
63    );
64    conn.execute_batch(&ddl)
65}
66
67fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
68    StorageError::driver(StorageCapability::Text, op, e)
69}
70
71fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
72    StorageError::driver(StorageCapability::Text, op, e)
73}
74
75/// A TextSearch backed by SQLite FTS5 virtual tables.
76///
77/// Each instance manages one table: `fts_{table_key}`. Documents are stored
78/// with their metadata in UNINDEXED columns; only `title` and `body` are
79/// full-text indexed.
80pub struct Fts5TextSearch {
81    pool: Arc<ConnectionPool>,
82    is_file_backed: bool,
83    table_name: String,
84    writer_task: Option<WriterTaskHandle>,
85}
86
87impl Fts5TextSearch {
88    /// Create a new FTS5 text search instance.
89    ///
90    /// The FTS5 virtual table must already exist (created by `StorageBackend::text()`).
91    pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
92        let table_name = format!("fts_{}", table_key);
93        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
94        // policy): a missing writer task degrades to the legacy pool-mutex /
95        // standalone-connection path rather than failing construction.
96        let writer_task = pool.writer_task_handle().ok().flatten();
97        Self {
98            pool,
99            is_file_backed,
100            table_name,
101            writer_task,
102        }
103    }
104
105    fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
106        self.pool
107            .open_standalone_writer()
108            .map_err(|e| map_sqlite_err(e, "open_fts_writer"))
109    }
110
111    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
112        self.pool
113            .open_standalone_reader()
114            .map_err(|e| map_sqlite_err(e, "open_fts_reader"))
115    }
116
117    /// Route a single-row write through the pool-wide `WriterTask` when
118    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
119    /// to the legacy standalone-connection / pool-mutex path (ADR-067
120    /// Component A, Fork C slice 2).
121    ///
122    /// This is the routing point for `with_writer` callers whose closure is
123    /// DML-only (`delete_document`/`fts_delete`, `rebuild`/`fts_rebuild`):
124    /// on the flag-on path the closure runs inside the WriterTask's own
125    /// transaction, so a bare `BEGIN IMMEDIATE` would violate SQLite's
126    /// nested-transaction rule. `upsert_document`/`upsert_documents` (the
127    /// single-doc and batch write methods) do their own flag check and
128    /// return early on `Some`, so their fallback calls into this helper
129    /// only ever execute on the flag-off path (`self.writer_task` is
130    /// `None` by construction whenever those calls are reached) — no
131    /// double-routing.
132    ///
133    /// `rename_namespace` (`#[allow(dead_code)]`, no production caller —
134    /// see ADR-067's `BEGIN IMMEDIATE` site inventory, EXEMPT) manages its
135    /// own manual transaction and calls [`Self::with_writer_unmanaged`]
136    /// instead of this helper — routing its closure through the WriterTask
137    /// would nest a bare `BEGIN IMMEDIATE` inside the WriterTask's own
138    /// transaction.
139    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
140    where
141        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
142        R: Send + 'static,
143    {
144        if let Some(writer_task) = &self.writer_task {
145            return writer_task
146                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
147                .await;
148        }
149
150        self.with_writer_unmanaged(op, f).await
151    }
152
153    /// Legacy standalone-connection / pool-mutex write path, bypassing the
154    /// WriterTask channel unconditionally regardless of
155    /// `KHIVE_WRITE_QUEUE`.
156    ///
157    /// Reserved for closures that manage their own transaction (a bare
158    /// `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`) — those cannot be sent through
159    /// the WriterTask channel, which already wraps every request in its own
160    /// transaction. `rename_namespace` is the only caller.
161    async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
162    where
163        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
164        R: Send + 'static,
165    {
166        if self.is_file_backed {
167            let conn = self.open_standalone_writer()?;
168            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
169                .await
170                .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
171        } else {
172            let pool = Arc::clone(&self.pool);
173            tokio::task::spawn_blocking(move || {
174                let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
175                f(guard.conn()).map_err(|e| map_err(e, op))
176            })
177            .await
178            .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
179        }
180    }
181
182    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
183    where
184        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
185        R: Send + 'static,
186    {
187        if self.is_file_backed {
188            let conn = self.open_standalone_reader()?;
189            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
190                .await
191                .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
192        } else {
193            let pool = Arc::clone(&self.pool);
194            tokio::task::spawn_blocking(move || {
195                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
196                f(guard.conn()).map_err(|e| map_err(e, op))
197            })
198            .await
199            .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
200        }
201    }
202}
203
204// -- Helper functions --
205
206fn tags_to_json(tags: &[String]) -> String {
207    serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
208}
209
210fn tags_from_json(s: &str) -> Vec<String> {
211    serde_json::from_str(s).unwrap_or_default()
212}
213
214fn dt_to_micros(dt: &DateTime<Utc>) -> i64 {
215    dt.timestamp_micros()
216}
217
218fn micros_to_dt(micros: i64) -> DateTime<Utc> {
219    Utc.timestamp_micros(micros)
220        .single()
221        .unwrap_or_else(Utc::now)
222}
223
224/// Sanitize an FTS5 query string to prevent driver errors from special chars.
225///
226/// Two-pass approach:
227/// 1. **Replace** grouping/separator chars with spaces so adjacent tokens are
228///    not merged. This prevents `NEAR(smile,5)` from becoming `NEARsmile5`.
229///    It also keeps punctuated identifiers searchable: `khive-pack-memory`
230///    becomes `khive pack memory`, not `khivepackmemory`.
231///    Chars replaced with space: `(`, `)`, `,`, `:`, `-`, `.`, `/`
232///    (`/` added: FTS5's MATCH-expression parser rejects a bareword
233///    containing `/` as a syntax error, e.g. the throughput query `GB/s`)
234/// 2. **Remove** remaining FTS5 operator characters (H1: `~`, `!` added;
235///    issue #388: `$` added — FTS5's MATCH-expression parser treats a bareword
236///    starting with, containing, or consisting solely of `$` as a syntax
237///    error regardless of tokenizer, e.g. the DSL-doc query `$prev.id`):
238///    `*`, `"`, `'`, `+`, `^`, `~`, `!`, `$`, `\0`, control characters
239///
240/// After character processing, split on whitespace and remove FTS5 keyword
241/// tokens: AND, OR, NOT, NEAR.
242///
243fn sanitize_fts5_query(query: &str) -> String {
244    // Pass 1: replace grouping/separator chars with spaces to isolate tokens.
245    // Colon, hyphen, and dot are included here (not in Pass 2) so punctuated
246    // identifiers become separate terms rather than merged tokens.
247    let spaced: String = query
248        .chars()
249        .map(|c| {
250            if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
251                ' '
252            } else {
253                c
254            }
255        })
256        .collect();
257
258    // Pass 2: remove remaining FTS5 special chars and control characters.
259    // Single quote (apostrophe) is included because FTS5 Plain-mode queries treat
260    // it as a string-literal delimiter causing "syntax error near '''".
261    // Dollar sign is included (#388) because FTS5's MATCH parser rejects it
262    // unconditionally — `syntax error near "$"` — wherever it appears in the
263    // expression, e.g. "$prev.id" (a common agent query for DSL docs).
264    let sanitized: String = spaced
265        .chars()
266        .filter(|c| {
267            !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
268        })
269        .collect();
270
271    // Pass 3: filter FTS5 operator keywords.
272    sanitized
273        .split_whitespace()
274        .filter(|t| {
275            !matches!(
276                t.to_ascii_uppercase().as_str(),
277                "AND" | "OR" | "NOT" | "NEAR"
278            )
279        })
280        .collect::<Vec<_>>()
281        .join(" ")
282}
283
284/// Legacy (pre-#397) sanitization: hyphen and dot are stripped outright
285/// instead of being space-split, so `khive-pack-memory` normalizes to the
286/// single merged bareword `khivepackmemory` rather than three terms. Slash is
287/// space-split because this fallback only preserves the pre-#397 hyphen/dot
288/// behavior and must not create a `GBs` alias for `GB/s`.
289///
290/// Used only to build the merged-form OR-alternative in
291/// [`sanitize_fts5_token_group`] — never as the sole sanitized query. Content
292/// indexed before #397, or under a tokenizer whose own token-splitting rules
293/// collapse punctuation differently than the current space-split pass,  may
294/// still carry this merged token; kept as a fallback match term so those
295/// documents stay reachable.
296fn sanitize_fts5_query_legacy_merged(query: &str) -> String {
297    let spaced: String = query
298        .chars()
299        .map(|c| {
300            if matches!(c, '(' | ')' | ',' | ':' | '/') {
301                ' '
302            } else {
303                c
304            }
305        })
306        .collect();
307
308    let sanitized: String = spaced
309        .chars()
310        .filter(|c| {
311            !matches!(
312                c,
313                '*' | '"' | '\'' | '+' | '-' | '^' | '.' | '~' | '!' | '$' | '\0'
314            ) && !c.is_control()
315        })
316        .collect();
317
318    sanitized
319        .split_whitespace()
320        .filter(|t| {
321            !matches!(
322                t.to_ascii_uppercase().as_str(),
323                "AND" | "OR" | "NOT" | "NEAR"
324            )
325        })
326        .collect::<Vec<_>>()
327        .join(" ")
328}
329
330/// Strip a raw token down to what is safe inside a double-quoted FTS5 phrase:
331/// the closing delimiter (`"`), the trailing-`*` prefix-query trigger, and
332/// control characters. Everything else — including `-`, `.`, `:`, `$`, `'`,
333/// digits — passes through literally, since FTS5 phrase text is matched
334/// against the column's own tokenization of that literal text (word-exact
335/// under `unicode61`, substring-exact under `trigram`), not re-sanitized.
336///
337/// Returns `None` if nothing survives the filter.
338fn sanitize_fts5_phrase_literal(token: &str) -> Option<String> {
339    let literal: String = token
340        .chars()
341        .filter(|c| !matches!(c, '"' | '*' | '\0') && !c.is_control())
342        .collect();
343    if literal.is_empty() {
344        None
345    } else {
346        Some(literal)
347    }
348}
349
350/// Below this length, FTS5's built-in `trigram` tokenizer (the production
351/// default — `backend.rs::StorageBackend::text()`) produces zero tokens for
352/// a bareword MATCH term. Verified against a live `tokenize='trigram'`
353/// table: a term this short silently drops out of its AND clause instead of
354/// constraining the match, e.g. `2026 07 10` matches any row containing
355/// `2026` regardless of month/day. `sanitize_fts5_token_group` treats any
356/// split segment at or below this length as trigram-unsafe.
357const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
358
359/// Sanitize a single whitespace-isolated raw token into an FTS5
360/// match-expression fragment.
361///
362/// #397 split punctuated identifiers (`khive-pack-memory` -> three terms
363/// `khive pack memory`, ANDed together) so they are searchable as distinct
364/// words. That is correct against content indexed with word-splitting
365/// tokenizers (`unicode61`, and multi-word `trigram` content where each
366/// word is long enough to trigram on its own), but two more cases need
367/// covering when punctuation actually causes a split:
368///
369/// - a query for content that only ever matched the pre-#397 merged
370///   bareword (`khivepackmemory`) would silently stop matching — kept
371///   reachable via the legacy-merged alternative. The merged form is
372///   dropped only when it is a literal duplicate of an alternative already
373///   emitted into the expression (the split AND-group's own space-joined
374///   content, or the quoted literal phrase) — not merely when it equals the
375///   *concatenation* of the split terms, which for an ordinary punctuated
376///   identifier (`khive-pack-memory`) is always true and would suppress the
377///   legacy alternative unconditionally, leaving rows stored under the
378///   pre-#397 merged token (`khivepackmemory`) unreachable under
379///   `unicode61`.
380/// - under the production `trigram` tokenizer, a split segment shorter
381///   than [`FTS5_TRIGRAM_MIN_SAFE_LEN`] (e.g. the `07`/`10` in a
382///   `2026-07-10` date) tokenizes to zero trigrams and silently drops out
383///   of its AND clause, broadening the match to anything sharing the
384///   longer segment (any `2026`, not just that date). Neither the plain
385///   split AND-group nor the merged form avoids this — both were checked
386///   empirically against a live `tokenize='trigram'` table and both either
387///   broaden or simply fail to match. The fix that does work is a literal
388///   phrase-quoted alternative: FTS5 matches it by exact substring under
389///   `trigram` (confirmed: quoting `"2026-07-10"` discriminates the exact
390///   day) and by exact adjacent sub-tokens under word tokenizers. So when
391///   any split segment is trigram-unsafe, the split AND-group is dropped
392///   entirely rather than paired with the merged/phrase alternatives — an
393///   OR would still admit its broadened matches. Retrieval stays correct
394///   (still matches the right content, still discriminates) at the cost of
395///   requiring adjacency for that token, which is the trade-off intended
396///   by the finding this fixes (khive #397 Finding 2): correctness over a
397///   marginally more lenient match.
398///
399/// All emitted readings are additive OR-alternatives otherwise — the result
400/// is never a narrower match than any single (safe) form alone.
401///
402/// Returns `None` if the token sanitizes to nothing.
403fn sanitize_fts5_token_group(token: &str) -> Option<String> {
404    let split = sanitize_fts5_query(token);
405    let split_terms: Vec<&str> = split.split_whitespace().collect();
406    if split_terms.is_empty() {
407        return None;
408    }
409    if split_terms.len() == 1 {
410        return Some(split_terms[0].to_string());
411    }
412
413    let has_trigram_unsafe_segment = split_terms
414        .iter()
415        .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
416
417    let mut alternatives = Vec::new();
418    if !has_trigram_unsafe_segment {
419        alternatives.push(format!("({})", split_terms.join(" ")));
420    }
421
422    // An operator-bearing token (e.g. `NEAR(alpha-beta,5)`) can make the
423    // legacy merge itself collapse to multiple space-separated terms rather
424    // than one bareword: pass 1 of `sanitize_fts5_query_legacy_merged` spaces
425    // out `(`, `)`, and `,` while pass 2 removes `-`/`.` outright, so
426    // `NEAR(alpha-beta,5)` merges to `"alphabeta 5"`, not one word. Pushed
427    // unguarded, that multi-term fragment carries the same trigram-unsafe
428    // `5` the split-group check above exists to exclude, and under FTS5's
429    // implicit-AND adjacency it silently drops, broadening the OR-alternative
430    // to any row containing `alphabeta`. Apply the same trigram-safety gate
431    // here whenever the merge is multi-term.
432    let merged = sanitize_fts5_query_legacy_merged(token);
433    let merged_terms: Vec<&str> = merged.split_whitespace().collect();
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.is_empty() && !duplicates_split && !duplicates_phrase && !merged_has_unsafe_segment {
450        alternatives.push(merged);
451    }
452
453    if let Some(phrase) = phrase {
454        alternatives.push(format!("\"{}\"", phrase));
455    }
456
457    match alternatives.len() {
458        0 => Some(format!("({})", split_terms.join(" "))),
459        1 => alternatives.into_iter().next(),
460        _ => Some(format!("({})", alternatives.join(" OR "))),
461    }
462}
463
464/// Join Plain-mode per-token groups into one MATCH expression.
465///
466/// FTS5's implicit-AND adjacency rule (a bare space between two terms)
467/// applies only between two *plain* terms — verified against a live table:
468/// `GQA KV cache` (all bare) matches, but `GQA AND KV AND cache` does not,
469/// because FTS5's explicit `AND` treats a trigram-unsafe short bareword
470/// (`KV`, 2 chars, zero trigrams) as an unsatisfiable operand, while
471/// implicit adjacency instead lets it drop out harmlessly. So: use a bare
472/// space between two plain (unparenthesized) groups to preserve that
473/// existing leniency, and fall back to explicit `AND` only where at least
474/// one side is a parenthesized OR-group (`sanitize_fts5_token_group`'s
475/// output for punctuated tokens) — adjacency there without the operator is
476/// a MATCH-expression syntax error (`(a OR b) c` fails; `(a OR b) AND c`
477/// does not).
478fn join_plain_groups(groups: &[String]) -> String {
479    let mut expr = String::new();
480    for (i, group) in groups.iter().enumerate() {
481        if i > 0 {
482            let prev_compound = groups[i - 1].starts_with('(');
483            let this_compound = group.starts_with('(');
484            expr.push_str(if prev_compound || this_compound {
485                " AND "
486            } else {
487                " "
488            });
489        }
490        expr.push_str(group);
491    }
492    expr
493}
494
495/// Build the FTS5 MATCH expression for a query string under a given mode.
496///
497/// Centralizes the AnyTerm/Plain/Phrase branching previously duplicated
498/// across `search()`, `search_unranked()`, and `search_rank_within_cap()`.
499///
500/// AnyTerm and Plain both process the query token-by-token (split on
501/// whitespace) through [`sanitize_fts5_token_group`], so a punctuated
502/// identifier anywhere in the query gets its split/merged OR-alternative;
503/// AnyTerm joins the per-token groups with `OR`; Plain joins them via
504/// [`join_plain_groups`] (implicit-AND space where safe, explicit `AND`
505/// where a group is parenthesized). Phrase mode keeps the single-string
506/// literal behavior — a double-quoted FTS5 phrase cannot contain
507/// `OR`/parenthesized groups.
508///
509/// Returns `None` when the sanitized query is empty (caller short-circuits
510/// to an empty result set rather than sending an invalid MATCH expression).
511fn build_match_expr(query: &str, mode: TextQueryMode) -> Option<String> {
512    match mode {
513        TextQueryMode::AnyTerm => {
514            let groups: Vec<String> = query
515                .split_whitespace()
516                .filter_map(sanitize_fts5_token_group)
517                .collect();
518            if groups.is_empty() {
519                None
520            } else {
521                Some(groups.join(" OR "))
522            }
523        }
524        TextQueryMode::Plain => {
525            let groups: Vec<String> = query
526                .split_whitespace()
527                .filter_map(sanitize_fts5_token_group)
528                .collect();
529            if groups.is_empty() {
530                None
531            } else {
532                Some(join_plain_groups(&groups))
533            }
534        }
535        TextQueryMode::Phrase => {
536            sanitize_fts5_phrase_literal(query).map(|literal| format!("\"{}\"", literal))
537        }
538    }
539}
540
541/// Build a WHERE clause fragment and params for a `TextFilter`.
542///
543/// Returns `(clause, params)` where clause is empty if no filters are active.
544/// Parameter indices start at `?{start_idx}`.
545fn build_filter_clause(
546    filter: &TextFilter,
547    table: &str,
548    start_idx: usize,
549) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
550    let mut conditions: Vec<String> = Vec::new();
551    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
552    let mut idx = start_idx;
553
554    if !filter.ids.is_empty() {
555        let placeholders: Vec<String> = filter
556            .ids
557            .iter()
558            .map(|_| {
559                let p = format!("?{}", idx);
560                idx += 1;
561                p
562            })
563            .collect();
564        conditions.push(format!(
565            "{}.subject_id IN ({})",
566            table,
567            placeholders.join(", ")
568        ));
569        for id in &filter.ids {
570            params.push(Box::new(id.to_string()));
571        }
572    }
573
574    if !filter.kinds.is_empty() {
575        let placeholders: Vec<String> = filter
576            .kinds
577            .iter()
578            .map(|_| {
579                let p = format!("?{}", idx);
580                idx += 1;
581                p
582            })
583            .collect();
584        conditions.push(format!("{}.kind IN ({})", table, placeholders.join(", ")));
585        for kind in &filter.kinds {
586            params.push(Box::new(kind.to_string()));
587        }
588    }
589
590    if !filter.namespaces.is_empty() {
591        let placeholders: Vec<String> = filter
592            .namespaces
593            .iter()
594            .map(|_| {
595                let p = format!("?{}", idx);
596                idx += 1;
597                p
598            })
599            .collect();
600        conditions.push(format!(
601            "{}.namespace IN ({})",
602            table,
603            placeholders.join(", ")
604        ));
605        for ns in &filter.namespaces {
606            params.push(Box::new(ns.clone()));
607        }
608    }
609
610    if conditions.is_empty() {
611        (String::new(), params)
612    } else {
613        (format!(" AND {}", conditions.join(" AND ")), params)
614    }
615}
616
617/// DML-only single-document upsert shared by both the legacy (flag-off) and
618/// WriterTask-routed (flag-on) `upsert_document` paths (ADR-067 Component A).
619///
620/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
621/// enclosing transaction.
622fn upsert_document_dml(
623    conn: &rusqlite::Connection,
624    table: &str,
625    document: &TextDocument,
626) -> Result<(), rusqlite::Error> {
627    let namespace = &document.namespace;
628
629    let del_sql = format!(
630        "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
631        table
632    );
633    conn.execute(
634        &del_sql,
635        rusqlite::params![namespace, document.subject_id.to_string()],
636    )?;
637
638    let ins_sql = format!(
639        "INSERT INTO {} \
640         (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
641         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
642        table
643    );
644    let tags_json = tags_to_json(&document.tags);
645    let metadata_json: Option<String> = document.metadata.as_ref().map(|v| v.to_string());
646
647    conn.execute(
648        &ins_sql,
649        rusqlite::params![
650            document.subject_id.to_string(),
651            document.kind.to_string(),
652            document.title.as_deref().unwrap_or(""),
653            document.body,
654            tags_json,
655            namespace,
656            metadata_json,
657            dt_to_micros(&document.updated_at),
658        ],
659    )?;
660    Ok(())
661}
662
663/// DML-only batch upsert loop shared by both the legacy (flag-off) and
664/// WriterTask-routed (flag-on) `upsert_documents` paths (ADR-067 Component A).
665///
666/// Issues no OUTER `BEGIN` / `COMMIT` / `ROLLBACK` — the caller owns the
667/// enclosing transaction. The per-row named `SAVEPOINT fts_upsert_doc` is
668/// preserved unchanged: it is what gives this loop its partial-success
669/// semantics (one bad document does not abort the whole batch) independent
670/// of which outer transaction wraps the loop.
671fn batch_upsert_documents_dml(
672    conn: &rusqlite::Connection,
673    table: &str,
674    documents: &[TextDocument],
675    attempted: u64,
676) -> Result<BatchWriteSummary, rusqlite::Error> {
677    let del_sql = format!(
678        "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
679        table
680    );
681    let ins_sql = format!(
682        "INSERT INTO {} \
683         (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
684         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
685        table
686    );
687
688    let mut affected = 0u64;
689    let mut failed = 0u64;
690    let mut first_error = String::new();
691
692    for doc in documents {
693        conn.execute_batch("SAVEPOINT fts_upsert_doc")?;
694        let id_str = doc.subject_id.to_string();
695        let namespace = &doc.namespace;
696        let result = (|| {
697            conn.execute(&del_sql, rusqlite::params![namespace, &id_str])?;
698
699            let tags_json = tags_to_json(&doc.tags);
700            let metadata_json: Option<String> = doc.metadata.as_ref().map(|v| v.to_string());
701
702            conn.execute(
703                &ins_sql,
704                rusqlite::params![
705                    &id_str,
706                    &doc.kind.to_string(),
707                    doc.title.as_deref().unwrap_or(""),
708                    &doc.body,
709                    &tags_json,
710                    namespace,
711                    &metadata_json,
712                    dt_to_micros(&doc.updated_at),
713                ],
714            )?;
715            Ok::<(), rusqlite::Error>(())
716        })();
717
718        match result {
719            Ok(()) => {
720                conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc")?;
721                affected += 1;
722            }
723            Err(e) => {
724                let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT fts_upsert_doc");
725                let _ = conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc");
726                if first_error.is_empty() {
727                    first_error = e.to_string();
728                }
729                failed += 1;
730            }
731        }
732    }
733
734    Ok(BatchWriteSummary {
735        attempted,
736        affected,
737        failed,
738        first_error,
739    })
740}
741
742#[async_trait]
743impl TextSearch for Fts5TextSearch {
744    async fn upsert_document(&self, document: TextDocument) -> Result<(), StorageError> {
745        let table = self.table_name.clone();
746
747        // ADR-067 Component A: when the write queue is enabled, route
748        // through the pool-wide WriterTask. DML-only closure — no BEGIN
749        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
750        // owns the transaction.
751        if let Some(writer_task) = &self.writer_task {
752            let table2 = table.clone();
753            return writer_task
754                .send(move |conn| {
755                    upsert_document_dml(conn, &table2, &document)
756                        .map_err(|e| map_err(e, "fts_upsert"))
757                })
758                .await;
759        }
760
761        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
762        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK.
763        self.with_writer("fts_upsert", move |conn| {
764            conn.execute_batch("BEGIN IMMEDIATE")?;
765            let _tx_handle =
766                khive_storage::tx_registry::register(Some("text_upsert_document".to_string()));
767
768            if let Err(e) = upsert_document_dml(conn, &table, &document) {
769                let _ = conn.execute_batch("ROLLBACK");
770                return Err(e);
771            }
772
773            conn.execute_batch("COMMIT")?;
774            Ok(())
775        })
776        .await
777    }
778
779    async fn upsert_documents(
780        &self,
781        documents: Vec<TextDocument>,
782    ) -> Result<BatchWriteSummary, StorageError> {
783        let table = self.table_name.clone();
784        let attempted = documents.len() as u64;
785
786        // ADR-067 Component A: when the write queue is enabled, route
787        // through the pool-wide WriterTask. DML-only closure (the per-row
788        // `SAVEPOINT fts_upsert_doc` is preserved unchanged — only the OUTER
789        // BEGIN IMMEDIATE/COMMIT is removed, since the WriterTask's run loop
790        // owns the enclosing transaction).
791        if let Some(writer_task) = &self.writer_task {
792            let table2 = table.clone();
793            return writer_task
794                .send(move |conn| {
795                    batch_upsert_documents_dml(conn, &table2, &documents, attempted)
796                        .map_err(|e| map_err(e, "fts_upsert_batch"))
797                })
798                .await;
799        }
800
801        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
802        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
803        self.with_writer("fts_upsert_batch", move |conn| {
804            conn.execute_batch("BEGIN IMMEDIATE")?;
805            let _tx_handle =
806                khive_storage::tx_registry::register(Some("text_upsert_batch".to_string()));
807
808            let summary = batch_upsert_documents_dml(conn, &table, &documents, attempted)?;
809
810            conn.execute_batch("COMMIT")?;
811
812            Ok(summary)
813        })
814        .await
815    }
816
817    async fn delete_document(
818        &self,
819        namespace: &str,
820        subject_id: Uuid,
821    ) -> Result<bool, StorageError> {
822        let statement = delete_document_statement(&self.table_name, namespace, subject_id);
823
824        self.with_writer("fts_delete", move |conn| {
825            let mut stmt = conn.prepare(&statement.sql)?;
826            bind_params(&mut stmt, &statement.params)?;
827            Ok(stmt.raw_execute()? > 0)
828        })
829        .await
830    }
831
832    async fn get_document(
833        &self,
834        namespace: &str,
835        subject_id: Uuid,
836    ) -> Result<Option<TextDocument>, StorageError> {
837        let namespace = namespace.to_string();
838        let table = self.table_name.clone();
839
840        self.with_reader("fts_get", move |conn| {
841            let sql = format!(
842                "SELECT subject_id, kind, title, body, tags, namespace, metadata, updated_at \
843                 FROM {} WHERE namespace = ?1 AND subject_id = ?2",
844                table
845            );
846            let mut stmt = conn.prepare(&sql)?;
847            let mut rows = stmt.query(rusqlite::params![namespace, subject_id.to_string()])?;
848
849            match rows.next()? {
850                Some(row) => {
851                    let id_str: String = row.get(0)?;
852                    let kind_str: String = row.get(1)?;
853                    let title: String = row.get(2)?;
854                    let body: String = row.get(3)?;
855                    let tags_json: String = row.get(4)?;
856                    let ns: String = row.get(5)?;
857                    let metadata_json: Option<String> = row.get(6)?;
858                    let updated_at_micros: i64 = row.get(7)?;
859
860                    let sid = Uuid::parse_str(&id_str).map_err(|e| {
861                        rusqlite::Error::FromSqlConversionFailure(
862                            0,
863                            rusqlite::types::Type::Text,
864                            Box::new(e),
865                        )
866                    })?;
867
868                    let kind = kind_str.parse::<SubstrateKind>().map_err(|e| {
869                        rusqlite::Error::FromSqlConversionFailure(
870                            1,
871                            rusqlite::types::Type::Text,
872                            Box::new(e),
873                        )
874                    })?;
875
876                    Ok(Some(TextDocument {
877                        subject_id: sid,
878                        kind,
879                        title: if title.is_empty() { None } else { Some(title) },
880                        body,
881                        tags: tags_from_json(&tags_json),
882                        namespace: ns,
883                        metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
884                        updated_at: micros_to_dt(updated_at_micros),
885                    }))
886                }
887                None => Ok(None),
888            }
889        })
890        .await
891    }
892
893    async fn search(&self, request: TextSearchRequest) -> Result<Vec<TextSearchHit>, StorageError> {
894        let table = self.table_name.clone();
895
896        self.with_reader("fts_search", move |conn| {
897            let match_expr = match build_match_expr(&request.query, request.mode) {
898                Some(expr) => expr,
899                None => return Ok(Vec::new()),
900            };
901
902            // Snippet column index 3 = body in the FTS5 schema.
903            // snippet_chars == 0 is the sentinel for "no snippet" — skip the
904            // snippet(...) call entirely and return NULL instead.  This avoids
905            // the ~12ms BM25 snippet computation on the hot recall path where
906            // snippets are unused.  Callers that need snippets (diagnostics) pass
907            // snippet_chars > 0 and get the same behaviour as before.
908            let snippet_expr = if request.snippet_chars == 0 {
909                "NULL AS snippet".to_string()
910            } else {
911                let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
912                format!("snippet({table}, 3, '', '', '...', {chars})")
913            };
914
915            let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
916                build_filter_clause(filter, &table, 3)
917            } else {
918                (String::new(), Vec::new())
919            };
920
921            let sql = format!(
922                "SELECT subject_id, rank, title, {snippet_expr} \
923                 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
924                 ORDER BY rank LIMIT ?2",
925            );
926
927            let mut stmt = conn.prepare(&sql)?;
928            stmt.raw_bind_parameter(1, &match_expr)?;
929            stmt.raw_bind_parameter(2, request.top_k as i64)?;
930
931            for (i, param) in filter_params.iter().enumerate() {
932                param
933                    .to_sql()
934                    .map(|val| stmt.raw_bind_parameter(3 + i, val))
935                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
936            }
937
938            let mut hits = Vec::new();
939            let mut rows = stmt.raw_query();
940            let mut rank_idx = 0u32;
941
942            while let Some(row) = rows.next()? {
943                let id_str: String = row.get(0)?;
944                let fts_rank: f64 = row.get(1)?;
945                let title: String = row.get(2)?;
946                let snippet: Option<String> = row.get(3)?;
947
948                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
949                    rusqlite::Error::FromSqlConversionFailure(
950                        0,
951                        rusqlite::types::Type::Text,
952                        Box::new(e),
953                    )
954                })?;
955
956                rank_idx += 1;
957                hits.push((subject_id, fts_rank, rank_idx, title, snippet));
958            }
959
960            // Normalize scores within the result set to (0.05, 1.0].
961            // Best rank (most negative) maps to 1.0, worst to 0.05.
962            let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
963            let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
964            let range = max_rank - min_rank;
965
966            let results = hits
967                .into_iter()
968                .map(|(subject_id, raw_rank, rank, title, snippet)| {
969                    let score = if range.abs() < 1e-12 {
970                        1.0
971                    } else {
972                        let t = (max_rank - raw_rank) / range;
973                        0.05 + 0.95 * t
974                    };
975                    TextSearchHit {
976                        subject_id,
977                        score: DeterministicScore::from_f64(score),
978                        rank,
979                        title: if title.is_empty() { None } else { Some(title) },
980                        snippet: snippet.filter(|s| !s.is_empty()),
981                    }
982                })
983                .collect();
984
985            Ok(results)
986        })
987        .await
988    }
989
990    async fn count(&self, filter: TextFilter) -> Result<u64, StorageError> {
991        let table = self.table_name.clone();
992
993        self.with_reader("fts_count", move |conn| {
994            let (filter_clause, filter_params) = build_filter_clause(&filter, &table, 1);
995
996            let sql = if filter_clause.is_empty() {
997                format!("SELECT COUNT(*) FROM {}", table)
998            } else {
999                let where_part = filter_clause.trim_start_matches(" AND ");
1000                format!("SELECT COUNT(*) FROM {} WHERE {}", table, where_part)
1001            };
1002
1003            let mut stmt = conn.prepare(&sql)?;
1004
1005            for (i, param) in filter_params.iter().enumerate() {
1006                param
1007                    .to_sql()
1008                    .map(|val| stmt.raw_bind_parameter(1 + i, val))
1009                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1010            }
1011
1012            let mut rows = stmt.raw_query();
1013            match rows.next()? {
1014                Some(row) => {
1015                    let count: i64 = row.get(0)?;
1016                    Ok(count as u64)
1017                }
1018                None => Ok(0),
1019            }
1020        })
1021        .await
1022    }
1023
1024    async fn stats(&self) -> Result<TextIndexStats, StorageError> {
1025        let table = self.table_name.clone();
1026
1027        self.with_reader("fts_stats", move |conn| {
1028            let sql = format!("SELECT COUNT(*) FROM {}", table);
1029            let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
1030
1031            Ok(TextIndexStats {
1032                document_count: count as u64,
1033                needs_rebuild: false,
1034                last_rebuild_at: None,
1035            })
1036        })
1037        .await
1038    }
1039
1040    async fn search_with_options(
1041        &self,
1042        request: TextSearchRequest,
1043        options: TextSearchOptions,
1044    ) -> Result<Vec<TextSearchHit>, StorageError> {
1045        match options.gather_mode {
1046            TextGatherMode::Ranked => self.search(request).await,
1047            TextGatherMode::Unranked => self.search_unranked(request).await,
1048            TextGatherMode::RankWithinCap => {
1049                let gather_limit = options
1050                    .gather_limit
1051                    .unwrap_or(request.top_k)
1052                    .max(request.top_k);
1053                self.search_rank_within_cap(request, gather_limit).await
1054            }
1055        }
1056    }
1057
1058    async fn term_stats(
1059        &self,
1060        request: TextTermStatsRequest,
1061    ) -> Result<Vec<TextTermStats>, StorageError> {
1062        let table = self.table_name.clone();
1063
1064        self.with_reader("fts_term_stats", move |conn| {
1065            let filter = request.filter.as_ref();
1066
1067            // Document count uses params starting at ?1 (no MATCH expression).
1068            let (count_filter_clause, count_filter_params) = if let Some(f) = filter {
1069                build_filter_clause(f, &table, 1)
1070            } else {
1071                (String::new(), Vec::new())
1072            };
1073
1074            let document_count: u64 = {
1075                let count_sql = if count_filter_clause.is_empty() {
1076                    format!("SELECT COUNT(*) FROM {table}")
1077                } else {
1078                    let where_part = count_filter_clause.trim_start_matches(" AND ");
1079                    format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1080                };
1081                let mut stmt = conn.prepare(&count_sql)?;
1082                for (i, param) in count_filter_params.iter().enumerate() {
1083                    param
1084                        .to_sql()
1085                        .map(|val| stmt.raw_bind_parameter(1 + i, val))
1086                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1087                }
1088                let mut rows = stmt.raw_query();
1089                match rows.next()? {
1090                    Some(row) => {
1091                        let c: i64 = row.get(0)?;
1092                        c as u64
1093                    }
1094                    None => 0,
1095                }
1096            };
1097
1098            let mut results = Vec::with_capacity(request.terms.len());
1099            for term in &request.terms {
1100                // Keep document frequency aligned with the per-token search expression.
1101                let sanitized = sanitize_fts5_token_group(term).unwrap_or_default();
1102                if sanitized.is_empty() {
1103                    results.push(TextTermStats {
1104                        term: term.clone(),
1105                        sanitized_term: sanitized,
1106                        document_frequency: 0,
1107                        document_count,
1108                        inverse_document_frequency: 0.0,
1109                    });
1110                    continue;
1111                }
1112
1113                // Per-term count: MATCH is ?1, so filter params start at ?2.
1114                let (term_filter_clause, term_filter_params) = if let Some(f) = filter {
1115                    build_filter_clause(f, &table, 2)
1116                } else {
1117                    (String::new(), Vec::new())
1118                };
1119
1120                let count_sql = format!(
1121                    "SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{term_filter_clause}"
1122                );
1123                let mut stmt = conn.prepare(&count_sql)?;
1124                stmt.raw_bind_parameter(1, &sanitized)?;
1125                for (i, param) in term_filter_params.iter().enumerate() {
1126                    param
1127                        .to_sql()
1128                        .map(|val| stmt.raw_bind_parameter(2 + i, val))
1129                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1130                }
1131
1132                let df: u64 = {
1133                    let mut rows = stmt.raw_query();
1134                    match rows.next()? {
1135                        Some(row) => {
1136                            let c: i64 = row.get(0)?;
1137                            c as u64
1138                        }
1139                        None => 0,
1140                    }
1141                };
1142
1143                let idf = Fts5TextSearch::bm25_idf(df, document_count);
1144                results.push(TextTermStats {
1145                    term: term.clone(),
1146                    sanitized_term: sanitized,
1147                    document_frequency: df,
1148                    document_count,
1149                    inverse_document_frequency: idf,
1150                });
1151            }
1152
1153            Ok(results)
1154        })
1155        .await
1156    }
1157
1158    async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<TextIndexStats, StorageError> {
1159        let table = self.table_name.clone();
1160
1161        self.with_writer("fts_rebuild", move |conn| {
1162            // FTS5 rebuild command: repopulates the internal index structures.
1163            let sql = format!("INSERT INTO {}({}) VALUES('rebuild')", table, table);
1164            conn.execute(&sql, [])?;
1165
1166            let count_sql = format!("SELECT COUNT(*) FROM {}", table);
1167            let count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
1168
1169            Ok(TextIndexStats {
1170                document_count: count as u64,
1171                needs_rebuild: false,
1172                last_rebuild_at: Some(Utc::now()),
1173            })
1174        })
1175        .await
1176    }
1177}
1178
1179impl Fts5TextSearch {
1180    /// Robertson-Walker BM25 IDF: ln(((N - df + 0.5) / (df + 0.5)) + 1)
1181    fn bm25_idf(df: u64, document_count: u64) -> f64 {
1182        let n = document_count as f64;
1183        let f = df as f64;
1184        ((n - f + 0.5) / (f + 0.5) + 1.0).ln()
1185    }
1186
1187    /// Gather candidates without BM25 ranking; return with uniform score 1.0.
1188    async fn search_unranked(
1189        &self,
1190        request: TextSearchRequest,
1191    ) -> Result<Vec<TextSearchHit>, StorageError> {
1192        let table = self.table_name.clone();
1193
1194        self.with_reader("fts_search_unranked", move |conn| {
1195            let match_expr = match build_match_expr(&request.query, request.mode) {
1196                Some(expr) => expr,
1197                None => return Ok(Vec::new()),
1198            };
1199
1200            let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1201                build_filter_clause(filter, &table, 3)
1202            } else {
1203                (String::new(), Vec::new())
1204            };
1205
1206            // No rank column, no ORDER BY — avoids BM25 computation entirely.
1207            let sql = format!(
1208                "SELECT subject_id, title \
1209                 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
1210                 LIMIT ?2",
1211            );
1212
1213            let mut stmt = conn.prepare(&sql)?;
1214            stmt.raw_bind_parameter(1, &match_expr)?;
1215            stmt.raw_bind_parameter(2, request.top_k as i64)?;
1216
1217            for (i, param) in filter_params.iter().enumerate() {
1218                param
1219                    .to_sql()
1220                    .map(|val| stmt.raw_bind_parameter(3 + i, val))
1221                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1222            }
1223
1224            let mut results = Vec::new();
1225            let mut rows = stmt.raw_query();
1226            let mut rank_idx = 0u32;
1227
1228            while let Some(row) = rows.next()? {
1229                let id_str: String = row.get(0)?;
1230                let title: String = row.get(1)?;
1231
1232                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1233                    rusqlite::Error::FromSqlConversionFailure(
1234                        0,
1235                        rusqlite::types::Type::Text,
1236                        Box::new(e),
1237                    )
1238                })?;
1239
1240                rank_idx += 1;
1241                results.push(TextSearchHit {
1242                    subject_id,
1243                    score: DeterministicScore::from_f64(1.0),
1244                    rank: rank_idx,
1245                    title: if title.is_empty() { None } else { Some(title) },
1246                    snippet: None,
1247                });
1248            }
1249
1250            Ok(results)
1251        })
1252        .await
1253    }
1254
1255    /// Two-stage gather: cheap unranked LIMIT gather_limit, then BM25-rank the subset.
1256    async fn search_rank_within_cap(
1257        &self,
1258        request: TextSearchRequest,
1259        gather_limit: u32,
1260    ) -> Result<Vec<TextSearchHit>, StorageError> {
1261        let table = self.table_name.clone();
1262
1263        self.with_reader("fts_search_rank_within_cap", move |conn| {
1264            let match_expr = match build_match_expr(&request.query, request.mode) {
1265                Some(expr) => expr,
1266                None => return Ok(Vec::new()),
1267            };
1268
1269            let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1270                build_filter_clause(filter, &table, 3)
1271            } else {
1272                (String::new(), Vec::new())
1273            };
1274
1275            // Stage 1: cheap unranked gather of rowids.
1276            let gather_sql = format!(
1277                "SELECT subject_id FROM {table} WHERE {table} MATCH ?1{filter_clause} LIMIT ?2"
1278            );
1279
1280            let mut stmt = conn.prepare(&gather_sql)?;
1281            stmt.raw_bind_parameter(1, &match_expr)?;
1282            stmt.raw_bind_parameter(2, gather_limit as i64)?;
1283            for (i, param) in filter_params.iter().enumerate() {
1284                param
1285                    .to_sql()
1286                    .map(|val| stmt.raw_bind_parameter(3 + i, val))
1287                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1288            }
1289
1290            let mut gathered_ids: Vec<String> = Vec::new();
1291            let mut rows = stmt.raw_query();
1292            while let Some(row) = rows.next()? {
1293                gathered_ids.push(row.get::<_, String>(0)?);
1294            }
1295
1296            if gathered_ids.is_empty() {
1297                return Ok(Vec::new());
1298            }
1299
1300            // Stage 2: BM25-rank only the gathered subset via subject_id IN (...).
1301            let snippet_expr = if request.snippet_chars == 0 {
1302                "NULL AS snippet".to_string()
1303            } else {
1304                let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1305                format!("snippet({table}, 3, '', '', '...', {chars})")
1306            };
1307
1308            // Build IN clause for the gathered IDs.
1309            let id_placeholders: Vec<String> = gathered_ids
1310                .iter()
1311                .enumerate()
1312                .map(|(i, _)| format!("?{}", 3 + i))
1313                .collect();
1314            let in_clause = id_placeholders.join(", ");
1315
1316            let rank_sql = format!(
1317                "SELECT subject_id, rank, title, {snippet_expr} \
1318                 FROM {table} WHERE {table} MATCH ?1 AND subject_id IN ({in_clause}) \
1319                 ORDER BY rank LIMIT ?2"
1320            );
1321
1322            let mut stmt2 = conn.prepare(&rank_sql)?;
1323            stmt2.raw_bind_parameter(1, &match_expr)?;
1324            stmt2.raw_bind_parameter(2, request.top_k as i64)?;
1325            for (i, id_str) in gathered_ids.iter().enumerate() {
1326                stmt2.raw_bind_parameter(3 + i, id_str.as_str())?;
1327            }
1328
1329            let mut hits = Vec::new();
1330            let mut rows2 = stmt2.raw_query();
1331            let mut rank_idx = 0u32;
1332
1333            while let Some(row) = rows2.next()? {
1334                let id_str: String = row.get(0)?;
1335                let fts_rank: f64 = row.get(1)?;
1336                let title: String = row.get(2)?;
1337                let snippet: Option<String> = row.get(3)?;
1338
1339                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1340                    rusqlite::Error::FromSqlConversionFailure(
1341                        0,
1342                        rusqlite::types::Type::Text,
1343                        Box::new(e),
1344                    )
1345                })?;
1346
1347                rank_idx += 1;
1348                hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1349            }
1350
1351            // Normalize scores within the ranked subset (same formula as search()).
1352            let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1353            let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1354            let range = max_rank - min_rank;
1355
1356            let results = hits
1357                .into_iter()
1358                .map(|(subject_id, raw_rank, rank, title, snippet)| {
1359                    let score = if range.abs() < 1e-12 {
1360                        1.0
1361                    } else {
1362                        let t = (max_rank - raw_rank) / range;
1363                        0.05 + 0.95 * t
1364                    };
1365                    TextSearchHit {
1366                        subject_id,
1367                        score: DeterministicScore::from_f64(score),
1368                        rank,
1369                        title: if title.is_empty() { None } else { Some(title) },
1370                        snippet: snippet.filter(|s| !s.is_empty()),
1371                    }
1372                })
1373                .collect();
1374
1375            Ok(results)
1376        })
1377        .await
1378    }
1379
1380    /// Move all FTS5 documents from `old_namespace` to `new_namespace` in a
1381    /// single transaction.
1382    ///
1383    /// FTS5 virtual tables do not support updating indexed columns (`title`,
1384    /// `body`) via UPDATE. The correct approach is read-then-delete-then-reinsert.
1385    ///
1386    /// Callers must invoke this after any SQL-level namespace change on the
1387    /// backing entity table so that FTS5 keyword search stays consistent with
1388    /// the entity store.
1389    // REASON: reserved for namespace migration operations
1390    #[allow(dead_code)]
1391    pub(crate) async fn rename_namespace(
1392        &self,
1393        old_namespace: &str,
1394        new_namespace: &str,
1395    ) -> Result<u64, StorageError> {
1396        if old_namespace == new_namespace {
1397            return Ok(0);
1398        }
1399        let table = self.table_name.clone();
1400        let old_ns = old_namespace.to_string();
1401        let new_ns = new_namespace.to_string();
1402
1403        self.with_writer_unmanaged("fts_rename_namespace", move |conn| {
1404            let sel_sql = format!(
1405                "SELECT subject_id, kind, title, body, tags, metadata, updated_at \
1406                 FROM {} WHERE namespace = ?1",
1407                table
1408            );
1409            struct Row {
1410                subject_id: String,
1411                kind: String,
1412                title: String,
1413                body: String,
1414                tags: String,
1415                metadata: Option<String>,
1416                updated_at: i64,
1417            }
1418            let rows: Vec<Row> = {
1419                let mut stmt = conn.prepare(&sel_sql)?;
1420                let iter = stmt.query_map(rusqlite::params![&old_ns], |row| {
1421                    Ok(Row {
1422                        subject_id: row.get(0)?,
1423                        kind: row.get(1)?,
1424                        title: row.get(2)?,
1425                        body: row.get(3)?,
1426                        tags: row.get(4)?,
1427                        metadata: row.get(5)?,
1428                        updated_at: row.get(6)?,
1429                    })
1430                })?;
1431                iter.collect::<Result<Vec<_>, _>>()?
1432            };
1433            let moved = rows.len() as u64;
1434            if moved == 0 {
1435                return Ok(0u64);
1436            }
1437
1438            conn.execute_batch("BEGIN IMMEDIATE")?;
1439            let _tx_handle =
1440                khive_storage::tx_registry::register(Some("text_rename_namespace".to_string()));
1441
1442            let del_sql = format!("DELETE FROM {} WHERE namespace = ?1", table);
1443            if let Err(e) = conn.execute(&del_sql, rusqlite::params![&old_ns]) {
1444                let _ = conn.execute_batch("ROLLBACK");
1445                return Err(e);
1446            }
1447
1448            let ins_sql = format!(
1449                "INSERT INTO {} \
1450                 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1451                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1452                table
1453            );
1454            for row in &rows {
1455                if let Err(e) = conn.execute(
1456                    &ins_sql,
1457                    rusqlite::params![
1458                        row.subject_id,
1459                        row.kind,
1460                        row.title,
1461                        row.body,
1462                        row.tags,
1463                        &new_ns,
1464                        row.metadata,
1465                        row.updated_at,
1466                    ],
1467                ) {
1468                    let _ = conn.execute_batch("ROLLBACK");
1469                    return Err(e);
1470                }
1471            }
1472
1473            conn.execute_batch("COMMIT")?;
1474            Ok(moved)
1475        })
1476        .await
1477    }
1478}
1479
1480#[cfg(test)]
1481#[path = "text_tests.rs"]
1482mod tests;