Skip to main content

sqlite_graphrag/storage/memories/
mod.rs

1//! Persistence layer for the `memories` table and its vector companion.
2//!
3//! Functions here encapsulate every SQL statement touching `memories`,
4//! `memory_embeddings` and the FTS5 `fts_memories` shadow table. Callers receive
5//! typed [`MemoryRow`] or [`NewMemory`] values and never build SQL strings.
6
7mod soft_delete;
8pub use soft_delete::{
9    clear_deleted_at, find_by_name_any_state, list_deleted_before, soft_delete,
10};
11
12use crate::embedder::f32_to_bytes;
13use crate::errors::AppError;
14use crate::storage::utils::with_busy_retry;
15use rusqlite::{params, Connection};
16use serde::{Deserialize, Serialize};
17
18/// Input payload for inserting or updating a memory.
19///
20/// `body_hash` must be the BLAKE3 digest of `body`. The `metadata` field is
21/// stored as a TEXT column containing JSON.
22#[derive(Debug, Serialize, Deserialize)]
23pub struct NewMemory {
24    /// Namespace scope.
25    pub namespace: String,
26    /// Name of this item.
27    pub name: String,
28    /// Memory type classification.
29    pub memory_type: String,
30    /// Human-readable description.
31    pub description: String,
32    /// Full text body.
33    pub body: String,
34    /// Body hash.
35    pub body_hash: String,
36    /// Session ID.
37    pub session_id: Option<String>,
38    /// Source side of the relationship.
39    pub source: String,
40    /// Arbitrary metadata.
41    pub metadata: serde_json::Value,
42}
43
44/// Fully materialized row from the `memories` table.
45///
46/// Returned by [`read_by_name`], [`read_full`], [`list`] and [`fts_search`].
47/// The `metadata` field is kept as a JSON string to avoid double parsing.
48#[derive(Debug, Serialize)]
49pub struct MemoryRow {
50    /// Unique identifier.
51    pub id: i64,
52    /// Namespace scope.
53    pub namespace: String,
54    /// Name of this item.
55    pub name: String,
56    /// Memory type classification.
57    pub memory_type: String,
58    /// Human-readable description.
59    pub description: String,
60    /// Full text body.
61    pub body: String,
62    /// Body hash.
63    pub body_hash: String,
64    /// Session ID.
65    pub session_id: Option<String>,
66    /// Source side of the relationship.
67    pub source: String,
68    /// Arbitrary metadata.
69    pub metadata: String,
70    /// Creation timestamp.
71    pub created_at: i64,
72    /// Last-update timestamp.
73    pub updated_at: i64,
74    /// Unix epoch when the memory was soft-deleted, or `None` for active memories.
75    /// Surfaced in `list --include-deleted --json` so LLM consumers can distinguish
76    /// active from soft-deleted rows without a second SQL query (v1.0.37 H7+M9 fix).
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub deleted_at: Option<i64>,
79}
80
81/// Finds a live memory by `(namespace, name)` and returns key metadata.
82///
83/// # Arguments
84///
85/// - `conn` — open SQLite connection configured with the project pragmas.
86/// - `namespace` — resolved namespace for the lookup.
87/// - `name` — kebab-case memory name.
88///
89/// # Returns
90///
91/// `Ok(Some((id, updated_at, max_version)))` when the memory exists and is
92/// not soft-deleted, `Ok(None)` otherwise.
93///
94/// # Errors
95///
96/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
97pub fn find_by_name(
98    conn: &Connection,
99    namespace: &str,
100    name: &str,
101) -> Result<Option<(i64, i64, i64)>, AppError> {
102    let mut stmt = conn.prepare_cached(
103        "SELECT m.id, m.updated_at, COALESCE(MAX(v.version), 0)
104         FROM memories m
105         LEFT JOIN memory_versions v ON v.memory_id = m.id
106         WHERE m.namespace = ?1 AND m.name = ?2 AND m.deleted_at IS NULL
107         GROUP BY m.id",
108    )?;
109    let result = stmt.query_row(params![namespace, name], |r| {
110        Ok((
111            r.get::<_, i64>(0)?,
112            r.get::<_, i64>(1)?,
113            r.get::<_, i64>(2)?,
114        ))
115    });
116    match result {
117        Ok(row) => Ok(Some(row)),
118        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
119        Err(e) => Err(AppError::Database(e)),
120    }
121}
122
123
124/// Looks up a live memory by exact `body_hash` within a namespace.
125///
126/// Used during `remember` to short-circuit semantic duplicates before
127/// spending an embedding call.
128///
129/// # Returns
130///
131/// `Ok(Some(id))` when a live memory with the same hash exists,
132/// `Ok(None)` otherwise.
133///
134/// # Errors
135///
136/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
137pub fn find_by_hash(
138    conn: &Connection,
139    namespace: &str,
140    body_hash: &str,
141) -> Result<Option<i64>, AppError> {
142    let mut stmt = conn.prepare_cached(
143        "SELECT id FROM memories WHERE namespace = ?1 AND body_hash = ?2 AND deleted_at IS NULL",
144    )?;
145    match stmt.query_row(params![namespace, body_hash], |r| r.get(0)) {
146        Ok(id) => Ok(Some(id)),
147        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
148        Err(e) => Err(AppError::Database(e)),
149    }
150}
151
152/// Inserts a new row into the `memories` table.
153///
154/// # Arguments
155///
156/// - `conn` — active SQLite connection, typically inside a transaction.
157/// - `m` — validated payload including `body_hash` and serialized metadata.
158///
159/// # Returns
160///
161/// The `rowid` assigned to the newly inserted memory.
162///
163/// # Errors
164///
165/// Returns `Err(AppError::Database)` on insertion failure and
166/// `Err(AppError::Json)` if metadata serialization fails.
167pub fn insert(conn: &Connection, m: &NewMemory) -> Result<i64, AppError> {
168    // G29 Passo 2 (v1.0.69): runtime guard for the CHECK constraint on
169    // `source`. Even though `MemorySource` is the typed future, every
170    // legacy `NewMemory { source: "..." }` literal still flows through
171    // this function; validating here keeps the footgun from regressing
172    // for callers that have not yet migrated to the enum.
173    let validated_source = crate::memory_source::validate_source(&m.source)?;
174    conn.execute(
175        "INSERT INTO memories (namespace, name, type, description, body, body_hash, session_id, source, metadata)
176         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
177        params![
178            m.namespace, m.name, m.memory_type, m.description, m.body,
179            m.body_hash, m.session_id, validated_source,
180            serde_json::to_string(&m.metadata)?
181        ],
182    )?;
183    Ok(conn.last_insert_rowid())
184}
185
186/// Updates an existing memory optionally guarded by optimistic concurrency.
187///
188/// When `expected_updated_at` is `Some(ts)` the row is only updated if its
189/// current `updated_at` equals `ts`. This protects concurrent `edit` calls
190/// from silently clobbering each other.
191///
192/// # Returns
193///
194/// `Ok(true)` when exactly one row was updated, `Ok(false)` when the
195/// optimistic check failed or the memory does not exist.
196///
197/// # Errors
198///
199/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
200pub fn update(
201    conn: &Connection,
202    id: i64,
203    m: &NewMemory,
204    expected_updated_at: Option<i64>,
205) -> Result<bool, AppError> {
206    // G29 Passo 2 (v1.0.69): runtime guard for the CHECK constraint on
207    // `source`. Mirrors `insert` so `body-enrich` and other mutations
208    // cannot reintroduce the historical "enrich" literal that broke
209    // `body-enrich` in v1.0.55 - v1.0.68.
210    let validated_source = crate::memory_source::validate_source(&m.source)?;
211    let affected = if let Some(ts) = expected_updated_at {
212        conn.execute(
213            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
214             session_id=?6, source=?7, metadata=?8
215             WHERE id=?1 AND updated_at=?9 AND deleted_at IS NULL",
216            params![
217                id,
218                m.memory_type,
219                m.description,
220                m.body,
221                m.body_hash,
222                m.session_id,
223                validated_source,
224                serde_json::to_string(&m.metadata)?,
225                ts
226            ],
227        )?
228    } else {
229        conn.execute(
230            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
231             session_id=?6, source=?7, metadata=?8
232             WHERE id=?1 AND deleted_at IS NULL",
233            params![
234                id,
235                m.memory_type,
236                m.description,
237                m.body,
238                m.body_hash,
239                m.session_id,
240                validated_source,
241                serde_json::to_string(&m.metadata)?
242            ],
243        )?
244    };
245    Ok(affected == 1)
246}
247
248/// Replaces the vector row for a memory in `memory_embeddings`.
249///
250/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
251/// table; cosine similarity is computed in pure Rust on demand. The
252/// `memory_type`, `name`, and `snippet` arguments are accepted for API
253/// compatibility but are not stored — the FTS5 shadow table is the
254/// source of truth for textual metadata.
255///
256/// # Errors
257///
258/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
259pub fn upsert_vec(
260    conn: &Connection,
261    memory_id: i64,
262    namespace: &str,
263    _memory_type: &str,
264    embedding: &[f32],
265    _name: &str,
266    _snippet: &str,
267) -> Result<(), AppError> {
268    // v1.1.1 (P1): skip empty vectors so the memory stays visible to the
269    // re-embed backfill scanner instead of persisting a vector-less row.
270    if embedding.is_empty() {
271        tracing::debug!(
272            memory_id,
273            "empty memory embedding: skipping memory_embeddings row (backfill via enrich re-embed)"
274        );
275        return Ok(());
276    }
277    let embedding_bytes = f32_to_bytes(embedding);
278    with_busy_retry(|| {
279        conn.execute(
280            "DELETE FROM memory_embeddings WHERE memory_id = ?1",
281            params![memory_id],
282        )?;
283        conn.execute(
284            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
285             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
286            params![
287                memory_id,
288                namespace,
289                &embedding_bytes,
290                "llm-headless",
291                crate::constants::SQLITE_GRAPHRAG_VERSION,
292                crate::constants::embedding_dim() as i64,
293            ],
294        )?;
295        Ok(())
296    })
297}
298
299/// Deletes the vector row for `memory_id` from `memory_embeddings`.
300///
301/// Called during `forget` and `purge` to keep the embeddings table
302/// consistent with the logical state of `memories`. FK CASCADE on
303/// `memory_embeddings.memory_id` handles the common case, but this
304/// function exists so callers can delete the embedding first
305/// (preserving the row in `memories` for audit).
306///
307/// # Errors
308///
309/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
310pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
311    conn.execute(
312        "DELETE FROM memory_embeddings WHERE memory_id = ?1",
313        params![memory_id],
314    )?;
315    Ok(())
316}
317
318/// Fetches a live memory by `(namespace, name)` and returns all columns.
319///
320/// # Returns
321///
322/// `Ok(Some(row))` when found, `Ok(None)` when missing or soft-deleted.
323///
324/// # Errors
325///
326/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
327pub fn read_by_name(
328    conn: &Connection,
329    namespace: &str,
330    name: &str,
331) -> Result<Option<MemoryRow>, AppError> {
332    let mut stmt = conn.prepare_cached(
333        "SELECT id, namespace, name, type, description, body, body_hash,
334                session_id, source, metadata, created_at, updated_at, deleted_at
335         FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
336    )?;
337    match stmt.query_row(params![namespace, name], |r| {
338        Ok(MemoryRow {
339            id: r.get(0)?,
340            namespace: r.get(1)?,
341            name: r.get(2)?,
342            memory_type: r.get(3)?,
343            description: r.get(4)?,
344            body: r.get(5)?,
345            body_hash: r.get(6)?,
346            session_id: r.get(7)?,
347            source: r.get(8)?,
348            metadata: r.get(9)?,
349            created_at: r.get(10)?,
350            updated_at: r.get(11)?,
351            deleted_at: r.get(12)?,
352        })
353    }) {
354        Ok(m) => Ok(Some(m)),
355        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
356        Err(e) => Err(AppError::Database(e)),
357    }
358}
359
360
361/// Lists live memories in a namespace ordered by `updated_at` descending.
362///
363/// # Arguments
364///
365/// - `memory_type` — optional filter on the `type` column.
366/// - `limit` / `offset` — standard pagination controls in rows.
367///
368/// # Errors
369///
370/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
371pub fn list(
372    conn: &Connection,
373    namespace: &str,
374    memory_type: Option<&str>,
375    limit: usize,
376    offset: usize,
377    include_deleted: bool,
378) -> Result<Vec<MemoryRow>, AppError> {
379    if let Some(mt) = memory_type {
380        let sql = if include_deleted {
381            "SELECT id, namespace, name, type, description, body, body_hash,
382                    session_id, source, metadata, created_at, updated_at, deleted_at
383             FROM memories WHERE namespace=?1 AND type=?2
384             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
385        } else {
386            "SELECT id, namespace, name, type, description, body, body_hash,
387                    session_id, source, metadata, created_at, updated_at, deleted_at
388             FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL
389             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
390        };
391        let mut stmt = conn.prepare_cached(sql)?;
392        let rows = stmt
393            .query_map(params![namespace, mt, limit as i64, offset as i64], |r| {
394                Ok(MemoryRow {
395                    id: r.get(0)?,
396                    namespace: r.get(1)?,
397                    name: r.get(2)?,
398                    memory_type: r.get(3)?,
399                    description: r.get(4)?,
400                    body: r.get(5)?,
401                    body_hash: r.get(6)?,
402                    session_id: r.get(7)?,
403                    source: r.get(8)?,
404                    metadata: r.get(9)?,
405                    created_at: r.get(10)?,
406                    updated_at: r.get(11)?,
407                    deleted_at: r.get(12)?,
408                })
409            })?
410            .collect::<Result<Vec<_>, _>>()?;
411        Ok(rows)
412    } else {
413        let sql = if include_deleted {
414            "SELECT id, namespace, name, type, description, body, body_hash,
415                    session_id, source, metadata, created_at, updated_at, deleted_at
416             FROM memories WHERE namespace=?1
417             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
418        } else {
419            "SELECT id, namespace, name, type, description, body, body_hash,
420                    session_id, source, metadata, created_at, updated_at, deleted_at
421             FROM memories WHERE namespace=?1 AND deleted_at IS NULL
422             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
423        };
424        let mut stmt = conn.prepare_cached(sql)?;
425        let rows = stmt
426            .query_map(params![namespace, limit as i64, offset as i64], |r| {
427                Ok(MemoryRow {
428                    id: r.get(0)?,
429                    namespace: r.get(1)?,
430                    name: r.get(2)?,
431                    memory_type: r.get(3)?,
432                    description: r.get(4)?,
433                    body: r.get(5)?,
434                    body_hash: r.get(6)?,
435                    session_id: r.get(7)?,
436                    source: r.get(8)?,
437                    metadata: r.get(9)?,
438                    created_at: r.get(10)?,
439                    updated_at: r.get(11)?,
440                    deleted_at: r.get(12)?,
441                })
442            })?
443            .collect::<Result<Vec<_>, _>>()?;
444        Ok(rows)
445    }
446}
447
448/// Count.
449pub fn count(
450    conn: &Connection,
451    namespace: &str,
452    memory_type: Option<&str>,
453    include_deleted: bool,
454) -> Result<usize, AppError> {
455    let (sql, params_vec): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match (
456        memory_type,
457        include_deleted,
458    ) {
459        (Some(mt), true) => (
460            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2",
461            vec![
462                Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
463                Box::new(mt.to_string()),
464            ],
465        ),
466        (Some(mt), false) => (
467            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL",
468            vec![
469                Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
470                Box::new(mt.to_string()),
471            ],
472        ),
473        (None, true) => (
474            "SELECT COUNT(*) FROM memories WHERE namespace=?1",
475            vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
476        ),
477        (None, false) => (
478            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND deleted_at IS NULL",
479            vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
480        ),
481    };
482    let params_refs: Vec<&dyn rusqlite::types::ToSql> =
483        params_vec.iter().map(|b| b.as_ref()).collect();
484    let n: i64 = conn.query_row(sql, params_refs.as_slice(), |r| r.get(0))?;
485    Ok(n as usize)
486}
487
488/// Runs a KNN search over `memory_embeddings`, optionally restricted to namespaces.
489///
490/// # Arguments
491///
492/// - `embedding` — query vector of length [`crate::constants::embedding_dim()`].
493/// - `namespaces` — namespaces to search. Empty slice means "all namespaces".
494/// - `memory_type` — optional filter on the `type` column.
495/// - `k` — maximum number of hits to return.
496///
497/// # Returns
498///
499/// A vector of `(memory_id, distance)` pairs sorted by ascending distance.
500///
501/// # Errors
502///
503/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
504pub fn knn_search(
505    conn: &Connection,
506    embedding: &[f32],
507    namespaces: &[String],
508    memory_type: Option<&str>,
509    k: usize,
510) -> Result<Vec<(i64, f32)>, AppError> {
511    if embedding.len() != crate::constants::embedding_dim() {
512        return Err(AppError::Embedding(
513            crate::i18n::validation::embedding_knn_search_dim_mismatch(
514                embedding.len(),
515                crate::constants::embedding_dim(),
516            ),
517        ));
518    }
519    // v1.0.76: full table scan + in-process cosine similarity. The
520    // `memory_embeddings` table no longer has a `distance` column or a
521    // `type` column (the namespace/type filters were dropped for the
522    // BLOB-backed table — they live on the `memories` table). The
523    // cosine result is converted to a "distance" so callers that read
524    // `distance` keep working unchanged.
525
526    // Build the SQL once with the namespace IN clause shape.
527    let placeholders = (0..namespaces.len())
528        .map(|_| "?")
529        .collect::<Vec<_>>()
530        .join(",");
531    let sql = if namespaces.is_empty() {
532        "SELECT memory_id, embedding, namespace FROM memory_embeddings".to_string()
533    } else {
534        format!(
535            "SELECT memory_id, embedding, namespace FROM memory_embeddings \
536             WHERE namespace IN ({placeholders})"
537        )
538    };
539    let mut stmt = conn.prepare(&sql)?;
540    let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
541    for ns in namespaces {
542        raw_params.push(Box::new(ns.clone()));
543    }
544    let param_refs: Vec<&dyn rusqlite::ToSql> = raw_params.iter().map(|b| b.as_ref()).collect();
545    let rows = stmt.query_map(param_refs.as_slice(), |r| {
546        let id: i64 = r.get(0)?;
547        let bytes: Vec<u8> = r.get(1)?;
548        let ns: String = r.get(2)?;
549        Ok((id, bytes, ns))
550    })?;
551
552    // Optionally restrict to a memory type by joining against the
553    // `memories` table on the fly.
554    let type_filter = memory_type.map(|t| t.to_string());
555    let mut candidates: Vec<(i64, f32)> = Vec::new();
556    for row in rows {
557        let (id, bytes, ns) = row?;
558        let stored = crate::embedder::bytes_to_f32(&bytes);
559        if stored.len() != embedding.len() {
560            continue;
561        }
562        let sim = crate::similarity::cosine_similarity(embedding, &stored);
563        let dist = crate::similarity::similarity_to_distance(sim);
564        if let Some(mt) = &type_filter {
565            // Look up the memory's type via a per-row check. For very
566            // large candidate sets this should be batched; for the
567            // v1.0.76 default namespace size (<10k memories) the
568            // per-row lookup is acceptable.
569            let actual: Option<String> = conn
570                .query_row(
571                    "SELECT type FROM memories WHERE id = ?1",
572                    params![id],
573                    |r| r.get(0),
574                )
575                .ok();
576            if actual.as_deref() != Some(mt.as_str()) {
577                continue;
578            }
579        }
580        let _ = ns; // namespace already filtered at SQL level
581        candidates.push((id, dist));
582    }
583    // Sort by distance ascending (best matches first).
584    candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
585    candidates.truncate(k);
586    Ok(candidates)
587}
588
589/// Fetches a live memory by `(namespace, name)` and returns all columns.
590/// Fetches a live memory by primary key and returns all columns.
591///
592/// Mirrors [`read_by_name`] but keyed on `rowid` for use after a KNN search.
593///
594/// # Errors
595///
596/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
597pub fn read_full(conn: &Connection, memory_id: i64) -> Result<Option<MemoryRow>, AppError> {
598    let mut stmt = conn.prepare_cached(
599        "SELECT id, namespace, name, type, description, body, body_hash,
600                session_id, source, metadata, created_at, updated_at, deleted_at
601         FROM memories WHERE id=?1 AND deleted_at IS NULL",
602    )?;
603    match stmt.query_row(params![memory_id], |r| {
604        Ok(MemoryRow {
605            id: r.get(0)?,
606            namespace: r.get(1)?,
607            name: r.get(2)?,
608            memory_type: r.get(3)?,
609            description: r.get(4)?,
610            body: r.get(5)?,
611            body_hash: r.get(6)?,
612            session_id: r.get(7)?,
613            source: r.get(8)?,
614            metadata: r.get(9)?,
615            created_at: r.get(10)?,
616            updated_at: r.get(11)?,
617            deleted_at: r.get(12)?,
618        })
619    }) {
620        Ok(m) => Ok(Some(m)),
621        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
622        Err(e) => Err(AppError::Database(e)),
623    }
624}
625
626
627/// Preprocesses a raw user query for FTS5 `MATCH`.
628///
629/// Technical separators (`-`, `.`, `_`, `/`) are treated as word boundaries by
630/// the `unicode61` tokenizer.  When the query contains any of these characters
631/// the function builds a compound FTS5 expression:
632///   1. A phrase query with the separated tokens (exact compound matching).
633///   2. Individual prefix terms joined with OR (broader recall).
634///
635/// Queries without separators keep the original `term*` prefix behaviour.
636fn preprocess_fts_query(raw: &str) -> String {
637    const SEPARATORS: &[char] = &['-', '.', '_', '/'];
638    const FTS5_SYNTAX: &[char] = &['"', '*', '(', ')', '^', ':'];
639    const FTS5_KEYWORDS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
640
641    let sanitized: String = raw.chars().filter(|c| !FTS5_SYNTAX.contains(c)).collect();
642    let trimmed = sanitized.trim();
643    if trimmed.is_empty() {
644        return String::new();
645    }
646
647    let is_fts_keyword = |t: &str| FTS5_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(t));
648
649    if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
650        return trimmed
651            .split_whitespace()
652            .filter(|t| !is_fts_keyword(t))
653            .map(|t| format!("{t}*"))
654            .collect::<Vec<_>>()
655            .join(" ");
656    }
657    let tokens: Vec<&str> = trimmed
658        .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
659        .filter(|t| !t.is_empty() && !is_fts_keyword(t))
660        .collect();
661    if tokens.is_empty() {
662        return String::new();
663    }
664    let phrase = format!("\"{}\"", tokens.join(" "));
665    let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
666    format!("{phrase} OR {}", prefix_terms.join(" OR "))
667}
668
669/// Executes an FTS5 search against `fts_memories` with query preprocessing.
670///
671/// Technical separators in the query are converted to phrase + prefix OR
672/// expressions so compound terms like `graphrag-precompact.sh` match correctly.
673///
674/// # Errors
675///
676/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
677pub fn fts_search(
678    conn: &Connection,
679    query: &str,
680    namespace: &str,
681    memory_type: Option<&str>,
682    limit: usize,
683) -> Result<Vec<MemoryRow>, AppError> {
684    let fts_query = preprocess_fts_query(query);
685    if let Some(mt) = memory_type {
686        let mut stmt = conn.prepare_cached(
687            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
688                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
689             FROM fts_memories fts
690             JOIN memories m ON m.id = fts.rowid
691             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.type = ?3 AND m.deleted_at IS NULL
692             ORDER BY rank LIMIT ?4",
693        )?;
694        let rows = stmt
695            .query_map(params![fts_query, namespace, mt, limit as i64], |r| {
696                Ok(MemoryRow {
697                    id: r.get(0)?,
698                    namespace: r.get(1)?,
699                    name: r.get(2)?,
700                    memory_type: r.get(3)?,
701                    description: r.get(4)?,
702                    body: r.get(5)?,
703                    body_hash: r.get(6)?,
704                    session_id: r.get(7)?,
705                    source: r.get(8)?,
706                    metadata: r.get(9)?,
707                    created_at: r.get(10)?,
708                    updated_at: r.get(11)?,
709                    deleted_at: r.get(12)?,
710                })
711            })?
712            .collect::<Result<Vec<_>, _>>()?;
713        Ok(rows)
714    } else {
715        let mut stmt = conn.prepare_cached(
716            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
717                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
718             FROM fts_memories fts
719             JOIN memories m ON m.id = fts.rowid
720             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
721             ORDER BY rank LIMIT ?3",
722        )?;
723        let rows = stmt
724            .query_map(params![fts_query, namespace, limit as i64], |r| {
725                Ok(MemoryRow {
726                    id: r.get(0)?,
727                    namespace: r.get(1)?,
728                    name: r.get(2)?,
729                    memory_type: r.get(3)?,
730                    description: r.get(4)?,
731                    body: r.get(5)?,
732                    body_hash: r.get(6)?,
733                    session_id: r.get(7)?,
734                    source: r.get(8)?,
735                    metadata: r.get(9)?,
736                    created_at: r.get(10)?,
737                    updated_at: r.get(11)?,
738                    deleted_at: r.get(12)?,
739                })
740            })?
741            .collect::<Result<Vec<_>, _>>()?;
742        Ok(rows)
743    }
744}
745
746/// Syncs FTS5 external-content index after an UPDATE on the memories table.
747///
748/// The AFTER UPDATE trigger (`trg_fts_au`) is intentionally absent because
749/// sqlite-vec loaded via `sqlite3_auto_extension` conflicts with FTS5 inside
750/// UPDATE triggers. This function performs the equivalent sync in Rust:
751/// DELETE the old entry, then INSERT the new one (external-content FTS5
752/// tables do not support in-place UPDATE).
753#[allow(clippy::too_many_arguments)]
754pub fn sync_fts_after_update(
755    conn: &Connection,
756    memory_id: i64,
757    old_name: &str,
758    old_desc: &str,
759    old_body: &str,
760    new_name: &str,
761    new_desc: &str,
762    new_body: &str,
763) -> Result<(), AppError> {
764    conn.execute(
765        "INSERT INTO fts_memories(fts_memories, rowid, name, description, body)
766         VALUES('delete', ?1, ?2, ?3, ?4)",
767        params![memory_id, old_name, old_desc, old_body],
768    )?;
769    conn.execute(
770        "INSERT INTO fts_memories(rowid, name, description, body)
771         VALUES(?1, ?2, ?3, ?4)",
772        params![memory_id, new_name, new_desc, new_body],
773    )?;
774    Ok(())
775}
776#[cfg(test)]
777mod tests;