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::usage::{UsageContext, UsageUnit};
17use khive_storage::StorageCapability;
18use khive_storage::TextSearch;
19use khive_types::SubstrateKind;
20
21use crate::error::SqliteError;
22use crate::pool::ConnectionPool;
23use crate::sql_bridge::bind_params;
24use crate::writer_task::WriterTaskHandle;
25
26/// Name of the sidecar rowid map for a given FTS table: `{namespace,
27/// subject_id} -> rowid`, `WITHOUT ROWID`, `PRIMARY KEY (namespace,
28/// subject_id)`.
29///
30/// `namespace` and `subject_id` are declared `UNINDEXED` in every FTS5 DDL
31/// (trigram-tokenized production tables cannot index them without polluting
32/// text queries with UUID fragments), so a point lookup on either column is a
33/// full virtual-table scan of `table` — this sidecar turns it into a
34/// primary-key lookup instead. `table` must already be a trusted, sanitized
35/// table name.
36pub fn rowid_map_table(table: &str) -> String {
37    format!("{table}_rowids")
38}
39
40/// Name of [`rowid_map_table`]'s own state sidecar: a `key -> value` table
41/// recording whether the map's one-time legacy backfill has completed, so
42/// completion is a durable, transactionally-written fact instead of being
43/// inferred from the map's row count.
44pub fn rowid_map_state_table(table: &str) -> String {
45    format!("{}_state", rowid_map_table(table))
46}
47
48/// Value of the `backfill` key in [`rowid_map_state_table`] once
49/// `ensure_fts_rowid_map_backfilled` has completed a full reconciliation
50/// pass over `table`.
51pub const ROWID_MAP_BACKFILL_COMPLETE: &str = "complete";
52
53/// DDL for [`rowid_map_table`]'s sidecar and its [`rowid_map_state_table`].
54/// `IF NOT EXISTS` so every creation site (backend ensure-path, migrations,
55/// test helpers) can call it unconditionally. `table` must already be a
56/// trusted, sanitized table name.
57pub fn rowid_map_ddl(table: &str) -> String {
58    let map = rowid_map_table(table);
59    let state = rowid_map_state_table(table);
60    format!(
61        "CREATE TABLE IF NOT EXISTS {map} (\
62         namespace TEXT NOT NULL, \
63         subject_id TEXT NOT NULL, \
64         rowid INTEGER NOT NULL, \
65         PRIMARY KEY (namespace, subject_id)\
66         ) WITHOUT ROWID; \
67         CREATE TABLE IF NOT EXISTS {state} (\
68         key TEXT PRIMARY KEY, \
69         value TEXT NOT NULL\
70         ) WITHOUT ROWID"
71    )
72}
73
74/// The exact `DELETE` this store's `delete_document` issues, for a given
75/// FTS table (ADR-099 B3 r6 structural cut — see `entity.rs`'s sibling
76/// block). Looks the target row's rowid up in [`rowid_map_table`] first (a
77/// primary-key lookup) instead of scanning `table` for `namespace`/
78/// `subject_id`, which are `UNINDEXED` FTS5 columns. `table` must already be
79/// a trusted, sanitized table name (this mirrors `delete_document`'s own
80/// pre-existing lack of a placeholder for table names — `format!` is
81/// required since table identifiers cannot be bound as SQL parameters).
82///
83/// The trailing `AND namespace = ?1 AND subject_id = ?2` re-checks the key
84/// on the (already rowid-narrowed) candidate row itself: if the map ever
85/// held a stale entry — e.g. a crash between an earlier delete's FTS-row
86/// removal and its own map-row removal, before FTS5 reused that rowid for a
87/// different document — this statement would otherwise delete whatever
88/// unrelated document now lives at that rowid instead of affecting zero
89/// rows. Cheap: the `rowid IN (...)` subquery already narrows to at most one
90/// candidate row via the map's primary key, so this is a single-row
91/// post-filter, not a second table scan.
92///
93/// Callers that also need the sidecar's own row removed (a real delete, not
94/// half of an upsert) must additionally run [`delete_document_map_statement`]
95/// — see [`delete_document_statements`] for the paired, order-safe form.
96pub fn delete_document_statement(table: &str, namespace: &str, subject_id: Uuid) -> SqlStatement {
97    let map = rowid_map_table(table);
98    SqlStatement {
99        sql: format!(
100            "DELETE FROM {table} WHERE rowid IN \
101             (SELECT rowid FROM {map} WHERE namespace = ?1 AND subject_id = ?2) \
102             AND namespace = ?1 AND subject_id = ?2"
103        ),
104        params: vec![
105            SqlValue::Text(namespace.to_string()),
106            SqlValue::Text(subject_id.to_string()),
107        ],
108        label: Some(format!("fts-delete-{table}")),
109    }
110}
111
112/// Pre-map fallback: the literal delete this store issued before the rowid
113/// map existed, scanning `table`'s `UNINDEXED` `namespace`/`subject_id`
114/// columns directly. Used only by [`Fts5TextSearch`] instances constructed
115/// in scan-fallback mode (a read-only snapshot whose FTS table predates the
116/// sidecar map — see `StorageBackend::text_with_tokenizer`'s read-only
117/// branch), where the map table does not exist to route through.
118fn delete_document_statement_scan_fallback(
119    table: &str,
120    namespace: &str,
121    subject_id: Uuid,
122) -> SqlStatement {
123    SqlStatement {
124        sql: format!("DELETE FROM {table} WHERE namespace = ?1 AND subject_id = ?2"),
125        params: vec![
126            SqlValue::Text(namespace.to_string()),
127            SqlValue::Text(subject_id.to_string()),
128        ],
129        label: Some(format!("fts-delete-scan-fallback-{table}")),
130    }
131}
132
133/// Remove `(namespace, subject_id)`'s row from `table`'s [`rowid_map_table`].
134///
135/// Order matters relative to [`delete_document_statement`]: that statement's
136/// subquery still needs this row, so run it first, then this one, in the same
137/// transaction. Use [`delete_document_statements`] to get both in the correct
138/// order.
139pub fn delete_document_map_statement(
140    table: &str,
141    namespace: &str,
142    subject_id: Uuid,
143) -> SqlStatement {
144    let map = rowid_map_table(table);
145    SqlStatement {
146        sql: format!("DELETE FROM {map} WHERE namespace = ?1 AND subject_id = ?2"),
147        params: vec![
148            SqlValue::Text(namespace.to_string()),
149            SqlValue::Text(subject_id.to_string()),
150        ],
151        label: Some(format!("fts-delete-map-{table}")),
152    }
153}
154
155/// The full, order-safe deletion of one FTS document: index 0 (the FTS row,
156/// looked up through the map) MUST execute before index 1 (the map row
157/// itself) in the same transaction/connection — index 0's subquery reads the
158/// row index 1 removes. Callers that only need the FTS row gone as half of a
159/// delete-then-insert upsert (the map row gets overwritten by the following
160/// insert's `INSERT OR REPLACE`, so removing it first is redundant) can use
161/// [`delete_document_statement`] alone.
162pub fn delete_document_statements(
163    table: &str,
164    namespace: &str,
165    subject_id: Uuid,
166) -> [SqlStatement; 2] {
167    [
168        delete_document_statement(table, namespace, subject_id),
169        delete_document_map_statement(table, namespace, subject_id),
170    ]
171}
172
173/// Build the `INSERT` half of the FTS delete-then-insert upsert.
174///
175/// `table` must be a trusted, sanitized table name because SQL identifiers
176/// cannot be bound as parameters.
177///
178/// This statement alone leaves [`rowid_map_table`] out of date — pair it with
179/// [`insert_document_map_statement`] (or use [`insert_document_statements`]
180/// for the order-safe combination) so the sidecar tracks the new rowid.
181pub fn insert_document_statement(table: &str, document: &TextDocument) -> SqlStatement {
182    let tags_json = tags_to_json(&document.tags);
183    let metadata_json = document.metadata.as_ref().map(|v| v.to_string());
184    SqlStatement {
185        sql: format!(
186            "INSERT INTO {table} \
187             (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
188             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
189        ),
190        params: vec![
191            SqlValue::Text(document.subject_id.to_string()),
192            SqlValue::Text(document.kind.to_string()),
193            SqlValue::Text(document.title.clone().unwrap_or_default()),
194            SqlValue::Text(document.body.clone()),
195            SqlValue::Text(tags_json),
196            SqlValue::Text(document.namespace.clone()),
197            match metadata_json {
198                Some(m) => SqlValue::Text(m),
199                None => SqlValue::Null,
200            },
201            SqlValue::Integer(dt_to_micros(&document.updated_at)),
202            match &document.record_kind {
203                Some(kind) => SqlValue::Text(kind.clone()),
204                None => SqlValue::Null,
205            },
206        ],
207        label: Some(format!("fts-insert-{table}")),
208    }
209}
210
211/// Upsert `(namespace, subject_id)`'s [`rowid_map_table`] row to point at the
212/// rowid the connection's most recent successful `INSERT` produced.
213///
214/// Must run immediately after the FTS `INSERT` it tracks, on the same
215/// connection, with nothing else written in between: `last_insert_rowid()` is
216/// connection-scoped and reports whichever `INSERT` ran last. `INSERT OR
217/// REPLACE` means this is safe to call whether or not a map row for this key
218/// already exists (the ordinary upsert case does: the old row is overwritten
219/// with the new rowid rather than requiring a prior delete).
220pub fn insert_document_map_statement(
221    table: &str,
222    namespace: &str,
223    subject_id: Uuid,
224) -> SqlStatement {
225    let map = rowid_map_table(table);
226    SqlStatement {
227        sql: format!(
228            "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
229             VALUES (?1, ?2, last_insert_rowid())"
230        ),
231        params: vec![
232            SqlValue::Text(namespace.to_string()),
233            SqlValue::Text(subject_id.to_string()),
234        ],
235        label: Some(format!("fts-insert-map-{table}")),
236    }
237}
238
239/// The full, order-safe insertion of one FTS document: index 0 (the FTS
240/// `INSERT`) MUST execute immediately before index 1 (the rowid-map upsert)
241/// on the same connection, with no other write between them — see
242/// [`insert_document_map_statement`]'s adjacency contract. Every insert path
243/// (create, and the insert half of an upsert) must go through this pair, or
244/// the sidecar map silently falls out of sync with the FTS table it indexes.
245pub fn insert_document_statements(table: &str, document: &TextDocument) -> [SqlStatement; 2] {
246    [
247        insert_document_statement(table, document),
248        insert_document_map_statement(table, &document.namespace, document.subject_id),
249    ]
250}
251
252/// Ensure the FTS5 virtual table for `table_key` (and its [`rowid_map_table`]
253/// sidecar) exist.
254///
255/// Used in tests to set up an in-memory FTS5 table without the full `StorageBackend`.
256#[cfg(test)]
257pub(crate) fn ensure_fts5_schema(
258    conn: &rusqlite::Connection,
259    table_key: &str,
260) -> Result<(), rusqlite::Error> {
261    let table_name = format!("fts_{}", table_key);
262    let ddl = format!(
263        "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(\
264         subject_id UNINDEXED, \
265         kind UNINDEXED, \
266         title, \
267         body, \
268         tags UNINDEXED, \
269         namespace UNINDEXED, \
270         metadata UNINDEXED, \
271         updated_at UNINDEXED, \
272         record_kind\
273         )",
274        table_name
275    );
276    conn.execute_batch(&ddl)?;
277    conn.execute_batch(&rowid_map_ddl(&table_name))
278}
279
280fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
281    StorageError::driver(StorageCapability::Text, op, e)
282}
283
284fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
285    e.into_storage_error(StorageCapability::Text, op)
286}
287
288fn count_fts_pass(context: Option<&UsageContext>) {
289    if let Some(context) = context {
290        context.add(UsageUnit::FtsPasses, 1);
291    }
292}
293
294/// A TextSearch backed by SQLite FTS5 virtual tables.
295///
296/// Each instance manages one table: `fts_{table_key}`. Documents are stored
297/// with their metadata in UNINDEXED columns. `title`, `body`, and the optional
298/// granular `record_kind` classifier are indexed; corpus filters use the
299/// classifier inside MATCH and retain exact row predicates.
300pub struct Fts5TextSearch {
301    pool: Arc<ConnectionPool>,
302    is_file_backed: bool,
303    table_name: String,
304    writer_task: Option<WriterTaskHandle>,
305    /// Set only for a read-only snapshot whose FTS table predates the
306    /// [`rowid_map_table`] sidecar (see `StorageBackend::text_with_tokenizer`'s
307    /// read-only branch, which cannot create/backfill the map on a read-only
308    /// connection). `get_document`/`delete_document` fall back to the
309    /// pre-map `namespace`/`subject_id` scan predicates in this mode instead
310    /// of joining/subquerying a map table that does not exist.
311    scan_fallback: bool,
312}
313
314impl Fts5TextSearch {
315    /// Create a new FTS5 text search instance.
316    ///
317    /// The FTS5 virtual table (and its [`rowid_map_table`] sidecar) must
318    /// already exist (created by `StorageBackend::text()`).
319    pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
320        Self::new_with_mode(pool, is_file_backed, table_key, false)
321    }
322
323    /// Create an instance in scan-fallback mode: the sidecar map is assumed
324    /// absent, so every point lookup/delete falls back to the pre-map
325    /// `namespace`/`subject_id` scan predicates. Reserved for a read-only
326    /// snapshot whose FTS table predates the map (see the [`scan_fallback`]
327    /// field doc and `StorageBackend::text_with_tokenizer`'s read-only
328    /// branch).
329    ///
330    /// [`scan_fallback`]: Self::scan_fallback
331    pub(crate) fn new_scan_fallback(
332        pool: Arc<ConnectionPool>,
333        is_file_backed: bool,
334        table_key: String,
335    ) -> Self {
336        Self::new_with_mode(pool, is_file_backed, table_key, true)
337    }
338
339    fn new_with_mode(
340        pool: Arc<ConnectionPool>,
341        is_file_backed: bool,
342        table_key: String,
343        scan_fallback: bool,
344    ) -> Self {
345        let table_name = format!("fts_{}", table_key);
346        // Enabled by default for file-backed pools; explicit off/degraded
347        // construction remains synchronous (ADR-067 Component A, mirrors
348        // entity.rs policy): a missing writer task is cached without failing
349        // construction. Every write re-resolves it and applies
350        // strict/compatibility policy then.
351        let writer_task = pool.writer_task_handle().ok().flatten();
352        Self {
353            pool,
354            is_file_backed,
355            table_name,
356            writer_task,
357            scan_fallback,
358        }
359    }
360
361    /// Re-derive writer-task availability at write time instead of trusting
362    /// only the field cached at construction (ADR-136 D1 gate 3 amendment).
363    /// `self.writer_task` permanently caches `None` when this store was
364    /// constructed outside a Tokio runtime (`writer_task_handle()` returns
365    /// `Err(WriterTaskNoRuntime)`, which construction collapses via
366    /// `.ok().flatten()`) — every later write, even ones running inside a
367    /// runtime, would otherwise silently keep bypassing an enabled queue.
368    /// The pool helper also enforces strict fail-closed routing and preserves
369    /// a typed `WriterTaskNoRuntime` error when no runtime is available.
370    fn current_writer_task(
371        &self,
372        operation: &'static str,
373    ) -> Result<Option<WriterTaskHandle>, StorageError> {
374        self.pool
375            .writer_task_for_write(self.writer_task.as_ref(), operation)
376    }
377
378    /// Route a single-row write through the pool-wide `WriterTask` when
379    /// the write queue is enabled and a handle is available. Strict mode
380    /// refuses a missing handle; compatibility mode falls back to the legacy
381    /// standalone-connection / pool-mutex path (ADR-067 Component A, Fork C
382    /// slice 2). See `crates/khive-db/docs/api/text.md`
383    /// for the per-caller routing rules (which methods bypass this via
384    /// `with_writer_unmanaged` and why).
385    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
386    where
387        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
388        R: Send + 'static,
389    {
390        if let Some(writer_task) = self.current_writer_task(op)? {
391            return writer_task
392                .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
393                .await;
394        }
395
396        self.pool
397            .record_direct_route(crate::timeout_sink::Site::DirectRouteFtsGeneralWrite);
398        self.with_writer_unmanaged(op, f).await
399    }
400
401    /// Direct standalone or pooled typed transaction path, bypassing the
402    /// WriterTask channel after routing has selected a compatibility fallback.
403    /// Callbacks contain DML only; this seam owns BEGIN and settlement.
404    async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
405    where
406        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
407        R: Send + 'static,
408    {
409        let pool = Arc::clone(&self.pool);
410        let result = tokio::task::spawn_blocking(move || {
411            pool.execute_direct_transaction(StorageCapability::Text, op, move |conn| {
412                f(conn).map_err(|error| map_err(error, op))
413            })
414        })
415        .await
416        .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?;
417        if let Err(err) = &result {
418            let msg = err.to_string();
419            if msg.contains("locked") || msg.contains("busy") {
420                if self.is_file_backed {
421                    // Only the standalone-connection path: the pool-mutex
422                    // (`try_writer`) path's own timeout is already recorded
423                    // at `Site::PoolAdmission` inside `ConnectionPool::writer`
424                    // — emitting here too would double-count the same event.
425                    crate::timeout_sink::emit_timeout(
426                        &crate::timeout_sink::db_label(&self.pool),
427                        crate::timeout_sink::Site::StandaloneText,
428                        &msg,
429                        None,
430                    );
431                }
432                let open: Vec<String> = khive_storage::tx_registry::snapshot()
433                    .into_iter()
434                    .map(|(age, label)| {
435                        format!(
436                            "{}@{}ms",
437                            label.as_deref().unwrap_or("unlabeled"),
438                            age.as_millis()
439                        )
440                    })
441                    .collect();
442                tracing::warn!(
443                    op,
444                    open_tx_count = open.len(),
445                    open_txs = %open.join(","),
446                    "text write starved on the SQLite write lock; open registered \
447                     transactions listed (empty means the holder issued no \
448                     registered BEGIN IMMEDIATE in this process)"
449                );
450            }
451        }
452        result
453    }
454
455    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
456    where
457        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
458        R: Send + 'static,
459    {
460        super::run_pooled_store_read(
461            Arc::clone(&self.pool),
462            StorageCapability::Text,
463            op,
464            move |conn| f(conn).map_err(|error| map_err(error, op)),
465        )
466        .await
467    }
468}
469
470// -- Helper functions --
471
472fn tags_to_json(tags: &[String]) -> String {
473    serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
474}
475
476fn tags_from_json(s: &str) -> Vec<String> {
477    serde_json::from_str(s).unwrap_or_default()
478}
479
480fn dt_to_micros(dt: &DateTime<Utc>) -> i64 {
481    dt.timestamp_micros()
482}
483
484fn micros_to_dt(micros: i64) -> DateTime<Utc> {
485    Utc.timestamp_micros(micros)
486        .single()
487        .unwrap_or_else(Utc::now)
488}
489
490/// Sanitize an FTS5 query string to prevent driver errors from special
491/// chars: replace grouping/separator chars with spaces (splitting
492/// punctuated identifiers into terms), strip remaining FTS5 operator
493/// characters, then drop FTS5 keyword tokens (AND, OR, NOT, NEAR). See
494/// `crates/khive-db/docs/api/text.md` for the exact char sets and the
495/// issues (#388 and others) that grew them.
496fn sanitize_fts5_query(query: &str) -> String {
497    // Pass 1: replace grouping/separator chars with spaces to isolate tokens.
498    // Colon, hyphen, and dot are included here (not in Pass 2) so punctuated
499    // identifiers become separate terms rather than merged tokens.
500    let spaced: String = query
501        .chars()
502        .map(|c| {
503            if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
504                ' '
505            } else {
506                c
507            }
508        })
509        .collect();
510
511    // Pass 2: remove remaining FTS5 special chars and control characters.
512    // Single quote (apostrophe) is included because FTS5 Plain-mode queries treat
513    // it as a string-literal delimiter causing "syntax error near '''".
514    // Dollar sign is included (#388) because FTS5's MATCH parser rejects it
515    // unconditionally — `syntax error near "$"` — wherever it appears in the
516    // expression, e.g. "$prev.id" (a common agent query for DSL docs).
517    let sanitized: String = spaced
518        .chars()
519        .filter(|c| {
520            !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
521        })
522        .collect();
523
524    // Pass 3: filter FTS5 operator keywords.
525    sanitized
526        .split_whitespace()
527        .filter(|t| {
528            !matches!(
529                t.to_ascii_uppercase().as_str(),
530                "AND" | "OR" | "NOT" | "NEAR"
531            )
532        })
533        .collect::<Vec<_>>()
534        .join(" ")
535}
536
537/// Legacy (pre-#397) sanitization: hyphen and dot are stripped outright
538/// (not space-split), so `khive-pack-memory` normalizes to the single merged
539/// bareword `khivepackmemory`. Used only to build the merged-form
540/// OR-alternative in [`sanitize_fts5_token_group`] — never as the sole
541/// sanitized query — so pre-#397-indexed content stays reachable. See
542/// `crates/khive-db/docs/api/text.md`.
543fn sanitize_fts5_query_legacy_merged(query: &str) -> String {
544    let spaced: String = query
545        .chars()
546        .map(|c| {
547            if matches!(c, '(' | ')' | ',' | ':' | '/') {
548                ' '
549            } else {
550                c
551            }
552        })
553        .collect();
554
555    let sanitized: String = spaced
556        .chars()
557        .filter(|c| {
558            !matches!(
559                c,
560                '*' | '"' | '\'' | '+' | '-' | '^' | '.' | '~' | '!' | '$' | '\0'
561            ) && !c.is_control()
562        })
563        .collect();
564
565    sanitized
566        .split_whitespace()
567        .filter(|t| {
568            !matches!(
569                t.to_ascii_uppercase().as_str(),
570                "AND" | "OR" | "NOT" | "NEAR"
571            )
572        })
573        .collect::<Vec<_>>()
574        .join(" ")
575}
576
577/// Escape a raw token for use inside a double-quoted FTS5 phrase. Embedded
578/// double quotes are doubled; the trailing-`*` prefix-query trigger and
579/// control characters are removed. Everything else, including punctuation,
580/// passes through literally, since FTS5 phrase text is matched
581/// against the column's own tokenization of that literal text (word-exact
582/// under `unicode61`, substring-exact under `trigram`), not re-sanitized.
583///
584/// Returns `None` if nothing survives the filter.
585fn sanitize_fts5_phrase_literal(token: &str) -> Option<String> {
586    let mut literal = String::with_capacity(token.len());
587    for c in token
588        .chars()
589        .filter(|c| !matches!(c, '*' | '\0') && !c.is_control())
590    {
591        if c == '"' {
592            literal.push_str("\"\"");
593        } else {
594            literal.push(c);
595        }
596    }
597    if literal.is_empty() {
598        None
599    } else {
600        Some(literal)
601    }
602}
603
604/// Below this length, FTS5's built-in `trigram` tokenizer (the production
605/// default — `backend.rs::StorageBackend::text()`) produces zero tokens for
606/// a bareword MATCH term. Verified against a live `tokenize='trigram'`
607/// table: a term this short silently drops out of its AND clause instead of
608/// constraining the match, e.g. `2026 07 10` matches any row containing
609/// `2026` regardless of month/day. `sanitize_fts5_token_group` treats any
610/// split segment at or below this length as trigram-unsafe.
611const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
612
613/// FTS5 reserves punctuation in barewords for query syntax, including some
614/// forms that change query meaning without producing an error. Keep bareword
615/// alternatives to the conservative ASCII set and quote everything else.
616fn is_fts5_bareword_safe(s: &str) -> bool {
617    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
618}
619
620/// Sanitize a single whitespace-isolated raw token into an FTS5
621/// match-expression fragment, emitting additive OR-alternatives (split
622/// AND-group, legacy-merged form, quoted literal phrase) so the result is
623/// never a narrower match than any single safe form alone. Returns `None` if
624/// the token sanitizes to nothing. See
625/// `crates/khive-db/docs/api/text.md` for the full #397 trigram-safety
626/// rationale behind which alternatives get emitted and when.
627fn sanitize_fts5_token_group(token: &str) -> Option<String> {
628    let split = sanitize_fts5_query(token);
629    let split_terms: Vec<&str> = split.split_whitespace().collect();
630    if split_terms.is_empty() {
631        return None;
632    }
633
634    let all_bareword_safe = split_terms.iter().all(|t| is_fts5_bareword_safe(t));
635    if split_terms.len() == 1 && is_fts5_bareword_safe(token) {
636        return Some(split_terms[0].to_string());
637    }
638
639    let has_trigram_unsafe_segment = split_terms
640        .iter()
641        .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
642
643    let mut alternatives = Vec::new();
644    if all_bareword_safe && !has_trigram_unsafe_segment {
645        alternatives.push(format!("({})", split_terms.join(" ")));
646    }
647
648    // An operator-bearing token (e.g. `NEAR(alpha-beta,5)`) can make the
649    // legacy merge itself collapse to multiple space-separated terms rather
650    // than one bareword: pass 1 of `sanitize_fts5_query_legacy_merged` spaces
651    // out `(`, `)`, and `,` while pass 2 removes `-`/`.` outright, so
652    // `NEAR(alpha-beta,5)` merges to `"alphabeta 5"`, not one word. Pushed
653    // unguarded, that multi-term fragment carries the same trigram-unsafe
654    // `5` the split-group check above exists to exclude, and under FTS5's
655    // implicit-AND adjacency it silently drops, broadening the OR-alternative
656    // to any row containing `alphabeta`. Apply the same trigram-safety gate
657    // here whenever the merge is multi-term.
658    let merged = sanitize_fts5_query_legacy_merged(token);
659    let merged_terms: Vec<&str> = merged.split_whitespace().collect();
660    let merged_all_bareword_safe =
661        !merged_terms.is_empty() && merged_terms.iter().all(|t| is_fts5_bareword_safe(t));
662    let merged_has_unsafe_segment = merged_terms.len() > 1
663        && merged_terms
664            .iter()
665            .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
666    // Compare against the split AND-group's own space-joined content — the
667    // form it actually contributes to the expression — not the bare
668    // concatenation of its terms. For an ordinary punctuated identifier the
669    // concatenation always matches the merged bareword (both simply drop the
670    // separators), which suppressed this alternative unconditionally and cut
671    // off legacy-indexed content; a real duplicate only exists when the
672    // merge itself produced the same space-separated content the split group
673    // already emits (e.g. an operator token whose merge stays multi-term).
674    let phrase = sanitize_fts5_phrase_literal(token);
675    let duplicates_split = merged == split_terms.join(" ");
676    let duplicates_phrase = phrase.as_deref() == Some(merged.as_str());
677    if merged_all_bareword_safe
678        && !merged.is_empty()
679        && !duplicates_split
680        && !duplicates_phrase
681        && !merged_has_unsafe_segment
682    {
683        alternatives.push(merged);
684    }
685
686    if let Some(phrase) = phrase {
687        alternatives.push(format!("\"{}\"", phrase));
688    }
689
690    match alternatives.len() {
691        0 => None,
692        1 => alternatives.into_iter().next(),
693        _ => Some(format!("({})", alternatives.join(" OR "))),
694    }
695}
696
697/// Join Plain-mode per-token groups into one MATCH expression.
698///
699/// FTS5's implicit-AND adjacency rule (a bare space between two terms)
700/// applies only between two *plain* terms — verified against a live table:
701/// `GQA KV cache` (all bare) matches, but `GQA AND KV AND cache` does not,
702/// because FTS5's explicit `AND` treats a trigram-unsafe short bareword
703/// (`KV`, 2 chars, zero trigrams) as an unsatisfiable operand, while
704/// implicit adjacency instead lets it drop out harmlessly. So: use a bare
705/// space between two plain (unparenthesized) groups to preserve that
706/// existing leniency, and fall back to explicit `AND` only where at least
707/// one side is a parenthesized OR-group (`sanitize_fts5_token_group`'s
708/// output for punctuated tokens) — adjacency there without the operator is
709/// a MATCH-expression syntax error (`(a OR b) c` fails; `(a OR b) AND c`
710/// does not).
711fn join_plain_groups(groups: &[String]) -> String {
712    let mut expr = String::new();
713    for (i, group) in groups.iter().enumerate() {
714        if i > 0 {
715            let prev_compound = groups[i - 1].starts_with('(');
716            let this_compound = group.starts_with('(');
717            expr.push_str(if prev_compound || this_compound {
718                " AND "
719            } else {
720                " "
721            });
722        }
723        expr.push_str(group);
724    }
725    expr
726}
727
728/// Build the FTS5 MATCH expression for a query string under a given mode.
729///
730/// Centralizes the AnyTerm/Plain/Phrase branching previously duplicated
731/// across `search()`, `search_unranked()`, and `search_rank_within_cap()`.
732///
733/// AnyTerm and Plain both process the query token-by-token (split on
734/// whitespace) through [`sanitize_fts5_token_group`], so a punctuated
735/// identifier anywhere in the query gets its split/merged OR-alternative;
736/// AnyTerm joins the per-token groups with `OR`; Plain joins them via
737/// [`join_plain_groups`] (implicit-AND space where safe, explicit `AND`
738/// where a group is parenthesized). Phrase mode keeps the single-string
739/// literal behavior — a double-quoted FTS5 phrase cannot contain
740/// `OR`/parenthesized groups.
741///
742/// Returns `None` when the sanitized query is empty (caller short-circuits
743/// to an empty result set rather than sending an invalid MATCH expression).
744fn build_match_expr(query: &str, mode: TextQueryMode) -> Option<String> {
745    match mode {
746        TextQueryMode::AnyTerm => {
747            let groups: Vec<String> = query
748                .split_whitespace()
749                .filter_map(sanitize_fts5_token_group)
750                .collect();
751            if groups.is_empty() {
752                None
753            } else {
754                Some(groups.join(" OR "))
755            }
756        }
757        TextQueryMode::Plain => {
758            let groups: Vec<String> = query
759                .split_whitespace()
760                .filter_map(sanitize_fts5_token_group)
761                .collect();
762            if groups.is_empty() {
763                None
764            } else {
765                Some(join_plain_groups(&groups))
766            }
767        }
768        TextQueryMode::Phrase => {
769            sanitize_fts5_phrase_literal(query).map(|literal| format!("\"{}\"", literal))
770        }
771    }
772}
773
774fn quote_fts5_phrase(value: &str) -> String {
775    format!("\"{}\"", value.replace('"', "\"\""))
776}
777
778/// Build an indexed classifier predicate for FTS5's MATCH expression.
779///
780/// The production tokenizer is trigram, so values shorter than three
781/// characters have no postings. In that uncommon case the caller keeps only
782/// the exact row predicate from `build_filter_clause`; correctness is
783/// preserved while the indexed optimization deliberately declines.
784fn record_kind_match_expr(filter: Option<&TextFilter>) -> Option<String> {
785    let record_kinds = &filter?.record_kinds;
786    if record_kinds.is_empty() || record_kinds.iter().any(|kind| kind.chars().count() < 3) {
787        return None;
788    }
789
790    let clauses: Vec<String> = record_kinds
791        .iter()
792        .map(|kind| format!("record_kind : {}", quote_fts5_phrase(kind)))
793        .collect();
794    if clauses.len() == 1 {
795        clauses.into_iter().next()
796    } else {
797        Some(format!("({})", clauses.join(" OR ")))
798    }
799}
800
801fn build_filtered_match_expr(
802    query: &str,
803    mode: TextQueryMode,
804    filter: Option<&TextFilter>,
805) -> Option<String> {
806    // Keep lexical terms confined to the two historically indexed text
807    // columns. Once record_kind became indexed, an unqualified query would
808    // otherwise make `memory` match every memory row solely via its classifier.
809    let query = format!("{{title body}} : ({})", build_match_expr(query, mode)?);
810    match record_kind_match_expr(filter) {
811        Some(classifier) => Some(format!("{classifier} AND ({query})")),
812        None => Some(query),
813    }
814}
815
816/// Custom FTS5 rank configuration that keeps the classifier out of lexical
817/// relevance while retaining the optimized hidden-`rank` ORDER BY path.
818///
819/// `record_kind` participates in MATCH for candidate pruning, but its weight is
820/// zero so the classifier cannot become a relevance signal. Subject metadata
821/// columns were already UNINDEXED; title/body retain their default unit weight.
822const LEXICAL_BM25_RANK: &str = "bm25(0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0)";
823
824/// Build a WHERE clause fragment and params for a `TextFilter`.
825///
826/// Returns `(clause, params)` where clause is empty if no filters are active.
827/// Parameter indices start at `?{start_idx}`.
828fn build_filter_clause(
829    filter: &TextFilter,
830    table: &str,
831    start_idx: usize,
832) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
833    let mut conditions: Vec<String> = Vec::new();
834    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
835    let mut idx = start_idx;
836
837    if !filter.ids.is_empty() {
838        let placeholders: Vec<String> = filter
839            .ids
840            .iter()
841            .map(|_| {
842                let p = format!("?{}", idx);
843                idx += 1;
844                p
845            })
846            .collect();
847        conditions.push(format!(
848            "{}.subject_id IN ({})",
849            table,
850            placeholders.join(", ")
851        ));
852        for id in &filter.ids {
853            params.push(Box::new(id.to_string()));
854        }
855    }
856
857    if !filter.kinds.is_empty() {
858        let placeholders: Vec<String> = filter
859            .kinds
860            .iter()
861            .map(|_| {
862                let p = format!("?{}", idx);
863                idx += 1;
864                p
865            })
866            .collect();
867        conditions.push(format!("{}.kind IN ({})", table, placeholders.join(", ")));
868        for kind in &filter.kinds {
869            params.push(Box::new(kind.to_string()));
870        }
871    }
872
873    if !filter.record_kinds.is_empty() {
874        let placeholders: Vec<String> = filter
875            .record_kinds
876            .iter()
877            .map(|_| {
878                let p = format!("?{}", idx);
879                idx += 1;
880                p
881            })
882            .collect();
883        conditions.push(format!(
884            "{}.record_kind IN ({})",
885            table,
886            placeholders.join(", ")
887        ));
888        for kind in &filter.record_kinds {
889            params.push(Box::new(kind.clone()));
890        }
891    }
892
893    if !filter.namespaces.is_empty() {
894        let placeholders: Vec<String> = filter
895            .namespaces
896            .iter()
897            .map(|_| {
898                let p = format!("?{}", idx);
899                idx += 1;
900                p
901            })
902            .collect();
903        conditions.push(format!(
904            "{}.namespace IN ({})",
905            table,
906            placeholders.join(", ")
907        ));
908        for ns in &filter.namespaces {
909            params.push(Box::new(ns.clone()));
910        }
911    }
912
913    if conditions.is_empty() {
914        (String::new(), params)
915    } else {
916        (format!(" AND {}", conditions.join(" AND ")), params)
917    }
918}
919
920/// Test-only seam for [`delete_document_dml`]'s unmanaged (no-writer-task)
921/// path: lets a test force the map-row delete to fail after the FTS-row
922/// delete has already run, so the enclosing `BEGIN IMMEDIATE` / `ROLLBACK`
923/// block's atomicity can be exercised without a real process crash
924/// mid-transaction. Compiles to nothing outside `#[cfg(test)]` — production
925/// code never reads this flag.
926///
927/// A process-wide `Mutex<HashSet<(String, Uuid)>>` rather than a
928/// thread-local: the unmanaged path runs the DML closure inside
929/// `tokio::task::spawn_blocking` (`with_writer_unmanaged`), on a worker
930/// thread distinct from the test's own thread, so a thread-local set by the
931/// test would never be observed by the closure. Each `(namespace,
932/// subject_id)` target is armed/disarmed independently in the shared set —
933/// `arm` inserts its own tuple and `disarm` removes only that same tuple —
934/// so two tests running concurrently, each targeting a different key, cannot
935/// clobber each other's armed state the way a single shared `Option` slot
936/// would (one test's `disarm` would otherwise clear a target a second test
937/// armed after the first but before the first's own disarm ran).
938/// `delete_document_dml` only forces the failure when its own arguments are
939/// a member of the set, so an unrelated delete running concurrently (e.g.
940/// `test_delete_document`, which is not `#[serial]` against this seam) on a
941/// key that was never armed is unaffected regardless of what else is armed
942/// at the time.
943#[cfg(test)]
944pub(crate) mod delete_dml_test_seam {
945    use std::collections::HashSet;
946    use std::sync::Mutex;
947    use uuid::Uuid;
948
949    pub(crate) static TARGETS: Mutex<Option<HashSet<(String, Uuid)>>> = Mutex::new(None);
950
951    pub(crate) fn arm(namespace: &str, subject_id: Uuid) {
952        TARGETS
953            .lock()
954            .unwrap()
955            .get_or_insert_with(HashSet::new)
956            .insert((namespace.to_string(), subject_id));
957    }
958
959    pub(crate) fn disarm(namespace: &str, subject_id: Uuid) {
960        if let Some(targets) = TARGETS.lock().unwrap().as_mut() {
961            targets.remove(&(namespace.to_string(), subject_id));
962        }
963    }
964
965    pub(crate) fn matches(namespace: &str, subject_id: Uuid) -> bool {
966        TARGETS
967            .lock()
968            .unwrap()
969            .as_ref()
970            .is_some_and(|targets| targets.contains(&(namespace.to_string(), subject_id)))
971    }
972}
973
974/// DML-only single-document delete shared by both the legacy (flag-off) and
975/// WriterTask-routed (flag-on) `delete_document` paths.
976///
977/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
978/// enclosing transaction. Both statements (the FTS row, looked up through
979/// the map, then the map row itself) must commit atomically: a crash
980/// between them would leave the map pointing at a rowid FTS5 may later
981/// reuse for a different document.
982fn delete_document_dml(
983    conn: &rusqlite::Connection,
984    table: &str,
985    namespace: &str,
986    subject_id: Uuid,
987) -> Result<bool, SqliteError> {
988    let [fts_statement, map_statement] = delete_document_statements(table, namespace, subject_id);
989
990    let mut stmt = conn.prepare(&fts_statement.sql)?;
991    bind_params(&mut stmt, &fts_statement.params)?;
992    let deleted = stmt.raw_execute()? > 0;
993    drop(stmt);
994
995    #[cfg(test)]
996    if delete_dml_test_seam::matches(namespace, subject_id) {
997        return Err(SqliteError::InvalidData(
998            "delete_document_dml test seam: forced failure between the FTS-row \
999             delete and the map-row delete"
1000                .to_string(),
1001        ));
1002    }
1003
1004    let mut map_stmt = conn.prepare(&map_statement.sql)?;
1005    bind_params(&mut map_stmt, &map_statement.params)?;
1006    map_stmt.raw_execute()?;
1007
1008    Ok(deleted)
1009}
1010
1011/// DML-only single-document upsert shared by both the legacy (flag-off) and
1012/// WriterTask-routed (flag-on) `upsert_document` paths (ADR-067 Component A).
1013///
1014/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
1015/// enclosing transaction.
1016fn upsert_document_dml(
1017    conn: &rusqlite::Connection,
1018    table: &str,
1019    document: &TextDocument,
1020) -> Result<(), rusqlite::Error> {
1021    // Delete (old rowid, via the map) -> insert (new rowid) -> map upsert
1022    // (new rowid). The map's own row for this key needs no separate delete:
1023    // `INSERT OR REPLACE` in the third statement overwrites it in place.
1024    let statement = delete_document_statement(table, &document.namespace, document.subject_id);
1025    let mut stmt = conn.prepare(&statement.sql)?;
1026    bind_params(&mut stmt, &statement.params)?;
1027    stmt.raw_execute()?;
1028
1029    for statement in insert_document_statements(table, document) {
1030        let mut stmt = conn.prepare(&statement.sql)?;
1031        bind_params(&mut stmt, &statement.params)?;
1032        stmt.raw_execute()?;
1033    }
1034    Ok(())
1035}
1036
1037/// DML-only batch upsert loop shared by both the legacy (flag-off) and
1038/// WriterTask-routed (flag-on) `upsert_documents` paths (ADR-067 Component A).
1039///
1040/// Issues no OUTER `BEGIN` / `COMMIT` / `ROLLBACK` — the caller owns the
1041/// enclosing transaction. The per-row named `SAVEPOINT fts_upsert_doc` is
1042/// preserved unchanged: it is what gives this loop its partial-success
1043/// semantics (one bad document does not abort the whole batch) independent
1044/// of which outer transaction wraps the loop.
1045fn batch_upsert_documents_dml(
1046    conn: &rusqlite::Connection,
1047    table: &str,
1048    documents: &[TextDocument],
1049    attempted: u64,
1050) -> Result<BatchWriteSummary, rusqlite::Error> {
1051    let map = rowid_map_table(table);
1052    // Delete via the map's primary key instead of a `namespace`/`subject_id`
1053    // scan of the (UNINDEXED on those columns) FTS table itself. The trailing
1054    // `AND namespace = ?1 AND subject_id = ?2` re-checks the key on the
1055    // rowid-narrowed candidate row: a stale map entry pointing at a rowid
1056    // FTS5 has since reused for a different document must delete zero rows,
1057    // not that unrelated document (mirrors `delete_document_statement`).
1058    let del_sql = format!(
1059        "DELETE FROM {table} WHERE rowid IN \
1060         (SELECT rowid FROM {map} WHERE namespace = ?1 AND subject_id = ?2) \
1061         AND namespace = ?1 AND subject_id = ?2"
1062    );
1063    let ins_sql = format!(
1064        "INSERT INTO {} \
1065         (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
1066         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1067        table
1068    );
1069    // `INSERT OR REPLACE` — no separate delete of the map's own row needed;
1070    // see `upsert_document_dml`'s matching comment.
1071    let map_ins_sql = format!(
1072        "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
1073         VALUES (?1, ?2, last_insert_rowid())"
1074    );
1075
1076    let mut summary = BatchWriteSummary {
1077        attempted,
1078        ..BatchWriteSummary::default()
1079    };
1080
1081    for (index, doc) in documents.iter().enumerate() {
1082        conn.execute_batch("SAVEPOINT fts_upsert_doc")?;
1083        let id_str = doc.subject_id.to_string();
1084        let namespace = &doc.namespace;
1085        let result = (|| {
1086            conn.execute(&del_sql, rusqlite::params![namespace, &id_str])?;
1087
1088            let tags_json = tags_to_json(&doc.tags);
1089            let metadata_json: Option<String> = doc.metadata.as_ref().map(|v| v.to_string());
1090
1091            conn.execute(
1092                &ins_sql,
1093                rusqlite::params![
1094                    &id_str,
1095                    &doc.kind.to_string(),
1096                    doc.title.as_deref().unwrap_or(""),
1097                    &doc.body,
1098                    &tags_json,
1099                    namespace,
1100                    &metadata_json,
1101                    dt_to_micros(&doc.updated_at),
1102                    &doc.record_kind,
1103                ],
1104            )?;
1105            conn.execute(&map_ins_sql, rusqlite::params![namespace, &id_str])?;
1106            Ok::<(), rusqlite::Error>(())
1107        })();
1108
1109        match result {
1110            Ok(()) => {
1111                conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc")?;
1112                summary.affected = summary.affected.saturating_add(1);
1113            }
1114            Err(e) => {
1115                let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT fts_upsert_doc");
1116                let _ = conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc");
1117                let (class, retryability) = super::classify_batch_sqlite_error(&e);
1118                summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
1119            }
1120        }
1121    }
1122
1123    Ok(summary)
1124}
1125
1126#[async_trait]
1127impl TextSearch for Fts5TextSearch {
1128    async fn upsert_document(&self, document: TextDocument) -> Result<(), StorageError> {
1129        let table = self.table_name.clone();
1130
1131        // ADR-067 Component A: when the write queue is enabled, route
1132        // through the pool-wide WriterTask. DML-only closure — no BEGIN
1133        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
1134        // owns the transaction. `current_writer_task("fts_upsert")`
1135        // (ADR-136 D1 gate 3
1136        // amendment) re-checks past a construction-time `None` cache so a
1137        // handle that only became available later is still used here.
1138        if let Some(writer_task) = self.current_writer_task("fts_upsert")? {
1139            let table2 = table.clone();
1140            return writer_task
1141                .send_bounded_at(
1142                    move |conn| {
1143                        upsert_document_dml(conn, &table2, &document)
1144                            .map_err(|e| map_err(e, "fts_upsert"))
1145                    },
1146                    "fts_upsert",
1147                )
1148                .await;
1149        }
1150
1151        // The direct typed unit owns the transaction around this DML body.
1152        self.with_writer("fts_upsert", move |conn| {
1153            upsert_document_dml(conn, &table, &document)
1154        })
1155        .await
1156    }
1157
1158    async fn upsert_documents(
1159        &self,
1160        documents: Vec<TextDocument>,
1161    ) -> Result<BatchWriteSummary, StorageError> {
1162        let table = self.table_name.clone();
1163        let attempted = documents.len() as u64;
1164
1165        // ADR-067 Component A: when the write queue is enabled, route
1166        // through the pool-wide WriterTask. DML-only closure (the per-row
1167        // `SAVEPOINT fts_upsert_doc` is preserved unchanged — only the OUTER
1168        // BEGIN IMMEDIATE/COMMIT is removed, since the WriterTask's run loop
1169        // owns the enclosing transaction). `current_writer_task` re-checks
1170        // past a construction-time `None` cache — see `upsert_document`'s
1171        // matching note.
1172        if let Some(writer_task) = self.current_writer_task("fts_upsert_batch")? {
1173            let table2 = table.clone();
1174            return writer_task
1175                .send_bounded_at(
1176                    move |conn| {
1177                        batch_upsert_documents_dml(conn, &table2, &documents, attempted)
1178                            .map_err(|e| map_err(e, "fts_upsert_batch"))
1179                    },
1180                    "fts_upsert_batch",
1181                )
1182                .await;
1183        }
1184
1185        // The direct typed unit owns the transaction around the complete batch.
1186        self.with_writer("fts_upsert_batch", move |conn| {
1187            batch_upsert_documents_dml(conn, &table, &documents, attempted)
1188        })
1189        .await
1190    }
1191
1192    async fn delete_document(
1193        &self,
1194        namespace: &str,
1195        subject_id: Uuid,
1196    ) -> Result<bool, StorageError> {
1197        let table = self.table_name.clone();
1198        let namespace = namespace.to_string();
1199
1200        if self.scan_fallback {
1201            let statement = delete_document_statement_scan_fallback(&table, &namespace, subject_id);
1202            return self
1203                .with_writer("fts_delete_scan_fallback", move |conn| {
1204                    let mut stmt = conn.prepare(&statement.sql)?;
1205                    bind_params(&mut stmt, &statement.params)?;
1206                    Ok(stmt.raw_execute()? > 0)
1207                })
1208                .await;
1209        }
1210
1211        // Route through the shared WriterTask (DML-only closure — the
1212        // queue's run loop owns the transaction) when available, exactly
1213        // like `upsert_document`'s matching branch. The fallback below wraps
1214        // both statements in one explicit transaction: a crash between the
1215        // FTS-row delete and the map-row delete must never persist only one
1216        // of the two, or a later insert could reuse the freed rowid while
1217        // the map still points at it.
1218        if let Some(writer_task) = self.current_writer_task("fts_delete")? {
1219            let table2 = table.clone();
1220            let namespace2 = namespace.clone();
1221            return writer_task
1222                .send_bounded_at(
1223                    move |conn| {
1224                        delete_document_dml(conn, &table2, &namespace2, subject_id)
1225                            .map_err(|e| map_sqlite_err(e, "fts_delete"))
1226                    },
1227                    "fts_delete",
1228                )
1229                .await;
1230        }
1231
1232        self.with_writer("fts_delete", move |conn| {
1233            delete_document_dml(conn, &table, &namespace, subject_id).map_err(|error| match error {
1234                SqliteError::Rusqlite(inner) => inner,
1235                other => rusqlite::Error::InvalidParameterName(other.to_string()),
1236            })
1237        })
1238        .await
1239    }
1240
1241    async fn get_document(
1242        &self,
1243        namespace: &str,
1244        subject_id: Uuid,
1245    ) -> Result<Option<TextDocument>, StorageError> {
1246        let namespace = namespace.to_string();
1247        let table = self.table_name.clone();
1248        let scan_fallback = self.scan_fallback;
1249
1250        self.with_reader("fts_get", move |conn| {
1251            let sql = if scan_fallback {
1252                format!(
1253                    "SELECT subject_id, kind, title, body, tags, namespace, \
1254                     metadata, updated_at, record_kind \
1255                     FROM {table} WHERE namespace = ?1 AND subject_id = ?2"
1256                )
1257            } else {
1258                let map = rowid_map_table(&table);
1259                format!(
1260                    "SELECT t.subject_id, t.kind, t.title, t.body, t.tags, t.namespace, \
1261                     t.metadata, t.updated_at, t.record_kind \
1262                     FROM {table} AS t JOIN {map} AS m ON m.rowid = t.rowid \
1263                     WHERE m.namespace = ?1 AND m.subject_id = ?2 \
1264                     AND t.namespace = ?1 AND t.subject_id = ?2"
1265                )
1266            };
1267            let mut stmt = conn.prepare(&sql)?;
1268            let mut rows = stmt.query(rusqlite::params![namespace, subject_id.to_string()])?;
1269
1270            match rows.next()? {
1271                Some(row) => {
1272                    let id_str: String = row.get(0)?;
1273                    let kind_str: String = row.get(1)?;
1274                    let title: String = row.get(2)?;
1275                    let body: String = row.get(3)?;
1276                    let tags_json: String = row.get(4)?;
1277                    let ns: String = row.get(5)?;
1278                    let metadata_json: Option<String> = row.get(6)?;
1279                    let updated_at_micros: i64 = row.get(7)?;
1280                    let record_kind: Option<String> = row.get(8)?;
1281
1282                    let sid = Uuid::parse_str(&id_str).map_err(|e| {
1283                        rusqlite::Error::FromSqlConversionFailure(
1284                            0,
1285                            rusqlite::types::Type::Text,
1286                            Box::new(e),
1287                        )
1288                    })?;
1289
1290                    let kind = kind_str.parse::<SubstrateKind>().map_err(|e| {
1291                        rusqlite::Error::FromSqlConversionFailure(
1292                            1,
1293                            rusqlite::types::Type::Text,
1294                            Box::new(e),
1295                        )
1296                    })?;
1297
1298                    Ok(Some(TextDocument {
1299                        subject_id: sid,
1300                        kind,
1301                        record_kind,
1302                        title: if title.is_empty() { None } else { Some(title) },
1303                        body,
1304                        tags: tags_from_json(&tags_json),
1305                        namespace: ns,
1306                        metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
1307                        updated_at: micros_to_dt(updated_at_micros),
1308                    }))
1309                }
1310                None => Ok(None),
1311            }
1312        })
1313        .await
1314    }
1315
1316    async fn search(&self, request: TextSearchRequest) -> Result<Vec<TextSearchHit>, StorageError> {
1317        let table = self.table_name.clone();
1318        let usage = khive_storage::usage::current();
1319
1320        let result = self
1321            .with_reader("fts_search", move |conn| {
1322                let match_expr = match build_filtered_match_expr(
1323                    &request.query,
1324                    request.mode,
1325                    request.filter.as_ref(),
1326                ) {
1327                    Some(expr) => expr,
1328                    None => return Ok(Vec::new()),
1329                };
1330
1331                // Snippet column index 3 = body in the FTS5 schema.
1332                // snippet_chars == 0 is the sentinel for "no snippet" — skip the
1333                // snippet(...) call entirely and return NULL instead.  This avoids
1334                // the ~12ms BM25 snippet computation on the hot recall path where
1335                // snippets are unused.  Callers that need snippets (diagnostics) pass
1336                // snippet_chars > 0 and get the same behaviour as before.
1337                let snippet_expr = if request.snippet_chars == 0 {
1338                    "NULL AS snippet".to_string()
1339                } else {
1340                    let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1341                    format!("snippet({table}, 3, '', '', '...', {chars})")
1342                };
1343
1344                let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1345                    build_filter_clause(filter, &table, 3)
1346                } else {
1347                    (String::new(), Vec::new())
1348                };
1349
1350                let sql = format!(
1351                    "SELECT subject_id, rank, title, {snippet_expr} \
1352                     FROM {table} WHERE {table} MATCH ?1 \
1353                     AND rank MATCH '{LEXICAL_BM25_RANK}'{filter_clause} \
1354                     ORDER BY rank LIMIT ?2",
1355                );
1356
1357                let mut stmt = conn.prepare(&sql)?;
1358                count_fts_pass(usage.as_ref());
1359                stmt.raw_bind_parameter(1, &match_expr)?;
1360                stmt.raw_bind_parameter(2, request.top_k as i64)?;
1361
1362                for (i, param) in filter_params.iter().enumerate() {
1363                    param
1364                        .to_sql()
1365                        .map(|val| stmt.raw_bind_parameter(3 + i, val))
1366                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1367                }
1368
1369                let mut hits = Vec::new();
1370                let mut rows = stmt.raw_query();
1371                let mut rank_idx = 0u32;
1372
1373                while let Some(row) = rows.next()? {
1374                    let id_str: String = row.get(0)?;
1375                    let fts_rank: f64 = row.get(1)?;
1376                    let title: String = row.get(2)?;
1377                    let snippet: Option<String> = row.get(3)?;
1378
1379                    let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1380                        rusqlite::Error::FromSqlConversionFailure(
1381                            0,
1382                            rusqlite::types::Type::Text,
1383                            Box::new(e),
1384                        )
1385                    })?;
1386
1387                    rank_idx += 1;
1388                    hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1389                }
1390
1391                // Normalize scores within the result set to (0.05, 1.0].
1392                // Best rank (most negative) maps to 1.0, worst to 0.05.
1393                let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1394                let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1395                let range = max_rank - min_rank;
1396
1397                let results = hits
1398                    .into_iter()
1399                    .map(|(subject_id, raw_rank, rank, title, snippet)| {
1400                        let score = if range.abs() < 1e-12 {
1401                            1.0
1402                        } else {
1403                            let t = (max_rank - raw_rank) / range;
1404                            0.05 + 0.95 * t
1405                        };
1406                        TextSearchHit {
1407                            subject_id,
1408                            score: DeterministicScore::from_f64(score),
1409                            rank,
1410                            title: if title.is_empty() { None } else { Some(title) },
1411                            snippet: snippet.filter(|s| !s.is_empty()),
1412                        }
1413                    })
1414                    .collect();
1415
1416                Ok(results)
1417            })
1418            .await;
1419
1420        result
1421    }
1422
1423    async fn count(&self, filter: TextFilter) -> Result<u64, StorageError> {
1424        let table = self.table_name.clone();
1425
1426        self.with_reader("fts_count", move |conn| {
1427            let indexed_classifier = record_kind_match_expr(Some(&filter));
1428            let filter_start = if indexed_classifier.is_some() { 2 } else { 1 };
1429            let (filter_clause, filter_params) = build_filter_clause(&filter, &table, filter_start);
1430
1431            let sql = if indexed_classifier.is_some() {
1432                format!("SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{filter_clause}")
1433            } else if filter_clause.is_empty() {
1434                format!("SELECT COUNT(*) FROM {table}")
1435            } else {
1436                let where_part = filter_clause.trim_start_matches(" AND ");
1437                format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1438            };
1439
1440            let mut stmt = conn.prepare(&sql)?;
1441
1442            if let Some(classifier) = indexed_classifier {
1443                stmt.raw_bind_parameter(1, classifier)?;
1444            }
1445
1446            for (i, param) in filter_params.iter().enumerate() {
1447                param
1448                    .to_sql()
1449                    .map(|val| stmt.raw_bind_parameter(filter_start + i, val))
1450                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1451            }
1452
1453            let mut rows = stmt.raw_query();
1454            match rows.next()? {
1455                Some(row) => {
1456                    let count: i64 = row.get(0)?;
1457                    Ok(count as u64)
1458                }
1459                None => Ok(0),
1460            }
1461        })
1462        .await
1463    }
1464
1465    async fn stats(&self) -> Result<TextIndexStats, StorageError> {
1466        let table = self.table_name.clone();
1467
1468        self.with_reader("fts_stats", move |conn| {
1469            let sql = format!("SELECT COUNT(*) FROM {}", table);
1470            let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
1471
1472            Ok(TextIndexStats {
1473                document_count: count as u64,
1474                needs_rebuild: false,
1475                last_rebuild_at: None,
1476            })
1477        })
1478        .await
1479    }
1480
1481    async fn search_with_options(
1482        &self,
1483        request: TextSearchRequest,
1484        options: TextSearchOptions,
1485    ) -> Result<Vec<TextSearchHit>, StorageError> {
1486        match options.gather_mode {
1487            TextGatherMode::Ranked => self.search(request).await,
1488            TextGatherMode::Unranked => self.search_unranked(request).await,
1489            TextGatherMode::RankWithinCap => {
1490                let gather_limit = options
1491                    .gather_limit
1492                    .unwrap_or(request.top_k)
1493                    .max(request.top_k);
1494                self.search_rank_within_cap(request, gather_limit).await
1495            }
1496        }
1497    }
1498
1499    async fn term_stats(
1500        &self,
1501        request: TextTermStatsRequest,
1502    ) -> Result<Vec<TextTermStats>, StorageError> {
1503        let table = self.table_name.clone();
1504
1505        self.with_reader("fts_term_stats", move |conn| {
1506            let filter = request.filter.as_ref();
1507            let indexed_classifier = record_kind_match_expr(filter);
1508
1509            let count_filter_start = if indexed_classifier.is_some() { 2 } else { 1 };
1510            let (count_filter_clause, count_filter_params) = if let Some(f) = filter {
1511                build_filter_clause(f, &table, count_filter_start)
1512            } else {
1513                (String::new(), Vec::new())
1514            };
1515
1516            let document_count: u64 = {
1517                let count_sql = if indexed_classifier.is_some() {
1518                    format!(
1519                        "SELECT COUNT(*) FROM {table} \
1520                         WHERE {table} MATCH ?1{count_filter_clause}"
1521                    )
1522                } else if count_filter_clause.is_empty() {
1523                    format!("SELECT COUNT(*) FROM {table}")
1524                } else {
1525                    let where_part = count_filter_clause.trim_start_matches(" AND ");
1526                    format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1527                };
1528                let mut stmt = conn.prepare(&count_sql)?;
1529                if let Some(ref classifier) = indexed_classifier {
1530                    stmt.raw_bind_parameter(1, classifier)?;
1531                }
1532                for (i, param) in count_filter_params.iter().enumerate() {
1533                    param
1534                        .to_sql()
1535                        .map(|val| stmt.raw_bind_parameter(count_filter_start + i, val))
1536                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1537                }
1538                let mut rows = stmt.raw_query();
1539                match rows.next()? {
1540                    Some(row) => {
1541                        let c: i64 = row.get(0)?;
1542                        c as u64
1543                    }
1544                    None => 0,
1545                }
1546            };
1547
1548            let mut results = Vec::with_capacity(request.terms.len());
1549            for term in &request.terms {
1550                // Keep document frequency aligned with the per-token search expression.
1551                let sanitized = sanitize_fts5_token_group(term).unwrap_or_default();
1552                if sanitized.is_empty() {
1553                    results.push(TextTermStats {
1554                        term: term.clone(),
1555                        sanitized_term: sanitized,
1556                        document_frequency: 0,
1557                        document_count,
1558                        inverse_document_frequency: 0.0,
1559                    });
1560                    continue;
1561                }
1562
1563                // Per-term count: MATCH is ?1, so filter params start at ?2.
1564                let (term_filter_clause, term_filter_params) = if let Some(f) = filter {
1565                    build_filter_clause(f, &table, 2)
1566                } else {
1567                    (String::new(), Vec::new())
1568                };
1569
1570                let text_term = format!("{{title body}} : ({sanitized})");
1571                let term_match = match &indexed_classifier {
1572                    Some(classifier) => format!("{classifier} AND ({text_term})"),
1573                    None => text_term,
1574                };
1575                let count_sql = format!(
1576                    "SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{term_filter_clause}"
1577                );
1578                let mut stmt = conn.prepare(&count_sql)?;
1579                stmt.raw_bind_parameter(1, &term_match)?;
1580                for (i, param) in term_filter_params.iter().enumerate() {
1581                    param
1582                        .to_sql()
1583                        .map(|val| stmt.raw_bind_parameter(2 + i, val))
1584                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1585                }
1586
1587                let df: u64 = {
1588                    let mut rows = stmt.raw_query();
1589                    match rows.next()? {
1590                        Some(row) => {
1591                            let c: i64 = row.get(0)?;
1592                            c as u64
1593                        }
1594                        None => 0,
1595                    }
1596                };
1597
1598                let idf = Fts5TextSearch::bm25_idf(df, document_count);
1599                results.push(TextTermStats {
1600                    term: term.clone(),
1601                    sanitized_term: sanitized,
1602                    document_frequency: df,
1603                    document_count,
1604                    inverse_document_frequency: idf,
1605                });
1606            }
1607
1608            Ok(results)
1609        })
1610        .await
1611    }
1612
1613    async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<TextIndexStats, StorageError> {
1614        let table = self.table_name.clone();
1615
1616        self.with_writer("fts_rebuild", move |conn| {
1617            // FTS5 rebuild command: repopulates the internal index structures.
1618            let sql = format!("INSERT INTO {}({}) VALUES('rebuild')", table, table);
1619            conn.execute(&sql, [])?;
1620
1621            let count_sql = format!("SELECT COUNT(*) FROM {}", table);
1622            let count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
1623
1624            Ok(TextIndexStats {
1625                document_count: count as u64,
1626                needs_rebuild: false,
1627                last_rebuild_at: Some(Utc::now()),
1628            })
1629        })
1630        .await
1631    }
1632}
1633
1634impl Fts5TextSearch {
1635    /// Robertson-Walker BM25 IDF: ln(((N - df + 0.5) / (df + 0.5)) + 1)
1636    fn bm25_idf(df: u64, document_count: u64) -> f64 {
1637        let n = document_count as f64;
1638        let f = df as f64;
1639        ((n - f + 0.5) / (f + 0.5) + 1.0).ln()
1640    }
1641
1642    /// Gather candidates without BM25 ranking; return with uniform score 1.0.
1643    async fn search_unranked(
1644        &self,
1645        request: TextSearchRequest,
1646    ) -> Result<Vec<TextSearchHit>, StorageError> {
1647        let table = self.table_name.clone();
1648        let usage = khive_storage::usage::current();
1649
1650        self.with_reader("fts_search_unranked", move |conn| {
1651            let match_expr = match build_filtered_match_expr(
1652                &request.query,
1653                request.mode,
1654                request.filter.as_ref(),
1655            ) {
1656                Some(expr) => expr,
1657                None => return Ok(Vec::new()),
1658            };
1659
1660            let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1661                build_filter_clause(filter, &table, 3)
1662            } else {
1663                (String::new(), Vec::new())
1664            };
1665
1666            // No rank column, no ORDER BY — avoids BM25 computation entirely.
1667            let sql = format!(
1668                "SELECT subject_id, title \
1669                 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
1670                 LIMIT ?2",
1671            );
1672
1673            let mut stmt = conn.prepare(&sql)?;
1674            count_fts_pass(usage.as_ref());
1675            stmt.raw_bind_parameter(1, &match_expr)?;
1676            stmt.raw_bind_parameter(2, request.top_k as i64)?;
1677
1678            for (i, param) in filter_params.iter().enumerate() {
1679                param
1680                    .to_sql()
1681                    .map(|val| stmt.raw_bind_parameter(3 + i, val))
1682                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1683            }
1684
1685            let mut results = Vec::new();
1686            let mut rows = stmt.raw_query();
1687            let mut rank_idx = 0u32;
1688
1689            while let Some(row) = rows.next()? {
1690                let id_str: String = row.get(0)?;
1691                let title: String = row.get(1)?;
1692
1693                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1694                    rusqlite::Error::FromSqlConversionFailure(
1695                        0,
1696                        rusqlite::types::Type::Text,
1697                        Box::new(e),
1698                    )
1699                })?;
1700
1701                rank_idx += 1;
1702                results.push(TextSearchHit {
1703                    subject_id,
1704                    score: DeterministicScore::from_f64(1.0),
1705                    rank: rank_idx,
1706                    title: if title.is_empty() { None } else { Some(title) },
1707                    snippet: None,
1708                });
1709            }
1710
1711            Ok(results)
1712        })
1713        .await
1714    }
1715
1716    /// Two-stage gather: cheap unranked LIMIT gather_limit, then BM25-rank the subset.
1717    async fn search_rank_within_cap(
1718        &self,
1719        request: TextSearchRequest,
1720        gather_limit: u32,
1721    ) -> Result<Vec<TextSearchHit>, StorageError> {
1722        let table = self.table_name.clone();
1723        let usage = khive_storage::usage::current();
1724
1725        self.with_reader("fts_search_rank_within_cap", move |conn| {
1726            let match_expr = match build_filtered_match_expr(
1727                &request.query,
1728                request.mode,
1729                request.filter.as_ref(),
1730            ) {
1731                Some(expr) => expr,
1732                None => return Ok(Vec::new()),
1733            };
1734
1735            let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1736                build_filter_clause(filter, &table, 3)
1737            } else {
1738                (String::new(), Vec::new())
1739            };
1740
1741            // Stage 1: cheap unranked gather of rowids.
1742            let gather_sql = format!(
1743                "SELECT subject_id FROM {table} WHERE {table} MATCH ?1{filter_clause} LIMIT ?2"
1744            );
1745
1746            let mut stmt = conn.prepare(&gather_sql)?;
1747            count_fts_pass(usage.as_ref());
1748            stmt.raw_bind_parameter(1, &match_expr)?;
1749            stmt.raw_bind_parameter(2, gather_limit as i64)?;
1750            for (i, param) in filter_params.iter().enumerate() {
1751                param
1752                    .to_sql()
1753                    .map(|val| stmt.raw_bind_parameter(3 + i, val))
1754                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1755            }
1756
1757            let mut gathered_ids: Vec<String> = Vec::new();
1758            let mut rows = stmt.raw_query();
1759            while let Some(row) = rows.next()? {
1760                gathered_ids.push(row.get::<_, String>(0)?);
1761            }
1762
1763            if gathered_ids.is_empty() {
1764                return Ok(Vec::new());
1765            }
1766
1767            // Stage 2: BM25-rank only the gathered subset via subject_id IN (...).
1768            let snippet_expr = if request.snippet_chars == 0 {
1769                "NULL AS snippet".to_string()
1770            } else {
1771                let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1772                format!("snippet({table}, 3, '', '', '...', {chars})")
1773            };
1774
1775            // Build IN clause for the gathered IDs.
1776            let id_placeholders: Vec<String> = gathered_ids
1777                .iter()
1778                .enumerate()
1779                .map(|(i, _)| format!("?{}", 3 + i))
1780                .collect();
1781            let in_clause = id_placeholders.join(", ");
1782
1783            let rank_sql = format!(
1784                "SELECT subject_id, rank, title, {snippet_expr} \
1785                 FROM {table} WHERE {table} MATCH ?1 \
1786                 AND rank MATCH '{LEXICAL_BM25_RANK}' \
1787                 AND subject_id IN ({in_clause}) \
1788                 ORDER BY rank LIMIT ?2",
1789            );
1790
1791            let mut stmt2 = conn.prepare(&rank_sql)?;
1792            count_fts_pass(usage.as_ref());
1793            stmt2.raw_bind_parameter(1, &match_expr)?;
1794            stmt2.raw_bind_parameter(2, request.top_k as i64)?;
1795            for (i, id_str) in gathered_ids.iter().enumerate() {
1796                stmt2.raw_bind_parameter(3 + i, id_str.as_str())?;
1797            }
1798
1799            let mut hits = Vec::new();
1800            let mut rows2 = stmt2.raw_query();
1801            let mut rank_idx = 0u32;
1802
1803            while let Some(row) = rows2.next()? {
1804                let id_str: String = row.get(0)?;
1805                let fts_rank: f64 = row.get(1)?;
1806                let title: String = row.get(2)?;
1807                let snippet: Option<String> = row.get(3)?;
1808
1809                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1810                    rusqlite::Error::FromSqlConversionFailure(
1811                        0,
1812                        rusqlite::types::Type::Text,
1813                        Box::new(e),
1814                    )
1815                })?;
1816
1817                rank_idx += 1;
1818                hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1819            }
1820
1821            // Normalize scores within the ranked subset (same formula as search()).
1822            let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1823            let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1824            let range = max_rank - min_rank;
1825
1826            let results = hits
1827                .into_iter()
1828                .map(|(subject_id, raw_rank, rank, title, snippet)| {
1829                    let score = if range.abs() < 1e-12 {
1830                        1.0
1831                    } else {
1832                        let t = (max_rank - raw_rank) / range;
1833                        0.05 + 0.95 * t
1834                    };
1835                    TextSearchHit {
1836                        subject_id,
1837                        score: DeterministicScore::from_f64(score),
1838                        rank,
1839                        title: if title.is_empty() { None } else { Some(title) },
1840                        snippet: snippet.filter(|s| !s.is_empty()),
1841                    }
1842                })
1843                .collect();
1844
1845            Ok(results)
1846        })
1847        .await
1848    }
1849
1850    /// Move all FTS5 documents from `old_namespace` to `new_namespace` in a
1851    /// single transaction.
1852    ///
1853    /// FTS5 virtual tables do not support updating indexed columns (`title`,
1854    /// `body`) via UPDATE. The correct approach is read-then-delete-then-reinsert.
1855    ///
1856    /// Callers must invoke this after any SQL-level namespace change on the
1857    /// backing entity table so that FTS5 keyword search stays consistent with
1858    /// the entity store.
1859    // REASON: reserved for namespace migration operations
1860    #[allow(dead_code)]
1861    pub(crate) async fn rename_namespace(
1862        &self,
1863        old_namespace: &str,
1864        new_namespace: &str,
1865    ) -> Result<u64, StorageError> {
1866        if old_namespace == new_namespace {
1867            return Ok(0);
1868        }
1869        let table = self.table_name.clone();
1870        let old_ns = old_namespace.to_string();
1871        let new_ns = new_namespace.to_string();
1872
1873        // ADR-136 D1 gate 2: queue-first, DML-only closure. `run_writer_task`
1874        // already owns the enclosing `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` for
1875        // this request, so the SELECT enumerating rows-to-move runs INSIDE
1876        // that same transaction as the DELETE+INSERT that consumes it —
1877        // closing the TOCTOU window the pre-migration path had (its SELECT
1878        // ran before its own `BEGIN IMMEDIATE`, so a writer landing between
1879        // the two could resurrect a stale row or lose one mid-rename).
1880        if let Some(writer_task) = self.current_writer_task("fts_rename_namespace")? {
1881            let table2 = table.clone();
1882            return writer_task
1883                .send_bounded_at(
1884                    move |conn| {
1885                        rename_namespace_dml(conn, &table2, &old_ns, &new_ns)
1886                            .map_err(|e| map_err(e, "fts_rename_namespace"))
1887                    },
1888                    "fts_rename_namespace",
1889                )
1890                .await;
1891        }
1892
1893        self.pool
1894            .record_direct_route(crate::timeout_sink::Site::DirectRouteFtsRenameNamespace);
1895
1896        self.with_writer_unmanaged("fts_rename_namespace", move |conn| {
1897            // The SELECT and writes share the typed unit's transaction.
1898            rename_namespace_dml(conn, &table, &old_ns, &new_ns)
1899        })
1900        .await
1901    }
1902}
1903
1904struct FtsRenameRow {
1905    subject_id: String,
1906    kind: String,
1907    title: String,
1908    body: String,
1909    tags: String,
1910    metadata: Option<String>,
1911    updated_at: i64,
1912    record_kind: Option<String>,
1913}
1914
1915/// Move every FTS5 document row from `old_ns` to `new_ns` in `table`.
1916/// DML-only — issues no `BEGIN`/`COMMIT`/`ROLLBACK` of its own, so it is safe
1917/// to call both inside an already-open transaction (the `WriterTask` queue
1918/// path) and wrapped by a caller-managed one (the legacy standalone path).
1919/// The `SELECT` enumerating rows-to-move and the `DELETE`+`INSERT` that
1920/// consumes it always run inside the SAME transaction the caller opened —
1921/// see `Fts5TextSearch::rename_namespace`'s TOCTOU note.
1922fn rename_namespace_dml(
1923    conn: &rusqlite::Connection,
1924    table: &str,
1925    old_ns: &str,
1926    new_ns: &str,
1927) -> Result<u64, rusqlite::Error> {
1928    let sel_sql = format!(
1929        "SELECT subject_id, kind, title, body, tags, metadata, updated_at, record_kind \
1930         FROM {} WHERE namespace = ?1",
1931        table
1932    );
1933    let rows: Vec<FtsRenameRow> = {
1934        let mut stmt = conn.prepare(&sel_sql)?;
1935        let iter = stmt.query_map(rusqlite::params![old_ns], |row| {
1936            Ok(FtsRenameRow {
1937                subject_id: row.get(0)?,
1938                kind: row.get(1)?,
1939                title: row.get(2)?,
1940                body: row.get(3)?,
1941                tags: row.get(4)?,
1942                metadata: row.get(5)?,
1943                updated_at: row.get(6)?,
1944                record_kind: row.get(7)?,
1945            })
1946        })?;
1947        iter.collect::<Result<Vec<_>, _>>()?
1948    };
1949    let moved = rows.len() as u64;
1950    if moved == 0 {
1951        return Ok(0);
1952    }
1953
1954    let del_sql = format!("DELETE FROM {} WHERE namespace = ?1", table);
1955    conn.execute(&del_sql, rusqlite::params![old_ns])?;
1956
1957    // The rows just deleted above are gone from `table` entirely (whole
1958    // namespace, not a single subject), so their map entries are pure
1959    // leftovers now — clear them before reinserting under `new_ns`.
1960    let map = rowid_map_table(table);
1961    let map_del_sql = format!("DELETE FROM {map} WHERE namespace = ?1");
1962    conn.execute(&map_del_sql, rusqlite::params![old_ns])?;
1963
1964    let ins_sql = format!(
1965        "INSERT INTO {} \
1966         (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
1967         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1968        table
1969    );
1970    let map_ins_sql = format!(
1971        "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
1972         VALUES (?1, ?2, last_insert_rowid())"
1973    );
1974    for row in &rows {
1975        conn.execute(
1976            &ins_sql,
1977            rusqlite::params![
1978                row.subject_id,
1979                row.kind,
1980                row.title,
1981                row.body,
1982                row.tags,
1983                new_ns,
1984                row.metadata,
1985                row.updated_at,
1986                row.record_kind,
1987            ],
1988        )?;
1989        conn.execute(&map_ins_sql, rusqlite::params![new_ns, row.subject_id])?;
1990    }
1991
1992    Ok(moved)
1993}
1994
1995#[cfg(test)]
1996#[path = "text_tests.rs"]
1997mod tests;
1998
1999#[cfg(test)]
2000#[path = "text_busy_tests.rs"]
2001mod direct_busy_tests;