Skip to main content

ai_session_search/
db.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-FileCopyrightText: 2026 Nisarg Patel
3// SPDX-License-Identifier: Apache-2.0
4
5use std::collections::HashMap;
6use std::fs;
7use std::num::{NonZeroU64, NonZeroUsize};
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13use anyhow::{anyhow, bail, Context, Result};
14use chrono::{DateTime, TimeZone, Utc};
15use fuzzy_matcher::skim::SkimMatcherV2;
16use fuzzy_matcher::FuzzyMatcher;
17use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
18use nucleo_matcher::{Config as NucleoConfig, Matcher as NucleoMatcher, Utf32Str};
19use rayon::prelude::*;
20use rusqlite::{params, Connection, ErrorCode, OptionalExtension};
21
22use crate::hashing::FramedSha256;
23use crate::message_search::selected_message_field_parts;
24use crate::models::{
25    AnalysisRequest, EditOp, FileCrossRef, FileEdit, FileEditSummary, FileQuery,
26    MessageClassificationMatch, MessageFilters, MessageHit, MessageSearchMode, ParsedSession,
27    ParserHealth, PlanningCount, Provider, ProviderParserHealth, Role, SearchExplain, SearchField,
28    SearchFilters, SearchHit, SessionRecord, SessionTimeProfile, SessionWithTranscript,
29};
30use crate::runtime::ExecutionRuntime;
31use crate::util::{
32    fold_caseless, snippet_from_match, snippet_from_match_while, UnicodeLowerNeedle, SIGMAS,
33};
34
35/// On-disk index generation (NOT the package version). This release INTRODUCES index versioning:
36/// the upstream session-only release never set SQLite's `pragma user_version`, so any pre-existing
37/// index reads as `0`. [`Db::needs_backfill`] compares this constant against `user_version` to
38/// trigger a one-time full reindex after an upgrade, without re-parsing on every run. Bump by
39/// exactly 1 in a future release whenever a schema/parse change requires existing indexes to be
40/// re-parsed; an upgrading user then reindexes once.
41///
42///   1: message-level index — the first versioned schema, layered over the upstream session-only
43///      schema (`sessions` + `transcripts` + a `sessions_fts` table dropped on open since 1.0.0rc2,
44///      unread since session search began scoring the complete corpus). It adds the per-message `messages`
45///      table (normalized role / `tool_name` / ts / compaction across all providers) with its
46///      `messages_fts` word index and the custom, parallel-built [`crate::trigram_index`]
47///      substring/regex prefilter (`trigram_postings` / `trigram_meta`), plus the `file_edits`
48///      table behind file-version recovery (`files …`). The parser excludes harness-injected
49///      output from the `user` role (claude `<local-command-*>`, codex `<environment_context>`). An
50///      upstream index is at `user_version = 0 < 1`, so the first run does a single full reindex to
51///      populate the message-level schema, then stamps `user_version = 1`; the trigram base then
52///      builds lazily on first regex use (no per-row trigram work during reindex).
53///   2: semantic message kinds and tool-call IDs are populated by the provider parsers.
54///   3: codex `<turn_aborted>` harness-control records are excluded from user messages; the
55///      post-reindex archive purge also removes them when their source transcript is unavailable.
56///   4: exact/regex message substring acceleration uses SQLite FTS5 word+trigram indexes.
57///   5: message authorship, mixed-content part ranges, and source-authoritative mirror
58///      correlations are persisted for SQL-first behavioral analysis. Existing rows remain
59///      `unknown` until their available provider source is reparsed; unavailable archive rows are
60///      never guessed.
61pub const SCHEMA_VERSION: i64 = 5;
62const PARSER_SCHEMA_VERSION: i64 = 5;
63const PRE_FTS_PARSER_SCHEMA_VERSION: i64 = 3;
64/// Oldest on-disk generation that has every table and column required for correct reads. A
65/// readable older generation can be served while `auto` upgrades parser-derived rows in a
66/// background process; older and future-unknown generations require synchronous preparation.
67pub const MIN_READABLE_SCHEMA_VERSION: i64 = 2;
68
69/// Closed result of inspecting the on-disk schema before choosing query or maintenance authority.
70/// `Missing` and `RecoveryRequired` are produced by the coordinator before `Db` is opened; an
71/// ordinary opened connection can report only the version-derived states.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub(crate) enum SchemaState {
74    Missing,
75    Current,
76    Older {
77        current: i64,
78        required: i64,
79    },
80    Newer {
81        current: i64,
82        supported: i64,
83    },
84    /// Current version stamp, but `sessions.provider` holds a value this build cannot decode. A
85    /// provider variant can ship without a schema-generation bump (Prime Agent did, at generation
86    /// 5), so this is how an index a newer aise wrote looks to an older aise. It is treated like
87    /// [`SchemaState::Newer`]: refused once at open with the upgrade advice, instead of failing
88    /// deep inside whichever query first reads such a row with advice to run the very reindex
89    /// that fails the same way.
90    UnknownProviders {
91        providers: Vec<String>,
92    },
93    /// Current version stamp, but the derived message-search layout is hybrid/incomplete while every
94    /// base table (including `messages`) is intact. The derived FTS5 objects can be rebuilt online
95    /// from the intact base rows — no transcript re-read, no data loss — so a writable command can
96    /// self-heal in place instead of demanding an offline `reindex --full`.
97    RepairableLayout {
98        reason: String,
99    },
100    RecoveryRequired {
101        reason: String,
102    },
103}
104
105impl SchemaState {
106    pub(crate) fn from_version(current: i64) -> Self {
107        match current.cmp(&SCHEMA_VERSION) {
108            std::cmp::Ordering::Less => Self::Older {
109                current,
110                required: SCHEMA_VERSION,
111            },
112            std::cmp::Ordering::Equal => Self::Current,
113            std::cmp::Ordering::Greater => Self::Newer {
114                current,
115                supported: SCHEMA_VERSION,
116            },
117        }
118    }
119
120    /// The single sentence every open path prints for [`SchemaState::UnknownProviders`]: the
121    /// provider values, this build's version so the reader can compare, and the one fix.
122    pub(crate) fn unknown_providers_message(path: &Path, providers: &[String]) -> String {
123        format!(
124            "index {} contains sessions from provider(s) {} that this aise {} does not recognize, \
125             so a newer aise wrote it; upgrade aise (`aise package update`) before opening it",
126            path.display(),
127            providers.join(", "),
128            env!("CARGO_PKG_VERSION")
129        )
130    }
131}
132
133/// Distinct `sessions.provider` values this build cannot decode, in index order. Empty when every
134/// provider is known or when the `sessions` table does not exist yet. `O(S)` sessions once per
135/// open; the column is short and the table is small next to `messages`.
136pub(crate) fn unknown_index_providers(conn: &Connection) -> Result<Vec<String>> {
137    let table_exists: bool = conn.query_row(
138        "select exists(select 1 from sqlite_schema where type = 'table' and name = 'sessions')",
139        [],
140        |row| row.get(0),
141    )?;
142    if !table_exists {
143        return Ok(Vec::new());
144    }
145    let mut statement = conn.prepare("select distinct provider from sessions order by provider")?;
146    let providers = statement
147        .query_map([], |row| row.get::<_, String>(0))?
148        .collect::<rusqlite::Result<Vec<_>>>()?;
149    Ok(providers
150        .into_iter()
151        .filter(|provider| Provider::from_db_str(provider).is_err())
152        .collect())
153}
154
155#[derive(Debug, Clone, Copy, Eq, PartialEq)]
156pub enum MessageOrder {
157    OldestFirst,
158    NewestFirst,
159}
160
161#[derive(Debug, Clone, Copy, Eq, PartialEq)]
162pub(crate) enum MessageBatchControl {
163    Continue,
164    Stop,
165}
166
167#[derive(Debug)]
168pub(crate) struct MessageBatchVisitOutcome {
169    pub(crate) rows_visited: usize,
170    pub(crate) exhausted: bool,
171    pub(crate) explain: Option<SearchExplain>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub(crate) enum SourceFileRefreshState {
176    Current,
177    ChangedSameParser,
178    ChangedParser,
179    New,
180}
181
182struct PreparedMessageQuery {
183    sql: String,
184    args: Vec<rusqlite::types::Value>,
185    /// The receipt this statement's plan produced, complete at prepare time; the rows a caller
186    /// then reads never change it (a `limit + 1` look-ahead used to be reported as the corpus).
187    explain: Option<SearchExplain>,
188}
189
190/// Corpus-size threshold below which a regex `messages search` skips the trigram prefilter and
191/// scans the structurally-filtered rows directly. The prefilter's win is amortized over a large
192/// corpus; once a role/session/time/tool filter narrows the scan to a small slice, a direct regex
193/// pass over that slice can beat intersecting it against the whole-corpus trigram index. See
194/// [`Db::search_messages`].
195const TRIGRAM_PREFILTER_MIN_CORPUS: i64 = 50_000;
196
197/// How many newer-than-base messages may accumulate before the custom trigram base index is
198/// rebuilt (in parallel). Below this, messages with `id > base_max` form the "delta" and are
199/// regex-verified by a direct scan rather than via the index — bounded by the SAME magnitude as
200/// [`TRIGRAM_PREFILTER_MIN_CORPUS`] so the un-indexed delta a query may direct-scan stays in the
201/// range a direct scan already handles cheaply. See [`Db::ensure_trigram_base`].
202const TRIGRAM_BASE_REBUILD_DELTA: i64 = TRIGRAM_PREFILTER_MIN_CORPUS;
203
204/// Maximum message rows retained for one parallel fuzzy-scoring batch. Global ranking keeps only
205/// `offset + limit` scored rows between batches, so retained row count is independent of corpus
206/// size. Byte memory still includes full content for the current batch and top window plus each
207/// worker's UTF-32/lowercase scratch for its largest processed row.
208const FUZZY_SCORE_BATCH_SIZE: usize = 512;
209/// Bound rows and ordinary transcript bytes retained between serial SQLite traversal and parallel
210/// session scoring. One individually oversized record is processed alone, so batching adds at most
211/// this byte budget beyond the largest record rather than multiplying that record by the row cap.
212const SESSION_SCORE_BATCH_SIZE: usize = 128;
213const SESSION_SCORE_BATCH_BYTES: usize = 8 * 1024 * 1024;
214/// Bound the message bytes retained by one fuzzy scoring batch. Rows alone do not bound memory:
215/// 512 tool results of a megabyte each are half a gigabyte held before the batch scores.
216const FUZZY_BATCH_BYTES: usize = 8 * 1024 * 1024;
217
218/// One rule for every fuzzy batch: score when the row cap or the byte cap is reached, so peak
219/// retained input is `min(FUZZY_SCORE_BATCH_SIZE rows, FUZZY_BATCH_BYTES + one row)`.
220const fn fuzzy_batch_is_full(rows: usize, bytes: usize) -> bool {
221    rows >= FUZZY_SCORE_BATCH_SIZE || bytes >= FUZZY_BATCH_BYTES
222}
223
224/// Default SQLite busy timeout for normal CLI/MCP use. This is intentionally a short wait, not
225/// an indefinite block: concurrent agent sessions should ride out brief write bursts, while real
226/// stuck maintenance still surfaces as an actionable error.
227pub const DEFAULT_BUSY_TIMEOUT_MS: u64 = 5_000;
228
229/// Automatic read-command refreshes use a separate stale-read fallback timeout: wait long enough
230/// for ordinary writer handoffs, then serve the existing index if another process is still writing.
231pub const DEFAULT_AUTO_REINDEX_BUSY_TIMEOUT_MS: u64 = 10_000;
232
233/// Shared cross-process window after a successful automatic refresh where later read commands skip
234/// auto-reindex and stay read-only. This replaces the old MCP-only in-process throttle.
235pub const DEFAULT_AUTO_REINDEX_INTERVAL_MS: u64 = 1_500;
236
237/// Per-connection SQLite page-cache target. SQLite interprets a negative `cache_size` as KiB;
238/// pages are allocated on demand, so an idle connection does not reserve this entire amount.
239const SQLITE_PAGE_CACHE_KIB: i64 = 64 * 1_024;
240
241/// Maximum database prefix eligible for SQLite memory-mapped reads. This is virtual address space,
242/// not eagerly allocated resident memory, and SQLite/OS may choose a lower platform-safe value.
243const SQLITE_MMAP_BYTES: i64 = 256 * 1_024 * 1_024;
244
245/// Partial covering index over the classes the default search predicate can exclude
246/// ([`crate::models::MessageKind::EXCLUDABLE_BY_DEFAULT`]). It exists for one statement: the
247/// receipt's corpus denominator, `count(rows the structural filters admit)`, whose class clause
248/// is `kind != 'harness_notice'`. No index answers a `!=` without visiting rows, and the message
249/// rows carry the bodies, so that count read every page of a 22.6 GB table (117 s). Written as
250/// `count(every class) - count(only the excluded classes)`, the first term is served by the
251/// index that leads with the selected filter column and the second by this one: it leads with
252/// `kind` and carries every column a structural filter tests, so both terms are index-only.
253///
254/// Partial, not whole-table, on purpose: an index leading with `kind` over every row is
255/// attractive to the planner for `kind = 'tool_call'` and `--kind` selections, and on the real
256/// schema and statistics it displaced the ordered `idx_messages_tool_calls`/`session_seq`
257/// walks with a seek plus a temp B-tree sort (checked with `explain query plan` against a
258/// replica of the production schema and `sqlite_stat1`). A partial index is usable only when the
259/// query text implies its `where` clause, so no other statement can pick it, and it holds
260/// 2 % of the rows. That implication is textual: [`excluded_kinds_predicate`] renders the one
261/// clause both the DDL and the count use.
262///
263/// Built by [`Db::ensure_message_kind_index`]: at open only while the table is empty,
264/// otherwise once by the elected refresh writer.
265pub(crate) const MESSAGE_KIND_INDEX: &str = "idx_messages_excluded_kinds";
266const MESSAGE_KIND_INDEX_COLUMNS: &str = "(kind, role, provider, session_id, ts, tool_name)";
267
268/// `kind in ('compaction', 'harness_notice')`, the exact text of [`MESSAGE_KIND_INDEX`]'s partial
269/// clause. SQLite proves partial-index usability by comparing expressions, and bound parameters
270/// never qualify, so the count statement inlines this literal (enum spellings, no user input)
271/// and narrows with a bound `kind in (?)` after it.
272fn excluded_kinds_predicate() -> String {
273    let classes = crate::models::MessageKind::EXCLUDABLE_BY_DEFAULT
274        .iter()
275        .map(|kind| format!("'{}'", kind.as_str()))
276        .collect::<Vec<_>>()
277        .join(", ");
278    format!("kind in ({classes})")
279}
280
281/// Caller-injected sink for human-facing progress notices (see [`Db::set_progress_reporter`]).
282type ProgressReporter = Box<dyn Fn(&str) + Send + Sync>;
283
284/// Set once the unread `sessions_fts` copy an earlier build filled has been emptied; see `Db::init`.
285const SESSIONS_FTS_RETIRED_KEY: &str = "sessions_fts_retired";
286const AUTO_REINDEX_COMPLETED_MS_KEY: &str = "auto_reindex_completed_ms";
287const AUTO_REINDEX_COMPLETED_NS_KEY: &str = "auto_reindex_completed_ns";
288const AUTO_REINDEX_PARSER_CONTRACT_KEYS: [&str; 4] = [
289    "auto_reindex_parser_contract_0",
290    "auto_reindex_parser_contract_1",
291    "auto_reindex_parser_contract_2",
292    "auto_reindex_parser_contract_3",
293];
294const AUTO_REINDEX_FRESHNESS_SQL: &str =
295    "select completed.value, word0.value, word1.value, word2.value, word3.value
296     from index_metadata completed
297     join index_metadata word0 on word0.key = ?2
298     join index_metadata word1 on word1.key = ?3
299     join index_metadata word2 on word2.key = ?4
300     join index_metadata word3 on word3.key = ?5
301     where completed.key = ?1";
302const NANOSECONDS_PER_MILLISECOND: i64 = 1_000_000;
303const NANOSECONDS_PER_SECOND: i64 = 1_000_000_000;
304const CORRECTION_CANDIDATES_SQL: &str =
305    "select m.id, m.session_id, m.seq, m.provider, m.ts, m.content, m.authorship
306     from messages m
307     where m.record_relation = 'original'
308       and (
309           m.authorship = 'human'
310           or (
311               m.authorship = 'mixed'
312               and exists (
313                   select 1
314                   from message_content_parts p
315                   where p.message_id = m.id and p.authorship = 'human'
316               )
317           )
318       )";
319const CORRECTION_HUMAN_PARTS_SQL: &str = "select m.id, p.start_char, p.end_char
320     from messages m
321     join message_content_parts p on p.message_id = m.id
322     where m.authorship = 'mixed'
323       and m.record_relation = 'original'
324       and p.authorship = 'human'";
325
326struct TrigramRebuild {
327    base_max: i64,
328    rebuilt: bool,
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub(crate) struct StorageAllocation {
333    pub total_bytes: u64,
334    pub reclaimable_bytes: u64,
335}
336
337fn elapsed_ms(now_ms: i64, earlier_ms: i64) -> u64 {
338    now_ms.saturating_sub(earlier_ms).max(0) as u64
339}
340
341pub struct Db {
342    conn: Connection,
343    runtime: Arc<ExecutionRuntime>,
344    access_scope: crate::search_scope::EffectiveAccessScope,
345    /// Fixed corpus-size threshold used only by the pre-v4 compatibility prefilter.
346    prefilter_min_corpus: i64,
347    /// Fixed un-indexed delta size before the pre-v4 compatibility base is rebuilt.
348    trigram_rebuild_delta: i64,
349    /// Whether read operations may perform persistent lazy index maintenance. Disabled by the
350    /// `existing-only` refresh policy; searches then reuse any existing base and scan its delta.
351    implicit_index_maintenance: bool,
352    /// Optional sink for human-facing progress notices (e.g. the one-time lazy index build). The
353    /// library NEVER writes to stderr/stdout itself — the caller injects how (or whether) to report:
354    /// the CLI sets an `eprintln` sink, the MCP server leaves it unset (silent, so nothing can
355    /// pollute its stdio JSON-RPC channel). Mirrors the indexer's `progress` callback.
356    progress: Option<ProgressReporter>,
357}
358
359/// Same-snapshot population facts accumulated by [`Db::visit_analysis_sessions`].
360pub(crate) struct AnalysisTraversalReceipt {
361    pub(crate) selected_sessions: u64,
362    pub(crate) messages_in_selected_sessions: u64,
363    pub(crate) analyzed_user_messages: u64,
364    pub(crate) has_more: bool,
365    pub(crate) last_selected_session_id: Option<String>,
366    pub(crate) max_selected_session_updated_at: Option<DateTime<Utc>>,
367    pub(crate) corpus_digest: String,
368}
369
370const QUERY_PROGRESS_HANDLER_OPCODES: i32 = 10_000;
371
372struct ProgressHandlerReset<'connection>(&'connection Connection);
373
374impl Drop for ProgressHandlerReset<'_> {
375    fn drop(&mut self) {
376        // rusqlite 0.40 reports whether clearing the handler succeeded. Drop cannot propagate,
377        // and leaving this query's expired deadline installed would stop the next query on the
378        // same connection, so surface it where a caller can act: debug and test builds.
379        let cleared = self.0.progress_handler(0, None::<fn() -> bool>);
380        debug_assert!(
381            cleared.is_ok(),
382            "clearing the SQLite progress handler failed: {cleared:?}"
383        );
384    }
385}
386
387struct ReadSnapshotRollback<'connection> {
388    connection: &'connection Connection,
389    active: bool,
390}
391
392/// Snapshot cleanup failed, so callers must not downgrade the operation to expected cancellation.
393///
394/// The operation is rendered rather than exposed as an error source: if it was `SQLITE_INTERRUPT`,
395/// allowing that source into an error chain would incorrectly classify the cleanup failure itself
396/// as a successful cancellation.
397#[derive(Debug)]
398pub(crate) struct ReadSnapshotCleanupError {
399    operation: Option<String>,
400    cleanup: String,
401}
402
403impl ReadSnapshotCleanupError {
404    pub(crate) fn new(operation: Option<anyhow::Error>, cleanup: anyhow::Error) -> Self {
405        Self {
406            operation: operation.map(|error| format!("{error:#}")),
407            cleanup: format!("{cleanup:#}"),
408        }
409    }
410}
411
412impl std::fmt::Display for ReadSnapshotCleanupError {
413    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        match self.operation.as_deref() {
415            Some(operation) => write!(
416                formatter,
417                "message search failed ({operation}) and its read snapshot also failed to release: {}",
418                self.cleanup
419            ),
420            None => write!(
421                formatter,
422                "failed to release message-search read snapshot: {}",
423                self.cleanup
424            ),
425        }
426    }
427}
428
429impl std::error::Error for ReadSnapshotCleanupError {}
430
431impl<'connection> ReadSnapshotRollback<'connection> {
432    fn begin(connection: &'connection Connection) -> Result<Self> {
433        anyhow::ensure!(
434            connection.is_autocommit(),
435            "cannot start a read snapshot while another transaction is active"
436        );
437        connection.execute_batch("begin deferred")?;
438        Ok(Self {
439            connection,
440            active: true,
441        })
442    }
443
444    fn rollback(mut self) -> Result<()> {
445        self.connection.execute_batch("rollback")?;
446        self.active = false;
447        Ok(())
448    }
449}
450
451impl Drop for ReadSnapshotRollback<'_> {
452    fn drop(&mut self) {
453        if self.active {
454            let _ = self.connection.execute_batch("rollback");
455        }
456    }
457}
458
459/// Request-owned cancellation shared by blocking Rust work and its current SQLite connection.
460///
461/// The atomic flag closes the cancel-before-statement race; `InterruptHandle` preempts an active
462/// statement without waiting for the progress callback's fixed opcode interval. Registration and
463/// cancellation are `O(1)` time and memory, and the mutex is never held while SQLite executes.
464#[derive(Debug)]
465pub(crate) struct QueryCancelled;
466
467impl std::fmt::Display for QueryCancelled {
468    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        formatter.write_str("query cancelled")
470    }
471}
472
473impl std::error::Error for QueryCancelled {}
474
475pub(crate) struct QueryCancellation {
476    cancelled: Arc<AtomicBool>,
477    interrupt: Mutex<Option<rusqlite::InterruptHandle>>,
478}
479
480impl QueryCancellation {
481    pub(crate) fn new() -> Self {
482        Self {
483            cancelled: Arc::new(AtomicBool::new(false)),
484            interrupt: Mutex::new(None),
485        }
486    }
487
488    pub(crate) fn cancel(&self) {
489        self.cancelled.store(true, Ordering::Release);
490        let interrupt = self
491            .interrupt
492            .lock()
493            .unwrap_or_else(std::sync::PoisonError::into_inner);
494        if let Some(interrupt) = interrupt.as_ref() {
495            interrupt.interrupt();
496        }
497    }
498
499    pub(crate) fn is_cancelled(&self) -> bool {
500        self.cancelled.load(Ordering::Acquire)
501    }
502
503    pub(crate) fn ensure_active(&self) -> Result<()> {
504        if self.is_cancelled() {
505            return Err(QueryCancelled.into());
506        }
507        Ok(())
508    }
509
510    pub(crate) fn flag(&self) -> &AtomicBool {
511        self.cancelled.as_ref()
512    }
513
514    pub(crate) fn flag_arc(&self) -> Arc<AtomicBool> {
515        Arc::clone(&self.cancelled)
516    }
517
518    pub(crate) fn register(&self, connection: &Connection) {
519        let mut interrupt = self
520            .interrupt
521            .lock()
522            .unwrap_or_else(std::sync::PoisonError::into_inner);
523        *interrupt = Some(connection.get_interrupt_handle());
524        if self.is_cancelled() {
525            interrupt
526                .as_ref()
527                .expect("interrupt handle was installed above")
528                .interrupt();
529        }
530    }
531}
532
533#[cfg(test)]
534pub(crate) fn with_sqlite_query_timeout<T>(
535    connection: &Connection,
536    timeout_ms: Option<NonZeroU64>,
537    operation: &str,
538    recovery: &str,
539    run: impl FnOnce() -> Result<T>,
540) -> Result<T> {
541    with_sqlite_query_control(connection, timeout_ms, None, operation, recovery, run)
542}
543
544pub(crate) fn with_sqlite_query_control<T>(
545    connection: &Connection,
546    timeout_ms: Option<NonZeroU64>,
547    cancellation: Option<Arc<AtomicBool>>,
548    operation: &str,
549    recovery: &str,
550    run: impl FnOnce() -> Result<T>,
551) -> Result<T> {
552    // One progress callback composes the two independent stop conditions. Installing competing
553    // handlers would make the last caller silently disable the earlier deadline or cancellation.
554    if timeout_ms.is_none() && cancellation.is_none() {
555        return run();
556    }
557    let deadline = timeout_ms
558        .map(|timeout_ms| {
559            Instant::now()
560                .checked_add(Duration::from_millis(timeout_ms.get()))
561                .ok_or_else(|| anyhow!("{operation} timeout_ms is too large for this platform"))
562        })
563        .transpose()?;
564    let progress_cancellation = cancellation.clone();
565    // Propagated rather than ignored: a handler that failed to install leaves the query with
566    // neither its deadline nor its cancellation, so it would run to completion while the caller
567    // believes both are armed.
568    connection
569        .progress_handler(
570            QUERY_PROGRESS_HANDLER_OPCODES,
571            Some(move || {
572                progress_cancellation
573                    .as_ref()
574                    .is_some_and(|flag| flag.load(Ordering::Acquire))
575                    || deadline.is_some_and(|deadline| Instant::now() >= deadline)
576            }),
577        )
578        .with_context(|| format!("{operation} could not arm its query stop conditions"))?;
579    let reset = ProgressHandlerReset(connection);
580    let result = run();
581    drop(reset);
582    result.map_err(|error| {
583        if error.chain().any(|cause| {
584            matches!(
585                cause.downcast_ref::<rusqlite::Error>(),
586                Some(rusqlite::Error::SqliteFailure(inner, _))
587                    if inner.code == rusqlite::ErrorCode::OperationInterrupted
588            )
589        }) {
590            if cancellation
591                .as_ref()
592                .is_some_and(|flag| flag.load(Ordering::Acquire))
593            {
594                anyhow!("{operation} was cancelled")
595            } else if let Some(timeout_ms) = timeout_ms {
596                anyhow!(
597                    "{operation} timed out after {} ms; {recovery}",
598                    timeout_ms.get()
599                )
600            } else {
601                error
602            }
603        } else {
604            error
605        }
606    })
607}
608
609macro_rules! session_record_columns {
610    () => {
611        "s.id, s.provider, s.provider_session_id, s.title, s.summary, s.cwd, s.repo_root, \
612         s.created_at, s.updated_at, s.last_message_at, s.preview_text, s.source_path, \
613         s.message_count, s.parse_version, s.raw_metadata_json, s.parse_warning, s.discovery_source, \
614         s.parent_session_id, s.agent_label"
615    };
616}
617
618impl Db {
619    pub fn open(path: &Path) -> Result<Self> {
620        Self::open_with_busy_timeout(path, DEFAULT_BUSY_TIMEOUT_MS)
621    }
622
623    pub fn open_with_busy_timeout(path: &Path, busy_timeout_ms: u64) -> Result<Self> {
624        let worker_threads = std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN);
625        Self::open_with_threads(path, busy_timeout_ms, worker_threads)
626    }
627
628    /// Open a database with an application-owned fixed-size worker runtime.
629    pub fn open_with_threads(
630        path: &Path,
631        busy_timeout_ms: u64,
632        worker_threads: NonZeroUsize,
633    ) -> Result<Self> {
634        if let Some(parent) = path.parent() {
635            fs::create_dir_all(parent)
636                .with_context(|| format!("failed to create {}", parent.display()))?;
637        }
638        let conn = Connection::open(path)?;
639        conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
640        crate::sql_functions::register(&conn)?;
641        let db = Self {
642            conn,
643            runtime: Arc::new(ExecutionRuntime::new(worker_threads)),
644            access_scope: crate::search_scope::EffectiveAccessScope::All,
645            prefilter_min_corpus: TRIGRAM_PREFILTER_MIN_CORPUS,
646            trigram_rebuild_delta: TRIGRAM_BASE_REBUILD_DELTA,
647            implicit_index_maintenance: true,
648            progress: None,
649        };
650        db.init()?;
651        Ok(db)
652    }
653
654    /// Open an existing index with SQLite-enforced read-only/query-only authority. This path
655    /// deliberately skips [`Db::init`]: it never creates directories, tables, triggers, indexes,
656    /// or compatibility objects and never changes durable database contents. SQLite itself may
657    /// create or update empty `-wal`/`-shm` coordination sidecars when they are absent. UDF
658    /// registration and cache/temp/mmap pragmas affect
659    /// only this connection and are required by the shared query implementation.
660    pub(crate) fn open_existing_read_only_with_threads(
661        path: &Path,
662        busy_timeout_ms: u64,
663        worker_threads: NonZeroUsize,
664    ) -> Result<Self> {
665        Self::open_existing_read_only_with_runtime(
666            path,
667            busy_timeout_ms,
668            Arc::new(ExecutionRuntime::new(worker_threads)),
669        )
670    }
671
672    /// Open a read-only SQLite connection that shares one fixed data-parallel worker budget.
673    ///
674    /// Opening `B` concurrent readers with the same runtime keeps Rayon worker memory and thread
675    /// count `O(T)`, where `T` is the configured worker count, instead of `O(B * T)`. Each reader
676    /// still owns independent SQLite connection state so WAL reads may proceed simultaneously.
677    pub(crate) fn open_existing_read_only_with_runtime(
678        path: &Path,
679        busy_timeout_ms: u64,
680        runtime: Arc<ExecutionRuntime>,
681    ) -> Result<Self> {
682        let flags = rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
683            | rusqlite::OpenFlags::SQLITE_OPEN_URI
684            | rusqlite::OpenFlags::SQLITE_OPEN_PRIVATE_CACHE;
685        // Callers only reach this open after IndexCoordinator::inspect_schema confirmed the
686        // index exists and is readable (service.rs bails out earlier, before ever calling this,
687        // when the schema state is Missing). So a failure here is NOT "it doesn't exist yet" —
688        // either file permissions changed, or another process removed/relocated it in the
689        // brief window between that check and this open.
690        let conn = Connection::open_with_flags(path, flags).with_context(|| {
691            format!(
692                "failed to open existing index {} read-only even though it was just verified to \
693                 exist; check file permissions, or another process may have removed or \
694                 relocated it in the meantime",
695                path.display()
696            )
697        })?;
698        conn.busy_timeout(Duration::from_millis(busy_timeout_ms))?;
699        crate::sql_functions::register(&conn)?;
700        conn.pragma_update(None, "query_only", true)?;
701        conn.pragma_update(None, "foreign_keys", true)?;
702        conn.pragma_update(None, "temp_store", 2_i64)?;
703        conn.pragma_update(None, "cache_size", -SQLITE_PAGE_CACHE_KIB)?;
704        conn.pragma_update(None, "mmap_size", SQLITE_MMAP_BYTES)?;
705        Ok(Self {
706            conn,
707            runtime,
708            access_scope: crate::search_scope::EffectiveAccessScope::All,
709            prefilter_min_corpus: TRIGRAM_PREFILTER_MIN_CORPUS,
710            trigram_rebuild_delta: TRIGRAM_BASE_REBUILD_DELTA,
711            implicit_index_maintenance: false,
712            progress: None,
713        })
714    }
715
716    /// Clone the application-owned data-parallel runtime for another read-only connection.
717    /// Sharing keeps the configured worker budget process-wide instead of multiplying it per
718    /// reader; each connection still owns independent SQLite/WAL state.
719    pub(crate) fn execution_runtime(&self) -> Arc<ExecutionRuntime> {
720        Arc::clone(&self.runtime)
721    }
722
723    /// Number of data-parallel workers owned by this database lifecycle.
724    pub fn worker_threads(&self) -> usize {
725        self.runtime.worker_threads()
726    }
727
728    #[cfg(test)]
729    fn worker_pool_build_count(&self) -> usize {
730        self.runtime.pool_build_count()
731    }
732
733    pub(crate) fn interrupt_handle(&self) -> rusqlite::InterruptHandle {
734        self.conn.get_interrupt_handle()
735    }
736
737    /// Arm this connection so a raised `cancellation` flag stops the running query.
738    ///
739    /// Returns the failure instead of swallowing it: a caller that believes cancellation is
740    /// armed, on a connection where it is not, waits for a query it can no longer stop.
741    pub(crate) fn interrupt_while(&self, cancellation: Arc<AtomicBool>) -> Result<()> {
742        self.conn
743            .progress_handler(
744                QUERY_PROGRESS_HANDLER_OPCODES,
745                Some(move || cancellation.load(Ordering::Acquire)),
746            )
747            .context("could not arm query cancellation on this connection")
748    }
749
750    pub(crate) fn install_query_cancellation(
751        &self,
752        cancellation: &QueryCancellation,
753    ) -> Result<()> {
754        self.interrupt_while(cancellation.flag_arc())?;
755        cancellation.register(&self.conn);
756        Ok(())
757    }
758
759    pub(crate) fn with_read_snapshot<T>(&self, run: impl FnOnce() -> Result<T>) -> Result<T> {
760        if !self.conn.is_autocommit() {
761            return run();
762        }
763        let snapshot = ReadSnapshotRollback::begin(&self.conn)?;
764        let result = run();
765        let cleanup = snapshot.rollback();
766        match (result, cleanup) {
767            (Ok(value), Ok(())) => Ok(value),
768            (Err(error), Ok(())) => Err(error),
769            (Ok(_), Err(cleanup_error)) => {
770                Err(ReadSnapshotCleanupError::new(None, cleanup_error).into())
771            }
772            (Err(error), Err(cleanup_error)) => {
773                Err(ReadSnapshotCleanupError::new(Some(error), cleanup_error).into())
774            }
775        }
776    }
777
778    pub(crate) fn set_access_scope(
779        &mut self,
780        access_scope: crate::search_scope::EffectiveAccessScope,
781    ) {
782        self.access_scope = access_scope;
783    }
784
785    pub(crate) const fn access_scope(&self) -> &crate::search_scope::EffectiveAccessScope {
786        &self.access_scope
787    }
788
789    fn validate_access_scope(&self) -> Result<()> {
790        self.access_scope.validate_stable()
791    }
792
793    pub fn set_busy_timeout_ms(&self, busy_timeout_ms: u64) -> Result<()> {
794        self.conn
795            .busy_timeout(Duration::from_millis(busy_timeout_ms))?;
796        Ok(())
797    }
798
799    pub fn busy_timeout_ms(&self) -> Result<u64> {
800        let timeout: i64 = self
801            .conn
802            .query_row("pragma busy_timeout", [], |row| row.get(0))?;
803        Ok(timeout.max(0) as u64)
804    }
805
806    /// Return main-database allocation from SQLite metadata without scanning rows or pages.
807    pub(crate) fn storage_allocation(&self) -> Result<StorageAllocation> {
808        let (page_size, page_count, freelist_count): (i64, i64, i64) = self.conn.query_row(
809            "select (select page_size from pragma_page_size),
810                    (select page_count from pragma_page_count),
811                    (select freelist_count from pragma_freelist_count)",
812            [],
813            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
814        )?;
815        if page_size < 0 || page_count < 0 || freelist_count < 0 {
816            return Err(anyhow::anyhow!(
817                "SQLite returned negative allocation metadata: page_size={page_size}, page_count={page_count}, freelist_count={freelist_count}"
818            ));
819        }
820        let page_size = page_size as u64;
821        let total_bytes = page_size.checked_mul(page_count as u64).ok_or_else(|| {
822            anyhow::anyhow!(
823                "SQLite allocation size overflow: page_size={page_size}, page_count={page_count}"
824            )
825        })?;
826        let reclaimable_bytes = page_size
827            .checked_mul(freelist_count as u64)
828            .ok_or_else(|| {
829                anyhow::anyhow!(
830                    "SQLite reclaimable size overflow: page_size={page_size}, freelist_count={freelist_count}"
831                )
832            })?;
833        Ok(StorageAllocation {
834            total_bytes,
835            reclaimable_bytes,
836        })
837    }
838
839    pub fn with_busy_timeout_ms<T>(
840        &self,
841        busy_timeout_ms: u64,
842        f: impl FnOnce() -> Result<T>,
843    ) -> Result<T> {
844        let original = self.busy_timeout_ms()?;
845        self.set_busy_timeout_ms(busy_timeout_ms)?;
846        let result = f();
847        let restore = self.set_busy_timeout_ms(original);
848        match (result, restore) {
849            (Ok(value), Ok(())) => Ok(value),
850            (Err(err), Ok(())) => Err(err),
851            (Ok(_), Err(err)) => Err(err),
852            // Both failed: chain the restore failure onto the original error instead of
853            // discarding it, since the busy_timeout pragma may now be left at
854            // `busy_timeout_ms` rather than restored to `original`.
855            (Err(err), Err(restore_err)) => Err(err.context(format!(
856                "also failed to restore busy_timeout to its prior value ({original}ms): {restore_err:#}"
857            ))),
858        }
859    }
860
861    fn with_immediate_transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
862        self.conn.execute_batch("begin immediate")?;
863        let result = f();
864        match result {
865            Ok(value) => {
866                self.conn.execute_batch("commit")?;
867                Ok(value)
868            }
869            Err(err) => {
870                let _ = self.conn.execute_batch("rollback");
871                Err(err)
872            }
873        }
874    }
875
876    pub fn is_sqlite_busy_error(err: &anyhow::Error) -> bool {
877        err.chain().any(|source| {
878            source
879                .downcast_ref::<rusqlite::Error>()
880                .and_then(rusqlite::Error::sqlite_error_code)
881                .is_some_and(|code| {
882                    matches!(code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
883                })
884        })
885    }
886
887    pub fn auto_reindex_is_fresh(&self, interval_ms: u64) -> Result<bool> {
888        self.auto_reindex_is_fresh_at(Utc::now().timestamp_millis(), interval_ms)
889    }
890
891    fn auto_reindex_is_fresh_at(&self, now_ms: i64, interval_ms: u64) -> Result<bool> {
892        let Some((completed_ms, parser_contract)) = self.auto_reindex_freshness_metadata()? else {
893            return Ok(false);
894        };
895        Ok(elapsed_ms(now_ms, completed_ms) < interval_ms
896            && parser_contract == crate::source::provider_parse_contract_fingerprint())
897    }
898
899    pub fn mark_auto_reindex_complete(&self) -> Result<()> {
900        self.mark_auto_reindex_complete_at(Utc::now())
901    }
902
903    pub fn auto_reindex_completed_at(&self) -> Result<Option<DateTime<Utc>>> {
904        if let Some(value) = self.index_metadata_i64(AUTO_REINDEX_COMPLETED_NS_KEY)? {
905            return Ok(DateTime::from_timestamp(
906                value.div_euclid(NANOSECONDS_PER_SECOND),
907                value.rem_euclid(NANOSECONDS_PER_SECOND) as u32,
908            ));
909        }
910        Ok(self
911            .index_metadata_i64(AUTO_REINDEX_COMPLETED_MS_KEY)?
912            .and_then(|value| Utc.timestamp_millis_opt(value).single()))
913    }
914
915    pub(crate) fn mark_auto_reindex_complete_at(&self, completed_at: DateTime<Utc>) -> Result<()> {
916        let completed_ms = completed_at.timestamp_millis();
917        let completed_ns = completed_at
918            .timestamp_nanos_opt()
919            .unwrap_or_else(|| completed_ms.saturating_mul(NANOSECONDS_PER_MILLISECOND));
920        let parser_contract = crate::source::provider_parse_contract_fingerprint();
921        self.conn.execute(
922            "insert into index_metadata (key, value) values
923                 (?1, ?2), (?3, ?4), (?5, ?6), (?7, ?8), (?9, ?10), (?11, ?12)
924             on conflict(key) do update set value = excluded.value",
925            params![
926                AUTO_REINDEX_COMPLETED_MS_KEY,
927                completed_ms,
928                AUTO_REINDEX_COMPLETED_NS_KEY,
929                completed_ns,
930                AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
931                parser_contract[0],
932                AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
933                parser_contract[1],
934                AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
935                parser_contract[2],
936                AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
937                parser_contract[3],
938            ],
939        )?;
940        Ok(())
941    }
942
943    /// Read the timestamp and full parser-contract fingerprint in one bounded SQLite statement.
944    ///
945    /// Five primary-key probes are independent of corpus size: time is `O(log K)` and retained
946    /// memory is `O(1)` for the tiny metadata table of `K` keys. A missing word denotes a legacy
947    /// completion marker and deliberately invalidates freshness once after an executable upgrade.
948    fn auto_reindex_freshness_metadata(&self) -> Result<Option<(i64, [i64; 4])>> {
949        self.conn
950            .query_row(
951                AUTO_REINDEX_FRESHNESS_SQL,
952                params![
953                    AUTO_REINDEX_COMPLETED_MS_KEY,
954                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
955                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
956                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
957                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
958                ],
959                |row| {
960                    Ok((
961                        row.get(0)?,
962                        [row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?],
963                    ))
964                },
965            )
966            .optional()
967            .map_err(Into::into)
968    }
969
970    fn index_metadata_i64(&self, key: &str) -> Result<Option<i64>> {
971        self.conn
972            .query_row(
973                "select value from index_metadata where key = ?1",
974                [key],
975                |row| row.get(0),
976            )
977            .optional()
978            .map_err(Into::into)
979    }
980
981    /// Control whether reads may build persistent derived indexes lazily.
982    pub(crate) fn set_implicit_index_maintenance(&mut self, enabled: bool) {
983        self.implicit_index_maintenance = enabled;
984    }
985
986    /// Inject a sink for human-facing progress notices (e.g. the one-time lazy trigram-index build).
987    /// Lets a terminal frontend report progress without the library hardcoding stderr; leave it unset
988    /// for silent operation (the MCP server, tests). Call once after [`Db::open`].
989    pub fn set_progress_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
990        self.progress = Some(Box::new(reporter));
991    }
992
993    /// Emit a progress notice to the injected sink, if any (no-op otherwise).
994    fn report_progress(&self, message: &str) {
995        if let Some(reporter) = &self.progress {
996            reporter(message);
997        }
998    }
999
1000    fn init(&self) -> Result<()> {
1001        self.conn.execute_batch(
1002            "
1003            pragma journal_mode = wal;
1004            pragma foreign_keys = on;
1005            -- Read-perf tuning for the analytics/search workload over a multi-GB index:
1006            -- queries like `corrections` fetch thousands of message rows by rowid (the
1007            -- role/ts index doesn't cover `content`), which is random I/O across the file.
1008            -- A larger page cache + memory-mapped reads + in-memory temp store cut that
1009            -- cost; synchronous=normal is the documented-safe durability level under WAL.
1010            pragma synchronous = normal;
1011            pragma temp_store = memory;
1012            create table if not exists sessions (
1013                id text primary key,
1014                provider text not null,
1015                provider_session_id text not null,
1016                title text,
1017                summary text,
1018                cwd text,
1019                repo_root text,
1020                created_at text,
1021                updated_at text,
1022                last_message_at text,
1023                preview_text text not null,
1024                source_path text not null,
1025                message_count integer,
1026                parse_version text not null,
1027                raw_metadata_json text,
1028                parse_warning text,
1029                discovery_source text not null,
1030                parent_session_id text,
1031                agent_label text
1032            );
1033            create table if not exists transcripts (
1034                session_id text primary key references sessions(id) on delete cascade,
1035                transcript_text text not null
1036            );
1037            create table if not exists files_seen (
1038                provider text not null,
1039                source_path text not null,
1040                mtime_ns integer not null,
1041                size_bytes integer not null,
1042                last_indexed_at text not null,
1043                content_hash text,
1044                parse_version text not null default '',
1045                -- Incremental tail-parse checkpoint (§7): byte offset (at a newline boundary)
1046                -- up to which the file is parsed, and a fingerprint of the file's leading bytes
1047                -- used to detect rewrite/rotation. NULL = no checkpoint → always a full parse.
1048                tail_byte_offset integer,
1049                prefix_fingerprint text,
1050                primary key(provider, source_path)
1051            );
1052            create table if not exists index_metadata (
1053                key text primary key,
1054                value integer not null
1055            );
1056            create index if not exists idx_sessions_provider on sessions(provider);
1057            create index if not exists idx_sessions_updated_at on sessions(updated_at desc);
1058            create index if not exists idx_sessions_provider_id on sessions(provider_session_id);
1059            create table if not exists messages (
1060                id integer primary key,
1061                session_id text not null references sessions(id) on delete cascade,
1062                provider text not null,
1063                seq integer not null,
1064                role text not null,
1065                ts text,
1066                tool_name text,
1067                kind text not null default 'unknown',
1068                tool_call_id text,
1069                is_compaction integer not null default 0,
1070                content text not null,
1071                authorship text not null default 'unknown',
1072                correlation_authority text,
1073                correlation_scope text,
1074                correlation_id text,
1075                record_relation text not null default 'unknown'
1076                    check (record_relation in ('original', 'mirror', 'unknown')),
1077                check (
1078                    (correlation_authority is null and correlation_scope is null and correlation_id is null)
1079                    or
1080                    (correlation_authority is not null and correlation_scope is not null and correlation_id is not null)
1081                )
1082            );
1083            -- Bare ts index for date-range message filters that span all roles; the
1084            -- composites below lead with role/session_id and so cannot serve a bare
1085            -- `ts between ? and ?` scan.
1086            create index if not exists idx_messages_ts on messages(ts);
1087            -- Composite indexes serve the hot filter + ORDER BY combinations straight from
1088            -- the index (no temp B-tree sort) and, by leftmost-prefix, subsume a bare
1089            -- (session_id) or (role) index: (session_id, seq) covers `where session_id=?`
1090            -- [+ `order by seq`] (message search / get / context); (role, ts) covers
1091            -- `where role=?` [+ `order by ts`] (corrections / planning / stats). Older
1092            -- branch builds created standalone (session_id) and (role) indexes before these
1093            -- composites existed — drop them so every index converges on this final shape;
1094            -- they were pure write-amplification (an upstream index never had them).
1095            drop index if exists idx_messages_session;
1096            drop index if exists idx_messages_role;
1097            create index if not exists idx_messages_session_seq on messages(session_id, seq);
1098            create index if not exists idx_messages_provider_session_seq
1099                on messages(provider, session_id, seq);
1100            create index if not exists idx_messages_role_ts on messages(role, ts);
1101            create index if not exists idx_messages_tool_name
1102                on messages(tool_name) where tool_name is not null;
1103            create table if not exists file_edits (
1104                id integer primary key,
1105                session_id text not null references sessions(id) on delete cascade,
1106                provider text not null,
1107                seq integer not null,
1108                ts text,
1109                tool text not null,
1110                file_path text not null,
1111                file_name text not null,
1112                new_content text,
1113                edits_json text
1114            );
1115            create index if not exists idx_file_edits_session on file_edits(session_id);
1116            create index if not exists idx_file_edits_provider on file_edits(provider);
1117            create index if not exists idx_file_edits_path on file_edits(file_path);
1118            create index if not exists idx_file_edits_name on file_edits(file_name);
1119            ",
1120        )?;
1121        self.conn
1122            .pragma_update(None, "cache_size", -SQLITE_PAGE_CACHE_KIB)?;
1123        self.conn
1124            .pragma_update(None, "mmap_size", SQLITE_MMAP_BYTES)?;
1125        // `create table if not exists` does not add columns to a version-1 index. Additive
1126        // migration keeps legacy rows readable; `user_version < SCHEMA_VERSION` then requests
1127        // the full parser backfill that replaces their `unknown` kinds with provider evidence.
1128        for (name, definition) in [
1129            ("kind", "kind text not null default 'unknown'"),
1130            ("tool_call_id", "tool_call_id text"),
1131            ("authorship", "authorship text not null default 'unknown'"),
1132            ("correlation_authority", "correlation_authority text"),
1133            ("correlation_scope", "correlation_scope text"),
1134            ("correlation_id", "correlation_id text"),
1135            (
1136                "record_relation",
1137                "record_relation text not null default 'unknown'",
1138            ),
1139        ] {
1140            let exists: bool = self.conn.query_row(
1141                "select exists(select 1 from pragma_table_info('messages') where name = ?1)",
1142                params![name],
1143                |row| row.get(0),
1144            )?;
1145            if !exists {
1146                self.conn
1147                    .execute_batch(&format!("alter table messages add column {definition}"))?;
1148            }
1149        }
1150        self.conn.execute_batch(
1151            "drop index if exists idx_messages_authorship_role_provider_ts;
1152             create index if not exists idx_messages_tool_calls
1153                 on messages(session_id, seq) where kind = 'tool_call';
1154             create index if not exists idx_messages_provider_session_seq
1155                 on messages(provider, session_id, seq);
1156             create index if not exists idx_messages_session_authorship_relation_seq
1157                 on messages(session_id, authorship, record_relation, seq);
1158             create index if not exists idx_messages_authorship_relation_ts
1159                 on messages(authorship, record_relation, ts desc, session_id, seq);
1160             create index if not exists idx_messages_correlation
1161                 on messages(correlation_authority, correlation_scope, correlation_id)
1162                 where correlation_id is not null;
1163             create table if not exists message_content_parts (
1164                 message_id integer not null references messages(id) on delete cascade,
1165                 ordinal integer not null check (ordinal >= 0),
1166                 start_char integer not null check (start_char >= 0),
1167                 end_char integer not null check (end_char > start_char),
1168                 authorship text not null,
1169                 origin text not null,
1170                 primary key(message_id, ordinal)
1171             ) without rowid;
1172             create trigger if not exists messages_correlation_all_or_none_insert
1173             before insert on messages
1174             when ((new.correlation_authority is not null)
1175                   + (new.correlation_scope is not null)
1176                   + (new.correlation_id is not null)) not in (0, 3)
1177             begin
1178                 select raise(abort, 'message correlation authority, scope, and id must be all null or all non-null');
1179             end;
1180             create trigger if not exists messages_correlation_all_or_none_update
1181             before update of correlation_authority, correlation_scope, correlation_id on messages
1182             when ((new.correlation_authority is not null)
1183                   + (new.correlation_scope is not null)
1184                   + (new.correlation_id is not null)) not in (0, 3)
1185             begin
1186                 select raise(abort, 'message correlation authority, scope, and id must be all null or all non-null');
1187             end;",
1188        )?;
1189        // `sessions_fts` (an FTS5 copy of every title, summary, preview, and transcript) stopped
1190        // being read on 2026-07-22, when session search moved to scoring the complete corpus, and
1191        // stayed maintained: on one real index it held 464 MB (a second copy of every transcript
1192        // plus its tokens) and every session upsert re-tokenized the whole transcript into it. This
1193        // build writes nothing into it. It is retired in place rather than removed: an aise before
1194        // 1.0.0rc2 on the same machine (a pinned MCP registration, a second package manager)
1195        // requires the table in its layout check, refuses existing-only reads without it, and on a
1196        // writable open rebuilds every derived message-search index to restore it, after which
1197        // this build would remove it again. So the copy an earlier build filled is emptied once
1198        // (`SESSIONS_FTS_RETIRED_KEY`, the pages return to the freelist and `aise compact` gives
1199        // them back), an empty table is kept for that older layout check, and an older writer that
1200        // refills it is left alone.
1201        let retired: bool = self.conn.query_row(
1202            "select exists(select 1 from index_metadata where key = ?1)",
1203            params![SESSIONS_FTS_RETIRED_KEY],
1204            |row| row.get(0),
1205        )?;
1206        if !retired {
1207            self.conn
1208                .execute_batch("drop table if exists sessions_fts")?;
1209            self.conn.execute(
1210                "insert into index_metadata (key, value) values (?1, 1)
1211                 on conflict(key) do update set value = excluded.value",
1212                params![SESSIONS_FTS_RETIRED_KEY],
1213            )?;
1214        }
1215        self.conn.execute_batch(
1216            "create virtual table if not exists sessions_fts using fts5(
1217                title, summary, preview_text, transcript_text
1218            )",
1219        )?;
1220        // External-content FTS over message bodies. Message search no longer lets FTS tokenization
1221        // define literal semantics, but this index is still kept current for vocabulary,
1222        // compatibility, and any future explicit word-search surface. The insert/delete/update
1223        // triggers are the full canonical FTS5 external-content set, written in the
1224        // `'delete'`-command form that AFTER triggers require (it passes the OLD content so
1225        // the right tokens are removed; a plain `delete from` here risks index corruption).
1226        // So every mutation path stays in sync: the delete+reinsert reindex path
1227        // (upsert_session) and any future in-place `update messages set content = ...`.
1228        crate::fts::install_released_message_word_index(&self.conn)?;
1229        // Backfill messages_fts if messages exist but its index is empty — e.g. an index
1230        // file from before messages_fts existed, or one whose FTS shadow was cleared. FTS5
1231        // triggers only maintain the index for mutations made AFTER they exist, so the
1232        // `'rebuild'` command is the canonical way to (re)populate an external-content index
1233        // from its content table. Without this, message search (which queries messages_fts)
1234        // would silently return nothing for such an index.
1235        //
1236        // NOTE: `count(*) from messages_fts` reflects the external CONTENT table (messages),
1237        // not the index, so it can't detect an empty index. The `_docsize` shadow holds one
1238        // row per INDEXED document, so its count is the true index population.
1239        // Initialization needs only empty/non-empty state. EXISTS stops after the first row;
1240        // COUNT(*) scanned 1.49M rows plus both FTS populations on every CLI/MCP/Python open.
1241        let messages_exist: bool =
1242            self.conn
1243                .query_row("select exists(select 1 from messages)", [], |row| {
1244                    row.get(0)
1245                })?;
1246        // The class index is free while the table is empty. On a populated index that lacks it
1247        // (written by an older aise) the build is one full scan under the write lock, so it is
1248        // left to the elected refresh writer rather than paid inside every query process's open.
1249        if !messages_exist {
1250            self.ensure_message_kind_index()?;
1251        }
1252        // A brand-new empty database has no incompatible data to migrate. Install the current
1253        // layout immediately so its first indexing pass maintains both FTS indexes atomically.
1254        // Existing schema-0 databases with messages still take the parser-backfill/v3 path first.
1255        if self.schema_version()? == 0 && !messages_exist {
1256            // Install the target layout and stamp the version ATOMICALLY. `user_version` is a
1257            // transactional header field, so a single transaction makes "create the FTS5 message
1258            // objects" and "declare this database current" all-or-nothing: a crash can never commit
1259            // the current stamp over a partially-built layout — the exact v4-stamped-but-missing-
1260            // trigram hybrid that the self-heal path (see `SchemaState::RepairableLayout`) exists to
1261            // repair. Ordering alone previously kept this safe; the transaction also protects any
1262            // future reordering.
1263            let tx = self.conn.unchecked_transaction()?;
1264            crate::fts::install_target_message_search_indexes(&tx)?;
1265            tx.execute_batch(&format!("pragma user_version = {SCHEMA_VERSION}"))?;
1266            tx.commit()?;
1267        }
1268        let indexed_messages_exist: bool = self.conn.query_row(
1269            "select exists(select 1 from messages_fts_docsize)",
1270            [],
1271            |row| row.get(0),
1272        )?;
1273        if messages_exist && !indexed_messages_exist {
1274            self.conn
1275                .execute_batch("insert into messages_fts(messages_fts) values('rebuild')")?;
1276        }
1277        // Literal/regex PREFILTER over message content (the Google Code Search trigram technique):
1278        // turns substring and regex-literal anchors into indexed candidate queries that exact
1279        // literal or Rust regex verification checks afterward. This is the custom, parallel-built
1280        // [`crate::trigram_index`] — NOT an FTS5 virtual table — because FTS5's trigram tokenizer
1281        // builds single-threaded inside the one SQLite writer, which is ~80% of a cold build
1282        // (measured ~145 s for 1.8 GB of content). The custom index tokenizes with Rayon and
1283        // bulk-loads compact delta-varint postings: ~5x faster build, same on-disk size, sub-3 ms
1284        // candidate queries. It is built LAZILY on first eligible message content search
1285        // ([`Db::ensure_trigram_base`]), so `reindex` does NO trigram work and
1286        // `list`/`show`/`paths`/`resume` never pay for it.
1287        if self.schema_version()? < 4 {
1288            crate::trigram_index::ensure_schema(&self.conn)?;
1289            // No released schema before v4 owns an FTS5 messages_trigram table. Remove abandoned
1290            // development copies so the v3 custom index remains the sole compatibility path.
1291            self.conn.execute_batch(
1292                "drop trigger if exists messages_tri_ai;
1293                 drop trigger if exists messages_tri_ad;
1294                 drop trigger if exists messages_tri_au;
1295                 drop table if exists messages_trigram_terms;
1296                 drop table if exists messages_trigram_vocab;
1297                 drop table if exists messages_trigram;",
1298            )?;
1299        }
1300        // Zero-storage word-term-frequency view (fts5vocab 'row' → term,doc,cnt) for `vocab`.
1301        // (Trigram vocab is served from the custom index's `trigram_postings.df` column instead.)
1302        self.conn.execute_batch(
1303            "create virtual table if not exists messages_vocab
1304                 using fts5vocab('messages_fts', 'row');",
1305        )?;
1306        // Self-enforce the v4 message-search layout on this writable open. `install_released_message_
1307        // word_index` above uses `create trigger if not exists`, so on a database stamped current
1308        // whose dual `messages_fts`+`messages_trigram` triggers were dropped (or whose FTS5 trigram
1309        // tables are missing) it would otherwise silently leave word-only triggers and stop
1310        // maintaining `messages_trigram`, making substring/fuzzy search return incomplete results
1311        // with no error. Rebuilding from the intact `messages` rows here means a direct `Db::open`
1312        // (embedder path) — not just `SessionSearch::open`'s coordinator — cannot leave a v4 index
1313        // half-maintained. Only fires when the base rows a rebuild would read are present; the
1314        // rebuild is idempotent, so a consistent layout costs one schema scan and no rebuild.
1315        let schema_version = self.schema_version()?;
1316        let message_search_layout_problem = match schema_version {
1317            4 => crate::indexer::message_search_layout_problem(&self.conn)?,
1318            SCHEMA_VERSION => crate::indexer::current_schema_layout_problem(&self.conn)?,
1319            _ => None,
1320        };
1321        if message_search_layout_problem.is_some() && crate::indexer::base_data_intact(&self.conn)?
1322        {
1323            self.migrate_message_search_schema_exclusive()?;
1324        }
1325        // Evolve an existing `files_seen` (a rebuildable cache) to carry the tail-parse
1326        // checkpoint columns. `create table if not exists` won't add columns to a table that
1327        // already exists, so add them idempotently; NULL on existing rows means "no checkpoint"
1328        // which the indexer treats as a full parse — always safe.
1329        self.ensure_column("files_seen", "tail_byte_offset", "tail_byte_offset integer")?;
1330        self.ensure_column(
1331            "files_seen",
1332            "prefix_fingerprint",
1333            "prefix_fingerprint text",
1334        )?;
1335        let parse_version_added = self.ensure_column(
1336            "files_seen",
1337            "parse_version",
1338            "parse_version text not null default ''",
1339        )?;
1340        if parse_version_added {
1341            self.backfill_source_parse_versions()?;
1342        }
1343        // Same reasoning for the subagent-origin columns on an existing `sessions` table. NULL
1344        // on existing rows means "not known to be a subagent run", which is what every row
1345        // indexed before these columns existed truthfully was. Rows gain real values on the
1346        // next reindex, so no backfill is possible or needed: the fact lives in the transcript.
1347        self.ensure_column("sessions", "parent_session_id", "parent_session_id text")?;
1348        self.ensure_column("sessions", "agent_label", "agent_label text")?;
1349        Ok(())
1350    }
1351
1352    /// Add `column_decl` to `table` if the column is not already present (idempotent
1353    /// schema evolution). Used for the `files_seen` cache columns; a no-op once the
1354    /// column exists, so it is safe to call on every `open`.
1355    fn ensure_column(&self, table: &str, column: &str, column_decl: &str) -> Result<bool> {
1356        let present = self
1357            .conn
1358            .prepare(&format!("pragma table_info({table})"))?
1359            .query_map([], |row| row.get::<_, String>(1))?
1360            .filter_map(Result::ok)
1361            .any(|name| name == column);
1362        if !present {
1363            self.conn
1364                .execute_batch(&format!("alter table {table} add column {column_decl}"))?;
1365        }
1366        Ok(!present)
1367    }
1368
1369    fn backfill_source_parse_versions(&self) -> Result<()> {
1370        self.conn.execute_batch(
1371            "with source_versions as (
1372                 select provider, source_path, min(parse_version) as parse_version
1373                 from sessions
1374                 group by provider, source_path
1375                 having count(distinct parse_version) = 1
1376             )
1377             update files_seen
1378             set parse_version = coalesce((
1379                 select source_versions.parse_version
1380                 from source_versions
1381                 where source_versions.provider = files_seen.provider
1382                   and source_versions.source_path = files_seen.source_path
1383             ), '')
1384             where parse_version = ''",
1385        )?;
1386        Ok(())
1387    }
1388
1389    /// Ensure the custom [`crate::trigram_index`] base is built and current enough to serve as the
1390    /// regex/substring prefilter, returning its `base_max_id`. Builds (in parallel) when the base
1391    /// is empty or the un-indexed delta (`max(id) - base_max`) exceeds
1392    /// `TRIGRAM_BASE_REBUILD_DELTA`; otherwise the recent delta is left for the caller to
1393    /// direct-scan (`id > base_max`). This makes the one-time parallel build lazy — paid on first
1394    /// regex use, never by `list`/`show`/`paths`/`resume` — and keeps incremental reindex free of
1395    /// trigram work (no triggers): new messages just accumulate in the delta until a rebuild.
1396    pub(crate) fn ensure_trigram_base(&self) -> Result<i64> {
1397        anyhow::ensure!(
1398            self.schema_version()? < 4,
1399            "custom trigram maintenance is unavailable on schema v4; SQLite FTS5 maintains messages_trigram incrementally"
1400        );
1401        if !crate::trigram_index::schema_is_compatible(&self.conn)? {
1402            // trigram_postings/trigram_meta are entirely derived from `messages` (like the
1403            // FTS5 prefilter they replace on schema v4+), so an incompatible derived-table shape
1404            // is safe to rebuild. Do not infer corruption from an arbitrary base_max_id error:
1405            // lock, I/O, and database-level failures must propagate without dropping anything.
1406            anyhow::ensure!(
1407                self.implicit_index_maintenance,
1408                "substring/regex search index has an incompatible schema and automatic \
1409                 maintenance is disabled; rerun without --index-refresh existing-only to rebuild it"
1410            );
1411            return self
1412                .rebuild_trigram_base_with_writer_lock()
1413                .map(|rebuild| rebuild.base_max);
1414        }
1415        let base_max = crate::trigram_index::base_max_id(&self.conn)?;
1416        if !self.implicit_index_maintenance {
1417            return Ok(base_max);
1418        }
1419        let max_id: i64 =
1420            self.conn
1421                .query_row("select coalesce(max(id), 0) from messages", [], |row| {
1422                    row.get(0)
1423                })?;
1424        if (base_max == 0 && max_id > 0) || (max_id - base_max) > self.trigram_rebuild_delta {
1425            return match self.rebuild_trigram_base_with_writer_lock() {
1426                Ok(rebuild) => Ok(rebuild.base_max),
1427                Err(err) if Self::is_sqlite_busy_error(&err) => {
1428                    self.report_progress(
1429                        "substring/regex search index is already being updated; scanning the unindexed delta directly",
1430                    );
1431                    Ok(base_max)
1432                }
1433                Err(err) => Err(err),
1434            };
1435        }
1436        Ok(base_max)
1437    }
1438
1439    fn rebuild_trigram_base_with_writer_lock(&self) -> Result<TrigramRebuild> {
1440        self.with_immediate_transaction(|| {
1441            let schema_compatible = crate::trigram_index::schema_is_compatible(&self.conn)?;
1442            if !schema_compatible {
1443                self.report_progress(
1444                    "substring/regex search index had an incompatible schema; rebuilding it from the message table",
1445                );
1446                self.conn.execute_batch(
1447                    "drop table if exists trigram_postings; drop table if exists trigram_meta;",
1448                )?;
1449                crate::trigram_index::ensure_schema(&self.conn)?;
1450            }
1451            let base_max = crate::trigram_index::base_max_id(&self.conn)?;
1452            let max_id: i64 =
1453                self.conn
1454                    .query_row("select coalesce(max(id), 0) from messages", [], |row| {
1455                        row.get(0)
1456                    })?;
1457            if schema_compatible
1458                && !((base_max == 0 && max_id > 0)
1459                    || (max_id - base_max) > self.trigram_rebuild_delta)
1460            {
1461                return Ok(TrigramRebuild {
1462                    base_max,
1463                    rebuilt: false,
1464                });
1465            }
1466
1467            // The one-time parallel build can take tens of seconds on a large corpus; notify via
1468            // the injected progress sink (the CLI prints it; the MCP server stays silent) so a first
1469            // regex/substring search isn't an unexplained pause. Holding BEGIN IMMEDIATE here is a
1470            // deliberate maintenance lock: readers keep working in WAL mode, while competing
1471            // writers/builders wait or fall back according to their configured busy timeout.
1472            let count: i64 = self
1473                .conn
1474                .query_row("select count(*) from messages", [], |row| row.get(0))?;
1475            self.report_progress(&format!(
1476                "building substring/regex search index in parallel (one-time over {count} messages)…"
1477            ));
1478            let base_max = crate::trigram_index::build_in_current_transaction(
1479                &self.conn,
1480                &self.runtime,
1481            )?;
1482            Ok(TrigramRebuild {
1483                base_max,
1484                rebuilt: true,
1485            })
1486        })
1487        .and_then(|rebuild| {
1488            if rebuild.rebuilt {
1489                // Fold the large build out of the WAL so the -wal file doesn't retain the index size.
1490                self.checkpoint_truncate()?;
1491            }
1492            Ok(rebuild)
1493        })
1494    }
1495
1496    /// Stage a regex prefilter's candidate row ids into the per-connection temp table
1497    /// `_trigram_cand`: the base candidates (`id <= base_max`) PLUS the un-indexed delta
1498    /// (`id > base_max`), which the caller's Rust regex then re-verifies. The caller joins
1499    /// `_trigram_cand` to restrict the scan. Temp tables are per-connection, so this is safe for
1500    /// the one-connection-per-command CLI and the single-connection MCP server.
1501    fn stage_candidates(
1502        &self,
1503        base_max: i64,
1504        candidates: &std::collections::HashSet<i64>,
1505    ) -> Result<()> {
1506        self.conn.execute_batch(
1507            "create temp table if not exists _trigram_cand (id integer primary key);
1508             delete from _trigram_cand;",
1509        )?;
1510        // Insert all candidates in ONE transaction: an unselective pattern can yield tens of
1511        // thousands of ids, and per-statement auto-commits would add needless overhead. The temp
1512        // table lives in memory (temp_store=memory), so this is a single in-memory batch.
1513        let tx = self.conn.unchecked_transaction()?;
1514        {
1515            let mut stmt = tx.prepare("insert or ignore into _trigram_cand(id) values (?1)")?;
1516            for id in candidates {
1517                stmt.execute([id])?;
1518            }
1519        }
1520        tx.execute(
1521            "insert or ignore into _trigram_cand(id) select id from messages where id > ?1",
1522            [base_max],
1523        )?;
1524        tx.commit()?;
1525        Ok(())
1526    }
1527
1528    /// Run a TRUNCATE WAL checkpoint: fold the write-ahead log back into the main database file
1529    /// and shrink `-wal` to zero. Cheap when the WAL is small (a no-op-ish), worth calling after
1530    /// large writes (the trigram rebuild, a big reindex) so the `-wal` file does not accumulate
1531    /// gigabytes. Best-effort: a concurrent reader can leave it partial, which is harmless.
1532    pub fn checkpoint_truncate(&self) -> Result<()> {
1533        // `wal_checkpoint` returns a row (busy, log, checkpointed); ignore it.
1534        self.conn
1535            .query_row("pragma wal_checkpoint(truncate)", [], |_| Ok(()))
1536            .or_else(|err| match err {
1537                rusqlite::Error::QueryReturnedNoRows => Ok(()),
1538                other => Err(other),
1539            })?;
1540        Ok(())
1541    }
1542
1543    #[cfg(test)]
1544    pub(crate) fn execute_batch_for_test(&self, sql: &str) -> Result<()> {
1545        self.conn.execute_batch(sql)?;
1546        Ok(())
1547    }
1548
1549    #[cfg(test)]
1550    pub(crate) fn wal_checkpoint_busy_for_test(&self) -> Result<i64> {
1551        self.conn
1552            .query_row("pragma wal_checkpoint(passive)", [], |row| row.get(0))
1553            .map_err(Into::into)
1554    }
1555
1556    /// Merge each FTS5 index's b-tree segments into one (the `'optimize'` command). A full reindex
1557    /// deletes and reinserts every message, which leaves `messages_fts` with many unmerged segments
1558    /// — measured to roughly DOUBLE its on-disk size (≈1.0 GB → ≈2.0 GB on a 637k-message corpus)
1559    /// and to slow queries. `'optimize'` merges them, freeing the redundant pages (reused by later
1560    /// writes, or returned to the OS by a one-time `VACUUM`). Call ONLY after a full reindex: it
1561    /// rewrites the whole index, so it must never run on the per-command incremental path. The cost
1562    /// is in `messages_fts`, amortized over a rare full rebuild.
1563    pub fn optimize_fts(&self) -> Result<()> {
1564        self.conn
1565            .execute_batch("insert into messages_fts(messages_fts) values('optimize');")?;
1566        if self.schema_version()? >= 4 {
1567            self.conn.execute_batch(
1568                "insert into messages_trigram(messages_trigram) values('optimize');",
1569            )?;
1570        }
1571        Ok(())
1572    }
1573
1574    /// Reclaim free pages to the OS by rewriting the database file (`VACUUM`). Run AFTER
1575    /// [`Db::optimize_fts`]: VACUUM repacks page bytes but does NOT merge FTS5 segments, so optimize
1576    /// must logically compact the index first (the documented OPTIMIZE → VACUUM order). VACUUM takes
1577    /// an exclusive lock and needs up to ~2x the database size in free disk while it runs, and cannot
1578    /// run inside a transaction — `execute_batch` runs it in autocommit.
1579    pub fn vacuum(&self) -> Result<()> {
1580        self.conn.execute_batch("vacuum")?;
1581        Ok(())
1582    }
1583
1584    /// True when the on-disk `user_version` is behind [`SCHEMA_VERSION`], i.e. a new
1585    /// schema generation has shipped and a one-time full reindex is needed to backfill
1586    /// new tables/columns (the old rows were skipped by incremental indexing).
1587    pub fn needs_backfill(&self) -> Result<bool> {
1588        Ok(self.schema_version()? < PARSER_SCHEMA_VERSION)
1589    }
1590
1591    /// True when the current binary can query this schema correctly before parser-derived rows
1592    /// are refreshed. Future schema generations fail closed because compatibility is unknown.
1593    pub fn schema_is_readable(&self) -> Result<bool> {
1594        Ok(match self.schema_state()? {
1595            SchemaState::Current => true,
1596            SchemaState::Older { current, .. } => current >= MIN_READABLE_SCHEMA_VERSION,
1597            SchemaState::Missing
1598            | SchemaState::Newer { .. }
1599            | SchemaState::UnknownProviders { .. }
1600            | SchemaState::RepairableLayout { .. }
1601            | SchemaState::RecoveryRequired { .. } => false,
1602        })
1603    }
1604
1605    pub(crate) fn schema_state(&self) -> Result<SchemaState> {
1606        match SchemaState::from_version(self.schema_version()?) {
1607            SchemaState::Current => {
1608                let providers = unknown_index_providers(&self.conn)?;
1609                Ok(if providers.is_empty() {
1610                    SchemaState::Current
1611                } else {
1612                    SchemaState::UnknownProviders { providers }
1613                })
1614            }
1615            other => Ok(other),
1616        }
1617    }
1618
1619    pub fn schema_version(&self) -> Result<i64> {
1620        self.conn
1621            .query_row("pragma user_version", [], |row| row.get(0))
1622            .map_err(Into::into)
1623    }
1624
1625    /// Promote the message-search layout on this already-elected writer connection. The caller
1626    /// must hold the cross-process maintenance permit for the complete full-reindex + migration
1627    /// operation; SQLite independently requires an exclusive journal transition and restores WAL.
1628    pub(crate) fn migrate_message_search_schema_exclusive(&self) -> Result<()> {
1629        let schema_version = self.schema_version()?;
1630        // Idempotent: if the database already owns the current layout there is nothing to rebuild.
1631        // This lets `init()` self-enforce a broken v4 open while a caller that ALSO requests the heal
1632        // (SessionSearch::open's coordinator) becomes a cheap no-op instead of a second full rebuild.
1633        if schema_version == SCHEMA_VERSION
1634            && crate::indexer::current_schema_layout_problem(&self.conn)?.is_none()
1635        {
1636            return Ok(());
1637        }
1638        if schema_version == 4
1639            && crate::indexer::message_search_layout_problem(&self.conn)?.is_none()
1640        {
1641            return Ok(());
1642        }
1643        // Notify via the injected progress sink (the CLI prints it; the MCP server and tests stay
1644        // silent) so a one-time in-place rebuild of the message-search indexes isn't an unexplained
1645        // pause. Mirrors the lazy trigram-base rebuild notice in
1646        // [`Db::rebuild_trigram_base_with_writer_lock`].
1647        let count: i64 = self
1648            .conn
1649            .query_row("select count(*) from messages", [], |row| row.get(0))?;
1650        self.report_progress(&format!(
1651            "rebuilding message-search indexes in place (one-time over {count} messages)…"
1652        ));
1653        // Repairing a v4 hybrid rebuilds only its derived FTS objects and preserves the v4 stamp:
1654        // source rows have not yet been reparsed, so promoting them would falsely claim schema-5
1655        // provenance. A full reindex first calls `mark_schema_current`, making this target 5.
1656        let target_version = if schema_version == 4 {
1657            4
1658        } else {
1659            SCHEMA_VERSION
1660        };
1661        crate::fts::migrate_message_search_schema_offline(&self.conn, target_version)
1662    }
1663
1664    /// Stamp parser-derived rows after a full reindex without bypassing the schema-4 FTS gate.
1665    ///
1666    /// A database already at v4 owns the released FTS layout, so reparsing can safely promote it
1667    /// to schema 5. A pre-v4 database stops at parser generation 3; the caller must then run
1668    /// `Self::migrate_message_search_schema_exclusive`, which atomically builds the derived FTS
1669    /// objects and stamps schema 5. This two-stage rule prevents a current stamp over missing
1670    /// trigram objects.
1671    pub fn mark_schema_current(&self) -> Result<()> {
1672        let target = if self.schema_version()? >= 4 {
1673            PARSER_SCHEMA_VERSION
1674        } else {
1675            PRE_FTS_PARSER_SCHEMA_VERSION
1676        };
1677        self.conn
1678            .execute_batch(&format!("pragma user_version = {target}"))?;
1679        Ok(())
1680    }
1681
1682    /// Explicit, total wipe of all indexed data. NOT used by [`crate::indexer::reindex`],
1683    /// which is a durable archive (it never deletes sessions whose source files were
1684    /// removed). This is the deliberate "start over" reset for embedders / corruption
1685    /// recovery; the user-facing equivalent is deleting the index file.
1686    pub fn clear_all(&self) -> Result<()> {
1687        // One transaction so an interruption (or a failing statement) leaves the index either fully
1688        // cleared or fully intact — never a logically inconsistent mix such as sessions carrying a
1689        // stale message_count while their messages have already been deleted.
1690        self.with_immediate_transaction(|| {
1691            self.conn.execute_batch(
1692                "
1693                delete from transcripts;
1694                delete from messages;
1695                delete from file_edits;
1696                delete from sessions;
1697                delete from files_seen;
1698                ",
1699            )?;
1700            Ok(())
1701        })
1702    }
1703
1704    pub(crate) fn clear_trigram_base(&self) -> Result<()> {
1705        if self.schema_version()? >= 4 {
1706            return Ok(());
1707        }
1708        self.conn.execute_batch(
1709            "
1710            delete from trigram_postings;
1711            delete from trigram_meta;
1712            ",
1713        )?;
1714        Ok(())
1715    }
1716
1717    pub fn is_file_current(
1718        &self,
1719        provider: Provider,
1720        path: &str,
1721        mtime_ns: i64,
1722        size: i64,
1723        parse_version: &str,
1724    ) -> Result<bool> {
1725        Ok(
1726            self.source_file_refresh_state(provider, path, mtime_ns, size, parse_version)?
1727                == SourceFileRefreshState::Current,
1728        )
1729    }
1730
1731    /// Resolve freshness and parser compatibility from one indexed row read.
1732    ///
1733    /// A changed parser must replace the stored prefix even if a grown file otherwise satisfies
1734    /// the append-only boundary check. One typed result drives both the skip and replacement
1735    /// decisions, avoiding a time-of-check/time-of-use seam between two database reads. Lookup is
1736    /// `O(log F)` through the `files_seen` primary key and retains one row.
1737    pub(crate) fn source_file_refresh_state(
1738        &self,
1739        provider: Provider,
1740        path: &str,
1741        mtime_ns: i64,
1742        size: i64,
1743        parse_version: &str,
1744    ) -> Result<SourceFileRefreshState> {
1745        Ok(match self.file_seen_state(provider, path)? {
1746            None => SourceFileRefreshState::New,
1747            Some((_, _, stored_version)) if stored_version != parse_version => {
1748                SourceFileRefreshState::ChangedParser
1749            }
1750            Some((stored_mtime, stored_size, _))
1751                if stored_mtime == mtime_ns && stored_size == size =>
1752            {
1753                SourceFileRefreshState::Current
1754            }
1755            Some(_) => SourceFileRefreshState::ChangedSameParser,
1756        })
1757    }
1758
1759    fn file_seen_state(
1760        &self,
1761        provider: Provider,
1762        path: &str,
1763    ) -> Result<Option<(i64, i64, String)>> {
1764        self.conn
1765            .query_row(
1766                "select mtime_ns, size_bytes, parse_version from files_seen
1767                 where provider = ?1 and source_path = ?2",
1768                params![provider.as_str(), path],
1769                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1770            )
1771            .optional()
1772            .map_err(Into::into)
1773    }
1774
1775    pub(crate) fn indexed_source_identities(
1776        &self,
1777    ) -> Result<Vec<(Provider, String, usize, String)>> {
1778        let mut stmt = self.conn.prepare(
1779            "select provider, source_path, count(*), min(id) from sessions
1780             group by provider, source_path",
1781        )?;
1782        let rows = stmt
1783            .query_map([], |row| {
1784                Ok((
1785                    decode_db_enum(
1786                        row.get::<_, String>(0)?,
1787                        0,
1788                        "sessions.provider",
1789                        Provider::from_db_str,
1790                    )?,
1791                    row.get::<_, String>(1)?,
1792                    row.get::<_, i64>(2)? as usize,
1793                    row.get::<_, String>(3)?,
1794                ))
1795            })?
1796            .collect::<rusqlite::Result<Vec<_>>>()?;
1797        Ok(rows)
1798    }
1799
1800    pub(crate) fn exact_session_source_path(&self, id: &str) -> Result<Option<String>> {
1801        self.conn
1802            .query_row(
1803                "select source_path from sessions where id = ?1",
1804                params![id],
1805                |row| row.get(0),
1806            )
1807            .optional()
1808            .map_err(Into::into)
1809    }
1810
1811    pub fn upsert_session(
1812        &self,
1813        parsed: &ParsedSession,
1814        mtime_ns: i64,
1815        size_bytes: i64,
1816    ) -> Result<()> {
1817        self.upsert_session_with_mode(parsed, mtime_ns, size_bytes, true, &[])
1818    }
1819
1820    /// Persist a fully re-parsed session and force message/file rows to be replaced, even
1821    /// when the new parse appears to be an append-only growth of the old rows. Use this
1822    /// for explicit full reindex/backfill paths so parser/schema fixes repair existing
1823    /// rows instead of preserving a stale prefix for performance.
1824    pub fn replace_session(
1825        &self,
1826        parsed: &ParsedSession,
1827        mtime_ns: i64,
1828        size_bytes: i64,
1829    ) -> Result<()> {
1830        self.upsert_session_with_mode(parsed, mtime_ns, size_bytes, false, &[])
1831    }
1832
1833    pub(crate) fn upsert_session_reconciling_sources(
1834        &self,
1835        parsed: &ParsedSession,
1836        mtime_ns: i64,
1837        size_bytes: i64,
1838        source_aliases: &[String],
1839        allow_append_optimization: bool,
1840    ) -> Result<()> {
1841        self.upsert_session_with_mode(
1842            parsed,
1843            mtime_ns,
1844            size_bytes,
1845            allow_append_optimization,
1846            source_aliases,
1847        )
1848    }
1849
1850    fn upsert_session_with_mode(
1851        &self,
1852        parsed: &ParsedSession,
1853        mtime_ns: i64,
1854        size_bytes: i64,
1855        allow_append_optimization: bool,
1856        source_aliases: &[String],
1857    ) -> Result<()> {
1858        let tx = self.conn.unchecked_transaction()?;
1859        let session = &parsed.session;
1860        // A complete source parse represents one canonical session. Parser improvements or
1861        // mutable sidecar metadata can change the provider session ID; remove prior identities
1862        // for this provider/source in the same transaction before publishing the replacement.
1863        // Preserve unrelated providers even if they happen to name the same filesystem path.
1864        for alias in source_aliases
1865            .iter()
1866            .filter(|alias| alias.as_str() != session.source_path)
1867        {
1868            tx.execute(
1869                "delete from sessions where provider = ?1 and source_path = ?2",
1870                params![session.provider.as_str(), alias],
1871            )?;
1872            tx.execute(
1873                "delete from files_seen where provider = ?1 and source_path = ?2",
1874                params![session.provider.as_str(), alias],
1875            )?;
1876        }
1877        tx.execute(
1878            "delete from sessions
1879             where provider = ?1 and source_path = ?2 and id != ?3",
1880            params![session.provider.as_str(), session.source_path, session.id],
1881        )?;
1882        tx.execute(
1883            "
1884            insert into sessions (
1885                id, provider, provider_session_id, title, summary, cwd, repo_root, created_at,
1886                updated_at, last_message_at, preview_text, source_path, message_count, parse_version,
1887                raw_metadata_json, parse_warning, discovery_source, parent_session_id, agent_label
1888            ) values (
1889                ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
1890                ?9, ?10, ?11, ?12, ?13, ?14,
1891                ?15, ?16, ?17, ?18, ?19
1892            )
1893            on conflict(id) do update set
1894                provider = excluded.provider,
1895                provider_session_id = excluded.provider_session_id,
1896                title = excluded.title,
1897                summary = excluded.summary,
1898                cwd = excluded.cwd,
1899                repo_root = excluded.repo_root,
1900                created_at = excluded.created_at,
1901                updated_at = excluded.updated_at,
1902                last_message_at = excluded.last_message_at,
1903                preview_text = excluded.preview_text,
1904                source_path = excluded.source_path,
1905                message_count = excluded.message_count,
1906                parse_version = excluded.parse_version,
1907                raw_metadata_json = excluded.raw_metadata_json,
1908                parse_warning = excluded.parse_warning,
1909                discovery_source = excluded.discovery_source,
1910                parent_session_id = excluded.parent_session_id,
1911                agent_label = excluded.agent_label
1912            ",
1913            params![
1914                session.id,
1915                session.provider.as_str(),
1916                session.provider_session_id,
1917                session.title,
1918                session.summary,
1919                session.cwd,
1920                session.repo_root,
1921                session.created_at.map(|value| value.to_rfc3339()),
1922                session.updated_at.map(|value| value.to_rfc3339()),
1923                session.last_message_at.map(|value| value.to_rfc3339()),
1924                session.preview_text,
1925                session.source_path,
1926                session.message_count,
1927                session.parse_version,
1928                session.raw_metadata_json,
1929                session.parse_warning,
1930                session.discovery_source,
1931                session.parent_session_id,
1932                session.agent_label,
1933            ],
1934        )?;
1935        tx.execute(
1936            "
1937            insert into transcripts (session_id, transcript_text)
1938            values (?1, ?2)
1939            on conflict(session_id) do update set transcript_text = excluded.transcript_text
1940            ",
1941            params![session.id, parsed.transcript_text],
1942        )?;
1943        tx.execute(
1944            "
1945            insert into files_seen (
1946                provider, source_path, mtime_ns, size_bytes, last_indexed_at,
1947                content_hash, parse_version
1948            )
1949            values (?1, ?2, ?3, ?4, ?5, null, ?6)
1950            on conflict(provider, source_path) do update set
1951                mtime_ns = excluded.mtime_ns,
1952                size_bytes = excluded.size_bytes,
1953                last_indexed_at = excluded.last_indexed_at,
1954                parse_version = excluded.parse_version
1955            ",
1956            params![
1957                session.provider.as_str(),
1958                session.source_path,
1959                mtime_ns,
1960                size_bytes,
1961                Utc::now().to_rfc3339(),
1962                session.parse_version,
1963            ],
1964        )?;
1965        // Re-sync per-message rows. Session logs are APPEND-ONLY, so when a re-parse only GREW
1966        // the message list and the existing rows are an unchanged prefix, insert just the new
1967        // tail instead of deleting and re-inserting the whole session. Re-inserting every message
1968        // also re-runs the messages_fts triggers over the entire session, so a
1969        // delete+insert re-indexed multi-hundred-MB sessions on EVERY incremental reindex — the
1970        // dominant reindex cost. The boundary check (the last existing message still matches the
1971        // parse at that seq) guards against in-place rewrites; on any mismatch or shrink we fall
1972        // back to a full replace. Messages carry seq = parse index, so the appended tail's seqs
1973        // never collide with the retained prefix.
1974        let existing_count: i64 = tx.query_row(
1975            "select count(*) from messages where session_id = ?1",
1976            params![session.id],
1977            |row| row.get(0),
1978        )?;
1979        let parsed_count = parsed.messages.len() as i64;
1980        let append_from: Option<usize> =
1981            if allow_append_optimization && existing_count > 0 && parsed_count > existing_count {
1982                let boundary = &parsed.messages[(existing_count - 1) as usize];
1983                let existing_boundary: Option<String> = tx
1984                    .query_row(
1985                        "select content from messages where session_id = ?1 and seq = ?2",
1986                        params![session.id, boundary.seq],
1987                        |row| row.get(0),
1988                    )
1989                    .optional()?;
1990                (existing_boundary.as_deref() == Some(boundary.content.as_str()))
1991                    .then_some(existing_count as usize)
1992            } else {
1993                None
1994            };
1995        let new_messages = match append_from {
1996            Some(start) => &parsed.messages[start..],
1997            None => {
1998                tx.execute(
1999                    "delete from messages where session_id = ?1",
2000                    params![session.id],
2001                )?;
2002                &parsed.messages[..]
2003            }
2004        };
2005        // Persist messages with their parse-order seq (0..N).
2006        insert_messages(&tx, session, new_messages.iter().map(|m| (m.seq, m)))?;
2007        // Re-sync file-edit rows (idempotent, same as messages). `edits` are stored as a
2008        // JSON array of [old, new] pairs; `new_content` holds full content for Write only.
2009        tx.execute(
2010            "delete from file_edits where session_id = ?1",
2011            params![session.id],
2012        )?;
2013        insert_file_edits(&tx, session, parsed.file_edits.iter().map(|e| (e.seq, e)))?;
2014        tx.commit()?;
2015        Ok(())
2016    }
2017
2018    /// Delete `user`-role messages that are harness-injected output, not prompts — content
2019    /// leading with `<local-command-stdout>` / `-stderr` / `-caveat` (claude) or
2020    /// `<environment_context>` / `<turn_aborted>` (codex). The current parser already excludes
2021    /// these from re-parsed files, but sessions whose source file was deleted are never re-visited
2022    /// (durable archive), so their already-indexed injected rows persist; this one-time data purge
2023    /// reaches them. Returns the number of rows deleted. The `messages_fts` delete trigger keeps the
2024    /// word index in sync; the custom trigram base is rebuilt lazily on next use. Run during the
2025    /// schema migration (see indexer.rs).
2026    pub fn purge_injected_messages(&self) -> Result<usize> {
2027        let deleted = self.conn.execute(
2028            "delete from messages where role = 'user' and (\
2029                 content like '<local-command-stdout>%' \
2030              or content like '<local-command-stderr>%' \
2031              or content like '<local-command-caveat>%' \
2032              or content like '<environment_context>%' \
2033              or content like '<turn_aborted>%')",
2034            [],
2035        )?;
2036        Ok(deleted)
2037    }
2038
2039    /// The stored incremental tail-parse checkpoint for a file, only when its parser version
2040    /// exactly matches `required_parse_version`.
2041    ///
2042    /// Returning `None` for an older parser generation forces a full replacement rather than
2043    /// appending newly classified rows to a stale prefix. Lookup is `O(log F)` through the
2044    /// `files_seen` primary key and retains only the two checkpoint fields.
2045    pub fn file_checkpoint(
2046        &self,
2047        provider: Provider,
2048        source_path: &str,
2049        required_parse_version: &str,
2050    ) -> Result<Option<(i64, String)>> {
2051        let row = self
2052            .conn
2053            .query_row(
2054                "select tail_byte_offset, prefix_fingerprint from files_seen
2055                 where provider = ?1 and source_path = ?2 and parse_version = ?3",
2056                params![provider.as_str(), source_path, required_parse_version],
2057                |row| {
2058                    Ok((
2059                        row.get::<_, Option<i64>>(0)?,
2060                        row.get::<_, Option<String>>(1)?,
2061                    ))
2062                },
2063            )
2064            .optional()?;
2065        Ok(match row {
2066            Some((Some(offset), Some(fingerprint))) => Some((offset, fingerprint)),
2067            _ => None,
2068        })
2069    }
2070
2071    /// Record/refresh a file's tail-parse checkpoint after a FULL parse, so the next reindex of
2072    /// the grown file can incrementally append from here. Must run after the `files_seen` row
2073    /// exists (i.e. after [`Db::upsert_session`]). Updating it on every full parse is what keeps a
2074    /// stale offset from causing the next tail parse to re-append already-indexed rows.
2075    pub fn set_file_checkpoint(
2076        &self,
2077        provider: Provider,
2078        source_path: &str,
2079        tail_byte_offset: i64,
2080        prefix_fingerprint: &str,
2081    ) -> Result<()> {
2082        self.conn.execute(
2083            "update files_seen set tail_byte_offset = ?3, prefix_fingerprint = ?4
2084             where provider = ?1 and source_path = ?2",
2085            params![
2086                provider.as_str(),
2087                source_path,
2088                tail_byte_offset,
2089                prefix_fingerprint
2090            ],
2091        )?;
2092        Ok(())
2093    }
2094
2095    /// Append ONLY the new rows from an incremental tail parse to an already-indexed session, in
2096    /// one transaction (SQLite makes the checkpoint update atomic with the data). New messages /
2097    /// file-edits are re-sequenced to continue after the rows already stored, so their seqs match
2098    /// what a full parse would assign. Immutable session fields (created_at, summary/first-user)
2099    /// are preserved; updated_at/last_message_at advance only forward; title/preview refresh from
2100    /// the tail's latest view; cwd fills in if it was NULL; message_count becomes the true count.
2101    /// The new conversation text is appended to the transcript blob and the session FTS is rebuilt
2102    /// from the now-current row. The messages_fts/trigram triggers index the new message rows
2103    /// automatically. See [`crate::tail`] and plan §7.
2104    pub fn append_tail(
2105        &self,
2106        tail: &crate::tail::TailParse,
2107        mtime_ns: i64,
2108        size_bytes: i64,
2109    ) -> Result<()> {
2110        let session = &tail.session;
2111        let tx = self.conn.unchecked_transaction()?;
2112
2113        // New messages, re-sequenced after the existing rows (seqs are 0..N parse-order).
2114        let existing_count: i64 = tx.query_row(
2115            "select count(*) from messages where session_id = ?1",
2116            params![session.id],
2117            |row| row.get(0),
2118        )?;
2119        // Append messages re-sequenced after the existing rows (seqs are 0..N parse-order).
2120        insert_messages(
2121            &tx,
2122            session,
2123            tail.new_messages
2124                .iter()
2125                .enumerate()
2126                .map(|(i, m)| (existing_count + i as i64, m)),
2127        )?;
2128
2129        // New file edits, re-sequenced after the existing ones.
2130        let existing_edit_seq: i64 = tx.query_row(
2131            "select coalesce(max(seq), -1) from file_edits where session_id = ?1",
2132            params![session.id],
2133            |row| row.get(0),
2134        )?;
2135        insert_file_edits(
2136            &tx,
2137            session,
2138            tail.new_file_edits
2139                .iter()
2140                .enumerate()
2141                .map(|(i, e)| (existing_edit_seq + 1 + i as i64, e)),
2142        )?;
2143
2144        // Advance volatile session metadata; updated_at/last_message_at only move forward (RFC3339
2145        // sorts lexically), title/preview take the tail's newest view, cwd fills if it was NULL.
2146        let new_count = existing_count + tail.new_messages.len() as i64;
2147        tx.execute(
2148            "update sessions set
2149                updated_at = case when ?2 is not null and ?2 > coalesce(updated_at, '') then ?2
2150                                  else updated_at end,
2151                last_message_at = case when ?3 is not null and ?3 > coalesce(last_message_at, '') then ?3
2152                                       else last_message_at end,
2153                title = ?4,
2154                preview_text = ?5,
2155                cwd = coalesce(cwd, ?6),
2156                message_count = ?7
2157             where id = ?1",
2158            params![
2159                session.id,
2160                session.updated_at.map(|value| value.to_rfc3339()),
2161                session.last_message_at.map(|value| value.to_rfc3339()),
2162                session.title,
2163                session.preview_text,
2164                session.cwd,
2165                new_count,
2166            ],
2167        )?;
2168
2169        // Append the new conversation text to the transcript blob, then rebuild this session's FTS
2170        // row from the now-current sessions + transcripts rows (no drift from the live values).
2171        if !tail.new_transcript.is_empty() {
2172            tx.execute(
2173                "update transcripts set transcript_text =
2174                    case when transcript_text = '' then ?2
2175                         else transcript_text || char(10) || char(10) || ?2 end
2176                 where session_id = ?1",
2177                params![session.id, tail.new_transcript],
2178            )?;
2179        }
2180        // Persist the checkpoint + refresh files_seen mtime/size in the same transaction.
2181        tx.execute(
2182            "update files_seen set
2183                mtime_ns = ?3, size_bytes = ?4, last_indexed_at = ?5,
2184                tail_byte_offset = ?6, prefix_fingerprint = ?7, parse_version = ?8
2185             where provider = ?1 and source_path = ?2",
2186            params![
2187                session.provider.as_str(),
2188                session.source_path,
2189                mtime_ns,
2190                size_bytes,
2191                Utc::now().to_rfc3339(),
2192                tail.new_tail_offset,
2193                tail.new_fingerprint,
2194                session.parse_version,
2195            ],
2196        )?;
2197        tx.commit()?;
2198        Ok(())
2199    }
2200
2201    /// Total persisted message rows. Basis for migration detection (empty → reindex) and tests.
2202    pub fn message_count(&self) -> Result<i64> {
2203        Ok(self
2204            .conn
2205            .query_row("select count(*) from messages", [], |row| row.get(0))?)
2206    }
2207
2208    /// Whether at least one session has been indexed, without counting the full table.
2209    pub fn has_sessions(&self) -> Result<bool> {
2210        Ok(self
2211            .conn
2212            .query_row("select exists(select 1 from sessions limit 1)", [], |row| {
2213                row.get(0)
2214            })?)
2215    }
2216
2217    /// Indexed document rows in the message FTS index. For external-content FTS5,
2218    /// `count(*) from messages_fts` reflects the `messages` content table even when
2219    /// the token index is empty; `_docsize` holds one row per indexed document and is
2220    /// the value that can actually assert trigger/rebuild sync (== `message_count`).
2221    pub fn messages_fts_count(&self) -> Result<i64> {
2222        Ok(self
2223            .conn
2224            .query_row("select count(*) from messages_fts_docsize", [], |row| {
2225                row.get(0)
2226            })?)
2227    }
2228
2229    /// Messages grouped by role, ordered by role, honoring the session/date filters.
2230    /// Basis for `stats` and tests. `MessageFilters::default()` counts everything.
2231    pub fn message_role_counts(&self, filters: &MessageFilters) -> Result<Vec<(String, i64)>> {
2232        use rusqlite::types::Value;
2233
2234        self.validate_access_scope()?;
2235
2236        let mut sql = String::from("select m.role, count(*) from messages m where 1 = 1");
2237        let mut args: Vec<Value> = Vec::new();
2238        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2239        sql.push_str(" group by m.role order by m.role");
2240
2241        let mut stmt = self.conn.prepare(&sql)?;
2242        let rows = stmt
2243            .query_map(rusqlite::params_from_iter(args.iter()), |row| {
2244                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2245            })?
2246            .collect::<rusqlite::Result<Vec<_>>>()?;
2247        Ok(rows)
2248    }
2249
2250    /// Term-frequency vocabulary over the message indexes. Returns
2251    /// `(term, doc_count, total_count)` ordered by total occurrences descending, then term.
2252    /// `trigram=false` reads the word-token `messages_vocab` view and reports true occurrence
2253    /// counts. `trigram=true` reads schema-v4's row-level `messages_trigram_terms` view. Because the
2254    /// compact trigram index uses `detail=none`, it records term/document membership rather than
2255    /// per-position occurrence counts; doc and total count therefore both mean document frequency.
2256    /// Schema versions older than v4 retain the custom-index compatibility source until migration.
2257    /// `limit == 0` returns all terms.
2258    ///
2259    /// `prefix` keeps only terms starting with it, as a term-range comparison SQLite answers from
2260    /// the index order rather than a scan; `None` and `Some("")` both mean every term. The limit
2261    /// applies to the terms the prefix kept, so a matching prefix never returns an empty page
2262    /// because commoner terms filled the limit first. Matching is case-insensitive because both
2263    /// sources case-fold as they tokenize and neither stores an uppercase term, so the prefix is
2264    /// folded here rather than left to report nothing for `Cargo`. The fold is the caseless rule
2265    /// every needle in search shares, which is the one FTS5 tokenizes with: it brings `Σ`, `σ`,
2266    /// and `ς` together the way the stored terms already are, where [`str::to_lowercase`] writes a
2267    /// word-final `Σ` as `ς` and asks for a term neither source stores.
2268    pub fn vocabulary(
2269        &self,
2270        trigram: bool,
2271        limit: usize,
2272        prefix: Option<&str>,
2273    ) -> Result<Vec<(String, i64, i64)>> {
2274        use rusqlite::types::Value;
2275
2276        let lim: i64 = if limit == 0 { -1 } else { limit as i64 };
2277        let schema_version = self.schema_version()?;
2278        // Every source names its term, document-frequency, and total-occurrence columns
2279        // differently, so the query is one template over those three names.
2280        let (source, term, doc, total) = if trigram && schema_version >= 4 {
2281            // The row vocabulary makes SQLite aggregate postings once inside FTS5. Reading the
2282            // instance vocabulary here would emit one row per term/document and then re-aggregate
2283            // millions of postings for even a five-row result.
2284            ("messages_trigram_terms", "term", "doc", "doc")
2285        } else if trigram {
2286            self.ensure_trigram_base()?;
2287            ("trigram_postings", "tg", "df", "df")
2288        } else {
2289            ("messages_vocab", "term", "doc", "cnt")
2290        };
2291
2292        let folded = prefix
2293            .map(fold_caseless)
2294            .filter(|prefix| !prefix.is_empty());
2295        let mut args: Vec<Value> = vec![Value::Integer(lim)];
2296        let mut filter = String::new();
2297        if let Some(prefix) = folded {
2298            filter = format!(" where {term} >= ?{}", args.len() + 1);
2299            if let Some(upper) = prefix_upper_bound(&prefix) {
2300                args.push(Value::Text(prefix));
2301                args.push(Value::Text(upper));
2302                filter.push_str(&format!(" and {term} < ?{}", args.len()));
2303            } else {
2304                args.push(Value::Text(prefix));
2305            }
2306        }
2307
2308        let sql = format!(
2309            "select {term}, {doc}, {total} from {source}{filter} \
2310             order by {total} desc, {term} limit ?1"
2311        );
2312        let mut stmt = self.conn.prepare(&sql)?;
2313        let rows = stmt
2314            .query_map(rusqlite::params_from_iter(args.iter()), |row| {
2315                Ok((
2316                    row.get::<_, String>(0)?,
2317                    row.get::<_, i64>(1)?,
2318                    row.get::<_, i64>(2)?,
2319                ))
2320            })?
2321            .collect::<rusqlite::Result<Vec<_>>>()?;
2322        Ok(rows)
2323    }
2324
2325    /// Message-level search. A literal `query` is an exact case-insensitive substring match:
2326    /// punctuation and infix text are significant (`/goal`, `C++`, `--path`, and `handled` inside
2327    /// `mishandled` all match literally). Schema v4 uses SQLite word/trigram indexes to admit
2328    /// candidates; exact literal or Rust regex verification remains authoritative. Fuzzy mode
2329    /// uses bounded-memory Nucleo sequence scoring across every structurally eligible row, then
2330    /// applies `offset` and `limit`. It is sequence matching rather than edit distance.
2331    /// Literal/regex `limit == 0` is unlimited. Fuzzy requires a query of at least three characters
2332    /// and a positive limit.
2333    pub fn search_messages(
2334        &self,
2335        query: &str,
2336        filters: &MessageFilters,
2337    ) -> Result<Vec<MessageHit>> {
2338        Ok(self.search_messages_with_explain(query, filters, false)?.0)
2339    }
2340
2341    pub(crate) fn search_message_plan(
2342        &self,
2343        plan: &crate::message_search::MessageRetrievalPlan,
2344        include_explain: bool,
2345    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2346        let (query, filters, order) = Self::message_plan_execution(plan)?;
2347        self.search_messages_with_explain_order(query, &filters, include_explain, order)
2348    }
2349
2350    pub(crate) fn visit_message_plan_batches(
2351        &self,
2352        plan: &crate::message_search::MessageRetrievalPlan,
2353        include_explain: bool,
2354        batch_rows: NonZeroUsize,
2355        visitor: impl FnMut(Vec<MessageHit>) -> Result<MessageBatchControl>,
2356    ) -> Result<MessageBatchVisitOutcome> {
2357        let (query, filters, order) = Self::message_plan_execution(plan)?;
2358        self.visit_messages_in_batches(query, &filters, include_explain, order, batch_rows, visitor)
2359    }
2360
2361    fn message_plan_execution(
2362        plan: &crate::message_search::MessageRetrievalPlan,
2363    ) -> Result<(&str, MessageFilters, MessageOrder)> {
2364        use crate::message_search::{MatchWindow, MessageQuery, ResolvedExtent};
2365
2366        let query = plan.query.text().unwrap_or("");
2367        let match_mode = match &plan.query {
2368            MessageQuery::All | MessageQuery::Literal(_) => MessageSearchMode::Literal,
2369            MessageQuery::Regex(_) => MessageSearchMode::Regex,
2370            MessageQuery::Fuzzy(_) => MessageSearchMode::Fuzzy,
2371        };
2372        let (limit, offset) = match plan.extent {
2373            ResolvedExtent::Page { limit, offset } => (
2374                limit
2375                    .get()
2376                    .checked_add(1)
2377                    .ok_or_else(|| anyhow!("message page limit plus look-ahead overflows"))?,
2378                offset,
2379            ),
2380            ResolvedExtent::AllResults { offset } => (0, offset),
2381        };
2382        let filters = MessageFilters {
2383            role: plan.predicates.role,
2384            kinds: plan.predicates.kinds.clone(),
2385            field: Some(plan.target.field()),
2386            argument_path: plan
2387                .target
2388                .argument_path()
2389                .map(|pointer| pointer.as_str().to_string()),
2390            providers: plan.predicates.providers.clone(),
2391            session_id: plan.predicates.session_id.clone(),
2392            workspace_path_prefix: plan.predicates.workspace_path_prefix.clone(),
2393            transcript_path_prefix: plan.predicates.transcript_path_prefix.clone(),
2394            exclude_workspace_path_prefixes: plan
2395                .predicates
2396                .exclude_workspace_path_prefixes
2397                .clone(),
2398            exclude_transcript_path_prefixes: plan
2399                .predicates
2400                .exclude_transcript_path_prefixes
2401                .clone(),
2402            exclude_session_ids: plan.predicates.exclude_session_ids.clone(),
2403            since: plan.predicates.time.since(),
2404            until: plan.predicates.time.until(),
2405            seq_from: plan.predicates.sequence.and_then(|range| range.from()),
2406            seq_to: plan.predicates.sequence.and_then(|range| range.to()),
2407            match_mode,
2408            tool: plan.predicates.tool_name_contains.clone(),
2409            no_compaction: !plan.predicates.include_compaction,
2410            limit,
2411            offset,
2412            ..Default::default()
2413        };
2414        let order = if plan.match_window == Some(MatchWindow::Latest) {
2415            MessageOrder::NewestFirst
2416        } else {
2417            MessageOrder::OldestFirst
2418        };
2419        Ok((query, filters, order))
2420    }
2421
2422    pub(crate) fn search_messages_ordered(
2423        &self,
2424        query: &str,
2425        filters: &MessageFilters,
2426        order: MessageOrder,
2427    ) -> Result<Vec<MessageHit>> {
2428        anyhow::ensure!(
2429            order == MessageOrder::OldestFirst || filters.session_id.is_some(),
2430            "newest-first message order requires session_id because sequence numbers are session-local"
2431        );
2432        Ok(self
2433            .search_messages_with_explain_order(query, filters, false, order)?
2434            .0)
2435    }
2436
2437    /// Read one session's messages, selecting the first (oldest) or last (newest)
2438    /// `filters.limit` by `order`, and ALWAYS returning them in chronological (seq-ascending)
2439    /// order so the caller reads oldest→newest regardless of the selection direction.
2440    ///
2441    /// `order` decides WHICH messages are kept, not just their display order (so
2442    /// `NewestFirst` + `limit = 75` is the last 75, not the first 75 shown backwards — this
2443    /// avoids the `git log --reverse` trap where reverse applies after the limit). It is the
2444    /// clean equivalent of a hand-written `ORDER BY seq DESC LIMIT N`.
2445    ///
2446    /// Scope, role, dates, `limit` (unsigned; 0 = all), `offset`, and `seq_from`/`seq_to` come
2447    /// from `filters`. `filters.session_id` is required because sequence numbers are
2448    /// session-local, so a newest-first window is otherwise undefined. `seq_from`/`seq_to`
2449    /// bound the set before the window applies, giving non-overlapping chunked reads; `offset`
2450    /// skips from the leading edge of the chosen direction.
2451    pub fn read_session_messages(
2452        &self,
2453        filters: &MessageFilters,
2454        order: MessageOrder,
2455    ) -> Result<Vec<MessageHit>> {
2456        anyhow::ensure!(
2457            filters.session_id.is_some(),
2458            "read_session_messages requires filters.session_id because sequence numbers are session-local"
2459        );
2460        let mut hits = self.search_messages_ordered("", filters, order)?;
2461        if order == MessageOrder::NewestFirst {
2462            // A newest-first fetch returns seq-descending rows; restore chronological order.
2463            hits.reverse();
2464        }
2465        Ok(hits)
2466    }
2467
2468    /// Like [`Db::search_messages`], optionally returning the exact planner diagnostics used by
2469    /// this search. This keeps the MCP `receipt_level`, CLI `--receipt-level`, and the search path
2470    /// on one shared FTS/trigram decision instead of running the planner twice. In every receipt
2471    /// `corpus` is the structural denominator, the rows the filters admit for the searched field,
2472    /// read from indexes; `candidates` is the prefilter output still to be verified (content and
2473    /// tool-argument trigram candidates) or the rows fuzzy scoring matched (content, tool name,
2474    /// tool argument), and absent when the field was verified in SQLite with no prefilter (tool
2475    /// name, literal or regex).
2476    pub fn search_messages_with_explain(
2477        &self,
2478        query: &str,
2479        filters: &MessageFilters,
2480        include_explain: bool,
2481    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2482        self.search_messages_with_explain_order(
2483            query,
2484            filters,
2485            include_explain,
2486            MessageOrder::OldestFirst,
2487        )
2488    }
2489
2490    /// Build the one authoritative non-fuzzy SQL statement used by materialized and batched
2491    /// traversal. Keeping filtering, acceleration, ordering, offset, and diagnostics here avoids
2492    /// semantic drift between output surfaces.
2493    fn prepare_non_fuzzy_message_query(
2494        &self,
2495        query: &str,
2496        filters: &MessageFilters,
2497        include_explain: bool,
2498        order: MessageOrder,
2499    ) -> Result<PreparedMessageQuery> {
2500        use rusqlite::types::Value;
2501
2502        debug_assert_ne!(filters.match_mode, MessageSearchMode::Fuzzy);
2503        let field = filters.field.unwrap_or(SearchField::Content);
2504        let mut sql = String::from(
2505            "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
2506             from messages m where 1 = 1",
2507        );
2508        let mut args = Vec::new();
2509        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2510
2511        let explain = if field == SearchField::Content {
2512            let literal_query =
2513                filters.match_mode == MessageSearchMode::Literal && !query.is_empty();
2514            if literal_query {
2515                sql.push_str(" and unicode_lower_contains(m.content, ?)");
2516                args.push(Value::Text(fold_caseless(query)));
2517            }
2518            if filters.match_mode == MessageSearchMode::Regex {
2519                regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
2520                sql.push_str(" and rust_regexp(?, m.content)");
2521                args.push(Value::Text(query.to_string()));
2522            }
2523            let prefilter_pattern = if filters.match_mode == MessageSearchMode::Regex {
2524                Some(query.to_string())
2525            } else if literal_query {
2526                Some(regex::escape(query))
2527            } else {
2528                None
2529            };
2530            if self.schema_version()? >= 4 {
2531                let prefilter = prefilter_pattern
2532                    .as_deref()
2533                    .and_then(crate::trigram::trigram_prefilter);
2534                if let Some(fts_query) = &prefilter {
2535                    sql.push_str(
2536                        " and m.id in (
2537                             select rowid from messages_trigram
2538                              where messages_trigram match ?
2539                         )",
2540                    );
2541                    args.push(Value::Text(fts_query.clone()));
2542                }
2543                if include_explain {
2544                    Some(SearchExplain {
2545                        prefilter: prefilter.clone(),
2546                        candidates: prefilter
2547                            .as_deref()
2548                            .map(|fts_query| self.fts5_candidate_count(filters, fts_query))
2549                            .transpose()?,
2550                        prefilter_skipped: prefilter
2551                            .is_none()
2552                            .then(|| "no required literal of at least three characters".into()),
2553                        corpus: self.filtered_corpus_count(filters)?,
2554                    })
2555                } else {
2556                    None
2557                }
2558            } else {
2559                let (use_trigram_candidates, explain) = self.prepare_content_prefilter(
2560                    prefilter_pattern.as_deref(),
2561                    filters,
2562                    include_explain,
2563                )?;
2564                if use_trigram_candidates {
2565                    sql.push_str(" and m.id in (select id from _trigram_cand)");
2566                }
2567                explain
2568            }
2569        } else {
2570            let mut derived_explain = None;
2571            match (field, filters.match_mode) {
2572                (SearchField::ToolName, MessageSearchMode::Literal) => {
2573                    sql.push_str(" and unicode_lower_contains(m.tool_name, ?)");
2574                    args.push(Value::Text(fold_caseless(query)));
2575                }
2576                (SearchField::ToolName, MessageSearchMode::Regex) => {
2577                    regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
2578                    sql.push_str(" and rust_regexp(?, m.tool_name)");
2579                    args.push(Value::Text(query.to_string()));
2580                }
2581                (SearchField::ToolArgument, mode) => {
2582                    // Let F be structurally eligible tool-call rows, C the raw-JSON trigram
2583                    // candidates (C <= F), B_C their total JSON bytes, Q the query length, P_Q the
2584                    // postings entries read for its required grams, and H the returned page. The
2585                    // previous path parsed/projected every row: O(F + B_F + H) application work.
2586                    // This path does O(Q + P_Q + C + B_C + H), including authoritative projection
2587                    // and matching. Worst case remains O(F + B_F + H) when every row is candidate;
2588                    // selective anchors reduce JSON parsing from B_F to B_C. Peak owned Rust
2589                    // result memory remains O(H + bytes(H)); SQLite's execution/postings state is
2590                    // implementation-owned and conservatively bounded by O(P_Q + C). No second
2591                    // candidate Vec is materialized here. Query latency is
2592                    // the same serial critical path as the work above—this branch does not claim
2593                    // a /worker_threads speedup. The existing incrementally maintained raw-content
2594                    // index adds no new storage or write-maintenance asymptotic cost.
2595                    if mode == MessageSearchMode::Regex {
2596                        regex::Regex::new(query)
2597                            .map_err(|error| anyhow!("invalid regex: {error}"))?;
2598                    }
2599                    let prefilter_pattern = match mode {
2600                        MessageSearchMode::Literal if !query.is_empty() => {
2601                            Some(regex::escape(query))
2602                        }
2603                        MessageSearchMode::Regex => Some(query.to_string()),
2604                        MessageSearchMode::Literal => None,
2605                        MessageSearchMode::Fuzzy => unreachable!("fuzzy query rejected above"),
2606                    };
2607                    let prefilter = (self.schema_version()? >= 4)
2608                        .then(|| {
2609                            prefilter_pattern
2610                                .as_deref()
2611                                .and_then(json_projection_prefilter_groups)
2612                                .map(|groups| crate::trigram::render_prefilter_groups(&groups))
2613                        })
2614                        .flatten();
2615                    if let Some(fts_query) = &prefilter {
2616                        sql.push_str(
2617                            " and m.id in (
2618                                 select rowid from messages_trigram
2619                                  where messages_trigram match ?
2620                             )",
2621                        );
2622                        args.push(Value::Text(fts_query.clone()));
2623                    }
2624                    sql.push_str(" and m.kind = 'tool_call'");
2625                    match mode {
2626                        MessageSearchMode::Literal => {
2627                            sql.push_str(
2628                                " and unicode_lower_contains(rust_json_pointer(?, m.content), ?)",
2629                            );
2630                            args.push(Value::Text(
2631                                filters.argument_path.clone().unwrap_or_default(),
2632                            ));
2633                            args.push(Value::Text(fold_caseless(query)));
2634                        }
2635                        MessageSearchMode::Regex => {
2636                            sql.push_str(" and rust_regexp(?, rust_json_pointer(?, m.content))");
2637                            args.push(Value::Text(query.to_string()));
2638                            args.push(Value::Text(
2639                                filters.argument_path.clone().unwrap_or_default(),
2640                            ));
2641                        }
2642                        MessageSearchMode::Fuzzy => unreachable!("fuzzy query rejected above"),
2643                    }
2644                    if include_explain {
2645                        // The receipt keeps its published meaning: `corpus` is the structural
2646                        // denominator (tool-call rows the filters admit, counted from the
2647                        // partial tool-call index) and `candidates` the raw-JSON prefilter
2648                        // output the projected predicate still verifies, a superset of the hits.
2649                        // Ordinary retrieval pays neither count.
2650                        let corpus =
2651                            self.corpus_count_with_base("m.kind = 'tool_call'", filters)?;
2652                        let candidates = prefilter
2653                            .as_deref()
2654                            .map(|fts_query| {
2655                                self.fts5_candidate_count_with_base(
2656                                    "m.kind = 'tool_call'",
2657                                    filters,
2658                                    fts_query,
2659                                )
2660                            })
2661                            .transpose()?;
2662                        derived_explain = Some(SearchExplain {
2663                            prefilter: prefilter.clone(),
2664                            candidates,
2665                            prefilter_skipped: prefilter.is_none().then(|| {
2666                                "no JSON-stable required literal of at least three characters"
2667                                    .to_string()
2668                            }),
2669                            corpus,
2670                        });
2671                    }
2672                }
2673                (_, MessageSearchMode::Fuzzy) => unreachable!("fuzzy query rejected above"),
2674                (SearchField::Content, _) => unreachable!("content handled above"),
2675            }
2676            if include_explain {
2677                match derived_explain {
2678                    Some(explain) => Some(explain),
2679                    // A literal or regex tool-name search verifies the name in SQLite with no
2680                    // prefilter; the denominator is every named-tool row the filters admit,
2681                    // read from the partial tool-name index.
2682                    None => Some(SearchExplain {
2683                        prefilter: None,
2684                        candidates: None,
2685                        prefilter_skipped: Some(
2686                            "tool name verified in SQLite without a prefilter".to_string(),
2687                        ),
2688                        corpus: self.corpus_count_with_base("m.tool_name is not null", filters)?,
2689                    }),
2690                }
2691            } else {
2692                None
2693            }
2694        };
2695
2696        sql.push_str(if order == MessageOrder::NewestFirst {
2697            " order by m.session_id, m.seq desc"
2698        } else {
2699            " order by m.session_id, m.seq"
2700        });
2701        if filters.limit > 0 {
2702            sql.push_str(" limit ?");
2703            args.push(Value::Integer(filters.limit as i64));
2704            if filters.offset > 0 {
2705                sql.push_str(" offset ?");
2706                args.push(Value::Integer(filters.offset as i64));
2707            }
2708        } else if filters.offset > 0 {
2709            sql.push_str(" limit -1 offset ?");
2710            args.push(Value::Integer(filters.offset as i64));
2711        }
2712        Ok(PreparedMessageQuery { sql, args, explain })
2713    }
2714
2715    fn search_messages_with_explain_order(
2716        &self,
2717        query: &str,
2718        filters: &MessageFilters,
2719        include_explain: bool,
2720        order: MessageOrder,
2721    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2722        self.validate_access_scope()?;
2723        filters.validate(query)?;
2724        let field = filters.field.unwrap_or(SearchField::Content);
2725        if filters.match_mode != MessageSearchMode::Fuzzy {
2726            let prepared =
2727                self.prepare_non_fuzzy_message_query(query, filters, include_explain, order)?;
2728            let hits = self.query_message_hits(&prepared.sql, &prepared.args)?;
2729            let explain = prepared.explain.clone();
2730            return Ok((hits, explain));
2731        }
2732        // Every statement of one fuzzy search reads one snapshot, whichever surface asked for it.
2733        //
2734        // A fuzzy search runs several: the ranking window's corpus count, the tool-name
2735        // vocabulary, the row scan, and the receipt's own counts. Each on its own would see
2736        // whatever the indexer had committed by the time it ran, so a refresh landing mid-search
2737        // could have the receipt describe a corpus the returned rows never came from, or leave a
2738        // row counted but never scored. `MessageService` already opens a snapshot around its own
2739        // path and `with_read_snapshot` then runs inline, so this is the guarantee for callers
2740        // holding `Db` directly — the Rust library API, and the tools and tests that use it.
2741        //
2742        // Only the fuzzy branch is wrapped. The non-fuzzy prefilter can build the legacy custom
2743        // trigram base ([`Db::ensure_trigram_base`]), which takes a write transaction and cannot
2744        // nest inside a read snapshot; fuzzy scoring performs no index maintenance.
2745        self.with_read_snapshot(|| {
2746            self.fuzzy_search_in_snapshot(query, filters, field, include_explain, order)
2747        })
2748    }
2749
2750    fn fuzzy_search_in_snapshot(
2751        &self,
2752        query: &str,
2753        filters: &MessageFilters,
2754        field: SearchField,
2755        include_explain: bool,
2756        order: MessageOrder,
2757    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
2758        use rusqlite::types::Value;
2759
2760        debug_assert!(
2761            !self.conn.is_autocommit(),
2762            "every statement of one fuzzy search must share one read snapshot"
2763        );
2764        if field != SearchField::Content {
2765            return self.search_derived_message_field(
2766                query,
2767                filters,
2768                field,
2769                include_explain,
2770                order,
2771            );
2772        }
2773
2774        let mut sql = String::from(
2775            "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
2776             from messages m where 1 = 1",
2777        );
2778        let mut args: Vec<Value> = Vec::new();
2779        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
2780        sql.push_str(if order == MessageOrder::NewestFirst {
2781            " order by m.session_id, m.seq desc"
2782        } else {
2783            " order by m.session_id, m.seq"
2784        });
2785        let ranked_limit = match self.fuzzy_ranking_window("1 = 1", filters)? {
2786            FuzzyRankingWindow::Rank { limit, .. } => limit,
2787            FuzzyRankingWindow::PastCorpus { corpus } => {
2788                return Ok((
2789                    Vec::new(),
2790                    include_explain.then(|| empty_past_corpus(corpus)),
2791                ))
2792            }
2793        };
2794        let pattern = Pattern::new(
2795            query,
2796            CaseMatching::Ignore,
2797            Normalization::Smart,
2798            AtomKind::Fuzzy,
2799        );
2800        let query_lower = fold_caseless(query);
2801        let mut stmt = self.conn.prepare(&sql)?;
2802        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
2803        let mut batch = Vec::with_capacity(FUZZY_SCORE_BATCH_SIZE);
2804        let mut batch_bytes = 0_usize;
2805        let mut top = Vec::new();
2806        let mut corpus = 0_i64;
2807        let mut matched = 0_i64;
2808        for row in rows {
2809            let hit = row?;
2810            corpus += 1;
2811            batch_bytes = batch_bytes.saturating_add(hit.content.len());
2812            batch.push(hit);
2813            if fuzzy_batch_is_full(batch.len(), batch_bytes) {
2814                batch_bytes = 0;
2815                let scored = self.runtime.install(|| {
2816                    score_fuzzy_message_hits(&pattern, &query_lower, std::mem::take(&mut batch))
2817                })?;
2818                matched += scored.len() as i64;
2819                top.extend(scored);
2820                if top.len() >= top_k_compaction_threshold(ranked_limit) {
2821                    retain_top_fuzzy_hits(&mut top, ranked_limit);
2822                }
2823            }
2824        }
2825        if !batch.is_empty() {
2826            let scored = self
2827                .runtime
2828                .install(|| score_fuzzy_message_hits(&pattern, &query_lower, batch))?;
2829            matched += scored.len() as i64;
2830            top.extend(scored);
2831        }
2832        let hits = finish_fuzzy_hits(top, ranked_limit, filters.offset);
2833        let explain = include_explain.then(|| scored_whole_corpus(matched, corpus));
2834        Ok((hits, explain))
2835    }
2836
2837    /// Visit one non-fuzzy query in bounded owned batches under a single SQLite snapshot.
2838    ///
2839    /// The query statement is prepared once and stepped once, so traversal is `O(C + H)` for
2840    /// candidate work `C` and returned rows `H`; it does not repeat numeric OFFSET scans. At most
2841    /// `batch_rows` message rows and their selected-field bytes are retained by this layer.
2842    /// Returning [`MessageBatchControl::Stop`] or an error drops the active row iterator and read
2843    /// transaction immediately without draining unread results.
2844    pub(crate) fn visit_messages_in_batches(
2845        &self,
2846        query: &str,
2847        filters: &MessageFilters,
2848        include_explain: bool,
2849        order: MessageOrder,
2850        batch_rows: NonZeroUsize,
2851        mut visitor: impl FnMut(Vec<MessageHit>) -> Result<MessageBatchControl>,
2852    ) -> Result<MessageBatchVisitOutcome> {
2853        self.validate_access_scope()?;
2854        filters.validate(query)?;
2855        anyhow::ensure!(
2856            filters.match_mode != MessageSearchMode::Fuzzy,
2857            "batched exhaustive traversal supports literal, regex, and queryless searches; pass a finite limit to the bounded fuzzy search"
2858        );
2859        anyhow::ensure!(
2860            order == MessageOrder::OldestFirst || filters.session_id.is_some(),
2861            "newest-first message order requires session_id because sequence numbers are session-local"
2862        );
2863
2864        // Pre-v4 compatibility may lazily rebuild and stage its custom trigram candidates using
2865        // an internal write transaction. Finish that maintenance before opening the read snapshot;
2866        // schema v4 uses incrementally maintained FTS5 and prepares entirely inside the snapshot.
2867        // A legacy explanation is rejected because its corpus/candidate counts would otherwise be
2868        // computed before the snapshot and could describe different rows under concurrent ingest.
2869        let legacy_prepared = if self.schema_version()? < 4 {
2870            anyhow::ensure!(
2871                !include_explain,
2872                "batched message-search receipts require database schema 4 so planner counts and results share one snapshot; run `aise reindex --full` before requesting a receipt"
2873            );
2874            Some(self.prepare_non_fuzzy_message_query(query, filters, false, order)?)
2875        } else {
2876            None
2877        };
2878
2879        // A caller-owned snapshot lets request planning and row traversal observe the same index
2880        // state. Otherwise this method owns an explicit read transaction. The rollback guard
2881        // releases it on early stop, visitor error, or panic without draining unread rows.
2882        let owned_snapshot = self
2883            .conn
2884            .is_autocommit()
2885            .then(|| ReadSnapshotRollback::begin(&self.conn))
2886            .transpose()?;
2887        let initial_prepared = match legacy_prepared {
2888            Some(prepared) => prepared,
2889            None => self.prepare_non_fuzzy_message_query(query, filters, include_explain, order)?,
2890        };
2891        let prepared = if order == MessageOrder::NewestFirst {
2892            // `NewestFirst` selects from the newest edge but public results remain chronological.
2893            // Find the first included match after the caller's newest-edge offset, then run the
2894            // authoritative predicate oldest-first through that sequence boundary. Reversing each
2895            // emitted batch would produce globally incorrect order, while retaining every row
2896            // merely to reverse once would make memory grow with the result count.
2897            let boundary = {
2898                let mut statement = self.conn.prepare(&initial_prepared.sql)?;
2899                let mut rows = statement.query_map(
2900                    rusqlite::params_from_iter(initial_prepared.args.iter()),
2901                    row_to_message_hit,
2902                )?;
2903                let boundary = rows.next().transpose()?;
2904                drop(rows);
2905                drop(statement);
2906                boundary
2907            };
2908            let Some(boundary) = boundary else {
2909                let explain = initial_prepared.explain.clone();
2910                if let Some(snapshot) = owned_snapshot {
2911                    snapshot.rollback()?;
2912                }
2913                return Ok(MessageBatchVisitOutcome {
2914                    rows_visited: 0,
2915                    exhausted: true,
2916                    explain,
2917                });
2918            };
2919            let mut chronological_filters = filters.clone();
2920            chronological_filters.limit = 0;
2921            chronological_filters.offset = 0;
2922            chronological_filters.seq_to = Some(
2923                chronological_filters
2924                    .seq_to
2925                    .map_or(boundary.seq, |current| current.min(boundary.seq)),
2926            );
2927            let mut chronological = self.prepare_non_fuzzy_message_query(
2928                query,
2929                &chronological_filters,
2930                false,
2931                MessageOrder::OldestFirst,
2932            )?;
2933            chronological.explain = initial_prepared.explain;
2934            chronological
2935        } else {
2936            initial_prepared
2937        };
2938        let mut statement = self.conn.prepare(&prepared.sql)?;
2939        let mut rows = statement.query_map(
2940            rusqlite::params_from_iter(prepared.args.iter()),
2941            row_to_message_hit,
2942        )?;
2943        let mut batch = Vec::new();
2944        batch.try_reserve_exact(batch_rows.get()).map_err(|error| {
2945            anyhow!(
2946                "batch_rows {} cannot be allocated: {error}; pass a smaller positive row count",
2947                batch_rows
2948            )
2949        })?;
2950        let mut rows_visited = 0_usize;
2951        let mut exhausted = false;
2952
2953        loop {
2954            match rows.next() {
2955                Some(row) => {
2956                    batch.push(row?);
2957                    rows_visited = rows_visited
2958                        .checked_add(1)
2959                        .ok_or_else(|| anyhow!("batched message row count overflow"))?;
2960                    if batch.len() == batch_rows.get()
2961                        && visitor(std::mem::take(&mut batch))? == MessageBatchControl::Stop
2962                    {
2963                        break;
2964                    }
2965                    if batch.capacity() < batch_rows.get() {
2966                        batch
2967                            .try_reserve_exact(batch_rows.get() - batch.capacity())
2968                            .map_err(|error| {
2969                                anyhow!(
2970                                    "batch_rows {} cannot be allocated: {error}; pass a smaller positive row count",
2971                                    batch_rows
2972                                )
2973                            })?;
2974                    }
2975                }
2976                None => {
2977                    exhausted = true;
2978                    if !batch.is_empty() {
2979                        let _ = visitor(batch)?;
2980                    }
2981                    break;
2982                }
2983            }
2984        }
2985
2986        drop(rows);
2987        drop(statement);
2988        if let Some(snapshot) = owned_snapshot {
2989            snapshot.rollback()?;
2990        }
2991        Ok(MessageBatchVisitOutcome {
2992            rows_visited,
2993            exhausted,
2994            explain: exhausted.then(|| prepared.explain.clone()).flatten(),
2995        })
2996    }
2997
2998    fn fts5_candidate_count(&self, filters: &MessageFilters, fts_query: &str) -> Result<i64> {
2999        self.fts5_candidate_count_with_base("1 = 1", filters, fts_query)
3000    }
3001
3002    /// [`Db::fts5_candidate_count`] over the rows that also satisfy `base_predicate`, an SQL
3003    /// fragment over alias `m` (`"m.kind = 'tool_call'"` for tool-argument search).
3004    fn fts5_candidate_count_with_base(
3005        &self,
3006        base_predicate: &str,
3007        filters: &MessageFilters,
3008        fts_query: &str,
3009    ) -> Result<i64> {
3010        use rusqlite::types::Value;
3011        let mut sql = format!("select count(*) from messages m where {base_predicate}");
3012        let mut args = Vec::new();
3013        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3014        sql.push_str(
3015            " and m.id in (
3016                 select rowid from messages_trigram where messages_trigram match ?
3017             )",
3018        );
3019        args.push(Value::Text(fts_query.to_string()));
3020        Ok(self
3021            .conn
3022            .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3023                row.get(0)
3024            })?)
3025    }
3026
3027    fn search_derived_message_field(
3028        &self,
3029        query: &str,
3030        filters: &MessageFilters,
3031        field: SearchField,
3032        include_explain: bool,
3033        order: MessageOrder,
3034    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3035        if field == SearchField::ToolName
3036            && filters.match_mode == MessageSearchMode::Fuzzy
3037            && self.schema_version()? >= 4
3038        {
3039            // Vocabulary, per-tier rows, and the explain counts are separate statements; the
3040            // snapshot `search_messages_with_explain_order` opens keeps them on the same data.
3041            return self.search_tool_name_fuzzy_indexed(query, filters, include_explain);
3042        }
3043        let mut sql = String::from(
3044            "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind, m.tool_call_id, m.content \
3045             from messages m where 1 = 1",
3046        );
3047        let mut args = Vec::new();
3048        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3049        // The dispatcher sends only fuzzy derived-field searches here. Literal and regex have one
3050        // owner in prepare_non_fuzzy_message_query(), including tool-argument trigram planning.
3051        debug_assert_eq!(filters.match_mode, MessageSearchMode::Fuzzy);
3052        // One predicate owns both the scan and the bound measured against it. Writing the
3053        // restriction here and passing a different one to `fuzzy_ranking_window` sized the
3054        // retention window and the receipt's `corpus` by rows this statement never visits.
3055        let base_predicate = fuzzy_derived_scan_predicate(field);
3056        sql.push_str(" and ");
3057        sql.push_str(base_predicate);
3058        sql.push_str(if order == MessageOrder::NewestFirst {
3059            " order by m.session_id, m.seq desc"
3060        } else {
3061            " order by m.session_id, m.seq"
3062        });
3063        let pattern = Pattern::new(
3064            query,
3065            CaseMatching::Ignore,
3066            Normalization::Smart,
3067            AtomKind::Fuzzy,
3068        );
3069        let query_lower = fold_caseless(query);
3070        let ranked_limit = match self.fuzzy_ranking_window(base_predicate, filters)? {
3071            FuzzyRankingWindow::Rank { limit, .. } => limit,
3072            FuzzyRankingWindow::PastCorpus { corpus } => {
3073                return Ok((
3074                    Vec::new(),
3075                    include_explain.then(|| empty_past_corpus(corpus)),
3076                ))
3077            }
3078        };
3079        let mut top = Vec::new();
3080        let mut corpus = 0_i64;
3081        let mut matched = 0_i64;
3082        let mut stmt = self.conn.prepare(&sql)?;
3083        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3084        let mut batch = Vec::with_capacity(FUZZY_SCORE_BATCH_SIZE);
3085        let mut batch_bytes = 0_usize;
3086        for row in rows {
3087            let hit = row?;
3088            corpus += 1;
3089            batch_bytes = batch_bytes.saturating_add(hit.content.len());
3090            batch.push(hit);
3091            if fuzzy_batch_is_full(batch.len(), batch_bytes) {
3092                let scored = self.runtime.install(|| {
3093                    score_fuzzy_derived_hits(
3094                        &pattern,
3095                        &query_lower,
3096                        field,
3097                        filters.argument_path.as_deref(),
3098                        std::mem::take(&mut batch),
3099                    )
3100                })?;
3101                batch_bytes = 0;
3102                matched += scored.len() as i64;
3103                top.extend(scored);
3104                if top.len() >= top_k_compaction_threshold(ranked_limit) {
3105                    retain_top_fuzzy_hits(&mut top, ranked_limit);
3106                }
3107            }
3108        }
3109        if !batch.is_empty() {
3110            let scored = self.runtime.install(|| {
3111                score_fuzzy_derived_hits(
3112                    &pattern,
3113                    &query_lower,
3114                    field,
3115                    filters.argument_path.as_deref(),
3116                    batch,
3117                )
3118            })?;
3119            matched += scored.len() as i64;
3120            top.extend(scored);
3121        }
3122        let hits = finish_fuzzy_hits(top, ranked_limit, filters.offset);
3123        let explain = include_explain.then(|| scored_whole_corpus(matched, corpus));
3124        Ok((hits, explain))
3125    }
3126
3127    /// Enumerate the global tool-name superset with one B-tree successor seek per distinct name.
3128    /// Every structurally eligible name is necessarily present globally; scoring extras is safe and
3129    /// row loading still applies all filters. For N named rows and V distinct names this performs
3130    /// O(V log N) indexed seeks and retains O(V) short strings, instead of scanning O(N) index
3131    /// entries through SELECT DISTINCT. SQLite rejects this recursive CTE on the public SQL surface,
3132    /// but it is a fixed internal statement over the trusted schema.
3133    fn global_tool_name_vocabulary(&self) -> Result<Vec<String>> {
3134        let mut stmt = self.conn.prepare(
3135            "with recursive names(name) as (
3136                 select min(tool_name) from messages where tool_name is not null
3137                 union all
3138                 select (select min(tool_name) from messages where tool_name > names.name)
3139                   from names where name is not null
3140             )
3141             select name from names where name is not null",
3142        )?;
3143        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
3144        rows.collect::<rusqlite::Result<Vec<_>>>()
3145            .map_err(Into::into)
3146    }
3147
3148    #[cfg(test)]
3149    fn search_tool_name_fuzzy_direct_for_test(
3150        &self,
3151        query: &str,
3152        filters: &MessageFilters,
3153    ) -> Result<Vec<MessageHit>> {
3154        let mut sql = String::from(
3155            "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3156                    m.kind, m.tool_call_id, m.content
3157               from messages m
3158              where m.tool_name is not null",
3159        );
3160        let mut args = Vec::new();
3161        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3162        sql.push_str(" order by m.session_id, m.seq");
3163        let rows = self.query_message_hits(&sql, &args)?;
3164        let pattern = Pattern::new(
3165            query,
3166            CaseMatching::Ignore,
3167            Normalization::Smart,
3168            AtomKind::Fuzzy,
3169        );
3170        let scored = score_fuzzy_derived_hits(
3171            &pattern,
3172            &fold_caseless(query),
3173            SearchField::ToolName,
3174            None,
3175            rows,
3176        );
3177        Ok(finish_fuzzy_hits(
3178            scored,
3179            fuzzy_ranked_limit(filters)?,
3180            filters.offset,
3181        ))
3182    }
3183
3184    fn search_tool_name_fuzzy_indexed(
3185        &self,
3186        query: &str,
3187        filters: &MessageFilters,
3188        include_explain: bool,
3189    ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3190        use rusqlite::types::Value;
3191
3192        // Tool-name score depends only on the name, not the row. Let N be globally named rows, V
3193        // global distinct names, and W=offset+limit. The old path read O(N + B_N) bytes and scored
3194        // O(V) names. This path discovers/scored the complete global vocabulary with O(V log N)
3195        // index seeks, then applies structural filters while materializing at most W ranked rows
3196        // and O(B_W) content bytes. Explain adds one matching-row count. The vocabulary/score map
3197        // retains O(V) short strings; result state is O(W + bytes(W)). SQLite traversal is serial
3198        // and no schema/write cost is added. A JSON virtual table carries bounded score tiers.
3199        // Inside SQLite each processed tier is still visited in full: `idx_messages_tool_name`
3200        // seeks the tier's names, every matching row's sort key is read, and `order by session_id,
3201        // seq limit ?` keeps W of them (O(R_tier log W)); the W bound above is what crosses into
3202        // Rust, not what the engine touches.
3203        debug_assert!(
3204            !self.conn.is_autocommit(),
3205            "the fuzzy tool-name statements must share one read snapshot"
3206        );
3207        let (ranked_limit, counted_corpus) =
3208            match self.fuzzy_ranking_window("m.tool_name is not null", filters)? {
3209                FuzzyRankingWindow::Rank { limit, corpus } => (limit, corpus),
3210                FuzzyRankingWindow::PastCorpus { corpus } => {
3211                    return Ok((
3212                        Vec::new(),
3213                        include_explain.then(|| empty_past_corpus(corpus)),
3214                    ))
3215                }
3216            };
3217        let names = self.global_tool_name_vocabulary()?;
3218
3219        let pattern = Pattern::new(
3220            query,
3221            CaseMatching::Ignore,
3222            Normalization::Smart,
3223            AtomKind::Fuzzy,
3224        );
3225        let query_lower = fold_caseless(query);
3226        let query_needle = UnicodeLowerNeedle::from_lowered(&query_lower);
3227        let mut matcher = NucleoMatcher::new(NucleoConfig::DEFAULT);
3228        let mut utf32_buf = Vec::new();
3229        let mut matching_names = HashMap::new();
3230        for name in names {
3231            utf32_buf.clear();
3232            if let Some(score) = pattern.score(Utf32Str::new(&name, &mut utf32_buf), &mut matcher) {
3233                let exact_phrase = query_needle.contains(&name);
3234                matching_names.insert(name, (score, exact_phrase));
3235            }
3236        }
3237
3238        let explained_matching_rows = if include_explain && !matching_names.is_empty() {
3239            let selected_names = serde_json::to_string(&matching_names.keys().collect::<Vec<_>>())?;
3240            let mut sql = String::from(
3241                "select count(*) from json_each(?) selected
3242                   join messages m on m.tool_name = selected.value
3243                  where m.tool_name is not null",
3244            );
3245            let mut args = vec![Value::Text(selected_names)];
3246            append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3247            Some(
3248                self.conn
3249                    .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3250                        row.get::<_, i64>(0)
3251                    })?,
3252            )
3253        } else {
3254            None
3255        };
3256
3257        // Names in one score/exactness tier can interleave only by session/seq, while every lower
3258        // tier ranks after every row in this tier. Read at most the still-missing W rows from each
3259        // tier and stop once W is full; this is equivalent to global row scoring without loading
3260        // content that cannot reach the requested window.
3261        let mut scored_names = matching_names.into_iter().collect::<Vec<_>>();
3262        scored_names.sort_by(|(left_name, left), (right_name, right)| {
3263            right
3264                .0
3265                .cmp(&left.0)
3266                .then_with(|| right.1.cmp(&left.1))
3267                .then_with(|| left_name.cmp(right_name))
3268        });
3269        // Sized by rows found, never by the request: reserving `ranked_limit` up front asked for
3270        // ~150 GB at offset 1e9 and overflowed at 1e17. `ranked_limit` is itself bounded by the
3271        // eligible corpus (see `Db::fuzzy_ranking_window`), so growth stops at the rows that exist.
3272        let mut top = Vec::new();
3273        let mut matched = 0_i64;
3274        for tier in scored_names.chunk_by(|left, right| left.1 == right.1) {
3275            if top.len() == ranked_limit {
3276                break;
3277            }
3278            let selected_names =
3279                serde_json::to_string(&tier.iter().map(|(name, _)| name).collect::<Vec<_>>())?;
3280            let remaining = ranked_limit - top.len();
3281            let mut sql = String::from(
3282                "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3283                        m.kind, m.tool_call_id, m.content
3284                   from json_each(?) selected
3285                   join messages m on m.tool_name = selected.value
3286                  where m.tool_name is not null",
3287            );
3288            let mut args = vec![Value::Text(selected_names)];
3289            append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3290            sql.push_str(" order by m.session_id, m.seq limit ?");
3291            args.push(Value::Integer(remaining as i64));
3292            let mut stmt = self.conn.prepare(&sql)?;
3293            let rows =
3294                stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3295            for row in rows {
3296                let mut hit = row?;
3297                hit.fuzzy_score = Some(tier[0].1 .0);
3298                top.push((hit, tier[0].1 .1));
3299                matched += 1;
3300            }
3301        }
3302        let hits = top
3303            .into_iter()
3304            .skip(filters.offset)
3305            .map(|(hit, _)| hit)
3306            .collect();
3307        let explain = include_explain
3308            .then(|| {
3309                // Reuse the count the ranking window already took, when it took one.
3310                let corpus = match counted_corpus {
3311                    Some(corpus) => corpus,
3312                    None => self.corpus_count_with_base("m.tool_name is not null", filters)?,
3313                };
3314                Ok::<_, anyhow::Error>(SearchExplain {
3315                    prefilter: None,
3316                    candidates: Some(explained_matching_rows.unwrap_or(matched)),
3317                    prefilter_skipped: Some(
3318                        "complete filtered tool-name vocabulary scored before matching rows are loaded"
3319                            .to_string(),
3320                    ),
3321                    corpus,
3322                })
3323            })
3324            .transpose()?;
3325        Ok((hits, explain))
3326    }
3327
3328    /// Collect every row the prepared statement returns.
3329    ///
3330    /// Retained memory is `O(R)` in the returned rows and their content bytes, because the caller
3331    /// is handed the whole page at once. `R` is bounded by whatever `LIMIT` the request carried, and
3332    /// an omitted limit means all results on the Rust, Python, and CLI surfaces, so a query matching
3333    /// the corpus retains the corpus. That is the contract those surfaces promise rather than an
3334    /// unbounded accident: MCP supplies a finite page instead, and Rust aborts on allocation failure,
3335    /// so a stated bound is the only defense a caller has. Work scanned is independent of `R` — the
3336    /// verification cost belongs to the statement, which is why a small `LIMIT` returns sooner
3337    /// without finishing sooner.
3338    fn query_message_hits(
3339        &self,
3340        sql: &str,
3341        args: &[rusqlite::types::Value],
3342    ) -> Result<Vec<MessageHit>> {
3343        let mut stmt = self.conn.prepare(sql)?;
3344        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3345        rows.collect::<rusqlite::Result<Vec<_>>>()
3346            .map_err(Into::into)
3347    }
3348
3349    /// Count the messages matching the structural filters (role / provider / session / path / time /
3350    /// tool / class) — the corpus that literal or regex content matching then scans, and the
3351    /// denominator every receipt reports. Only receipts and the fuzzy tool-name planner call
3352    /// this; the search hot path probes [`Db::filtered_corpus_reaches`] instead.
3353    ///
3354    /// Cost: index-only. The class predicate defaults to `kind != 'harness_notice'`, and no
3355    /// index answers a `!=` without visiting rows, so this is computed as
3356    /// `count(every class) - count(only the excluded classes)`. The first term is served by the
3357    /// index that leads with the selected filter column (`role`, `provider`, `session_id`, `ts`,
3358    /// or SQLite's smallest index for `count(*)`); the second by [`MESSAGE_KIND_INDEX`], which
3359    /// leads with `kind` and carries every column a structural filter tests. Time is
3360    /// `O(F + E)` index entries for `F` rows matching the structural filters and `E` rows of the
3361    /// excluded classes (2 % of a real index), never `O(N)` message-row page reads. Measured on
3362    /// a 22.6 GB / 2.69 M-row index: the previous single `!=` statement scanned every row in
3363    /// 117 s; the decomposition reads ~80 MB of index pages in ≈5 s cold and well under a
3364    /// second warm. When [`MESSAGE_KIND_INDEX`] is absent (an index an older aise wrote, before
3365    /// its elected writer's next refresh builds it), the second term falls back to the row scan
3366    /// with the same result. See [`Db::corpus_count_statements`] for the exact statements.
3367    fn filtered_corpus_count(&self, filters: &MessageFilters) -> Result<i64> {
3368        self.corpus_count_with_base("1 = 1", filters)
3369    }
3370
3371    /// Whether a fuzzy page has any row to rank, and how many scored candidates it may retain.
3372    ///
3373    /// `offset + limit` is what the caller asked to rank, and both are theirs to choose: paging
3374    /// deep into a large result set is legitimate, which is why there is no arbitrary window cap.
3375    /// Ranking further than there are rows to rank never is. An offset past the last eligible row
3376    /// used to score the whole corpus anyway: [`retain_top_fuzzy_hits`] truncates only above the
3377    /// retention target, an unreachable target never truncated, so the vector of scored rows — each
3378    /// carrying its message content — grew to every matching row and `skip(offset)` then discarded
3379    /// all of it. Answering that page from the count is what removes the cost.
3380    ///
3381    /// The `min` on the returned limit is a clamp, not that repair. Retention was always at most
3382    /// the matching rows, which are at most the rows scanned, which are at most the corpus, so
3383    /// taking the minimum cannot lower a peak; it only keeps `ranked_limit` from being a number no
3384    /// run could reach. A deep page that does land inside the corpus still retains up to every
3385    /// matching row, because scoring the complete corpus and keeping a top-K larger than it has no
3386    /// smaller answer.
3387    ///
3388    /// The count reads covering indexes rather than message rows (see
3389    /// [`Db::filtered_corpus_count`]) and is taken only when the requested window exceeds one
3390    /// scoring batch, so an ordinary page pays nothing for it and is told `None` back so it counts
3391    /// on its own terms if a receipt needs one. `base_predicate` is the same fragment
3392    /// [`Db::corpus_count_with_base`] takes, so each caller measures the rows it actually scans; an
3393    /// over-estimate would only leave the window wider than needed, never truncate a real page.
3394    fn fuzzy_ranking_window(
3395        &self,
3396        base_predicate: &str,
3397        filters: &MessageFilters,
3398    ) -> Result<FuzzyRankingWindow> {
3399        let requested = fuzzy_ranked_limit(filters)?;
3400        if requested <= FUZZY_SCORE_BATCH_SIZE {
3401            return Ok(FuzzyRankingWindow::Rank {
3402                limit: requested,
3403                corpus: None,
3404            });
3405        }
3406        let corpus = self.corpus_count_with_base(base_predicate, filters)?;
3407        let corpus_rows = usize::try_from(corpus).unwrap_or(usize::MAX);
3408        // A zero offset never skips anything, so it never lands past the last eligible row. The
3409        // comparison alone would say otherwise for an empty corpus, where `0 >= 0` holds, and the
3410        // receipt would blame an offset that is zero and drop `candidates` — a filter matching no
3411        // rows would then describe itself one way under a small limit and another under a large
3412        // one. An empty corpus scores nothing on the ordinary path anyway.
3413        if filters.offset > 0 && filters.offset >= corpus_rows {
3414            return Ok(FuzzyRankingWindow::PastCorpus { corpus });
3415        }
3416        Ok(FuzzyRankingWindow::Rank {
3417            limit: requested.min(corpus_rows),
3418            corpus: Some(corpus),
3419        })
3420    }
3421
3422    /// [`Db::filtered_corpus_count`] over the rows that also satisfy `base_predicate`, an SQL
3423    /// fragment over alias `m` (`"m.tool_name is not null"` for the fuzzy tool-name planner).
3424    fn corpus_count_with_base(
3425        &self,
3426        base_predicate: &str,
3427        filters: &MessageFilters,
3428    ) -> Result<i64> {
3429        let mut corpus = 0_i64;
3430        for (sql, args, sign) in self.corpus_count_statements(base_predicate, filters) {
3431            let count: i64 =
3432                self.conn
3433                    .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3434                        row.get(0)
3435                    })?;
3436            corpus += sign * count;
3437        }
3438        Ok(corpus)
3439    }
3440
3441    /// The signed count statements whose sum is the filtered corpus (see
3442    /// [`Db::filtered_corpus_count`]). Public to tests, which assert every statement plans as
3443    /// an index-only read.
3444    fn corpus_count_statements(
3445        &self,
3446        base_predicate: &str,
3447        filters: &MessageFilters,
3448    ) -> Vec<(String, Vec<rusqlite::types::Value>, i64)> {
3449        let build = |class: ClassSelection<'_>| {
3450            let mut sql = format!("select count(*) from messages m where {base_predicate}");
3451            if matches!(class, ClassSelection::Only(_)) {
3452                // The literal clause that lets SQLite use the partial index; the bound
3453                // `m.kind in (?)` the class selection appends narrows to the excluded set.
3454                sql.push_str(&format!(" and m.{}", excluded_kinds_predicate()));
3455            }
3456            let mut args = Vec::new();
3457            append_message_filters_with_class(
3458                &mut sql,
3459                &mut args,
3460                filters,
3461                &self.access_scope,
3462                class,
3463            );
3464            if filters.tool.is_some() {
3465                // `unicode_lower_contains(m.tool_name, ?)` is already false on NULL, so this adds
3466                // no rows; it states the partial index's condition so `idx_messages_tool_name`
3467                // (covering) can answer the count instead of a row scan.
3468                sql.push_str(" and m.tool_name is not null");
3469            }
3470            (sql, args)
3471        };
3472        let excludable = crate::models::MessageKind::EXCLUDABLE_BY_DEFAULT;
3473        match filters.kind_predicate() {
3474            crate::models::KindPredicate::AllExcept(excluded) => {
3475                debug_assert!(excluded.iter().all(|kind| excludable.contains(kind)));
3476                let (total_sql, total_args) = build(ClassSelection::Unrestricted);
3477                let (excluded_sql, excluded_args) = build(ClassSelection::Only(&excluded));
3478                vec![
3479                    (total_sql, total_args, 1),
3480                    (excluded_sql, excluded_args, -1),
3481                ]
3482            }
3483            // An explicit selection inside the excludable set (`--kinds harness-notice`) is the
3484            // partial index's own population. Any other explicit set names classes no index
3485            // carries, so it counts by reading rows: `O(N)` row pages, the bound the receipt
3486            // description states for explicit class selections.
3487            crate::models::KindPredicate::Only(kinds)
3488                if !kinds.is_empty() && kinds.iter().all(|kind| excludable.contains(kind)) =>
3489            {
3490                let (sql, args) = build(ClassSelection::Only(&kinds));
3491                vec![(sql, args, 1)]
3492            }
3493            crate::models::KindPredicate::Only(_) => {
3494                let (sql, args) = build(ClassSelection::FromFilters);
3495                vec![(sql, args, 1)]
3496            }
3497        }
3498    }
3499
3500    /// Whether [`MESSAGE_KIND_INDEX`] exists.
3501    pub(crate) fn message_kind_index_exists(&self) -> Result<bool> {
3502        Ok(self.conn.query_row(
3503            "select exists(select 1 from sqlite_schema where type = 'index' and name = ?1)",
3504            params![MESSAGE_KIND_INDEX],
3505            |row| row.get(0),
3506        )?)
3507    }
3508
3509    /// Build [`MESSAGE_KIND_INDEX`] if it is missing; returns whether it was built.
3510    ///
3511    /// The build is one full scan of `messages` (minutes on a multi-gigabyte index) inside one
3512    /// write transaction, so it runs only where a long write is already expected: [`Db::init`]
3513    /// calls it while the table is still empty (free), and the elected writer calls it at the
3514    /// start of every refresh ([`crate::indexer::reindex_until`]) so an index written by an
3515    /// older aise gains it once, under the cross-process writer election, while WAL readers keep
3516    /// serving. Until then every count that needs it degrades to the row scan, never to an error.
3517    pub(crate) fn ensure_message_kind_index(&self) -> Result<bool> {
3518        if self.message_kind_index_exists()? {
3519            return Ok(false);
3520        }
3521        self.report_progress(
3522            "building the message-class index once (one scan of every indexed message) so \
3523             receipts and class-filtered counts read the index instead of every message row",
3524        );
3525        let started = std::time::Instant::now();
3526        self.conn.execute_batch(&format!(
3527            "create index if not exists {MESSAGE_KIND_INDEX} on messages{MESSAGE_KIND_INDEX_COLUMNS} \
3528             where {}",
3529            excluded_kinds_predicate()
3530        ))?;
3531        self.report_progress(&format!(
3532            "built the message-class index in {:.1?}",
3533            started.elapsed()
3534        ));
3535        Ok(true)
3536    }
3537
3538    /// Determine whether the structurally filtered corpus reaches `threshold` without counting
3539    /// beyond the planner decision boundary. Unlike [`Db::filtered_corpus_count`], this is safe on
3540    /// the normal hot path because SQLite stops the inner relation after `threshold` rows.
3541    fn filtered_corpus_reaches(&self, filters: &MessageFilters, threshold: i64) -> Result<bool> {
3542        use rusqlite::types::Value;
3543        anyhow::ensure!(threshold > 0, "corpus probe threshold must be positive");
3544        let mut inner = String::from("select 1 from messages m where 1 = 1");
3545        let mut args: Vec<Value> = Vec::new();
3546        append_message_filters(&mut inner, &mut args, filters, &self.access_scope);
3547        inner.push_str(" limit 1 offset ?");
3548        args.push(Value::Integer(threshold - 1));
3549        let sql = format!("select exists({inner})");
3550        Ok(self
3551            .conn
3552            .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3553                row.get(0)
3554            })?)
3555    }
3556
3557    fn corpus_count(&self, filters: &MessageFilters, cached: Option<i64>) -> Result<i64> {
3558        cached.map_or_else(|| self.filtered_corpus_count(filters), Ok)
3559    }
3560
3561    fn staged_candidate_count(&self, filters: &MessageFilters) -> Result<i64> {
3562        use rusqlite::types::Value;
3563        let mut sql = String::from("select count(*) from messages m where 1 = 1");
3564        let mut args: Vec<Value> = Vec::new();
3565        append_message_filters(&mut sql, &mut args, filters, &self.access_scope);
3566        sql.push_str(" and m.id in (select id from _trigram_cand)");
3567        Ok(self
3568            .conn
3569            .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
3570                row.get(0)
3571            })?)
3572    }
3573
3574    /// Prepare the trigram acceleration path and, when requested, return diagnostics for the
3575    /// same decision. The prefilter is a superset: it may include false positives, but the
3576    /// caller's literal/regex verifier remains authoritative. Returning `(false, explain)` is
3577    /// still correct: it means either no usable anchor exists or the structured filters already
3578    /// made a direct scan cheaper than intersecting the whole-corpus trigram index.
3579    fn prepare_content_prefilter(
3580        &self,
3581        pattern: Option<&str>,
3582        filters: &MessageFilters,
3583        include_explain: bool,
3584    ) -> Result<(bool, Option<SearchExplain>)> {
3585        let Some(pattern) = pattern else {
3586            let explain = include_explain
3587                .then(|| {
3588                    self.filtered_corpus_count(filters)
3589                        .map(|corpus| SearchExplain {
3590                            prefilter: None,
3591                            candidates: None,
3592                            prefilter_skipped: None,
3593                            corpus,
3594                        })
3595                })
3596                .transpose()?;
3597            return Ok((false, explain));
3598        };
3599
3600        let corpus = if include_explain {
3601            Some(self.filtered_corpus_count(filters)?)
3602        } else {
3603            None
3604        };
3605        // The custom index tokenizes documents with `str::to_lowercase`, whose word-final `Σ`
3606        // becomes `ς`, while the verifier folds every sigma onto `σ`
3607        // (`crate::util::fold_caseless`). One sigma spelling therefore asks for trigrams the other
3608        // spelling never stored, so a row the verifier accepts can be missing from the candidate
3609        // set — a prefilter narrowing the result instead of widening it. Scan directly for those
3610        // patterns and let the verifier decide. FTS5 folds all three sigmas onto one form, so the
3611        // schema-v4 path above keeps its indexed candidates.
3612        if pattern.contains(SIGMAS) {
3613            let explain = if include_explain {
3614                Some(SearchExplain {
3615                    prefilter: None,
3616                    candidates: None,
3617                    prefilter_skipped: Some(
3618                        "the pre-v4 compatibility index spells sigma by position, so this query scans directly"
3619                            .into(),
3620                    ),
3621                    corpus: self.corpus_count(filters, corpus)?,
3622                })
3623            } else {
3624                None
3625            };
3626            return Ok((false, explain));
3627        }
3628        let Some(groups) = crate::trigram::trigram_prefilter_groups(pattern) else {
3629            let explain = if include_explain {
3630                Some(SearchExplain {
3631                    prefilter: None,
3632                    candidates: None,
3633                    prefilter_skipped: None,
3634                    corpus: self.corpus_count(filters, corpus)?,
3635                })
3636            } else {
3637                None
3638            };
3639            return Ok((false, explain));
3640        };
3641
3642        // Corpus-size gate: only query the trigram index when the structurally-filtered corpus is
3643        // large enough to benefit. A role/session/path/ts/tool filter can restrict the scan to a
3644        // small slice, where a direct literal/regex scan beats intersecting it against the
3645        // whole-corpus trigram index. Regression-free: the prefilter is a superset and the final
3646        // literal/regex verifier remains authoritative.
3647        let use_prefilter = !filters.narrows_corpus()
3648            || if let Some(corpus) = corpus {
3649                corpus >= self.prefilter_min_corpus
3650            } else {
3651                self.filtered_corpus_reaches(filters, self.prefilter_min_corpus)?
3652            };
3653        let prefilter = include_explain.then(|| crate::trigram::render_prefilter_groups(&groups));
3654        if !use_prefilter {
3655            let explain = if include_explain {
3656                Some(SearchExplain {
3657                    prefilter,
3658                    candidates: None,
3659                    prefilter_skipped: Some(format!(
3660                        "structured filters reduced the corpus below the pre-v4 compatibility prefilter threshold ({})",
3661                        self.prefilter_min_corpus
3662                    )),
3663                    corpus: self.corpus_count(filters, corpus)?,
3664                })
3665            } else {
3666                None
3667            };
3668            return Ok((false, explain));
3669        }
3670
3671        // Custom parallel-built trigram index (base) + un-indexed delta; the final literal/regex
3672        // verifier checks every candidate, so this is a SUPERSET filter.
3673        let base_max = self.ensure_trigram_base()?;
3674        let candidates = crate::trigram_index::candidates(&self.conn, &groups)?;
3675        self.stage_candidates(base_max, &candidates)?;
3676        let explain = if include_explain {
3677            Some(SearchExplain {
3678                prefilter,
3679                candidates: Some(self.staged_candidate_count(filters)?),
3680                prefilter_skipped: None,
3681                corpus: self.corpus_count(filters, corpus)?,
3682            })
3683        } else {
3684            None
3685        };
3686        Ok((true, explain))
3687    }
3688
3689    /// Execute the actual message-search plan and return its diagnostics while discarding hits.
3690    /// Prefer [`Db::search_messages_with_explain`] when the caller also needs results so the query
3691    /// runs only once. This method deliberately shares every field/mode/schema branch with search;
3692    /// it never invokes a second legacy-only planner.
3693    pub fn explain_message_search(
3694        &self,
3695        query: &str,
3696        filters: &MessageFilters,
3697    ) -> Result<SearchExplain> {
3698        let (_, explain) = self.search_messages_with_explain(query, filters, true)?;
3699        explain.context("message search explanation was not produced")
3700    }
3701
3702    /// Fetch the messages surrounding a `(session_id, seq)` anchor — `before` rows
3703    /// before and `after` rows after — for `messages search --context`. Ordered by
3704    /// seq; served directly by the `(session_id, seq)` index.
3705    pub fn message_context(
3706        &self,
3707        session_id: &str,
3708        seq: i64,
3709        before: i64,
3710        after: i64,
3711    ) -> Result<Vec<MessageHit>> {
3712        use rusqlite::types::Value;
3713
3714        self.validate_access_scope()?;
3715        anyhow::ensure!(before >= 0, "context before must be non-negative");
3716        anyhow::ensure!(after >= 0, "context after must be non-negative");
3717        let mut sql = String::from(
3718            "select session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, content from messages
3719             where session_id = ? and seq between ? and ?",
3720        );
3721        // Saturate instead of wrapping: a huge `context` request (e.g. i64::MAX) must widen
3722        // to the whole session, not overflow into a negative BETWEEN bound that silently
3723        // matches nothing (release) or panics (debug).
3724        let mut args = vec![
3725            Value::Text(session_id.to_string()),
3726            Value::Integer(seq.saturating_sub(before)),
3727            Value::Integer(seq.saturating_add(after)),
3728        ];
3729        push_access_scope(&mut sql, &mut args, "session_id", &self.access_scope);
3730        sql.push_str(" order by seq");
3731        let mut stmt = self.conn.prepare(&sql)?;
3732        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3733        rows.collect::<rusqlite::Result<Vec<_>>>()
3734            .map_err(Into::into)
3735    }
3736
3737    /// Fetch context for multiple anchors in one SQLite statement. The returned windows preserve
3738    /// anchor order and include empty entries for anchors that have no visible rows.
3739    pub(crate) fn message_context_windows(
3740        &self,
3741        anchors: &[(String, i64)],
3742        before: i64,
3743        after: i64,
3744    ) -> Result<Vec<Vec<MessageHit>>> {
3745        use rusqlite::types::Value;
3746
3747        self.validate_access_scope()?;
3748        anyhow::ensure!(before >= 0, "context before must be non-negative");
3749        anyhow::ensure!(after >= 0, "context after must be non-negative");
3750        let mut windows = vec![Vec::new(); anchors.len()];
3751        if anchors.is_empty() {
3752            return Ok(windows);
3753        }
3754        let bounds = anchors
3755            .iter()
3756            .map(|(session_id, seq)| {
3757                (
3758                    session_id,
3759                    seq.saturating_sub(before),
3760                    seq.saturating_add(after),
3761                )
3762            })
3763            .collect::<Vec<_>>();
3764        let mut sql = String::from(
3765            "with anchors(ord, session_id, lower_seq, upper_seq) as materialized (
3766                 select cast(key as integer),
3767                        json_extract(value, '$[0]'),
3768                        json_extract(value, '$[1]'),
3769                        json_extract(value, '$[2]')
3770                   from json_each(?)
3771             )
3772             select a.ord, m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name,
3773                    m.kind, m.tool_call_id, m.content
3774               from anchors a
3775               join messages m on m.session_id = a.session_id
3776                              and m.seq between a.lower_seq and a.upper_seq
3777              where 1 = 1",
3778        );
3779        let mut args = vec![Value::Text(serde_json::to_string(&bounds)?)];
3780        push_access_scope(&mut sql, &mut args, "m.session_id", &self.access_scope);
3781        sql.push_str(" order by a.ord, m.seq");
3782        let mut stmt = self.conn.prepare(&sql)?;
3783        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3784            // `ord` is json_each's key over the bounds array, so it numbers the anchors from
3785            // zero and indexes `windows` directly. SQLite stores it as an i64 and rusqlite
3786            // stopped converting integers to usize in 0.38, so read the stored width and
3787            // narrow once here.
3788            Ok((row.get::<_, i64>(0)?, row_to_message_hit_at(row, 1)?))
3789        })?;
3790        for row in rows {
3791            let (ord, hit) = row?;
3792            // A negative or oversized ordinal would index outside `windows`. Indexing panics
3793            // there; naming the value and the bound reports which query produced it.
3794            let window = usize::try_from(ord)
3795                .ok()
3796                .and_then(|ordinal| windows.get_mut(ordinal))
3797                .with_context(|| {
3798                    format!(
3799                        "context window ordinal {ord} falls outside the {} anchors queried",
3800                        anchors.len()
3801                    )
3802                })?;
3803            window.push(hit);
3804        }
3805        Ok(windows)
3806    }
3807
3808    /// Fetch compact session metadata for a set of canonical session IDs in one indexed query.
3809    ///
3810    /// The JSON table-valued parameter keeps SQLite bind usage constant as a result batch grows;
3811    /// joining it to `sessions.id` avoids an N+1 lookup and SQLite's host-parameter ceiling.
3812    /// Unknown IDs are absent from the returned map.
3813    pub fn session_metadata(
3814        &self,
3815        ids: &[String],
3816    ) -> Result<std::collections::HashMap<String, crate::models::SessionMeta>> {
3817        use crate::models::SessionMeta;
3818        use rusqlite::types::Value;
3819        use std::collections::HashMap;
3820        self.validate_access_scope()?;
3821        let mut map = HashMap::new();
3822        if ids.is_empty() {
3823            return Ok(map);
3824        }
3825        let mut sql = String::from(
3826            "select s.id, s.provider_session_id, s.cwd, s.repo_root, s.title, s.updated_at,
3827                    s.last_message_at, s.message_count, s.parse_warning, s.parent_session_id,
3828                    s.agent_label
3829               from json_each(?) requested
3830               join sessions s on s.id = requested.value
3831              where 1 = 1",
3832        );
3833        let mut args = vec![Value::Text(serde_json::to_string(ids)?)];
3834        push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
3835        let mut stmt = self.conn.prepare(&sql)?;
3836        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3837            Ok((
3838                row.get::<_, String>(0)?,
3839                SessionMeta {
3840                    provider_session_id: row.get(1)?,
3841                    cwd: row.get(2)?,
3842                    repo_root: row.get(3)?,
3843                    title: row.get(4)?,
3844                    updated_at: row
3845                        .get::<_, Option<String>>(5)?
3846                        .as_deref()
3847                        .and_then(crate::util::parse_datetime),
3848                    last_message_at: row
3849                        .get::<_, Option<String>>(6)?
3850                        .as_deref()
3851                        .and_then(crate::util::parse_datetime),
3852                    message_count: row.get(7)?,
3853                    parse_warning: row.get(8)?,
3854                    parent_session_id: row.get(9)?,
3855                    agent_label: row.get(10)?,
3856                },
3857            ))
3858        })?;
3859        for row in rows {
3860            let (id, meta) = row?;
3861            map.insert(id, meta);
3862        }
3863        Ok(map)
3864    }
3865
3866    /// Load verbatim provider metadata only for explicitly requested canonical session IDs.
3867    ///
3868    /// Let `S` be the number of requested sessions and `B` the returned metadata bytes. One JSON
3869    /// table-valued parameter is joined to indexed `sessions.id`, so database work is
3870    /// `O(S log N + B)`, bind usage is `O(1)`, and output memory is `O(S + B)`. Ordinary searches
3871    /// do not call this method.
3872    pub fn session_raw_provider_metadata(
3873        &self,
3874        ids: &[String],
3875    ) -> Result<std::collections::BTreeMap<String, Box<serde_json::value::RawValue>>> {
3876        use rusqlite::types::Value;
3877        use std::collections::BTreeMap;
3878
3879        self.validate_access_scope()?;
3880        let mut map = BTreeMap::new();
3881        if ids.is_empty() {
3882            return Ok(map);
3883        }
3884        let mut sql = String::from(
3885            "select s.id, s.raw_metadata_json
3886               from json_each(?) requested
3887               join sessions s on s.id = requested.value
3888              where s.raw_metadata_json is not null",
3889        );
3890        let mut args = vec![Value::Text(serde_json::to_string(ids)?)];
3891        push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
3892        let mut statement = self.conn.prepare(&sql)?;
3893        let rows = statement.query_map(rusqlite::params_from_iter(args.iter()), |row| {
3894            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3895        })?;
3896        for row in rows {
3897            let (session_id, raw_json) = row?;
3898            let raw = serde_json::value::RawValue::from_string(raw_json).map_err(|error| {
3899                anyhow::anyhow!(
3900                    "session {session_id} contains invalid raw provider metadata JSON: {error}"
3901                )
3902            })?;
3903            map.insert(session_id, raw);
3904        }
3905        Ok(map)
3906    }
3907
3908    /// Return only source-authoritative human text for prose analytics such as `repeats`.
3909    ///
3910    /// Whole human messages retain their original identity and content. Mixed messages concatenate
3911    /// source-ordered human parts, so generated or harness regions cannot become repeat evidence.
3912    /// Unknown authorship and mirror rows fail closed. Let `M` be structurally eligible messages,
3913    /// `P_h` human parts, and `B_h` emitted human bytes: work is `O(M + P_h + B_h)` plus the
3914    /// authoritative literal/regex predicate and output ordering; finite result memory is
3915    /// `O(K + bytes(K))`, while an explicit unlimited request retains all matches. SQLite owns the
3916    /// correlated part-scan state. This path deliberately does not use raw-content trigrams because
3917    /// a match may cross two human fragments after an intervening generated fragment is removed.
3918    pub(crate) fn search_attributable_human_messages(
3919        &self,
3920        query: &str,
3921        filters: &MessageFilters,
3922    ) -> Result<Vec<MessageHit>> {
3923        use rusqlite::types::Value;
3924
3925        self.validate_access_scope()?;
3926        if filters.match_mode == MessageSearchMode::Fuzzy {
3927            bail!("attributable human-message analytics do not support fuzzy matching");
3928        }
3929        if filters.field.unwrap_or(SearchField::Content) != SearchField::Content {
3930            bail!("attributable human-message analytics search message content only");
3931        }
3932        if filters.match_mode == MessageSearchMode::Regex {
3933            regex::Regex::new(query).map_err(|error| anyhow!("invalid regex: {error}"))?;
3934        }
3935
3936        let mut inner = String::from(
3937            "select m.session_id, m.provider, m.seq, m.role, m.ts, m.tool_name, m.kind,
3938                    m.tool_call_id,
3939                    case
3940                      when m.authorship = 'human' then m.content
3941                      else (
3942                        select group_concat(fragment, '')
3943                        from (
3944                          select substr(m.content, p.start_char + 1,
3945                                        p.end_char - p.start_char) as fragment
3946                          from message_content_parts p
3947                          where p.message_id = m.id and p.authorship = 'human'
3948                          order by p.ordinal
3949                        )
3950                      )
3951                    end as content
3952             from messages m
3953             where m.record_relation = 'original'
3954               and (
3955                 m.authorship = 'human'
3956                 or (
3957                   m.authorship = 'mixed'
3958                   and exists (
3959                     select 1 from message_content_parts p
3960                     where p.message_id = m.id and p.authorship = 'human'
3961                   )
3962                 )
3963               )",
3964        );
3965        let mut args: Vec<Value> = Vec::new();
3966        append_message_filters(&mut inner, &mut args, filters, &self.access_scope);
3967        let mut sql = format!("select * from ({inner}) attributable where 1 = 1");
3968        if !query.is_empty() {
3969            match filters.match_mode {
3970                MessageSearchMode::Literal => {
3971                    sql.push_str(" and unicode_lower_contains(attributable.content, ?)");
3972                    args.push(Value::Text(fold_caseless(query)));
3973                }
3974                MessageSearchMode::Regex => {
3975                    sql.push_str(" and rust_regexp(?, attributable.content)");
3976                    args.push(Value::Text(query.to_string()));
3977                }
3978                MessageSearchMode::Fuzzy => unreachable!("fuzzy rejected above"),
3979            }
3980        }
3981        sql.push_str(" order by attributable.session_id, attributable.seq");
3982        if filters.limit > 0 {
3983            sql.push_str(" limit ? offset ?");
3984            args.push(Value::Integer(filters.limit as i64));
3985            args.push(Value::Integer(filters.offset as i64));
3986        } else if filters.offset > 0 {
3987            sql.push_str(" limit -1 offset ?");
3988            args.push(Value::Integer(filters.offset as i64));
3989        }
3990        let mut stmt = self.conn.prepare(&sql)?;
3991        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_message_hit)?;
3992        rows.collect::<rusqlite::Result<Vec<_>>>()
3993            .map_err(Into::into)
3994    }
3995
3996    /// Scan user messages and tag each against `policies` (policies in selection order,
3997    /// categories in declaration order, first match wins — so `other` must be last).
3998    /// `filters.limit == 0` means unlimited.
3999    ///
4000    /// Corrections are intrinsically scoped to `role = 'user'` — the user's own prompts, a small
4001    /// slice of the corpus (≈7.7k rows / ~10 MB on the real index vs 628k total). A direct regex
4002    /// scan of that slice is milliseconds. We deliberately do NOT route this through the trigram
4003    /// prefilter: the correction keywords ("wrong", "stop", "actually", …) have very common
4004    /// trigrams, so a prefilter `MATCH` would scan a large fraction of the multi-GB trigram index
4005    /// (95% of which is tool output the `role='user'` filter then discards) AND trigger its lazy
4006    /// build — measured ~21 s, strictly slower than scanning the user rows outright. The structural
4007    /// `role='user'` filter is the selective one here, so we lean on it alone.
4008    pub(crate) fn find_corrections(
4009        &self,
4010        policies: &crate::corrections::ResolvedCorrectionPolicySet,
4011        filters: &MessageFilters,
4012    ) -> Result<Vec<MessageClassificationMatch>> {
4013        self.validate_access_scope()?;
4014        // An empty selection (`[skills].enabled = []`) defines no categories, so no row can match.
4015        // Returning here skips a full scan of the user slice whose only possible result is this
4016        // same empty vector. Placed AFTER the scope check so "no rules" cannot become a way to
4017        // query an index the caller is not allowed to touch.
4018        if policies.is_empty() {
4019            return Ok(Vec::new());
4020        }
4021        self.find_corrections_with_after_candidates(policies, filters, || {})
4022    }
4023
4024    fn find_corrections_with_after_candidates(
4025        &self,
4026        policies: &crate::corrections::ResolvedCorrectionPolicySet,
4027        filters: &MessageFilters,
4028        after_candidates: impl FnOnce(),
4029    ) -> Result<Vec<MessageClassificationMatch>> {
4030        use rusqlite::types::Value;
4031
4032        let mut filters = filters.clone();
4033        filters.role = Some(Role::User);
4034        // A correction is what a PERSON told the agent. In a subagent transcript the `role='user'`
4035        // rows are the SPAWNING AGENT's delegation prompt, not anything a human typed, and
4036        // subagents outnumber user-started sessions roughly five to one (models.rs SessionKind).
4037        // Leaving them in lets ordinary orchestrator instructions register as corrections --
4038        // `\bdon'?t forget\b` matches "don't forget to check the tests" exactly.
4039        //
4040        // Narrowed here, beside the forced role, because both are the same kind of claim about
4041        // what the operation intrinsically means rather than a caller preference.
4042        // `--session-kinds` can reopen subagent session scope, but it never overrides source
4043        // authorship: agent delegation prompts remain excluded while genuinely human content in
4044        // that scope remains reachable. The SET is the mechanism, never an
4045        // `include_subagents` boolean.
4046        filters
4047            .session_kinds
4048            .get_or_insert_with(|| vec![crate::models::SessionKind::User]);
4049
4050        struct CorrectionCandidate {
4051            message_id: i64,
4052            session_id: String,
4053            message_seq: i64,
4054            provider: Provider,
4055            ts: Option<String>,
4056            content: String,
4057            // (absolute start character, start byte, end byte) into `content`.
4058            human_parts: Option<Vec<(usize, usize, usize)>>,
4059        }
4060
4061        fn resolve_human_part_bytes(
4062            message_id: i64,
4063            content: &str,
4064            ranges: &[(usize, usize)],
4065        ) -> Result<Vec<(usize, usize, usize)>> {
4066            let mut resolved = Vec::with_capacity(ranges.len());
4067            let mut range_index = 0;
4068            let mut start_byte = None;
4069            for (char_index, byte_index) in content
4070                .char_indices()
4071                .map(|(byte_index, _)| byte_index)
4072                .chain(std::iter::once(content.len()))
4073                .enumerate()
4074            {
4075                while let Some(&(start_char, end_char)) = ranges.get(range_index) {
4076                    if start_byte.is_none() && char_index == start_char {
4077                        start_byte = Some(byte_index);
4078                    }
4079                    if char_index == end_char {
4080                        let start_byte = start_byte.take().ok_or_else(|| {
4081                            anyhow!(
4082                                "message {message_id} human content part starts beyond its content"
4083                            )
4084                        })?;
4085                        resolved.push((start_char, start_byte, byte_index));
4086                        range_index += 1;
4087                        continue;
4088                    }
4089                    break;
4090                }
4091            }
4092            if range_index != ranges.len() {
4093                bail!(
4094                    "message {message_id} human content part ends beyond its content; \
4095                     reindex the source transcript"
4096                );
4097            }
4098            Ok(resolved)
4099        }
4100
4101        // Keep candidate rows and their content-part ranges in one SQLite read snapshot, then
4102        // release the transaction before CPU-bound parallel regex classification.
4103        let rows = self.with_read_snapshot(|| {
4104            // Select only source-authoritative human content. Unknown schema-5 authorship stays
4105            // excluded rather than being guessed; unavailable source transcripts retain their
4106            // index rows but cannot become evidence about what a person said.
4107            let mut sql = String::from(CORRECTION_CANDIDATES_SQL);
4108            let mut args: Vec<Value> = Vec::new();
4109            append_message_filters(&mut sql, &mut args, &filters, &self.access_scope);
4110            // Timestamp alone is not a total order: providers may omit it, and several messages
4111            // may share one timestamp. Stable tie-breakers make offset pages reproducible and
4112            // non-overlapping across the CLI, Rust, Python, and MCP adapters.
4113            sql.push_str(" order by m.ts desc, m.session_id asc, m.seq asc");
4114
4115            let mut stmt = self.conn.prepare(&sql)?;
4116            // Materialize each candidate's full content exactly once before going parallel:
4117            // rusqlite's `Connection`/`Statement` are not `Sync`, while Rayon requires owned rows.
4118            let mut rows: Vec<CorrectionCandidate> = stmt
4119                .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4120                    let authorship = row.get::<_, String>(6)?;
4121                    Ok(CorrectionCandidate {
4122                        message_id: row.get(0)?,
4123                        session_id: row.get(1)?,
4124                        message_seq: row.get(2)?,
4125                        provider: decode_db_enum(
4126                            row.get::<_, String>(3)?,
4127                            3,
4128                            "messages.provider",
4129                            Provider::from_db_str,
4130                        )?,
4131                        ts: row.get(4)?,
4132                        content: row.get(5)?,
4133                        human_parts: (authorship == "mixed").then(Vec::new),
4134                    })
4135                })?
4136                .collect::<rusqlite::Result<Vec<_>>>()?;
4137            drop(stmt);
4138            after_candidates();
4139
4140            // Fetch every selected mixed-message range in one additional indexed query. Resolve
4141            // its Unicode-scalar coordinates to borrowed UTF-8 slices with one linear pass per
4142            // mixed message, avoiding both repeated SQLite `substr` prefix scans and copied
4143            // fragment text. This is two set-oriented queries total, never one query per message.
4144            if rows.iter().any(|row| row.human_parts.is_some()) {
4145                let mut parts_sql = String::from(CORRECTION_HUMAN_PARTS_SQL);
4146                let mut parts_args: Vec<Value> = Vec::new();
4147                append_message_filters(
4148                    &mut parts_sql,
4149                    &mut parts_args,
4150                    &filters,
4151                    &self.access_scope,
4152                );
4153                parts_sql.push_str(" order by m.id, p.ordinal");
4154                let mut human_parts_by_message: HashMap<i64, Vec<(usize, usize)>> = HashMap::new();
4155                let mut parts_stmt = self.conn.prepare(&parts_sql)?;
4156                let part_rows =
4157                    parts_stmt.query_map(rusqlite::params_from_iter(parts_args.iter()), |row| {
4158                        Ok((
4159                            row.get::<_, i64>(0)?,
4160                            row.get::<_, i64>(1)?,
4161                            row.get::<_, i64>(2)?,
4162                        ))
4163                    })?;
4164                for part in part_rows {
4165                    let (message_id, start_char, end_char) = part?;
4166                    let start_char = usize::try_from(start_char).with_context(|| {
4167                        format!(
4168                            "message {message_id} has a negative human content-part start coordinate"
4169                        )
4170                    })?;
4171                    let end_char = usize::try_from(end_char).with_context(|| {
4172                        format!(
4173                            "message {message_id} has a negative human content-part end coordinate"
4174                        )
4175                    })?;
4176                    human_parts_by_message
4177                        .entry(message_id)
4178                        .or_default()
4179                        .push((start_char, end_char));
4180                }
4181                for row in &mut rows {
4182                    if let Some(parts) = &mut row.human_parts {
4183                        let ranges = human_parts_by_message
4184                            .remove(&row.message_id)
4185                            .unwrap_or_default();
4186                        *parts =
4187                            resolve_human_part_bytes(row.message_id, &row.content, &ranges)?;
4188                    }
4189                }
4190            }
4191            Ok(rows)
4192        })?;
4193
4194        // Classify one candidate against whole human messages or source-ordered human fragments.
4195        // Policies and categories remain the outer loops, so priority is unchanged; fragments are
4196        // independent regex inputs, so generated gaps cannot create an artificial cross-part hit.
4197        // Borrow content/fragments only during matching, then move the one owned full content into
4198        // the result without a per-match clone. Both sequential and parallel paths share this
4199        // closure and therefore the same provenance semantics.
4200        let classify = |row: CorrectionCandidate| -> Option<MessageClassificationMatch> {
4201            let CorrectionCandidate {
4202                session_id,
4203                message_seq,
4204                provider,
4205                ts,
4206                content,
4207                human_parts,
4208                ..
4209            } = row;
4210            let (identity, hit) = match &human_parts {
4211                Some(parts) => policies.classify_fragments(parts.iter().map(
4212                    |(start_char, start_byte, end_byte)| {
4213                        (*start_char, &content[*start_byte..*end_byte])
4214                    },
4215                )),
4216                None => policies.classify(&content),
4217            }?;
4218            let ts = ts.as_deref().and_then(|value| {
4219                chrono::DateTime::parse_from_rfc3339(value)
4220                    .ok()
4221                    .map(|dt| dt.with_timezone(&Utc))
4222            });
4223            Some(MessageClassificationMatch {
4224                session_id,
4225                message_seq,
4226                provider,
4227                ts,
4228                policy_name: identity.name.clone(),
4229                category: hit.category.to_string(),
4230                matched_text: hit.matched_text,
4231                match_start_char: hit.match_start_char,
4232                match_end_char_exclusive: hit.match_end_char_exclusive,
4233                content,
4234            })
4235        };
4236
4237        // Let `R` be selected messages, `P_h` selected human parts, and `B` their content bytes.
4238        // Stable SQL ordering is O(R log R + P_h log P_h) in the worst case when SQLite cannot
4239        // satisfy it from an index; row/part projection plus classification is O(R + P_h + B)
4240        // before regex-engine costs. Retained memory is O(R + P_h + B). No N+1 query or per-part
4241        // full-content copy is introduced, and all rows/parts come from one read snapshot.
4242        // Run sequentially when the configured pool is single-threaded (threads=1) to avoid Rayon's
4243        // split/join overhead; otherwise classify in parallel. `regex::Regex` is `Sync`, so sharing
4244        // `patterns` read-only across workers is safe. Both paths preserve the SQL total order
4245        // (Rayon's `collect` is order-preserving), so output is identical — verified by
4246        // `find_corrections_parallel_matches_sequential`. `ResolvedCorrectionPolicySet` holds only
4247        // `Regex` and owned strings, so sharing it read-only across workers is safe.
4248        use rayon::prelude::*;
4249        let mut out: Vec<MessageClassificationMatch> = if self.runtime.worker_threads() <= 1 {
4250            rows.into_iter().filter_map(classify).collect()
4251        } else {
4252            self.runtime
4253                .install(|| rows.into_par_iter().filter_map(classify).collect())?
4254        };
4255        // `limit == 0` means unlimited; otherwise keep the first N in ts-desc order — identical to
4256        // the sequential early-break, which stopped after N matches in that same order.
4257        // Matches are newest-first with stable session/sequence tie-breakers, so `offset` skips
4258        // that many before `limit` takes: this is the page the caller asked for, not a different
4259        // sort. Honors `MessageFilters::offset`, which this function previously accepted and
4260        // silently ignored -- the failure mode that returns success.
4261        if filters.offset > 0 {
4262            out.drain(..filters.offset.min(out.len()));
4263        }
4264        if filters.limit > 0 {
4265            out.truncate(filters.limit);
4266        }
4267        Ok(out)
4268    }
4269
4270    /// Aggregate slash-command frequency: count, distinct sessions, distinct projects
4271    /// (session repo_root, falling back to cwd). Sorted by count desc then command.
4272    /// Count slash-command usage. When `command_filters` is non-empty, only commands whose
4273    /// token matches one of the (already-compiled) patterns are counted; empty = count all.
4274    pub fn planning_usage(
4275        &self,
4276        filters: &MessageFilters,
4277        command_filters: &[regex::Regex],
4278    ) -> Result<Vec<PlanningCount>> {
4279        use rusqlite::types::Value;
4280        use std::collections::{HashMap, HashSet};
4281
4282        self.validate_access_scope()?;
4283        let mut sql = String::from(
4284            "select m.session_id, s.repo_root, s.cwd, m.content from messages m \
4285             join sessions s on s.id = m.session_id where 1 = 1",
4286        );
4287        let mut args: Vec<Value> = Vec::new();
4288        let mut filters = filters.clone();
4289        filters.role = Some(Role::Slash);
4290        append_message_filters(&mut sql, &mut args, &filters, &self.access_scope);
4291
4292        let mut stmt = self.conn.prepare(&sql)?;
4293        let raw = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
4294            Ok((
4295                row.get::<_, String>(0)?,
4296                row.get::<_, Option<String>>(1)?,
4297                row.get::<_, Option<String>>(2)?,
4298                row.get::<_, String>(3)?,
4299            ))
4300        })?;
4301
4302        // command -> (count, distinct sessions, distinct projects)
4303        let mut agg: HashMap<String, (i64, HashSet<String>, HashSet<String>)> = HashMap::new();
4304        for row in raw {
4305            let (session_id, repo_root, cwd, content) = row?;
4306            if let Some(command) = crate::util::slash_command_token(&content) {
4307                if !command_filters.is_empty()
4308                    && !command_filters.iter().any(|re| re.is_match(&command))
4309                {
4310                    continue;
4311                }
4312                let project = repo_root.or(cwd).unwrap_or_default();
4313                let entry = agg.entry(command).or_default();
4314                entry.0 += 1;
4315                entry.1.insert(session_id);
4316                if !project.is_empty() {
4317                    entry.2.insert(project);
4318                }
4319            }
4320        }
4321        let mut counts: Vec<PlanningCount> = agg
4322            .into_iter()
4323            .map(|(command, (count, sessions, projects))| PlanningCount {
4324                command,
4325                count,
4326                unique_sessions: sessions.len() as i64,
4327                unique_projects: projects.len() as i64,
4328            })
4329            .collect();
4330        counts.sort_by(|a, b| {
4331            b.count
4332                .cmp(&a.count)
4333                .then_with(|| a.command.cmp(&b.command))
4334        });
4335        if filters.limit > 0 {
4336            counts.truncate(filters.limit);
4337        }
4338        Ok(counts)
4339    }
4340
4341    /// Aggregate file-edit activity per file (`files search`). Honors an optional glob
4342    /// `pattern`, session scope, date window, and min/max edit-count thresholds.
4343    pub fn file_search(&self, query: &FileQuery) -> Result<Vec<FileEditSummary>> {
4344        use rusqlite::types::Value;
4345
4346        self.validate_access_scope()?;
4347        let mut sql = String::from(
4348            "select file_path, file_name, count(*) as edits, \
4349             count(distinct session_id) as sessions, max(ts) as last_edited \
4350             from file_edits where 1 = 1",
4351        );
4352        let mut args: Vec<Value> = Vec::new();
4353        if let Some(pattern) = &query.pattern {
4354            let (col, like) = glob_clause(pattern);
4355            sql.push_str(&format!(" and {col} like ? escape '\\'"));
4356            args.push(Value::Text(like));
4357        }
4358        push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4359        push_ts_window(&mut sql, &mut args, "ts", query.since, query.until);
4360        sql.push_str(" group by file_path");
4361        let mut having: Vec<&str> = Vec::new();
4362        if let Some(min) = query.min_edits {
4363            having.push("count(*) >= ?");
4364            args.push(Value::Integer(min));
4365        }
4366        if let Some(max) = query.max_edits {
4367            having.push("count(*) <= ?");
4368            args.push(Value::Integer(max));
4369        }
4370        if !having.is_empty() {
4371            sql.push_str(" having ");
4372            sql.push_str(&having.join(" and "));
4373        }
4374        sql.push_str(" order by edits desc, last_edited desc");
4375        if query.limit > 0 || query.offset > 0 {
4376            sql.push_str(" limit ? offset ?");
4377            args.push(Value::Integer(if query.limit == 0 {
4378                -1
4379            } else {
4380                query.limit as i64
4381            }));
4382            args.push(Value::Integer(query.offset as i64));
4383        }
4384
4385        let mut stmt = self.conn.prepare(&sql)?;
4386        let rows = stmt
4387            .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4388                Ok(FileEditSummary {
4389                    file_path: row.get(0)?,
4390                    file_name: row.get(1)?,
4391                    edits: row.get(2)?,
4392                    sessions: row.get(3)?,
4393                    last_edited: row
4394                        .get::<_, Option<String>>(4)?
4395                        .as_deref()
4396                        .and_then(crate::util::parse_datetime),
4397                })
4398            })?
4399            .collect::<rusqlite::Result<Vec<_>>>()?;
4400        Ok(rows)
4401    }
4402
4403    /// File ↔ session linkage with per-pair edit counts (`files cross-ref`).
4404    pub fn file_cross_ref(&self, query: &FileQuery) -> Result<Vec<FileCrossRef>> {
4405        use rusqlite::types::Value;
4406
4407        self.validate_access_scope()?;
4408        let mut sql = String::from(
4409            "select file_path, session_id, provider, count(*) as edits \
4410             from file_edits where 1 = 1",
4411        );
4412        let mut args: Vec<Value> = Vec::new();
4413        if let Some(pattern) = &query.pattern {
4414            let (col, like) = glob_clause(pattern);
4415            sql.push_str(&format!(" and {col} like ? escape '\\'"));
4416            args.push(Value::Text(like));
4417        }
4418        push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4419        push_ts_window(&mut sql, &mut args, "ts", query.since, query.until);
4420        sql.push_str(" group by file_path, session_id order by file_path, edits desc");
4421        if query.limit > 0 || query.offset > 0 {
4422            sql.push_str(" limit ? offset ?");
4423            args.push(Value::Integer(if query.limit == 0 {
4424                -1
4425            } else {
4426                query.limit as i64
4427            }));
4428            args.push(Value::Integer(query.offset as i64));
4429        }
4430
4431        let mut stmt = self.conn.prepare(&sql)?;
4432        let rows = stmt
4433            .query_map(rusqlite::params_from_iter(args.iter()), |row| {
4434                let provider: String = row.get(2)?;
4435                Ok(FileCrossRef {
4436                    file_path: row.get(0)?,
4437                    session_id: row.get(1)?,
4438                    provider: decode_db_enum(
4439                        provider,
4440                        2,
4441                        "file_edits.provider",
4442                        Provider::from_db_str,
4443                    )?,
4444                    edits: row.get(3)?,
4445                })
4446            })?
4447            .collect::<rusqlite::Result<Vec<_>>>()?;
4448        Ok(rows)
4449    }
4450
4451    /// Ordered raw edits for one file (`files history`/`extract`). Matches by exact
4452    /// basename, exact path, or path suffix (`%/file`), optionally scoped to an exact session ID.
4453    /// Results are ordered by `(session_id, seq)` so callers can number versions per
4454    /// session and replay deltas deterministically.
4455    pub fn file_edits_for(
4456        &self,
4457        file: &str,
4458        session_id: Option<&str>,
4459    ) -> Result<Vec<(String, Provider, FileEdit)>> {
4460        let session_id = session_id
4461            .map(|id| self.resolve_session_record(id).map(|session| session.id))
4462            .transpose()?;
4463        self.file_edits_for_query(
4464            file,
4465            &FileQuery {
4466                session_id,
4467                ..Default::default()
4468            },
4469        )
4470    }
4471
4472    pub fn file_edits_for_query(
4473        &self,
4474        file: &str,
4475        query: &FileQuery,
4476    ) -> Result<Vec<(String, Provider, FileEdit)>> {
4477        self.file_edits_for_scoped(file, query)
4478    }
4479
4480    pub fn file_edits_for_session_id(
4481        &self,
4482        file: &str,
4483        session_id: &str,
4484    ) -> Result<Vec<(String, Provider, FileEdit)>> {
4485        self.file_edits_for_scoped(
4486            file,
4487            &FileQuery {
4488                session_id: Some(session_id.to_string()),
4489                ..Default::default()
4490            },
4491        )
4492    }
4493
4494    fn file_edits_for_scoped(
4495        &self,
4496        file: &str,
4497        query: &FileQuery,
4498    ) -> Result<Vec<(String, Provider, FileEdit)>> {
4499        use rusqlite::types::Value;
4500
4501        self.validate_access_scope()?;
4502        let mut sql = String::from(
4503            "select session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json \
4504             from file_edits where (file_name = ? or file_path = ? or file_path like ?)",
4505        );
4506        let mut args: Vec<Value> = vec![
4507            Value::Text(file.to_string()),
4508            Value::Text(file.to_string()),
4509            Value::Text(format!("%/{file}")),
4510        ];
4511        push_file_filters(&mut sql, &mut args, query, &self.access_scope);
4512        sql.push_str(" order by session_id, seq");
4513
4514        let mut stmt = self.conn.prepare(&sql)?;
4515        let raw = stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
4516            Ok((
4517                row.get::<_, String>(0)?,
4518                row.get::<_, String>(1)?,
4519                row.get::<_, i64>(2)?,
4520                row.get::<_, Option<String>>(3)?,
4521                row.get::<_, String>(4)?,
4522                row.get::<_, String>(5)?,
4523                row.get::<_, String>(6)?,
4524                row.get::<_, Option<String>>(7)?,
4525                row.get::<_, Option<String>>(8)?,
4526            ))
4527        })?;
4528
4529        let mut out = Vec::new();
4530        for row in raw {
4531            let (
4532                session_id,
4533                provider,
4534                seq,
4535                ts,
4536                tool,
4537                file_path,
4538                file_name,
4539                new_content,
4540                edits_json,
4541            ) = row?;
4542            // Surface a corrupt/truncated edits_json instead of silently yielding no edits (which
4543            // would make `files extract` show an edit row with no diffs and no signal).
4544            let edits: Vec<EditOp> = match edits_json.as_deref() {
4545                Some(json) => serde_json::from_str(json).with_context(|| {
4546                    format!("corrupt edits_json for {file_path} in session {session_id}")
4547                })?,
4548                None => Vec::new(),
4549            };
4550            out.push((
4551                session_id,
4552                decode_db_enum(provider, 1, "file_edits.provider", Provider::from_db_str)?,
4553                FileEdit {
4554                    seq,
4555                    ts: ts.as_deref().and_then(crate::util::parse_datetime),
4556                    tool,
4557                    file_path,
4558                    file_name,
4559                    new_content,
4560                    edits,
4561                },
4562            ));
4563        }
4564        Ok(out)
4565    }
4566
4567    /// Total persisted file-edit rows. Basis for migration detection and tests.
4568    pub fn file_edit_count(&self) -> Result<i64> {
4569        Ok(self
4570            .conn
4571            .query_row("select count(*) from file_edits", [], |row| row.get(0))?)
4572    }
4573
4574    pub fn list_recent(&self, filters: &SearchFilters) -> Result<Vec<SessionRecord>> {
4575        self.list_recent_page(filters, 0)
4576    }
4577
4578    pub fn list_recent_page(
4579        &self,
4580        filters: &SearchFilters,
4581        offset: usize,
4582    ) -> Result<Vec<SessionRecord>> {
4583        self.validate_access_scope()?;
4584        let mut sql = format!(
4585            "select {} from sessions s where 1 = 1",
4586            session_record_columns!()
4587        );
4588        let mut params_vec = Vec::new();
4589        push_session_filters(&mut sql, &mut params_vec, filters, &self.access_scope);
4590        use std::fmt::Write as _;
4591        sql.push_str(" order by s.updated_at desc, s.id asc");
4592        if filters.limit != 0 {
4593            write!(sql, " limit {}", filters.limit)?;
4594            if offset != 0 {
4595                write!(sql, " offset {offset}")?;
4596            }
4597        } else if offset != 0 {
4598            write!(sql, " limit -1 offset {offset}")?;
4599        }
4600
4601        let mut stmt = self.conn.prepare(&sql)?;
4602        let rows = stmt.query_map(
4603            rusqlite::params_from_iter(params_vec.iter()),
4604            row_to_session_record,
4605        )?;
4606        let mut results = Vec::new();
4607        for row in rows {
4608            results.push(row?);
4609        }
4610        Ok(results)
4611    }
4612
4613    pub(crate) fn analysis_documents(
4614        &self,
4615        filters: &SearchFilters,
4616        cursor: Option<&crate::models::AnalysisCursor>,
4617    ) -> Result<crate::models::AnalysisDocumentPage> {
4618        self.validate_access_scope()?;
4619        if filters.limit == 0 {
4620            return Err(anyhow!(
4621                "analysis document page limit must be greater than zero"
4622            ));
4623        }
4624        // Keep session metadata and all per-session message reads on one SQLite snapshot.
4625        // The read-only transaction rolls back via RAII on every return/error path.
4626        let transaction = self.conn.unchecked_transaction()?;
4627        analysis_document_page(
4628            &transaction,
4629            filters,
4630            cursor,
4631            filters.limit,
4632            &self.access_scope,
4633        )
4634    }
4635
4636    /// Visit each matching session's metadata and attributable human-authored original text as an
4637    /// ordered row stream, in bounded keyset pages under one read snapshot. Message text is never
4638    /// concatenated or retained here, so a single session's aggregate human text no longer bounds
4639    /// memory. [`AnalysisRequest`] owns population selection independently of the in-memory page
4640    /// size. A successful visitor may inspect only a prefix of a session's message stream; this
4641    /// owner drains the remainder so the returned corpus digest always covers every selected human
4642    /// message or human-authored region of a mixed message.
4643    pub(crate) fn visit_analysis_sessions(
4644        &self,
4645        request: &AnalysisRequest,
4646        session_batch_size: std::num::NonZeroUsize,
4647        mut visitor: impl FnMut(
4648            crate::models::SessionRecord,
4649            i64,
4650            i64,
4651            &mut dyn Iterator<Item = Result<(i64, String)>>,
4652        ) -> Result<()>,
4653    ) -> Result<AnalysisTraversalReceipt> {
4654        self.validate_access_scope()?;
4655        let transaction = self.conn.unchecked_transaction()?;
4656        let mut count_stmt = transaction.prepare(ANALYSIS_MESSAGE_COUNTS_SQL)?;
4657        let mut human_message_stmt =
4658            transaction.prepare(ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL)?;
4659        let mut cursor = None;
4660        let mut selected_sessions = 0_u64;
4661        let mut messages_in_selected_sessions = 0_u64;
4662        let mut analyzed_user_messages = 0_u64;
4663        let mut has_more = false;
4664        let mut last_selected_session_id = None;
4665        let mut max_selected_session_updated_at = None;
4666        let mut corpus_digest = FramedSha256::new(b"aise-analysis-corpus-v1");
4667        loop {
4668            let remaining = match request.selection().max_sessions() {
4669                None => usize::MAX,
4670                Some(max_sessions) => max_sessions
4671                    .get()
4672                    .saturating_sub(selected_sessions.try_into().unwrap_or(usize::MAX)),
4673            };
4674            if remaining == 0 {
4675                break;
4676            }
4677            let limit = session_batch_size.get().min(remaining);
4678            let (sessions, next_cursor) = analysis_session_page(
4679                &transaction,
4680                request.scope(),
4681                cursor.as_ref(),
4682                limit,
4683                &self.access_scope,
4684            )?;
4685            if sessions.is_empty() {
4686                if next_cursor.is_some() {
4687                    // A pagination cursor with an empty page is a contradiction in
4688                    // analysis_session_page's own bookkeeping, not a data/environment problem —
4689                    // reindexing would not fix it. Point at filing a bug instead of a data-repair
4690                    // command that would not address the actual cause.
4691                    bail!(
4692                        "internal error: an analysis page returned a pagination cursor but no \
4693                         documents; this indicates a bug in aise's pagination logic — please \
4694                         file an issue at https://github.com/ahundt/ai-session-search/issues \
4695                         with the filters/query that triggered it, or send a pull request fixing \
4696                         analysis_session_page's cursor logic in db.rs"
4697                    );
4698                }
4699                break;
4700            }
4701            let page_len = sessions.len();
4702            for session in sessions {
4703                let (message_count, human_message_count) = count_stmt
4704                    .query_row([&session.id], |row| {
4705                        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
4706                    })?;
4707                corpus_digest.update_u8(0);
4708                corpus_digest.update_json(&session)?;
4709                corpus_digest.update_i64(message_count);
4710                corpus_digest.update_i64(human_message_count);
4711                messages_in_selected_sessions = messages_in_selected_sessions
4712                    .checked_add(u64::try_from(message_count)?)
4713                    .ok_or_else(|| anyhow!("analysis total message count overflow"))?;
4714                analyzed_user_messages = analyzed_user_messages
4715                    .checked_add(u64::try_from(human_message_count)?)
4716                    .ok_or_else(|| anyhow!("analysis human-authored message count overflow"))?;
4717                last_selected_session_id = Some(session.id.clone());
4718                if let Some(updated_at) = session.updated_at.or(session.created_at) {
4719                    max_selected_session_updated_at = Some(
4720                        max_selected_session_updated_at
4721                            .map_or(updated_at, |current: DateTime<Utc>| current.max(updated_at)),
4722                    );
4723                }
4724                let session_id = session.id.clone();
4725                let mut rows = human_message_stmt
4726                    .query_map([&session.id], |row| {
4727                        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
4728                    })?
4729                    .map(|row| {
4730                        let (seq, content) = row.map_err(anyhow::Error::from)?;
4731                        corpus_digest.update_u8(1);
4732                        corpus_digest.update_bytes(session_id.as_bytes());
4733                        corpus_digest.update_i64(seq);
4734                        corpus_digest.update_bytes(content.as_bytes());
4735                        Ok((seq, content))
4736                    });
4737                visitor(session, message_count, human_message_count, &mut rows)?;
4738                for unread in rows {
4739                    unread?;
4740                }
4741            }
4742            selected_sessions = selected_sessions
4743                .checked_add(page_len as u64)
4744                .ok_or_else(|| anyhow!("analysis document count overflow"))?;
4745            if request
4746                .selection()
4747                .max_sessions()
4748                .is_some_and(|maximum| selected_sessions >= maximum.get() as u64)
4749            {
4750                has_more = next_cursor.is_some();
4751                break;
4752            }
4753            let Some(next_cursor) = next_cursor else {
4754                break;
4755            };
4756            if cursor
4757                .as_ref()
4758                .is_some_and(|previous: &crate::models::AnalysisCursor| {
4759                    previous.as_str() >= next_cursor.as_str()
4760                })
4761            {
4762                // Same reasoning as the empty-page case above: a non-advancing cursor is an
4763                // internal pagination bug, not a data problem.
4764                bail!(
4765                    "internal error: analysis pagination did not advance to a new cursor; this \
4766                     indicates a bug in aise's pagination logic — please file an issue at \
4767                     https://github.com/ahundt/ai-session-search/issues with the filters/query \
4768                     that triggered it, or send a pull request fixing analysis_session_page's \
4769                     cursor logic in db.rs"
4770                );
4771            }
4772            cursor = Some(next_cursor);
4773        }
4774        Ok(AnalysisTraversalReceipt {
4775            selected_sessions,
4776            messages_in_selected_sessions,
4777            analyzed_user_messages,
4778            has_more,
4779            last_selected_session_id,
4780            max_selected_session_updated_at,
4781            corpus_digest: corpus_digest.finish(),
4782        })
4783    }
4784
4785    pub fn search(
4786        &self,
4787        query: &str,
4788        filters: &SearchFilters,
4789        current_repo: Option<&str>,
4790        scoring: &crate::config::ScoringConfig,
4791    ) -> Result<Vec<SearchHit>> {
4792        self.search_with_cancellation(query, filters, current_repo, scoring, None)
4793    }
4794
4795    pub(crate) fn search_cancellable(
4796        &self,
4797        query: &str,
4798        filters: &SearchFilters,
4799        current_repo: Option<&str>,
4800        scoring: &crate::config::ScoringConfig,
4801        cancellation: &QueryCancellation,
4802    ) -> Result<Vec<SearchHit>> {
4803        self.search_with_cancellation(query, filters, current_repo, scoring, Some(cancellation))
4804    }
4805
4806    fn search_with_cancellation(
4807        &self,
4808        query: &str,
4809        filters: &SearchFilters,
4810        current_repo: Option<&str>,
4811        scoring: &crate::config::ScoringConfig,
4812        cancellation: Option<&QueryCancellation>,
4813    ) -> Result<Vec<SearchHit>> {
4814        self.validate_access_scope()?;
4815        if let Some(cancellation) = cancellation {
4816            cancellation.ensure_active()?;
4817        }
4818        let query_lower = fold_caseless(query);
4819        let tokens: Vec<&str> = query_lower.split_whitespace().collect();
4820        let query_needle = UnicodeLowerNeedle::from_lowered(&query_lower);
4821        let token_needles = tokens
4822            .iter()
4823            .map(|token| UnicodeLowerNeedle::from_lowered(token))
4824            .collect::<Vec<_>>();
4825        let context = SessionScoreContext {
4826            query,
4827            query_needle: &query_needle,
4828            tokens: &tokens,
4829            token_needles: &token_needles,
4830            current_repo,
4831            scoring,
4832            cancellation,
4833            now: Utc::now(),
4834        };
4835        let mut hits = Vec::new();
4836        let ranked_limit = filters.limit;
4837        let mut sql = format!(
4838            "select {}, coalesce(t.transcript_text, '') as transcript_text
4839               from sessions s
4840               left join transcripts t on t.session_id = s.id
4841              where 1 = 1",
4842            session_record_columns!()
4843        );
4844        let mut params_vec = Vec::new();
4845        push_session_filters(&mut sql, &mut params_vec, filters, &self.access_scope);
4846        sql.push_str(" order by s.id asc");
4847        let mut stmt = self.conn.prepare(&sql)?;
4848        let candidates = stmt.query_map(
4849            rusqlite::params_from_iter(params_vec.iter()),
4850            row_to_session_with_transcript,
4851        )?;
4852
4853        let mut batch = Vec::with_capacity(SESSION_SCORE_BATCH_SIZE);
4854        let mut batch_bytes = 0_usize;
4855        for record in candidates {
4856            if let Some(cancellation) = cancellation {
4857                cancellation.ensure_active()?;
4858            }
4859            let record = record?;
4860            batch_bytes = batch_bytes.saturating_add(session_search_record_bytes(&record));
4861            batch.push(record);
4862            if batch.len() == SESSION_SCORE_BATCH_SIZE || batch_bytes >= SESSION_SCORE_BATCH_BYTES {
4863                let scored = self
4864                    .runtime
4865                    .install(|| score_session_records(std::mem::take(&mut batch), &context))??;
4866                if let Some(cancellation) = cancellation {
4867                    cancellation.ensure_active()?;
4868                }
4869                batch_bytes = 0;
4870                hits.extend(scored);
4871                if ranked_limit > 0 && hits.len() >= top_k_compaction_threshold(ranked_limit) {
4872                    retain_top_session_hits(&mut hits, ranked_limit);
4873                }
4874            }
4875        }
4876        if !batch.is_empty() {
4877            hits.extend(
4878                self.runtime
4879                    .install(|| score_session_records(batch, &context))??,
4880            );
4881        }
4882        if let Some(cancellation) = cancellation {
4883            cancellation.ensure_active()?;
4884        }
4885
4886        if ranked_limit > 0 {
4887            retain_top_session_hits(&mut hits, ranked_limit);
4888        }
4889        if let Some(cancellation) = cancellation {
4890            cancellation.ensure_active()?;
4891        }
4892        hits.sort_by(compare_session_hits);
4893        if let Some(cancellation) = cancellation {
4894            cancellation.ensure_active()?;
4895        }
4896        Ok(hits)
4897    }
4898
4899    pub fn resolve_session(&self, value: &str) -> Result<SessionWithTranscript> {
4900        let exact = self.session_resolution_matches(
4901            RESOLVE_SESSION_EXACT_SQL,
4902            value,
4903            row_to_session_with_transcript,
4904        )?;
4905        if !exact.is_empty() {
4906            return unique_session_match(value, exact, |session| &session.session.id);
4907        }
4908        let prefix = literal_like_prefix_pattern(value);
4909        let matches = self.session_resolution_matches(
4910            RESOLVE_SESSION_PREFIX_SQL,
4911            &prefix,
4912            row_to_session_with_transcript,
4913        )?;
4914        unique_session_match(value, matches, |session| &session.session.id)
4915    }
4916
4917    pub fn resolve_session_record(&self, value: &str) -> Result<SessionRecord> {
4918        let exact = self.session_resolution_matches(
4919            RESOLVE_SESSION_RECORD_EXACT_SQL,
4920            value,
4921            row_to_session_record,
4922        )?;
4923        if !exact.is_empty() {
4924            return unique_session_match(value, exact, |session| &session.id);
4925        }
4926        let prefix = literal_like_prefix_pattern(value);
4927        let matches = self.session_resolution_matches(
4928            RESOLVE_SESSION_RECORD_PREFIX_SQL,
4929            &prefix,
4930            row_to_session_record,
4931        )?;
4932        unique_session_match(value, matches, |session| &session.id)
4933    }
4934
4935    /// Inspect one session's canonical transcript without cloning the complete TEXT value into
4936    /// a `String`. The callback's borrow cannot escape the SQLite row, so retained memory is
4937    /// determined by the caller's output rather than by transcript size. This is the same
4938    /// transcript authority used by `resolve_session`, CLI `show`, MCP `get_session`, and export.
4939    pub(crate) fn inspect_session_transcript<T>(
4940        &self,
4941        value: &str,
4942        cancellation: &QueryCancellation,
4943        inspect: impl FnOnce(&SessionRecord, &str) -> Result<T>,
4944    ) -> Result<T> {
4945        self.with_read_snapshot(|| {
4946            let session = self.resolve_session_record(value)?;
4947            cancellation.ensure_active()?;
4948            let mut statement = self.conn.prepare(
4949                "select coalesce(transcript_text, '') from transcripts where session_id = ?1",
4950            )?;
4951            let mut rows = statement.query([&session.id])?;
4952            let transcript = match rows.next()? {
4953                Some(row) => row.get_ref(0)?.as_str()?,
4954                None => "",
4955            };
4956            cancellation.ensure_active()?;
4957            let result = inspect(&session, transcript);
4958            drop(rows);
4959            drop(statement);
4960            result
4961        })
4962    }
4963
4964    /// Query one exact or prefix session-resolution stage under the current access authority.
4965    ///
4966    /// Exact lookup is attempted first by callers, allowing SQLite to use the canonical-id and
4967    /// provider-native-id indexes in `O(log S + K)` planner work. Only an exact miss reaches the
4968    /// prefix query, whose case-insensitive `LIKE` fallback may scan `O(S)`. Returned memory is
4969    /// `O(K)` candidate records, capped only by the authorized matching corpus.
4970    fn session_resolution_matches<T>(
4971        &self,
4972        base_sql: &str,
4973        value: &str,
4974        mapper: fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
4975    ) -> Result<Vec<T>> {
4976        self.access_scope.validate_stable()?;
4977        let mut sql = base_sql.to_string();
4978        let mut args = vec![rusqlite::types::Value::Text(value.to_string())];
4979        push_access_scope(&mut sql, &mut args, "s.id", &self.access_scope);
4980        let mut stmt = self.conn.prepare(&sql)?;
4981        let rows = stmt.query_map(rusqlite::params_from_iter(args.iter()), mapper)?;
4982        let mut matches = Vec::new();
4983        for row in rows {
4984            matches.push(row?);
4985        }
4986        Ok(matches)
4987    }
4988
4989    pub fn count_parse_warnings(&self) -> Result<i64> {
4990        self.conn
4991            .query_row(
4992                "select count(*) from sessions where parse_warning is not null and parse_warning != ''",
4993                [],
4994                |row| row.get(0),
4995            )
4996            .map_err(Into::into)
4997    }
4998
4999    pub fn parser_health(&self) -> Result<ParserHealth> {
5000        let schema_version: i64 = self
5001            .conn
5002            .query_row("pragma user_version", [], |row| row.get(0))?;
5003        let mut stmt = self.conn.prepare(
5004            "select provider, parse_version, count(*) from sessions
5005             group by provider, parse_version",
5006        )?;
5007        let grouped = stmt
5008            .query_map([], |row| {
5009                Ok((
5010                    row.get::<_, String>(0)?,
5011                    row.get::<_, String>(1)?,
5012                    row.get::<_, i64>(2)?,
5013                ))
5014            })?
5015            .collect::<rusqlite::Result<Vec<_>>>()?;
5016
5017        let providers = crate::source::PROVIDERS
5018            .into_iter()
5019            .map(|provider| {
5020                let expected = crate::util::provider_parse_version(provider);
5021                let mut indexed_sessions = 0;
5022                let mut current_sessions = 0;
5023                for (stored_provider, parse_version, count) in &grouped {
5024                    if stored_provider == provider.as_str() {
5025                        indexed_sessions += count;
5026                        if parse_version == expected {
5027                            current_sessions += count;
5028                        }
5029                    }
5030                }
5031                ProviderParserHealth {
5032                    provider,
5033                    expected_parse_version: expected.to_string(),
5034                    indexed_sessions,
5035                    current_sessions,
5036                    stale_sessions: indexed_sessions - current_sessions,
5037                }
5038            })
5039            .collect::<Vec<_>>();
5040        let indexed_sessions = providers.iter().map(|item| item.indexed_sessions).sum();
5041        let current_sessions = providers.iter().map(|item| item.current_sessions).sum();
5042
5043        Ok(ParserHealth {
5044            schema_version,
5045            expected_schema_version: SCHEMA_VERSION,
5046            schema_current: schema_version == SCHEMA_VERSION,
5047            indexed_sessions,
5048            current_sessions,
5049            stale_sessions: indexed_sessions - current_sessions,
5050            parse_warnings: self.count_parse_warnings()?,
5051            providers,
5052        })
5053    }
5054
5055    pub(crate) fn stale_session_sources(&self) -> Result<Vec<(Provider, String)>> {
5056        let mut stmt = self
5057            .conn
5058            .prepare("select provider, parse_version, source_path from sessions")?;
5059        let rows = stmt
5060            .query_map([], |row| {
5061                Ok((
5062                    row.get::<_, String>(0)?,
5063                    row.get::<_, String>(1)?,
5064                    row.get::<_, String>(2)?,
5065                ))
5066            })?
5067            .collect::<rusqlite::Result<Vec<_>>>()?;
5068        rows.into_iter()
5069            .filter_map(|(provider, parse_version, source_path)| {
5070                let provider =
5071                    match decode_db_enum(provider, 0, "sessions.provider", Provider::from_db_str) {
5072                        Ok(provider) => provider,
5073                        Err(error) => return Some(Err(error.into())),
5074                    };
5075                (parse_version != crate::util::provider_parse_version(provider))
5076                    .then_some(Ok((provider, source_path)))
5077            })
5078            .collect()
5079    }
5080
5081    pub fn session_time_profile(&self, session_id: &str) -> Result<SessionTimeProfile> {
5082        use rusqlite::types::Value;
5083
5084        self.validate_access_scope()?;
5085        let mut sql = String::from(
5086            "with ordered as (
5087                 select ts, kind, lag(ts) over (order by seq) as previous_ts
5088                 from messages where session_id = ?",
5089        );
5090        let mut args = vec![Value::Text(session_id.to_string())];
5091        push_access_scope(&mut sql, &mut args, "session_id", &self.access_scope);
5092        sql.push_str(
5093            ") select count(*), count(ts), min(ts), max(ts),
5094                    max(case when previous_ts is null or ts is null then null
5095                             else unixepoch(ts) - unixepoch(previous_ts) end),
5096                    coalesce(sum(kind = 'tool_call'), 0),
5097                    coalesce(sum(kind = 'tool_result'), 0)
5098               from ordered",
5099        );
5100        let row = self
5101            .conn
5102            .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
5103                Ok((
5104                    row.get::<_, i64>(0)?,
5105                    row.get::<_, i64>(1)?,
5106                    row.get::<_, Option<String>>(2)?,
5107                    row.get::<_, Option<String>>(3)?,
5108                    row.get::<_, Option<i64>>(4)?,
5109                    row.get::<_, i64>(5)?,
5110                    row.get::<_, i64>(6)?,
5111                ))
5112            })?;
5113        let parse_ts = |value: Option<String>| {
5114            value.and_then(|value| {
5115                chrono::DateTime::parse_from_rfc3339(&value)
5116                    .ok()
5117                    .map(|timestamp| timestamp.with_timezone(&Utc))
5118            })
5119        };
5120        let first_timestamp = parse_ts(row.2);
5121        let last_timestamp = parse_ts(row.3);
5122        Ok(SessionTimeProfile {
5123            messages: row.0,
5124            timestamped_messages: row.1,
5125            undated_messages: row.0 - row.1,
5126            observed_span_seconds: first_timestamp
5127                .zip(last_timestamp)
5128                .map(|(first, last)| (last - first).num_seconds()),
5129            first_timestamp,
5130            last_timestamp,
5131            max_message_gap_seconds: row.4,
5132            tool_calls: row.5,
5133            tool_results: row.6,
5134        })
5135    }
5136
5137    pub fn counts_by_provider(&self) -> Result<HashMap<String, i64>> {
5138        let mut stmt = self
5139            .conn
5140            .prepare("select provider, count(*) from sessions group by provider")?;
5141        let rows = stmt.query_map([], |row| {
5142            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
5143        })?;
5144        let mut out = HashMap::new();
5145        for row in rows {
5146            let (provider, count) = row?;
5147            out.insert(provider, count);
5148        }
5149        Ok(out)
5150    }
5151}
5152
5153const ANALYSIS_MESSAGE_COUNTS_SQL: &str = "select count(*),
5154            coalesce(sum(
5155                case
5156                    when m.record_relation = 'original'
5157                     and (
5158                        m.authorship = 'human'
5159                        or (
5160                            m.authorship = 'mixed'
5161                            and exists (
5162                                select 1
5163                                from message_content_parts p
5164                                where p.message_id = m.id and p.authorship = 'human'
5165                            )
5166                        )
5167                     )
5168                    then 1
5169                    else 0
5170                end
5171            ), 0)
5172     from messages m
5173     where m.session_id = ?1";
5174
5175/// One ordered text row per original message with attributable human-authored content.
5176///
5177/// SQLite `substr` counts Unicode code points, matching the persisted Unicode-scalar part
5178/// coordinates. The ordered compound subquery concatenates only human parts before grouping, so
5179/// generated/tool/harness regions never cross into the analysis process. Both message branches
5180/// use `idx_messages_session_authorship_relation_seq`; part lookup uses the table primary key.
5181///
5182/// Let `M` be candidate messages and `P_h` selected human parts. Time is `O(log M + M + P_h)` plus
5183/// emitted text bytes, and retained SQLite state is bounded by one session's selected part rows.
5184const ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL: &str = "select seq, group_concat(fragment, '')
5185     from (
5186         select m.id as message_id, m.seq as seq, -1 as ordinal, m.content as fragment
5187         from messages m
5188         where m.session_id = ?1
5189           and m.authorship = 'human'
5190           and m.record_relation = 'original'
5191         union all
5192         select m.id as message_id, m.seq as seq, p.ordinal as ordinal,
5193                substr(m.content, p.start_char + 1, p.end_char - p.start_char) as fragment
5194         from messages m
5195         join message_content_parts p on p.message_id = m.id
5196         where m.session_id = ?1
5197           and m.authorship = 'mixed'
5198           and m.record_relation = 'original'
5199           and p.authorship = 'human'
5200         order by seq, message_id, ordinal
5201     )
5202     group by message_id, seq
5203     order by seq";
5204
5205/// One keyset page of matching session records, ordered by canonical session ID.
5206fn analysis_session_page(
5207    transaction: &rusqlite::Transaction<'_>,
5208    filters: &SearchFilters,
5209    cursor: Option<&crate::models::AnalysisCursor>,
5210    page_limit: usize,
5211    access: &crate::search_scope::EffectiveAccessScope,
5212) -> Result<(Vec<SessionRecord>, Option<crate::models::AnalysisCursor>)> {
5213    use crate::models::AnalysisCursor;
5214    use std::fmt::Write as _;
5215
5216    if page_limit == 0 {
5217        bail!("analysis document page limit must be greater than zero");
5218    }
5219    let fetch_limit = page_limit
5220        .checked_add(1)
5221        .ok_or_else(|| anyhow!("analysis document page limit is too large"))?;
5222    let mut sql = format!(
5223        "select {} from sessions s where 1 = 1",
5224        session_record_columns!()
5225    );
5226    let mut params_vec = Vec::new();
5227    push_session_filters(&mut sql, &mut params_vec, filters, access);
5228    if let Some(cursor) = cursor {
5229        sql.push_str(" and s.id > ? ");
5230        params_vec.push(cursor.as_str().to_string());
5231    }
5232    write!(sql, " order by s.id asc limit {fetch_limit}")?;
5233
5234    let mut session_stmt = transaction.prepare(&sql)?;
5235    let session_rows = session_stmt.query_map(
5236        rusqlite::params_from_iter(params_vec.iter()),
5237        row_to_session_record,
5238    )?;
5239    let mut sessions = Vec::new();
5240    for row in session_rows {
5241        sessions.push(row?);
5242    }
5243    let has_more = sessions.len() > page_limit;
5244    sessions.truncate(page_limit);
5245    let next_cursor = has_more
5246        .then(|| sessions.last().map(|session| session.id.clone()))
5247        .flatten()
5248        .map(AnalysisCursor::after);
5249    Ok((sessions, next_cursor))
5250}
5251
5252fn analysis_document_page(
5253    transaction: &rusqlite::Transaction<'_>,
5254    filters: &SearchFilters,
5255    cursor: Option<&crate::models::AnalysisCursor>,
5256    page_limit: usize,
5257    access: &crate::search_scope::EffectiveAccessScope,
5258) -> Result<crate::models::AnalysisDocumentPage> {
5259    use crate::models::{AnalysisDocument, AnalysisDocumentPage};
5260
5261    let (sessions, next_cursor) =
5262        analysis_session_page(transaction, filters, cursor, page_limit, access)?;
5263    let mut count_stmt = transaction.prepare(ANALYSIS_MESSAGE_COUNTS_SQL)?;
5264    let mut human_message_stmt = transaction.prepare(ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL)?;
5265    let mut documents = Vec::with_capacity(sessions.len());
5266    for session in sessions {
5267        let (message_count, human_message_count) = count_stmt.query_row([&session.id], |row| {
5268            Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
5269        })?;
5270        let rows = human_message_stmt.query_map([&session.id], |row| {
5271            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
5272        })?;
5273        let mut user_text = String::new();
5274        let mut first_user_text = None;
5275        for row in rows {
5276            let (_seq, content) = row?;
5277            if first_user_text.is_none() {
5278                first_user_text = Some(content.clone());
5279            }
5280            if !user_text.is_empty() {
5281                user_text.push(' ');
5282            }
5283            user_text.push_str(&content);
5284        }
5285        documents.push(AnalysisDocument {
5286            session,
5287            user_text,
5288            first_user_text,
5289            message_count,
5290            user_message_count: human_message_count,
5291        });
5292    }
5293    Ok(AnalysisDocumentPage {
5294        documents,
5295        next_cursor,
5296    })
5297}
5298
5299fn push_session_filters(
5300    sql: &mut String,
5301    params_vec: &mut Vec<String>,
5302    filters: &SearchFilters,
5303    access: &crate::search_scope::EffectiveAccessScope,
5304) {
5305    if let Some(provider) = filters.provider {
5306        sql.push_str(" and s.provider = ? ");
5307        params_vec.push(provider.as_str().to_string());
5308    }
5309    if let Some(path_prefix) = &filters.path_prefix {
5310        push_session_path_prefix(sql, params_vec, path_prefix);
5311    }
5312    push_session_access_scope(sql, params_vec, access);
5313    push_session_exclusions(sql, params_vec, filters);
5314    push_session_kinds(sql, filters);
5315    if let Some(parent_session_id) = &filters.parent_session_id {
5316        sql.push_str(" and s.parent_session_id = ? ");
5317        params_vec.push(parent_session_id.clone());
5318    }
5319    push_session_time_window(sql, params_vec, filters.since, filters.until);
5320    if filters.warnings_only {
5321        sql.push_str(" and s.parse_warning is not null and s.parse_warning != '' ");
5322    }
5323}
5324
5325/// RFC3339 text for an inclusive UPPER date bound. `dates.rs` already expands imprecise calendar,
5326/// hour, and minute periods to the final nanosecond while preserving explicit RFC3339 instants.
5327/// Always rendering nine fractional digits makes lexicographic SQLite comparison agree with
5328/// chronological order and preserves an exact fractional bound for race reconstruction.
5329fn until_bound_text(until: chrono::DateTime<Utc>) -> String {
5330    until.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
5331}
5332
5333/// Least string ordering after every string that starts with `prefix`, so `term >= prefix and
5334/// term < bound` selects exactly the terms extending it. UTF-8 bytes sort in code-point order in
5335/// both SQLite's BINARY collation and Rust, so the two agree on the range.
5336///
5337/// Returns None when the prefix is one or more `char::MAX` and no greater string exists; the
5338/// caller then applies the lower bound alone, which is still exact because nothing sorts after it.
5339/// Prefer this over `glob 'prefix*'`, which reads `*`, `?`, and `[` in the prefix as wildcards.
5340fn prefix_upper_bound(prefix: &str) -> Option<String> {
5341    let mut head = prefix.to_string();
5342    while let Some(last) = head.pop() {
5343        // 0xD800..=0xDFFF are UTF-16 surrogates rather than scalar values, so step over the block.
5344        let next = match u32::from(last) + 1 {
5345            0xD800 => 0xE000,
5346            code => code,
5347        };
5348        if let Some(next) = char::from_u32(next) {
5349            head.push(next);
5350            return Some(head);
5351        }
5352    }
5353    None
5354}
5355
5356/// Append the `path_prefix` predicate — restrict rows to sessions rooted at the prefix — onto a
5357/// query whose rows expose `session_id` as `id_col` (e.g. `m.session_id` or a bare `session_id`).
5358/// The `sessions` table is tiny relative to messages/file edits, so a subquery is cheap and needs
5359/// no dedicated index. Mirrors the session-level `path_prefix` semantics in `list_recent`/`search`
5360/// (exact directory or a child path, with LIKE metacharacters escaped) so `--path` behaves
5361/// identically across session, message, analytics, and file surfaces. No-op when `path_prefix` is
5362/// None.
5363fn push_path_prefix(
5364    sql: &mut String,
5365    args: &mut Vec<rusqlite::types::Value>,
5366    id_col: &str,
5367    path_prefix: Option<&str>,
5368) {
5369    use std::fmt::Write as _;
5370    if let Some(prefix) = path_prefix {
5371        let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5372        push_path_condition(sql, args, prefix);
5373        sql.push(')');
5374    }
5375}
5376
5377fn push_path_condition(sql: &mut String, args: &mut Vec<rusqlite::types::Value>, prefix: &str) {
5378    use rusqlite::types::Value;
5379    sql.push_str(
5380        "(coalesce(cwd, '') = ? or coalesce(cwd, '') like ? escape '\\' \
5381          or coalesce(repo_root, '') = ? or coalesce(repo_root, '') like ? escape '\\' \
5382          or coalesce(source_path, '') = ? or coalesce(source_path, '') like ? escape '\\')",
5383    );
5384    let (exact, child_pattern) = path_prefix_patterns(prefix);
5385    args.push(Value::Text(exact.clone()));
5386    args.push(Value::Text(child_pattern.clone()));
5387    args.push(Value::Text(exact.clone()));
5388    args.push(Value::Text(child_pattern.clone()));
5389    args.push(Value::Text(exact));
5390    args.push(Value::Text(child_pattern));
5391}
5392
5393#[derive(Clone, Copy)]
5394enum SessionPathDomain {
5395    Workspace,
5396    Transcript,
5397}
5398
5399fn push_domain_path_prefix(
5400    sql: &mut String,
5401    args: &mut Vec<rusqlite::types::Value>,
5402    id_col: &str,
5403    prefix: Option<&str>,
5404    domain: SessionPathDomain,
5405) {
5406    use std::fmt::Write as _;
5407    if let Some(prefix) = prefix {
5408        let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5409        push_domain_path_condition(sql, args, prefix, domain);
5410        sql.push(')');
5411    }
5412}
5413
5414fn push_domain_path_condition(
5415    sql: &mut String,
5416    args: &mut Vec<rusqlite::types::Value>,
5417    prefix: &str,
5418    domain: SessionPathDomain,
5419) {
5420    use rusqlite::types::Value;
5421    let (exact, child_pattern) = path_prefix_patterns(prefix);
5422    match domain {
5423        SessionPathDomain::Workspace => {
5424            sql.push_str(
5425                "(coalesce(cwd, '') = ? or coalesce(cwd, '') like ? escape '\\' \
5426                  or coalesce(repo_root, '') = ? or coalesce(repo_root, '') like ? escape '\\')",
5427            );
5428            args.push(Value::Text(exact.clone()));
5429            args.push(Value::Text(child_pattern.clone()));
5430            args.push(Value::Text(exact));
5431            args.push(Value::Text(child_pattern));
5432        }
5433        SessionPathDomain::Transcript => {
5434            sql.push_str(
5435                "(coalesce(source_path, '') = ? or coalesce(source_path, '') like ? escape '\\')",
5436            );
5437            args.push(Value::Text(exact));
5438            args.push(Value::Text(child_pattern));
5439        }
5440    }
5441}
5442
5443fn push_access_scope(
5444    sql: &mut String,
5445    args: &mut Vec<rusqlite::types::Value>,
5446    id_col: &str,
5447    access: &crate::search_scope::EffectiveAccessScope,
5448) {
5449    use std::fmt::Write as _;
5450    let prefixes: Vec<&str> = access.workspace_prefixes().collect();
5451    if prefixes.is_empty() {
5452        return;
5453    }
5454    let _ = write!(sql, " and {id_col} in (select id from sessions where ");
5455    for (index, prefix) in prefixes.into_iter().enumerate() {
5456        if index > 0 {
5457            sql.push_str(" or ");
5458        }
5459        push_domain_path_condition(sql, args, prefix, SessionPathDomain::Workspace);
5460    }
5461    sql.push(')');
5462}
5463
5464fn push_session_access_scope(
5465    sql: &mut String,
5466    args: &mut Vec<String>,
5467    access: &crate::search_scope::EffectiveAccessScope,
5468) {
5469    let prefixes: Vec<&str> = access.workspace_prefixes().collect();
5470    if prefixes.is_empty() {
5471        return;
5472    }
5473    sql.push_str(" and (");
5474    for (index, prefix) in prefixes.into_iter().enumerate() {
5475        if index > 0 {
5476            sql.push_str(" or ");
5477        }
5478        let (exact, child_pattern) = path_prefix_patterns(prefix);
5479        sql.push_str(
5480            "(coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\' \
5481              or coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\')",
5482        );
5483        args.push(exact.clone());
5484        args.push(child_pattern.clone());
5485        args.push(exact);
5486        args.push(child_pattern);
5487    }
5488    sql.push(')');
5489}
5490
5491fn push_exclude_domain_path_prefixes(
5492    sql: &mut String,
5493    args: &mut Vec<rusqlite::types::Value>,
5494    id_col: &str,
5495    prefixes: &[String],
5496    domain: SessionPathDomain,
5497) {
5498    use std::fmt::Write as _;
5499    if prefixes.is_empty() {
5500        return;
5501    }
5502    let _ = write!(sql, " and {id_col} not in (select id from sessions where ");
5503    for (index, prefix) in prefixes.iter().enumerate() {
5504        if index > 0 {
5505            sql.push_str(" or ");
5506        }
5507        push_domain_path_condition(sql, args, prefix, domain);
5508    }
5509    sql.push(')');
5510}
5511
5512fn push_exclude_path_prefixes(
5513    sql: &mut String,
5514    args: &mut Vec<rusqlite::types::Value>,
5515    id_col: &str,
5516    prefixes: &[String],
5517) {
5518    use std::fmt::Write as _;
5519    if prefixes.is_empty() {
5520        return;
5521    }
5522    let _ = write!(sql, " and {id_col} not in (select id from sessions where ");
5523    for (i, prefix) in prefixes.iter().enumerate() {
5524        if i > 0 {
5525            sql.push_str(" or ");
5526        }
5527        push_path_condition(sql, args, prefix);
5528    }
5529    sql.push(')');
5530}
5531
5532/// Append the structural message predicates shared by [`Db::search_messages`] and
5533/// [`Db::explain_message_search`] — role, provider, session, tool name, the date
5534/// window, and the compaction filter — all ANDed onto an existing WHERE using the
5535/// `m` table alias. Centralizing this guarantees the `explain` candidate count is
5536/// computed over exactly the rows `search_messages` scans (no filter drift between
5537/// the two as filters are added).
5538/// Which message-class clause [`append_message_filters_with_class`] emits. Every search and
5539/// count uses [`Self::FromFilters`]; the two other selections exist only so the receipt's corpus
5540/// count can be written as `count(unrestricted) - count(only the excluded classes)`, both halves
5541/// of which existing indexes answer without reading message rows.
5542#[derive(Debug, Clone, Copy)]
5543enum ClassSelection<'a> {
5544    /// The class predicate the filters resolve to ([`MessageFilters::kind_predicate`]).
5545    FromFilters,
5546    /// No class clause at all: every stored `kind`, known to this build or not.
5547    Unrestricted,
5548    /// Exactly these classes (`kind in (...)`).
5549    Only(&'a [crate::models::MessageKind]),
5550}
5551
5552fn append_message_filters(
5553    sql: &mut String,
5554    args: &mut Vec<rusqlite::types::Value>,
5555    filters: &MessageFilters,
5556    access: &crate::search_scope::EffectiveAccessScope,
5557) {
5558    append_message_filters_with_class(sql, args, filters, access, ClassSelection::FromFilters);
5559}
5560
5561fn append_message_filters_with_class(
5562    sql: &mut String,
5563    args: &mut Vec<rusqlite::types::Value>,
5564    filters: &MessageFilters,
5565    access: &crate::search_scope::EffectiveAccessScope,
5566    class: ClassSelection<'_>,
5567) {
5568    use rusqlite::types::Value;
5569    if let Some(role) = filters.role {
5570        sql.push_str(" and m.role = ?");
5571        args.push(Value::Text(role.as_str().to_string()));
5572    }
5573    // One clause decides which classes come back. `kinds` and `no_compaction` are resolved
5574    // into a single set first, so no combination can both select and exclude a class.
5575    //
5576    // PATTERN: do not add a second class predicate here. A `kind != 'harness_notice'` clause
5577    // was added beside this one and removed: combined with `kind = 'harness_notice'` it
5578    // produced an always-empty result that reads as "no such messages exist". The removed
5579    // `is_compaction = 0` clause was the same redundancy, since role Compaction always infers
5580    // kind Compaction. Class selection changes belong in `MessageFilters::effective_kinds`.
5581    let predicate = match class {
5582        ClassSelection::FromFilters => Some(filters.kind_predicate()),
5583        ClassSelection::Unrestricted => None,
5584        ClassSelection::Only(kinds) => Some(crate::models::KindPredicate::Only(kinds.to_vec())),
5585    };
5586    match predicate {
5587        None => {}
5588        Some(crate::models::KindPredicate::AllExcept(excluded)) => {
5589            for kind in excluded {
5590                sql.push_str(" and m.kind != ?");
5591                args.push(Value::Text(kind.as_str().to_string()));
5592            }
5593        }
5594        Some(crate::models::KindPredicate::Only(kinds)) if kinds.is_empty() => {
5595            // Every named class was removed. Match nothing rather than silently matching all.
5596            sql.push_str(" and 0");
5597        }
5598        Some(crate::models::KindPredicate::Only(kinds)) => {
5599            sql.push_str(" and m.kind in (");
5600            for (i, kind) in kinds.iter().enumerate() {
5601                if i > 0 {
5602                    sql.push_str(", ");
5603                }
5604                sql.push('?');
5605                args.push(Value::Text(kind.as_str().to_string()));
5606            }
5607            sql.push(')');
5608        }
5609    }
5610    if let Some(providers) = &filters.providers {
5611        if providers.is_empty() {
5612            sql.push_str(" and 0");
5613        } else {
5614            sql.push_str(" and m.provider in (");
5615            for (index, provider) in providers.iter().enumerate() {
5616                if index > 0 {
5617                    sql.push_str(", ");
5618                }
5619                sql.push('?');
5620                args.push(Value::Text(provider.as_str().to_string()));
5621            }
5622            sql.push(')');
5623        }
5624    }
5625    if let Some(session_id) = &filters.session_id {
5626        sql.push_str(" and m.session_id = ?");
5627        args.push(Value::Text(session_id.clone()));
5628    }
5629    push_message_session_kinds(sql, "m.session_id", filters.session_kinds.as_ref());
5630    push_access_scope(sql, args, "m.session_id", access);
5631    push_path_prefix(sql, args, "m.session_id", filters.path_prefix.as_deref());
5632    push_exclude_path_prefixes(sql, args, "m.session_id", &filters.exclude_path_prefixes);
5633    push_domain_path_prefix(
5634        sql,
5635        args,
5636        "m.session_id",
5637        filters.workspace_path_prefix.as_deref(),
5638        SessionPathDomain::Workspace,
5639    );
5640    push_domain_path_prefix(
5641        sql,
5642        args,
5643        "m.session_id",
5644        filters.transcript_path_prefix.as_deref(),
5645        SessionPathDomain::Transcript,
5646    );
5647    push_exclude_domain_path_prefixes(
5648        sql,
5649        args,
5650        "m.session_id",
5651        &filters.exclude_workspace_path_prefixes,
5652        SessionPathDomain::Workspace,
5653    );
5654    push_exclude_domain_path_prefixes(
5655        sql,
5656        args,
5657        "m.session_id",
5658        &filters.exclude_transcript_path_prefixes,
5659        SessionPathDomain::Transcript,
5660    );
5661    for session_id in &filters.exclude_session_ids {
5662        sql.push_str(" and m.session_id <> ?");
5663        args.push(Value::Text(session_id.clone()));
5664    }
5665    if let Some(tool) = &filters.tool {
5666        // NULL tool_name rows are excluded because the scalar predicate returns false.
5667        sql.push_str(" and unicode_lower_contains(m.tool_name, ?)");
5668        args.push(Value::Text(fold_caseless(tool)));
5669    }
5670    if let Some(seq_from) = filters.seq_from {
5671        sql.push_str(" and m.seq >= ?");
5672        args.push(Value::Integer(seq_from));
5673    }
5674    if let Some(seq_to) = filters.seq_to {
5675        sql.push_str(" and m.seq <= ?");
5676        args.push(Value::Integer(seq_to));
5677    }
5678    push_ts_window(sql, args, "m.ts", filters.since, filters.until);
5679    // Class selection, including compaction and harness notices, is applied by the single
5680    // `m.kind in (...)` clause above. `is_compaction` is left unused here because it is
5681    // redundant with `kind`: role Compaction always infers kind Compaction, so filtering on
5682    // both would be two sources of truth for one fact.
5683}
5684
5685/// Insert message rows and their nonduplicating content-part ranges for `session`.
5686///
5687/// Taking each row's `seq` from the caller supports both full upsert parse order and incremental
5688/// append. Shared by `upsert_session` and `append_tail` so validation, message insertion, part
5689/// insertion, and correlation binding have one owner. Runtime is `O(M + S)` for `M` messages and
5690/// `S` parts, with `O(1)` additional memory beyond the parsed input.
5691fn insert_messages<'a>(
5692    tx: &rusqlite::Transaction<'_>,
5693    session: &SessionRecord,
5694    rows: impl Iterator<Item = (i64, &'a crate::models::Message)>,
5695) -> Result<()> {
5696    let mut stmt = tx.prepare(
5697        "insert into messages
5698            (session_id, provider, seq, role, ts, tool_name, kind, tool_call_id, is_compaction,
5699             content, authorship, correlation_authority, correlation_scope, correlation_id,
5700             record_relation)
5701         values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
5702    )?;
5703    let mut part_stmt = tx.prepare(
5704        "insert into message_content_parts
5705            (message_id, ordinal, start_char, end_char, authorship, origin)
5706         values (?1, ?2, ?3, ?4, ?5, ?6)",
5707    )?;
5708    for (seq, message) in rows {
5709        if let Err(detail) = message.provenance.validate(&message.content) {
5710            bail!(
5711                "invalid provenance for message sequence {seq} in session '{}': {detail}",
5712                session.id
5713            );
5714        }
5715        let correlation = message.provenance.correlation_identity.as_ref();
5716        stmt.execute(params![
5717            session.id,
5718            session.provider.as_str(),
5719            seq,
5720            message.role.as_str(),
5721            message.ts.map(|ts| ts.to_rfc3339()),
5722            message.tool_name,
5723            message.kind.as_str(),
5724            message.tool_call_id,
5725            message.is_compaction as i64,
5726            message.content,
5727            message.provenance.authorship.as_str(),
5728            correlation.map(|value| value.authority.as_str()),
5729            correlation.map(|value| value.scope.as_str()),
5730            correlation.map(|value| value.id.as_str()),
5731            message.provenance.record_relation.as_str(),
5732        ])?;
5733        let message_id = tx.last_insert_rowid();
5734        for part in &message.provenance.content_parts {
5735            part_stmt.execute(params![
5736                message_id,
5737                i64::from(part.ordinal),
5738                i64::try_from(part.start_char).context("content-part start exceeds SQLite i64")?,
5739                i64::try_from(part.end_char).context("content-part end exceeds SQLite i64")?,
5740                part.authorship.as_str(),
5741                part.origin.as_str(),
5742            ])?;
5743        }
5744    }
5745    Ok(())
5746}
5747
5748/// Insert file-edit rows for `session`, with the caller-supplied `seq`. Shared by `upsert_session`
5749/// and `append_tail`. `edits` serialize to a JSON `[old, new]` array (NULL when empty); the same
5750/// shape both call sites previously duplicated.
5751fn insert_file_edits<'a>(
5752    tx: &rusqlite::Transaction<'_>,
5753    session: &SessionRecord,
5754    rows: impl Iterator<Item = (i64, &'a FileEdit)>,
5755) -> Result<()> {
5756    let mut stmt = tx.prepare(
5757        "insert into file_edits
5758            (session_id, provider, seq, ts, tool, file_path, file_name, new_content, edits_json)
5759         values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
5760    )?;
5761    for (seq, edit) in rows {
5762        let edits_json = if edit.edits.is_empty() {
5763            None
5764        } else {
5765            Some(serde_json::to_string(&edit.edits)?)
5766        };
5767        stmt.execute(params![
5768            session.id,
5769            session.provider.as_str(),
5770            seq,
5771            edit.ts.map(|ts| ts.to_rfc3339()),
5772            edit.tool,
5773            edit.file_path,
5774            edit.file_name,
5775            edit.new_content,
5776            edits_json,
5777        ])?;
5778    }
5779    Ok(())
5780}
5781
5782/// Append the inclusive timestamp-window clauses and push their rfc3339 args,
5783/// centralizing the date filter shared by every time-scoped query (messages,
5784/// corrections, planning, files). `col` lets callers target `ts` or a table-qualified
5785/// `m.ts`. Args are pushed since-then-until to match the SQL order. The upper bound
5786/// preserves the exact resolved endpoint (see [`until_bound_text`]).
5787/// Unknown (`NULL`) timestamps do not match a date window. Providers/indexing paths
5788/// that need date-filterable rows must persist a fallback timestamp instead of letting
5789/// every undated row leak through every date filter.
5790fn push_ts_window(
5791    sql: &mut String,
5792    args: &mut Vec<rusqlite::types::Value>,
5793    col: &str,
5794    since: Option<chrono::DateTime<Utc>>,
5795    until: Option<chrono::DateTime<Utc>>,
5796) {
5797    use rusqlite::types::Value;
5798    use std::fmt::Write as _;
5799    // `write!` into the existing String avoids a throwaway `format!` allocation; writing to a
5800    // String is infallible, so the `Result` is discarded.
5801    if let Some(since) = since {
5802        let _ = write!(sql, " and {col} >= ?");
5803        args.push(Value::Text(since.to_rfc3339()));
5804    }
5805    if let Some(until) = until {
5806        let _ = write!(sql, " and {col} <= ?");
5807        args.push(Value::Text(until_bound_text(until)));
5808    }
5809}
5810
5811fn push_file_filters(
5812    sql: &mut String,
5813    args: &mut Vec<rusqlite::types::Value>,
5814    query: &FileQuery,
5815    access: &crate::search_scope::EffectiveAccessScope,
5816) {
5817    use rusqlite::types::Value;
5818    if let Some(provider) = query.provider {
5819        sql.push_str(" and provider = ?");
5820        args.push(Value::Text(provider.as_str().to_string()));
5821    }
5822    if let Some(session_id) = query.session_id.as_deref() {
5823        sql.push_str(" and session_id = ?");
5824        args.push(Value::Text(session_id.to_string()));
5825    }
5826    push_access_scope(sql, args, "session_id", access);
5827    push_path_prefix(sql, args, "session_id", query.path_prefix.as_deref());
5828    push_exclude_path_prefixes(sql, args, "session_id", &query.exclude_path_prefixes);
5829    for session_id in &query.exclude_session_ids {
5830        sql.push_str(" and session_id <> ?");
5831        args.push(Value::Text(session_id.clone()));
5832    }
5833}
5834
5835fn path_prefix_parts(prefix: &str) -> (String, String) {
5836    let bytes = prefix.as_bytes();
5837    let windows_style = prefix.starts_with(r"\\")
5838        || matches!(bytes, [drive, b':', b'\\' | b'/', ..] if drive.is_ascii_alphabetic());
5839    let separator = if windows_style { '\\' } else { '/' };
5840    let exact = prefix.trim_end_matches(separator).to_string();
5841    let child = format!("{exact}{separator}");
5842    (exact, child)
5843}
5844
5845fn path_prefix_patterns(prefix: &str) -> (String, String) {
5846    let (exact, child) = path_prefix_parts(prefix);
5847    (exact, literal_like_prefix_pattern(&child))
5848}
5849
5850/// Escape a caller-authored literal for a SQLite `LIKE ... ESCAPE '\'` prefix probe.
5851///
5852/// Time and returned memory are `O(|value|)`. Centralizing this prevents session IDs and path
5853/// prefixes containing `%`, `_`, or `\` from accidentally becoming wildcard expressions.
5854fn literal_like_prefix_pattern(value: &str) -> String {
5855    let mut escaped = String::with_capacity(value.len() + 2);
5856    for ch in value.chars() {
5857        match ch {
5858            '%' | '_' | '\\' => {
5859                escaped.push('\\');
5860                escaped.push(ch);
5861            }
5862            other => escaped.push(other),
5863        }
5864    }
5865    escaped.push('%');
5866    escaped
5867}
5868
5869fn push_session_path_prefix(sql: &mut String, args: &mut Vec<String>, path_prefix: &str) {
5870    let (exact, child_pattern) = path_prefix_patterns(path_prefix);
5871    sql.push_str(
5872        " and ((coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\') \
5873         or (coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\') \
5874         or (coalesce(s.source_path, '') = ? or coalesce(s.source_path, '') like ? escape '\\')) ",
5875    );
5876    args.push(exact.clone());
5877    args.push(child_pattern.clone());
5878    args.push(exact.clone());
5879    args.push(child_pattern.clone());
5880    args.push(exact);
5881    args.push(child_pattern);
5882}
5883
5884fn push_session_exclusions(sql: &mut String, args: &mut Vec<String>, filters: &SearchFilters) {
5885    for session_id in &filters.exclude_session_ids {
5886        sql.push_str(" and s.id <> ? ");
5887        args.push(session_id.clone());
5888    }
5889    for prefix in &filters.exclude_path_prefixes {
5890        let (exact, child_pattern) = path_prefix_patterns(prefix);
5891        sql.push_str(
5892            " and not ((coalesce(s.cwd, '') = ? or coalesce(s.cwd, '') like ? escape '\\') \
5893             or (coalesce(s.repo_root, '') = ? or coalesce(s.repo_root, '') like ? escape '\\') \
5894             or (coalesce(s.source_path, '') = ? or coalesce(s.source_path, '') like ? escape '\\')) ",
5895        );
5896        args.push(exact.clone());
5897        args.push(child_pattern.clone());
5898        args.push(exact.clone());
5899        args.push(child_pattern.clone());
5900        args.push(exact);
5901        args.push(child_pattern);
5902    }
5903}
5904
5905/// Restrict which classes of session come back, ORing each selected class's own predicate.
5906///
5907/// PATTERN: one clause decides session class, the way `append_message_filters` has one clause
5908/// for message class. Do not add a second predicate beside it — a `parent_session_id is null`
5909/// clause added elsewhere would combine with `session_kinds = [subagent]` into an always-empty
5910/// result that reads as "no such sessions exist". Class selection belongs in
5911/// [`SearchFilters::effective_session_kinds`].
5912///
5913/// Takes no `args` because [`SessionKind::sql_predicate`] is a fixed null test, not a bound
5914/// value: the class is derived from `parent_session_id`, never stored as text.
5915/// The OR-ed class predicate for a session-kind selection over `alias`.
5916///
5917/// `None` means every class is selected, so a predicate would be a tautology and the caller
5918/// should emit nothing. `Some("0")` means the selection is empty: match nothing rather than
5919/// silently matching all, which is the failure that makes an over-narrowed filter look like an
5920/// empty database.
5921///
5922/// Shared by the `sessions`-rooted query and the message-side subquery so the two cannot drift
5923/// on what an empty or full selection means.
5924fn session_kind_predicate(selected: &[crate::models::SessionKind], alias: &str) -> Option<String> {
5925    if selected.is_empty() {
5926        return Some("0".to_string());
5927    }
5928    if crate::models::SessionKind::all()
5929        .iter()
5930        .all(|kind| selected.contains(kind))
5931    {
5932        return None;
5933    }
5934    let mut predicate = String::from("(");
5935    for (index, kind) in selected.iter().enumerate() {
5936        if index > 0 {
5937            predicate.push_str(" or ");
5938        }
5939        predicate.push_str(&kind.sql_predicate(alias));
5940    }
5941    predicate.push(')');
5942    Some(predicate)
5943}
5944
5945fn push_session_kinds(sql: &mut String, filters: &SearchFilters) {
5946    if let Some(predicate) = session_kind_predicate(&filters.effective_session_kinds(), "s") {
5947        sql.push_str(" and ");
5948        sql.push_str(&predicate);
5949        sql.push(' ');
5950    }
5951}
5952
5953/// Restrict messages to sessions of the selected classes, through the same
5954/// `<id_col> in (select id from sessions where ...)` shape `push_path_prefix` uses.
5955fn push_message_session_kinds(
5956    sql: &mut String,
5957    id_col: &str,
5958    session_kinds: Option<&Vec<crate::models::SessionKind>>,
5959) {
5960    use std::fmt::Write as _;
5961    let Some(selected) = session_kinds else {
5962        // None = every class, which is what `messages search` and `list` already return.
5963        return;
5964    };
5965    let Some(predicate) = session_kind_predicate(selected, "sessions") else {
5966        return;
5967    };
5968    if predicate == "0" {
5969        sql.push_str(" and 0");
5970        return;
5971    }
5972    let _ = write!(
5973        sql,
5974        " and {id_col} in (select id from sessions where {predicate})"
5975    );
5976}
5977
5978/// Append closed-interval overlap predicates for a session's known indexed span.
5979///
5980/// Let `S` be session rows considered by SQLite. This adds at most two constant-time text
5981/// comparisons per considered row: `O(S)` worst-case SQL work and `O(1)` application memory.
5982/// It performs no message join, allocation proportional to `S`, output growth, or database write;
5983/// ordering, paging, and result retention remain owned by the calling query.
5984fn push_session_time_window(
5985    sql: &mut String,
5986    args: &mut Vec<String>,
5987    since: Option<chrono::DateTime<Utc>>,
5988    until: Option<chrono::DateTime<Utc>>,
5989) {
5990    if let Some(since) = since {
5991        sql.push_str(" and coalesce(s.updated_at, s.created_at) >= ? ");
5992        args.push(since.to_rfc3339());
5993    }
5994    if let Some(until) = until {
5995        sql.push_str(" and coalesce(s.created_at, s.updated_at) <= ? ");
5996        args.push(until_bound_text(until));
5997    }
5998}
5999
6000fn row_to_message_hit(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageHit> {
6001    row_to_message_hit_at(row, 0)
6002}
6003
6004fn decode_db_enum<T>(
6005    value: String,
6006    column_index: usize,
6007    context: &'static str,
6008    decode: impl FnOnce(&str) -> std::result::Result<T, String>,
6009) -> rusqlite::Result<T> {
6010    // The recovery advice travels with the decoder's reason: an unknown provider means a newer
6011    // aise wrote the row and the fix is to upgrade, while an unknown role means corruption and
6012    // the fix is a full reparse. This wrapper only adds where the value was read. It must not
6013    // append its own `aise reindex --full` advice: for a provider that is the command that fails
6014    // the same way, the circular advice a Pi session hit on 2026-08-13.
6015    decode(&value).map_err(|reason| {
6016        rusqlite::Error::FromSqlConversionFailure(
6017            column_index,
6018            rusqlite::types::Type::Text,
6019            std::io::Error::new(
6020                std::io::ErrorKind::InvalidData,
6021                format!("{reason} (while reading {context})"),
6022            )
6023            .into(),
6024        )
6025    })
6026}
6027
6028fn row_to_message_hit_at(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<MessageHit> {
6029    let ts: Option<String> = row.get(offset + 4)?;
6030    Ok(MessageHit {
6031        session_id: row.get(offset)?,
6032        provider: decode_db_enum(
6033            row.get::<_, String>(offset + 1)?,
6034            offset + 1,
6035            "messages.provider",
6036            Provider::from_db_str,
6037        )?,
6038        seq: row.get(offset + 2)?,
6039        role: decode_db_enum(
6040            row.get::<_, String>(offset + 3)?,
6041            offset + 3,
6042            "messages.role",
6043            Role::from_db_str,
6044        )?,
6045        ts: ts.and_then(|value| {
6046            chrono::DateTime::parse_from_rfc3339(&value)
6047                .ok()
6048                .map(|dt| dt.with_timezone(&Utc))
6049        }),
6050        tool_name: row.get(offset + 5)?,
6051        kind: crate::models::MessageKind::from_db_str(&row.get::<_, String>(offset + 6)?),
6052        tool_call_id: row.get(offset + 7)?,
6053        fuzzy_score: None,
6054        content: row.get(offset + 8)?,
6055    })
6056}
6057
6058/// Convert regex required literals into raw-JSON-safe trigram groups for a projected tool
6059/// argument.
6060///
6061/// A JSON string may escape quotes, backslashes, and control characters, so a trigram spanning
6062/// any such character is not necessarily present in the stored envelope. Removing those grams
6063/// preserves the candidate-superset contract; if any regex alternative loses every usable gram,
6064/// this returns `None` and the caller scans every eligible tool call. Provider-normalized tool
6065/// envelopes and non-string projections both use `serde_json`'s compact serialization, while
6066/// string projections remove their surrounding quotes and decode escapes; this is why only the
6067/// latter representation boundary needs filtering. The projected-value matcher remains final.
6068///
6069/// For Q query scalar values and G extracted trigrams, runtime is O(Q + G), peak memory is O(G),
6070/// and latency is one serial extraction pass. In practice G <= O(Q); no corpus data is read here.
6071fn json_projection_prefilter_groups(pattern: &str) -> Option<Vec<Vec<String>>> {
6072    let groups = crate::trigram::trigram_prefilter_groups(pattern)?;
6073    let groups = groups
6074        .into_iter()
6075        .map(|group| {
6076            group
6077                .into_iter()
6078                .filter(|trigram| {
6079                    trigram.chars().all(|character| {
6080                        !character.is_control() && !matches!(character, '"' | '\\')
6081                    })
6082                })
6083                .collect::<Vec<_>>()
6084        })
6085        .collect::<Vec<_>>();
6086    (!groups.is_empty() && groups.iter().all(|group| !group.is_empty())).then_some(groups)
6087}
6088
6089/// Project and score one derived-field batch in parallel. For raw bytes `B_b`, rows `N_b`, and
6090/// workers `P`, tool-argument projection plus fuzzy scoring does O(B_b + score(B_b)) aggregate
6091/// work and ideal O((B_b + score(B_b))/P) scoring latency, excluding serial SQLite traversal and
6092/// scheduling. Worker-local matcher/buffer state is O(P*Q); output retains only matching rows.
6093fn score_fuzzy_derived_hits(
6094    pattern: &Pattern,
6095    query_lower: &str,
6096    field: SearchField,
6097    argument_path: Option<&str>,
6098    hits: Vec<MessageHit>,
6099) -> Vec<(MessageHit, bool)> {
6100    let exact_phrase_needle = UnicodeLowerNeedle::from_lowered(query_lower);
6101    hits.into_par_iter()
6102        .map_init(
6103            || (NucleoMatcher::new(NucleoConfig::DEFAULT), Vec::new()),
6104            |(matcher, utf32_buf), mut hit| {
6105                let value = selected_message_field_parts(&hit, field, argument_path)?.into_owned();
6106                utf32_buf.clear();
6107                pattern
6108                    .score(Utf32Str::new(&value, utf32_buf), matcher)
6109                    .map(|score| {
6110                        hit.fuzzy_score = Some(score);
6111                        let exact_phrase = exact_phrase_needle.contains(&value);
6112                        (hit, exact_phrase)
6113                    })
6114            },
6115        )
6116        .filter_map(std::convert::identity)
6117        .collect()
6118}
6119
6120fn score_fuzzy_message_hits(
6121    pattern: &Pattern,
6122    query_lower: &str,
6123    hits: Vec<MessageHit>,
6124) -> Vec<(MessageHit, bool)> {
6125    let exact_phrase_needle = UnicodeLowerNeedle::from_lowered(query_lower);
6126    hits.into_par_iter()
6127        .map_init(
6128            || (NucleoMatcher::new(NucleoConfig::DEFAULT), Vec::new()),
6129            |(matcher, utf32_buf), mut hit| {
6130                let score = {
6131                    let haystack = Utf32Str::new(&hit.content, utf32_buf);
6132                    pattern.score(haystack, matcher)
6133                };
6134                score.map(|score| {
6135                    hit.fuzzy_score = Some(score);
6136                    let exact_phrase = exact_phrase_needle.contains(&hit.content);
6137                    (hit, exact_phrase)
6138                })
6139            },
6140        )
6141        .filter_map(std::convert::identity)
6142        .collect()
6143}
6144
6145fn compare_fuzzy_hits(left: &(MessageHit, bool), right: &(MessageHit, bool)) -> std::cmp::Ordering {
6146    right
6147        .0
6148        .fuzzy_score
6149        .unwrap_or_default()
6150        .cmp(&left.0.fuzzy_score.unwrap_or_default())
6151        .then_with(|| right.1.cmp(&left.1))
6152        .then_with(|| left.0.session_id.cmp(&right.0.session_id))
6153        .then_with(|| left.0.seq.cmp(&right.0.seq))
6154}
6155
6156/// The receipt for a fuzzy page that scored every eligible row.
6157///
6158/// `matched` is how many of them the pattern matched and `corpus` how many were eligible. The
6159/// message names the retention bound [`Db::fuzzy_ranking_window`] enforces, so a reader can tell
6160/// that a deep page costs scoring time without costing proportional memory.
6161fn scored_whole_corpus(matched: i64, corpus: i64) -> SearchExplain {
6162    SearchExplain {
6163        prefilter: None,
6164        candidates: Some(matched),
6165        prefilter_skipped: Some(
6166            "complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows"
6167                .to_string(),
6168        ),
6169        corpus,
6170    }
6171}
6172
6173/// The receipt for a fuzzy page whose window starts at or past the last eligible row.
6174///
6175/// `candidates` counts rows that scored, and none did, so it stays absent rather than reporting a
6176/// zero that would read as "the corpus was scored and nothing matched". `corpus` is exact: it is
6177/// the index-only count that decided the page is empty.
6178fn empty_past_corpus(corpus: i64) -> SearchExplain {
6179    SearchExplain {
6180        prefilter: None,
6181        candidates: None,
6182        prefilter_skipped: Some(
6183            "offset starts at or past the last eligible row, so no row was scored".to_string(),
6184        ),
6185        corpus,
6186    }
6187}
6188
6189/// The rows a fuzzy derived-field scan visits, as an SQL fragment over alias `m`.
6190///
6191/// One owner for the restriction keeps the statement and the bound measured against it in step:
6192/// [`Db::fuzzy_ranking_window`] takes the same fragment so the corpus it counts is the corpus the
6193/// scan reads. Tool-argument search restricts itself to tool calls, while the tool-name fallback
6194/// (schema versions below 4, which have no tool-name vocabulary to search) visits every admitted
6195/// row and lets scoring skip the ones carrying no tool name.
6196const fn fuzzy_derived_scan_predicate(field: SearchField) -> &'static str {
6197    match field {
6198        SearchField::ToolArgument => "m.kind = 'tool_call'",
6199        SearchField::ToolName | SearchField::Content => "1 = 1",
6200    }
6201}
6202
6203/// The outcome of sizing a fuzzy page's ranking window against the eligible corpus.
6204///
6205/// See [`Db::fuzzy_ranking_window`], which explains why the window is bounded at all.
6206enum FuzzyRankingWindow {
6207    /// Score the corpus while retaining at most `limit` ranked candidates. `corpus` carries the
6208    /// eligible count when sizing the window had to measure it, so a receipt can reuse it instead
6209    /// of repeating the query.
6210    Rank { limit: usize, corpus: Option<i64> },
6211    /// The window starts at or past the last eligible row, so the page is empty whatever the
6212    /// scores are. `corpus` is the exact eligible count, which the receipt reports in place of a
6213    /// scored-candidate count that was never measured.
6214    PastCorpus { corpus: i64 },
6215}
6216
6217fn fuzzy_ranked_limit(filters: &MessageFilters) -> Result<usize> {
6218    filters
6219        .offset
6220        .checked_add(filters.limit)
6221        .ok_or_else(|| anyhow!("fuzzy offset + limit exceeds the platform addressable range"))
6222}
6223
6224/// Compact after retained candidates roughly double. This keeps retained row count `O(K)` while
6225/// making repeated linear selections amortize to `O(N)` rather than rescanning `K` rows every
6226/// fixed batch. Retained text bytes still scale with the selected rows' content sizes.
6227fn top_k_compaction_threshold(limit: usize) -> usize {
6228    limit.saturating_mul(2).max(FUZZY_SCORE_BATCH_SIZE)
6229}
6230
6231fn retain_top_fuzzy_hits(scored: &mut Vec<(MessageHit, bool)>, limit: usize) {
6232    if scored.len() > limit {
6233        scored.select_nth_unstable_by(limit, compare_fuzzy_hits);
6234        scored.truncate(limit);
6235    }
6236}
6237
6238fn finish_fuzzy_hits(
6239    mut scored: Vec<(MessageHit, bool)>,
6240    ranked_limit: usize,
6241    offset: usize,
6242) -> Vec<MessageHit> {
6243    retain_top_fuzzy_hits(&mut scored, ranked_limit);
6244    scored.sort_by(compare_fuzzy_hits);
6245    scored
6246        .into_iter()
6247        .skip(offset)
6248        .map(|(hit, _)| hit)
6249        .collect()
6250}
6251
6252fn session_search_record_bytes(record: &SessionWithTranscript) -> usize {
6253    record.transcript_text.len()
6254        + record.session.preview_text.len()
6255        + record.session.title.as_ref().map_or(0, String::len)
6256        + record.session.summary.as_ref().map_or(0, String::len)
6257        + record.session.cwd.as_ref().map_or(0, String::len)
6258        + record.session.repo_root.as_ref().map_or(0, String::len)
6259}
6260
6261struct SessionScoreContext<'a> {
6262    query: &'a str,
6263    query_needle: &'a UnicodeLowerNeedle,
6264    tokens: &'a [&'a str],
6265    token_needles: &'a [UnicodeLowerNeedle],
6266    current_repo: Option<&'a str>,
6267    scoring: &'a crate::config::ScoringConfig,
6268    cancellation: Option<&'a QueryCancellation>,
6269    now: DateTime<Utc>,
6270}
6271
6272fn session_contains(
6273    needle: &UnicodeLowerNeedle,
6274    value: &str,
6275    cancellation: Option<&QueryCancellation>,
6276) -> Result<bool> {
6277    needle
6278        .contains_while(value, || {
6279            cancellation.is_none_or(|token| !token.is_cancelled())
6280        })
6281        .ok_or_else(|| QueryCancelled.into())
6282}
6283
6284fn session_snippet(
6285    value: &str,
6286    query: &str,
6287    cancellation: Option<&QueryCancellation>,
6288) -> Result<String> {
6289    match cancellation {
6290        Some(token) => snippet_from_match_while(value, query, 160, || !token.is_cancelled())
6291            .ok_or_else(|| QueryCancelled.into()),
6292        None => Ok(snippet_from_match(value, query, 160)),
6293    }
6294}
6295
6296/// Score one bounded batch independently. For batch bytes `B_b`, rows `N_b`, query tokens `A`,
6297/// and workers `P`, aggregate work is O(B_b * (A + 1) + N_b); ideal scoring latency is
6298/// O(B_b * (A + 1) / P + N_b / P), excluding Rayon scheduling. Output memory is O(N_b + D_b)
6299/// for matching record bytes `D_b`; the caller compacts globally retained top-K results between
6300/// batches. A single `now` value preserves recency scores across worker counts and batch timing.
6301fn score_session_records(
6302    records: Vec<SessionWithTranscript>,
6303    context: &SessionScoreContext<'_>,
6304) -> Result<Vec<SearchHit>> {
6305    let SessionScoreContext {
6306        query,
6307        query_needle,
6308        tokens,
6309        token_needles,
6310        current_repo,
6311        scoring,
6312        cancellation,
6313        now,
6314    } = context;
6315    records
6316        .into_par_iter()
6317        .map(|record| -> Result<Option<SearchHit>> {
6318            if let Some(cancellation) = cancellation {
6319                cancellation.ensure_active()?;
6320            }
6321            let matcher = SkimMatcherV2::default().smart_case();
6322            let title = record.session.title.as_deref().unwrap_or_default();
6323            let summary = record.session.summary.as_deref().unwrap_or_default();
6324            let cwd = record.session.cwd.as_deref().unwrap_or_default();
6325            let repo_root = record.session.repo_root.as_deref().unwrap_or_default();
6326            let preview = record.session.preview_text.as_str();
6327            let transcript = record.transcript_text.as_str();
6328            let haystacks = [
6329                ("title", title),
6330                ("summary", summary),
6331                ("cwd", cwd),
6332                ("repo", repo_root),
6333                ("preview", preview),
6334                ("transcript", transcript),
6335            ];
6336
6337            let mut score = 0_i64;
6338            let mut best_source = "fuzzy".to_string();
6339            let mut best_source_score = i64::MIN;
6340            let mut best_snippet = session_snippet(preview, query, *cancellation)?;
6341            let mut term_coverage = vec![false; tokens.len()];
6342            let mut matched = false;
6343            for (source, value) in haystacks {
6344                let mut source_score = 0_i64;
6345                if session_contains(query_needle, value, *cancellation)? {
6346                    matched = true;
6347                    source_score += match source {
6348                        "title" => scoring.title_score,
6349                        "summary" => scoring.summary_score,
6350                        "cwd" | "repo" => scoring.path_score,
6351                        "preview" => scoring.preview_score,
6352                        _ => scoring.other_score,
6353                    };
6354                }
6355                for (index, token) in tokens.iter().enumerate() {
6356                    if !token.is_empty()
6357                        && session_contains(&token_needles[index], value, *cancellation)?
6358                    {
6359                        matched = true;
6360                        source_score += scoring.token_bonus;
6361                        term_coverage[index] = true;
6362                    }
6363                }
6364                if matches!(source, "title" | "cwd" | "repo" | "preview") {
6365                    if let Some(fuzzy_score) = matcher.fuzzy_match(value, query) {
6366                        matched = true;
6367                        source_score += fuzzy_score;
6368                    }
6369                }
6370                score += source_score;
6371                if source_score > best_source_score {
6372                    best_source_score = source_score;
6373                    best_source = source.to_string();
6374                    best_snippet = session_snippet(value, query, *cancellation)?;
6375                }
6376            }
6377            if !matched {
6378                return Ok(None);
6379            }
6380            if tokens.len() > 1 && term_coverage.iter().all(|matched| *matched) {
6381                score += scoring.all_tokens_bonus;
6382            }
6383            if let Some(updated_at) = record.session.updated_at {
6384                let age_days = (*now - updated_at)
6385                    .num_days()
6386                    .clamp(0, scoring.recency_max_days);
6387                score += (scoring.recency_max_days - age_days) * scoring.recency_weight;
6388            }
6389            if let (Some(current_repo), Some(repo_root)) =
6390                (current_repo, record.session.repo_root.as_deref())
6391            {
6392                if *current_repo == repo_root {
6393                    score += scoring.current_repo_bonus;
6394                    if best_source == "fuzzy" {
6395                        best_source = "repo".to_string();
6396                        best_snippet = snippet_from_match(repo_root, query, 160);
6397                    }
6398                }
6399            }
6400            Ok(Some(SearchHit {
6401                session: record.session,
6402                score,
6403                match_source: best_source,
6404                match_snippet: best_snippet,
6405            }))
6406        })
6407        // Transpose before collecting so non-matching rows are dropped inside the parallel
6408        // fold. Collecting `Vec<Option<SearchHit>>` first and flattening afterwards would size
6409        // the intermediate by batch rows `N_b` rather than by matches `M_b`, and move every
6410        // surviving hit a second time.
6411        .filter_map(|scored| scored.transpose())
6412        .collect::<Result<Vec<_>>>()
6413}
6414
6415fn compare_session_hits(left: &SearchHit, right: &SearchHit) -> std::cmp::Ordering {
6416    right
6417        .score
6418        .cmp(&left.score)
6419        .then_with(|| right.session.updated_at.cmp(&left.session.updated_at))
6420        .then_with(|| left.session.id.cmp(&right.session.id))
6421}
6422
6423fn retain_top_session_hits(hits: &mut Vec<SearchHit>, limit: usize) {
6424    if hits.len() > limit {
6425        hits.select_nth_unstable_by(limit, compare_session_hits);
6426        hits.truncate(limit);
6427    }
6428}
6429
6430/// Translate a shell-style glob into an `(column, LIKE-pattern)` pair for the
6431/// `file_edits` table. A pattern without `/` matches the basename (`file_name`);
6432/// one containing `/` matches anywhere in the absolute `file_path` (leading `%`).
6433/// `*`→`%`, `?`→`_`; literal `%`/`_`/`\` are backslash-escaped (use `escape '\'`).
6434fn glob_clause(pattern: &str) -> (&'static str, String) {
6435    let mut like = String::with_capacity(pattern.len() + 1);
6436    for ch in pattern.chars() {
6437        match ch {
6438            '*' => like.push('%'),
6439            '?' => like.push('_'),
6440            '%' | '_' | '\\' => {
6441                like.push('\\');
6442                like.push(ch);
6443            }
6444            other => like.push(other),
6445        }
6446    }
6447    if pattern.contains('/') {
6448        ("file_path", format!("%{like}"))
6449    } else {
6450        ("file_name", like)
6451    }
6452}
6453
6454const RESOLVE_SESSION_EXACT_SQL: &str = concat!(
6455    "select ",
6456    session_record_columns!(),
6457    ", coalesce(t.transcript_text, '') as transcript_text \
6458     from sessions s \
6459     left join transcripts t on t.session_id = s.id \
6460     where (s.id = ?1 or s.provider_session_id = ?1)"
6461);
6462
6463const RESOLVE_SESSION_PREFIX_SQL: &str = concat!(
6464    "select ",
6465    session_record_columns!(),
6466    // Aliased so the reader can take it by name and stay immune to columns being added to
6467    // session_record_columns!.
6468    ", coalesce(t.transcript_text, '') as transcript_text \
6469     from sessions s \
6470     left join transcripts t on t.session_id = s.id \
6471     where (s.id like ?1 escape '\\' or s.provider_session_id like ?1 escape '\\')"
6472);
6473
6474const RESOLVE_SESSION_RECORD_EXACT_SQL: &str = concat!(
6475    "select ",
6476    session_record_columns!(),
6477    " from sessions s where (s.id = ?1 or s.provider_session_id = ?1)"
6478);
6479
6480const RESOLVE_SESSION_RECORD_PREFIX_SQL: &str = concat!(
6481    "select ",
6482    session_record_columns!(),
6483    " from sessions s where (s.id like ?1 escape '\\' or s.provider_session_id like ?1 escape '\\')"
6484);
6485
6486fn unique_session_match<T>(
6487    value: &str,
6488    mut matches: Vec<T>,
6489    id_of: impl Fn(&T) -> &str,
6490) -> Result<T> {
6491    // Exact canonical/native probes run before prefix fallback. Retain this exact preference as a
6492    // defensive invariant for callers that provide a combined candidate set, while still
6493    // rejecting a bare native ID shared by multiple providers with canonical candidates named.
6494    if matches.len() > 1 {
6495        let is_exact =
6496            |id: &str| id == value || id.split_once(':').is_some_and(|(_, rest)| rest == value);
6497        let mut exact = matches
6498            .iter()
6499            .enumerate()
6500            .filter(|(_, m)| is_exact(id_of(m)));
6501        if let (Some((index, _)), None) = (exact.next(), exact.next()) {
6502            return Ok(matches.remove(index));
6503        }
6504    }
6505    match matches.len() {
6506        0 => Err(anyhow!(
6507            "no session matches '{value}' — run `aise list` to see recent session \
6508             ids, or `aise search <keywords>` to find one"
6509        )),
6510        1 => Ok(matches.remove(0)),
6511        _ => {
6512            let shown: Vec<&str> = matches.iter().take(8).map(id_of).collect();
6513            let more = matches.len().saturating_sub(shown.len());
6514            let suffix = if more > 0 {
6515                format!(" (+{more} more)")
6516            } else {
6517                String::new()
6518            };
6519            Err(anyhow!(
6520                "session prefix '{value}' is ambiguous — {} sessions match: {}{}. \
6521                 Pass a longer prefix or the full id.",
6522                matches.len(),
6523                shown.join(", "),
6524                suffix
6525            ))
6526        }
6527    }
6528}
6529
6530fn row_to_session_with_transcript(
6531    row: &rusqlite::Row<'_>,
6532) -> rusqlite::Result<SessionWithTranscript> {
6533    Ok(SessionWithTranscript {
6534        session: row_to_session_record(row)?,
6535        // By NAME, not position: this column follows `session_record_columns!`, so every column
6536        // added to that macro shifts its index. Adding parent_session_id and agent_label broke
6537        // it exactly that way, and the failure was a confusing "Invalid column type Null at
6538        // index 17" rather than anything naming this line.
6539        transcript_text: row.get("transcript_text")?,
6540    })
6541}
6542
6543fn row_to_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
6544    let provider: String = row.get(1)?;
6545    Ok(SessionRecord {
6546        id: row.get(0)?,
6547        provider: decode_db_enum(provider, 1, "sessions.provider", Provider::from_db_str)?,
6548        provider_session_id: row.get(2)?,
6549        title: row.get(3)?,
6550        summary: row.get(4)?,
6551        cwd: row.get(5)?,
6552        repo_root: row.get(6)?,
6553        created_at: row
6554            .get::<_, Option<String>>(7)?
6555            .as_deref()
6556            .and_then(crate::util::parse_datetime),
6557        updated_at: row
6558            .get::<_, Option<String>>(8)?
6559            .as_deref()
6560            .and_then(crate::util::parse_datetime),
6561        last_message_at: row
6562            .get::<_, Option<String>>(9)?
6563            .as_deref()
6564            .and_then(crate::util::parse_datetime),
6565        preview_text: row.get(10)?,
6566        source_path: row.get(11)?,
6567        message_count: row.get(12)?,
6568        parse_version: row.get(13)?,
6569        raw_metadata_json: row.get(14)?,
6570        parse_warning: row.get(15)?,
6571        discovery_source: row.get(16)?,
6572        parent_session_id: row.get(17)?,
6573        agent_label: row.get(18)?,
6574    })
6575}
6576
6577#[cfg(test)]
6578mod tests {
6579    use super::*;
6580    use crate::models::SessionKind;
6581
6582    const TEST_BUSY_TIMEOUT_MS: u64 = 250;
6583    const TEST_NO_WAIT_BUSY_TIMEOUT_MS: u64 = 0;
6584
6585    #[test]
6586    fn message_row_rejects_corrupt_provider_and_role_with_recovery_context() {
6587        let connection = Connection::open_in_memory().unwrap();
6588        // The recovery differs by column: an unknown provider was written by a newer aise and a
6589        // reindex from this build fails on the same rows, so it must say upgrade; an unknown role
6590        // is corruption, so it must say reindex.
6591        for (provider, role, bad_value, column, advice, forbidden) in [
6592            (
6593                "not-a-provider",
6594                "user",
6595                "not-a-provider",
6596                "messages.provider",
6597                "upgrade aise",
6598                "reindex --full",
6599            ),
6600            (
6601                "claude",
6602                "not-a-role",
6603                "not-a-role",
6604                "messages.role",
6605                "aise reindex --full",
6606                "upgrade aise",
6607            ),
6608        ] {
6609            let error = connection
6610                .query_row(
6611                    "select 'session', ?1, 0, ?2, null, null, 'conversation', null, 'content'",
6612                    params![provider, role],
6613                    row_to_message_hit,
6614                )
6615                .unwrap_err();
6616            let error = error.to_string();
6617            assert!(error.contains(bad_value), "{error}");
6618            assert!(error.contains(column), "{error}");
6619            assert!(error.contains(advice), "{error}");
6620            assert!(!error.contains(forbidden), "{error}");
6621        }
6622    }
6623
6624    /// A one-policy set built from ad-hoc `(category, [patterns])` pairs.
6625    ///
6626    /// Tests exercising the *query* path — scoping, paging, provider filters, parallelism — care
6627    /// about which rows come back, not about how a policy got selected, so they state categories
6628    /// inline instead of standing up a skill directory. Patterns are written WITHOUT `(?i)`: the
6629    /// compiler adds exactly one per category, as `analytics::compile_category_patterns` does.
6630    fn test_policies(
6631        categories: &[(&str, &[&str])],
6632    ) -> crate::corrections::ResolvedCorrectionPolicySet {
6633        let spec = crate::corrections::CorrectionPolicySpec {
6634            schema_version: crate::corrections::CORRECTION_POLICY_SCHEMA_VERSION,
6635            name: "test-policy".to_string(),
6636            version: "1.0.0".to_string(),
6637            categories: categories
6638                .iter()
6639                .map(
6640                    |(name, patterns)| crate::corrections::CorrectionCategorySpec {
6641                        name: (*name).to_string(),
6642                        patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
6643                    },
6644                )
6645                .collect(),
6646        };
6647        crate::corrections::ResolvedCorrectionPolicySet::from_policies(vec![spec
6648            // `Embedded` because these bytes really do live in the program, not on disk.
6649            .compile_in_memory(crate::corrections::CorrectionPolicySource::Embedded)
6650            .expect("test correction policy compiles")])
6651    }
6652
6653    /// What `aise skills corrections` evaluates for its embedded primary package.
6654    fn default_policies() -> crate::corrections::ResolvedCorrectionPolicySet {
6655        crate::corrections::ResolvedCorrectionPolicySet::from_policies(vec![
6656            crate::corrections::embedded_policy().expect("the built-in capability compiles"),
6657        ])
6658    }
6659
6660    fn schema_fingerprint(conn: &Connection) -> Vec<(String, String, String, Option<String>)> {
6661        conn.prepare(
6662            "select type, name, tbl_name, sql
6663               from sqlite_schema
6664              where name not like 'sqlite_autoindex_%'
6665              order by type, name, tbl_name",
6666        )
6667        .unwrap()
6668        .query_map([], |row| {
6669            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
6670        })
6671        .unwrap()
6672        .collect::<rusqlite::Result<_>>()
6673        .unwrap()
6674    }
6675
6676    fn enable_v3_custom_trigram_compatibility(db: &Db) {
6677        db.conn
6678            .execute_batch(
6679                "drop trigger if exists messages_ai;
6680                 drop trigger if exists messages_ad;
6681                 drop trigger if exists messages_au;
6682                 drop table if exists messages_trigram_terms;
6683                 drop table if exists messages_trigram_vocab;
6684                 drop table if exists messages_trigram;",
6685            )
6686            .unwrap();
6687        crate::fts::install_released_message_word_index(&db.conn).unwrap();
6688        crate::trigram_index::ensure_schema(&db.conn).unwrap();
6689        db.conn.pragma_update(None, "user_version", 3).unwrap();
6690    }
6691
6692    #[derive(Debug, Clone, Copy)]
6693    enum MessageContentMode {
6694        Literal,
6695        Regex,
6696        Fuzzy,
6697    }
6698
6699    impl MessageContentMode {
6700        fn search(
6701            self,
6702            db: &Db,
6703            pattern: &str,
6704            mut filters: MessageFilters,
6705        ) -> Result<Vec<MessageHit>> {
6706            filters.match_mode = match self {
6707                Self::Literal => MessageSearchMode::Literal,
6708                Self::Regex => MessageSearchMode::Regex,
6709                Self::Fuzzy => MessageSearchMode::Fuzzy,
6710            };
6711            db.search_messages(pattern, &filters)
6712        }
6713    }
6714
6715    const MESSAGE_CONTENT_MODE_CASES: [(MessageContentMode, &str); 3] = [
6716        (MessageContentMode::Literal, "shared needle"),
6717        (MessageContentMode::Regex, r"shared\s+needle"),
6718        (MessageContentMode::Fuzzy, "shared needle"),
6719    ];
6720
6721    fn hit_keys(hits: Vec<MessageHit>) -> Vec<(String, i64)> {
6722        hits.into_iter()
6723            .map(|hit| (hit.session_id, hit.seq))
6724            .collect()
6725    }
6726
6727    fn utc(value: &str) -> DateTime<Utc> {
6728        DateTime::parse_from_rfc3339(value)
6729            .unwrap()
6730            .with_timezone(&Utc)
6731    }
6732
6733    #[test]
6734    fn glob_clause_maps_basename_and_path() {
6735        // No slash → basename match; `*`→`%`, `?`→`_`.
6736        assert_eq!(glob_clause("*.rs"), ("file_name", "%.rs".to_string()));
6737        assert_eq!(glob_clause("db.rs"), ("file_name", "db.rs".to_string()));
6738        // Slash present → full-path match, anchored with a leading `%`.
6739        assert_eq!(
6740            glob_clause("src/*.rs"),
6741            ("file_path", "%src/%.rs".to_string())
6742        );
6743        // LIKE specials are escaped so they match literally.
6744        assert_eq!(glob_clause("a_b%c"), ("file_name", "a\\_b\\%c".to_string()));
6745    }
6746
6747    /// A session that spawned subagents must still resolve by its own id.
6748    ///
6749    /// Runs are identified as `<parent>/<run>`, so a spawner's id is a prefix of every run it
6750    /// spawned. Prefix resolution therefore reports the spawner's own exact id as ambiguous,
6751    /// which breaks `messages get`, `show`, `export`, and `resume` for exactly the sessions
6752    /// that delegated the most work. Caught by the demo suite the moment subagent fixtures were
6753    /// added: `session prefix 'cafe0001-...-000000000006' is ambiguous — 3 sessions match`.
6754    ///
6755    /// An exact identity is never ambiguous, whether given as the whole `id` or as the bare
6756    /// `provider_session_id`. A prefix that is nobody's exact id still reports every candidate.
6757    #[test]
6758    fn a_spawner_resolves_by_its_own_id_even_though_its_runs_extend_it() {
6759        let dir = tempfile::tempdir().unwrap();
6760        let db = Db::open(&dir.path().join("index.db")).unwrap();
6761        db.conn
6762            .execute_batch(
6763                "insert into sessions (
6764                     id, provider, provider_session_id, updated_at, preview_text,
6765                     source_path, parse_version, discovery_source, parent_session_id
6766                 ) values
6767                   ('claude:s6', 'claude', 's6', '2026-03-01T00:00:00+00:00', '', '/p', 'v1',
6768                    'test', null),
6769                   ('claude:s6/agent-a', 'claude', 's6/agent-a', '2026-03-02T00:00:00+00:00', '',
6770                    '/a', 'v1', 'test', 'claude:s6'),
6771                   ('claude:s6/agent-b', 'claude', 's6/agent-b', '2026-03-03T00:00:00+00:00', '',
6772                    '/b', 'v1', 'test', 'claude:s6');",
6773            )
6774            .unwrap();
6775
6776        for spelling in ["s6", "claude:s6"] {
6777            assert_eq!(
6778                db.resolve_session_record(spelling).unwrap().id,
6779                "claude:s6",
6780                "the spawner's exact id must win over the runs that extend it: {spelling}"
6781            );
6782        }
6783        // A run still resolves by its own exact id.
6784        assert_eq!(
6785            db.resolve_session_record("s6/agent-a").unwrap().id,
6786            "claude:s6/agent-a"
6787        );
6788        // A prefix that is nobody's exact id is still reported as ambiguous, naming candidates.
6789        let error = db
6790            .resolve_session_record("s6/agent-")
6791            .expect_err("a genuine prefix collision must still be refused")
6792            .to_string();
6793        assert!(error.contains("ambiguous"), "{error}");
6794        assert!(
6795            error.contains("claude:s6/agent-a") && error.contains("claude:s6/agent-b"),
6796            "the refusal must name the candidates: {error}"
6797        );
6798    }
6799
6800    /// Session date bounds intersect the closed known session span.
6801    ///
6802    /// A session spanning January through March matches periods and exact instants inside that
6803    /// span even when its terminal update lies outside the query. Missing endpoints retain the
6804    /// existing defensive fallback to the endpoint that is present.
6805    #[test]
6806    fn session_date_window_intersects_known_span_and_falls_back_for_null_endpoints() {
6807        let dir = tempfile::tempdir().unwrap();
6808        let db = Db::open(&dir.path().join("index.db")).unwrap();
6809        db.conn
6810            .execute_batch(
6811                "insert into sessions (
6812                     id, provider, provider_session_id, created_at, updated_at, preview_text,
6813                     source_path, parse_version, discovery_source
6814                 ) values
6815                   ('claude:long-running', 'claude', 'long-running', '2026-01-10T00:00:00+00:00',
6816                    '2026-03-10T00:00:00+00:00', '', '/long', 'v1', 'test'),
6817                   ('claude:february', 'claude', 'february', '2026-02-05T00:00:00+00:00',
6818                    '2026-02-06T00:00:00+00:00', '', '/february', 'v1', 'test'),
6819                   ('claude:never-updated', 'claude', 'never-updated', '2026-03-11T00:00:00+00:00',
6820                    null, '', '/never', 'v1', 'test'),
6821                   ('claude:creation-missing', 'claude', 'creation-missing', null,
6822                    '2026-01-20T00:00:00+00:00', '', '/creation-missing', 'v1', 'test');",
6823            )
6824            .unwrap();
6825
6826        let ids = |since: Option<&str>, until: Option<&str>| {
6827            let mut ids: Vec<String> = db
6828                .list_recent(&SearchFilters {
6829                    since: since.and_then(crate::util::parse_datetime),
6830                    until: until.and_then(crate::util::parse_datetime),
6831                    ..SearchFilters::default()
6832                })
6833                .unwrap()
6834                .into_iter()
6835                .map(|session| session.id)
6836                .collect();
6837            ids.sort();
6838            ids
6839        };
6840
6841        assert_eq!(
6842            ids(
6843                Some("2026-01-01T00:00:00+00:00"),
6844                Some("2026-01-31T23:59:59+00:00")
6845            ),
6846            vec!["claude:creation-missing", "claude:long-running"],
6847            "January intersects the long-running span and both point fallbacks"
6848        );
6849        assert_eq!(
6850            ids(
6851                Some("2026-02-15T00:00:00+00:00"),
6852                Some("2026-02-15T00:00:00+00:00")
6853            ),
6854            vec!["claude:long-running"],
6855            "an exact instant inside the known span is included"
6856        );
6857        assert_eq!(
6858            ids(None, Some("2026-01-31T23:59:59+00:00")),
6859            vec!["claude:creation-missing", "claude:long-running"],
6860            "until compares the session start, not terminal activity"
6861        );
6862        assert_eq!(
6863            ids(Some("2026-03-01T00:00:00+00:00"), None),
6864            vec!["claude:long-running", "claude:never-updated"],
6865            "since compares the session end"
6866        );
6867        assert!(ids(Some("2026-03-12T00:00:00+00:00"), None).is_empty());
6868    }
6869
6870    /// Subagent runs outnumber the sessions that spawned them — 4,051 against 858 user-started
6871    /// claude sessions on the machine this was built against — so selecting a class has to
6872    /// work, and the default has to return both rather than quietly halving the index.
6873    #[test]
6874    fn session_kinds_select_user_started_runs_or_both() {
6875        let dir = tempfile::tempdir().unwrap();
6876        let db = Db::open(&dir.path().join("index.db")).unwrap();
6877        db.conn
6878            .execute_batch(
6879                "insert into sessions (
6880                     id, provider, provider_session_id, updated_at, preview_text,
6881                     source_path, parse_version, discovery_source, parent_session_id, agent_label
6882                 ) values
6883                   ('claude:parent', 'claude', 'parent', '2026-03-01T00:00:00+00:00', '',
6884                    '/parent', 'v1', 'test', null, null),
6885                   ('claude:parent/agent-a', 'claude', 'parent/agent-a', '2026-03-02T00:00:00+00:00',
6886                    '', '/a', 'v1', 'test', 'claude:parent', 'Explore'),
6887                   ('claude:other/agent-b', 'claude', 'other/agent-b', '2026-03-03T00:00:00+00:00',
6888                    '', '/b', 'v1', 'test', 'claude:other', 'general-purpose');",
6889            )
6890            .unwrap();
6891
6892        let ids = |kinds: Option<Vec<SessionKind>>, parent: Option<&str>| {
6893            let mut ids: Vec<String> = db
6894                .list_recent(&SearchFilters {
6895                    session_kinds: kinds,
6896                    parent_session_id: parent.map(str::to_string),
6897                    ..SearchFilters::default()
6898                })
6899                .unwrap()
6900                .into_iter()
6901                .map(|session| session.id)
6902                .collect();
6903            ids.sort();
6904            ids
6905        };
6906
6907        assert_eq!(
6908            ids(None, None),
6909            vec![
6910                "claude:other/agent-b",
6911                "claude:parent",
6912                "claude:parent/agent-a"
6913            ],
6914            "naming no class returns every class, so indexed work is never invisible"
6915        );
6916        assert_eq!(
6917            ids(Some(vec![SessionKind::User]), None),
6918            vec!["claude:parent"]
6919        );
6920        assert_eq!(
6921            ids(Some(vec![SessionKind::User, SessionKind::User]), None),
6922            vec!["claude:parent"],
6923            "repeating one set member must not make it equivalent to the complete set"
6924        );
6925        assert_eq!(
6926            ids(Some(vec![SessionKind::Subagent]), None),
6927            vec!["claude:other/agent-b", "claude:parent/agent-a"]
6928        );
6929        assert_eq!(
6930            ids(Some(vec![SessionKind::User, SessionKind::Subagent]), None),
6931            ids(None, None),
6932            "naming both classes is the default set spelled out"
6933        );
6934        assert!(
6935            ids(Some(Vec::new()), None).is_empty(),
6936            "deselecting every class matches nothing rather than silently matching all"
6937        );
6938
6939        // `parent_session_id` holds the parent row's whole id, so the value a caller already
6940        // holds from a listing is the value that selects that session's runs.
6941        assert_eq!(
6942            ids(None, Some("claude:parent")),
6943            vec!["claude:parent/agent-a"]
6944        );
6945        assert!(ids(None, Some("claude:parent/agent-a")).is_empty());
6946
6947        // `claude:other` is named by a run but is not itself a row, which is the ordinary case
6948        // rather than corruption: providers keep a run's transcript after the parent
6949        // conversation is rotated away, and 69 of 69 unresolved parents in one project
6950        // directory had no transcript left on disk. The run must still store and still select,
6951        // which is what makes that otherwise-unreachable work searchable.
6952        assert_eq!(
6953            ids(Some(vec![SessionKind::Subagent]), Some("claude:other")),
6954            vec!["claude:other/agent-b"],
6955            "a run whose parent is not indexed is still a session"
6956        );
6957    }
6958
6959    #[test]
6960    fn list_recent_is_sql_bounded_and_does_not_read_transcripts() {
6961        let dir = tempfile::tempdir().unwrap();
6962        let db = Db::open(&dir.path().join("index.db")).unwrap();
6963        db.conn
6964            .execute_batch(
6965                "insert into sessions (
6966                     id, provider, provider_session_id, updated_at, preview_text,
6967                     source_path, parse_version, discovery_source
6968                 ) values
6969                   ('claude:old', 'claude', 'old', '2026-01-01T00:00:00+00:00', '', '/old', 'v1', 'test'),
6970                   ('claude:middle', 'claude', 'middle', '2026-02-01T00:00:00+00:00', '', '/middle', 'v1', 'test'),
6971                   ('claude:new', 'claude', 'new', '2026-03-01T00:00:00+00:00', '', '/new', 'v1', 'test');
6972                 drop table transcripts;",
6973            )
6974            .unwrap();
6975
6976        let mut filters = SearchFilters {
6977            limit: 2,
6978            ..SearchFilters::default()
6979        };
6980        let sessions = db.list_recent(&filters).unwrap();
6981
6982        assert_eq!(
6983            sessions
6984                .into_iter()
6985                .map(|session| session.id)
6986                .collect::<Vec<_>>(),
6987            ["claude:new", "claude:middle"]
6988        );
6989
6990        filters.limit = 0;
6991        assert_eq!(
6992            db.list_recent(&filters)
6993                .unwrap()
6994                .into_iter()
6995                .map(|session| session.id)
6996                .collect::<Vec<_>>(),
6997            ["claude:new", "claude:middle", "claude:old"]
6998        );
6999    }
7000
7001    #[test]
7002    fn analysis_documents_keyset_pages_normalized_user_text_and_empty_sessions() {
7003        let dir = tempfile::tempdir().unwrap();
7004        let db = Db::open(&dir.path().join("index.db")).unwrap();
7005        db.conn
7006            .execute_batch(
7007                "insert into sessions (
7008                     id, provider, provider_session_id, preview_text,
7009                     source_path, parse_version, discovery_source
7010                 ) values
7011                   ('claude:a', 'claude', 'a', '', '/a', 'v1', 'test'),
7012                   ('claude:b', 'claude', 'b', '', '/b', 'v1', 'test'),
7013                   ('codex:c', 'codex', 'c', '', '/c', 'v1', 'test');
7014                 insert into messages (
7015                     session_id, provider, seq, role, content, authorship, record_relation
7016                 ) values
7017                   ('claude:a', 'claude', 0, 'user', 'first request', 'human', 'original'),
7018                   ('claude:a', 'claude', 1, 'assistant', 'answer', 'agent', 'original'),
7019                   ('claude:a', 'claude', 2, 'user', 'second request', 'human', 'original'),
7020                   ('codex:c', 'codex', 0, 'user', 'other provider', 'human', 'original');
7021                 drop table transcripts;",
7022            )
7023            .unwrap();
7024        let filters = SearchFilters {
7025            provider: Some(Provider::Claude),
7026            limit: 1,
7027            ..SearchFilters::default()
7028        };
7029
7030        let first = db.analysis_documents(&filters, None).unwrap();
7031        assert_eq!(first.documents.len(), 1);
7032        assert_eq!(first.documents[0].session.id, "claude:a");
7033        assert_eq!(first.documents[0].message_count, 3);
7034        assert_eq!(first.documents[0].user_message_count, 2);
7035        assert_eq!(first.documents[0].user_text, "first request second request");
7036        assert_eq!(
7037            first.documents[0].first_user_text.as_deref(),
7038            Some("first request")
7039        );
7040
7041        let second = db
7042            .analysis_documents(&filters, first.next_cursor.as_ref())
7043            .unwrap();
7044        assert_eq!(second.documents.len(), 1);
7045        assert_eq!(second.documents[0].session.id, "claude:b");
7046        assert_eq!(second.documents[0].message_count, 0);
7047        assert_eq!(second.documents[0].user_message_count, 0);
7048        assert!(second.documents[0].user_text.is_empty());
7049        assert!(second.documents[0].first_user_text.is_none());
7050        assert!(second.next_cursor.is_none());
7051    }
7052
7053    #[test]
7054    fn analysis_selects_original_human_authorship_and_human_parts_of_mixed_messages() {
7055        let dir = tempfile::tempdir().unwrap();
7056        let db = Db::open(&dir.path().join("index.db")).unwrap();
7057        db.conn
7058            .execute_batch(
7059                "insert into sessions (
7060                     id, provider, provider_session_id, preview_text,
7061                     source_path, parse_version, discovery_source
7062                 ) values ('claude:authorship', 'claude', 'authorship', '', '/authorship', 'v1', 'test');
7063                 insert into messages (
7064                     id, session_id, provider, seq, role, kind, content, authorship, record_relation
7065                 ) values
7066                   (101, 'claude:authorship', 'claude', 0, 'user', 'conversation',
7067                    'human original', 'human', 'original'),
7068                   (102, 'claude:authorship', 'claude', 1, 'user', 'conversation',
7069                    'agent delegation', 'agent', 'original'),
7070                   (103, 'claude:authorship', 'claude', 2, 'user', 'conversation',
7071                    'human α\ngenerated β\nhuman γ', 'mixed', 'original'),
7072                   (104, 'claude:authorship', 'claude', 3, 'user', 'conversation',
7073                    'mirrored human', 'human', 'mirror'),
7074                   (105, 'claude:authorship', 'claude', 4, 'user', 'conversation',
7075                    'legacy uncertainty', 'unknown', 'unknown');
7076                 insert into message_content_parts (
7077                     message_id, ordinal, start_char, end_char, authorship, origin
7078                 ) values
7079                   (103, 0, 0, 8, 'human', 'direct_input'),
7080                   (103, 1, 8, 20, 'generated', 'tool_payload'),
7081                   (103, 2, 20, 27, 'human', 'direct_input');",
7082            )
7083            .unwrap();
7084
7085        let filters = SearchFilters {
7086            limit: 1,
7087            ..SearchFilters::default()
7088        };
7089        let page = db.analysis_documents(&filters, None).unwrap();
7090        let document = &page.documents[0];
7091        assert_eq!(document.message_count, 5);
7092        assert_eq!(document.user_message_count, 2);
7093        assert_eq!(
7094            document.user_text, "human original human α\nhuman γ",
7095            "analysis must exclude agent, generated, mirrored, and unknown-attribution text"
7096        );
7097        assert_eq!(document.first_user_text.as_deref(), Some("human original"));
7098
7099        let request = crate::models::AnalysisRequest::new(
7100            SearchFilters::default(),
7101            crate::models::AnalysisSessionSelection::AllEligible,
7102        )
7103        .unwrap();
7104        let mut chunks = Vec::new();
7105        let receipt = db
7106            .visit_analysis_sessions(
7107                &request,
7108                std::num::NonZeroUsize::new(1).unwrap(),
7109                |_session, _message_count, human_message_count, messages| {
7110                    assert_eq!(human_message_count, 2);
7111                    chunks.extend(messages.collect::<Result<Vec<_>>>()?);
7112                    Ok(())
7113                },
7114            )
7115            .unwrap();
7116        assert_eq!(
7117            chunks,
7118            [
7119                (0, "human original".to_string()),
7120                (2, "human α\nhuman γ".to_string())
7121            ]
7122        );
7123        assert_eq!(receipt.analyzed_user_messages, 2);
7124    }
7125
7126    #[test]
7127    fn analysis_visit_keeps_one_snapshot_across_bounded_pages() {
7128        let dir = tempfile::tempdir().unwrap();
7129        let path = dir.path().join("index.db");
7130        let db = Db::open(&path).unwrap();
7131        let writer = Db::open(&path).unwrap();
7132        for (id, source) in [
7133            ("claude:a", "/fixture/a.jsonl"),
7134            ("claude:z", "/fixture/z.jsonl"),
7135        ] {
7136            let mut parsed =
7137                crate::util::minimal_record(Provider::Claude, Path::new(source), "test".into());
7138            parsed.session.id = id.into();
7139            parsed.session.provider_session_id = id.into();
7140            parsed.messages.push(crate::models::Message {
7141                seq: 0,
7142                role: crate::models::Role::User,
7143                ts: None,
7144                tool_name: None,
7145                kind: crate::models::MessageKind::Conversation,
7146                tool_call_id: None,
7147                is_compaction: false,
7148                content: format!("content for {id}"),
7149                provenance: crate::models::MessageProvenance::default(),
7150            });
7151            db.upsert_session(&parsed, 0, 0).unwrap();
7152        }
7153        let mut late = crate::util::minimal_record(
7154            Provider::Claude,
7155            Path::new("/fixture/m.jsonl"),
7156            "test".into(),
7157        );
7158        late.session.id = "claude:m".into();
7159        late.session.provider_session_id = "claude:m".into();
7160        let request = crate::models::AnalysisRequest::new(
7161            SearchFilters::default(),
7162            crate::models::AnalysisSessionSelection::AllEligible,
7163        )
7164        .unwrap();
7165        let mut seen = Vec::new();
7166        let receipt = db
7167            .visit_analysis_sessions(
7168                &request,
7169                std::num::NonZeroUsize::new(1).unwrap(),
7170                |session, _message_count, _user_message_count, _chunks| {
7171                    seen.push(session.id);
7172                    if seen.len() == 1 {
7173                        writer.upsert_session(&late, 0, 0)?;
7174                    }
7175                    Ok(())
7176                },
7177            )
7178            .unwrap();
7179
7180        assert_eq!(receipt.selected_sessions, 2);
7181        assert!(!receipt.has_more);
7182        assert_eq!(seen, ["claude:a", "claude:z"]);
7183        assert_eq!(
7184            receipt.last_selected_session_id.as_deref(),
7185            Some("claude:z")
7186        );
7187        assert_eq!(
7188            db.resolve_session_record("claude:m").unwrap().id,
7189            "claude:m"
7190        );
7191        let original_population = crate::models::AnalysisRequest::new(
7192            SearchFilters {
7193                exclude_session_ids: vec!["claude:m".to_owned()],
7194                ..SearchFilters::default()
7195            },
7196            crate::models::AnalysisSessionSelection::AllEligible,
7197        )
7198        .unwrap();
7199        let consumed = db
7200            .visit_analysis_sessions(
7201                &original_population,
7202                std::num::NonZeroUsize::new(1).unwrap(),
7203                |_session, _message_count, _user_message_count, chunks| {
7204                    for chunk in chunks {
7205                        chunk?;
7206                    }
7207                    Ok(())
7208                },
7209            )
7210            .unwrap();
7211        assert_eq!(
7212            receipt.corpus_digest, consumed.corpus_digest,
7213            "the owner must hash unread human-authored message rows after a successful visitor"
7214        );
7215        let later = db
7216            .visit_analysis_sessions(
7217                &request,
7218                std::num::NonZeroUsize::new(1).unwrap(),
7219                |_session, _message_count, _user_message_count, _chunks| Ok(()),
7220            )
7221            .unwrap();
7222        assert_eq!(later.selected_sessions, 3);
7223        assert_ne!(
7224            receipt.corpus_digest, later.corpus_digest,
7225            "the earlier receipt must describe only its original read snapshot"
7226        );
7227    }
7228
7229    #[test]
7230    fn analysis_documents_rejects_unbounded_pages() {
7231        let dir = tempfile::tempdir().unwrap();
7232        let db = Db::open(&dir.path().join("index.db")).unwrap();
7233        let error = db
7234            // limit stated rather than defaulted: an unbounded page is what this rejects, so
7235            // the value under test must not move if the default ever does.
7236            .analysis_documents(
7237                &SearchFilters {
7238                    limit: 0,
7239                    ..SearchFilters::default()
7240                },
7241                None,
7242            )
7243            .unwrap_err();
7244        assert!(error.to_string().contains("greater than zero"));
7245    }
7246
7247    #[test]
7248    fn planning_usage_optionally_filters_by_command_patterns() {
7249        let dir = tempfile::tempdir().unwrap();
7250        let db = Db::open(&dir.path().join("index.db")).unwrap();
7251        db.conn
7252            .execute(
7253                "insert into sessions (id, provider, provider_session_id, preview_text, \
7254                 source_path, parse_version, discovery_source) \
7255                 values ('s1','claude','s1','','/p','1','test')",
7256                [],
7257            )
7258            .unwrap();
7259        db.conn
7260            .execute(
7261                "insert into sessions (id, provider, provider_session_id, preview_text, \
7262                 source_path, parse_version, discovery_source) \
7263                 values ('s2','codex','s2','','/p2','1','test')",
7264                [],
7265            )
7266            .unwrap();
7267        let slash = |id: i64, session_id: &str, provider: &str, seq: i64, content: &str| {
7268            db.conn
7269                .execute(
7270                    "insert into messages (id, session_id, provider, seq, role, content) \
7271                     values (?1,?2,?3,?4,'slash',?5)",
7272                    params![id, session_id, provider, seq, content],
7273                )
7274                .unwrap();
7275        };
7276        slash(1, "s1", "claude", 0, "/cmd-a make a plan");
7277        slash(2, "s1", "claude", 1, "/cmd-b");
7278        slash(3, "s1", "claude", 2, "/cmd-a refine it");
7279        slash(4, "s2", "codex", 0, "/cmd-c ship the fix");
7280
7281        // No filter (config default) → every slash command is counted.
7282        let all = db.planning_usage(&MessageFilters::default(), &[]).unwrap();
7283        assert_eq!(all.len(), 3, "all distinct commands counted");
7284
7285        // A configured filter restricts to matching commands.
7286        let only = db
7287            .planning_usage(
7288                &MessageFilters::default(),
7289                &[regex::Regex::new(r"^/cmd-a$").unwrap()],
7290            )
7291            .unwrap();
7292        assert_eq!(only.len(), 1);
7293        assert_eq!(only[0].command, "/cmd-a");
7294        assert_eq!(only[0].count, 2);
7295
7296        let command_token_only = db
7297            .planning_usage(
7298                &MessageFilters::default(),
7299                &[regex::Regex::new(r"^/cmd-b$").unwrap()],
7300            )
7301            .unwrap();
7302        assert_eq!(
7303            command_token_only
7304                .iter()
7305                .map(|row| row.command.as_str())
7306                .collect::<Vec<_>>(),
7307            vec!["/cmd-b"],
7308            "planning filters command tokens, not arbitrary slash-message body text"
7309        );
7310
7311        // Shared MessageFilters must apply here too; planning is not a special search path.
7312        let codex = db
7313            .planning_usage(
7314                &MessageFilters {
7315                    providers: Some(vec![Provider::Codex]),
7316                    ..Default::default()
7317                },
7318                &[],
7319            )
7320            .unwrap();
7321        assert_eq!(codex.len(), 1);
7322        assert_eq!(codex[0].command, "/cmd-c");
7323    }
7324
7325    #[test]
7326    fn search_messages_uses_exact_literal_substring_semantics() {
7327        let dir = tempfile::tempdir().unwrap();
7328        let db = Db::open(&dir.path().join("index.db")).unwrap();
7329        db.conn
7330            .execute(
7331                "insert into sessions (id, provider, provider_session_id, preview_text, \
7332                 source_path, parse_version, discovery_source) \
7333                 values ('s1','claude','s1','','/p','1','test')",
7334                [],
7335            )
7336            .unwrap();
7337        let insert = |id: i64, seq: i64, content: &str| {
7338            db.conn
7339                .execute(
7340                    "insert into messages (id, session_id, provider, seq, role, content) \
7341                     values (?1,'s1','claude',?2,'user',?3)",
7342                    params![id, seq, content],
7343                )
7344                .unwrap();
7345        };
7346        insert(1, 0, "please handle the error");
7347        insert(2, 1, "the handler crashed");
7348        insert(3, 2, "we mishandled the input");
7349        insert(4, 3, "error handling code here");
7350        insert(5, 4, "use a => b arrow");
7351        insert(6, 5, "literal /goal command");
7352        insert(7, 6, "plain goal token");
7353        insert(8, 7, "compile C++ today");
7354        insert(9, 8, "flag --path passed");
7355        insert(10, 9, "CAFÉ diagnostic");
7356        insert(11, 10, "İstanbul diagnostic");
7357
7358        let seqs = |query: &str| -> Vec<i64> {
7359            let mut v: Vec<i64> = db
7360                .search_messages(query, &MessageFilters::default())
7361                .unwrap()
7362                .into_iter()
7363                .map(|h| h.seq)
7364                .collect();
7365            v.sort();
7366            v
7367        };
7368        assert_eq!(seqs("handle"), vec![0, 1, 2]);
7369        assert_eq!(seqs("handled"), vec![2]);
7370        // A multi-word query is a contiguous phrase.
7371        assert_eq!(seqs("error handling"), vec![3]);
7372        assert_eq!(seqs("=>"), vec![4]);
7373        assert_eq!(seqs("/goal"), vec![5]);
7374        assert_eq!(seqs("goal"), vec![5, 6]);
7375        assert_eq!(seqs("C++"), vec![7]);
7376        assert_eq!(seqs("--path"), vec![8]);
7377        assert_eq!(seqs("café"), vec![9]);
7378        assert_eq!(seqs("İS"), vec![10]);
7379        assert_eq!(
7380            seqs("İST"),
7381            vec![10],
7382            "the trigram candidate gate must remain a superset of Unicode lowercase matching"
7383        );
7384        // Empty query lists everything (structured filters only).
7385        assert_eq!(seqs("").len(), 11);
7386        // --regex still matches arbitrary patterns over the rows (scan path).
7387        let re = db
7388            .search_messages(
7389                "h.ndler",
7390                &MessageFilters {
7391                    match_mode: MessageSearchMode::Regex,
7392                    ..Default::default()
7393                },
7394            )
7395            .unwrap();
7396        assert_eq!(re.into_iter().map(|h| h.seq).collect::<Vec<_>>(), vec![1]);
7397    }
7398
7399    #[test]
7400    fn fuzzy_message_search_ranks_approximate_matches_without_changing_literal_search() {
7401        let dir = tempfile::tempdir().unwrap();
7402        let db = Db::open(&dir.path().join("index.db")).unwrap();
7403        db.conn
7404            .execute(
7405                "insert into sessions (id, provider, provider_session_id, preview_text, \
7406                 source_path, parse_version, discovery_source) \
7407                 values ('s1','claude','s1','','/p','1','test')",
7408                [],
7409            )
7410            .unwrap();
7411        for (seq, content) in [
7412            (
7413                0,
7414                "please avoid magic values and keep settings configurable",
7415            ),
7416            (1, "hard-coded timeout should move into config"),
7417            (2, "unrelated transcript text"),
7418            (3, "magic numbers should move into named values"),
7419        ] {
7420            db.conn
7421                .execute(
7422                    "insert into messages (session_id, provider, seq, role, content) \
7423                     values ('s1','claude',?1,'user',?2)",
7424                    params![seq, content],
7425                )
7426                .unwrap();
7427        }
7428
7429        let literal = db
7430            .search_messages("magic config", &MessageFilters::default())
7431            .unwrap();
7432        assert!(literal.is_empty(), "literal search remains exact");
7433
7434        let fuzzy = db
7435            .search_messages(
7436                "magic config",
7437                &MessageFilters {
7438                    match_mode: MessageSearchMode::Fuzzy,
7439                    limit: 10,
7440                    ..Default::default()
7441                },
7442            )
7443            .unwrap();
7444        assert_eq!(fuzzy.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
7445        assert!(fuzzy[0].fuzzy_score.is_some());
7446
7447        let fuzzy_phrase = db
7448            .search_messages(
7449                "magic values",
7450                &MessageFilters {
7451                    match_mode: MessageSearchMode::Fuzzy,
7452                    limit: 10,
7453                    ..Default::default()
7454                },
7455            )
7456            .unwrap();
7457        assert_eq!(fuzzy_phrase[0].seq, 0, "exact phrase wins fuzzy ties");
7458    }
7459
7460    #[test]
7461    fn schema_v4_fuzzy_search_filters_structurally_before_exhaustive_scoring() {
7462        let dir = tempfile::tempdir().unwrap();
7463        let path = dir.path().join("index.db");
7464        {
7465            let db = Db::open(&path).unwrap();
7466            for (id, provider, seq, role, content) in [
7467                (
7468                    "s1",
7469                    "claude",
7470                    0,
7471                    "user",
7472                    "incremental trigram acceleration",
7473                ),
7474                ("s1", "claude", 1, "assistant", "unrelated response"),
7475                ("s2", "codex", 0, "user", "incremental index design"),
7476            ] {
7477                db.conn
7478                    .execute(
7479                        "insert or ignore into sessions
7480                         (id, provider, provider_session_id, preview_text, source_path,
7481                          parse_version, discovery_source)
7482                         values (?1, ?2, ?1, '', '/p', '1', 'test')",
7483                        params![id, provider],
7484                    )
7485                    .unwrap();
7486                db.conn
7487                    .execute(
7488                        "insert into messages
7489                         (session_id, provider, seq, role, content)
7490                         values (?1, ?2, ?3, ?4, ?5)",
7491                        params![id, provider, seq, role, content],
7492                    )
7493                    .unwrap();
7494            }
7495        }
7496
7497        let db = Db::open(&path).unwrap();
7498        let (hits, explain) = db
7499            .search_messages_with_explain(
7500                "incrmental trigram",
7501                &MessageFilters {
7502                    providers: Some(vec![Provider::Claude]),
7503                    role: Some(Role::User),
7504                    match_mode: MessageSearchMode::Fuzzy,
7505                    limit: 10,
7506                    ..Default::default()
7507                },
7508                true,
7509            )
7510            .unwrap();
7511        assert_eq!(hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
7512        assert!(hits[0].fuzzy_score.is_some());
7513        let explain = explain.unwrap();
7514        assert_eq!(explain.prefilter.as_deref(), None);
7515        assert_eq!(
7516            explain.prefilter_skipped.as_deref(),
7517            Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
7518        );
7519        assert_eq!(explain.corpus, 1);
7520    }
7521
7522    #[test]
7523    fn exhaustive_fuzzy_ranking_keeps_a_best_hit_beyond_the_old_candidate_cap() {
7524        let dir = tempfile::tempdir().unwrap();
7525        let db = Db::open(&dir.path().join("index.db")).unwrap();
7526        db.conn
7527            .execute(
7528                "insert into sessions (id, provider, provider_session_id, preview_text,
7529                 source_path, parse_version, discovery_source)
7530                 values ('s1','claude','s1','','/p','1','test')",
7531                [],
7532            )
7533            .unwrap();
7534        let tx = db.conn.unchecked_transaction().unwrap();
7535        {
7536            let mut insert = tx
7537                .prepare(
7538                    "insert into messages(session_id, provider, seq, role, content)
7539                     values ('s1','claude',?1,'user',?2)",
7540                )
7541                .unwrap();
7542            for seq in 0..1_300_i64 {
7543                insert
7544                    .execute(params![
7545                        seq,
7546                        format!("magic values configurable filler {seq}")
7547                    ])
7548                    .unwrap();
7549            }
7550            insert.execute(params![1_300_i64, "magic config"]).unwrap();
7551        }
7552        tx.commit().unwrap();
7553
7554        let hits = db
7555            .search_messages(
7556                "magic config",
7557                &MessageFilters {
7558                    match_mode: MessageSearchMode::Fuzzy,
7559                    limit: 1,
7560                    ..Default::default()
7561                },
7562            )
7563            .unwrap();
7564
7565        assert_eq!(hits.len(), 1);
7566        assert_eq!(hits[0].seq, 1_300);
7567
7568        let first_two = db
7569            .search_messages(
7570                "magic config",
7571                &MessageFilters {
7572                    match_mode: MessageSearchMode::Fuzzy,
7573                    limit: 2,
7574                    ..Default::default()
7575                },
7576            )
7577            .unwrap();
7578        let second = db
7579            .search_messages(
7580                "magic config",
7581                &MessageFilters {
7582                    match_mode: MessageSearchMode::Fuzzy,
7583                    limit: 1,
7584                    offset: 1,
7585                    ..Default::default()
7586                },
7587            )
7588            .unwrap();
7589        assert_eq!(second.len(), 1);
7590        assert_eq!(second[0].session_id, first_two[1].session_id);
7591        assert_eq!(second[0].seq, first_two[1].seq);
7592        assert_eq!(second[0].fuzzy_score, first_two[1].fuzzy_score);
7593    }
7594
7595    #[test]
7596    fn search_messages_path_prefix_scopes_by_session_root_and_metadata_enriches() {
7597        let dir = tempfile::tempdir().unwrap();
7598        let db = Db::open(&dir.path().join("index.db")).unwrap();
7599        let session = |id: &str, cwd: &str, repo: &str, title: &str| {
7600            db.conn
7601                .execute(
7602                    "insert into sessions (id, provider, provider_session_id, title, cwd, \
7603                     repo_root, preview_text, source_path, parse_version, discovery_source) \
7604                     values (?1,'claude',?1,?2,?3,?4,'','/p','1','test')",
7605                    params![id, title, cwd, repo],
7606                )
7607                .unwrap();
7608        };
7609        session("a", "/Users/x/proj-a", "/Users/x/proj-a", "Proj A");
7610        session("b", "/Users/x/proj-b", "/Users/x/proj-b", "Proj B");
7611        let msg = |id: i64, sid: &str| {
7612            db.conn
7613                .execute(
7614                    "insert into messages (id, session_id, provider, seq, role, content) \
7615                     values (?1,?2,'claude',0,'user','shared keyword here')",
7616                    params![id, sid],
7617                )
7618                .unwrap();
7619        };
7620        msg(1, "a");
7621        msg(2, "b");
7622
7623        // path_prefix scopes to sessions rooted under the prefix (cwd OR repo_root),
7624        // mirroring the session-level `--path` semantics.
7625        let scoped = db
7626            .search_messages(
7627                "keyword",
7628                &MessageFilters {
7629                    path_prefix: Some("/Users/x/proj-a".into()),
7630                    ..Default::default()
7631                },
7632            )
7633            .unwrap();
7634        assert_eq!(
7635            scoped
7636                .iter()
7637                .map(|h| h.session_id.clone())
7638                .collect::<Vec<_>>(),
7639            vec!["a"],
7640            "only the proj-a session matches the path prefix"
7641        );
7642
7643        // No prefix → both sessions match.
7644        assert_eq!(
7645            db.search_messages("keyword", &MessageFilters::default())
7646                .unwrap()
7647                .len(),
7648            2
7649        );
7650
7651        // session_metadata batch-enriches by id (used by the MCP search_messages serializer).
7652        let meta = db
7653            .session_metadata(&["a".to_string(), "b".to_string()])
7654            .unwrap();
7655        assert_eq!(meta["a"].cwd.as_deref(), Some("/Users/x/proj-a"));
7656        assert_eq!(meta["a"].repo_root.as_deref(), Some("/Users/x/proj-a"));
7657        assert_eq!(meta["a"].title.as_deref(), Some("Proj A"));
7658        assert_eq!(meta["b"].title.as_deref(), Some("Proj B"));
7659    }
7660
7661    #[test]
7662    fn session_metadata_batches_use_one_bind_and_indexed_sql_above_parameter_limits() {
7663        let dir = tempfile::tempdir().unwrap();
7664        let db = Db::open(&dir.path().join("index.db")).unwrap();
7665        let ids = (0..1_200)
7666            .map(|index| format!("codex:metadata-{index}"))
7667            .collect::<Vec<_>>();
7668        {
7669            let transaction = db.conn.unchecked_transaction().unwrap();
7670            {
7671                let mut insert = transaction
7672                    .prepare(
7673                        "insert into sessions (
7674                             id, provider, provider_session_id, title, preview_text, source_path,
7675                             parse_version, discovery_source, raw_metadata_json
7676                         ) values (?1, 'codex', ?2, ?2, '', ?3, '1', 'test', ?4)",
7677                    )
7678                    .unwrap();
7679                for (index, id) in ids.iter().enumerate() {
7680                    let provider_session_id = format!("metadata-{index}");
7681                    insert
7682                        .execute(params![
7683                            id,
7684                            provider_session_id,
7685                            format!("/transcripts/{provider_session_id}.jsonl"),
7686                            format!(r#"{{"index":{index}}}"#),
7687                        ])
7688                        .unwrap();
7689                }
7690            }
7691            transaction.commit().unwrap();
7692        }
7693
7694        let metadata = db.session_metadata(&ids).unwrap();
7695        let raw_metadata = db.session_raw_provider_metadata(&ids).unwrap();
7696        assert_eq!(metadata.len(), ids.len());
7697        assert_eq!(raw_metadata.len(), ids.len());
7698        assert_eq!(
7699            metadata[&ids[1_199]].title.as_deref(),
7700            Some("metadata-1199")
7701        );
7702
7703        let plan = {
7704            let mut statement = db
7705                .conn
7706                .prepare(
7707                    "explain query plan
7708                     select s.id
7709                       from json_each(?) requested
7710                       join sessions s on s.id = requested.value",
7711                )
7712                .unwrap();
7713            statement
7714                .query_map([serde_json::to_string(&ids).unwrap()], |row| {
7715                    row.get::<_, String>(3)
7716                })
7717                .unwrap()
7718                .collect::<rusqlite::Result<Vec<_>>>()
7719                .unwrap()
7720                .join(" | ")
7721        };
7722        assert!(
7723            plan.contains("sqlite_autoindex_sessions_1"),
7724            "session metadata join must probe indexed sessions.id: {plan}"
7725        );
7726    }
7727
7728    #[test]
7729    fn search_messages_excludes_paths_and_sessions_before_limit() {
7730        let dir = tempfile::tempdir().unwrap();
7731        let db = Db::open(&dir.path().join("index.db")).unwrap();
7732        let session = |id: &str, cwd: &str, repo: &str, source_path: &str| {
7733            db.conn
7734                .execute(
7735                    "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7736                     preview_text, source_path, parse_version, discovery_source) \
7737                     values (?1,'claude',?1,?2,?3,'',?4,'1','test')",
7738                    params![id, cwd, repo, source_path],
7739                )
7740                .unwrap();
7741        };
7742        session("a", "/Users/x/proj-a", "/Users/x", "/logs/a.jsonl");
7743        session("b", "/Users/x/proj-b", "/Users/x", "/logs/b.jsonl");
7744        session("c", "/Users/x/proj-c", "/Users/x", "/tmp/noisy/c.jsonl");
7745        for (id, seq) in [("a", 0), ("b", 0), ("c", 0)] {
7746            db.conn
7747                .execute(
7748                    "insert into messages (session_id, provider, seq, role, content) \
7749                     values (?1,'claude',?2,'user','shared needle')",
7750                    params![id, seq],
7751                )
7752                .unwrap();
7753        }
7754
7755        for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7756            let hits = mode
7757                .search(
7758                    &db,
7759                    pattern,
7760                    MessageFilters {
7761                        path_prefix: Some("/Users/x".into()),
7762                        exclude_path_prefixes: vec!["/Users/x/proj-a".into(), "/tmp/noisy".into()],
7763                        limit: 1,
7764                        ..Default::default()
7765                    },
7766                )
7767                .unwrap();
7768            assert_eq!(
7769                hits.iter()
7770                    .map(|hit| hit.session_id.as_str())
7771                    .collect::<Vec<_>>(),
7772                vec!["b"],
7773                "{mode:?}: exclusions apply before limit, including source_path exclusions"
7774            );
7775        }
7776
7777        for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7778            let hits = mode
7779                .search(
7780                    &db,
7781                    pattern,
7782                    MessageFilters {
7783                        exclude_session_ids: vec!["b".into()],
7784                        limit: 10,
7785                        ..Default::default()
7786                    },
7787                )
7788                .unwrap();
7789            assert_eq!(
7790                hits.iter()
7791                    .map(|hit| hit.session_id.as_str())
7792                    .collect::<Vec<_>>(),
7793                vec!["a", "c"],
7794                "{mode:?}: session exclusions apply before content matching"
7795            );
7796        }
7797    }
7798
7799    #[test]
7800    fn search_messages_content_modes_share_structural_filters() {
7801        let dir = tempfile::tempdir().unwrap();
7802        let db = Db::open(&dir.path().join("index.db")).unwrap();
7803        let session = |id: &str, provider: Provider, cwd: &str, repo: &str, source_path: &str| {
7804            db.conn
7805                .execute(
7806                    "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7807                     preview_text, source_path, parse_version, discovery_source) \
7808                     values (?1,?2,?1,?3,?4,'',?5,'1','test')",
7809                    params![id, provider.as_str(), cwd, repo, source_path],
7810                )
7811                .unwrap();
7812        };
7813        session(
7814            "target",
7815            Provider::Claude,
7816            "/Users/x/proj-a",
7817            "/Users/x/proj-a",
7818            "/logs/target.jsonl",
7819        );
7820        session(
7821            "wrong-provider",
7822            Provider::Codex,
7823            "/Users/x/proj-a",
7824            "/Users/x/proj-a",
7825            "/logs/wrong-provider.jsonl",
7826        );
7827        session(
7828            "wrong-path",
7829            Provider::Claude,
7830            "/Users/x/proj-b",
7831            "/Users/x/proj-b",
7832            "/logs/wrong-path.jsonl",
7833        );
7834        let msg =
7835            |id: i64, session_id: &str, provider: Provider, seq: i64, role: Role, ts: &str| {
7836                db.conn
7837                    .execute(
7838                        "insert into messages (id, session_id, provider, seq, role, ts, content) \
7839                     values (?1,?2,?3,?4,?5,?6,'shared needle phrase')",
7840                        params![id, session_id, provider.as_str(), seq, role.as_str(), ts],
7841                    )
7842                    .unwrap();
7843            };
7844        msg(
7845            1,
7846            "target",
7847            Provider::Claude,
7848            10,
7849            Role::User,
7850            "2026-01-02T00:00:00Z",
7851        );
7852        msg(
7853            2,
7854            "target",
7855            Provider::Claude,
7856            11,
7857            Role::Assistant,
7858            "2026-01-02T00:00:00Z",
7859        );
7860        msg(
7861            3,
7862            "wrong-provider",
7863            Provider::Codex,
7864            10,
7865            Role::User,
7866            "2026-01-02T00:00:00Z",
7867        );
7868        msg(
7869            4,
7870            "wrong-path",
7871            Provider::Claude,
7872            10,
7873            Role::User,
7874            "2026-01-02T00:00:00Z",
7875        );
7876        msg(
7877            5,
7878            "target",
7879            Provider::Claude,
7880            9,
7881            Role::User,
7882            "2025-12-31T00:00:00Z",
7883        );
7884
7885        let filters = MessageFilters {
7886            role: Some(Role::User),
7887            providers: Some(vec![Provider::Claude]),
7888            path_prefix: Some("/Users/x/proj-a".into()),
7889            since: Some(utc("2026-01-01T00:00:00Z")),
7890            until: Some(utc("2026-01-31T00:00:00Z")),
7891            seq_from: Some(10),
7892            seq_to: Some(10),
7893            limit: 10,
7894            ..Default::default()
7895        };
7896
7897        for (mode, pattern) in MESSAGE_CONTENT_MODE_CASES {
7898            assert_eq!(
7899                hit_keys(mode.search(&db, pattern, filters.clone()).unwrap()),
7900                vec![("target".to_string(), 10)],
7901                "{mode:?}: content mode must compose with role/provider/path/time/seq filters"
7902            );
7903        }
7904    }
7905
7906    #[test]
7907    fn read_session_messages_selects_by_order_within_range_and_offset() {
7908        let dir = tempfile::tempdir().unwrap();
7909        let db = Db::open(&dir.path().join("index.db")).unwrap();
7910        db.conn
7911            .execute(
7912                "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
7913                 preview_text, source_path, parse_version, discovery_source) \
7914                 values ('claude:s','claude','s','/p','/p','','/p','1','test')",
7915                [],
7916            )
7917            .unwrap();
7918        let msg = |seq: i64, role: &str, content: &str| {
7919            db.conn
7920                .execute(
7921                    "insert into messages (session_id, provider, seq, role, content) \
7922                     values ('claude:s','claude',?1,?2,?3)",
7923                    params![seq, role, content],
7924                )
7925                .unwrap();
7926        };
7927        // seq 0..3, roles: user, assistant, user, slash (mirrors the CLAUDE_FIXTURE shape).
7928        msg(0, "user", "first user turn");
7929        msg(1, "assistant", "assistant reply");
7930        msg(2, "user", "second user turn");
7931        msg(3, "slash", "/cmd make a plan");
7932
7933        let seqs = |hits: Vec<MessageHit>| hits.iter().map(|h| h.seq).collect::<Vec<i64>>();
7934        let filters = |mutate: &dyn Fn(&mut MessageFilters)| {
7935            let mut f = MessageFilters {
7936                session_id: Some("claude:s".to_string()),
7937                ..Default::default()
7938            };
7939            mutate(&mut f);
7940            f
7941        };
7942
7943        // OldestFirst + limit 2 = the first (oldest) 2, in chronological order.
7944        assert_eq!(
7945            seqs(
7946                db.read_session_messages(&filters(&|f| f.limit = 2), MessageOrder::OldestFirst)
7947                    .unwrap()
7948            ),
7949            vec![0, 1]
7950        );
7951        // NewestFirst + limit 2 = the last (newest) 2, STILL returned oldest-first. Order drives
7952        // SELECTION, not just display (no git --reverse-after-limit trap).
7953        assert_eq!(
7954            seqs(
7955                db.read_session_messages(&filters(&|f| f.limit = 2), MessageOrder::NewestFirst)
7956                    .unwrap()
7957            ),
7958            vec![2, 3]
7959        );
7960        // limit 0 = all, chronological regardless of the fetch direction.
7961        assert_eq!(
7962            seqs(
7963                db.read_session_messages(&filters(&|f| f.limit = 0), MessageOrder::NewestFirst)
7964                    .unwrap()
7965            ),
7966            vec![0, 1, 2, 3]
7967        );
7968        // Role filter composes with the newest-N window.
7969        assert_eq!(
7970            seqs(
7971                db.read_session_messages(
7972                    &filters(&|f| {
7973                        f.role = Some(Role::User);
7974                        f.limit = 1;
7975                    }),
7976                    MessageOrder::NewestFirst
7977                )
7978                .unwrap()
7979            ),
7980            vec![2]
7981        );
7982        // Inclusive seq range is the non-overlapping chunked-read primitive.
7983        assert_eq!(
7984            seqs(
7985                db.read_session_messages(
7986                    &filters(&|f| {
7987                        f.seq_from = Some(1);
7988                        f.seq_to = Some(2);
7989                    }),
7990                    MessageOrder::OldestFirst
7991                )
7992                .unwrap()
7993            ),
7994            vec![1, 2]
7995        );
7996        // Offset paginates the oldest-first window.
7997        assert_eq!(
7998            seqs(
7999                db.read_session_messages(
8000                    &filters(&|f| {
8001                        f.offset = 1;
8002                        f.limit = 2;
8003                    }),
8004                    MessageOrder::OldestFirst
8005                )
8006                .unwrap()
8007            ),
8008            vec![1, 2]
8009        );
8010    }
8011
8012    #[test]
8013    fn read_session_messages_requires_a_session_id() {
8014        let dir = tempfile::tempdir().unwrap();
8015        let db = Db::open(&dir.path().join("index.db")).unwrap();
8016        // Without a session_id the newest-first direction is undefined (seq is session-local).
8017        assert!(db
8018            .read_session_messages(&MessageFilters::default(), MessageOrder::NewestFirst)
8019            .is_err());
8020    }
8021
8022    #[test]
8023    fn read_session_messages_edge_cases_from_the_premortem_catalog() {
8024        // Covers failure modes F6-F14 from
8025        // notes/2026_07_20_2230_read_capability_premortem_and_failure_modes.md.
8026        let dir = tempfile::tempdir().unwrap();
8027        let db = Db::open(&dir.path().join("index.db")).unwrap();
8028        db.conn
8029            .execute(
8030                "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8031                 preview_text, source_path, parse_version, discovery_source) \
8032                 values ('claude:s','claude','s','/p','/p','','/p','1','test')",
8033                [],
8034            )
8035            .unwrap();
8036        // A single-message session to exercise F14 in isolation.
8037        db.conn
8038            .execute(
8039                "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8040                 preview_text, source_path, parse_version, discovery_source) \
8041                 values ('claude:one','claude','one','/p','/p','','/p','1','test')",
8042                [],
8043            )
8044            .unwrap();
8045        db.conn
8046            .execute(
8047                "insert into messages (session_id, provider, seq, role, content) \
8048                 values ('claude:one','claude',0,'user','only turn')",
8049                [],
8050            )
8051            .unwrap();
8052        for seq in 0..6 {
8053            db.conn
8054                .execute(
8055                    "insert into messages (session_id, provider, seq, role, content) \
8056                     values ('claude:s','claude',?1,'user',?2)",
8057                    params![seq, format!("turn {seq}")],
8058                )
8059                .unwrap();
8060        }
8061        let seqs = |hits: Vec<MessageHit>| hits.iter().map(|h| h.seq).collect::<Vec<i64>>();
8062        let f = |mutate: &dyn Fn(&mut MessageFilters)| {
8063            let mut m = MessageFilters {
8064                session_id: Some("claude:s".to_string()),
8065                ..Default::default()
8066            };
8067            mutate(&mut m);
8068            m
8069        };
8070
8071        // F7: an inclusive range includes BOTH endpoints exactly.
8072        assert_eq!(
8073            seqs(
8074                db.read_session_messages(
8075                    &f(&|m| {
8076                        m.seq_from = Some(1);
8077                        m.seq_to = Some(3);
8078                    }),
8079                    MessageOrder::OldestFirst
8080                )
8081                .unwrap()
8082            ),
8083            vec![1, 2, 3]
8084        );
8085        // F11: range then limit — newest 2 WITHIN [1,4] is [3,4], still chronological.
8086        assert_eq!(
8087            seqs(
8088                db.read_session_messages(
8089                    &f(&|m| {
8090                        m.seq_from = Some(1);
8091                        m.seq_to = Some(4);
8092                        m.limit = 2;
8093                    }),
8094                    MessageOrder::NewestFirst
8095                )
8096                .unwrap()
8097            ),
8098            vec![3, 4]
8099        );
8100        // F11 (oldest side): first 2 within [1,4] is [1,2].
8101        assert_eq!(
8102            seqs(
8103                db.read_session_messages(
8104                    &f(&|m| {
8105                        m.seq_from = Some(1);
8106                        m.seq_to = Some(4);
8107                        m.limit = 2;
8108                    }),
8109                    MessageOrder::OldestFirst
8110                )
8111                .unwrap()
8112            ),
8113            vec![1, 2]
8114        );
8115        // F10: offset skips from the NEWEST edge under NewestFirst (skip seq 5), then limit 2 →
8116        // [4,3] reversed to chronological [3,4].
8117        assert_eq!(
8118            seqs(
8119                db.read_session_messages(
8120                    &f(&|m| {
8121                        m.offset = 1;
8122                        m.limit = 2;
8123                    }),
8124                    MessageOrder::NewestFirst
8125                )
8126                .unwrap()
8127            ),
8128            vec![3, 4]
8129        );
8130        // F9: an offset past the end returns empty, not a panic or wrap.
8131        assert_eq!(
8132            seqs(
8133                db.read_session_messages(&f(&|m| m.offset = 99), MessageOrder::OldestFirst)
8134                    .unwrap()
8135            ),
8136            Vec::<i64>::new()
8137        );
8138        // F8: a seq range beyond the max seq returns empty.
8139        assert_eq!(
8140            seqs(
8141                db.read_session_messages(
8142                    &f(&|m| {
8143                        m.seq_from = Some(100);
8144                        m.seq_to = Some(200);
8145                    }),
8146                    MessageOrder::OldestFirst
8147                )
8148                .unwrap()
8149            ),
8150            Vec::<i64>::new()
8151        );
8152        // F6: from>to is rejected at the DB layer too (MessageFilters::validate), not silently
8153        // returned as empty — so the same invalid range fails the same way through every surface.
8154        assert!(db
8155            .read_session_messages(
8156                &f(&|m| {
8157                    m.seq_from = Some(4);
8158                    m.seq_to = Some(1);
8159                }),
8160                MessageOrder::OldestFirst
8161            )
8162            .is_err());
8163        // F13: a role with no matches is empty, not misleading.
8164        assert_eq!(
8165            seqs(
8166                db.read_session_messages(
8167                    &f(&|m| m.role = Some(Role::Assistant)),
8168                    MessageOrder::NewestFirst
8169                )
8170                .unwrap()
8171            ),
8172            Vec::<i64>::new()
8173        );
8174        // F14: a single-message session returns that one row for both directions.
8175        let one = |order| {
8176            db.read_session_messages(
8177                &MessageFilters {
8178                    session_id: Some("claude:one".to_string()),
8179                    ..Default::default()
8180                },
8181                order,
8182            )
8183            .unwrap()
8184        };
8185        assert_eq!(seqs(one(MessageOrder::OldestFirst)), vec![0]);
8186        assert_eq!(seqs(one(MessageOrder::NewestFirst)), vec![0]);
8187    }
8188
8189    #[test]
8190    fn path_prefix_matches_directory_boundary_and_escapes_like_metacharacters() {
8191        let dir = tempfile::tempdir().unwrap();
8192        let db = Db::open(&dir.path().join("index.db")).unwrap();
8193        let session = |id: &str, cwd: &str| {
8194            db.conn
8195                .execute(
8196                    "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8197                     preview_text, source_path, parse_version, discovery_source) \
8198                     values (?1,'claude',?1,?2,?2,'','/p','1','test')",
8199                    params![id, cwd],
8200                )
8201                .unwrap();
8202        };
8203        session("root", "/tmp/proj");
8204        session("child", "/tmp/proj/sub");
8205        session("sibling", "/tmp/project2");
8206        session("under", "/tmp/proj_under");
8207        session("percent", "/tmp/proj%literal");
8208        let msg = |id: i64, sid: &str| {
8209            db.conn
8210                .execute(
8211                    "insert into messages (id, session_id, provider, seq, role, content) \
8212                     values (?1,?2,'claude',0,'user','needle')",
8213                    params![id, sid],
8214                )
8215                .unwrap();
8216        };
8217        msg(1, "root");
8218        msg(2, "child");
8219        msg(3, "sibling");
8220        msg(4, "under");
8221        msg(5, "percent");
8222
8223        let ids = |prefix: &str| -> Vec<String> {
8224            let mut ids: Vec<String> = db
8225                .search_messages(
8226                    "needle",
8227                    &MessageFilters {
8228                        path_prefix: Some(prefix.into()),
8229                        ..Default::default()
8230                    },
8231                )
8232                .unwrap()
8233                .into_iter()
8234                .map(|h| h.session_id)
8235                .collect();
8236            ids.sort();
8237            ids
8238        };
8239
8240        assert_eq!(ids("/tmp/proj"), vec!["child", "root"]);
8241        assert_eq!(ids("/tmp/proj%literal"), vec!["percent"]);
8242
8243        session("windows-root", r"C:\Users\x\proj");
8244        session("windows-child", r"C:\Users\x\proj\sub");
8245        session("windows-sibling", r"C:\Users\x\project2");
8246        msg(6, "windows-root");
8247        msg(7, "windows-child");
8248        msg(8, "windows-sibling");
8249        assert_eq!(
8250            ids(r"C:\Users\x\proj"),
8251            vec!["windows-child", "windows-root"]
8252        );
8253    }
8254
8255    #[test]
8256    fn list_recent_returns_newest_sessions_in_exact_directory_or_descendants() {
8257        let dir = tempfile::tempdir().unwrap();
8258        let db = Db::open(&dir.path().join("index.db")).unwrap();
8259        db.conn
8260            .execute_batch(
8261                "insert into sessions (
8262                     id, provider, provider_session_id, cwd, updated_at, preview_text,
8263                     source_path, parse_version, discovery_source
8264                 ) values
8265                   ('claude:root-old', 'claude', 'root-old', '/work/project',
8266                    '2026-01-01T00:00:00+00:00', '', '/root-old', 'v1', 'test'),
8267                   ('claude:child-new', 'claude', 'child-new', '/work/project/sub/dir',
8268                    '2026-03-01T00:00:00+00:00', '', '/child-new', 'v1', 'test'),
8269                   ('claude:sibling-newer', 'claude', 'sibling-newer', '/work/project-other',
8270                    '2026-04-01T00:00:00+00:00', '', '/sibling-newer', 'v1', 'test');",
8271            )
8272            .unwrap();
8273
8274        let query = |limit| SearchFilters {
8275            path_prefix: Some("/work/project".to_string()),
8276            limit,
8277            ..SearchFilters::default()
8278        };
8279        assert_eq!(
8280            db.list_recent(&query(1))
8281                .unwrap()
8282                .into_iter()
8283                .map(|session| session.id)
8284                .collect::<Vec<_>>(),
8285            ["claude:child-new"],
8286            "limit applies after exact-directory/descendant filtering and newest-first ordering"
8287        );
8288        assert_eq!(
8289            db.list_recent(&query(0))
8290                .unwrap()
8291                .into_iter()
8292                .map(|session| session.id)
8293                .collect::<Vec<_>>(),
8294            ["claude:child-new", "claude:root-old"]
8295        );
8296    }
8297
8298    #[test]
8299    fn restricted_fuzzy_search_scores_only_authorized_messages() {
8300        use crate::config::{SearchScopeConfig, SearchScopeMode};
8301        use crate::search_scope::{EffectiveAccessScope, TrustedAccessInputs};
8302
8303        let dir = tempfile::tempdir().unwrap();
8304        let allowed = dir.path().join("allowed");
8305        let hidden = dir.path().join("hidden");
8306        fs::create_dir_all(&allowed).unwrap();
8307        fs::create_dir_all(&hidden).unwrap();
8308        let mut db = Db::open(&dir.path().join("index.db")).unwrap();
8309        db.mark_schema_current().unwrap();
8310        let transaction = db.conn.unchecked_transaction().unwrap();
8311        {
8312            let mut insert_session = transaction
8313                .prepare(
8314                    "insert into sessions (
8315                         id, provider, provider_session_id, cwd, repo_root, preview_text,
8316                         source_path, parse_version, discovery_source
8317                     ) values (?, 'claude', ?, ?, ?, '', '/fixture', '1', 'test')",
8318                )
8319                .unwrap();
8320            let mut insert_message = transaction
8321                .prepare(
8322                    "insert into messages (
8323                         id, session_id, provider, seq, role, kind, content
8324                     ) values (?, ?, 'claude', 0, 'user', 'conversation', 'needle')",
8325                )
8326                .unwrap();
8327            for index in 0..1_201_i64 {
8328                let id = format!("hidden-{index:04}");
8329                let workspace = hidden.to_string_lossy();
8330                insert_session
8331                    .execute(params![id, id, workspace.as_ref(), workspace.as_ref()])
8332                    .unwrap();
8333                insert_message.execute(params![index + 1, id]).unwrap();
8334            }
8335            let allowed_id = "allowed";
8336            let workspace = allowed.to_string_lossy();
8337            insert_session
8338                .execute(params![
8339                    allowed_id,
8340                    allowed_id,
8341                    workspace.as_ref(),
8342                    workspace.as_ref()
8343                ])
8344                .unwrap();
8345            insert_message
8346                .execute(params![1_202_i64, allowed_id])
8347                .unwrap();
8348        }
8349        transaction.commit().unwrap();
8350
8351        db.set_access_scope(
8352            EffectiveAccessScope::resolve(
8353                &SearchScopeConfig {
8354                    mode: SearchScopeMode::AllowedRoots,
8355                    roots: vec![allowed.to_string_lossy().into_owned()],
8356                    include_invocation_directory: false,
8357                },
8358                TrustedAccessInputs::default(),
8359            )
8360            .unwrap(),
8361        );
8362        let hits = db
8363            .search_messages(
8364                "needle",
8365                &MessageFilters {
8366                    match_mode: MessageSearchMode::Fuzzy,
8367                    limit: 1,
8368                    ..Default::default()
8369                },
8370            )
8371            .unwrap();
8372        assert_eq!(hits.len(), 1);
8373        assert_eq!(hits[0].session_id, "allowed");
8374    }
8375
8376    #[test]
8377    fn exact_session_filter_does_not_merge_substring_matches() {
8378        let dir = tempfile::tempdir().unwrap();
8379        let db = Db::open(&dir.path().join("index.db")).unwrap();
8380        for id in ["abc", "xabcx"] {
8381            db.conn
8382                .execute(
8383                    "insert into sessions (id, provider, provider_session_id, preview_text, \
8384                     source_path, parse_version, discovery_source) \
8385                     values (?1,'claude',?1,'','/p','1','test')",
8386                    params![id],
8387                )
8388                .unwrap();
8389        }
8390        db.conn
8391            .execute(
8392                "insert into messages (id, session_id, provider, seq, role, content) \
8393                 values (1,'abc','claude',0,'user','same')",
8394                [],
8395            )
8396            .unwrap();
8397        db.conn
8398            .execute(
8399                "insert into messages (id, session_id, provider, seq, role, content) \
8400                 values (2,'xabcx','claude',0,'user','same')",
8401                [],
8402            )
8403            .unwrap();
8404
8405        let exact = db
8406            .search_messages(
8407                "",
8408                &MessageFilters {
8409                    session_id: Some("abc".into()),
8410                    ..Default::default()
8411                },
8412            )
8413            .unwrap();
8414        assert_eq!(exact.len(), 1);
8415        assert_eq!(exact[0].session_id, "abc");
8416    }
8417
8418    #[test]
8419    fn open_with_busy_timeout_sets_sqlite_busy_timeout() {
8420        let dir = tempfile::tempdir().unwrap();
8421        let db =
8422            Db::open_with_busy_timeout(&dir.path().join("index.db"), TEST_BUSY_TIMEOUT_MS).unwrap();
8423        assert_eq!(db.busy_timeout_ms().unwrap(), TEST_BUSY_TIMEOUT_MS);
8424    }
8425
8426    #[test]
8427    fn scoped_busy_timeout_restores_previous_value() {
8428        let dir = tempfile::tempdir().unwrap();
8429        let db =
8430            Db::open_with_busy_timeout(&dir.path().join("index.db"), TEST_BUSY_TIMEOUT_MS).unwrap();
8431        let observed = db
8432            .with_busy_timeout_ms(TEST_NO_WAIT_BUSY_TIMEOUT_MS, || db.busy_timeout_ms())
8433            .unwrap();
8434        assert_eq!(observed, TEST_NO_WAIT_BUSY_TIMEOUT_MS);
8435        assert_eq!(db.busy_timeout_ms().unwrap(), TEST_BUSY_TIMEOUT_MS);
8436    }
8437
8438    #[test]
8439    fn sqlite_busy_error_detection_matches_locked_writer() {
8440        let dir = tempfile::tempdir().unwrap();
8441        let path = dir.path().join("index.db");
8442        let writer = Db::open(&path).unwrap();
8443        let contender = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
8444
8445        writer.conn.execute_batch("begin immediate").unwrap();
8446        let err = contender
8447            .conn
8448            .execute(
8449                "insert into sessions (id, provider, provider_session_id, preview_text, \
8450                 source_path, parse_version, discovery_source) \
8451                 values ('busy','claude','busy','','/p','1','test')",
8452                [],
8453            )
8454            .unwrap_err();
8455        let err = anyhow::Error::from(err);
8456        assert!(Db::is_sqlite_busy_error(&err));
8457        writer.conn.execute_batch("rollback").unwrap();
8458    }
8459
8460    #[test]
8461    fn auto_reindex_completion_timestamp_controls_shared_freshness_window() {
8462        const COMPLETED_MS: i64 = 20_000;
8463        const INTERVAL_MS: u64 = 1_000;
8464
8465        let dir = tempfile::tempdir().unwrap();
8466        let path = dir.path().join("index.db");
8467        let first = Db::open(&path).unwrap();
8468        let second = Db::open(&path).unwrap();
8469
8470        assert!(!second
8471            .auto_reindex_is_fresh_at(COMPLETED_MS, INTERVAL_MS)
8472            .unwrap());
8473        let completed_at = Utc.timestamp_millis_opt(COMPLETED_MS).single().unwrap()
8474            + chrono::Duration::nanoseconds(456_789);
8475        first.mark_auto_reindex_complete_at(completed_at).unwrap();
8476        assert_eq!(
8477            second.auto_reindex_completed_at().unwrap(),
8478            Some(completed_at)
8479        );
8480        assert!(second
8481            .auto_reindex_is_fresh_at(COMPLETED_MS + 999, INTERVAL_MS)
8482            .unwrap());
8483        assert!(!second
8484            .auto_reindex_is_fresh_at(COMPLETED_MS + 1_000, INTERVAL_MS)
8485            .unwrap());
8486    }
8487
8488    #[test]
8489    fn auto_reindex_freshness_uses_only_metadata_primary_keys() {
8490        let dir = tempfile::tempdir().unwrap();
8491        let db = Db::open(&dir.path().join("index.db")).unwrap();
8492        db.mark_auto_reindex_complete().unwrap();
8493        let plan = db
8494            .conn
8495            .prepare(&format!("explain query plan {AUTO_REINDEX_FRESHNESS_SQL}"))
8496            .unwrap()
8497            .query_map(
8498                params![
8499                    AUTO_REINDEX_COMPLETED_MS_KEY,
8500                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[0],
8501                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[1],
8502                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[2],
8503                    AUTO_REINDEX_PARSER_CONTRACT_KEYS[3],
8504                ],
8505                |row| row.get::<_, String>(3),
8506            )
8507            .unwrap()
8508            .collect::<rusqlite::Result<Vec<_>>>()
8509            .unwrap()
8510            .join("\n");
8511
8512        assert!(!plan.contains("SCAN"), "{plan}");
8513        assert_eq!(
8514            plan.matches("sqlite_autoindex_index_metadata_1").count(),
8515            5,
8516            "{plan}"
8517        );
8518    }
8519
8520    #[test]
8521    fn message_search_filters_by_session_local_seq_range() {
8522        let dir = tempfile::tempdir().unwrap();
8523        let db = Db::open(&dir.path().join("index.db")).unwrap();
8524        for id in ["claude:s1", "claude:s2"] {
8525            db.conn
8526                .execute(
8527                    "insert into sessions (id, provider, provider_session_id, preview_text, \
8528                     source_path, parse_version, discovery_source) \
8529                     values (?1,'claude',?1,'','/p','1','test')",
8530                    params![id],
8531                )
8532                .unwrap();
8533            for seq in 0..5 {
8534                db.conn
8535                    .execute(
8536                        "insert into messages (session_id, provider, seq, role, content) \
8537                         values (?1,'claude',?2,'user',?3)",
8538                        params![id, seq, format!("needle {id} {seq}")],
8539                    )
8540                    .unwrap();
8541            }
8542        }
8543
8544        let bounded = db
8545            .search_messages(
8546                "needle",
8547                &MessageFilters {
8548                    session_id: Some("claude:s1".into()),
8549                    seq_from: Some(1),
8550                    seq_to: Some(3),
8551                    ..Default::default()
8552                },
8553            )
8554            .unwrap();
8555        assert_eq!(
8556            bounded.iter().map(|h| h.seq).collect::<Vec<_>>(),
8557            vec![1, 2, 3]
8558        );
8559        assert!(bounded.iter().all(|h| h.session_id == "claude:s1"));
8560
8561        let open_ended = db
8562            .search_messages(
8563                "",
8564                &MessageFilters {
8565                    session_id: Some("claude:s2".into()),
8566                    seq_from: Some(3),
8567                    ..Default::default()
8568                },
8569            )
8570            .unwrap();
8571        assert_eq!(
8572            open_ended.iter().map(|h| h.seq).collect::<Vec<_>>(),
8573            vec![3, 4]
8574        );
8575    }
8576
8577    #[test]
8578    fn find_corrections_honors_path_prefix() {
8579        // Regression: the analytics queries build bespoke SQL, so path_prefix must be applied
8580        // there too (it was silently ignored until push_path_prefix unified the predicate).
8581        let dir = tempfile::tempdir().unwrap();
8582        let db = Db::open(&dir.path().join("index.db")).unwrap();
8583        let session = |id: &str, cwd: &str| {
8584            db.conn
8585                .execute(
8586                    "insert into sessions (id, provider, provider_session_id, cwd, repo_root, \
8587                     preview_text, source_path, parse_version, discovery_source) \
8588                     values (?1,'claude',?1,?2,?2,'','/p','1','test')",
8589                    params![id, cwd],
8590                )
8591                .unwrap();
8592        };
8593        session("a", "/Users/x/proj-a");
8594        session("b", "/Users/x/proj-b");
8595        let user_msg = |id: i64, sid: &str| {
8596            db.conn
8597                .execute(
8598                    "insert into messages (
8599                         id, session_id, provider, seq, role, content,
8600                         authorship, record_relation
8601                     ) values (
8602                         ?1,?2,'claude',0,'user','that is wrong, please revert',
8603                         'human','original'
8604                     )",
8605                    params![id, sid],
8606                )
8607                .unwrap();
8608        };
8609        user_msg(1, "a");
8610        user_msg(2, "b");
8611
8612        let policies = test_policies(&[("misc", &["wrong"])]);
8613        let scoped = db
8614            .find_corrections(
8615                &policies,
8616                &MessageFilters {
8617                    path_prefix: Some("/Users/x/proj-a".into()),
8618                    ..Default::default()
8619                },
8620            )
8621            .unwrap();
8622        assert_eq!(
8623            scoped
8624                .iter()
8625                .map(|c| c.session_id.clone())
8626                .collect::<Vec<_>>(),
8627            vec!["a"],
8628            "path_prefix must scope corrections to the matching session"
8629        );
8630        // Without the prefix both sessions' corrections surface.
8631        assert_eq!(
8632            db.find_corrections(&policies, &MessageFilters::default())
8633                .unwrap()
8634                .len(),
8635            2
8636        );
8637    }
8638
8639    #[test]
8640    fn analytics_queries_honor_exact_session_id() {
8641        let dir = tempfile::tempdir().unwrap();
8642        let db = Db::open(&dir.path().join("index.db")).unwrap();
8643        let session = |id: &str| {
8644            db.conn
8645                .execute(
8646                    "insert into sessions (id, provider, provider_session_id, preview_text, \
8647                     source_path, parse_version, discovery_source) \
8648                     values (?1,'claude',?1,'','/p','1','test')",
8649                    params![id],
8650                )
8651                .unwrap();
8652        };
8653        session("s1");
8654        session("s10");
8655        let message = |id: i64, sid: &str, seq: i64, role: &str, content: &str| {
8656            db.conn
8657                .execute(
8658                    "insert into messages (
8659                         id, session_id, provider, seq, role, content,
8660                         authorship, record_relation
8661                     ) values (?1,?2,'claude',?3,?4,?5,'human','original')",
8662                    params![id, sid, seq, role, content],
8663                )
8664                .unwrap();
8665        };
8666        message(1, "s1", 0, "user", "that is wrong");
8667        message(2, "s1", 1, "slash", "/cmd-a");
8668        message(3, "s10", 0, "user", "that is wrong");
8669        message(4, "s10", 1, "slash", "/cmd-b");
8670
8671        let filters = MessageFilters {
8672            session_id: Some("s1".into()),
8673            ..Default::default()
8674        };
8675        let policies = test_policies(&[("misc", &["wrong"])]);
8676        assert_eq!(
8677            db.find_corrections(&policies, &filters)
8678                .unwrap()
8679                .iter()
8680                .map(|row| row.session_id.as_str())
8681                .collect::<Vec<_>>(),
8682            vec!["s1"],
8683            "exact session_id must not match s10"
8684        );
8685        assert_eq!(
8686            db.message_role_counts(&filters).unwrap(),
8687            vec![("slash".to_string(), 1), ("user".to_string(), 1)]
8688        );
8689        let planning = db.planning_usage(&filters, &[]).unwrap();
8690        assert_eq!(planning.len(), 1);
8691        assert_eq!(planning[0].command, "/cmd-a");
8692    }
8693
8694    #[test]
8695    fn search_messages_filters_by_tool_name_and_surfaces_it() {
8696        let dir = tempfile::tempdir().unwrap();
8697        let db = Db::open(&dir.path().join("index.db")).unwrap();
8698        db.conn
8699            .execute(
8700                "insert into sessions (id, provider, provider_session_id, preview_text, \
8701                 source_path, parse_version, discovery_source) \
8702                 values ('s1','claude','s1','','/p','1','test')",
8703                [],
8704            )
8705            .unwrap();
8706        let insert = |id: i64, seq: i64, role: &str, tool: Option<&str>, content: &str| {
8707            db.conn
8708                .execute(
8709                    "insert into messages (id, session_id, provider, seq, role, tool_name, content) \
8710                     values (?1,'s1','claude',?2,?3,?4,?5)",
8711                    params![id, seq, role, tool, content],
8712                )
8713                .unwrap();
8714        };
8715        insert(1, 0, "user", None, "run the build");
8716        insert(2, 1, "tool", Some("Bash"), "build ok");
8717        insert(3, 2, "tool", Some("Edit"), "edited the file");
8718        insert(4, 3, "tool", Some("ÄTool"), "unicode tool");
8719
8720        // tool_name is surfaced on the hit.
8721        let tools = db
8722            .search_messages(
8723                "",
8724                &MessageFilters {
8725                    role: Some(Role::Tool),
8726                    ..Default::default()
8727                },
8728            )
8729            .unwrap();
8730        let bash = tools
8731            .iter()
8732            .find(|h| h.seq == 1)
8733            .expect("Bash tool message");
8734        assert_eq!(bash.tool_name.as_deref(), Some("Bash"));
8735
8736        // --tool is a case-insensitive substring filter and never matches NULL-tool rows.
8737        let only = db
8738            .search_messages(
8739                "",
8740                &MessageFilters {
8741                    tool: Some("bash".into()),
8742                    ..Default::default()
8743                },
8744            )
8745            .unwrap();
8746        assert_eq!(only.len(), 1);
8747        assert_eq!(only[0].seq, 1);
8748        let unicode = db
8749            .search_messages(
8750                "ät",
8751                &MessageFilters {
8752                    field: Some(SearchField::ToolName),
8753                    limit: 1,
8754                    ..Default::default()
8755                },
8756            )
8757            .unwrap();
8758        assert_eq!(unicode.len(), 1);
8759        assert_eq!(unicode[0].tool_name.as_deref(), Some("ÄTool"));
8760        db.conn
8761            .execute_batch(
8762                "with recursive n(value) as (
8763                     values(1) union all select value + 1 from n where value < 100
8764                 )
8765                 insert into messages (
8766                     session_id, provider, seq, role, tool_name, content
8767                 )
8768                 select 's1', 'claude', 100 + value, 'tool',
8769                        printf('other_tool_%03d', value), 'unrelated'
8770                   from n;",
8771            )
8772            .unwrap();
8773        let (fuzzy, explain) = db
8774            .search_messages_with_explain(
8775                "edt",
8776                &MessageFilters {
8777                    field: Some(SearchField::ToolName),
8778                    match_mode: MessageSearchMode::Fuzzy,
8779                    limit: 5,
8780                    ..Default::default()
8781                },
8782                true,
8783            )
8784            .unwrap();
8785        assert_eq!(fuzzy.len(), 1);
8786        assert_eq!(fuzzy[0].tool_name.as_deref(), Some("Edit"));
8787        let explain = explain.unwrap();
8788        assert_eq!(explain.prefilter, None);
8789        assert_eq!(explain.candidates, Some(1));
8790        assert_eq!(
8791            explain.prefilter_skipped.as_deref(),
8792            Some("complete filtered tool-name vocabulary scored before matching rows are loaded")
8793        );
8794        assert!(explain.candidates.unwrap() < explain.corpus);
8795        let vocabulary_plan = db
8796            .conn
8797            .prepare(
8798                "explain query plan select tool_name from messages
8799                  where tool_name is not null order by tool_name",
8800            )
8801            .unwrap()
8802            .query_map([], |row| row.get::<_, String>(3))
8803            .unwrap()
8804            .collect::<rusqlite::Result<Vec<_>>>()
8805            .unwrap()
8806            .join("\n");
8807        assert!(
8808            vocabulary_plan.contains("idx_messages_tool_name"),
8809            "{vocabulary_plan}"
8810        );
8811        let none = db
8812            .search_messages(
8813                "",
8814                &MessageFilters {
8815                    tool: Some("zzz".into()),
8816                    ..Default::default()
8817                },
8818            )
8819            .unwrap();
8820        assert!(
8821            none.is_empty(),
8822            "unknown tool matches nothing (incl. NULL-tool rows)"
8823        );
8824    }
8825
8826    #[test]
8827    #[ignore = "deterministic release-mode tool-name vocabulary performance measurement"]
8828    fn fuzzy_tool_name_vocabulary_benchmark() {
8829        let dir = tempfile::tempdir().unwrap();
8830        let db = Db::open(&dir.path().join("index.db")).unwrap();
8831        db.conn
8832            .execute_batch(
8833                "insert into sessions (
8834                     id, provider, provider_session_id, preview_text, source_path,
8835                     parse_version, discovery_source
8836                 ) values ('s1','claude','s1','','/p','1','test')",
8837            )
8838            .unwrap();
8839        let tx = db.conn.unchecked_transaction().unwrap();
8840        {
8841            let mut insert = tx
8842                .prepare(
8843                    "insert into messages (
8844                         session_id, provider, seq, role, tool_name, content
8845                     ) values ('s1', 'claude', ?1, 'tool', ?2, ?3)",
8846                )
8847                .unwrap();
8848            for seq in 0..250_000_i64 {
8849                let name = if seq % 25_000 == 0 {
8850                    "exec_command".to_string()
8851                } else {
8852                    format!("unrelated_tool_{:03}", seq % 250)
8853                };
8854                insert
8855                    .execute(params![seq, name, "x".repeat(2_048)])
8856                    .unwrap();
8857            }
8858        }
8859        tx.commit().unwrap();
8860        let filters = MessageFilters {
8861            field: Some(SearchField::ToolName),
8862            match_mode: MessageSearchMode::Fuzzy,
8863            limit: 20,
8864            ..Default::default()
8865        };
8866        let measure = |optimized: bool| {
8867            let search = || {
8868                if optimized {
8869                    db.search_messages("exec", &filters).unwrap()
8870                } else {
8871                    db.search_tool_name_fuzzy_direct_for_test("exec", &filters)
8872                        .unwrap()
8873                }
8874            };
8875            let expected = serde_json::to_vec(&search()).unwrap();
8876            let digest = crate::hashing::sha256(&expected);
8877            let mut elapsed = Vec::new();
8878            for _ in 0..7 {
8879                let started = Instant::now();
8880                assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
8881                elapsed.push(started.elapsed().as_micros());
8882            }
8883            elapsed.sort_unstable();
8884            (
8885                elapsed[elapsed.len() / 2],
8886                elapsed[elapsed.len() - 1],
8887                digest,
8888            )
8889        };
8890        let direct = measure(false);
8891        let optimized = measure(true);
8892        assert_eq!(direct.2, optimized.2);
8893        eprintln!(
8894            "tool_name_vocabulary_benchmark rows=250000 vocabulary=251 direct_median_us={} direct_p95_us={} optimized_median_us={} optimized_p95_us={} digest=sha256:{}",
8895            direct.0, direct.1, optimized.0, optimized.1, direct.2
8896        );
8897    }
8898
8899    #[test]
8900    fn global_tool_name_vocabulary_matches_distinct_index_scan() {
8901        let dir = tempfile::tempdir().unwrap();
8902        let db = Db::open(&dir.path().join("index.db")).unwrap();
8903        db.conn
8904            .execute_batch(
8905                "insert into sessions (
8906                     id, provider, provider_session_id, preview_text, source_path,
8907                     parse_version, discovery_source
8908                 ) values ('s1','claude','s1','','/p','1','test');
8909                 insert into messages (
8910                     session_id, provider, seq, role, tool_name, content
8911                 ) values
8912                   ('s1', 'claude', 0, 'tool', 'ÄTool', ''),
8913                   ('s1', 'claude', 1, 'tool', 'exec_command', ''),
8914                   ('s1', 'claude', 2, 'tool', 'exec_command', ''),
8915                   ('s1', 'claude', 3, 'user', null, '');",
8916            )
8917            .unwrap();
8918        let expected = db
8919            .conn
8920            .prepare(
8921                "select distinct tool_name from messages
8922                  where tool_name is not null order by tool_name",
8923            )
8924            .unwrap()
8925            .query_map([], |row| row.get::<_, String>(0))
8926            .unwrap()
8927            .collect::<rusqlite::Result<Vec<_>>>()
8928            .unwrap();
8929        assert_eq!(db.global_tool_name_vocabulary().unwrap(), expected);
8930    }
8931
8932    #[test]
8933    fn fuzzy_tool_name_vocabulary_path_matches_direct_row_scoring() {
8934        let dir = tempfile::tempdir().unwrap();
8935        let db = Db::open(&dir.path().join("index.db")).unwrap();
8936        db.conn
8937            .execute_batch(
8938                "insert into sessions (
8939                     id, provider, provider_session_id, preview_text, source_path,
8940                     parse_version, discovery_source
8941                 ) values
8942                   ('s1','claude','s1','','/p','1','test'),
8943                   ('s2','codex','s2','','/q','1','test');
8944                 insert into messages (
8945                     session_id, provider, seq, role, tool_name, content
8946                 ) values
8947                   ('s1', 'claude', 0, 'tool', 'exec_command', 'large ignored payload a'),
8948                   ('s1', 'claude', 1, 'tool', 'Edit', 'large ignored payload b'),
8949                   ('s1', 'claude', 2, 'tool', 'mcp:memory:execute', 'large ignored payload c'),
8950                   ('s1', 'claude', 3, 'tool', 'ExecCommand', 'equal-score spelling payload'),
8951                   ('s2', 'codex', 0, 'tool', 'exec_command', 'large ignored payload d'),
8952                   ('s2', 'codex', 1, 'tool', 'write_stdin', 'large ignored payload e');",
8953            )
8954            .unwrap();
8955        let filters = MessageFilters {
8956            field: Some(SearchField::ToolName),
8957            match_mode: MessageSearchMode::Fuzzy,
8958            providers: Some(vec![Provider::Claude]),
8959            limit: 2,
8960            offset: 1,
8961            ..Default::default()
8962        };
8963        let optimized = db.search_messages("exec", &filters).unwrap();
8964        let direct = db
8965            .search_tool_name_fuzzy_direct_for_test("exec", &filters)
8966            .unwrap();
8967        assert_eq!(
8968            optimized
8969                .iter()
8970                .map(|hit| (&hit.session_id, hit.seq, hit.fuzzy_score))
8971                .collect::<Vec<_>>(),
8972            direct
8973                .iter()
8974                .map(|hit| (&hit.session_id, hit.seq, hit.fuzzy_score))
8975                .collect::<Vec<_>>()
8976        );
8977    }
8978
8979    #[test]
8980    fn fuzzy_tool_name_searches_the_complete_filtered_vocabulary() {
8981        let dir = tempfile::tempdir().unwrap();
8982        let db = Db::open(&dir.path().join("index.db")).unwrap();
8983        db.conn
8984            .execute_batch(
8985                "insert into sessions (
8986                     id, provider, provider_session_id, preview_text, source_path,
8987                     parse_version, discovery_source
8988                 ) values ('s1','claude','s1','','/p','1','test')",
8989            )
8990            .unwrap();
8991        let tx = db.conn.unchecked_transaction().unwrap();
8992        {
8993            let mut insert = tx
8994                .prepare(
8995                    "insert into messages (
8996                         session_id, provider, seq, role, tool_name, content
8997                     ) values ('s1', 'claude', ?1, 'tool', ?2, '')",
8998                )
8999                .unwrap();
9000            for seq in 0..=10_000_usize {
9001                insert
9002                    .execute(params![seq as i64, format!("tool_{seq:05}")])
9003                    .unwrap();
9004            }
9005        }
9006        tx.commit().unwrap();
9007
9008        db.conn
9009            .execute(
9010                "insert into messages (
9011                     session_id, provider, seq, role, tool_name, content
9012                 ) values ('s1', 'claude', ?1, 'tool', 'tol', '')",
9013                params![10_001_i64],
9014            )
9015            .unwrap();
9016
9017        let (hits, explain) = db
9018            .search_messages_with_explain(
9019                "tol",
9020                &MessageFilters {
9021                    field: Some(SearchField::ToolName),
9022                    match_mode: MessageSearchMode::Fuzzy,
9023                    limit: 1,
9024                    ..Default::default()
9025                },
9026                true,
9027            )
9028            .unwrap();
9029
9030        assert_eq!(hits.len(), 1);
9031        assert_eq!(hits[0].tool_name.as_deref(), Some("tol"));
9032        let explain = explain.unwrap();
9033        assert_eq!(explain.corpus, 10_002);
9034        assert_eq!(explain.candidates, Some(10_002));
9035    }
9036
9037    /// A literal or regex tool-name search verifies in SQLite with no prefilter, and its receipt
9038    /// used to report the rows the caller's page happened to read (`limit + 1` with the CLI's
9039    /// look-ahead, so `--limit 1` printed `2 / 2 corpus rows`). The receipt now carries the
9040    /// structural denominator, tool-call and tool-result rows the filters admit, whatever the
9041    /// page size, and no candidate count because nothing was prefiltered.
9042    #[test]
9043    fn tool_name_literal_receipt_reports_the_named_tool_corpus_independent_of_the_page() {
9044        let dir = tempfile::tempdir().unwrap();
9045        let db = Db::open(&dir.path().join("index.db")).unwrap();
9046        db.conn
9047            .execute_batch(
9048                "insert into sessions (
9049                     id, provider, provider_session_id, preview_text, source_path,
9050                     parse_version, discovery_source
9051                 ) values ('s1','claude','s1','','/p','1','test');
9052                 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
9053                 values ('s1', 'claude', 0, 'tool', 'tool_call', 'exec_command', ''),
9054                        ('s1', 'claude', 1, 'tool', 'tool_call', 'exec_command', ''),
9055                        ('s1', 'claude', 2, 'tool', 'tool_call', 'apply_patch', ''),
9056                        ('s1', 'claude', 3, 'user', 'conversation', null, 'no tool');",
9057            )
9058            .unwrap();
9059        for limit in [1, 2, 10] {
9060            let (hits, explain) = db
9061                .search_messages_with_explain(
9062                    "exec",
9063                    &MessageFilters {
9064                        field: Some(SearchField::ToolName),
9065                        limit,
9066                        ..Default::default()
9067                    },
9068                    true,
9069                )
9070                .unwrap();
9071            assert_eq!(hits.len(), 2.min(limit));
9072            let explain = explain.unwrap();
9073            assert_eq!(
9074                explain.corpus, 3,
9075                "limit {limit}: the denominator is every named-tool row the filters admit"
9076            );
9077            assert_eq!(explain.candidates, None, "nothing was prefiltered");
9078            assert!(explain.prefilter_skipped.is_some());
9079        }
9080    }
9081
9082    /// Every fuzzy scoring batch is bounded by rows and by bytes; the content path used to be
9083    /// bounded by rows alone, so 512 megabyte-sized tool results were held before scoring.
9084    #[test]
9085    fn fuzzy_batches_score_at_the_row_cap_or_the_byte_cap_whichever_comes_first() {
9086        assert!(fuzzy_batch_is_full(FUZZY_SCORE_BATCH_SIZE, 0));
9087        assert!(fuzzy_batch_is_full(3, FUZZY_BATCH_BYTES));
9088        assert!(fuzzy_batch_is_full(3, FUZZY_BATCH_BYTES + 1));
9089        assert!(!fuzzy_batch_is_full(
9090            FUZZY_SCORE_BATCH_SIZE - 1,
9091            FUZZY_BATCH_BYTES - 1
9092        ));
9093        assert!(!fuzzy_batch_is_full(1, 1));
9094    }
9095
9096    /// `offset` is caller input on every surface and no schema caps it. The ranked page
9097    /// (`offset + limit` rows) must be retained memory proportional to rows actually found, never
9098    /// a reservation sized by the request: `Vec::with_capacity(offset + limit)` panicked with
9099    /// "capacity overflow" at `--offset 100000000000000000` (CLI, and inside the MCP worker) and
9100    /// asked the allocator for ~150 GB at `--offset 1000000000` before that.
9101    #[test]
9102    fn fuzzy_tool_name_offset_beyond_the_corpus_returns_empty_without_reserving_the_offset() {
9103        let dir = tempfile::tempdir().unwrap();
9104        let db = Db::open(&dir.path().join("index.db")).unwrap();
9105        db.conn
9106            .execute_batch(
9107                "insert into sessions (
9108                     id, provider, provider_session_id, preview_text, source_path,
9109                     parse_version, discovery_source
9110                 ) values ('s1','claude','s1','','/p','1','test');
9111                 insert into messages (session_id, provider, seq, role, tool_name, content)
9112                 values ('s1', 'claude', 0, 'tool', 'exec_command', '');",
9113            )
9114            .unwrap();
9115        for offset in [100_000_000_000_000_000_usize, 1_000_000_000] {
9116            let hits = db
9117                .search_messages(
9118                    "exec",
9119                    &MessageFilters {
9120                        field: Some(SearchField::ToolName),
9121                        match_mode: MessageSearchMode::Fuzzy,
9122                        limit: 1,
9123                        offset,
9124                        ..Default::default()
9125                    },
9126                )
9127                .unwrap();
9128            assert!(hits.is_empty(), "offset {offset} lies past the only row");
9129        }
9130    }
9131
9132    /// A fuzzy window past the eligible corpus is answered from the index, without scoring a row.
9133    ///
9134    /// `offset + limit` is the retention target for fuzzy ranking, and `retain_top_fuzzy_hits`
9135    /// truncates only above it. An offset larger than any corpus therefore made the target
9136    /// unreachable: every matching row stayed in memory with its content until `skip(offset)`
9137    /// threw all of it away. The receipt is the observable proof that no scoring happened —
9138    /// `candidates` counts rows that scored, and it is absent rather than a zero that would read
9139    /// as "the corpus was scored and nothing matched".
9140    #[test]
9141    fn a_fuzzy_window_past_the_corpus_is_answered_without_scoring_any_row() {
9142        let dir = tempfile::tempdir().unwrap();
9143        let db = Db::open(&dir.path().join("index.db")).unwrap();
9144        db.conn
9145            .execute_batch(
9146                "insert into sessions (
9147                     id, provider, provider_session_id, preview_text, source_path,
9148                     parse_version, discovery_source
9149                 ) values ('s1','claude','s1','','/p','1','test');",
9150            )
9151            .unwrap();
9152        for seq in 0..4 {
9153            db.conn
9154                .execute(
9155                    "insert into messages (session_id, provider, seq, role, tool_name, content) \
9156                     values ('s1', 'claude', ?, 'tool', 'exec_command', 'exec command output')",
9157                    [seq],
9158                )
9159                .unwrap();
9160        }
9161
9162        for field in [
9163            None,
9164            Some(SearchField::ToolName),
9165            Some(SearchField::Content),
9166        ] {
9167            let filters = |offset: usize| MessageFilters {
9168                field,
9169                match_mode: MessageSearchMode::Fuzzy,
9170                limit: 2,
9171                offset,
9172                ..Default::default()
9173            };
9174
9175            // Past the corpus: empty page, exact corpus, and nothing scored. The offset is above
9176            // FUZZY_SCORE_BATCH_SIZE, which is what makes the window worth bounding.
9177            let (hits, explain) = db
9178                .search_messages_with_explain("exec", &filters(1_000_000_000), true)
9179                .unwrap();
9180            assert!(hits.is_empty(), "{field:?}");
9181            let explain = explain.expect("an explained request returns a receipt");
9182            assert_eq!(explain.corpus, 4, "{field:?}");
9183            assert_eq!(
9184                explain.candidates, None,
9185                "{field:?}: no row scored, so no candidate count was measured"
9186            );
9187
9188            // Inside the corpus the page and its receipt are unchanged by the bound.
9189            let (hits, explain) = db
9190                .search_messages_with_explain("exec", &filters(0), true)
9191                .unwrap();
9192            assert_eq!(hits.len(), 2, "{field:?}");
9193            assert_eq!(
9194                explain.expect("receipt").candidates,
9195                Some(4),
9196                "{field:?}: every row scored"
9197            );
9198        }
9199    }
9200
9201    /// The last row inside the corpus is returned, and the first offset past it is not.
9202    ///
9203    /// [`Db::fuzzy_ranking_window`] answers a page from the count when `offset >= corpus`, and the
9204    /// two offsets either side of that comparison are where an off-by-one would flip: one row
9205    /// silently dropped from the end of a deep page, or an empty page reported as scored. The
9206    /// existing coverage uses offset zero and an offset far past any corpus, so both sit well away
9207    /// from the boundary. The window is only measured when the requested page exceeds one scoring
9208    /// batch, which is what the large limit is for.
9209    #[test]
9210    fn the_fuzzy_window_boundary_keeps_the_last_row_and_drops_the_first_one_past_it() {
9211        let dir = tempfile::tempdir().unwrap();
9212        let db = Db::open(&dir.path().join("index.db")).unwrap();
9213        db.conn
9214            .execute_batch(
9215                "insert into sessions (
9216                     id, provider, provider_session_id, preview_text, source_path,
9217                     parse_version, discovery_source
9218                 ) values ('s1','claude','s1','','/p','1','test');",
9219            )
9220            .unwrap();
9221        let corpus = 4_usize;
9222        // `seq` is a SQLite integer column: bind the width SQLite stores, which rusqlite 0.40
9223        // no longer infers from usize.
9224        for seq in 0..corpus as i64 {
9225            db.conn
9226                .execute(
9227                    "insert into messages (session_id, provider, seq, role, content) \
9228                     values ('s1', 'claude', ?, 'user', 'exec command output')",
9229                    [seq],
9230                )
9231                .unwrap();
9232        }
9233
9234        let filters = |offset: usize| MessageFilters {
9235            match_mode: MessageSearchMode::Fuzzy,
9236            limit: FUZZY_SCORE_BATCH_SIZE + 100,
9237            offset,
9238            ..Default::default()
9239        };
9240
9241        // The last offset inside the corpus still yields its one remaining row, scored.
9242        let (hits, explain) = db
9243            .search_messages_with_explain("exec", &filters(corpus - 1), true)
9244            .unwrap();
9245        assert_eq!(hits.len(), 1, "the last row is inside the corpus");
9246        assert_eq!(explain.expect("receipt").candidates, Some(corpus as i64));
9247
9248        // The first offset past it is answered from the count, with nothing scored.
9249        let (hits, explain) = db
9250            .search_messages_with_explain("exec", &filters(corpus), true)
9251            .unwrap();
9252        assert!(hits.is_empty(), "one past the last row is an empty page");
9253        let explain = explain.expect("receipt");
9254        assert_eq!(explain.corpus, corpus as i64);
9255        assert_eq!(
9256            explain.candidates, None,
9257            "an empty page from the count scored no row"
9258        );
9259    }
9260
9261    /// A literal search finds Greek text ending in sigma, whichever spelling either side uses.
9262    ///
9263    /// End to end rather than at the matcher: the query needle, the trigram candidate selection,
9264    /// and the SQL scalar that verifies each candidate all have to agree about sigma, and each was
9265    /// reached by a different route. The needle was lowered as a whole string, which turns a
9266    /// word-final `Σ` into `ς`, while the scalar folded the stored text one character at a time
9267    /// into `σ` — so a search for a word could return nothing for the message holding that word.
9268    #[test]
9269    fn a_literal_search_finds_greek_text_ending_in_sigma() {
9270        let dir = tempfile::tempdir().unwrap();
9271        let db = Db::open(&dir.path().join("index.db")).unwrap();
9272        db.conn
9273            .execute_batch(
9274                "insert into sessions (
9275                     id, provider, provider_session_id, preview_text, source_path,
9276                     parse_version, discovery_source
9277                 ) values ('s1','claude','s1','','/p','1','test');
9278                 insert into messages (session_id, provider, seq, role, kind, content)
9279                 values ('s1','claude',0,'user','message','η ΟΔΟΣΣ που ψαχνω'),
9280                        ('s1','claude',1,'user','message','η οδοσς που ψαχνω επισης');",
9281            )
9282            .unwrap();
9283
9284        // Every spelling of the word selects both rows: the uppercase text, and the lowercase text
9285        // whose sigma is written in its word-final form.
9286        for query in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ"] {
9287            let hits = db
9288                .search_messages(
9289                    query,
9290                    &MessageFilters {
9291                        match_mode: MessageSearchMode::Literal,
9292                        limit: 10,
9293                        ..Default::default()
9294                    },
9295                )
9296                .unwrap();
9297            assert_eq!(hits.len(), 2, "query {query:?} finds both spellings");
9298        }
9299    }
9300
9301    /// A database on the pre-v4 layout finds that same Greek text.
9302    ///
9303    /// Schema versions below 4 have no FTS5 trigram table and select candidates from the custom
9304    /// [`crate::trigram_index`] instead. That index tokenizes a document with
9305    /// [`str::to_lowercase`], which writes a word-final `Σ` as `ς`, while the verifier folds every
9306    /// sigma onto `σ`. A query spelled with the other sigma then asks for a trigram no document
9307    /// holds, and a row the verifier would accept never becomes a candidate — a prefilter narrowing
9308    /// the result set instead of widening it, which is the one thing it may never do.
9309    #[test]
9310    fn a_pre_v4_literal_search_finds_greek_text_ending_in_sigma() {
9311        let dir = tempfile::tempdir().unwrap();
9312        let db = Db::open(&dir.path().join("index.db")).unwrap();
9313        enable_v3_custom_trigram_compatibility(&db);
9314        seed_messages(
9315            &db,
9316            &[
9317                ("user", "η ΟΔΟΣΣ που ψαχνω"),
9318                ("user", "η οδοσς που ψαχνω επισης"),
9319            ],
9320        );
9321
9322        for query in ["ΟΔΟΣΣ", "οδοσς", "οδοσσ"] {
9323            let (hits, explain) = db
9324                .search_messages_with_explain(
9325                    query,
9326                    &MessageFilters {
9327                        match_mode: MessageSearchMode::Literal,
9328                        limit: 10,
9329                        ..Default::default()
9330                    },
9331                    true,
9332                )
9333                .unwrap();
9334            assert_eq!(hits.len(), 2, "query {query:?} finds both spellings");
9335            // Both rows come back because the scan reached the verifier, rather than because the
9336            // index happened to hold the spelling that was asked for: the receipt names the skip.
9337            let explain = explain.expect("receipt");
9338            assert_eq!(explain.prefilter, None, "query {query:?} indexed nothing");
9339            assert!(
9340                explain
9341                    .prefilter_skipped
9342                    .as_deref()
9343                    .is_some_and(|reason| reason.contains("sigma")),
9344                "query {query:?} reports why it scanned: {:?}",
9345                explain.prefilter_skipped
9346            );
9347        }
9348    }
9349
9350    /// A tool-argument window is bounded by the tool-call rows, not by every message.
9351    ///
9352    /// [`Db::fuzzy_ranking_window`] takes the base predicate so that "each caller measures the rows
9353    /// it actually scans". The tool-argument scan restricts itself to `kind = 'tool_call'` but
9354    /// passed `1 = 1` to the window, so a corpus of ordinary conversation rows sized the retention
9355    /// bound and the receipt. A caller reading `corpus` then divides by a denominator the searched
9356    /// field never had, and an offset already past every tool argument still looked like it landed
9357    /// inside the corpus.
9358    #[test]
9359    fn a_tool_argument_window_counts_only_tool_call_rows() {
9360        let dir = tempfile::tempdir().unwrap();
9361        let db = Db::open(&dir.path().join("index.db")).unwrap();
9362        db.conn
9363            .execute_batch(
9364                "insert into sessions (
9365                     id, provider, provider_session_id, preview_text, source_path,
9366                     parse_version, discovery_source
9367                 ) values ('s1','claude','s1','','/p','1','test');
9368                 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
9369                 values ('s1','claude',0,'tool','tool_call','exec_command','{\"args\":{\"cmd\":\"needle\"}}');
9370                 insert into messages (session_id, provider, seq, role, kind, content)
9371                 values ('s1','claude',1,'user','message','needle in ordinary prose'),
9372                        ('s1','claude',2,'assistant','message','needle again'),
9373                        ('s1','claude',3,'user','message','needle once more');",
9374            )
9375            .unwrap();
9376
9377        let filters = |offset: usize| MessageFilters {
9378            field: Some(SearchField::ToolArgument),
9379            argument_path: Some("/cmd".to_string()),
9380            match_mode: MessageSearchMode::Fuzzy,
9381            limit: 1,
9382            offset,
9383            ..Default::default()
9384        };
9385
9386        // One tool call carries an argument; the three ordinary rows are not tool arguments at all.
9387        let (hits, explain) = db
9388            .search_messages_with_explain("needle", &filters(0), true)
9389            .unwrap();
9390        assert_eq!(hits.len(), 1, "only the tool call has an argument");
9391        assert_eq!(
9392            explain.expect("receipt").corpus,
9393            1,
9394            "corpus is the rows the searched field admits"
9395        );
9396
9397        // An offset past the only tool argument is answered from the count, with nothing scored.
9398        let (hits, explain) = db
9399            .search_messages_with_explain("needle", &filters(1_000_000_000), true)
9400            .unwrap();
9401        assert!(hits.is_empty());
9402        let explain = explain.expect("an explained request returns a receipt");
9403        assert_eq!(explain.corpus, 1, "ordinary rows are not tool arguments");
9404        assert_eq!(
9405            explain.candidates, None,
9406            "no row scored, so no candidate count was measured"
9407        );
9408    }
9409
9410    /// An empty result describes itself the same way whatever page size asked for it.
9411    ///
9412    /// The past-corpus check compared `offset >= corpus`, and `0 >= 0` holds, so a filter matching
9413    /// no rows took the past-corpus path whenever `offset + limit` exceeded one scoring batch. Two
9414    /// requests differing only in `limit` then returned different receipt shapes for the same empty
9415    /// result, and the larger one blamed an offset that is zero and dropped `candidates` — the
9416    /// count readers divide by `corpus` to get selectivity.
9417    #[test]
9418    fn an_empty_corpus_reports_the_same_receipt_whatever_the_page_size() {
9419        let dir = tempfile::tempdir().unwrap();
9420        let db = Db::open(&dir.path().join("index.db")).unwrap();
9421
9422        let receipt = |limit: usize| {
9423            let (hits, explain) = db
9424                .search_messages_with_explain(
9425                    "anything",
9426                    &MessageFilters {
9427                        match_mode: MessageSearchMode::Fuzzy,
9428                        limit,
9429                        ..Default::default()
9430                    },
9431                    true,
9432                )
9433                .unwrap();
9434            assert!(hits.is_empty(), "limit {limit}");
9435            explain.expect("an explained request returns a receipt")
9436        };
9437
9438        // One page size stays under the scoring batch and the other exceeds it, which is what
9439        // decides whether the corpus is counted up front at all.
9440        let small = receipt(100);
9441        let large = receipt(FUZZY_SCORE_BATCH_SIZE + 1);
9442        assert_eq!(small.corpus, 0);
9443        assert_eq!(large.corpus, 0);
9444        assert_eq!(small.candidates, Some(0));
9445        assert_eq!(
9446            large.candidates, small.candidates,
9447            "an empty corpus scored nothing either way; the count is 0, not absent"
9448        );
9449        assert_eq!(large.prefilter_skipped, small.prefilter_skipped);
9450    }
9451
9452    #[test]
9453    fn date_filter_until_covers_sub_second_tail_of_final_second() {
9454        let dir = tempfile::tempdir().unwrap();
9455        let db = Db::open(&dir.path().join("index.db")).unwrap();
9456        db.conn
9457            .execute(
9458                "insert into sessions (id, provider, provider_session_id, preview_text, \
9459                 source_path, parse_version, discovery_source) \
9460                 values ('s1','claude','s1','','/p','1','test')",
9461                [],
9462            )
9463            .unwrap();
9464        // A message stored with sub-second precision in the final second of 2026-01-15.
9465        db.conn
9466            .execute(
9467                "insert into messages (id, session_id, provider, seq, role, ts, content) \
9468                 values (1,'s1','claude',0,'user','2026-01-15T23:59:59.123456789+00:00','late')",
9469                [],
9470            )
9471            .unwrap();
9472        let until =
9473            crate::dates::parse_bound("2026-01-15", crate::dates::Bound::End, Utc::now()).unwrap();
9474        let hits = db
9475            .search_messages(
9476                "",
9477                &MessageFilters {
9478                    until: Some(until),
9479                    ..Default::default()
9480                },
9481            )
9482            .unwrap();
9483        assert_eq!(
9484            hits.len(),
9485            1,
9486            "an inclusive --until must cover sub-second timestamps in its final second"
9487        );
9488    }
9489
9490    #[test]
9491    fn date_filter_preserves_exact_fractional_upper_bound() {
9492        let dir = tempfile::tempdir().unwrap();
9493        let db = Db::open(&dir.path().join("index.db")).unwrap();
9494        db.conn
9495            .execute(
9496                "insert into sessions (id, provider, provider_session_id, preview_text, \
9497                 source_path, parse_version, discovery_source) \
9498                 values ('s1','claude','s1','','/p','1','test')",
9499                [],
9500            )
9501            .unwrap();
9502        for (id, seq, ts) in [
9503            (1, 0, "2026-07-07T02:53:22.357999999+00:00"),
9504            (2, 1, "2026-07-07T02:53:22.358000000+00:00"),
9505            (3, 2, "2026-07-07T02:53:22.358000001+00:00"),
9506        ] {
9507            db.conn
9508                .execute(
9509                    "insert into messages (id, session_id, provider, seq, role, ts, content) \
9510                     values (?1,'s1','claude',?2,'user',?3,'event')",
9511                    rusqlite::params![id, seq, ts],
9512                )
9513                .unwrap();
9514        }
9515        let until = crate::dates::parse_bound(
9516            "2026-07-07T02:53:22.358Z",
9517            crate::dates::Bound::End,
9518            Utc::now(),
9519        )
9520        .unwrap();
9521        let hits = db
9522            .search_messages(
9523                "",
9524                &MessageFilters {
9525                    until: Some(until),
9526                    ..Default::default()
9527                },
9528            )
9529            .unwrap();
9530        assert_eq!(
9531            hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
9532            vec![0, 1],
9533            "an exact fractional --until must exclude later events in the same second"
9534        );
9535    }
9536
9537    #[test]
9538    fn date_filter_keeps_messages_with_unknown_timestamp() {
9539        use chrono::TimeZone;
9540        let dir = tempfile::tempdir().unwrap();
9541        let db = Db::open(&dir.path().join("index.db")).unwrap();
9542        db.conn
9543            .execute(
9544                "insert into sessions (id, provider, provider_session_id, preview_text, \
9545                 source_path, parse_version, discovery_source) \
9546                 values ('s1','claude','s1','','/p','1','test')",
9547                [],
9548            )
9549            .unwrap();
9550        // A message whose timestamp is unknown (NULL) — e.g. a provider/record with no ts.
9551        db.conn
9552            .execute(
9553                "insert into messages (id, session_id, provider, seq, role, ts, content) \
9554                 values (1,'s1','claude',0,'user',NULL,'undated correction')",
9555                [],
9556            )
9557            .unwrap();
9558        let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap();
9559        let until = Utc
9560            .with_ymd_and_hms(2026, 12, 31, 23, 59, 59)
9561            .single()
9562            .unwrap();
9563        let hits = db
9564            .search_messages(
9565                "zebracode",
9566                &MessageFilters {
9567                    since: Some(since),
9568                    until: Some(until),
9569                    ..Default::default()
9570                },
9571            )
9572            .unwrap();
9573        assert!(
9574            hits.is_empty(),
9575            "a NULL-timestamp message must not match every date filter; index a fallback timestamp instead"
9576        );
9577    }
9578
9579    #[test]
9580    fn session_search_zero_is_unlimited_and_large_limit_does_not_overflow() {
9581        let dir = tempfile::tempdir().unwrap();
9582        let db = Db::open(&dir.path().join("index.db")).unwrap();
9583        for id in ["s1", "s2"] {
9584            db.conn
9585                .execute(
9586                    "insert into sessions (id, provider, provider_session_id, preview_text, \
9587                     source_path, parse_version, discovery_source) \
9588                     values (?1,'claude',?1,'alpha preview',?2,'1','test')",
9589                    params![id, format!("/{id}.jsonl")],
9590                )
9591                .unwrap();
9592        }
9593
9594        let scoring = crate::config::ScoringConfig::default();
9595        let mut filters = SearchFilters::default();
9596
9597        assert_eq!(
9598            db.search("alpha", &filters, None, &scoring).unwrap().len(),
9599            2
9600        );
9601
9602        filters.limit = 1;
9603        assert_eq!(
9604            db.search("alpha", &filters, None, &scoring).unwrap().len(),
9605            1
9606        );
9607
9608        filters.limit = usize::MAX;
9609        assert_eq!(
9610            db.search("alpha", &filters, None, &scoring).unwrap().len(),
9611            2
9612        );
9613    }
9614
9615    #[test]
9616    fn session_search_requires_a_text_match_before_ranking_bonuses() {
9617        let dir = tempfile::tempdir().unwrap();
9618        let db = Db::open(&dir.path().join("index.db")).unwrap();
9619        db.conn
9620            .execute(
9621                "insert into sessions (
9622                     id, provider, provider_session_id, cwd, repo_root, preview_text,
9623                     source_path, updated_at, parse_version, discovery_source
9624                 ) values ('s1','claude','s1','/repo','/repo','ordinary text','/p',?1,'1','test')",
9625                params![Utc::now().to_rfc3339()],
9626            )
9627            .unwrap();
9628
9629        let hits = db
9630            .search(
9631                "⸘⸘⸘",
9632                &SearchFilters::default(),
9633                Some("/repo"),
9634                &crate::config::ScoringConfig::default(),
9635            )
9636            .unwrap();
9637
9638        assert!(
9639            hits.is_empty(),
9640            "recency and repository bonuses must not admit an unrelated session"
9641        );
9642    }
9643
9644    #[test]
9645    fn session_search_combines_query_term_coverage_across_one_session() {
9646        let dir = tempfile::tempdir().unwrap();
9647        let db = Db::open(&dir.path().join("index.db")).unwrap();
9648        db.conn
9649            .execute(
9650                "insert into sessions (
9651                     id, provider, provider_session_id, title, summary, preview_text,
9652                     source_path, parse_version, discovery_source
9653                 ) values ('s1','claude','s1','alpha','beta','','/p','1','test')",
9654                [],
9655            )
9656            .unwrap();
9657        let scoring = crate::config::ScoringConfig {
9658            title_score: 0,
9659            summary_score: 0,
9660            path_score: 0,
9661            preview_score: 0,
9662            other_score: 0,
9663            token_bonus: 0,
9664            all_tokens_bonus: 150,
9665            recency_weight: 0,
9666            current_repo_bonus: 0,
9667            ..Default::default()
9668        };
9669
9670        let hits = db
9671            .search("alpha beta", &SearchFilters::default(), None, &scoring)
9672            .unwrap();
9673
9674        assert_eq!(hits.len(), 1);
9675        assert_eq!(hits[0].session.id, "s1");
9676        assert_eq!(hits[0].score, 150);
9677    }
9678
9679    #[test]
9680    fn session_search_positive_limit_is_a_prefix_of_unlimited_ranking() {
9681        let dir = tempfile::tempdir().unwrap();
9682        let db = Db::open(&dir.path().join("index.db")).unwrap();
9683        for (id, title, preview) in [("s1", "", "alpha"), ("s2", "alpha", "")] {
9684            db.conn
9685                .execute(
9686                    "insert into sessions (
9687                         id, provider, provider_session_id, title, preview_text,
9688                         source_path, parse_version, discovery_source
9689                     ) values (?1,'claude',?1,?2,?3,?4,'1','test')",
9690                    params![id, title, preview, format!("/{id}.jsonl")],
9691                )
9692                .unwrap();
9693        }
9694        let scoring = crate::config::ScoringConfig {
9695            recency_weight: 0,
9696            ..Default::default()
9697        };
9698        let all_filters = SearchFilters {
9699            limit: 0,
9700            ..Default::default()
9701        };
9702        let all = db.search("alpha", &all_filters, None, &scoring).unwrap();
9703        let one_filter = SearchFilters {
9704            limit: 1,
9705            ..all_filters
9706        };
9707        let one = db.search("alpha", &one_filter, None, &scoring).unwrap();
9708
9709        assert_eq!(one.len(), 1);
9710        assert_eq!(one[0].session.id, all[0].session.id);
9711        assert_eq!(one[0].session.id, "s2");
9712    }
9713
9714    #[test]
9715    fn session_search_uses_the_application_worker_runtime_without_changing_ranking() {
9716        let root = tempfile::tempdir().unwrap();
9717        let one = Db::open_with_threads(
9718            &root.path().join("one.db"),
9719            TEST_BUSY_TIMEOUT_MS,
9720            NonZeroUsize::new(1).unwrap(),
9721        )
9722        .unwrap();
9723        let four = Db::open_with_threads(
9724            &root.path().join("four.db"),
9725            TEST_BUSY_TIMEOUT_MS,
9726            NonZeroUsize::new(4).unwrap(),
9727        )
9728        .unwrap();
9729        for db in [&one, &four] {
9730            for index in 0..1_100 {
9731                let id = format!("s{index:04}");
9732                db.conn
9733                    .execute(
9734                        "insert into sessions (
9735                             id, provider, provider_session_id, title, summary, cwd, repo_root,
9736                             preview_text, source_path, updated_at, parse_version, discovery_source
9737                         ) values (?1, 'claude', ?1, ?2, ?3, ?4, ?4, ?5, ?6, ?7, '1', 'test')",
9738                        params![
9739                            id,
9740                            (index % 7 == 0).then_some("database migration"),
9741                            format!("summary {index}"),
9742                            format!("/repo/{index}"),
9743                            format!("preview database work item {index}"),
9744                            format!("/{index}.jsonl"),
9745                            format!("2026-01-{:02}T00:00:00Z", index % 28 + 1),
9746                        ],
9747                    )
9748                    .unwrap();
9749                db.conn
9750                    .execute(
9751                        "insert into transcripts(session_id, transcript_text) values (?1, ?2)",
9752                        params![
9753                            format!("s{index:04}"),
9754                            format!(
9755                                "transcript {index} discusses database schema migration planning"
9756                            )
9757                        ],
9758                    )
9759                    .unwrap();
9760            }
9761        }
9762        let filters = SearchFilters {
9763            limit: 40,
9764            ..Default::default()
9765        };
9766        let scoring = crate::config::ScoringConfig {
9767            recency_weight: 0,
9768            ..Default::default()
9769        };
9770        let identities = |hits: Vec<SearchHit>| {
9771            hits.into_iter()
9772                .map(|hit| {
9773                    (
9774                        hit.session.id,
9775                        hit.score,
9776                        hit.match_source,
9777                        hit.match_snippet,
9778                    )
9779                })
9780                .collect::<Vec<_>>()
9781        };
9782        assert_eq!(one.worker_pool_build_count(), 0);
9783        assert_eq!(four.worker_pool_build_count(), 0);
9784        let expected = identities(
9785            one.search("databse migraton", &filters, Some("/repo/7"), &scoring)
9786                .unwrap(),
9787        );
9788        let actual = identities(
9789            four.search("databse migraton", &filters, Some("/repo/7"), &scoring)
9790                .unwrap(),
9791        );
9792        assert_eq!(
9793            actual, expected,
9794            "worker count must not change session ranking"
9795        );
9796        assert_eq!(one.worker_pool_build_count(), 1);
9797        assert_eq!(four.worker_pool_build_count(), 1);
9798    }
9799
9800    /// Opt-in deterministic comparison of one-worker and multi-worker session scoring. Correctness
9801    /// belongs to the non-ignored parity test above; this prints p50/p95 and digest evidence without
9802    /// imposing a scheduler-sensitive speedup threshold.
9803    #[test]
9804    #[ignore = "deterministic release-mode session-search performance measurement"]
9805    fn session_search_parallel_scoring_benchmark() {
9806        let root = tempfile::tempdir().unwrap();
9807        let path = root.path().join("index.db");
9808        {
9809            let db =
9810                Db::open_with_threads(&path, TEST_BUSY_TIMEOUT_MS, NonZeroUsize::new(1).unwrap())
9811                    .unwrap();
9812            let transcript_padding = "database schema migration planning ".repeat(250);
9813            let tx = db.conn.unchecked_transaction().unwrap();
9814            {
9815                let mut session = tx
9816                    .prepare(
9817                        "insert into sessions (
9818                             id, provider, provider_session_id, title, preview_text, source_path,
9819                             parse_version, discovery_source
9820                         ) values (?1, 'claude', ?1, ?2, ?3, ?4, '1', 'test')",
9821                    )
9822                    .unwrap();
9823                let mut transcript = tx
9824                    .prepare("insert into transcripts(session_id, transcript_text) values (?1, ?2)")
9825                    .unwrap();
9826                for index in 0..4_000 {
9827                    let id = format!("s{index:04}");
9828                    session
9829                        .execute(params![
9830                            id,
9831                            (index % 11 == 0).then_some("database migration"),
9832                            format!("preview {index}"),
9833                            format!("/{index}.jsonl"),
9834                        ])
9835                        .unwrap();
9836                    transcript
9837                        .execute(params![
9838                            format!("s{index:04}"),
9839                            format!("{transcript_padding}{index}")
9840                        ])
9841                        .unwrap();
9842                }
9843            }
9844            tx.commit().unwrap();
9845        }
9846        let filters = SearchFilters {
9847            limit: 50,
9848            ..Default::default()
9849        };
9850        let scoring = crate::config::ScoringConfig {
9851            recency_weight: 0,
9852            ..Default::default()
9853        };
9854        let measure = |threads| {
9855            let db = Db::open_with_threads(
9856                &path,
9857                TEST_BUSY_TIMEOUT_MS,
9858                NonZeroUsize::new(threads).unwrap(),
9859            )
9860            .unwrap();
9861            let search = || {
9862                db.search("databse migraton", &filters, None, &scoring)
9863                    .unwrap()
9864            };
9865            let expected = serde_json::to_vec(&search()).unwrap();
9866            let digest = crate::hashing::sha256(&expected);
9867            let mut elapsed = Vec::new();
9868            for _ in 0..7 {
9869                let started = Instant::now();
9870                assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
9871                elapsed.push(started.elapsed().as_micros());
9872            }
9873            elapsed.sort_unstable();
9874            (
9875                elapsed[elapsed.len() / 2],
9876                elapsed[elapsed.len() - 1],
9877                digest,
9878            )
9879        };
9880        let one = measure(1);
9881        let four = measure(4);
9882        assert_eq!(one.2, four.2);
9883        eprintln!(
9884            "session_parallel_benchmark sessions=4000 transcript_bytes_per_session={} one_worker_median_us={} one_worker_p95_us={} four_worker_median_us={} four_worker_p95_us={} digest=sha256:{}",
9885            "database schema migration planning ".len() * 250,
9886            one.0,
9887            one.1,
9888            four.0,
9889            four.1,
9890            one.2
9891        );
9892    }
9893
9894    /// Opt-in direct attribution of terminal-timestamp versus closed-span predicates. Correctness
9895    /// stays in ordinary tests; this records median/p95 and verifies both plans avoid message or
9896    /// transcript scans without making scheduler-sensitive timing a unit-test gate.
9897    #[test]
9898    #[ignore = "deterministic release-mode temporal predicate measurement"]
9899    fn session_temporal_overlap_predicate_benchmark() {
9900        let dir = tempfile::tempdir().unwrap();
9901        let db = Db::open(&dir.path().join("index.db")).unwrap();
9902        let tx = db.conn.unchecked_transaction().unwrap();
9903        {
9904            let mut insert = tx
9905                .prepare(
9906                    "insert into sessions (id, provider, provider_session_id, created_at, updated_at, preview_text, source_path, parse_version, discovery_source) values (?1, 'claude', ?1, ?2, ?3, '', ?4, '1', 'test')",
9907                )
9908                .unwrap();
9909            for index in 0..100_000 {
9910                let created = if index % 2 == 0 {
9911                    "2026-01-01T00:00:00+00:00"
9912                } else {
9913                    "2025-12-01T00:00:00+00:00"
9914                };
9915                insert
9916                    .execute(params![
9917                        format!("s{index:06}"),
9918                        created,
9919                        "2026-01-15T00:00:00+00:00",
9920                        format!("/{index}.jsonl")
9921                    ])
9922                    .unwrap();
9923            }
9924        }
9925        tx.commit().unwrap();
9926        let measure = |predicate: &str| {
9927            let sql = format!("select count(*) from sessions s where {predicate}");
9928            let expected: i64 = db.conn.query_row(&sql, [], |row| row.get(0)).unwrap();
9929            let mut elapsed = Vec::new();
9930            for _ in 0..9 {
9931                let started = Instant::now();
9932                assert_eq!(
9933                    db.conn
9934                        .query_row::<i64, _, _>(&sql, [], |row| row.get(0))
9935                        .unwrap(),
9936                    expected
9937                );
9938                elapsed.push(started.elapsed().as_micros());
9939            }
9940            elapsed.sort_unstable();
9941            let plan = db
9942                .conn
9943                .prepare(&format!("explain query plan {sql}"))
9944                .unwrap()
9945                .query_map([], |row| row.get::<_, String>(3))
9946                .unwrap()
9947                .collect::<rusqlite::Result<Vec<_>>>()
9948                .unwrap()
9949                .join(" | ");
9950            assert!(
9951                !plan.contains("messages") && !plan.contains("transcripts"),
9952                "{plan}"
9953            );
9954            (
9955                elapsed[elapsed.len() / 2],
9956                elapsed[elapsed.len() - 1],
9957                expected,
9958                plan,
9959            )
9960        };
9961        let terminal = measure(
9962            "coalesce(s.updated_at, s.created_at) >= '2025-12-15T00:00:00+00:00' and coalesce(s.updated_at, s.created_at) <= '2025-12-31T23:59:59+00:00'",
9963        );
9964        let overlap = measure(
9965            "coalesce(s.updated_at, s.created_at) >= '2025-12-01T00:00:00+00:00' and coalesce(s.created_at, s.updated_at) <= '2025-12-31T23:59:59+00:00'",
9966        );
9967        eprintln!(
9968            "session_temporal_predicate_benchmark sessions=100000 terminal_median_us={} terminal_p95_us={} terminal_matches={} overlap_median_us={} overlap_p95_us={} overlap_matches={} terminal_plan={:?} overlap_plan={:?}",
9969            terminal.0, terminal.1, terminal.2, overlap.0, overlap.1, overlap.2, terminal.3, overlap.3
9970        );
9971    }
9972
9973    #[test]
9974    fn message_update_keeps_fts_in_sync() {
9975        // External-content messages_fts must track in-place UPDATEs, not just
9976        // insert/delete — otherwise a future `update messages set content=...` would
9977        // leave stale terms in the index and miss new ones.
9978        let dir = tempfile::tempdir().unwrap();
9979        let db = Db::open(&dir.path().join("index.db")).unwrap();
9980        db.conn
9981            .execute(
9982                "insert into sessions (id, provider, provider_session_id, preview_text, \
9983                 source_path, parse_version, discovery_source) \
9984                 values ('s1','claude','s1','','/p','1','test')",
9985                [],
9986            )
9987            .unwrap();
9988        db.conn
9989            .execute(
9990                "insert into messages (id, session_id, provider, seq, role, content) \
9991                 values (1,'s1','claude',0,'user','alpha original')",
9992                [],
9993            )
9994            .unwrap();
9995        let count = |term: &str| -> i64 {
9996            db.conn
9997                .query_row(
9998                    "select count(*) from messages_fts where messages_fts match ?1",
9999                    params![term],
10000                    |r| r.get(0),
10001                )
10002                .unwrap()
10003        };
10004        assert_eq!(count("original"), 1, "inserted content is searchable");
10005        db.conn
10006            .execute("update messages set content='beta updated' where id=1", [])
10007            .unwrap();
10008        assert_eq!(count("updated"), 1, "new content searchable after update");
10009        assert_eq!(count("original"), 0, "stale term dropped after update");
10010    }
10011
10012    /// `sessions_fts` was maintained on every session write and read by nothing since session
10013    /// search began scoring the complete corpus (2026-07-22). This build empties the copy an
10014    /// earlier build filled exactly once, keeps an empty table for the layout check of an aise
10015    /// before 1.0.0rc2 sharing the index, writes nothing into it, and leaves an older writer's
10016    /// refill alone so two builds on one machine cannot take turns filling and emptying it.
10017    #[test]
10018    fn open_retires_the_unread_session_fts_copy_once_and_keeps_the_empty_table_for_older_builds() {
10019        let dir = tempfile::tempdir().unwrap();
10020        let path = dir.path().join("index.db");
10021        drop(Db::open(&path).unwrap());
10022        let fill = || {
10023            rusqlite::Connection::open(&path)
10024                .unwrap()
10025                .execute_batch(
10026                    "insert into sessions_fts(rowid, title, summary, preview_text, transcript_text)
10027                     values (1, 'older build wrote this', '', '', '');",
10028                )
10029                .unwrap();
10030        };
10031        let fts_rows = |db: &Db| -> i64 {
10032            db.conn
10033                .query_row("select count(*) from sessions_fts", [], |row| row.get(0))
10034                .unwrap()
10035        };
10036        // A fresh database: the table exists, empty, and the retirement is recorded.
10037        let db = Db::open(&path).unwrap();
10038        assert_eq!(fts_rows(&db), 0);
10039        assert!(
10040            crate::indexer::current_schema_layout_problem(&db.conn)
10041                .unwrap()
10042                .is_none(),
10043            "the empty table satisfies the layout check an older build performs"
10044        );
10045        seed_messages(&db, &[("user", "hello")]);
10046        assert_eq!(fts_rows(&db), 0, "no write path fills it");
10047        db.clear_all().unwrap();
10048        drop(db);
10049        // An older build sharing the index refills it; this build leaves that alone.
10050        fill();
10051        let reopened = Db::open(&path).unwrap();
10052        assert_eq!(
10053            fts_rows(&reopened),
10054            1,
10055            "a refill by an older writer is left alone after the one-time retirement"
10056        );
10057        drop(reopened);
10058        // A database an earlier build filled and this build has never opened: emptied once.
10059        rusqlite::Connection::open(&path)
10060            .unwrap()
10061            .execute_batch("delete from index_metadata where key = 'sessions_fts_retired';")
10062            .unwrap();
10063        let retired = Db::open(&path).unwrap();
10064        assert_eq!(
10065            fts_rows(&retired),
10066            0,
10067            "the earlier build's copy is emptied once"
10068        );
10069    }
10070
10071    #[test]
10072    fn resolve_session_errors_are_actionable() {
10073        let dir = tempfile::tempdir().unwrap();
10074        let db = Db::open(&dir.path().join("index.db")).unwrap();
10075        let insert = |id: &str| {
10076            db.conn
10077                .execute(
10078                    "insert into sessions (id, provider, provider_session_id, preview_text, \
10079                     source_path, parse_version, discovery_source) \
10080                     values (?1,'claude',?1,'','/p','1','test')",
10081                    params![id],
10082                )
10083                .unwrap();
10084        };
10085        insert("claude:abc123");
10086        insert("claude:abc456");
10087
10088        // Unknown id → points at the commands that list/find sessions.
10089        let err = db.resolve_session("zzz").unwrap_err().to_string();
10090        assert!(err.contains("no session matches"));
10091        assert!(err.contains("aise list") || err.contains("aise search"));
10092        let err = db.resolve_session_record("zzz").unwrap_err().to_string();
10093        assert!(err.contains("no session matches"));
10094        assert!(err.contains("aise list") || err.contains("aise search"));
10095
10096        // Ambiguous prefix → names the matching candidates so the user can disambiguate.
10097        let err = db.resolve_session("claude:abc").unwrap_err().to_string();
10098        assert!(err.contains("ambiguous"));
10099        assert!(
10100            err.contains("claude:abc123") && err.contains("claude:abc456"),
10101            "ambiguous error must list candidates: {err}"
10102        );
10103        let err = db
10104            .resolve_session_record("claude:abc")
10105            .unwrap_err()
10106            .to_string();
10107        assert!(err.contains("ambiguous"));
10108        assert!(
10109            err.contains("claude:abc123") && err.contains("claude:abc456"),
10110            "ambiguous error must list candidates: {err}"
10111        );
10112    }
10113
10114    #[test]
10115    fn exact_session_resolution_uses_indexes_and_bare_collisions_name_canonical_candidates() {
10116        let dir = tempfile::tempdir().unwrap();
10117        let db = Db::open(&dir.path().join("index.db")).unwrap();
10118        for provider in [Provider::Claude, Provider::Codex] {
10119            db.conn
10120                .execute(
10121                    "insert into sessions (id, provider, provider_session_id, preview_text, \
10122                     source_path, parse_version, discovery_source) \
10123                     values (?1, ?2, 'shared-native-id', '', '/p', '1', 'test')",
10124                    params![
10125                        format!("{}:shared-native-id", provider.as_str()),
10126                        provider.as_str()
10127                    ],
10128                )
10129                .unwrap();
10130        }
10131
10132        for canonical in ["claude:shared-native-id", "codex:shared-native-id"] {
10133            assert_eq!(db.resolve_session_record(canonical).unwrap().id, canonical);
10134        }
10135        let collision = db
10136            .resolve_session_record("shared-native-id")
10137            .unwrap_err()
10138            .to_string();
10139        assert!(collision.contains("ambiguous"), "{collision}");
10140        assert!(collision.contains("claude:shared-native-id"), "{collision}");
10141        assert!(collision.contains("codex:shared-native-id"), "{collision}");
10142
10143        let mut statement = db
10144            .conn
10145            .prepare(&format!(
10146                "explain query plan {RESOLVE_SESSION_RECORD_EXACT_SQL}"
10147            ))
10148            .unwrap();
10149        let plan = statement
10150            .query_map(["claude:shared-native-id"], |row| row.get::<_, String>(3))
10151            .unwrap()
10152            .collect::<rusqlite::Result<Vec<_>>>()
10153            .unwrap()
10154            .join("\n");
10155        assert!(plan.contains("SEARCH"), "{plan}");
10156        assert!(!plan.contains("SCAN s"), "{plan}");
10157    }
10158
10159    #[test]
10160    fn session_prefix_resolution_treats_sql_wildcards_as_literal_text() {
10161        let dir = tempfile::tempdir().unwrap();
10162        let db = Db::open(&dir.path().join("index.db")).unwrap();
10163        for id in ["claude:percent", "claude:under_score"] {
10164            db.conn
10165                .execute(
10166                    "insert into sessions (id, provider, provider_session_id, preview_text, \
10167                     source_path, parse_version, discovery_source) \
10168                     values (?1, 'claude', ?1, '', '/p', '1', 'test')",
10169                    params![id],
10170                )
10171                .unwrap();
10172        }
10173        for literal in ["%", "_"] {
10174            let error = db.resolve_session_record(literal).unwrap_err().to_string();
10175            assert!(error.contains("no session matches"), "{error}");
10176        }
10177    }
10178
10179    #[test]
10180    fn open_adds_typed_message_columns_to_version_one_database() {
10181        let dir = tempfile::tempdir().unwrap();
10182        let path = dir.path().join("index.db");
10183        {
10184            let legacy = Connection::open(&path).unwrap();
10185            legacy
10186                .execute_batch(
10187                    "create table messages (
10188                        id integer primary key,
10189                        session_id text not null,
10190                        provider text not null,
10191                        seq integer not null,
10192                        role text not null,
10193                        ts text,
10194                        tool_name text,
10195                        is_compaction integer not null default 0,
10196                        content text not null
10197                    );
10198                    insert into messages
10199                        (session_id, provider, seq, role, content)
10200                    values ('claude:legacy', 'claude', 0, 'user', 'legacy text');
10201                    pragma user_version = 1;",
10202                )
10203                .unwrap();
10204        }
10205
10206        let db = Db::open(&path).unwrap();
10207        let columns = db
10208            .conn
10209            .prepare("pragma table_info(messages)")
10210            .unwrap()
10211            .query_map([], |row| row.get::<_, String>(1))
10212            .unwrap()
10213            .collect::<rusqlite::Result<Vec<_>>>()
10214            .unwrap();
10215        assert!(columns.iter().any(|column| column == "kind"));
10216        assert!(columns.iter().any(|column| column == "tool_call_id"));
10217        assert!(columns.iter().any(|column| column == "authorship"));
10218        assert!(columns
10219            .iter()
10220            .any(|column| column == "correlation_authority"));
10221        assert!(columns.iter().any(|column| column == "correlation_scope"));
10222        assert!(columns.iter().any(|column| column == "correlation_id"));
10223        assert!(columns.iter().any(|column| column == "record_relation"));
10224        let legacy_authorship: String = db
10225            .conn
10226            .query_row("select authorship from messages limit 1", [], |row| {
10227                row.get(0)
10228            })
10229            .unwrap();
10230        assert_eq!(legacy_authorship, "unknown");
10231        let parts_table_exists: bool = db
10232            .conn
10233            .query_row(
10234                "select exists(
10235                    select 1 from sqlite_schema
10236                    where type = 'table' and name = 'message_content_parts'
10237                )",
10238                [],
10239                |row| row.get(0),
10240            )
10241            .unwrap();
10242        assert!(parts_table_exists);
10243        assert!(db.needs_backfill().unwrap());
10244    }
10245
10246    #[test]
10247    fn message_provenance_round_trips_without_copying_part_text() {
10248        use crate::models::{
10249            ContentPartAuthorship, ContentPartOrigin, MessageAuthorship, MessageContentPart,
10250            MessageCorrelationAuthority, MessageCorrelationIdentity, MessageProvenance,
10251            MessageRecordRelation,
10252        };
10253
10254        let dir = tempfile::tempdir().unwrap();
10255        let db = Db::open(&dir.path().join("index.db")).unwrap();
10256        let mut parsed = crate::util::minimal_record(
10257            Provider::GeminiCli,
10258            Path::new("/fixture/mixed.json"),
10259            String::new(),
10260        );
10261        parsed.session.id = "gemini-cli:mixed".to_string();
10262        parsed.session.provider_session_id = "mixed".to_string();
10263        parsed.messages = vec![crate::models::Message {
10264            seq: 0,
10265            role: Role::User,
10266            ts: None,
10267            tool_name: None,
10268            kind: crate::models::MessageKind::Conversation,
10269            tool_call_id: None,
10270            is_compaction: false,
10271            content: "askAI".to_string(),
10272            provenance: MessageProvenance {
10273                authorship: MessageAuthorship::Mixed,
10274                record_relation: MessageRecordRelation::Mirror,
10275                correlation_identity: Some(MessageCorrelationIdentity {
10276                    authority: MessageCorrelationAuthority::Google,
10277                    scope: "conversation_event".to_string(),
10278                    id: "event-7".to_string(),
10279                }),
10280                content_parts: vec![
10281                    MessageContentPart {
10282                        ordinal: 0,
10283                        start_char: 0,
10284                        end_char: 3,
10285                        authorship: ContentPartAuthorship::Human,
10286                        origin: ContentPartOrigin::DirectInput,
10287                    },
10288                    MessageContentPart {
10289                        ordinal: 1,
10290                        start_char: 3,
10291                        end_char: 5,
10292                        authorship: ContentPartAuthorship::Generated,
10293                        origin: ContentPartOrigin::QuotedContent,
10294                    },
10295                ],
10296            },
10297        }];
10298
10299        db.upsert_session(&parsed, 0, 0).unwrap();
10300        let stored: (String, String, String, String, String) = db
10301            .conn
10302            .query_row(
10303                "select authorship, correlation_authority, correlation_scope,
10304                        correlation_id, record_relation
10305                 from messages where session_id = ?1 and seq = 0",
10306                [&parsed.session.id],
10307                |row| {
10308                    Ok((
10309                        row.get(0)?,
10310                        row.get(1)?,
10311                        row.get(2)?,
10312                        row.get(3)?,
10313                        row.get(4)?,
10314                    ))
10315                },
10316            )
10317            .unwrap();
10318        assert_eq!(
10319            stored,
10320            (
10321                "mixed".to_string(),
10322                "google".to_string(),
10323                "conversation_event".to_string(),
10324                "event-7".to_string(),
10325                "mirror".to_string(),
10326            )
10327        );
10328        let parts = db
10329            .conn
10330            .prepare(
10331                "select ordinal, start_char, end_char, authorship, origin
10332                 from message_content_parts order by ordinal",
10333            )
10334            .unwrap()
10335            .query_map([], |row| {
10336                Ok((
10337                    row.get::<_, i64>(0)?,
10338                    row.get::<_, i64>(1)?,
10339                    row.get::<_, i64>(2)?,
10340                    row.get::<_, String>(3)?,
10341                    row.get::<_, String>(4)?,
10342                ))
10343            })
10344            .unwrap()
10345            .collect::<rusqlite::Result<Vec<_>>>()
10346            .unwrap();
10347        assert_eq!(
10348            parts,
10349            vec![
10350                (0, 0, 3, "human".to_string(), "direct_input".to_string()),
10351                (
10352                    1,
10353                    3,
10354                    5,
10355                    "generated".to_string(),
10356                    "quoted_content".to_string()
10357                ),
10358            ]
10359        );
10360        let part_columns = db
10361            .conn
10362            .prepare("pragma table_info(message_content_parts)")
10363            .unwrap()
10364            .query_map([], |row| row.get::<_, String>(1))
10365            .unwrap()
10366            .collect::<rusqlite::Result<Vec<_>>>()
10367            .unwrap();
10368        assert!(!part_columns.iter().any(|column| column == "content"));
10369    }
10370
10371    #[test]
10372    fn invalid_message_provenance_rolls_back_the_whole_session_upsert() {
10373        use crate::models::{MessageAuthorship, MessageProvenance};
10374
10375        let dir = tempfile::tempdir().unwrap();
10376        let db = Db::open(&dir.path().join("index.db")).unwrap();
10377        let mut parsed = crate::util::minimal_record(
10378            Provider::Claude,
10379            Path::new("/fixture/invalid.jsonl"),
10380            String::new(),
10381        );
10382        parsed.session.id = "claude:invalid-provenance".to_string();
10383        parsed.session.provider_session_id = "invalid-provenance".to_string();
10384        parsed.messages = vec![crate::models::Message {
10385            seq: 0,
10386            role: Role::User,
10387            ts: None,
10388            tool_name: None,
10389            kind: crate::models::MessageKind::Conversation,
10390            tool_call_id: None,
10391            is_compaction: false,
10392            content: "text".to_string(),
10393            provenance: MessageProvenance {
10394                authorship: MessageAuthorship::Mixed,
10395                ..Default::default()
10396            },
10397        }];
10398
10399        let error = db.upsert_session(&parsed, 0, 0).unwrap_err().to_string();
10400        assert!(error.contains("message sequence 0"), "{error}");
10401        assert!(error.contains("claude:invalid-provenance"), "{error}");
10402        assert!(error.contains("requires content_parts"), "{error}");
10403        assert_eq!(
10404            db.conn
10405                .query_row(
10406                    "select count(*) from sessions where id = ?1",
10407                    [&parsed.session.id],
10408                    |row| row.get::<_, i64>(0),
10409                )
10410                .unwrap(),
10411            0,
10412            "the transaction must not publish a session whose message provenance is invalid"
10413        );
10414    }
10415
10416    #[test]
10417    fn replacing_a_mixed_message_cascades_its_part_ranges() {
10418        use crate::models::{
10419            ContentPartAuthorship, ContentPartOrigin, MessageAuthorship, MessageContentPart,
10420            MessageProvenance,
10421        };
10422
10423        let dir = tempfile::tempdir().unwrap();
10424        let db = Db::open(&dir.path().join("index.db")).unwrap();
10425        let mut parsed = parsed_with_messages("claude:parts-cascade", &["ab"]);
10426        parsed.messages[0].provenance = MessageProvenance {
10427            authorship: MessageAuthorship::Mixed,
10428            content_parts: vec![
10429                MessageContentPart {
10430                    ordinal: 0,
10431                    start_char: 0,
10432                    end_char: 1,
10433                    authorship: ContentPartAuthorship::Human,
10434                    origin: ContentPartOrigin::DirectInput,
10435                },
10436                MessageContentPart {
10437                    ordinal: 1,
10438                    start_char: 1,
10439                    end_char: 2,
10440                    authorship: ContentPartAuthorship::Generated,
10441                    origin: ContentPartOrigin::QuotedContent,
10442                },
10443            ],
10444            ..Default::default()
10445        };
10446        db.upsert_session(&parsed, 0, 0).unwrap();
10447        assert_eq!(
10448            db.conn
10449                .query_row("select count(*) from message_content_parts", [], |row| {
10450                    row.get::<_, i64>(0)
10451                })
10452                .unwrap(),
10453            2
10454        );
10455
10456        parsed.messages[0].provenance = MessageProvenance {
10457            authorship: MessageAuthorship::Human,
10458            ..Default::default()
10459        };
10460        db.replace_session(&parsed, 1, 1).unwrap();
10461        assert_eq!(
10462            db.conn
10463                .query_row("select count(*) from message_content_parts", [], |row| {
10464                    row.get::<_, i64>(0)
10465                })
10466                .unwrap(),
10467            0
10468        );
10469    }
10470
10471    #[test]
10472    fn provenance_queries_use_session_and_behavioral_analysis_indexes() {
10473        let dir = tempfile::tempdir().unwrap();
10474        let db = Db::open(&dir.path().join("index.db")).unwrap();
10475        for (sql, expected_index) in [
10476            (
10477                "select seq from messages
10478                 where session_id = 'claude:s' and authorship = 'human'
10479                   and record_relation = 'original'
10480                 order by seq",
10481                "idx_messages_session_authorship_relation_seq",
10482            ),
10483            (
10484                "select session_id, seq from messages
10485                 where authorship = 'human' and record_relation = 'original'
10486                 order by ts desc, session_id, seq",
10487                "idx_messages_authorship_relation_ts",
10488            ),
10489        ] {
10490            let plan = db
10491                .conn
10492                .prepare(&format!("explain query plan {sql}"))
10493                .unwrap()
10494                .query_map([], |row| row.get::<_, String>(3))
10495                .unwrap()
10496                .collect::<rusqlite::Result<Vec<_>>>()
10497                .unwrap()
10498                .join("\n");
10499            assert!(plan.contains(expected_index), "{plan}");
10500            assert!(!plan.contains("SCAN messages"), "{plan}");
10501        }
10502
10503        for sql in [
10504            ANALYSIS_MESSAGE_COUNTS_SQL,
10505            ANALYSIS_ATTRIBUTABLE_HUMAN_MESSAGES_SQL,
10506        ] {
10507            let plan = db
10508                .conn
10509                .prepare(&format!("explain query plan {sql}"))
10510                .unwrap()
10511                .query_map(["claude:s"], |row| row.get::<_, String>(3))
10512                .unwrap()
10513                .collect::<rusqlite::Result<Vec<_>>>()
10514                .unwrap()
10515                .join("\n");
10516            assert!(
10517                plan.contains("idx_messages_session_authorship_relation_seq"),
10518                "analysis must select candidate messages through the composite provenance index:\n{plan}"
10519            );
10520            assert!(
10521                !plan.contains("SCAN m"),
10522                "analysis must not full-scan messages for one session:\n{plan}"
10523            );
10524        }
10525    }
10526
10527    #[test]
10528    fn correction_candidate_and_part_queries_use_provenance_indexes() {
10529        let dir = tempfile::tempdir().unwrap();
10530        let db = Db::open(&dir.path().join("index.db")).unwrap();
10531        let candidates = format!(
10532            "explain query plan {CORRECTION_CANDIDATES_SQL}
10533             order by m.ts desc, m.session_id asc, m.seq asc"
10534        );
10535        let candidate_plan = db
10536            .conn
10537            .prepare(&candidates)
10538            .unwrap()
10539            .query_map([], |row| row.get::<_, String>(3))
10540            .unwrap()
10541            .collect::<rusqlite::Result<Vec<_>>>()
10542            .unwrap()
10543            .join("\n");
10544        assert!(
10545            candidate_plan.contains("idx_messages_authorship_relation_ts"),
10546            "{candidate_plan}"
10547        );
10548        assert!(!candidate_plan.contains("SCAN m"), "{candidate_plan}");
10549
10550        let parts = format!(
10551            "explain query plan {CORRECTION_HUMAN_PARTS_SQL}
10552             order by m.id, p.ordinal"
10553        );
10554        let parts_plan = db
10555            .conn
10556            .prepare(&parts)
10557            .unwrap()
10558            .query_map([], |row| row.get::<_, String>(3))
10559            .unwrap()
10560            .collect::<rusqlite::Result<Vec<_>>>()
10561            .unwrap()
10562            .join("\n");
10563        assert!(
10564            parts_plan.contains("idx_messages_authorship_relation_ts"),
10565            "{parts_plan}"
10566        );
10567        assert!(
10568            parts_plan.contains("SEARCH p USING PRIMARY KEY"),
10569            "{parts_plan}"
10570        );
10571        assert!(!parts_plan.contains("SCAN m"), "{parts_plan}");
10572        assert!(!parts_plan.contains("SCAN p"), "{parts_plan}");
10573    }
10574
10575    #[test]
10576    fn tool_argument_search_uses_explicit_json_pointer_and_excludes_results() {
10577        let dir = tempfile::tempdir().unwrap();
10578        let db = Db::open(&dir.path().join("index.db")).unwrap();
10579        seed_messages(&db, &[("tool", "call"), ("tool", "cargo test output")]);
10580        db.conn
10581            .execute_batch(
10582                r#"update messages set
10583                       kind = 'tool_call', tool_name = 'exec_command',
10584                       content = '{"args":{"cmd":"cargo test","request":{"path":"src/lib.rs"}},"kind":"tool_call","tool_name":"exec_command"}'
10585                   where seq = 0;
10586                   update messages set kind = 'tool_result', tool_name = 'exec_command'
10587                   where seq = 1;"#,
10588            )
10589            .unwrap();
10590
10591        let hits = db
10592            .search_messages(
10593                "cargo test",
10594                &MessageFilters {
10595                    field: Some(SearchField::ToolArgument),
10596                    argument_path: Some("/cmd".to_string()),
10597                    ..Default::default()
10598                },
10599            )
10600            .unwrap();
10601        assert_eq!(hits.len(), 1);
10602        assert_eq!(hits[0].seq, 0);
10603
10604        let nested = db
10605            .search_messages(
10606                "src/lib.rs",
10607                &MessageFilters {
10608                    field: Some(SearchField::ToolArgument),
10609                    argument_path: Some("/request/path".to_string()),
10610                    ..Default::default()
10611                },
10612            )
10613            .unwrap();
10614        assert_eq!(nested.len(), 1);
10615
10616        db.conn
10617            .execute(
10618                "insert into messages (
10619                     session_id, provider, seq, role, kind, content
10620                 ) values (
10621                     'claude:s1', 'claude', 2, 'user', 'conversation',
10622                     '{\"args\":{\"cmd\":\"cargo test\"}}'
10623                 )",
10624                [],
10625            )
10626            .unwrap();
10627        for (query, match_mode) in [
10628            ("", MessageSearchMode::Literal),
10629            ("cargo test", MessageSearchMode::Literal),
10630            (r"cargo t.st", MessageSearchMode::Regex),
10631            ("crgo tst", MessageSearchMode::Fuzzy),
10632        ] {
10633            let hits = db
10634                .search_messages(
10635                    query,
10636                    &MessageFilters {
10637                        kinds: Some(vec![
10638                            crate::models::MessageKind::Conversation,
10639                            crate::models::MessageKind::ToolCall,
10640                        ]),
10641                        field: Some(SearchField::ToolArgument),
10642                        argument_path: Some("/cmd".to_string()),
10643                        match_mode,
10644                        limit: 10,
10645                        ..Default::default()
10646                    },
10647                )
10648                .unwrap();
10649            assert_eq!(
10650                hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
10651                vec![0],
10652                "tool-argument {match_mode:?} search must intersect an explicit kind union with tool_call"
10653            );
10654        }
10655
10656        db.conn
10657            .execute_batch(
10658                r#"with recursive n(value) as (
10659                       values(1) union all select value + 1 from n where value < 100
10660                   )
10661                   insert into messages (
10662                       session_id, provider, seq, role, tool_name, kind, content
10663                   )
10664                   select 'claude:s1', 'claude', 10 + value, 'tool', 'exec_command',
10665                          'tool_call',
10666                          printf('{"args":{"cmd":"unrelated payload %d"}}', value)
10667                     from n;"#,
10668            )
10669            .unwrap();
10670        for (query, match_mode) in [
10671            ("cargo test", MessageSearchMode::Literal),
10672            (r"cargo t.st", MessageSearchMode::Regex),
10673        ] {
10674            let filters = MessageFilters {
10675                field: Some(SearchField::ToolArgument),
10676                argument_path: Some("/cmd".to_string()),
10677                match_mode,
10678                ..Default::default()
10679            };
10680            let (hits, explain) = db
10681                .search_messages_with_explain(query, &filters, true)
10682                .unwrap();
10683            assert_eq!(hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(), vec![0]);
10684            let explain = explain.unwrap();
10685            assert!(
10686                explain.prefilter.is_some(),
10687                "tool-argument {match_mode:?} search should prefilter raw tool-call JSON: {explain:?}"
10688            );
10689            // The receipt keeps the published meaning of its two counts: `corpus` is the
10690            // structural denominator (every tool-call row the filters admit, 101 here) and
10691            // `candidates` is the prefilter output still needing verification (a superset of
10692            // the one hit, never more than the corpus). Reporting the authoritative match count
10693            // in both fields made every prefiltered tool-argument search read as 100 %
10694            // selectivity with the "low selectivity" hint attached.
10695            assert!(
10696                explain.corpus >= 101,
10697                "corpus is the structural tool-call denominator: {explain:?}"
10698            );
10699            let candidates = explain.candidates.unwrap();
10700            assert!(
10701                candidates >= hits.len() as i64 && candidates <= explain.corpus,
10702                "candidates are the prefilter superset of the hits: {explain:?}"
10703            );
10704            let summary = explain.summary(true);
10705            assert!(
10706                !summary.contains("low selectivity"),
10707                "one hit in a hundred is not low selectivity: {summary}"
10708            );
10709        }
10710
10711        db.conn
10712            .execute(
10713                "update messages set content = ?1 where seq = 0",
10714                [crate::util::tool_call_message_content(
10715                    "exec_command",
10716                    serde_json::json!({"cmd": "quoted \"target\" and slash \\ value"}),
10717                )],
10718            )
10719            .unwrap();
10720        for (query, expected, expect_prefilter) in [
10721            ("quoted \"target\"", vec![0], true),
10722            (r#"quoted \"target\""#, Vec::new(), true),
10723            ("slash \\ value", vec![0], true),
10724            ("d\\ ", Vec::new(), false),
10725        ] {
10726            let filters = MessageFilters {
10727                field: Some(SearchField::ToolArgument),
10728                argument_path: Some("/cmd".to_string()),
10729                ..Default::default()
10730            };
10731            let (hits, explain) = db
10732                .search_messages_with_explain(query, &filters, true)
10733                .unwrap();
10734            assert_eq!(
10735                hits.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
10736                expected,
10737                "JSON escaping must not create a trigram false negative for {query:?}"
10738            );
10739            assert_eq!(
10740                explain.unwrap().prefilter.is_some(),
10741                expect_prefilter,
10742                "only JSON-stable required trigrams may prefilter {query:?}"
10743            );
10744        }
10745
10746        db.conn
10747            .execute(
10748                "update messages set content = ?1 where seq = 0",
10749                [crate::util::tool_call_message_content(
10750                    "exec_command",
10751                    serde_json::json!({"cmd": "cargo test"}),
10752                )],
10753            )
10754            .unwrap();
10755
10756        // Incremental insert/update/delete triggers must keep derived-field prefilter membership
10757        // in sync just as they do for ordinary content search.
10758        db.conn
10759            .execute(
10760                "insert into messages (
10761                     session_id, provider, seq, role, tool_name, kind, content
10762                 ) values ('claude:s1', 'claude', 500, 'tool', 'exec_command', 'tool_call', ?1)",
10763                [crate::util::tool_call_message_content(
10764                    "exec_command",
10765                    serde_json::json!({"cmd": "incremental trigram target"}),
10766                )],
10767            )
10768            .unwrap();
10769        let incremental_filters = MessageFilters {
10770            field: Some(SearchField::ToolArgument),
10771            argument_path: Some("/cmd".to_string()),
10772            ..Default::default()
10773        };
10774        let find_incremental = |query: &str| {
10775            db.search_messages(query, &incremental_filters)
10776                .unwrap()
10777                .into_iter()
10778                .map(|hit| hit.seq)
10779                .collect::<Vec<_>>()
10780        };
10781        assert_eq!(find_incremental("incremental trigram target"), vec![500]);
10782        db.conn
10783            .execute(
10784                "update messages set content = ?1 where seq = 500",
10785                [crate::util::tool_call_message_content(
10786                    "exec_command",
10787                    serde_json::json!({"cmd": "replacement trigram target"}),
10788                )],
10789            )
10790            .unwrap();
10791        assert!(find_incremental("incremental trigram target").is_empty());
10792        assert_eq!(find_incremental("replacement trigram target"), vec![500]);
10793        db.conn
10794            .execute("delete from messages where seq = 500", [])
10795            .unwrap();
10796        assert!(find_incremental("replacement trigram target").is_empty());
10797
10798        let fuzzy_filters = MessageFilters {
10799            field: Some(SearchField::ToolArgument),
10800            argument_path: Some("/cmd".to_string()),
10801            match_mode: MessageSearchMode::Fuzzy,
10802            limit: 5,
10803            ..Default::default()
10804        };
10805        let (fuzzy, explain) = db
10806            .search_messages_with_explain("crgo tst", &fuzzy_filters, true)
10807            .unwrap();
10808        assert_eq!(fuzzy.len(), 1);
10809        assert_eq!(fuzzy[0].seq, 0);
10810        let explain = explain.unwrap();
10811        assert_eq!(
10812            explain.prefilter_skipped.as_deref(),
10813            Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
10814        );
10815        assert!(
10816            explain.candidates.unwrap() < explain.corpus,
10817            "only matching tool-argument projections should count as fuzzy candidates: {explain:?}"
10818        );
10819
10820        let plan = db
10821            .conn
10822            .prepare(
10823                "explain query plan select session_id, seq from messages
10824                 where kind = 'tool_call' order by session_id, seq",
10825            )
10826            .unwrap()
10827            .query_map([], |row| row.get::<_, String>(3))
10828            .unwrap()
10829            .collect::<rusqlite::Result<Vec<_>>>()
10830            .unwrap()
10831            .join("\n");
10832        assert!(plan.contains("idx_messages_tool_calls"), "{plan}");
10833
10834        let error = db
10835            .search_messages(
10836                "cargo",
10837                &MessageFilters {
10838                    field: Some(SearchField::ToolArgument),
10839                    argument_path: Some("cmd".to_string()),
10840                    ..Default::default()
10841                },
10842            )
10843            .unwrap_err()
10844            .to_string();
10845        assert!(error.contains("RFC 6901"));
10846    }
10847
10848    #[test]
10849    fn tool_argument_trigram_prefilter_preserves_direct_scan_order_and_pages() {
10850        let dir = tempfile::tempdir().unwrap();
10851        let db = Db::open(&dir.path().join("index.db")).unwrap();
10852        seed_messages(&db, &[("tool", "placeholder")]);
10853        db.conn
10854            .execute_batch(
10855                r#"update messages set kind = 'tool_call', tool_name = 'exec_command',
10856                       content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
10857                   where seq = 0;
10858                   with recursive n(value) as (
10859                       values(1) union all select value + 1 from n where value < 250
10860                   )
10861                   insert into messages (
10862                       session_id, provider, seq, role, tool_name, kind, content
10863                   )
10864                   select 'claude:s1', 'claude', value, 'tool', 'exec_command', 'tool_call',
10865                          case when value in (70, 140, 210)
10866                               then printf('{"args":{"cmd":"cargo test target %d"},"kind":"tool_call","tool_name":"exec_command"}', value)
10867                               else printf('{"args":{"cmd":"unrelated payload %d"},"kind":"tool_call","tool_name":"exec_command"}', value)
10868                          end
10869                     from n;"#,
10870            )
10871            .unwrap();
10872
10873        for (query, mode) in [
10874            ("cargo test", MessageSearchMode::Literal),
10875            (r"cargo t.st", MessageSearchMode::Regex),
10876        ] {
10877            for (limit, offset) in [(0, 0), (2, 0), (2, 1), (1, 3)] {
10878                let filters = MessageFilters {
10879                    field: Some(SearchField::ToolArgument),
10880                    argument_path: Some("/cmd".to_string()),
10881                    match_mode: mode,
10882                    limit,
10883                    offset,
10884                    ..Default::default()
10885                };
10886                let accelerated = db.search_messages(query, &filters).unwrap();
10887                let predicate = match mode {
10888                    MessageSearchMode::Literal => {
10889                        "unicode_lower_contains(rust_json_pointer('/cmd', m.content), ?1)"
10890                    }
10891                    MessageSearchMode::Regex => {
10892                        "rust_regexp(?1, rust_json_pointer('/cmd', m.content))"
10893                    }
10894                    MessageSearchMode::Fuzzy => unreachable!(),
10895                };
10896                let mut sql = format!(
10897                    "select m.session_id, m.seq from messages m \
10898                     where m.kind = 'tool_call' and {predicate} \
10899                     order by m.session_id, m.seq"
10900                );
10901                if limit > 0 {
10902                    sql.push_str(" limit ?2 offset ?3");
10903                } else if offset > 0 {
10904                    sql.push_str(" limit -1 offset ?3");
10905                }
10906                // `unicode_lower_contains` takes an already-folded needle, so the oracle folds it
10907                // the way every production caller does. Lowercasing here would make the
10908                // comparison run against a rule aise does not ship, and the sigma spellings are
10909                // exactly where the two disagree.
10910                let query_arg = if mode == MessageSearchMode::Literal {
10911                    fold_caseless(query)
10912                } else {
10913                    query.to_string()
10914                };
10915                let mut statement = db.conn.prepare(&sql).unwrap();
10916                let direct = if limit > 0 {
10917                    statement
10918                        .query_map(params![query_arg, limit as i64, offset as i64], |row| {
10919                            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10920                        })
10921                        .unwrap()
10922                        .collect::<rusqlite::Result<Vec<_>>>()
10923                        .unwrap()
10924                } else if offset > 0 {
10925                    statement
10926                        .query_map(params![query_arg, limit as i64, offset as i64], |row| {
10927                            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10928                        })
10929                        .unwrap()
10930                        .collect::<rusqlite::Result<Vec<_>>>()
10931                        .unwrap()
10932                } else {
10933                    statement
10934                        .query_map([query_arg], |row| {
10935                            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10936                        })
10937                        .unwrap()
10938                        .collect::<rusqlite::Result<Vec<_>>>()
10939                        .unwrap()
10940                };
10941                assert_eq!(
10942                    hit_keys(accelerated),
10943                    direct,
10944                    "accelerated {mode:?} page limit={limit} offset={offset} must equal the independent direct projection scan"
10945                );
10946            }
10947        }
10948    }
10949
10950    /// Deterministic, opt-in latency measurement for the raw-JSON candidate prefilter. The
10951    /// ordinary test above owns correctness; this reports median warm latency and digest parity
10952    /// without making a scheduler-sensitive timing assertion.
10953    ///
10954    /// Complexity model: N rows of J JSON bytes each and C candidates. Direct scan is O(N*J);
10955    /// accelerated verification is postings lookup plus O(C*J), with O(H) returned-result memory
10956    /// in both cases. Run with `cargo test -p ai-session-search --release
10957    /// tool_argument_trigram_prefilter_benchmark -- --ignored --nocapture`.
10958    #[test]
10959    #[ignore = "deterministic release-mode performance measurement"]
10960    fn tool_argument_trigram_prefilter_benchmark() {
10961        let dir = tempfile::tempdir().unwrap();
10962        let db = Db::open(&dir.path().join("index.db")).unwrap();
10963        seed_messages(&db, &[("tool", "placeholder")]);
10964        let tx = db.conn.unchecked_transaction().unwrap();
10965        {
10966            let mut insert = tx
10967                .prepare(
10968                    "insert into messages (session_id, provider, seq, role, tool_name, kind, content) \
10969                     values ('claude:s1', 'claude', ?1, 'tool', 'exec_command', 'tool_call', ?2)",
10970                )
10971                .unwrap();
10972            for seq in 1..=25_000_i64 {
10973                let cmd = if seq % 5_000 == 0 {
10974                    format!("selective trigram benchmark target {seq}")
10975                } else {
10976                    format!("ordinary deterministic payload {seq} with JSON parsing padding")
10977                };
10978                insert
10979                    .execute(params![
10980                        seq,
10981                        crate::util::tool_call_message_content(
10982                            "exec_command",
10983                            serde_json::json!({"cmd": cmd})
10984                        )
10985                    ])
10986                    .unwrap();
10987            }
10988        }
10989        tx.commit().unwrap();
10990        let filters = MessageFilters {
10991            field: Some(SearchField::ToolArgument),
10992            argument_path: Some("/cmd".to_string()),
10993            ..Default::default()
10994        };
10995        let query = "selective trigram benchmark target";
10996        let accelerated = || db.search_messages(query, &filters).unwrap();
10997        let direct = || {
10998            let mut statement = db
10999                .conn
11000                .prepare(
11001                    "select m.session_id, m.seq from messages m \
11002                     where m.kind = 'tool_call' and \
11003                           unicode_lower_contains(rust_json_pointer('/cmd', m.content), ?1) \
11004                     order by m.session_id, m.seq",
11005                )
11006                .unwrap();
11007            statement
11008                // Folded for the same reason the oracle above folds: the scalar's needle
11009                // argument arrives folded from every production caller.
11010                .query_map([fold_caseless(query)], |row| {
11011                    Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11012                })
11013                .unwrap()
11014                .collect::<rusqlite::Result<Vec<_>>>()
11015                .unwrap()
11016        };
11017        let accelerated_keys = hit_keys(accelerated());
11018        let direct_keys = direct();
11019        assert_eq!(accelerated_keys, direct_keys);
11020        let digest = crate::hashing::sha256(&serde_json::to_vec(&direct_keys).unwrap());
11021        let mut accelerated_us = Vec::new();
11022        let mut direct_us = Vec::new();
11023        for _ in 0..7 {
11024            let started = Instant::now();
11025            assert_eq!(hit_keys(accelerated()), direct_keys);
11026            accelerated_us.push(started.elapsed().as_micros());
11027            let started = Instant::now();
11028            assert_eq!(direct(), direct_keys);
11029            direct_us.push(started.elapsed().as_micros());
11030        }
11031        accelerated_us.sort_unstable();
11032        direct_us.sort_unstable();
11033        let (_, explain) = db
11034            .search_messages_with_explain(query, &filters, true)
11035            .unwrap();
11036        let explain = explain.unwrap();
11037        eprintln!(
11038            "tool_argument_prefilter_benchmark rows={} candidates={} hits={} accelerated_median_us={} accelerated_p95_us={} direct_median_us={} direct_p95_us={} digest=sha256:{}",
11039            explain.corpus,
11040            explain.candidates.unwrap(),
11041            direct_keys.len(),
11042            accelerated_us[accelerated_us.len() / 2],
11043            accelerated_us[accelerated_us.len() - 1],
11044            direct_us[direct_us.len() / 2],
11045            direct_us[direct_us.len() - 1],
11046            digest
11047        );
11048    }
11049
11050    #[test]
11051    fn fuzzy_tool_argument_search_uses_workers_without_changing_scores_or_order() {
11052        let root = tempfile::tempdir().unwrap();
11053        let seed = |db: &Db| {
11054            seed_messages(db, &[("tool", "placeholder")]);
11055            let tx = db.conn.unchecked_transaction().unwrap();
11056            {
11057                let mut insert = tx
11058                    .prepare(
11059                        "insert into messages (
11060                             session_id, provider, seq, role, tool_name, kind, content
11061                         ) values ('claude:s1', 'claude', ?1, 'tool', 'exec_command',
11062                                   'tool_call', ?2)",
11063                    )
11064                    .unwrap();
11065                for seq in 1..=1_100_i64 {
11066                    let cmd = if seq % 137 == 0 {
11067                        format!("cargo test workspace target {seq}")
11068                    } else {
11069                        format!("ordinary tool argument payload {seq}")
11070                    };
11071                    insert
11072                        .execute(params![
11073                            seq,
11074                            crate::util::tool_call_message_content(
11075                                "exec_command",
11076                                serde_json::json!({"cmd": cmd})
11077                            )
11078                        ])
11079                        .unwrap();
11080                }
11081            }
11082            tx.commit().unwrap();
11083        };
11084        let one = Db::open_with_threads(
11085            &root.path().join("one.db"),
11086            TEST_BUSY_TIMEOUT_MS,
11087            NonZeroUsize::new(1).unwrap(),
11088        )
11089        .unwrap();
11090        let four = Db::open_with_threads(
11091            &root.path().join("four.db"),
11092            TEST_BUSY_TIMEOUT_MS,
11093            NonZeroUsize::new(4).unwrap(),
11094        )
11095        .unwrap();
11096        seed(&one);
11097        seed(&four);
11098        let filters = MessageFilters {
11099            field: Some(SearchField::ToolArgument),
11100            argument_path: Some("/cmd".to_string()),
11101            match_mode: MessageSearchMode::Fuzzy,
11102            limit: 20,
11103            ..Default::default()
11104        };
11105        let identity = |hits: Vec<MessageHit>| {
11106            hits.into_iter()
11107                .map(|hit| (hit.session_id, hit.seq, hit.fuzzy_score))
11108                .collect::<Vec<_>>()
11109        };
11110        assert_eq!(one.worker_pool_build_count(), 0);
11111        assert_eq!(four.worker_pool_build_count(), 0);
11112        let expected = identity(one.search_messages("crgo tst wrkspce", &filters).unwrap());
11113        let actual = identity(four.search_messages("crgo tst wrkspce", &filters).unwrap());
11114        assert_eq!(actual, expected);
11115        assert_eq!(one.worker_pool_build_count(), 1);
11116        assert_eq!(four.worker_pool_build_count(), 1);
11117    }
11118
11119    #[test]
11120    #[ignore = "deterministic release-mode derived fuzzy performance measurement"]
11121    fn fuzzy_tool_argument_parallel_scoring_benchmark() {
11122        let root = tempfile::tempdir().unwrap();
11123        let path = root.path().join("index.db");
11124        {
11125            let db =
11126                Db::open_with_threads(&path, TEST_BUSY_TIMEOUT_MS, NonZeroUsize::new(1).unwrap())
11127                    .unwrap();
11128            seed_messages(&db, &[("tool", "placeholder")]);
11129            let tx = db.conn.unchecked_transaction().unwrap();
11130            {
11131                let mut insert = tx
11132                    .prepare(
11133                        "insert into messages (
11134                             session_id, provider, seq, role, tool_name, kind, content
11135                         ) values ('claude:s1', 'claude', ?1, 'tool', 'exec_command',
11136                                   'tool_call', ?2)",
11137                    )
11138                    .unwrap();
11139                for seq in 1..=30_000_i64 {
11140                    let cmd = if seq % 3_000 == 0 {
11141                        format!("cargo test workspace target {seq}")
11142                    } else {
11143                        format!("ordinary deterministic tool argument payload {seq}")
11144                    };
11145                    insert
11146                        .execute(params![
11147                            seq,
11148                            crate::util::tool_call_message_content(
11149                                "exec_command",
11150                                serde_json::json!({"cmd": cmd})
11151                            )
11152                        ])
11153                        .unwrap();
11154                }
11155            }
11156            tx.commit().unwrap();
11157        }
11158        let filters = MessageFilters {
11159            field: Some(SearchField::ToolArgument),
11160            argument_path: Some("/cmd".to_string()),
11161            match_mode: MessageSearchMode::Fuzzy,
11162            limit: 50,
11163            ..Default::default()
11164        };
11165        let measure = |threads| {
11166            let db = Db::open_with_threads(
11167                &path,
11168                TEST_BUSY_TIMEOUT_MS,
11169                NonZeroUsize::new(threads).unwrap(),
11170            )
11171            .unwrap();
11172            let search = || db.search_messages("crgo tst wrkspce", &filters).unwrap();
11173            let expected = serde_json::to_vec(&search()).unwrap();
11174            let digest = crate::hashing::sha256(&expected);
11175            let mut elapsed = Vec::new();
11176            for _ in 0..7 {
11177                let started = Instant::now();
11178                assert_eq!(serde_json::to_vec(&search()).unwrap(), expected);
11179                elapsed.push(started.elapsed().as_micros());
11180            }
11181            elapsed.sort_unstable();
11182            (
11183                elapsed[elapsed.len() / 2],
11184                elapsed[elapsed.len() - 1],
11185                digest,
11186            )
11187        };
11188        let one = measure(1);
11189        let four = measure(4);
11190        assert_eq!(one.2, four.2);
11191        eprintln!(
11192            "tool_argument_fuzzy_parallel_benchmark rows=30000 one_worker_median_us={} one_worker_p95_us={} four_worker_median_us={} four_worker_p95_us={} digest=sha256:{}",
11193            one.0, one.1, four.0, four.1, one.2
11194        );
11195    }
11196
11197    #[test]
11198    fn message_search_three_modes_by_three_fields_share_one_result_contract() {
11199        let dir = tempfile::tempdir().unwrap();
11200        let db = Db::open(&dir.path().join("index.db")).unwrap();
11201        seed_messages(
11202            &db,
11203            &[
11204                ("tool", "placeholder"),
11205                ("tool", "distractor"),
11206                ("tool", "second target"),
11207            ],
11208        );
11209        db.conn
11210            .execute_batch(
11211                r#"update messages set
11212                       kind = 'tool_call', tool_name = 'exec_command',
11213                       content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
11214                   where seq = 0;
11215                   update messages set
11216                       kind = 'tool_call', tool_name = 'read_file',
11217                       content = '{"args":{"cmd":"open notes.md"},"kind":"tool_call","tool_name":"read_file"}'
11218                   where seq = 1;
11219                   update messages set
11220                       kind = 'tool_call', tool_name = 'exec_command',
11221                       content = '{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
11222                   where seq = 2;"#,
11223            )
11224            .unwrap();
11225
11226        let cases = [
11227            (
11228                SearchField::Content,
11229                MessageSearchMode::Literal,
11230                "cargo test",
11231            ),
11232            (
11233                SearchField::Content,
11234                MessageSearchMode::Regex,
11235                r"cargo\s+test",
11236            ),
11237            (SearchField::Content, MessageSearchMode::Fuzzy, "crgo tst"),
11238            (SearchField::ToolName, MessageSearchMode::Literal, "exec"),
11239            (SearchField::ToolName, MessageSearchMode::Regex, r"^exec_"),
11240            (SearchField::ToolName, MessageSearchMode::Fuzzy, "excmd"),
11241            (
11242                SearchField::ToolArgument,
11243                MessageSearchMode::Literal,
11244                "cargo test",
11245            ),
11246            (
11247                SearchField::ToolArgument,
11248                MessageSearchMode::Regex,
11249                r"cargo\s+test",
11250            ),
11251            (
11252                SearchField::ToolArgument,
11253                MessageSearchMode::Fuzzy,
11254                "crgo tst",
11255            ),
11256        ];
11257
11258        for (field, match_mode, query) in cases {
11259            let (hits, explain) = db
11260                .search_messages_with_explain(
11261                    query,
11262                    &MessageFilters {
11263                        kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11264                        field: Some(field),
11265                        argument_path: (field == SearchField::ToolArgument)
11266                            .then(|| "/cmd".to_string()),
11267                        match_mode,
11268                        limit: 10,
11269                        ..Default::default()
11270                    },
11271                    true,
11272                )
11273                .unwrap_or_else(|error| panic!("{field:?}/{match_mode:?}: {error:#}"));
11274            if match_mode == MessageSearchMode::Fuzzy {
11275                assert_eq!(
11276                    hits[0].fuzzy_score, hits[1].fuzzy_score,
11277                    "identical projected values must exercise the stable identity tie-break"
11278                );
11279            }
11280            assert_eq!(
11281                hit_keys(hits),
11282                vec![("claude:s1".into(), 0), ("claude:s1".into(), 2)],
11283                "{field:?}/{match_mode:?}"
11284            );
11285            assert!(explain.is_some(), "{field:?}/{match_mode:?}");
11286            let page = db
11287                .search_messages(
11288                    query,
11289                    &MessageFilters {
11290                        kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11291                        field: Some(field),
11292                        argument_path: (field == SearchField::ToolArgument)
11293                            .then(|| "/cmd".to_string()),
11294                        match_mode,
11295                        limit: 1,
11296                        offset: 1,
11297                        ..Default::default()
11298                    },
11299                )
11300                .unwrap_or_else(|error| panic!("paged {field:?}/{match_mode:?}: {error:#}"));
11301            assert_eq!(
11302                hit_keys(page),
11303                vec![("claude:s1".into(), 2)],
11304                "paged {field:?}/{match_mode:?}"
11305            );
11306        }
11307    }
11308
11309    #[test]
11310    fn non_fuzzy_message_batches_match_materialized_search_across_fields_and_offsets() {
11311        let dir = tempfile::tempdir().unwrap();
11312        let db = Db::open(&dir.path().join("index.db")).unwrap();
11313        seed_messages(
11314            &db,
11315            &[
11316                ("user", "alpha needle"),
11317                ("tool", "placeholder"),
11318                ("user", "omega needle"),
11319            ],
11320        );
11321        db.conn
11322            .execute(
11323                "update messages
11324                    set kind = 'tool_call',
11325                        tool_name = 'exec_command',
11326                        content = '{\"args\":{\"cmd\":\"cargo test needle\"}}'
11327                  where seq = 1",
11328                [],
11329            )
11330            .unwrap();
11331
11332        for (field, match_mode, query, argument_path) in [
11333            (
11334                SearchField::Content,
11335                MessageSearchMode::Literal,
11336                "needle",
11337                None,
11338            ),
11339            (
11340                SearchField::Content,
11341                MessageSearchMode::Regex,
11342                "n[e]+dle",
11343                None,
11344            ),
11345            (SearchField::Content, MessageSearchMode::Literal, "", None),
11346            (
11347                SearchField::ToolName,
11348                MessageSearchMode::Literal,
11349                "exec",
11350                None,
11351            ),
11352            (
11353                SearchField::ToolArgument,
11354                MessageSearchMode::Literal,
11355                "cargo test",
11356                Some("/cmd".to_string()),
11357            ),
11358        ] {
11359            let filters = MessageFilters {
11360                field: Some(field),
11361                argument_path,
11362                match_mode,
11363                limit: 0,
11364                offset: 1,
11365                ..Default::default()
11366            };
11367            let materialized = db
11368                .search_messages_ordered(query, &filters, MessageOrder::OldestFirst)
11369                .unwrap();
11370            for batch_size in [1, 2, 17] {
11371                let mut batched = Vec::new();
11372                let outcome = db
11373                    .visit_messages_in_batches(
11374                        query,
11375                        &filters,
11376                        true,
11377                        MessageOrder::OldestFirst,
11378                        NonZeroUsize::new(batch_size).unwrap(),
11379                        |batch| {
11380                            assert!(batch.len() <= batch_size);
11381                            batched.extend(batch);
11382                            Ok(MessageBatchControl::Continue)
11383                        },
11384                    )
11385                    .unwrap_or_else(|error| {
11386                        panic!("{field:?}/{match_mode:?}/batch={batch_size}: {error:#}")
11387                    });
11388                assert!(outcome.exhausted);
11389                assert_eq!(outcome.rows_visited, materialized.len());
11390                assert!(outcome.explain.is_some());
11391                assert_eq!(
11392                    hit_keys(batched),
11393                    hit_keys(materialized.clone()),
11394                    "{field:?}/{match_mode:?}/batch={batch_size}"
11395                );
11396            }
11397        }
11398    }
11399
11400    #[test]
11401    fn newest_edge_batches_stream_the_post_offset_selection_in_chronological_order() {
11402        let dir = tempfile::tempdir().unwrap();
11403        let db = Db::open(&dir.path().join("index.db")).unwrap();
11404        seed_messages(
11405            &db,
11406            &[
11407                ("user", "needle zero"),
11408                ("user", "needle one"),
11409                ("user", "needle two"),
11410                ("user", "needle three"),
11411                ("user", "needle four"),
11412            ],
11413        );
11414        let filters = MessageFilters {
11415            session_id: Some("claude:s1".into()),
11416            limit: 0,
11417            offset: 2,
11418            ..Default::default()
11419        };
11420        let (mut materialized, materialized_explain) = db
11421            .search_messages_with_explain_order("needle", &filters, true, MessageOrder::NewestFirst)
11422            .unwrap();
11423        materialized.reverse();
11424
11425        for batch_size in [1, 2, 17] {
11426            let mut batched = Vec::new();
11427            let outcome = db
11428                .visit_messages_in_batches(
11429                    "needle",
11430                    &filters,
11431                    true,
11432                    MessageOrder::NewestFirst,
11433                    NonZeroUsize::new(batch_size).unwrap(),
11434                    |batch| {
11435                        batched.extend(batch);
11436                        Ok(MessageBatchControl::Continue)
11437                    },
11438                )
11439                .unwrap();
11440            assert!(outcome.exhausted);
11441            assert_eq!(hit_keys(batched), hit_keys(materialized.clone()));
11442            assert_eq!(
11443                outcome.explain.as_ref().map(|explain| explain.corpus),
11444                materialized_explain.as_ref().map(|explain| explain.corpus)
11445            );
11446            assert_eq!(
11447                outcome
11448                    .explain
11449                    .as_ref()
11450                    .and_then(|explain| explain.candidates),
11451                materialized_explain
11452                    .as_ref()
11453                    .and_then(|explain| explain.candidates)
11454            );
11455        }
11456    }
11457
11458    #[test]
11459    fn non_fuzzy_message_batches_hold_one_snapshot_and_stop_without_draining() {
11460        let dir = tempfile::tempdir().unwrap();
11461        let path = dir.path().join("index.db");
11462        let db = Db::open(&path).unwrap();
11463        let writer = Db::open(&path).unwrap();
11464        seed_messages(
11465            &db,
11466            &[
11467                ("user", "needle zero"),
11468                ("user", "needle one"),
11469                ("user", "needle two"),
11470            ],
11471        );
11472        let filters = MessageFilters {
11473            limit: 0,
11474            ..Default::default()
11475        };
11476        let mut seen = Vec::new();
11477        let snapshot = db
11478            .visit_messages_in_batches(
11479                "needle",
11480                &filters,
11481                false,
11482                MessageOrder::OldestFirst,
11483                NonZeroUsize::new(1).unwrap(),
11484                |batch| {
11485                    seen.extend(batch.into_iter().map(|hit| hit.seq));
11486                    if seen.len() == 1 {
11487                        writer
11488                            .conn
11489                            .execute(
11490                                "insert into messages (
11491                                     session_id, provider, seq, role, content
11492                                 ) values ('claude:s1', 'claude', 3, 'user', 'needle late')",
11493                                [],
11494                            )
11495                            .unwrap();
11496                    }
11497                    Ok(MessageBatchControl::Continue)
11498                },
11499            )
11500            .unwrap();
11501        assert!(snapshot.exhausted);
11502        assert_eq!(seen, [0, 1, 2]);
11503        assert_eq!(db.search_messages("needle", &filters).unwrap().len(), 4);
11504
11505        let mut delivered = 0;
11506        let stopped = db
11507            .visit_messages_in_batches(
11508                "needle",
11509                &filters,
11510                false,
11511                MessageOrder::OldestFirst,
11512                NonZeroUsize::new(2).unwrap(),
11513                |batch| {
11514                    delivered += batch.len();
11515                    Ok(MessageBatchControl::Stop)
11516                },
11517            )
11518            .unwrap();
11519        assert_eq!(delivered, 2);
11520        assert_eq!(stopped.rows_visited, 2);
11521        assert!(!stopped.exhausted);
11522        assert!(stopped.explain.is_none());
11523        let checkpoint_busy: i64 = writer
11524            .conn
11525            .query_row("pragma wal_checkpoint(passive)", [], |row| row.get(0))
11526            .expect("early stop drops the read transaction without draining unread rows");
11527        assert_eq!(checkpoint_busy, 0);
11528    }
11529
11530    #[test]
11531    fn non_fuzzy_message_batches_finish_legacy_index_maintenance_before_the_snapshot() {
11532        let dir = tempfile::tempdir().unwrap();
11533        let db = Db::open(&dir.path().join("index.db")).unwrap();
11534        enable_v3_custom_trigram_compatibility(&db);
11535        seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
11536        let filters = MessageFilters {
11537            match_mode: MessageSearchMode::Regex,
11538            ..Default::default()
11539        };
11540        let mut seen = Vec::new();
11541
11542        let outcome = db
11543            .visit_messages_in_batches(
11544                "ECONNRESET",
11545                &filters,
11546                false,
11547                MessageOrder::OldestFirst,
11548                NonZeroUsize::new(1).unwrap(),
11549                |batch| {
11550                    seen.extend(batch);
11551                    Ok(MessageBatchControl::Continue)
11552                },
11553            )
11554            .unwrap();
11555
11556        assert!(outcome.exhausted);
11557        assert_eq!(seen.len(), 1);
11558        assert!(seen[0].content.contains("ECONNRESET"));
11559        assert!(
11560            crate::trigram_index::base_max_id(&db.conn).unwrap() > 0,
11561            "legacy trigram maintenance must finish before the read transaction starts"
11562        );
11563
11564        let error = db
11565            .visit_messages_in_batches(
11566                "ECONNRESET",
11567                &filters,
11568                true,
11569                MessageOrder::OldestFirst,
11570                NonZeroUsize::new(1).unwrap(),
11571                |_| Ok(MessageBatchControl::Continue),
11572            )
11573            .unwrap_err()
11574            .to_string();
11575        assert!(
11576            error.contains("planner counts and results share one snapshot"),
11577            "{error}"
11578        );
11579    }
11580
11581    #[test]
11582    fn message_search_validation_is_identical_for_all_three_fields() {
11583        let dir = tempfile::tempdir().unwrap();
11584        let db = Db::open(&dir.path().join("index.db")).unwrap();
11585        for field in [
11586            SearchField::Content,
11587            SearchField::ToolName,
11588            SearchField::ToolArgument,
11589        ] {
11590            let filters = |match_mode, limit, offset| MessageFilters {
11591                field: Some(field),
11592                argument_path: (field == SearchField::ToolArgument).then(|| "/cmd".into()),
11593                match_mode,
11594                limit,
11595                offset,
11596                ..Default::default()
11597            };
11598            let malformed = db
11599                .search_messages("[", &filters(MessageSearchMode::Regex, 10, 0))
11600                .unwrap_err()
11601                .to_string();
11602            assert!(
11603                malformed.contains("invalid regex"),
11604                "{field:?}: {malformed}"
11605            );
11606
11607            let short = db
11608                .search_messages("ab", &filters(MessageSearchMode::Fuzzy, 10, 0))
11609                .unwrap_err()
11610                .to_string();
11611            assert!(
11612                short.contains("at least 3 characters"),
11613                "{field:?}: {short}"
11614            );
11615
11616            let unlimited = db
11617                .search_messages("abc", &filters(MessageSearchMode::Fuzzy, 0, 0))
11618                .unwrap_err()
11619                .to_string();
11620            assert!(
11621                unlimited.contains("finite non-zero limit"),
11622                "{field:?}: {unlimited}"
11623            );
11624
11625            let large_offset =
11626                db.search_messages("abc", &filters(MessageSearchMode::Fuzzy, 2, 9_999));
11627            assert!(
11628                large_offset.is_ok(),
11629                "{field:?}: finite fuzzy offsets have no arbitrary result window: {large_offset:?}"
11630            );
11631        }
11632    }
11633
11634    #[test]
11635    fn tool_argument_search_handles_json_shapes_unicode_punctuation_and_large_values() {
11636        let dir = tempfile::tempdir().unwrap();
11637        let db = Db::open(&dir.path().join("index.db")).unwrap();
11638        seed_messages(
11639            &db,
11640            &[
11641                ("tool", "malformed"),
11642                ("tool", "missing args"),
11643                ("tool", "scalar args"),
11644                ("tool", "null"),
11645                ("tool", "array"),
11646                ("tool", "nested"),
11647                ("tool", "large"),
11648            ],
11649        );
11650        let large = format!("{} cargo test --workspace", "x".repeat(128 * 1024));
11651        let rows = [
11652            "{malformed".to_string(),
11653            r#"{"kind":"tool_call"}"#.to_string(),
11654            r#"{"args":"scalar","kind":"tool_call"}"#.to_string(),
11655            r#"{"args":{"cmd":null},"kind":"tool_call"}"#.to_string(),
11656            r#"{"args":{"cmd":["café","C++","--path"]},"kind":"tool_call"}"#.to_string(),
11657            r#"{"args":{"request":{"path":"src/quoted file.rs"}},"kind":"tool_call"}"#.to_string(),
11658            serde_json::json!({"args": {"cmd": large}, "kind": "tool_call"}).to_string(),
11659        ];
11660        for (seq, content) in rows.iter().enumerate() {
11661            db.conn
11662                .execute(
11663                    "update messages set kind='tool_call', tool_name='ÄTool::C++', content=?1
11664                     where seq=?2",
11665                    params![content, seq as i64],
11666                )
11667                .unwrap();
11668        }
11669        let argument = |path: &str, mode| MessageFilters {
11670            field: Some(SearchField::ToolArgument),
11671            argument_path: Some(path.into()),
11672            match_mode: mode,
11673            limit: 10,
11674            ..Default::default()
11675        };
11676
11677        assert_eq!(
11678            hit_keys(
11679                db.search_messages("café", &argument("/cmd", MessageSearchMode::Literal))
11680                    .unwrap()
11681            ),
11682            vec![("claude:s1".into(), 4)]
11683        );
11684        assert_eq!(
11685            hit_keys(
11686                db.search_messages(
11687                    r#"C\+\+.*--path"#,
11688                    &argument("/cmd", MessageSearchMode::Regex),
11689                )
11690                .unwrap()
11691            ),
11692            vec![("claude:s1".into(), 4)]
11693        );
11694        assert_eq!(
11695            hit_keys(
11696                db.search_messages(
11697                    "quoted file.rs",
11698                    &argument("/request/path", MessageSearchMode::Literal),
11699                )
11700                .unwrap()
11701            ),
11702            vec![("claude:s1".into(), 5)]
11703        );
11704        assert_eq!(
11705            hit_keys(
11706                db.search_messages("null", &argument("/cmd", MessageSearchMode::Literal))
11707                    .unwrap()
11708            ),
11709            vec![("claude:s1".into(), 3)]
11710        );
11711        assert_eq!(
11712            hit_keys(
11713                db.search_messages(
11714                    "cargo test --workspace",
11715                    &argument("/cmd", MessageSearchMode::Literal),
11716                )
11717                .unwrap()
11718            ),
11719            vec![("claude:s1".into(), 6)]
11720        );
11721        assert!(
11722            db.search_messages("scalar", &argument("/cmd", MessageSearchMode::Literal))
11723                .unwrap()
11724                .is_empty(),
11725            "a pointer below scalar args and malformed/missing envelopes must project NULL"
11726        );
11727
11728        for mode in [MessageSearchMode::Literal, MessageSearchMode::Regex] {
11729            let query = if mode == MessageSearchMode::Literal {
11730                "ätool::c++"
11731            } else {
11732                r#"(?i)^ätool::c\+\+$"#
11733            };
11734            assert_eq!(
11735                db.search_messages(
11736                    query,
11737                    &MessageFilters {
11738                        field: Some(SearchField::ToolName),
11739                        match_mode: mode,
11740                        limit: 10,
11741                        ..Default::default()
11742                    },
11743                )
11744                .unwrap()
11745                .len(),
11746                7,
11747                "{mode:?} must preserve Unicode and punctuation in tool names"
11748            );
11749        }
11750    }
11751
11752    #[test]
11753    fn fuzzy_content_reports_complete_filtered_corpus_scoring() {
11754        let dir = tempfile::tempdir().unwrap();
11755        let db = Db::open(&dir.path().join("index.db")).unwrap();
11756        db.conn
11757            .execute(
11758                "insert into sessions (
11759                     id, provider, provider_session_id, preview_text, source_path,
11760                     parse_version, discovery_source
11761                 ) values ('s1','claude','s1','','/p','1','test')",
11762                [],
11763            )
11764            .unwrap();
11765        let tx = db.conn.unchecked_transaction().unwrap();
11766        {
11767            let mut insert = tx
11768                .prepare(
11769                    "insert into messages(session_id, provider, seq, role, content)
11770                     values ('s1','claude',?1,'user',?2)",
11771                )
11772                .unwrap();
11773            for seq in 0..1_205_i64 {
11774                insert
11775                    .execute(params![seq, format!("abc candidate {seq:04}")])
11776                    .unwrap();
11777            }
11778        }
11779        tx.commit().unwrap();
11780
11781        let (hits, explain) = db
11782            .search_messages_with_explain(
11783                "abc",
11784                &MessageFilters {
11785                    match_mode: MessageSearchMode::Fuzzy,
11786                    limit: 1,
11787                    ..Default::default()
11788                },
11789                true,
11790            )
11791            .unwrap();
11792
11793        assert_eq!(hits.len(), 1);
11794        let explain = explain.unwrap();
11795        assert_eq!(
11796            explain.prefilter_skipped.as_deref(),
11797            Some("complete filtered corpus scored, retaining at most min(offset + limit, corpus) ranked rows")
11798        );
11799        assert_eq!(explain.candidates, Some(1_205));
11800        assert_eq!(explain.corpus, 1_205);
11801    }
11802
11803    #[test]
11804    fn v4_prefilter_work_stays_selective_across_one_two_four_x_corpora() {
11805        for corpus in [200_i64, 400, 800] {
11806            let dir = tempfile::tempdir().unwrap();
11807            let db = Db::open(&dir.path().join("index.db")).unwrap();
11808            db.conn
11809                .execute(
11810                    "insert into sessions (
11811                         id, provider, provider_session_id, preview_text, source_path,
11812                         parse_version, discovery_source
11813                     ) values ('s1','claude','s1','','/p','1','test')",
11814                    [],
11815                )
11816                .unwrap();
11817            let tx = db.conn.unchecked_transaction().unwrap();
11818            {
11819                let mut insert = tx
11820                    .prepare(
11821                        "insert into messages(session_id, provider, seq, role, content)
11822                         values ('s1','claude',?1,'user',?2)",
11823                    )
11824                    .unwrap();
11825                insert
11826                    .execute(params![0_i64, "rare fuzzy anchor C++ --path"])
11827                    .unwrap();
11828                for seq in 1..corpus {
11829                    insert
11830                        .execute(params![seq, format!("unrelated filler row {seq:04}")])
11831                        .unwrap();
11832                }
11833            }
11834            tx.commit().unwrap();
11835
11836            for (mode, query) in [
11837                (MessageSearchMode::Literal, "fuzzy anchor C++"),
11838                (MessageSearchMode::Regex, r"fuzzy\s+anchor\s+C\+\+"),
11839                (MessageSearchMode::Fuzzy, "fzzy anchr C++"),
11840            ] {
11841                let (hits, explain) = db
11842                    .search_messages_with_explain(
11843                        query,
11844                        &MessageFilters {
11845                            match_mode: mode,
11846                            limit: 10,
11847                            ..Default::default()
11848                        },
11849                        true,
11850                    )
11851                    .unwrap();
11852                assert_eq!(hit_keys(hits), vec![("s1".into(), 0)], "{corpus}/{mode:?}");
11853                let explain = explain.unwrap();
11854                assert_eq!(explain.corpus, corpus, "{corpus}/{mode:?}");
11855                assert!(
11856                    explain.candidates.is_some_and(|count| count < corpus / 4),
11857                    "candidate work must stay selective as the unrelated corpus scales: \
11858                     corpus={corpus} mode={mode:?} explain={explain:?}"
11859                );
11860            }
11861        }
11862    }
11863
11864    #[test]
11865    fn message_kind_call_id_and_offset_share_one_query_contract() {
11866        let dir = tempfile::tempdir().unwrap();
11867        let db = Db::open(&dir.path().join("index.db")).unwrap();
11868        seed_messages(&db, &[("tool", "first call"), ("tool", "first result")]);
11869        db.conn
11870            .execute_batch(
11871                "update messages set kind = 'tool_call', tool_call_id = 'call-1' where seq = 0;
11872                 update messages set kind = 'tool_result', tool_call_id = 'call-1' where seq = 1;",
11873            )
11874            .unwrap();
11875
11876        let calls = db
11877            .search_messages(
11878                "",
11879                &MessageFilters {
11880                    kinds: Some(vec![crate::models::MessageKind::ToolCall]),
11881                    ..Default::default()
11882                },
11883            )
11884            .unwrap();
11885        assert_eq!(calls.len(), 1);
11886        assert_eq!(calls[0].kind, crate::models::MessageKind::ToolCall);
11887        assert_eq!(calls[0].tool_call_id.as_deref(), Some("call-1"));
11888
11889        let page = db
11890            .search_messages(
11891                "",
11892                &MessageFilters {
11893                    limit: 1,
11894                    offset: 1,
11895                    ..Default::default()
11896                },
11897            )
11898            .unwrap();
11899        assert_eq!(page.len(), 1);
11900        assert_eq!(page[0].seq, 1);
11901        assert_eq!(page[0].kind, crate::models::MessageKind::ToolResult);
11902    }
11903
11904    #[test]
11905    fn message_context_rejects_negative_window_bounds() {
11906        let dir = tempfile::tempdir().unwrap();
11907        let db = Db::open(&dir.path().join("index.db")).unwrap();
11908        let error = db
11909            .message_context("missing", 0, -1, 0)
11910            .unwrap_err()
11911            .to_string();
11912        assert!(error.contains("before must be non-negative"), "{error}");
11913        let error = db
11914            .message_context("missing", 0, 0, -1)
11915            .unwrap_err()
11916            .to_string();
11917        assert!(error.contains("after must be non-negative"), "{error}");
11918    }
11919
11920    #[test]
11921    fn query_timeout_interrupts_expensive_work_and_resets_the_handler() {
11922        let dir = tempfile::tempdir().unwrap();
11923        let db = Db::open(&dir.path().join("index.db")).unwrap();
11924
11925        let error = with_sqlite_query_timeout(
11926            &db.conn,
11927            std::num::NonZeroU64::new(1),
11928            "read-only SQL query",
11929            "increase timeout_ms or pass 0 for no timeout",
11930            || {
11931                db.conn
11932                    .query_row(
11933                        "with recursive n(x) as (values(0) union all select x + 1 from n where x < 100000000) select sum(x) from n",
11934                        [],
11935                        |row| row.get::<_, i64>(0),
11936                    )
11937                    .map_err(Into::into)
11938            },
11939        )
11940        .unwrap_err()
11941        .to_string();
11942        assert!(error.contains("timed out after 1 ms"), "{error}");
11943
11944        assert_eq!(
11945            db.conn
11946                .query_row("select 1", [], |row| row.get::<_, i64>(0))
11947                .unwrap(),
11948            1,
11949            "the RAII guard must clear SQLite's progress handler after interruption"
11950        );
11951    }
11952
11953    #[test]
11954    fn persistent_interrupt_cancels_a_statement_started_after_the_flag_is_set() {
11955        let dir = tempfile::tempdir().unwrap();
11956        let db = Db::open(&dir.path().join("index.db")).unwrap();
11957        let cancellation = Arc::new(AtomicBool::new(false));
11958        db.interrupt_while(Arc::clone(&cancellation))
11959            .expect("arming cancellation is a precondition of this test");
11960
11961        // Setting the flag before SQLite starts the statement reproduces the race that a one-shot
11962        // InterruptHandle cannot cover: interrupt() is a no-op when no statement is active.
11963        cancellation.store(true, Ordering::Release);
11964        let error = db
11965            .conn
11966            .query_row(
11967                "with recursive n(value) as (
11968                     values(1)
11969                     union all
11970                     select value + 1 from n where value < 1000000
11971                 )
11972                 select sum(value) from n",
11973                [],
11974                |row| row.get::<_, i64>(0),
11975            )
11976            .unwrap_err();
11977        assert!(matches!(
11978            error,
11979            rusqlite::Error::SqliteFailure(inner, _)
11980                if inner.code == rusqlite::ErrorCode::OperationInterrupted
11981        ));
11982    }
11983
11984    #[test]
11985    fn query_cancellation_registered_after_cancel_still_interrupts_the_next_statement() {
11986        let dir = tempfile::tempdir().unwrap();
11987        let db = Db::open(&dir.path().join("index.db")).unwrap();
11988        let cancellation = QueryCancellation::new();
11989
11990        cancellation.cancel();
11991        db.install_query_cancellation(&cancellation)
11992            .expect("arming cancellation is a precondition of this test");
11993
11994        let error = db
11995            .conn
11996            .query_row(
11997                "with recursive n(value) as (
11998                     values(1)
11999                     union all
12000                     select value + 1 from n where value < 1000000
12001                 )
12002                 select sum(value) from n",
12003                [],
12004                |row| row.get::<_, i64>(0),
12005            )
12006            .unwrap_err();
12007        assert!(matches!(
12008            error,
12009            rusqlite::Error::SqliteFailure(inner, _)
12010                if inner.code == rusqlite::ErrorCode::OperationInterrupted
12011        ));
12012    }
12013
12014    #[test]
12015    fn message_batch_allocation_failure_names_the_unit_and_recovery() {
12016        let dir = tempfile::tempdir().unwrap();
12017        let db = Db::open(&dir.path().join("index.db")).unwrap();
12018        seed_messages(&db, &[("user", "needle")]);
12019        let error = db
12020            .visit_messages_in_batches(
12021                "needle",
12022                &MessageFilters {
12023                    limit: 0,
12024                    ..Default::default()
12025                },
12026                false,
12027                MessageOrder::OldestFirst,
12028                NonZeroUsize::new(usize::MAX).unwrap(),
12029                |_| Ok(MessageBatchControl::Continue),
12030            )
12031            .unwrap_err()
12032            .to_string();
12033        assert!(error.contains("batch_rows"));
12034        assert!(error.contains(&usize::MAX.to_string()));
12035        assert!(error.contains("pass a smaller positive row count"));
12036    }
12037
12038    #[test]
12039    fn query_timeout_never_panics_when_deadline_exceeds_platform_instant_range() {
12040        let dir = tempfile::tempdir().unwrap();
12041        let db = Db::open(&dir.path().join("index.db")).unwrap();
12042
12043        let result = with_sqlite_query_timeout(
12044            &db.conn,
12045            std::num::NonZeroU64::new(u64::MAX),
12046            "read-only SQL query",
12047            "increase timeout_ms or pass 0 for no timeout",
12048            || {
12049                db.conn
12050                    .query_row("select 1", [], |row| row.get::<_, i64>(0))
12051                    .map_err(Into::into)
12052            },
12053        );
12054
12055        match result {
12056            Ok(value) => assert_eq!(value, 1),
12057            Err(error) => assert!(
12058                error.to_string().contains("timeout_ms is too large"),
12059                "{error}"
12060            ),
12061        }
12062        assert_eq!(
12063            db.conn
12064                .query_row("select 2", [], |row| row.get::<_, i64>(0))
12065                .unwrap(),
12066            2,
12067            "an unrepresentable timeout must not leave a progress handler installed"
12068        );
12069    }
12070
12071    #[test]
12072    fn message_context_windows_batch_preserves_anchor_order_and_empty_windows() {
12073        let dir = tempfile::tempdir().unwrap();
12074        let db = Db::open(&dir.path().join("index.db")).unwrap();
12075        seed_messages(
12076            &db,
12077            &[
12078                ("user", "zero"),
12079                ("assistant", "one"),
12080                ("user", "two"),
12081                ("assistant", "three"),
12082            ],
12083        );
12084        let anchors = vec![
12085            ("claude:s1".to_string(), 3),
12086            ("missing".to_string(), 10),
12087            ("claude:s1".to_string(), 0),
12088        ];
12089
12090        let windows = db.message_context_windows(&anchors, 1, 1).unwrap();
12091
12092        assert_eq!(windows.len(), 3);
12093        assert_eq!(
12094            windows[0].iter().map(|hit| hit.seq).collect::<Vec<_>>(),
12095            vec![2, 3]
12096        );
12097        assert!(windows[1].is_empty());
12098        assert_eq!(
12099            windows[2].iter().map(|hit| hit.seq).collect::<Vec<_>>(),
12100            vec![0, 1]
12101        );
12102    }
12103
12104    #[test]
12105    fn session_time_profile_is_bounded_and_uses_typed_message_metadata() {
12106        let dir = tempfile::tempdir().unwrap();
12107        let db = Db::open(&dir.path().join("index.db")).unwrap();
12108        seed_messages(
12109            &db,
12110            &[
12111                ("user", "start"),
12112                ("tool", "call"),
12113                ("tool", "result"),
12114                ("assistant", "end"),
12115            ],
12116        );
12117        db.conn
12118            .execute_batch(
12119                "update messages set ts = '2026-07-10T10:00:00Z' where seq = 0;
12120                 update messages set ts = '2026-07-10T10:00:05Z', kind = 'tool_call' where seq = 1;
12121                 update messages set ts = '2026-07-10T10:00:20Z', kind = 'tool_result' where seq = 2;",
12122            )
12123            .unwrap();
12124
12125        let profile = db.session_time_profile("claude:s1").unwrap();
12126        assert_eq!(profile.messages, 4);
12127        assert_eq!(profile.timestamped_messages, 3);
12128        assert_eq!(profile.undated_messages, 1);
12129        assert_eq!(profile.observed_span_seconds, Some(20));
12130        assert_eq!(profile.max_message_gap_seconds, Some(15));
12131        assert_eq!((profile.tool_calls, profile.tool_results), (1, 1));
12132    }
12133
12134    #[test]
12135    fn session_time_profile_uses_intermediate_events_across_months() {
12136        let dir = tempfile::tempdir().unwrap();
12137        let db = Db::open(&dir.path().join("index.db")).unwrap();
12138        seed_messages(
12139            &db,
12140            &[
12141                ("user", "january"),
12142                ("assistant", "february"),
12143                ("user", "april"),
12144                ("assistant", "undated tail"),
12145            ],
12146        );
12147        db.conn
12148            .execute_batch(
12149                "update sessions
12150                    set created_at = '2026-04-30T00:00:00Z',
12151                        updated_at = '2026-04-30T00:00:01Z',
12152                        last_message_at = '2026-04-30T00:00:01Z'
12153                  where id = 'claude:s1';
12154                 update messages set ts = '2026-01-01T00:00:00.100Z' where seq = 0;
12155                 update messages set ts = '2026-02-01T00:00:00.200Z' where seq = 1;
12156                 update messages set ts = '2026-04-30T00:00:00.300Z' where seq = 2;
12157                 update messages set ts = null where seq = 3;",
12158            )
12159            .unwrap();
12160
12161        let profile = db.session_time_profile("claude:s1").unwrap();
12162
12163        assert_eq!(profile.timestamped_messages, 3);
12164        assert_eq!(profile.undated_messages, 1);
12165        assert_eq!(
12166            profile.first_timestamp.unwrap().to_rfc3339(),
12167            "2026-01-01T00:00:00.100+00:00"
12168        );
12169        assert_eq!(
12170            profile.last_timestamp.unwrap().to_rfc3339(),
12171            "2026-04-30T00:00:00.300+00:00"
12172        );
12173        assert_eq!(profile.observed_span_seconds, Some(10_281_600));
12174        assert_eq!(profile.max_message_gap_seconds, Some(7_603_200));
12175    }
12176
12177    #[test]
12178    fn parser_health_uses_shared_provider_versions_and_consistent_totals() {
12179        let dir = tempfile::tempdir().unwrap();
12180        let db = Db::open(&dir.path().join("index.db")).unwrap();
12181        db.mark_schema_current().unwrap();
12182        db.conn
12183            .execute_batch(
12184                "insert into sessions(id, provider, provider_session_id, preview_text, source_path, parse_version, parse_warning, discovery_source) values
12185                 ('claude:current','claude','current','','/a','claude-v4',null,'jsonl'),
12186                 ('claude:stale','claude','stale','','/b','claude-v1','old parser','jsonl');",
12187            )
12188            .unwrap();
12189        db.conn
12190            .execute(
12191                "insert into sessions(id, provider, provider_session_id, preview_text, source_path, parse_version, discovery_source)
12192                 values ('codex:current','codex','current','','/c',?1,'jsonl')",
12193                [crate::util::provider_parse_version(Provider::Codex)],
12194            )
12195            .unwrap();
12196
12197        let health = db.parser_health().unwrap();
12198        assert!(health.schema_current);
12199        assert_eq!(health.indexed_sessions, 3);
12200        assert_eq!(health.current_sessions, 2);
12201        assert_eq!(health.stale_sessions, 1);
12202        assert_eq!(health.parse_warnings, 1);
12203        let claude = health
12204            .providers
12205            .iter()
12206            .find(|item| item.provider == Provider::Claude)
12207            .unwrap();
12208        assert_eq!(claude.expected_parse_version, "claude-v4");
12209        assert_eq!((claude.current_sessions, claude.stale_sessions), (1, 1));
12210        assert_eq!(
12211            db.stale_session_sources().unwrap(),
12212            vec![(Provider::Claude, "/b".to_string())]
12213        );
12214    }
12215
12216    #[test]
12217    fn complete_parse_replaces_superseded_identity_for_the_same_source() {
12218        let dir = tempfile::tempdir().unwrap();
12219        let db = Db::open(&dir.path().join("index.db")).unwrap();
12220        let source = std::path::Path::new("/fixture/changing-identity.jsonl");
12221        let mut old = crate::util::minimal_record(Provider::ClaudeDesktop, source, String::new());
12222        old.session.id = "claude-desktop:old".into();
12223        old.session.provider_session_id = "old".into();
12224        old.session.parse_version = "claude-desktop-local-agent-v1".into();
12225        old.messages = vec![crate::models::Message {
12226            seq: 0,
12227            role: Role::User,
12228            kind: crate::models::MessageKind::Conversation,
12229            ts: None,
12230            tool_name: None,
12231            tool_call_id: None,
12232            is_compaction: false,
12233            content: "obsolete".into(),
12234            provenance: crate::models::MessageProvenance::default(),
12235        }];
12236        db.upsert_session(&old, 1, 1).unwrap();
12237
12238        let mut current =
12239            crate::util::minimal_record(Provider::ClaudeDesktop, source, String::new());
12240        current.session.id = "claude-desktop:current".into();
12241        current.session.provider_session_id = "current".into();
12242        current.messages = vec![crate::models::Message {
12243            seq: 0,
12244            role: Role::User,
12245            kind: crate::models::MessageKind::Conversation,
12246            ts: None,
12247            tool_name: None,
12248            tool_call_id: None,
12249            is_compaction: false,
12250            content: "replacement".into(),
12251            provenance: crate::models::MessageProvenance::default(),
12252        }];
12253        db.upsert_session(&current, 2, 2).unwrap();
12254
12255        assert!(db.resolve_session_record("claude-desktop:old").is_err());
12256        assert_eq!(
12257            db.resolve_session_record("claude-desktop:current")
12258                .unwrap()
12259                .source_path,
12260            source.to_string_lossy()
12261        );
12262        assert!(db
12263            .search_messages("obsolete", &MessageFilters::default())
12264            .unwrap()
12265            .is_empty());
12266        assert!(db
12267            .is_file_current(
12268                Provider::ClaudeDesktop,
12269                &source.to_string_lossy(),
12270                2,
12271                2,
12272                crate::util::provider_parse_version(Provider::ClaudeDesktop),
12273            )
12274            .unwrap());
12275    }
12276
12277    #[test]
12278    fn source_checkpoint_version_backfill_requires_a_matching_source_path() {
12279        let dir = tempfile::tempdir().unwrap();
12280        let db = Db::open(&dir.path().join("index.db")).unwrap();
12281        let source = dir.path().join("session.jsonl");
12282        let parsed = crate::util::minimal_record(Provider::Claude, &source, String::new());
12283        db.upsert_session(&parsed, 10, 20).unwrap();
12284        db.conn
12285            .execute("update files_seen set parse_version = ''", [])
12286            .unwrap();
12287
12288        db.backfill_source_parse_versions().unwrap();
12289        assert!(db
12290            .is_file_current(
12291                Provider::Claude,
12292                &source.to_string_lossy(),
12293                10,
12294                20,
12295                crate::util::provider_parse_version(Provider::Claude),
12296            )
12297            .unwrap());
12298
12299        db.conn
12300            .execute_batch(
12301                "update sessions set source_path = '/different/source.jsonl';
12302                 update files_seen set parse_version = '';",
12303            )
12304            .unwrap();
12305        db.backfill_source_parse_versions().unwrap();
12306        assert!(!db
12307            .is_file_current(
12308                Provider::Claude,
12309                &source.to_string_lossy(),
12310                10,
12311                20,
12312                crate::util::provider_parse_version(Provider::Claude),
12313            )
12314            .unwrap());
12315    }
12316
12317    #[test]
12318    fn complete_parse_reconciles_verified_source_aliases() {
12319        let dir = tempfile::tempdir().unwrap();
12320        let db = Db::open(&dir.path().join("index.db")).unwrap();
12321        let alias = std::path::Path::new("/fixture/alias/session.jsonl");
12322        let canonical = std::path::Path::new("/fixture/canonical/session.jsonl");
12323        let mut old = crate::util::minimal_record(Provider::Claude, alias, String::new());
12324        old.session.id = "claude:old-alias".into();
12325        old.session.provider_session_id = "old-alias".into();
12326        db.upsert_session(&old, 1, 1).unwrap();
12327        let mut current = crate::util::minimal_record(Provider::Claude, canonical, String::new());
12328        current.session.id = "claude:current".into();
12329        current.session.provider_session_id = "current".into();
12330
12331        db.upsert_session_reconciling_sources(
12332            &current,
12333            2,
12334            2,
12335            &[alias.to_string_lossy().into_owned()],
12336            true,
12337        )
12338        .unwrap();
12339
12340        assert!(db.resolve_session_record("claude:old-alias").is_err());
12341        assert_eq!(db.indexed_source_identities().unwrap().len(), 1);
12342        assert_eq!(
12343            db.indexed_source_identities().unwrap()[0].1,
12344            canonical.to_string_lossy()
12345        );
12346    }
12347
12348    #[test]
12349    fn schema_backfill_flag_round_trips() {
12350        let dir = tempfile::tempdir().unwrap();
12351        let db = Db::open(&dir.path().join("index.db")).unwrap();
12352        db.conn.pragma_update(None, "user_version", 2).unwrap();
12353        assert!(db.needs_backfill().unwrap());
12354        db.mark_schema_current().unwrap();
12355        assert!(
12356            db.needs_backfill().unwrap(),
12357            "parser rows are backfilled, but the pre-v4 FTS layout is not yet current"
12358        );
12359        assert_eq!(db.schema_version().unwrap(), PRE_FTS_PARSER_SCHEMA_VERSION);
12360        db.migrate_message_search_schema_exclusive().unwrap();
12361        assert!(!db.needs_backfill().unwrap());
12362        assert_eq!(db.schema_version().unwrap(), SCHEMA_VERSION);
12363    }
12364
12365    #[test]
12366    fn mark_schema_current_preserves_the_v4_fts_gate_before_promoting_schema_5() {
12367        // Recurrence guard for the v4-stamped-but-broken hybrid schema. `mark_schema_current` runs
12368        // on the reindex fast path; it must NEVER stamp SCHEMA_VERSION over a database that has not
12369        // already reached it. Only the atomic fresh-install (init) and the atomic offline migration
12370        // — each of which builds the v4 message-search layout in the SAME transaction as the stamp —
12371        // may promote to v4. If a future edit makes this method stamp SCHEMA_VERSION unconditionally,
12372        // a pre-v4 or partially-built index would be declared "current" without its trigram objects
12373        // and dead-lock every command at open. This test fails loudly if that invariant is broken.
12374        let dir = tempfile::tempdir().unwrap();
12375        for start in [0_i64, 1, 2, PRE_FTS_PARSER_SCHEMA_VERSION] {
12376            let path = dir.path().join(format!("v{start}.db"));
12377            let db = Db::open(&path).unwrap();
12378            db.conn.pragma_update(None, "user_version", start).unwrap();
12379            db.mark_schema_current().unwrap();
12380            assert!(
12381                db.schema_version().unwrap() < SCHEMA_VERSION,
12382                "start={start}: mark_schema_current must not promote a pre-v{SCHEMA_VERSION} index to current",
12383            );
12384        }
12385        let v4 = Db::open(&dir.path().join("v4.db")).unwrap();
12386        v4.conn.pragma_update(None, "user_version", 4).unwrap();
12387        v4.mark_schema_current().unwrap();
12388        assert_eq!(v4.schema_version().unwrap(), SCHEMA_VERSION);
12389    }
12390
12391    #[test]
12392    fn clear_all_is_atomic_and_rolls_back_on_failure() {
12393        // clear_all must be all-or-nothing: a failure partway through must not leave sessions whose
12394        // messages were already deleted (a stale message_count with zero messages). Force the final
12395        // delete to fail by dropping files_seen; the transaction must roll the earlier deletes back.
12396        let dir = tempfile::tempdir().unwrap();
12397        let db = Db::open(&dir.path().join("index.db")).unwrap();
12398        db.conn
12399            .execute_batch(
12400                "insert into sessions (id, provider, provider_session_id, preview_text, source_path,
12401                     parse_version, discovery_source)
12402                     values ('s', 'claude', 's', '', '/s.jsonl', 'test', 'fixture');
12403                 insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12404                     values ('s', 'claude', 0, 'user', 'message', null, 'keep me');",
12405            )
12406            .unwrap();
12407        db.conn.execute_batch("drop table files_seen;").unwrap();
12408
12409        assert!(
12410            db.clear_all().is_err(),
12411            "clear_all should surface the failing delete"
12412        );
12413        let (sessions, messages): (i64, i64) = db
12414            .conn
12415            .query_row(
12416                "select (select count(*) from sessions), (select count(*) from messages)",
12417                [],
12418                |row| Ok((row.get(0)?, row.get(1)?)),
12419            )
12420            .unwrap();
12421        assert_eq!(
12422            (sessions, messages),
12423            (1, 1),
12424            "clear_all must roll back all deletes when one fails"
12425        );
12426    }
12427
12428    #[test]
12429    fn direct_db_open_self_enforces_v4_layout_after_triggers_dropped() {
12430        // A direct Db::open (embedder path, bypassing SessionSearch's coordinator) on a v4 database
12431        // whose dual triggers were dropped must self-enforce the layout: after open, inserting a
12432        // message must maintain messages_trigram rather than silently falling back to word-only
12433        // indexing (which would make substring/fuzzy search return incomplete results).
12434        let dir = tempfile::tempdir().unwrap();
12435        let path = dir.path().join("index.db");
12436        {
12437            let db = Db::open(&path).unwrap();
12438            db.conn
12439                .execute_batch(
12440                    "insert into sessions (id, provider, provider_session_id, preview_text,
12441                         source_path, parse_version, discovery_source)
12442                         values ('s', 'claude', 's', '', '/s.jsonl', 'test', 'fixture');
12443                     insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12444                         values ('s', 'claude', 0, 'user', 'message', null, 'existing-row');
12445                     drop trigger messages_ai;
12446                     drop trigger messages_ad;
12447                     drop trigger messages_au;",
12448                )
12449                .unwrap();
12450        }
12451
12452        // Reopen directly via Db::open — init() must heal the drifted v4 layout.
12453        let db = Db::open(&path).unwrap();
12454        assert!(
12455            crate::indexer::current_schema_layout_problem(&db.conn)
12456                .unwrap()
12457                .is_none(),
12458            "Db::open must self-enforce a consistent v4 layout"
12459        );
12460        // The restored dual trigger must index a NEW insert into messages_trigram. messages_trigram
12461        // is a detail=none FTS5 table (no phrase/MATCH queries), so observe maintenance through its
12462        // fts5vocab term table: inserting content with trigrams absent from the existing rows must
12463        // grow the distinct-term count.
12464        let terms_before: i64 = db
12465            .conn
12466            .query_row("select count(*) from messages_trigram_terms", [], |row| {
12467                row.get(0)
12468            })
12469            .unwrap();
12470        db.conn
12471            .execute_batch(
12472                "insert into messages (session_id, provider, seq, role, kind, tool_name, content)
12473                     values ('s', 'claude', 1, 'user', 'message', null, 'freshtrigramrow');",
12474            )
12475            .unwrap();
12476        let terms_after: i64 = db
12477            .conn
12478            .query_row("select count(*) from messages_trigram_terms", [], |row| {
12479                row.get(0)
12480            })
12481            .unwrap();
12482        assert!(
12483            terms_after > terms_before,
12484            "new insert must be maintained in messages_trigram (terms {terms_before} -> {terms_after})"
12485        );
12486    }
12487
12488    #[test]
12489    fn open_drops_legacy_fts5_trigram_and_self_heals() {
12490        // An in-development index may carry the old FTS5 messages_trigram; opening with the current
12491        // binary must drop it and stand up the custom trigram_index — no out-of-repo transition code
12492        // needed. (Proves resetting SCHEMA_VERSION to 1 is safe for such indexes: init() fixes the
12493        // schema objects on open regardless of user_version.)
12494        let dir = tempfile::tempdir().unwrap();
12495        let path = dir.path().join("index.db");
12496        {
12497            let db = Db::open(&path).unwrap();
12498            db.conn
12499                .execute_batch(
12500                    "drop trigger messages_ai;
12501                     drop trigger messages_ad;
12502                     drop trigger messages_au;
12503                     drop table messages_trigram_terms;
12504                     drop table messages_trigram_vocab;
12505                     drop table messages_trigram;
12506                     create virtual table messages_trigram using fts5(content, \
12507                     content='messages', content_rowid='id', tokenize='trigram', detail='none');",
12508                )
12509                .unwrap();
12510            crate::fts::install_released_message_word_index(&db.conn).unwrap();
12511            db.conn.pragma_update(None, "user_version", 3).unwrap();
12512        }
12513        let db = Db::open(&path).unwrap();
12514        let legacy: i64 = db
12515            .conn
12516            .query_row(
12517                "select count(*) from sqlite_master where name='messages_trigram'",
12518                [],
12519                |r| r.get(0),
12520            )
12521            .unwrap();
12522        assert_eq!(legacy, 0, "legacy FTS5 messages_trigram dropped on open");
12523        let custom: i64 = db
12524            .conn
12525            .query_row(
12526                "select count(*) from sqlite_master where name='trigram_postings'",
12527                [],
12528                |r| r.get(0),
12529            )
12530            .unwrap();
12531        assert_eq!(custom, 1, "custom trigram_postings present after open");
12532        seed_messages(&db, &[("user", "an econnreset row")]);
12533        let hits = db
12534            .search_messages(
12535                "econnreset",
12536                &MessageFilters {
12537                    match_mode: MessageSearchMode::Regex,
12538                    ..Default::default()
12539                },
12540            )
12541            .unwrap();
12542        assert_eq!(hits.len(), 1, "regex search works after the self-heal");
12543    }
12544
12545    #[test]
12546    fn messages_indexes_drop_redundant_singles_and_keep_composites() {
12547        let dir = tempfile::tempdir().unwrap();
12548        let path = dir.path().join("index.db");
12549        let count_index = |db: &Db, name: &str| -> i64 {
12550            db.conn
12551                .query_row(
12552                    "select count(*) from sqlite_master where type='index' and name=?1",
12553                    [name],
12554                    |row| row.get(0),
12555                )
12556                .unwrap()
12557        };
12558
12559        // Simulate an older branch build that created the now-redundant standalone indexes.
12560        {
12561            let db = Db::open(&path).unwrap();
12562            db.conn
12563                .execute_batch(
12564                    "create index if not exists idx_messages_session on messages(session_id);
12565                     create index if not exists idx_messages_role on messages(role);",
12566                )
12567                .unwrap();
12568            assert_eq!(count_index(&db, "idx_messages_session"), 1, "precondition");
12569            assert_eq!(count_index(&db, "idx_messages_role"), 1, "precondition");
12570        }
12571
12572        // Reopening runs init(), whose `drop index if exists` removes the redundant singles
12573        // (the composites subsume them by leftmost-prefix) and leaves the final index shape.
12574        let db = Db::open(&path).unwrap();
12575        assert_eq!(
12576            count_index(&db, "idx_messages_session"),
12577            0,
12578            "redundant (session_id) dropped"
12579        );
12580        assert_eq!(
12581            count_index(&db, "idx_messages_role"),
12582            0,
12583            "redundant (role) dropped"
12584        );
12585        for idx in [
12586            "idx_messages_session_seq",
12587            "idx_messages_role_ts",
12588            "idx_messages_ts",
12589        ] {
12590            assert_eq!(count_index(&db, idx), 1, "{idx} must exist");
12591        }
12592    }
12593
12594    #[test]
12595    fn hot_message_queries_use_indexes_not_full_scans() {
12596        // Performance regression guard: the hot message queries must be served by an index
12597        // (or the FTS virtual table), never a full `SCAN` of the multi-GB messages table.
12598        // We populate enough rows and run ANALYZE so the planner's choice is statistics-
12599        // driven and deterministic, matching production rather than a tiny-table heuristic.
12600        let dir = tempfile::tempdir().unwrap();
12601        let db = Db::open(&dir.path().join("index.db")).unwrap();
12602        db.conn
12603            .execute_batch(
12604                "insert into sessions(id, provider, provider_session_id, preview_text, \
12605                   source_path, parse_version, discovery_source) \
12606                 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
12607            )
12608            .unwrap();
12609        {
12610            let tx = db.conn.unchecked_transaction().unwrap();
12611            {
12612                let mut stmt = tx
12613                    .prepare(
12614                        "insert into messages(session_id, provider, seq, role, ts, content) \
12615                         values('claude:s1','claude',?1,?2,?3,?4)",
12616                    )
12617                    .unwrap();
12618                for i in 0..2000i64 {
12619                    let role = if i % 7 == 0 { "user" } else { "assistant" };
12620                    let ts = format!("2026-06-{:02}T00:00:00+00:00", (i % 28) + 1);
12621                    stmt.execute(params![i, role, ts, format!("message number {i} alpha")])
12622                        .unwrap();
12623                }
12624            }
12625            tx.commit().unwrap();
12626        }
12627        db.conn.execute_batch("analyze").unwrap();
12628
12629        // Join the EXPLAIN QUERY PLAN `detail` column (index 3) for each query.
12630        let plan = |sql: &str| -> String {
12631            let mut stmt = db
12632                .conn
12633                .prepare(&format!("explain query plan {sql}"))
12634                .unwrap();
12635            let rows = stmt.query_map([], |r| r.get::<_, String>(3)).unwrap();
12636            rows.filter_map(Result::ok).collect::<Vec<_>>().join(" | ")
12637        };
12638
12639        // 1. Content search is driven by the messages_fts index (MATCH): the FTS virtual
12640        //    table supplies matching rowids and the messages rows are fetched by INTEGER
12641        //    PRIMARY KEY — never a full scan of the messages table.
12642        let p = plan(
12643            "select m.id from messages_fts f join messages m on m.id = f.rowid \
12644             where messages_fts match 'alpha'",
12645        );
12646        assert!(
12647            p.contains("VIRTUAL TABLE INDEX"),
12648            "content search must be driven by the messages_fts index: {p}"
12649        );
12650        assert!(
12651            p.contains("USING INTEGER PRIMARY KEY") && !p.contains("SCAN m "),
12652            "messages rows must be reached by rowid from the FTS matches, not scanned: {p}"
12653        );
12654
12655        // 2. role [+ order by ts] (corrections / planning / stats) → idx_messages_role_ts.
12656        let p = plan("select content from messages where role = 'user' order by ts desc");
12657        assert!(
12658            p.contains("idx_messages_role_ts"),
12659            "role/ts query must use the composite: {p}"
12660        );
12661
12662        // 3. session_id + seq range (message get / context) → idx_messages_session_seq.
12663        let p = plan(
12664            "select content from messages where session_id = 'claude:s1' \
12665             and seq between 10 and 20 order by seq",
12666        );
12667        assert!(
12668            p.contains("idx_messages_session_seq"),
12669            "session/seq query must use the composite: {p}"
12670        );
12671
12672        // 4. provider + session-sequence page (the common CLI/MCP scoped-page path) must not
12673        // scan and sort the whole messages table before honoring LIMIT.
12674        let p = plan(
12675            "select content from messages where provider = 'claude' \
12676             order by session_id, seq limit 2",
12677        );
12678        assert!(
12679            p.contains("idx_messages_provider_session_seq") && !p.contains("USE TEMP B-TREE"),
12680            "provider-scoped pages must use the ordered provider composite without a sort: {p}"
12681        );
12682    }
12683
12684    #[test]
12685    fn messages_fts_is_rebuilt_when_empty_but_messages_exist() {
12686        let dir = tempfile::tempdir().unwrap();
12687        let path = dir.path().join("index.db");
12688        // Populate one message (triggers fill messages_fts), then simulate an index that
12689        // predates messages_fts by dropping the FTS shadow + its sync triggers.
12690        {
12691            let db = Db::open(&path).unwrap();
12692            db.conn
12693                .execute_batch(
12694                    "insert into sessions(id, provider, provider_session_id, preview_text, \
12695                       source_path, parse_version, discovery_source) \
12696                     values('claude:s1','claude','s1','','/x','claude-v1','jsonl'); \
12697                     insert into messages(session_id, provider, seq, role, content) \
12698                     values('claude:s1','claude',0,'user','findthisneedle');",
12699                )
12700                .unwrap();
12701            let hit: i64 = db
12702                .conn
12703                .query_row(
12704                    "select count(*) from messages_fts where messages_fts match 'findthisneedle'",
12705                    [],
12706                    |r| r.get(0),
12707                )
12708                .unwrap();
12709            assert_eq!(hit, 1, "precondition: triggers index the inserted message");
12710            db.conn
12711                .execute_batch(
12712                    "drop trigger messages_ai; drop trigger messages_ad; drop trigger messages_au; \
12713                     drop table messages_fts;",
12714                )
12715                .unwrap();
12716        }
12717        // Reopen: init() recreates messages_fts (empty) + triggers, and the integrity net
12718        // rebuilds it from the messages content table so search works again.
12719        let db = Db::open(&path).unwrap();
12720        let hit: i64 = db
12721            .conn
12722            .query_row(
12723                "select count(*) from messages_fts where messages_fts match 'findthisneedle'",
12724                [],
12725                |r| r.get(0),
12726            )
12727            .unwrap();
12728        assert_eq!(
12729            hit, 1,
12730            "messages_fts rebuilt on open when empty but messages exist"
12731        );
12732    }
12733
12734    #[test]
12735    fn messages_fts_count_reports_index_documents_not_content_rows() {
12736        let dir = tempfile::tempdir().unwrap();
12737        let db = Db::open(&dir.path().join("index.db")).unwrap();
12738        db.conn
12739            .execute_batch(
12740                "insert into sessions(id, provider, provider_session_id, preview_text, \
12741                   source_path, parse_version, discovery_source) \
12742                 values('claude:s1','claude','s1','','/x','claude-v1','jsonl'); \
12743                 insert into messages(session_id, provider, seq, role, content) \
12744                 values('claude:s1','claude',0,'user','indexedtoken');",
12745            )
12746            .unwrap();
12747        assert_eq!(db.message_count().unwrap(), 1);
12748        assert_eq!(db.messages_fts_count().unwrap(), 1);
12749
12750        // Simulate a broken/empty FTS index while leaving the external content table
12751        // (`messages`) populated. FTS5's external-content table view still reports the
12752        // content row; only the `_docsize` shadow exposes that no document is indexed.
12753        db.conn
12754            .execute("delete from messages_fts_docsize", [])
12755            .unwrap();
12756        let external_content_rows: i64 = db
12757            .conn
12758            .query_row("select count(*) from messages_fts", [], |r| r.get(0))
12759            .unwrap();
12760        assert_eq!(external_content_rows, 1);
12761        assert_eq!(
12762            db.messages_fts_count().unwrap(),
12763            0,
12764            "helper must report indexed docs, not external content rows"
12765        );
12766    }
12767
12768    #[test]
12769    fn substring_search_matches_inside_tokens_via_custom_index() {
12770        // The trigram prefilter matches ARBITRARY substrings (inside a token, and multi-word
12771        // phrases), built lazily by the custom trigram index on first regex use. Exercised
12772        // end-to-end via the public search_messages regex path.
12773        let dir = tempfile::tempdir().unwrap();
12774        let db = Db::open(&dir.path().join("index.db")).unwrap();
12775        seed_messages(
12776            &db,
12777            &[
12778                ("user", "the socket failed with ECONNRESET) today"),
12779                ("user", "you forgot the tests again"),
12780                ("assistant", "an unrelated message"),
12781            ],
12782        );
12783        let find = |needle: &str| -> usize {
12784            db.search_messages(
12785                needle,
12786                &MessageFilters {
12787                    match_mode: MessageSearchMode::Regex,
12788                    ..Default::default()
12789                },
12790            )
12791            .unwrap()
12792            .len()
12793        };
12794        // 'ECONNRESET' is INSIDE the token 'ECONNRESET)' — only a substring index finds it.
12795        assert_eq!(find("ECONNRESET"), 1, "substring inside a token");
12796        assert_eq!(find("you forgot"), 1, "multi-word phrase substring");
12797        assert_eq!(find("nonexistent_zz"), 0, "no false positives");
12798    }
12799
12800    #[test]
12801    fn trigram_base_rebuild_restores_searchability_including_short_docs() {
12802        // Regression: building the custom trigram base from existing content makes EVERY row
12803        // searchable — including a <3-char message that produces zero trigrams (it must not break
12804        // the build or the base_max accounting, and must not silently drop the other rows).
12805        // Exercised via the public search path, which builds the base lazily on first regex use.
12806        let dir = tempfile::tempdir().unwrap();
12807        let db = Db::open(&dir.path().join("index.db")).unwrap();
12808        enable_v3_custom_trigram_compatibility(&db);
12809        seed_messages(
12810            &db,
12811            &[
12812                ("user", "alpha contains zebracode here"),
12813                ("user", "bravo contains zebracode too"),
12814                ("user", "charlie has zebracode as well"),
12815                ("user", "ok"), // zero-trigram short doc must not break the build
12816            ],
12817        );
12818        let hits = db
12819            .search_messages(
12820                "zebracode",
12821                &MessageFilters {
12822                    match_mode: MessageSearchMode::Regex,
12823                    ..Default::default()
12824                },
12825            )
12826            .unwrap();
12827        assert_eq!(
12828            hits.len(),
12829            3,
12830            "every zebracode row searchable after base build"
12831        );
12832        assert_eq!(
12833            crate::trigram_index::base_max_id(&db.conn).unwrap(),
12834            4,
12835            "base covers all messages including the zero-trigram short doc"
12836        );
12837    }
12838
12839    #[test]
12840    fn messages_fts_updates_are_transactional_with_messages() {
12841        // #235 RAII / crash-safety: the messages_fts trigger updates are atomic with the message
12842        // rows. A rolled-back message insert must leave NEITHER a message row NOR an FTS entry — the
12843        // trigger writes participate in the surrounding transaction and unwind on rollback.
12844        let dir = tempfile::tempdir().unwrap();
12845        let db = Db::open(&dir.path().join("index.db")).unwrap();
12846        db.conn
12847            .execute_batch(
12848                "insert into sessions(id, provider, provider_session_id, preview_text, \
12849                   source_path, parse_version, discovery_source) \
12850                 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
12851            )
12852            .unwrap();
12853        let before = db.messages_fts_count().unwrap();
12854        {
12855            // Open a transaction, insert a message (the ai trigger indexes it into messages_fts),
12856            // then DROP the tx without committing → rollback.
12857            let tx = db.conn.unchecked_transaction().unwrap();
12858            tx.execute(
12859                "insert into messages(session_id, provider, seq, role, content) \
12860                 values('claude:s1','claude',0,'user','rollbackme token here')",
12861                [],
12862            )
12863            .unwrap();
12864        }
12865        assert_eq!(
12866            db.messages_fts_count().unwrap(),
12867            before,
12868            "rolled-back insert leaves no messages_fts entry"
12869        );
12870        let hit: i64 = db
12871            .conn
12872            .query_row(
12873                "select count(*) from messages_fts where messages_fts match 'rollbackme'",
12874                [],
12875                |r| r.get(0),
12876            )
12877            .unwrap();
12878        assert_eq!(
12879            hit, 0,
12880            "rolled-back content is not searchable via messages_fts"
12881        );
12882    }
12883
12884    #[test]
12885    fn storage_allocation_reports_freed_pages_without_scanning_content() {
12886        let dir = tempfile::tempdir().unwrap();
12887        let db = Db::open(&dir.path().join("index.db")).unwrap();
12888        db.conn
12889            .execute_batch(
12890                "create table allocation_probe (payload blob not null);
12891                 insert into allocation_probe(payload) values(zeroblob(2097152));
12892                 delete from allocation_probe;",
12893            )
12894            .unwrap();
12895
12896        let allocation = db.storage_allocation().unwrap();
12897        assert!(allocation.total_bytes >= allocation.reclaimable_bytes);
12898        assert!(
12899            allocation.reclaimable_bytes > 0,
12900            "deleted overflow pages must be reported as reclaimable"
12901        );
12902    }
12903
12904    #[test]
12905    fn replacement_freelist_compaction_preserves_rows_fts_and_search_results() {
12906        let dir = tempfile::tempdir().unwrap();
12907        let db = Db::open(&dir.path().join("index.db")).unwrap();
12908        let large_suffix = " allocation-padding".repeat(32_768);
12909
12910        for index in 0..6 {
12911            let id = format!("claude:allocation-{index}");
12912            let content = format!("original-token-{index}{large_suffix}");
12913            let mut parsed = parsed_with_messages(&id, &[&content]);
12914            parsed.session.provider_session_id = format!("allocation-{index}");
12915            parsed.session.source_path = format!("/allocation/{index}.jsonl");
12916            db.replace_session(&parsed, index, index).unwrap();
12917        }
12918        assert_eq!(db.message_count().unwrap(), 6);
12919        assert_eq!(db.messages_fts_count().unwrap(), 6);
12920
12921        for index in 0..3 {
12922            let id = format!("claude:allocation-{index}");
12923            let content = format!("replacement-token-{index}");
12924            let mut parsed = parsed_with_messages(&id, &[&content]);
12925            parsed.session.provider_session_id = format!("allocation-{index}");
12926            parsed.session.source_path = format!("/allocation/{index}.jsonl");
12927            db.replace_session(&parsed, 10 + index, 10 + index).unwrap();
12928        }
12929        db.optimize_fts().unwrap();
12930
12931        let before = db.storage_allocation().unwrap();
12932        assert!(before.reclaimable_bytes > 0);
12933        assert_eq!(db.message_count().unwrap(), 6);
12934        assert_eq!(db.messages_fts_count().unwrap(), 6);
12935        for index in 0..3 {
12936            assert_eq!(
12937                db.search_messages(
12938                    &format!("replacement-token-{index}"),
12939                    &MessageFilters::default(),
12940                )
12941                .unwrap()
12942                .len(),
12943                1
12944            );
12945        }
12946
12947        db.vacuum().unwrap();
12948        db.checkpoint_truncate().unwrap();
12949
12950        let after = db.storage_allocation().unwrap();
12951        assert_eq!(after.reclaimable_bytes, 0);
12952        assert!(after.total_bytes < before.total_bytes);
12953        assert_eq!(db.message_count().unwrap(), 6);
12954        assert_eq!(db.messages_fts_count().unwrap(), 6);
12955        for index in 0..3 {
12956            assert_eq!(
12957                db.search_messages(
12958                    &format!("replacement-token-{index}"),
12959                    &MessageFilters::default(),
12960                )
12961                .unwrap()
12962                .len(),
12963                1
12964            );
12965        }
12966    }
12967
12968    #[test]
12969    fn session_replacement_rolls_back_every_index_when_sqlite_is_full() {
12970        let dir = tempfile::tempdir().unwrap();
12971        let db = Db::open(&dir.path().join("index.db")).unwrap();
12972        let mut original = parsed_with_messages("claude:s1", &["original durable token"]);
12973        original.session.title = Some("original title".into());
12974        db.replace_session(&original, 1, 1).unwrap();
12975        db.checkpoint_truncate().unwrap();
12976
12977        let original_max_pages: i64 = db
12978            .conn
12979            .query_row("pragma max_page_count", [], |row| row.get(0))
12980            .unwrap();
12981        let current_pages: i64 = db
12982            .conn
12983            .query_row("pragma page_count", [], |row| row.get(0))
12984            .unwrap();
12985        let free_pages: i64 = db
12986            .conn
12987            .query_row("pragma freelist_count", [], |row| row.get(0))
12988            .unwrap();
12989        let page_size: i64 = db
12990            .conn
12991            .query_row("pragma page_size", [], |row| row.get(0))
12992            .unwrap();
12993        db.conn
12994            .pragma_update(None, "max_page_count", current_pages)
12995            .unwrap();
12996
12997        let mut replacement = original.clone();
12998        replacement.session.title = Some("replacement title".into());
12999        const REQUIRED_GROWTH_PAGES: i64 = 2;
13000        const REPLACEMENT_TOKEN: &str = "replacement-full-token ";
13001        let required_bytes = (free_pages + REQUIRED_GROWTH_PAGES) * page_size;
13002        let repeats = required_bytes as usize / REPLACEMENT_TOKEN.len() + 1;
13003        replacement.transcript_text = REPLACEMENT_TOKEN.repeat(repeats);
13004        replacement.messages[0].content = replacement.transcript_text.clone();
13005        let error = db.replace_session(&replacement, 2, 2).unwrap_err();
13006
13007        db.conn
13008            .pragma_update(None, "max_page_count", original_max_pages)
13009            .unwrap();
13010        assert!(error.chain().any(|cause| {
13011            cause
13012                .downcast_ref::<rusqlite::Error>()
13013                .and_then(rusqlite::Error::sqlite_error_code)
13014                == Some(ErrorCode::DiskFull)
13015        }));
13016
13017        let persisted = db.resolve_session("claude:s1").unwrap();
13018        assert_eq!(persisted.session.title.as_deref(), Some("original title"));
13019        assert_eq!(persisted.transcript_text, "original durable token");
13020        assert_eq!(
13021            db.search_messages("original durable token", &MessageFilters::default())
13022                .unwrap()
13023                .len(),
13024            1
13025        );
13026        assert!(db
13027            .search_messages("replacement-full-token", &MessageFilters::default())
13028            .unwrap()
13029            .is_empty());
13030    }
13031
13032    #[test]
13033    fn generated_trigram_query_is_a_superset_in_the_custom_index() {
13034        // P0b (closes the R1 gap): build the ACTUAL custom trigram index and assert its candidate
13035        // set (via the structured trigram_prefilter_groups) is a SUPERSET of regex matches.
13036        let dir = tempfile::tempdir().unwrap();
13037        let db = Db::open(&dir.path().join("index.db")).unwrap();
13038        db.conn
13039            .execute_batch(
13040                "insert into sessions(id, provider, provider_session_id, preview_text, \
13041                   source_path, parse_version, discovery_source) \
13042                 values('claude:s1','claude','s1','','/x','claude-v1','jsonl');",
13043            )
13044            .unwrap();
13045        let rows = [
13046            "you forgot the tests",
13047            "well You Forgot it",
13048            "no, that's wrong",
13049            "we also need more coverage",
13050            "socket hang up ECONNRESET here",
13051            "please stop doing that",
13052            "totally unrelated message",
13053            "scatter the cats", // contains 'cat' as a substring but no word boundary
13054        ];
13055        {
13056            let tx = db.conn.unchecked_transaction().unwrap();
13057            {
13058                let mut stmt = tx
13059                    .prepare(
13060                        "insert into messages(session_id, provider, seq, role, content) \
13061                         values('claude:s1','claude',?1,'user',?2)",
13062                    )
13063                    .unwrap();
13064                for (i, row) in rows.iter().enumerate() {
13065                    stmt.execute(params![i as i64, row]).unwrap();
13066                }
13067            }
13068            tx.commit().unwrap();
13069        }
13070        crate::trigram_index::build(&db.conn, &db.runtime).unwrap();
13071        let patterns = [
13072            r"\byou forgot\b",
13073            r"\bno,?\s+that'?s\b",
13074            r"\balso need\b",
13075            "ECONNRESET",
13076            r"\bstop doing\b",
13077            r"\bcat\b", // matches none (no boundary), but candidate "scatter the cats" is a superset
13078        ];
13079        for pat in patterns {
13080            let regex = regex::Regex::new(&format!("(?i){pat}")).unwrap();
13081            // Ground truth: ids whose content the regex matches.
13082            let expected: Vec<i64> = {
13083                let mut stmt = db.conn.prepare("select id, content from messages").unwrap();
13084                let iter = stmt
13085                    .query_map([], |row| {
13086                        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
13087                    })
13088                    .unwrap();
13089                iter.filter_map(Result::ok)
13090                    .filter(|(_, c)| regex.is_match(c))
13091                    .map(|(id, _)| id)
13092                    .collect()
13093            };
13094            if let Some(groups) = crate::trigram::trigram_prefilter_groups(pat) {
13095                let candidates = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
13096                for id in &expected {
13097                    assert!(
13098                        candidates.contains(id),
13099                        "SUPERSET VIOLATION: {pat:?} -> {groups:?} missed id {id}",
13100                    );
13101                }
13102            }
13103            // When None, the caller falls back to a scan, which is trivially a superset.
13104        }
13105    }
13106
13107    #[test]
13108    fn detail_mode_comparison_like_on_external_content() {
13109        // #230: empirically compare the trigram methods for an EXTERNAL-CONTENT table over
13110        // `messages`, to ground the #231 decision with real numbers (NOT the multi-GB real DB):
13111        //   (A) detail='full' + MATCH phrase  — the original baseline (positions stored; biggest).
13112        //   (B) detail='none' + content LIKE  — works on external content (FTS5 fetches the value
13113        //       from `messages` to reject false-positives; the SQLite forum notes LIKE/GLOB *fails*
13114        //       on fully *contentless* tables, which ours is not).
13115        //   (C) detail='none' + AND-of-trigrams MATCH — THE METHOD WE ADOPTED. The prefilter never
13116        //       needs adjacency (the regex re-verifies), so trigrams are ANDed as independent terms
13117        //       instead of a phrase; that reads only doclists, so detail='none' (no positions) is
13118        //       sufficient and ~3-5x smaller. This test asserts (C) returns the right rows on a
13119        //       real detail='none' external-content table and reports the size delta vs (A).
13120        let dir = tempfile::tempdir().unwrap();
13121        let size_of = |variant: &str, detail_clause: &str| -> (i64, rusqlite::Connection) {
13122            let conn =
13123                rusqlite::Connection::open(dir.path().join(format!("{variant}.db"))).unwrap();
13124            conn.execute_batch(
13125                "create table messages(id integer primary key, content text not null);",
13126            )
13127            .unwrap();
13128            {
13129                let tx = conn.unchecked_transaction().unwrap();
13130                {
13131                    let mut stmt = tx
13132                        .prepare("insert into messages(content) values(?1)")
13133                        .unwrap();
13134                    stmt.execute(["the socket failed with ECONNRESET) today"])
13135                        .unwrap();
13136                    stmt.execute(["you forgot the tests again"]).unwrap();
13137                    stmt.execute(["an unrelated assistant message"]).unwrap();
13138                    // Filler so the index-size delta between the two detail modes is measurable.
13139                    for i in 0..3000 {
13140                        stmt.execute([format!(
13141                            "filler row {i} lorem ipsum dolor sit amet consectetur adipiscing"
13142                        )])
13143                        .unwrap();
13144                    }
13145                }
13146                tx.commit().unwrap();
13147            }
13148            conn.execute_batch(&format!(
13149                "create virtual table tri using fts5(content, content='messages', \
13150                   content_rowid='id', tokenize='trigram'{detail_clause}); \
13151                 insert into tri(tri) values('rebuild');",
13152            ))
13153            .unwrap();
13154            let pages: i64 = conn
13155                .query_row("pragma page_count", [], |r| r.get(0))
13156                .unwrap();
13157            let page_size: i64 = conn
13158                .query_row("pragma page_size", [], |r| r.get(0))
13159                .unwrap();
13160            (pages * page_size, conn)
13161        };
13162
13163        let (full_bytes, full) = size_of("full", "");
13164        let (none_bytes, none) = size_of("none", ", detail='none'");
13165
13166        // (A) detail='full' + MATCH: substring inside a token + multi-word phrase.
13167        let full_match = |q: &str| -> i64 {
13168            full.query_row("select count(*) from tri where tri match ?1", [q], |r| {
13169                r.get(0)
13170            })
13171            .unwrap()
13172        };
13173        assert_eq!(
13174            full_match("\"econnreset\""),
13175            1,
13176            "detail=full MATCH substring-in-token"
13177        );
13178        assert_eq!(
13179            full_match("\"you forgot\""),
13180            1,
13181            "detail=full MATCH multi-word phrase"
13182        );
13183
13184        // (B) THE key question: detail='none' + LIKE on EXTERNAL content. If FTS5 fetches the
13185        // value from `messages` to verify, these return the correct row; if it behaves like a
13186        // contentless table, they return 0 and detail='none'+LIKE is NOT viable here.
13187        let none_like = |needle: &str| -> i64 {
13188            none.query_row(
13189                "select count(*) from tri where content like ?1 escape '\\'",
13190                [format!("%{needle}%")],
13191                |r| r.get(0),
13192            )
13193            .unwrap()
13194        };
13195        assert_eq!(
13196            none_like("econnreset"),
13197            1,
13198            "detail=none LIKE substring-in-token MUST work on external content",
13199        );
13200        assert_eq!(
13201            none_like("you forgot"),
13202            1,
13203            "detail=none LIKE multi-word phrase MUST work on external content",
13204        );
13205        // A non-existent substring must return nothing (guards against a silent full match).
13206        assert_eq!(
13207            none_like("zzqqxx_absent"),
13208            0,
13209            "detail=none LIKE rejects absent substring"
13210        );
13211
13212        // (C) detail='none' + AND-of-trigrams MATCH — THE PRODUCTION METHOD. Build the query the
13213        // way `trigram_prefilter` does (boolean AND of the needle's 3-grams, no phrase) and run it
13214        // against the real detail='none' table. It must return the matching row(s) (a SUPERSET the
13215        // caller's regex then verifies) and reject an absent needle — proving detail='none' is
13216        // sufficient for our prefilter without the positions that detail='full' would store.
13217        let none_match = |needle: &str| -> i64 {
13218            let query = crate::trigram::trigram_prefilter(needle)
13219                .unwrap_or_else(|| panic!("needle {needle:?} must be prefilterable"));
13220            none.query_row(
13221                "select count(*) from tri where tri match ?1",
13222                [query],
13223                |r| r.get(0),
13224            )
13225            .unwrap()
13226        };
13227        assert!(
13228            none_match("econnreset") >= 1,
13229            "detail=none AND-of-trigrams MATCH finds substring-in-token on external content",
13230        );
13231        assert!(
13232            none_match("you forgot") >= 1,
13233            "detail=none AND-of-trigrams MATCH finds multi-word substring on external content",
13234        );
13235        assert_eq!(
13236            none_match("zzqqwwxx"),
13237            0,
13238            "detail=none AND-of-trigrams MATCH rejects an absent needle",
13239        );
13240
13241        // (D) Confirm LIKE actually engages the trigram index rather than scanning `messages`.
13242        let plan: String = {
13243            let mut stmt = none
13244                .prepare(
13245                    "explain query plan select rowid from tri \
13246                     where content like '%econnreset%' escape '\\'",
13247                )
13248                .unwrap();
13249            let rows = stmt
13250                .query_map([], |r| r.get::<_, String>(3))
13251                .unwrap()
13252                .filter_map(Result::ok)
13253                .collect::<Vec<_>>()
13254                .join(" | ");
13255            rows
13256        };
13257        // (E) Report the measured deltas for the #231 decision.
13258        eprintln!(
13259            "[#230] index size: detail=full={}KB  detail=none={}KB  (full is {:.1}x none)",
13260            full_bytes / 1024,
13261            none_bytes / 1024,
13262            full_bytes as f64 / none_bytes.max(1) as f64,
13263        );
13264        eprintln!("[#230] detail=none LIKE query plan: {plan}");
13265        assert!(
13266            !plan.to_lowercase().contains("scan messages"),
13267            "detail=none LIKE should not linear-scan the messages table; plan was: {plan}",
13268        );
13269    }
13270
13271    #[test]
13272    fn regex_search_composes_with_role_and_session_scope() {
13273        // Each search scans only its needed SUBSET: a --regex query restricts via role / session
13274        // filters (the trigram prefilter is a SUPERSET the Rust regex re-verifies). Exercised via
13275        // the public search_messages path, which uses the custom trigram index (built lazily).
13276        let dir = tempfile::tempdir().unwrap();
13277        let db = Db::open(&dir.path().join("index.db")).unwrap();
13278        db.conn
13279            .execute_batch(
13280                "insert into sessions(id, provider, provider_session_id, preview_text, \
13281                   source_path, parse_version, discovery_source) values \
13282                   ('claude:a','claude','a','','/x','v1','jsonl'), \
13283                   ('claude:b','claude','b','','/x','v1','jsonl');",
13284            )
13285            .unwrap();
13286        {
13287            let tx = db.conn.unchecked_transaction().unwrap();
13288            {
13289                let mut stmt = tx
13290                    .prepare(
13291                        "insert into messages(session_id, provider, seq, role, content) \
13292                         values(?1,'claude',?2,?3,?4)",
13293                    )
13294                    .unwrap();
13295                stmt.execute(params!["claude:a", 0, "user", "needle_xyz in a user"])
13296                    .unwrap();
13297                stmt.execute(params![
13298                    "claude:a",
13299                    1,
13300                    "assistant",
13301                    "needle_xyz in a assistant"
13302                ])
13303                .unwrap();
13304                stmt.execute(params!["claude:b", 0, "user", "needle_xyz in b user"])
13305                    .unwrap();
13306            }
13307            tx.commit().unwrap();
13308        }
13309        let count = |role: Option<Role>, session_id: Option<&str>| -> usize {
13310            db.search_messages(
13311                "needle_xyz",
13312                &MessageFilters {
13313                    match_mode: MessageSearchMode::Regex,
13314                    role,
13315                    session_id: session_id.map(str::to_string),
13316                    ..Default::default()
13317                },
13318            )
13319            .unwrap()
13320            .len()
13321        };
13322        assert_eq!(count(None, None), 3, "unscoped: all three rows");
13323        assert_eq!(
13324            count(Some(Role::User), None),
13325            2,
13326            "role scope narrows to user rows"
13327        );
13328        assert_eq!(
13329            count(None, Some("claude:a")),
13330            2,
13331            "session scope narrows to session a"
13332        );
13333        assert_eq!(
13334            count(Some(Role::User), Some("claude:a")),
13335            1,
13336            "role+session scope composes",
13337        );
13338    }
13339
13340    #[test]
13341    fn trigram_search_correct_across_all_providers() {
13342        // #234: the trigram index + the real trigram_prefilter() generator return correct results
13343        // for every harness's content SHAPE — dense JSON (claude tool), code+markdown (codex),
13344        // short text (pi), unicode (antigravity), and through provider scoping.
13345        let dir = tempfile::tempdir().unwrap();
13346        let db = Db::open(&dir.path().join("index.db")).unwrap();
13347        for p in [
13348            "claude",
13349            "claude-desktop",
13350            "codex",
13351            "cursor",
13352            "antigravity",
13353            "pi",
13354        ] {
13355            db.conn
13356                .execute(
13357                    "insert into sessions(id, provider, provider_session_id, preview_text, \
13358                       source_path, parse_version, discovery_source) \
13359                     values(?1, ?2, 's', '', '/x', 'v1', 'jsonl')",
13360                    params![format!("{p}:s"), p],
13361                )
13362                .unwrap();
13363        }
13364        // Each provider-shaped message contains 'ECONNRESET' inside a token; only the cursor one
13365        // also contains the correction phrase 'you forgot'.
13366        let rows: &[(&str, &str, &str)] = &[
13367            (
13368                "claude",
13369                "tool",
13370                r#"{"type":"tool_result","content":"net error ECONNRESET) deploy"}"#,
13371            ),
13372            (
13373                "claude-desktop",
13374                "assistant",
13375                "Desktop local agent saw ECONNRESET too",
13376            ),
13377            (
13378                "codex",
13379                "assistant",
13380                "```rust\nconnect()?; // ECONNRESET retry\n```",
13381            ),
13382            ("cursor", "user", "hey you forgot the ECONNRESET retry path"),
13383            (
13384                "antigravity",
13385                "assistant",
13386                "MODEL: ECONNRESET observed — naïve café résumé",
13387            ),
13388            ("pi", "user", "ECONNRESET again"),
13389        ];
13390        {
13391            let tx = db.conn.unchecked_transaction().unwrap();
13392            {
13393                let mut stmt = tx
13394                    .prepare(
13395                        "insert into messages(session_id, provider, seq, role, content) \
13396                         values(?1,?2,?3,?4,?5)",
13397                    )
13398                    .unwrap();
13399                for (i, (p, role, content)) in rows.iter().enumerate() {
13400                    stmt.execute(params![format!("{p}:s"), p, i as i64, role, content])
13401                        .unwrap();
13402                }
13403            }
13404            tx.commit().unwrap();
13405        }
13406        // Substring 'ECONNRESET' (inside JSON / code / plain / unicode) hits ALL providers via the
13407        // public regex search (custom trigram index, lazily built on first use).
13408        let providers_for = |query: &str, filters: MessageFilters| -> Vec<String> {
13409            let mut got: Vec<String> = db
13410                .search_messages(query, &filters)
13411                .unwrap()
13412                .into_iter()
13413                .map(|h| h.provider.as_str().to_string())
13414                .collect();
13415            got.sort();
13416            got
13417        };
13418        let all = providers_for(
13419            "ECONNRESET",
13420            MessageFilters {
13421                match_mode: MessageSearchMode::Regex,
13422                ..Default::default()
13423            },
13424        );
13425        assert_eq!(
13426            all.len(),
13427            6,
13428            "every provider's ECONNRESET found regardless of content shape"
13429        );
13430        let claude = providers_for(
13431            "ECONNRESET",
13432            MessageFilters {
13433                match_mode: MessageSearchMode::Regex,
13434                providers: Some(vec![Provider::Claude]),
13435                ..Default::default()
13436            },
13437        );
13438        assert_eq!(
13439            claude,
13440            vec!["claude"],
13441            "provider scope restricts to the claude message"
13442        );
13443        let claude_desktop = providers_for(
13444            "ECONNRESET",
13445            MessageFilters {
13446                match_mode: MessageSearchMode::Regex,
13447                providers: Some(vec![Provider::ClaudeDesktop]),
13448                ..Default::default()
13449            },
13450        );
13451        assert_eq!(
13452            claude_desktop,
13453            vec!["claude-desktop"],
13454            "provider scope restricts to the claude-desktop message"
13455        );
13456        // The correction phrase 'you forgot' appears only in the cursor message.
13457        let forgot = providers_for(
13458            r"\byou forgot\b",
13459            MessageFilters {
13460                match_mode: MessageSearchMode::Regex,
13461                ..Default::default()
13462            },
13463        );
13464        assert_eq!(
13465            forgot,
13466            vec!["cursor"],
13467            "you-forgot regex selects exactly cursor"
13468        );
13469    }
13470
13471    /// Insert one claude session + the given (seq, role, content) rows for the wiring tests.
13472    #[cfg(test)]
13473    fn seed_messages(db: &Db, rows: &[(&str, &str)]) {
13474        db.conn
13475            .execute_batch(
13476                "insert into sessions(id, provider, provider_session_id, preview_text, \
13477                   source_path, parse_version, discovery_source) \
13478                 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13479            )
13480            .unwrap();
13481        let tx = db.conn.unchecked_transaction().unwrap();
13482        {
13483            let mut stmt = tx
13484                .prepare(
13485                    "insert into messages(session_id, provider, seq, role, content) \
13486                     values('claude:s1','claude',?1,?2,?3)",
13487                )
13488                .unwrap();
13489            for (i, (role, content)) in rows.iter().enumerate() {
13490                stmt.execute(params![i as i64, role, content]).unwrap();
13491            }
13492        }
13493        tx.commit().unwrap();
13494    }
13495
13496    #[test]
13497    fn purge_injected_messages_removes_only_leading_marker_user_rows() {
13498        let dir = tempfile::tempdir().unwrap();
13499        let db = Db::open(&dir.path().join("index.db")).unwrap();
13500        seed_messages(
13501            &db,
13502            &[
13503                (
13504                    "user",
13505                    "<local-command-stdout>Set model to Opus</local-command-stdout>",
13506                ), // purge
13507                ("user", "<local-command-stderr>boom</local-command-stderr>"), // purge
13508                (
13509                    "user",
13510                    "<environment_context>\n<current_date>2026</current_date>",
13511                ), // purge
13512                (
13513                    "user",
13514                    "<turn_aborted>\nThe user interrupted the previous turn on purpose.",
13515                ), // purge
13516                ("user", "fix the failing login test before release"),         // KEEP (prompt)
13517                ("user", "what does <local-command-stdout> mean in the logs"), // KEEP (not leading)
13518                (
13519                    "user",
13520                    "what does <turn_aborted> mean in a Codex transcript",
13521                ), // KEEP (not leading)
13522                (
13523                    "assistant",
13524                    "<local-command-stdout>tool output</local-command-stdout>",
13525                ), // KEEP (not user)
13526            ],
13527        );
13528        let before = db.message_count().unwrap();
13529        let purged = db.purge_injected_messages().unwrap();
13530        assert_eq!(purged, 4, "the four leading-marker USER rows are deleted");
13531        assert_eq!(db.message_count().unwrap(), before - 4);
13532        // FTS + trigram stay in sync via the delete triggers.
13533        assert_eq!(
13534            db.messages_fts_count().unwrap(),
13535            db.message_count().unwrap()
13536        );
13537        let users: Vec<String> = db
13538            .search_messages(
13539                "",
13540                &MessageFilters {
13541                    role: Some(Role::User),
13542                    ..Default::default()
13543                },
13544            )
13545            .unwrap()
13546            .into_iter()
13547            .map(|h| h.content)
13548            .collect();
13549        assert!(
13550            users
13551                .iter()
13552                .any(|c| c.contains("fix the failing login test")),
13553            "real prompt kept"
13554        );
13555        assert!(
13556            users
13557                .iter()
13558                .any(|c| c.starts_with("what does <local-command-stdout>")),
13559            "a non-leading mention is kept"
13560        );
13561        assert_eq!(
13562            users.len(),
13563            3,
13564            "exactly the three legitimate user messages remain"
13565        );
13566    }
13567
13568    #[test]
13569    fn regex_search_lazily_builds_custom_trigram_base_and_is_correct() {
13570        // The --regex path must call ensure_trigram_base() so an UNBUILT custom index (e.g. right
13571        // after a fresh reindex that does no trigram work) does not silently drop matches: the
13572        // search builds the base on first use and returns the correct row.
13573        let dir = tempfile::tempdir().unwrap();
13574        let db = Db::open(&dir.path().join("index.db")).unwrap();
13575        enable_v3_custom_trigram_compatibility(&db);
13576        seed_messages(&db, &[("user", "socket failed with ECONNRESET) today")]);
13577        // Precondition: the custom base index has not been built yet (lazy by construction).
13578        assert_eq!(
13579            crate::trigram_index::base_max_id(&db.conn).unwrap(),
13580            0,
13581            "precondition: custom trigram base not built before first regex use"
13582        );
13583        let filters = MessageFilters {
13584            match_mode: MessageSearchMode::Regex,
13585            ..Default::default()
13586        };
13587        let hits = db.search_messages("ECONNRESET", &filters).unwrap();
13588        assert_eq!(
13589            hits.len(),
13590            1,
13591            "regex search returns the match despite an unbuilt index"
13592        );
13593        assert!(
13594            crate::trigram_index::base_max_id(&db.conn).unwrap() > 0,
13595            "regex search lazily built the custom trigram base index"
13596        );
13597    }
13598
13599    #[test]
13600    fn lazy_trigram_build_busy_writer_falls_back_to_delta_scan() {
13601        let dir = tempfile::tempdir().unwrap();
13602        let path = dir.path().join("index.db");
13603        let writer = Db::open(&path).unwrap();
13604        let reader = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
13605        seed_messages(
13606            &writer,
13607            &[
13608                (
13609                    "user",
13610                    "the deploy hit ECONNRESET while the trigram base was empty",
13611                ),
13612                ("assistant", "ack"),
13613            ],
13614        );
13615        assert_eq!(
13616            crate::trigram_index::base_max_id(&reader.conn).unwrap(),
13617            0,
13618            "precondition: custom trigram base starts empty"
13619        );
13620
13621        writer.conn.execute_batch("begin immediate").unwrap();
13622        let filters = MessageFilters {
13623            match_mode: MessageSearchMode::Regex,
13624            ..Default::default()
13625        };
13626        let hits = reader.search_messages("ECONNRESET", &filters).unwrap();
13627        writer.conn.execute_batch("rollback").unwrap();
13628
13629        assert_eq!(hits.len(), 1, "busy lazy build must not drop regex hits");
13630        assert_eq!(
13631            crate::trigram_index::base_max_id(&reader.conn).unwrap(),
13632            0,
13633            "busy fallback serves the existing base and leaves rebuild for a later query"
13634        );
13635    }
13636
13637    #[test]
13638    fn progress_reporter_fires_on_lazy_build_only_and_is_silent_when_unset() {
13639        use std::sync::atomic::{AtomicU32, Ordering};
13640        use std::sync::Arc;
13641        // Unset reporter: the library builds silently (no panic, no I/O), returns base_max.
13642        let dir = tempfile::tempdir().unwrap();
13643        let silent = Db::open(&dir.path().join("a.db")).unwrap();
13644        enable_v3_custom_trigram_compatibility(&silent);
13645        seed_messages(&silent, &[("user", "econnreset here")]);
13646        assert_eq!(silent.ensure_trigram_base().unwrap(), 1);
13647
13648        // Injected reporter: fires exactly once, when (and only when) a build happens.
13649        let dir2 = tempfile::tempdir().unwrap();
13650        let mut db = Db::open(&dir2.path().join("b.db")).unwrap();
13651        enable_v3_custom_trigram_compatibility(&db);
13652        let calls = Arc::new(AtomicU32::new(0));
13653        let counter = Arc::clone(&calls);
13654        db.set_progress_reporter(move |_msg| {
13655            counter.fetch_add(1, Ordering::Relaxed);
13656        });
13657        seed_messages(&db, &[("user", "econnreset here")]);
13658        assert_eq!(db.ensure_trigram_base().unwrap(), 1);
13659        assert_eq!(
13660            calls.load(Ordering::Relaxed),
13661            1,
13662            "reporter fires once for the one-time build"
13663        );
13664        db.ensure_trigram_base().unwrap();
13665        assert_eq!(
13666            calls.load(Ordering::Relaxed),
13667            1,
13668            "no report when the base is already current"
13669        );
13670    }
13671
13672    #[test]
13673    fn search_messages_regex_prunes_lookaround_and_falls_back() {
13674        // #223 correctness: the prefilter only NARROWS; the Rust regex still verifies, so a
13675        // trigram candidate the full regex rejects (look-around) is pruned. Non-prefilterable
13676        // patterns (no >=3-char literal) fall back to a scan and stay correct.
13677        let dir = tempfile::tempdir().unwrap();
13678        let db = Db::open(&dir.path().join("index.db")).unwrap();
13679        seed_messages(
13680            &db,
13681            &[
13682                ("user", "socket failed with ECONNRESET) today"),
13683                ("user", "you forgot the tests"),
13684                ("assistant", "scatter the cats"), // contains 'cat' but NOT \bcat\b
13685                ("user", "a cat sat here"),        // matches \bcat\b
13686                ("assistant", "totally unrelated text 1234"),
13687            ],
13688        );
13689        let run = |pattern: &str| -> Vec<String> {
13690            let filters = MessageFilters {
13691                match_mode: MessageSearchMode::Regex,
13692                ..Default::default()
13693            };
13694            let mut got: Vec<String> = db
13695                .search_messages(pattern, &filters)
13696                .unwrap()
13697                .into_iter()
13698                .map(|h| h.content)
13699                .collect();
13700            got.sort();
13701            got
13702        };
13703        assert_eq!(
13704            run(r"\bcat\b"),
13705            vec!["a cat sat here".to_string()],
13706            "look-around pruned"
13707        );
13708        assert_eq!(
13709            run("ECONNRESET"),
13710            vec!["socket failed with ECONNRESET) today".to_string()],
13711            "substring inside a token",
13712        );
13713        assert_eq!(
13714            run(r"\byou forgot\b"),
13715            vec!["you forgot the tests".to_string()],
13716            "phrase"
13717        );
13718        assert_eq!(
13719            run(r"\d{4}"),
13720            vec!["totally unrelated text 1234".to_string()],
13721            "non-prefilterable pattern falls back to scan, still correct",
13722        );
13723        assert!(run(r"[0-9]{9}").is_empty(), "non-prefilterable, no match");
13724    }
13725
13726    #[test]
13727    fn search_messages_provider_filter_scopes() {
13728        // #223 --provider scope: the new provider filter restricts results to one harness.
13729        let dir = tempfile::tempdir().unwrap();
13730        let db = Db::open(&dir.path().join("index.db")).unwrap();
13731        for p in ["claude", "codex"] {
13732            db.conn
13733                .execute(
13734                    "insert into sessions(id, provider, provider_session_id, preview_text, \
13735                       source_path, parse_version, discovery_source) \
13736                     values(?1, ?2, 's', '', '/x', 'v1', 'jsonl')",
13737                    params![format!("{p}:s"), p],
13738                )
13739                .unwrap();
13740            db.conn
13741                .execute(
13742                    "insert into messages(session_id, provider, seq, role, content) \
13743                     values(?1, ?2, 0, 'user', 'shared ECONNRESET token')",
13744                    params![format!("{p}:s"), p],
13745                )
13746                .unwrap();
13747        }
13748        let scoped = |provider: Option<Provider>| -> usize {
13749            let filters = MessageFilters {
13750                match_mode: MessageSearchMode::Regex,
13751                providers: provider.map(|provider| vec![provider]),
13752                ..Default::default()
13753            };
13754            db.search_messages("ECONNRESET", &filters).unwrap().len()
13755        };
13756        assert_eq!(scoped(None), 2, "unscoped: both providers");
13757        assert_eq!(scoped(Some(Provider::Claude)), 1, "scoped to claude");
13758        assert_eq!(scoped(Some(Provider::Codex)), 1, "scoped to codex");
13759    }
13760
13761    // S14: a subagent transcript's `role='user'` rows are the SPAWNING AGENT's delegation prompt,
13762    // not anything a person typed. Subagents outnumber user-started sessions ~5:1, and the
13763    // built-in patterns match ordinary orchestrator text directly, so counting them turns routine
13764    // delegation into "the user corrected me". Narrowing by role alone is not enough.
13765    #[test]
13766    fn find_corrections_requires_human_authorship_after_session_kind_selection() {
13767        let dir = tempfile::tempdir().unwrap();
13768        let db = Db::open(&dir.path().join("index.db")).unwrap();
13769        db.conn
13770            .execute_batch(
13771                "insert into sessions(id, provider, provider_session_id, preview_text, \
13772                   source_path, parse_version, discovery_source) \
13773                 values('claude:human','claude','human','','/x','v1','jsonl'); \
13774                 insert into sessions(id, provider, provider_session_id, preview_text, \
13775                   source_path, parse_version, discovery_source, parent_session_id) \
13776                 values('claude:spawned','claude','spawned','','/x','v1','jsonl','claude:human');",
13777            )
13778            .unwrap();
13779        db.conn
13780            .execute_batch(
13781                "insert into messages(
13782                     session_id, provider, seq, role, content, authorship, record_relation
13783                 ) values
13784                   ('claude:human','claude',0,'user','don''t forget to check the tests',
13785                    'human','original'),
13786                   ('claude:spawned','claude',0,'user','don''t forget to check the tests',
13787                    'agent','original'),
13788                   ('claude:spawned','claude',1,'user','you forgot the human follow-up',
13789                    'human','original');",
13790            )
13791            .unwrap();
13792
13793        let policies = default_policies();
13794        let matches_of = |filters: &MessageFilters| -> Vec<(String, i64)> {
13795            let mut matches: Vec<(String, i64)> = db
13796                .find_corrections(&policies, filters)
13797                .unwrap()
13798                .into_iter()
13799                .map(|hit| (hit.session_id, hit.message_seq))
13800                .collect();
13801            matches.sort();
13802            matches
13803        };
13804
13805        assert_eq!(
13806            matches_of(&MessageFilters::default()),
13807            [("claude:human".to_string(), 0)],
13808            "by default a correction is what a person said, so the delegation prompt is excluded"
13809        );
13810        assert_eq!(
13811            matches_of(&MessageFilters {
13812                session_kinds: Some(vec![crate::models::SessionKind::Subagent]),
13813                ..Default::default()
13814            }),
13815            [("claude:spawned".to_string(), 1)],
13816            "session scope reopens genuine human content without reclassifying an agent prompt"
13817        );
13818        assert_eq!(
13819            matches_of(&MessageFilters {
13820                session_kinds: Some(vec![
13821                    crate::models::SessionKind::User,
13822                    crate::models::SessionKind::Subagent,
13823                ]),
13824                ..Default::default()
13825            }),
13826            [
13827                ("claude:human".to_string(), 0),
13828                ("claude:spawned".to_string(), 1),
13829            ],
13830            "the union contains attributable human content from both session classes"
13831        );
13832        assert!(
13833            matches_of(&MessageFilters {
13834                session_kinds: Some(Vec::new()),
13835                ..Default::default()
13836            })
13837            .is_empty(),
13838            "an empty class set matches nothing rather than silently matching all"
13839        );
13840    }
13841
13842    // S10: `offset` was accepted and silently dropped -- a parameter that validated, echoed, and
13843    // never reached the behavior, which is the failure mode that returns success.
13844    #[test]
13845    fn find_corrections_honors_offset_before_limit_in_newest_first_order() {
13846        let dir = tempfile::tempdir().unwrap();
13847        let db = Db::open(&dir.path().join("index.db")).unwrap();
13848        db.conn
13849            .execute_batch(
13850                "insert into sessions(id, provider, provider_session_id, preview_text, \
13851                   source_path, parse_version, discovery_source) \
13852                 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13853            )
13854            .unwrap();
13855        for seq in 0..5i64 {
13856            db.conn
13857                .execute(
13858                    "insert into messages(
13859                         session_id, provider, seq, role, ts, content,
13860                         authorship, record_relation
13861                     ) values(
13862                         'claude:s1','claude',?1,'user',?2,?3,'human','original'
13863                     )",
13864                    params![
13865                        seq,
13866                        format!("2026-01-0{}T00:00:00Z", seq + 1),
13867                        format!("you forgot item {seq}")
13868                    ],
13869                )
13870                .unwrap();
13871        }
13872
13873        let policies = default_policies();
13874        let contents = |offset: usize, limit: usize| -> Vec<String> {
13875            db.find_corrections(
13876                &policies,
13877                &MessageFilters {
13878                    offset,
13879                    limit,
13880                    ..Default::default()
13881                },
13882            )
13883            .unwrap()
13884            .into_iter()
13885            .map(|hit| hit.content)
13886            .collect()
13887        };
13888
13889        // Newest first, so item 4 leads.
13890        assert_eq!(contents(0, 2), ["you forgot item 4", "you forgot item 3"]);
13891        assert_eq!(
13892            contents(2, 2),
13893            ["you forgot item 2", "you forgot item 1"],
13894            "offset skips the newest N, then limit takes: one page, not a different sort"
13895        );
13896        assert_eq!(
13897            contents(3, 0),
13898            ["you forgot item 1", "you forgot item 0"],
13899            "offset applies even when limit is 0 (unbounded)"
13900        );
13901        assert!(
13902            contents(99, 0).is_empty(),
13903            "an offset past the end is empty, not a panic or a wrap-around"
13904        );
13905    }
13906
13907    #[test]
13908    fn find_corrections_pages_timestamp_ties_and_nulls_in_one_stable_total_order() {
13909        let dir = tempfile::tempdir().unwrap();
13910        let db = Db::open(&dir.path().join("index.db")).unwrap();
13911        for session_id in ["claude:a", "claude:b"] {
13912            db.conn
13913                .execute(
13914                    "insert into sessions(id, provider, provider_session_id, preview_text, \
13915                       source_path, parse_version, discovery_source) \
13916                     values(?1,'claude',?1,'','/x','v1','jsonl')",
13917                    [session_id],
13918                )
13919                .unwrap();
13920        }
13921        for (session_id, seq, timestamp, content) in [
13922            (
13923                "claude:b",
13924                0_i64,
13925                Some("2026-01-01T00:00:00Z"),
13926                "you forgot b0",
13927            ),
13928            ("claude:a", 1, Some("2026-01-01T00:00:00Z"), "you forgot a1"),
13929            ("claude:a", 0, Some("2026-01-01T00:00:00Z"), "you forgot a0"),
13930            ("claude:b", 1, None, "you forgot b1-null"),
13931            ("claude:a", 2, None, "you forgot a2-null"),
13932        ] {
13933            db.conn
13934                .execute(
13935                    "insert into messages(
13936                         session_id, provider, seq, role, ts, content,
13937                         authorship, record_relation
13938                     ) values(?1,'claude',?2,'user',?3,?4,'human','original')",
13939                    params![session_id, seq, timestamp, content],
13940                )
13941                .unwrap();
13942        }
13943
13944        let policies = default_policies();
13945        let page = |offset: usize, limit: usize| {
13946            db.find_corrections(
13947                &policies,
13948                &MessageFilters {
13949                    offset,
13950                    limit,
13951                    ..Default::default()
13952                },
13953            )
13954            .unwrap()
13955            .into_iter()
13956            .map(|hit| hit.content)
13957            .collect::<Vec<_>>()
13958        };
13959        let first = page(0, 2);
13960        let second = page(2, 2);
13961        let third = page(4, 2);
13962        let combined = first
13963            .iter()
13964            .chain(&second)
13965            .chain(&third)
13966            .cloned()
13967            .collect::<Vec<_>>();
13968
13969        assert_eq!(
13970            combined,
13971            [
13972                "you forgot a0",
13973                "you forgot a1",
13974                "you forgot b0",
13975                "you forgot a2-null",
13976                "you forgot b1-null",
13977            ],
13978            "equal and missing timestamps use session id then sequence as stable tie-breakers"
13979        );
13980        assert_eq!(combined, page(0, 0), "pages must partition the full result");
13981    }
13982
13983    #[test]
13984    fn find_corrections_scans_user_rows_and_classifies() {
13985        // #224 (revised): corrections scans only `role='user'` rows directly — no trigram prefilter
13986        // (see `find_corrections` doc: the role filter is the selective one, the prefilter would
13987        // only add cost). Verify it classifies each user row against the ordered patterns and
13988        // ignores non-user roles even when their content matches a pattern.
13989        let dir = tempfile::tempdir().unwrap();
13990        let db = Db::open(&dir.path().join("index.db")).unwrap();
13991        db.conn
13992            .execute_batch(
13993                "insert into sessions(id, provider, provider_session_id, preview_text, \
13994                   source_path, parse_version, discovery_source) \
13995                 values('claude:s1','claude','s1','','/x','v1','jsonl');",
13996            )
13997            .unwrap();
13998        let rows = [
13999            ("user", "you forgot the unit tests again"), // skip_step
14000            ("user", "we also need integration coverage"), // incomplete
14001            ("user", "looks great, ship it"),            // no correction
14002            ("assistant", "you forgot nothing, here is the fix"), // role=assistant → ignored
14003            ("user", "the deploy hit econnreset once more"), // no correction
14004        ];
14005        {
14006            let tx = db.conn.unchecked_transaction().unwrap();
14007            {
14008                let mut stmt = tx
14009                    .prepare(
14010                        "insert into messages(
14011                             session_id, provider, seq, role, content, authorship, record_relation
14012                         ) values('claude:s1','claude',?1,?2,?3,'human','original')",
14013                    )
14014                    .unwrap();
14015                for (i, (role, content)) in rows.iter().enumerate() {
14016                    stmt.execute(params![i as i64, role, content]).unwrap();
14017                }
14018            }
14019            tx.commit().unwrap();
14020        }
14021        let policies = test_policies(&[
14022            ("skip_step", &[r"\byou forgot\b"]),
14023            ("incomplete", &[r"\balso need\b"]),
14024        ]);
14025        let scan = db
14026            .find_corrections(&policies, &MessageFilters::default())
14027            .unwrap();
14028        assert_eq!(
14029            scan.len(),
14030            2,
14031            "exactly the two user corrections, assistant ignored"
14032        );
14033        assert!(scan.iter().any(|c| c.category == "skip_step"));
14034        assert!(scan.iter().any(|c| c.category == "incomplete"));
14035        // The assistant row matches the skip_step regex but is role=assistant → must be excluded.
14036        assert!(
14037            !scan
14038                .iter()
14039                .any(|c| c.content.contains("you forgot nothing")),
14040            "assistant-role match excluded by the role='user' filter",
14041        );
14042    }
14043
14044    #[test]
14045    fn find_corrections_classifies_only_original_human_content_with_absolute_coordinates() {
14046        let dir = tempfile::tempdir().unwrap();
14047        let db = Db::open(&dir.path().join("index.db")).unwrap();
14048        db.conn
14049            .execute_batch(
14050                "insert into sessions (
14051                     id, provider, provider_session_id, preview_text,
14052                     source_path, parse_version, discovery_source
14053                 ) values (
14054                     'claude:provenance', 'claude', 'provenance', '',
14055                     '/provenance', 'claude-v4', 'test'
14056                 );
14057                 insert into messages (
14058                     id, session_id, provider, seq, role, kind, content,
14059                     authorship, record_relation
14060                 ) values
14061                   (201, 'claude:provenance', 'claude', 0, 'user', 'conversation',
14062                    'human original you forgot', 'human', 'original'),
14063                   (202, 'claude:provenance', 'claude', 1, 'user', 'conversation',
14064                    'agent original you forgot', 'agent', 'original'),
14065                   (203, 'claude:provenance', 'claude', 2, 'user', 'conversation',
14066                    'human mirror you forgot', 'human', 'mirror');",
14067            )
14068            .unwrap();
14069
14070        let generated_prefix = "agent you forgot | ";
14071        let human_request = "human α you forgot the tests";
14072        let mixed_content = format!("{generated_prefix}{human_request}");
14073        db.conn
14074            .execute(
14075                "insert into messages (
14076                     id, session_id, provider, seq, role, kind, content,
14077                     authorship, record_relation
14078                 ) values (
14079                     204, 'claude:provenance', 'claude', 3, 'user', 'conversation', ?1,
14080                     'mixed', 'original'
14081                 )",
14082                [&mixed_content],
14083            )
14084            .unwrap();
14085        let generated_end = generated_prefix.chars().count() as i64;
14086        let mixed_end = mixed_content.chars().count() as i64;
14087        db.conn
14088            .execute(
14089                "insert into message_content_parts (
14090                     message_id, ordinal, start_char, end_char, authorship, origin
14091                 ) values
14092                   (204, 0, 0, ?1, 'generated', 'generated_summary'),
14093                   (204, 1, ?1, ?2, 'human', 'direct_input')",
14094                params![generated_end, mixed_end],
14095            )
14096            .unwrap();
14097
14098        let left = "left";
14099        let generated_bridge = " generated bridge ";
14100        let right = "right";
14101        let split_content = format!("{left}{generated_bridge}{right}");
14102        db.conn
14103            .execute(
14104                "insert into messages (
14105                     id, session_id, provider, seq, role, kind, content,
14106                     authorship, record_relation
14107                 ) values (
14108                     205, 'claude:provenance', 'claude', 4, 'user', 'conversation', ?1,
14109                     'mixed', 'original'
14110                 )",
14111                [&split_content],
14112            )
14113            .unwrap();
14114        let left_end = left.chars().count() as i64;
14115        let bridge_end = (left.chars().count() + generated_bridge.chars().count()) as i64;
14116        let split_end = split_content.chars().count() as i64;
14117        db.conn
14118            .execute(
14119                "insert into message_content_parts (
14120                     message_id, ordinal, start_char, end_char, authorship, origin
14121                 ) values
14122                   (205, 0, 0, ?1, 'human', 'direct_input'),
14123                   (205, 1, ?1, ?2, 'generated', 'generated_summary'),
14124                   (205, 2, ?2, ?3, 'human', 'direct_input')",
14125                params![left_end, bridge_end, split_end],
14126            )
14127            .unwrap();
14128
14129        let policies = test_policies(&[
14130            ("skip_step", &[r"\byou forgot\b"]),
14131            ("cross_part", &[r"\bleft\s*right\b"]),
14132        ]);
14133        let hits = db
14134            .find_corrections(&policies, &MessageFilters::default())
14135            .unwrap();
14136
14137        assert_eq!(
14138            hits.iter().map(|hit| hit.message_seq).collect::<Vec<_>>(),
14139            [0, 3],
14140            "agent-authored originals, mirrors, and artificial cross-part matches are excluded"
14141        );
14142        let mixed_hit = &hits[1];
14143        assert_eq!(mixed_hit.content, mixed_content);
14144        assert_eq!(mixed_hit.matched_text, "you forgot");
14145        let expected_start = generated_prefix.chars().count() + "human α ".chars().count();
14146        assert_eq!(mixed_hit.match_start_char, expected_start);
14147        assert_eq!(
14148            mixed_hit.match_end_char_exclusive,
14149            expected_start + "you forgot".chars().count(),
14150            "mixed-message coordinates remain absolute in the original full content"
14151        );
14152    }
14153
14154    #[test]
14155    fn find_corrections_reads_candidates_and_parts_from_one_snapshot() {
14156        let dir = tempfile::tempdir().unwrap();
14157        let path = dir.path().join("index.db");
14158        let db = Db::open(&path).unwrap();
14159        let writer = Db::open(&path).unwrap();
14160        db.conn
14161            .execute_batch(
14162                "insert into sessions (
14163                     id, provider, provider_session_id, preview_text,
14164                     source_path, parse_version, discovery_source
14165                 ) values (
14166                     'claude:snapshot', 'claude', 'snapshot', '',
14167                     '/snapshot', 'claude-v4', 'test'
14168                 );
14169                 insert into messages (
14170                     id, session_id, provider, seq, role, kind, content,
14171                     authorship, record_relation
14172                 ) values (
14173                     301, 'claude:snapshot', 'claude', 0, 'user', 'conversation',
14174                     'human you forgot', 'mixed', 'original'
14175                 );
14176                 insert into message_content_parts (
14177                     message_id, ordinal, start_char, end_char, authorship, origin
14178                 ) values (
14179                     301, 0, 0, 16, 'human', 'direct_input'
14180                 );",
14181            )
14182            .unwrap();
14183
14184        let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14185        let hits = db
14186            .find_corrections_with_after_candidates(&policies, &MessageFilters::default(), || {
14187                writer
14188                    .conn
14189                    .execute_batch(
14190                        "begin immediate;
14191                             update messages
14192                             set content = 'generated you forgot | human okay'
14193                             where id = 301;
14194                             delete from message_content_parts where message_id = 301;
14195                             insert into message_content_parts (
14196                                 message_id, ordinal, start_char, end_char, authorship, origin
14197                             ) values
14198                               (301, 0, 0, 23, 'generated', 'generated_summary'),
14199                               (301, 1, 23, 33, 'human', 'direct_input');
14200                             commit;",
14201                    )
14202                    .unwrap();
14203            })
14204            .unwrap();
14205
14206        assert_eq!(hits.len(), 1);
14207        assert_eq!(hits[0].content, "human you forgot");
14208        assert_eq!(hits[0].matched_text, "you forgot");
14209        assert_eq!(
14210            writer
14211                .conn
14212                .query_row("select content from messages where id = 301", [], |row| {
14213                    row.get::<_, String>(0)
14214                })
14215                .unwrap(),
14216            "generated you forgot | human okay",
14217            "the writer committed while the reader retained the older snapshot"
14218        );
14219    }
14220
14221    #[test]
14222    fn find_corrections_honors_provider_scope() {
14223        let dir = tempfile::tempdir().unwrap();
14224        let db = Db::open(&dir.path().join("index.db")).unwrap();
14225        for provider in [Provider::Claude, Provider::Codex] {
14226            let session_id = format!("{}:s1", provider.as_str());
14227            db.conn
14228                .execute(
14229                    "insert into sessions(id, provider, provider_session_id, preview_text, \
14230                       source_path, parse_version, discovery_source) \
14231                     values(?1, ?2, 's1', '', '/x', 'v1', 'jsonl')",
14232                    params![session_id, provider.as_str()],
14233                )
14234                .unwrap();
14235            db.conn
14236                .execute(
14237                    "insert into messages(
14238                         session_id, provider, seq, role, content, authorship, record_relation
14239                     ) values(
14240                         ?1, ?2, 0, 'user', 'you forgot the provider filter', 'human', 'original'
14241                     )",
14242                    params![session_id, provider.as_str()],
14243                )
14244                .unwrap();
14245        }
14246        let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14247
14248        let hits = db
14249            .find_corrections(
14250                &policies,
14251                &MessageFilters {
14252                    providers: Some(vec![Provider::Claude]),
14253                    ..Default::default()
14254                },
14255            )
14256            .unwrap();
14257
14258        assert_eq!(hits.len(), 1);
14259        assert_eq!(hits[0].provider, Provider::Claude);
14260        assert_eq!(hits[0].session_id, "claude:s1");
14261    }
14262
14263    #[test]
14264    fn find_corrections_parallel_matches_sequential() {
14265        // The parallel (rayon) classification must produce EXACTLY the sequential result: same
14266        // matches, in the same `order by ts desc`, with the same limit semantics. We seed 600 user
14267        // rows across many threads' worth of work; row i matches iff i % 5 == 0, and its content
14268        // embeds i so we can assert the precise descending order. (Order-preservation under
14269        // rayon's `collect` is the property most at risk from parallelism — this pins it.)
14270        let dir = tempfile::tempdir().unwrap();
14271        let db = Db::open(&dir.path().join("index.db")).unwrap();
14272        db.conn
14273            .execute_batch(
14274                "insert into sessions(id, provider, provider_session_id, preview_text, \
14275                   source_path, parse_version, discovery_source) \
14276                 values('claude:s1','claude','s1','','/x','v1','jsonl');",
14277            )
14278            .unwrap();
14279        let base = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z").unwrap();
14280        let n = 600i64;
14281        {
14282            let tx = db.conn.unchecked_transaction().unwrap();
14283            {
14284                let mut stmt = tx
14285                    .prepare(
14286                        "insert into messages(
14287                             session_id, provider, seq, role, ts, content,
14288                             authorship, record_relation
14289                         ) values(
14290                             'claude:s1','claude',?1,'user',?2,?3,'human','original'
14291                         )",
14292                    )
14293                    .unwrap();
14294                for i in 0..n {
14295                    let ts = (base + chrono::Duration::seconds(i)).to_rfc3339();
14296                    let content = if i % 5 == 0 {
14297                        format!("row-{i} you forgot the tests")
14298                    } else {
14299                        format!("row-{i} all good here")
14300                    };
14301                    stmt.execute(params![i, ts, content]).unwrap();
14302                }
14303            }
14304            tx.commit().unwrap();
14305        }
14306        let policies = test_policies(&[("skip_step", &[r"\byou forgot\b"])]);
14307
14308        // Expected: every 5th row, in DESCENDING i order (ts desc), starting at the largest
14309        // multiple of 5 below n.
14310        let expected: Vec<i64> = (0..n).rev().filter(|i| i % 5 == 0).collect();
14311
14312        let all = db
14313            .find_corrections(&policies, &MessageFilters::default())
14314            .unwrap();
14315        assert_eq!(all.len(), expected.len(), "match count");
14316        for (hit, want_i) in all.iter().zip(&expected) {
14317            assert_eq!(hit.category, "skip_step");
14318            assert_eq!(hit.matched_text, "you forgot");
14319            assert!(
14320                hit.content.starts_with(&format!("row-{want_i} ")),
14321                "order mismatch: got {:?}, expected row-{want_i}",
14322                hit.content
14323            );
14324        }
14325
14326        // Limit keeps the first N in the same ts-desc order (identical to a sequential early-break).
14327        let limited = db
14328            .find_corrections(
14329                &policies,
14330                &MessageFilters {
14331                    limit: 10,
14332                    ..Default::default()
14333                },
14334            )
14335            .unwrap();
14336        assert_eq!(limited.len(), 10);
14337        for (hit, want_i) in limited.iter().zip(expected.iter().take(10)) {
14338            assert!(hit.content.starts_with(&format!("row-{want_i} ")));
14339        }
14340    }
14341
14342    #[test]
14343    fn regex_search_corpus_gate_is_result_equivalent() {
14344        // #272: the trigram-prefilter corpus-size gate must change SPEED, never RESULTS. With a
14345        // role filter present the corpus is below the threshold, so the gate SKIPS the prefilter
14346        // (direct regex scan); with no structural filter it USES the prefilter (trigram path).
14347        // Both must agree: the role-filtered result equals the no-filter result restricted to
14348        // that role. (The fixture is tiny, so `narrows_corpus()` is what selects the branch.)
14349        let dir = tempfile::tempdir().unwrap();
14350        let db = Db::open(&dir.path().join("index.db")).unwrap();
14351        seed_messages(
14352            &db,
14353            &[
14354                ("user", "the deploy hit ECONNRESET in prod"),
14355                ("assistant", "ECONNRESET means the socket closed"),
14356                ("tool", "stderr: ECONNRESET at line 42"),
14357                ("user", "an unrelated note about apples"),
14358            ],
14359        );
14360        let search = |role: Option<Role>| -> Vec<(Role, String)> {
14361            let filters = MessageFilters {
14362                match_mode: MessageSearchMode::Regex,
14363                role,
14364                ..Default::default()
14365            };
14366            db.search_messages("ECONNRESET", &filters)
14367                .unwrap()
14368                .into_iter()
14369                .map(|hit| (hit.role, hit.content))
14370                .collect()
14371        };
14372        // No structural filter → narrows_corpus()==false → prefilter (trigram) path.
14373        let all = search(None);
14374        assert_eq!(
14375            all.len(),
14376            3,
14377            "ECONNRESET in the user + assistant + tool rows"
14378        );
14379        // role filter → narrows_corpus()==true, corpus < threshold → prefilter SKIPPED (scan path).
14380        let user_only = search(Some(Role::User));
14381        let expected_user: Vec<(Role, String)> = all
14382            .iter()
14383            .filter(|(role, _)| *role == Role::User)
14384            .cloned()
14385            .collect();
14386        assert_eq!(
14387            user_only, expected_user,
14388            "gate's scan path agrees with the prefilter path restricted to role=user"
14389        );
14390        assert_eq!(user_only.len(), 1, "exactly the one user ECONNRESET row");
14391    }
14392
14393    #[test]
14394    fn explain_message_search_counts_candidates_within_corpus() {
14395        let dir = tempfile::tempdir().unwrap();
14396        let mut db = Db::open(&dir.path().join("index.db")).unwrap();
14397        db.prefilter_min_corpus = 1;
14398        // Four user messages; only the first carries the rare literal "zebracode".
14399        db.upsert_session(
14400            &parsed_with_messages(
14401                "claude:s1",
14402                &[
14403                    "zebracode appears here once",
14404                    "common text alpha",
14405                    "common text bravo",
14406                    "common text charlie",
14407                ],
14408            ),
14409            1,
14410            100,
14411        )
14412        .unwrap();
14413
14414        // Selective regex anchored on the rare literal: a trigram prefilter exists and
14415        // narrows the 4-row corpus to the single zebracode row before regex verification.
14416        let selective = MessageFilters {
14417            role: Some(Role::User),
14418            match_mode: MessageSearchMode::Regex,
14419            ..Default::default()
14420        };
14421        let ex = db
14422            .explain_message_search("(?i)zebra.ode", &selective)
14423            .unwrap();
14424        assert_eq!(
14425            ex.corpus, 4,
14426            "all four user messages form the selectivity denominator"
14427        );
14428        assert!(
14429            ex.prefilter.is_some(),
14430            "a >=3-char literal yields a trigram prefilter"
14431        );
14432        let candidates = ex
14433            .candidates
14434            .expect("the regex path reports a candidate count");
14435        assert!(
14436            candidates <= ex.corpus,
14437            "candidates are always a subset of the corpus"
14438        );
14439        assert_eq!(
14440            candidates, 1,
14441            "only the zebracode row survives the trigram prefilter"
14442        );
14443
14444        // A regex with no >=3-char literal run ("a.b") has no usable anchor: no prefilter,
14445        // hence no candidate count — the regex would scan the whole corpus.
14446        let no_anchor = MessageFilters {
14447            role: Some(Role::User),
14448            match_mode: MessageSearchMode::Regex,
14449            ..Default::default()
14450        };
14451        let ex2 = db.explain_message_search("a.b", &no_anchor).unwrap();
14452        assert!(ex2.prefilter.is_none(), "no >=3-char anchor → no prefilter");
14453        assert!(
14454            ex2.candidates.is_none(),
14455            "no prefilter → no candidate count"
14456        );
14457        assert_eq!(ex2.corpus, 4);
14458    }
14459
14460    #[test]
14461    fn schema_v4_search_explain_uses_sqlite_prefilter_without_legacy_threshold() {
14462        let dir = tempfile::tempdir().unwrap();
14463        let db = Db::open(&dir.path().join("index.db")).unwrap();
14464        db.upsert_session(
14465            &parsed_with_messages("claude:s1", &["zebracode appears here once"]),
14466            1,
14467            100,
14468        )
14469        .unwrap();
14470
14471        let filters = MessageFilters {
14472            role: Some(Role::User),
14473            match_mode: MessageSearchMode::Regex,
14474            ..Default::default()
14475        };
14476        let (hits, explain) = db
14477            .search_messages_with_explain("zebracode", &filters, true)
14478            .unwrap();
14479        let explain = explain.expect("explain requested");
14480
14481        assert_eq!(hits.len(), 1);
14482        assert!(explain.prefilter.is_some(), "anchor is available");
14483        assert_eq!(explain.candidates, Some(1));
14484        assert!(explain.prefilter_skipped.is_none());
14485    }
14486
14487    #[test]
14488    fn internal_message_order_is_typed_scoped_and_reversible() {
14489        let dir = tempfile::tempdir().unwrap();
14490        let db = Db::open(&dir.path().join("index.db")).unwrap();
14491        db.upsert_session(
14492            &parsed_with_messages("claude:ordered", &["zero", "one", "two", "three", "four"]),
14493            1,
14494            1,
14495        )
14496        .unwrap();
14497
14498        let newest = db
14499            .search_messages_ordered(
14500                "",
14501                &MessageFilters {
14502                    session_id: Some("claude:ordered".to_string()),
14503                    limit: 2,
14504                    ..Default::default()
14505                },
14506                MessageOrder::NewestFirst,
14507            )
14508            .unwrap();
14509        assert_eq!(
14510            newest.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
14511            vec![4, 3]
14512        );
14513
14514        let oldest = db
14515            .search_messages(
14516                "",
14517                &MessageFilters {
14518                    session_id: Some("claude:ordered".to_string()),
14519                    limit: 2,
14520                    ..Default::default()
14521                },
14522            )
14523            .unwrap();
14524        assert_eq!(
14525            oldest.iter().map(|hit| hit.seq).collect::<Vec<_>>(),
14526            vec![0, 1]
14527        );
14528
14529        let error = db
14530            .search_messages_ordered("", &MessageFilters::default(), MessageOrder::NewestFirst)
14531            .unwrap_err();
14532        assert!(error
14533            .to_string()
14534            .contains("newest-first message order requires session_id"));
14535    }
14536
14537    /// Build a claude `ParsedSession` whose messages are the given contents (seq = index).
14538    #[cfg(test)]
14539    fn parsed_with_messages(id: &str, contents: &[&str]) -> crate::models::ParsedSession {
14540        use crate::models::{Message, ParsedSession, SessionRecord};
14541        let messages = contents
14542            .iter()
14543            .enumerate()
14544            .map(|(i, c)| Message {
14545                seq: i as i64,
14546                role: Role::User,
14547                ts: None,
14548                tool_name: None,
14549                kind: crate::models::MessageKind::Conversation,
14550                tool_call_id: None,
14551                is_compaction: false,
14552                content: c.to_string(),
14553                provenance: crate::models::MessageProvenance::default(),
14554            })
14555            .collect();
14556        ParsedSession {
14557            session: SessionRecord {
14558                id: id.to_string(),
14559                provider: Provider::Claude,
14560                provider_session_id: "s".into(),
14561                title: None,
14562                summary: None,
14563                cwd: None,
14564                repo_root: None,
14565                created_at: None,
14566                updated_at: None,
14567                last_message_at: None,
14568                preview_text: String::new(),
14569                source_path: "/x".into(),
14570                message_count: Some(contents.len() as i64),
14571                parse_version: "v1".into(),
14572                raw_metadata_json: None,
14573                parse_warning: None,
14574                discovery_source: "jsonl".into(),
14575                // No spawn concept on this path: subagent runs are either excluded from
14576                // discovery or unmarked by this provider. See models.rs SessionRecord.
14577                parent_session_id: None,
14578                agent_label: None,
14579            },
14580            transcript_text: contents.join("\n\n"),
14581            messages,
14582            file_edits: Vec::new(),
14583        }
14584    }
14585
14586    #[test]
14587    fn upsert_appends_only_new_messages_when_session_grows() {
14588        // Root-cause reindex perf: an append-only session that GREW must NOT delete + re-insert
14589        // (and re-trigram-index) its unchanged prefix — that re-indexed entire multi-hundred-MB
14590        // sessions on every incremental reindex. Detected with a SENTINEL tag on the existing
14591        // rows: it survives an append but not a delete+re-insert. (Row ids can't tell them apart
14592        // — SQLite reuses freed rowids after a delete-all.)
14593        let dir = tempfile::tempdir().unwrap();
14594        let db = Db::open(&dir.path().join("index.db")).unwrap();
14595        let contents = |db: &Db| -> Vec<String> {
14596            let mut s = db
14597                .conn
14598                .prepare("select content from messages where session_id='claude:s1' order by seq")
14599                .unwrap();
14600            s.query_map([], |r| r.get(0))
14601                .unwrap()
14602                .filter_map(Result::ok)
14603                .collect()
14604        };
14605        let tagged = |db: &Db| -> i64 {
14606            db.conn
14607                .query_row(
14608                    "select count(*) from messages where session_id='claude:s1' \
14609                     and tool_name='SENTINEL'",
14610                    [],
14611                    |r| r.get(0),
14612                )
14613                .unwrap()
14614        };
14615        db.upsert_session(
14616            &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie"]),
14617            1,
14618            100,
14619        )
14620        .unwrap();
14621        // Tag the existing rows; a fresh re-insert (from the parse) would not carry the sentinel.
14622        db.conn
14623            .execute(
14624                "update messages set tool_name='SENTINEL' where session_id='claude:s1'",
14625                [],
14626            )
14627            .unwrap();
14628
14629        // Append-only growth: same prefix + 2 new messages.
14630        db.upsert_session(
14631            &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie", "delta", "echo"]),
14632            2,
14633            200,
14634        )
14635        .unwrap();
14636        assert_eq!(
14637            contents(&db),
14638            ["alpha", "bravo", "charlie", "delta", "echo"]
14639        );
14640        assert_eq!(
14641            tagged(&db),
14642            3,
14643            "prefix rows RETAINED the sentinel (appended, not re-indexed)"
14644        );
14645        // The appended message is findable by regex search (custom index built lazily over the
14646        // grown corpus, or covered by the un-indexed delta direct-scan).
14647        let new_found = db
14648            .search_messages(
14649                "delta",
14650                &MessageFilters {
14651                    match_mode: MessageSearchMode::Regex,
14652                    ..Default::default()
14653                },
14654            )
14655            .unwrap();
14656        assert_eq!(new_found.len(), 1, "the appended message is searchable");
14657
14658        // Shrink → full replace (safe fallback): sentinel gone, content correct.
14659        db.upsert_session(
14660            &parsed_with_messages("claude:s1", &["alpha", "bravo"]),
14661            3,
14662            60,
14663        )
14664        .unwrap();
14665        assert_eq!(
14666            contents(&db),
14667            ["alpha", "bravo"],
14668            "shrink re-replaces fully"
14669        );
14670        assert_eq!(tagged(&db), 0, "shrink did a full replace");
14671
14672        // Grow with a CHANGED boundary message (in-place rewrite) → full replace, correct content.
14673        db.upsert_session(
14674            &parsed_with_messages("claude:s1", &["alpha", "boundary"]),
14675            4,
14676            70,
14677        )
14678        .unwrap();
14679        db.conn
14680            .execute(
14681                "update messages set tool_name='SENTINEL' where session_id='claude:s1'",
14682                [],
14683            )
14684            .unwrap();
14685        db.upsert_session(
14686            &parsed_with_messages("claude:s1", &["alpha", "CHANGED", "extra"]),
14687            5,
14688            90,
14689        )
14690        .unwrap();
14691        assert_eq!(
14692            contents(&db),
14693            ["alpha", "CHANGED", "extra"],
14694            "boundary content changed → full replace keeps content correct",
14695        );
14696        assert_eq!(tagged(&db), 0, "boundary mismatch forced a full replace");
14697    }
14698
14699    #[test]
14700    fn replace_session_rewrites_matching_prefix_metadata_on_full_reindex() {
14701        let dir = tempfile::tempdir().unwrap();
14702        let db = Db::open(&dir.path().join("index.db")).unwrap();
14703
14704        db.replace_session(
14705            &parsed_with_messages("claude:s1", &["alpha", "bravo"]),
14706            1,
14707            100,
14708        )
14709        .unwrap();
14710        db.conn
14711            .execute(
14712                "update messages set role='tool', tool_name='STALE' where session_id='claude:s1' and seq=0",
14713                [],
14714            )
14715            .unwrap();
14716
14717        db.replace_session(
14718            &parsed_with_messages("claude:s1", &["alpha", "bravo", "charlie"]),
14719            2,
14720            150,
14721        )
14722        .unwrap();
14723        let row: (String, Option<String>) = db
14724            .conn
14725            .query_row(
14726                "select role, tool_name from messages where session_id='claude:s1' and seq=0",
14727                [],
14728                |r| Ok((r.get(0)?, r.get(1)?)),
14729            )
14730            .unwrap();
14731        assert_eq!(
14732            row,
14733            ("user".to_string(), None),
14734            "full replace must repair stale prefix metadata even when content is unchanged"
14735        );
14736    }
14737
14738    #[test]
14739    fn tail_flow_appends_without_reparsing_prefix() {
14740        // Drive the incremental tail path directly (parse_reader → tail_parse → append_tail) and
14741        // PROVE it appends only the new rows: a deliberately corrupted prefix row — which a full
14742        // reparse would overwrite via the boundary-mismatch replace — must survive untouched.
14743        use crate::providers::claude::ClaudeAdapter;
14744        use std::io::Write;
14745
14746        let dir = tempfile::tempdir().unwrap();
14747        let file = dir.path().join("probe.jsonl");
14748        let line = |ts: &str, role: &str, text: &str| {
14749            format!(
14750                "{{\"type\":\"{role}\",\"sessionId\":\"probe\",\"timestamp\":\"{ts}\",\
14751                 \"message\":{{\"role\":\"{role}\",\"content\":[{{\"type\":\"text\",\"text\":\"{text}\"}}]}}}}\n"
14752            )
14753        };
14754        let initial = format!(
14755            "{}{}",
14756            line("2026-06-01T10:00:00Z", "user", "first prompt"),
14757            line("2026-06-01T10:00:05Z", "assistant", "first reply"),
14758        );
14759        std::fs::write(&file, &initial).unwrap();
14760
14761        let claude = ClaudeAdapter::new(vec![dir.path().to_path_buf()]);
14762        let source = crate::models::SourceFile {
14763            provider: Provider::Claude,
14764            path: file.clone(),
14765            mtime_ns: 1,
14766            size_bytes: std::fs::metadata(&file).unwrap().len() as i64,
14767        };
14768        let source_path = crate::util::normalize_path(&file);
14769        let db = Db::open(&dir.path().join("index.db")).unwrap();
14770        let mut parsed = claude.parse(&source);
14771        crate::util::backfill_session_dates(&mut parsed.session, source.mtime_ns);
14772        db.upsert_session(&parsed, source.mtime_ns, source.size_bytes)
14773            .unwrap();
14774        db.set_file_checkpoint(
14775            Provider::Claude,
14776            &source_path,
14777            crate::tail::complete_prefix_offset(&file).unwrap(),
14778            &crate::tail::prefix_fingerprint(&file).unwrap(),
14779        )
14780        .unwrap();
14781        assert_eq!(db.message_count().unwrap(), 2);
14782
14783        db.conn
14784            .execute(
14785                "update messages set content='CORRUPTED_PROBE' \
14786                 where session_id='claude:probe' and seq=0",
14787                [],
14788            )
14789            .unwrap();
14790
14791        // Append a third turn, then run the tail path against the stored checkpoint.
14792        std::fs::OpenOptions::new()
14793            .append(true)
14794            .open(&file)
14795            .unwrap()
14796            .write_all(line("2026-06-01T10:01:00Z", "user", "second prompt").as_bytes())
14797            .unwrap();
14798        let new_size = std::fs::metadata(&file).unwrap().len() as i64;
14799        let (offset, stored_fp) = db
14800            .file_checkpoint(
14801                Provider::Claude,
14802                &source_path,
14803                crate::util::provider_parse_version(Provider::Claude),
14804            )
14805            .unwrap()
14806            .unwrap();
14807        assert!(
14808            crate::tail::fingerprint_matches(&file, &stored_fp).unwrap(),
14809            "an append must keep the head fingerprint matching"
14810        );
14811        let tail = crate::tail::tail_parse(&file, offset, |cursor, path| {
14812            claude.parse_reader(cursor, path)
14813        })
14814        .unwrap()
14815        .expect("a new complete line was appended");
14816        db.append_tail(&tail, 2, new_size).unwrap();
14817
14818        assert_eq!(
14819            db.message_count().unwrap(),
14820            3,
14821            "the appended turn is indexed"
14822        );
14823        let seq2: String = db
14824            .conn
14825            .query_row(
14826                "select content from messages where session_id='claude:probe' and seq=2",
14827                [],
14828                |row| row.get(0),
14829            )
14830            .unwrap();
14831        assert_eq!(
14832            seq2, "second prompt",
14833            "new message appended at the next seq"
14834        );
14835        let seq0: String = db
14836            .conn
14837            .query_row(
14838                "select content from messages where session_id='claude:probe' and seq=0",
14839                [],
14840                |row| row.get(0),
14841            )
14842            .unwrap();
14843        assert_eq!(
14844            seq0, "CORRUPTED_PROBE",
14845            "tail append must NOT reparse/replace the prefix rows"
14846        );
14847        assert_eq!(
14848            db.messages_fts_count().unwrap(),
14849            db.message_count().unwrap(),
14850            "FTS in sync"
14851        );
14852    }
14853
14854    #[test]
14855    fn vocabulary_reports_term_frequencies() {
14856        // #226: fts5vocab term frequency — a term repeated across messages has the right doc and
14857        // total counts and sorts ahead of rarer terms; the trigram source yields 3-gram terms.
14858        let dir = tempfile::tempdir().unwrap();
14859        let db = Db::open(&dir.path().join("index.db")).unwrap();
14860        seed_messages(
14861            &db,
14862            &[
14863                ("user", "alpha alpha bravo"), // alpha x2, bravo x1
14864                ("user", "alpha charlie"),     // alpha x1, charlie x1
14865            ],
14866        );
14867        let vocab = db.vocabulary(false, 0, None).unwrap();
14868        let alpha = vocab
14869            .iter()
14870            .find(|(t, _, _)| t == "alpha")
14871            .expect("alpha present");
14872        assert_eq!(alpha.1, 2, "alpha appears in 2 documents");
14873        assert_eq!(alpha.2, 3, "alpha occurs 3 times total");
14874        // Ordered by total count desc → alpha (3) is first.
14875        assert_eq!(vocab[0].0, "alpha", "most frequent term first");
14876        // Trigram vocab yields 3-grams (substring stats), e.g. "alp" from "alpha".
14877        let schema_before = schema_fingerprint(&db.conn);
14878        let row_vocab_exists: bool = db
14879            .conn
14880            .query_row(
14881                "select exists(
14882                     select 1 from sqlite_schema
14883                      where type = 'table' and name = 'messages_trigram_terms'
14884                 )",
14885                [],
14886                |row| row.get(0),
14887            )
14888            .unwrap();
14889        assert!(
14890            row_vocab_exists,
14891            "v4 must provide the row-level trigram vocabulary used for bounded output"
14892        );
14893        let tri = db.vocabulary(true, 0, None).unwrap();
14894        assert!(
14895            tri.iter().any(|(t, _, _)| t == "alp"),
14896            "trigram vocab has 3-gram terms"
14897        );
14898        assert_eq!(
14899            schema_fingerprint(&db.conn),
14900            schema_before,
14901            "reading v4 trigram vocabulary must not mutate any schema object"
14902        );
14903    }
14904
14905    /// A vocabulary prefix reaches the term the index stored, whichever sigma the caller typed.
14906    ///
14907    /// FTS5 folds all three of `Σ`, `σ`, and `ς` onto `σ` as it tokenizes, which is the rule
14908    /// [`crate::util::fold_caseless`] applies and the rule [`str::to_lowercase`] does not:
14909    /// lowercasing writes a word-final `Σ` as `ς`, a spelling no term in either source is stored
14910    /// under. Folding the prefix by that rule asked for a term that cannot exist, so every
14911    /// spelling of a sigma-final prefix reported nothing at all while the term sat in the index.
14912    #[test]
14913    fn a_vocabulary_prefix_ending_in_sigma_finds_the_term_the_index_folded_it_to() {
14914        let dir = tempfile::tempdir().unwrap();
14915        let db = Db::open(&dir.path().join("index.db")).unwrap();
14916        seed_messages(&db, &[("user", "ΟΔΟΣ ΟΔΟΣΣΑ")]);
14917
14918        // One term, because tokenizing folded the two sigma spellings together.
14919        let stored: Vec<String> = db
14920            .vocabulary(false, 0, Some("οδοσ"))
14921            .unwrap()
14922            .into_iter()
14923            .map(|(term, _, _)| term)
14924            .collect();
14925        assert_eq!(
14926            stored,
14927            vec!["οδοσ".to_string(), "οδοσσα".to_string()],
14928            "the folded spelling is what the index stores"
14929        );
14930
14931        for prefix in ["ΟΔΟΣ", "οδος", "Οδος"] {
14932            let terms: Vec<String> = db
14933                .vocabulary(false, 0, Some(prefix))
14934                .unwrap()
14935                .into_iter()
14936                .map(|(term, _, _)| term)
14937                .collect();
14938            assert_eq!(
14939                terms,
14940                vec!["οδοσ".to_string(), "οδοσσα".to_string()],
14941                "prefix {prefix:?} reaches the terms the index folded it to"
14942            );
14943        }
14944    }
14945
14946    #[test]
14947    fn vocabulary_prefix_selects_only_terms_starting_with_it() {
14948        // Without a prefix the only way to ask about one term is to read every term and filter
14949        // outside SQLite: 1,591,051 rows and 22.7s against this author's index, versus 0.05s for
14950        // the equivalent term-range query. The prefix has to reach the query, not the output.
14951        let dir = tempfile::tempdir().unwrap();
14952        let db = Db::open(&dir.path().join("index.db")).unwrap();
14953        seed_messages(
14954            &db,
14955            &[
14956                ("user", "alpha alpha bravo alphabet"),
14957                ("user", "alpha charlie alphabet"),
14958            ],
14959        );
14960
14961        let terms = |prefix: Option<&str>, limit: usize| -> Vec<String> {
14962            db.vocabulary(false, limit, prefix)
14963                .unwrap()
14964                .into_iter()
14965                .map(|(term, _, _)| term)
14966                .collect()
14967        };
14968
14969        let mut matched = terms(Some("alph"), 0);
14970        matched.sort();
14971        assert_eq!(
14972            matched,
14973            vec!["alpha".to_string(), "alphabet".to_string()],
14974            "a prefix keeps every term extending it and drops the rest"
14975        );
14976        assert_eq!(
14977            terms(Some("alphabet"), 0),
14978            vec!["alphabet".to_string()],
14979            "a prefix equal to a whole term still matches that term"
14980        );
14981        assert!(
14982            terms(Some("zulu"), 0).is_empty(),
14983            "an unindexed prefix reports nothing rather than falling back to every term"
14984        );
14985
14986        // An empty prefix and an absent one both mean every term, so neither reads as a filter
14987        // that silently dropped rows.
14988        let mut every = terms(None, 0);
14989        let mut empty = terms(Some(""), 0);
14990        every.sort();
14991        empty.sort();
14992        assert_eq!(every, empty, "an empty prefix selects every term");
14993
14994        // The limit counts terms that survive the prefix, so a caller cannot receive an empty page
14995        // out of a matching prefix just because commoner terms filled the limit first.
14996        assert_eq!(
14997            terms(Some("alph"), 1),
14998            vec!["alpha".to_string()],
14999            "limit applies after the prefix, most frequent first"
15000        );
15001
15002        // Counts stay the term's counts over the whole index; a prefix selects rows, never rescopes
15003        // what each row counts.
15004        let alpha = db.vocabulary(false, 0, Some("alpha")).unwrap();
15005        let alpha = alpha
15006            .iter()
15007            .find(|(term, _, _)| term == "alpha")
15008            .expect("alpha present");
15009        assert_eq!((alpha.1, alpha.2), (2, 3), "2 documents, 3 occurrences");
15010
15011        assert!(
15012            db.vocabulary(true, 0, Some("alp"))
15013                .unwrap()
15014                .iter()
15015                .all(|(term, _, _)| term.starts_with("alp")),
15016            "the prefix reaches the trigram source too, not only word tokens"
15017        );
15018    }
15019
15020    #[test]
15021    fn schema_v4_rejects_custom_trigram_maintenance_without_mutation() {
15022        let dir = tempfile::tempdir().unwrap();
15023        let db = Db::open(&dir.path().join("index.db")).unwrap();
15024        let schema_before = schema_fingerprint(&db.conn);
15025
15026        let error = db.ensure_trigram_base().unwrap_err().to_string();
15027
15028        assert!(error.contains("unavailable on schema v4"), "{error}");
15029        assert_eq!(schema_fingerprint(&db.conn), schema_before);
15030    }
15031
15032    #[test]
15033    fn ensure_trigram_base_self_heals_corrupt_metadata_instead_of_failing_every_search() {
15034        // trigram_postings/trigram_meta are entirely derived from `messages`, so corruption
15035        // there must self-heal (rebuild) rather than turn every subsequent regex/substring
15036        // search into a hard failure requiring a manual `aise reindex --full`.
15037        let dir = tempfile::tempdir().unwrap();
15038        let db = Db::open(&dir.path().join("index.db")).unwrap();
15039        enable_v3_custom_trigram_compatibility(&db);
15040        seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15041
15042        // Corrupt trigram_meta the same way trigram_index::tests does: recreate it with an
15043        // incompatible shape (no `value` column) so the metadata read fails for a real reason.
15044        db.conn
15045            .execute_batch(
15046                "drop table trigram_meta; create table trigram_meta (key text primary key);",
15047            )
15048            .unwrap();
15049
15050        let base_max = db
15051            .ensure_trigram_base()
15052            .expect("corruption must self-heal, not propagate an error");
15053
15054        assert_eq!(
15055            base_max, 1,
15056            "self-heal rebuilds the base from the message table"
15057        );
15058        assert_eq!(
15059            crate::trigram_index::base_max_id(&db.conn).unwrap(),
15060            1,
15061            "trigram_meta is valid and queryable again after self-heal"
15062        );
15063        let groups = crate::trigram::trigram_prefilter_groups("ECONNRESET").unwrap();
15064        let cands = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
15065        assert!(
15066            cands.contains(&1),
15067            "the rebuilt index actually finds the seeded message, not just an empty shell"
15068        );
15069    }
15070
15071    #[test]
15072    fn ensure_trigram_base_does_not_repair_metadata_when_maintenance_is_disabled() {
15073        let dir = tempfile::tempdir().unwrap();
15074        let mut db = Db::open(&dir.path().join("index.db")).unwrap();
15075        enable_v3_custom_trigram_compatibility(&db);
15076        seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15077        db.conn
15078            .execute_batch(
15079                "drop table trigram_meta; create table trigram_meta (key text primary key);",
15080            )
15081            .unwrap();
15082        db.set_implicit_index_maintenance(false);
15083
15084        let error = db.ensure_trigram_base().unwrap_err().to_string();
15085        assert!(
15086            error.contains("automatic maintenance is disabled"),
15087            "{error}"
15088        );
15089        assert!(
15090            !crate::trigram_index::schema_is_compatible(&db.conn).unwrap(),
15091            "a maintenance-disabled read must not replace the malformed table"
15092        );
15093    }
15094
15095    #[test]
15096    fn read_only_open_does_not_repair_incompatible_trigram_tables() {
15097        let dir = tempfile::tempdir().unwrap();
15098        let path = dir.path().join("index.db");
15099        let db = Db::open(&path).unwrap();
15100        enable_v3_custom_trigram_compatibility(&db);
15101        seed_messages(&db, &[("user", "socket failed with ECONNRESET today")]);
15102        db.conn
15103            .execute_batch(
15104                "drop table trigram_meta; create table trigram_meta (key text primary key);",
15105            )
15106            .unwrap();
15107        let schema_before = schema_fingerprint(&db.conn);
15108        drop(db);
15109
15110        let read_only = Db::open_existing_read_only_with_threads(
15111            &path,
15112            TEST_BUSY_TIMEOUT_MS,
15113            NonZeroUsize::MIN,
15114        )
15115        .unwrap();
15116        let error = read_only.ensure_trigram_base().unwrap_err().to_string();
15117
15118        assert!(
15119            error.contains("automatic maintenance is disabled"),
15120            "{error}"
15121        );
15122        assert_eq!(
15123            schema_fingerprint(&read_only.conn),
15124            schema_before,
15125            "SQLite read-only mode must leave the incompatible derived schema unchanged"
15126        );
15127    }
15128
15129    #[test]
15130    fn concurrent_read_only_connections_share_one_fixed_worker_runtime() {
15131        let dir = tempfile::tempdir().unwrap();
15132        let path = dir.path().join("index.db");
15133        drop(Db::open(&path).unwrap());
15134        let runtime = Arc::new(ExecutionRuntime::new(
15135            NonZeroUsize::new(2).expect("nonzero worker count"),
15136        ));
15137
15138        let first = Db::open_existing_read_only_with_runtime(
15139            &path,
15140            TEST_BUSY_TIMEOUT_MS,
15141            Arc::clone(&runtime),
15142        )
15143        .unwrap();
15144        let second = Db::open_existing_read_only_with_runtime(
15145            &path,
15146            TEST_BUSY_TIMEOUT_MS,
15147            Arc::clone(&runtime),
15148        )
15149        .unwrap();
15150
15151        assert!(Arc::ptr_eq(&first.runtime, &runtime));
15152        assert!(Arc::ptr_eq(&second.runtime, &runtime));
15153        assert_eq!(first.worker_threads(), 2);
15154        assert_eq!(second.worker_threads(), 2);
15155    }
15156
15157    #[test]
15158    fn ensure_trigram_base_does_not_create_missing_tables_when_maintenance_is_disabled() {
15159        let dir = tempfile::tempdir().unwrap();
15160        let mut db = Db::open(&dir.path().join("index.db")).unwrap();
15161        enable_v3_custom_trigram_compatibility(&db);
15162        db.conn
15163            .execute_batch("drop table trigram_postings; drop table trigram_meta;")
15164            .unwrap();
15165        db.set_implicit_index_maintenance(false);
15166
15167        let error = db.ensure_trigram_base().unwrap_err().to_string();
15168        assert!(
15169            error.contains("automatic maintenance is disabled"),
15170            "{error}"
15171        );
15172        let derived_tables: i64 = db
15173            .conn
15174            .query_row(
15175                "select count(*) from sqlite_master where name in ('trigram_postings', 'trigram_meta')",
15176                [],
15177                |row| row.get(0),
15178            )
15179            .unwrap();
15180        assert_eq!(
15181            derived_tables, 0,
15182            "a read-only probe must not create schema"
15183        );
15184    }
15185
15186    #[test]
15187    fn ensure_trigram_base_rolls_back_schema_repair_when_rebuild_fails() {
15188        let dir = tempfile::tempdir().unwrap();
15189        let db = Db::open(&dir.path().join("index.db")).unwrap();
15190        enable_v3_custom_trigram_compatibility(&db);
15191        db.conn
15192            .execute_batch(
15193                "drop table trigram_meta;
15194                 create table trigram_meta (key text primary key);
15195                 drop table messages;",
15196            )
15197            .unwrap();
15198
15199        let error = db.ensure_trigram_base().unwrap_err().to_string();
15200        assert!(error.contains("no such table: messages"), "{error}");
15201        assert!(
15202            !crate::trigram_index::schema_is_compatible(&db.conn).unwrap(),
15203            "the failed transaction must restore the original incompatible schema"
15204        );
15205    }
15206
15207    #[test]
15208    fn ensure_trigram_base_busy_repair_leaves_incompatible_tables_untouched() {
15209        let dir = tempfile::tempdir().unwrap();
15210        let path = dir.path().join("index.db");
15211        let writer = Db::open(&path).unwrap();
15212        enable_v3_custom_trigram_compatibility(&writer);
15213        seed_messages(&writer, &[("user", "socket failed with ECONNRESET today")]);
15214        writer
15215            .conn
15216            .execute_batch(
15217                "drop table trigram_meta; create table trigram_meta (key text primary key);",
15218            )
15219            .unwrap();
15220        let schema_before = schema_fingerprint(&writer.conn);
15221
15222        let contender = Db::open_with_busy_timeout(&path, TEST_NO_WAIT_BUSY_TIMEOUT_MS).unwrap();
15223        writer.conn.execute_batch("begin immediate").unwrap();
15224        let error = contender
15225            .ensure_trigram_base()
15226            .expect_err("a competing writer must prevent derived-table repair");
15227        writer.conn.execute_batch("rollback").unwrap();
15228
15229        assert!(Db::is_sqlite_busy_error(&error), "{error:#}");
15230        assert!(
15231            !crate::trigram_index::schema_is_compatible(&contender.conn).unwrap(),
15232            "a failed repair attempt must not replace either derived table"
15233        );
15234        assert_eq!(
15235            schema_fingerprint(&contender.conn),
15236            schema_before,
15237            "the failed repair must preserve the entire preexisting schema"
15238        );
15239    }
15240
15241    #[test]
15242    fn regex_prefilter_reaches_rows_by_primary_key_not_full_scan() {
15243        // #207: a prefilterable regex search resolves candidates through the custom trigram index
15244        // (staged into the `_trigram_cand` temp table) and reaches message rows by primary key —
15245        // never a full table scan of `messages`.
15246        let dir = tempfile::tempdir().unwrap();
15247        let db = Db::open(&dir.path().join("index.db")).unwrap();
15248        enable_v3_custom_trigram_compatibility(&db);
15249        seed_messages(&db, &[("user", "socket failed with ECONNRESET) today")]);
15250        // Build the base + stage candidates exactly as search_messages does, then check the plan of
15251        // the candidate-restricted scan.
15252        let base_max = db.ensure_trigram_base().unwrap();
15253        let groups = crate::trigram::trigram_prefilter_groups("ECONNRESET").unwrap();
15254        let cands = crate::trigram_index::candidates(&db.conn, &groups).unwrap();
15255        db.stage_candidates(base_max, &cands).unwrap();
15256        let plan = {
15257            let mut stmt = db
15258                .conn
15259                .prepare(
15260                    "explain query plan select m.id from messages m \
15261                     where m.id in (select id from _trigram_cand) order by m.session_id, m.seq",
15262                )
15263                .unwrap();
15264            stmt.query_map([], |r| r.get::<_, String>(3))
15265                .unwrap()
15266                .filter_map(Result::ok)
15267                .collect::<Vec<_>>()
15268                .join(" | ")
15269        };
15270        assert!(
15271            plan.contains("PRIMARY KEY") || plan.contains("USING INTEGER PRIMARY KEY"),
15272            "messages must be reached by primary key, not scanned: {plan}",
15273        );
15274        assert!(
15275            !plan.contains("SCAN m "),
15276            "no full scan of the messages table: {plan}",
15277        );
15278    }
15279
15280    #[test]
15281    fn checkpoint_truncate_is_safe_and_preserves_data() {
15282        // #240: the WAL truncate-checkpoint runs without error and the index stays queryable
15283        // (it folds the WAL into the main DB; the substring index must still match afterward).
15284        let dir = tempfile::tempdir().unwrap();
15285        let db = Db::open(&dir.path().join("index.db")).unwrap();
15286        seed_messages(&db, &[("user", "the deploy hit ECONNRESET) again")]);
15287        db.checkpoint_truncate().unwrap();
15288        // Idempotent: a second checkpoint on a quiescent DB is fine.
15289        db.checkpoint_truncate().unwrap();
15290        let hits = db
15291            .search_messages(
15292                "ECONNRESET",
15293                &MessageFilters {
15294                    match_mode: MessageSearchMode::Regex,
15295                    ..Default::default()
15296                },
15297            )
15298            .unwrap();
15299        assert_eq!(hits.len(), 1, "data intact and searchable after checkpoint");
15300    }
15301
15302    #[test]
15303    fn schema_state_classification_is_total_and_preserves_readable_older_policy() {
15304        assert_eq!(
15305            SchemaState::from_version(SCHEMA_VERSION),
15306            SchemaState::Current
15307        );
15308        assert_eq!(
15309            SchemaState::from_version(SCHEMA_VERSION - 1),
15310            SchemaState::Older {
15311                current: SCHEMA_VERSION - 1,
15312                required: SCHEMA_VERSION,
15313            }
15314        );
15315        assert_eq!(
15316            SchemaState::from_version(SCHEMA_VERSION + 1),
15317            SchemaState::Newer {
15318                current: SCHEMA_VERSION + 1,
15319                supported: SCHEMA_VERSION,
15320            }
15321        );
15322
15323        let dir = tempfile::tempdir().unwrap();
15324        let db = Db::open(&dir.path().join("index.db")).unwrap();
15325        db.conn
15326            .pragma_update(None, "user_version", MIN_READABLE_SCHEMA_VERSION)
15327            .unwrap();
15328        assert!(db.needs_backfill().unwrap());
15329        assert!(db.schema_is_readable().unwrap());
15330    }
15331
15332    #[test]
15333    fn corpus_threshold_probe_stops_at_the_decision_boundary() {
15334        let dir = tempfile::tempdir().unwrap();
15335        let db = Db::open(&dir.path().join("index.db")).unwrap();
15336        seed_messages(
15337            &db,
15338            &[
15339                ("user", "one"),
15340                ("user", "two"),
15341                ("user", "three"),
15342                ("user", "four"),
15343                ("assistant", "five"),
15344            ],
15345        );
15346        let filters = MessageFilters {
15347            role: Some(Role::User),
15348            ..Default::default()
15349        };
15350
15351        assert!(db.filtered_corpus_reaches(&filters, 3).unwrap());
15352        assert!(db.filtered_corpus_reaches(&filters, 4).unwrap());
15353        assert!(!db.filtered_corpus_reaches(&filters, 5).unwrap());
15354        assert_eq!(db.filtered_corpus_count(&filters).unwrap(), 4);
15355    }
15356
15357    /// Seed one session with rows of every class the receipt count must handle: the classes
15358    /// excluded by default (harness notice, compaction), the ones returned by default, one tool
15359    /// row with a name, and one legacy `kind` this build does not know, which the default class
15360    /// predicate must still count (an inclusion list would silently drop it).
15361    fn seed_every_message_class(db: &Db) {
15362        seed_messages(db, &[("user", "one"), ("assistant", "two")]);
15363        db.conn
15364            .execute_batch(
15365                "update messages set kind = 'conversation', ts = '2026-08-01T00:00:00Z';
15366                 insert into messages(session_id, provider, seq, role, kind, ts, tool_name, content) values
15367                   ('claude:s1', 'claude', 10, 'user', 'harness_notice', '2026-08-02T00:00:00Z', null, 'notice'),
15368                   ('claude:s1', 'claude', 11, 'user', 'compaction', '2026-08-02T00:00:00Z', null, 'summary'),
15369                   ('claude:s1', 'claude', 12, 'assistant', 'message', '2026-08-03T00:00:00Z', null, 'legacy kind'),
15370                   ('claude:s1', 'claude', 13, 'assistant', 'tool_call', '2026-08-03T00:00:00Z', 'Bash', 'ls'),
15371                   ('claude:s1', 'claude', 14, 'tool', 'tool_result', '2026-08-03T00:00:00Z', 'Bash', 'ok');",
15372            )
15373            .unwrap();
15374    }
15375
15376    fn corpus_count_filter_cases() -> Vec<(&'static str, MessageFilters)> {
15377        vec![
15378            ("default", MessageFilters::default()),
15379            (
15380                "role",
15381                MessageFilters {
15382                    role: Some(Role::User),
15383                    ..Default::default()
15384                },
15385            ),
15386            (
15387                "provider",
15388                MessageFilters {
15389                    providers: Some(vec![Provider::Claude]),
15390                    ..Default::default()
15391                },
15392            ),
15393            (
15394                "since",
15395                MessageFilters {
15396                    since: Some(utc("2026-08-02T12:00:00Z")),
15397                    ..Default::default()
15398                },
15399            ),
15400            (
15401                "session",
15402                MessageFilters {
15403                    session_id: Some("claude:s1".to_string()),
15404                    ..Default::default()
15405                },
15406            ),
15407            (
15408                "workspace_path",
15409                MessageFilters {
15410                    workspace_path_prefix: Some("/x".to_string()),
15411                    ..Default::default()
15412                },
15413            ),
15414            (
15415                "tool",
15416                MessageFilters {
15417                    tool: Some("bash".to_string()),
15418                    ..Default::default()
15419                },
15420            ),
15421            (
15422                "no_compaction",
15423                MessageFilters {
15424                    no_compaction: true,
15425                    ..Default::default()
15426                },
15427            ),
15428            (
15429                "explicit_excludable_kind",
15430                MessageFilters {
15431                    kinds: Some(vec![crate::models::MessageKind::HarnessNotice]),
15432                    ..Default::default()
15433                },
15434            ),
15435        ]
15436    }
15437
15438    /// Explicit selections outside the excludable set count by reading rows (no index carries
15439    /// those classes); they must still be exact.
15440    fn explicit_row_scanning_kind_filters() -> MessageFilters {
15441        MessageFilters {
15442            kinds: Some(vec![
15443                crate::models::MessageKind::Conversation,
15444                crate::models::MessageKind::ToolCall,
15445            ]),
15446            ..Default::default()
15447        }
15448    }
15449
15450    /// The single-statement count every corpus figure used to be: correct, and a full scan of
15451    /// the messages table because no index carried `kind`. Kept here as the oracle.
15452    fn naive_corpus_count(db: &Db, filters: &MessageFilters) -> i64 {
15453        let mut sql = String::from("select count(*) from messages m where 1 = 1");
15454        let mut args = Vec::new();
15455        append_message_filters(&mut sql, &mut args, filters, &db.access_scope);
15456        db.conn
15457            .query_row(&sql, rusqlite::params_from_iter(args.iter()), |row| {
15458                row.get(0)
15459            })
15460            .unwrap()
15461    }
15462
15463    fn query_plan(db: &Db, sql: &str, args: &[rusqlite::types::Value]) -> String {
15464        let mut stmt = db
15465            .conn
15466            .prepare(&format!("explain query plan {sql}"))
15467            .unwrap();
15468        stmt.query_map(rusqlite::params_from_iter(args.iter()), |row| {
15469            row.get::<_, String>(3)
15470        })
15471        .unwrap()
15472        .filter_map(Result::ok)
15473        .collect::<Vec<_>>()
15474        .join(" | ")
15475    }
15476
15477    #[test]
15478    fn corpus_count_equals_the_naive_count_for_every_class_predicate() {
15479        let dir = tempfile::tempdir().unwrap();
15480        let db = Db::open(&dir.path().join("index.db")).unwrap();
15481        seed_every_message_class(&db);
15482        for (name, filters) in corpus_count_filter_cases() {
15483            assert_eq!(
15484                db.filtered_corpus_count(&filters).unwrap(),
15485                naive_corpus_count(&db, &filters),
15486                "{name}: the decomposed count must equal the single-statement count"
15487            );
15488        }
15489        let explicit = explicit_row_scanning_kind_filters();
15490        assert_eq!(
15491            db.filtered_corpus_count(&explicit).unwrap(),
15492            naive_corpus_count(&db, &explicit),
15493            "explicit non-excludable classes"
15494        );
15495        // Sanity: the default predicate keeps the legacy `message` row and drops only the notice.
15496        assert_eq!(
15497            db.filtered_corpus_count(&MessageFilters::default())
15498                .unwrap(),
15499            6
15500        );
15501    }
15502
15503    #[test]
15504    fn corpus_count_statements_are_served_from_covering_indexes() {
15505        // A receipt (`receipt_level=summary|full`) must never cost a scan of the messages table:
15506        // on a 22.6 GB index that turned a 0.5 s search into 117 s (each row's page is read to
15507        // test `kind`). Every statement behind the corpus figure must be answered from an index
15508        // that carries every column it tests, for every single-family structural filter.
15509        let dir = tempfile::tempdir().unwrap();
15510        let db = Db::open(&dir.path().join("index.db")).unwrap();
15511        seed_every_message_class(&db);
15512        // The tool-argument receipt counts the same way over the tool-call rows, which the
15513        // partial `idx_messages_tool_calls` answers because its predicate is the literal text.
15514        let mut cases = corpus_count_filter_cases()
15515            .into_iter()
15516            .map(|(name, filters)| (name, "1 = 1", filters))
15517            .collect::<Vec<_>>();
15518        cases.push((
15519            "tool_argument",
15520            "m.kind = 'tool_call'",
15521            MessageFilters::default(),
15522        ));
15523        for (name, base, filters) in cases {
15524            let statements = db.corpus_count_statements(base, &filters);
15525            assert!(!statements.is_empty(), "{name}: no count statements");
15526            for (sql, args, _sign) in &statements {
15527                let plan = query_plan(&db, sql, args);
15528                // Every step that touches the messages alias `m` (a subquery over sessions may
15529                // appear too) must be a covering-index step: `SCAN m USING COVERING INDEX` is
15530                // SQLite's count(*) optimization, `SEARCH m USING COVERING INDEX ...` a seek.
15531                // A bare `SCAN m` or `SEARCH m USING INDEX` (non-covering) reads message rows.
15532                for step in plan.split(" | ") {
15533                    if step.contains("SCAN m") || step.contains("SEARCH m") {
15534                        assert!(
15535                            step.contains("USING COVERING INDEX"),
15536                            "{name}: corpus count must not read message rows\nsql: {sql}\nplan: {plan}"
15537                        );
15538                    }
15539                }
15540                assert!(
15541                    plan.contains("COVERING INDEX"),
15542                    "{name}: corpus count must be index-only\nsql: {sql}\nplan: {plan}"
15543                );
15544            }
15545        }
15546    }
15547
15548    #[test]
15549    fn message_kind_index_is_created_free_on_an_empty_database_and_deferred_on_a_populated_one() {
15550        let dir = tempfile::tempdir().unwrap();
15551        let path = dir.path().join("index.db");
15552        let db = Db::open(&path).unwrap();
15553        assert!(
15554            db.message_kind_index_exists().unwrap(),
15555            "an empty database gets the class index at open, where building it is free"
15556        );
15557        seed_every_message_class(&db);
15558        db.conn
15559            .execute_batch(&format!("drop index {MESSAGE_KIND_INDEX}"))
15560            .unwrap();
15561        drop(db);
15562        // A populated database that lacks the index (one written by an older aise) must not pay
15563        // for the build inside `open`: `open` runs in every query process, the build holds the
15564        // write lock for one full scan of `messages`, and peers opening meanwhile would fail on
15565        // busy timeout. The elected writer builds it during the next refresh instead.
15566        let reopened = Db::open(&path).unwrap();
15567        assert!(
15568            !reopened.message_kind_index_exists().unwrap(),
15569            "open must defer the class index build on a populated database"
15570        );
15571        assert!(
15572            reopened.ensure_message_kind_index().unwrap(),
15573            "the maintenance path reports that it built the index"
15574        );
15575        assert!(reopened.message_kind_index_exists().unwrap());
15576        assert!(
15577            !reopened.ensure_message_kind_index().unwrap(),
15578            "a second call is a no-op"
15579        );
15580        // Correctness is identical with or without the index; only the plan changes.
15581        for (name, filters) in corpus_count_filter_cases() {
15582            assert_eq!(
15583                reopened.filtered_corpus_count(&filters).unwrap(),
15584                naive_corpus_count(&reopened, &filters),
15585                "{name}"
15586            );
15587        }
15588    }
15589}