Skip to main content

sqlite_graphrag/storage/
memories.rs

1//! Persistence layer for the `memories` table and its vector companion.
2//!
3//! Functions here encapsulate every SQL statement touching `memories`,
4//! `vec_memories` and the FTS5 `fts_memories` shadow table. Callers receive
5//! typed [`MemoryRow`] or [`NewMemory`] values and never build SQL strings.
6
7use crate::embedder::f32_to_bytes;
8use crate::errors::AppError;
9use crate::storage::utils::with_busy_retry;
10use rusqlite::{params, Connection};
11use serde::{Deserialize, Serialize};
12
13/// Input payload for inserting or updating a memory.
14///
15/// `body_hash` must be the BLAKE3 digest of `body`. The `metadata` field is
16/// stored as a TEXT column containing JSON.
17#[derive(Debug, Serialize, Deserialize)]
18pub struct NewMemory {
19    pub namespace: String,
20    pub name: String,
21    pub memory_type: String,
22    pub description: String,
23    pub body: String,
24    pub body_hash: String,
25    pub session_id: Option<String>,
26    pub source: String,
27    pub metadata: serde_json::Value,
28}
29
30/// Fully materialized row from the `memories` table.
31///
32/// Returned by [`read_by_name`], [`read_full`], [`list`] and [`fts_search`].
33/// The `metadata` field is kept as a JSON string to avoid double parsing.
34#[derive(Debug, Serialize)]
35pub struct MemoryRow {
36    pub id: i64,
37    pub namespace: String,
38    pub name: String,
39    pub memory_type: String,
40    pub description: String,
41    pub body: String,
42    pub body_hash: String,
43    pub session_id: Option<String>,
44    pub source: String,
45    pub metadata: String,
46    pub created_at: i64,
47    pub updated_at: i64,
48    /// Unix epoch when the memory was soft-deleted, or `None` for active memories.
49    /// Surfaced in `list --include-deleted --json` so LLM consumers can distinguish
50    /// active from soft-deleted rows without a second SQL query (v1.0.37 H7+M9 fix).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub deleted_at: Option<i64>,
53}
54
55/// Finds a live memory by `(namespace, name)` and returns key metadata.
56///
57/// # Arguments
58///
59/// - `conn` — open SQLite connection configured with the project pragmas.
60/// - `namespace` — resolved namespace for the lookup.
61/// - `name` — kebab-case memory name.
62///
63/// # Returns
64///
65/// `Ok(Some((id, updated_at, max_version)))` when the memory exists and is
66/// not soft-deleted, `Ok(None)` otherwise.
67///
68/// # Errors
69///
70/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
71pub fn find_by_name(
72    conn: &Connection,
73    namespace: &str,
74    name: &str,
75) -> Result<Option<(i64, i64, i64)>, AppError> {
76    let mut stmt = conn.prepare_cached(
77        "SELECT m.id, m.updated_at, COALESCE(MAX(v.version), 0)
78         FROM memories m
79         LEFT JOIN memory_versions v ON v.memory_id = m.id
80         WHERE m.namespace = ?1 AND m.name = ?2 AND m.deleted_at IS NULL
81         GROUP BY m.id",
82    )?;
83    let result = stmt.query_row(params![namespace, name], |r| {
84        Ok((
85            r.get::<_, i64>(0)?,
86            r.get::<_, i64>(1)?,
87            r.get::<_, i64>(2)?,
88        ))
89    });
90    match result {
91        Ok(row) => Ok(Some(row)),
92        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
93        Err(e) => Err(AppError::Database(e)),
94    }
95}
96
97/// Looks up a memory by `(namespace, name)` regardless of deletion state.
98///
99/// Returns `Some((id, is_deleted))` when the row exists.
100/// `is_deleted` is `true` when `deleted_at IS NOT NULL`.
101///
102/// # Errors
103///
104/// Propagates [`AppError::Database`] on SQLite failures.
105pub fn find_by_name_any_state(
106    conn: &Connection,
107    namespace: &str,
108    name: &str,
109) -> Result<Option<(i64, bool)>, AppError> {
110    let mut stmt = conn.prepare_cached(
111        "SELECT id, (deleted_at IS NOT NULL) AS is_deleted
112         FROM memories WHERE namespace = ?1 AND name = ?2",
113    )?;
114    let result = stmt.query_row(params![namespace, name], |r| {
115        Ok((r.get::<_, i64>(0)?, r.get::<_, bool>(1)?))
116    });
117    match result {
118        Ok(row) => Ok(Some(row)),
119        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
120        Err(e) => Err(AppError::Database(e)),
121    }
122}
123
124/// Clears `deleted_at` to restore a soft-deleted memory.
125///
126/// # Errors
127///
128/// Propagates [`AppError::Database`] on SQLite failures.
129pub fn clear_deleted_at(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
130    conn.execute(
131        "UPDATE memories SET deleted_at = NULL WHERE id = ?1",
132        params![memory_id],
133    )?;
134    Ok(())
135}
136
137/// Looks up a live memory by exact `body_hash` within a namespace.
138///
139/// Used during `remember` to short-circuit semantic duplicates before
140/// spending an embedding call.
141///
142/// # Returns
143///
144/// `Ok(Some(id))` when a live memory with the same hash exists,
145/// `Ok(None)` otherwise.
146///
147/// # Errors
148///
149/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
150pub fn find_by_hash(
151    conn: &Connection,
152    namespace: &str,
153    body_hash: &str,
154) -> Result<Option<i64>, AppError> {
155    let mut stmt = conn.prepare_cached(
156        "SELECT id FROM memories WHERE namespace = ?1 AND body_hash = ?2 AND deleted_at IS NULL",
157    )?;
158    match stmt.query_row(params![namespace, body_hash], |r| r.get(0)) {
159        Ok(id) => Ok(Some(id)),
160        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
161        Err(e) => Err(AppError::Database(e)),
162    }
163}
164
165/// Inserts a new row into the `memories` table.
166///
167/// # Arguments
168///
169/// - `conn` — active SQLite connection, typically inside a transaction.
170/// - `m` — validated payload including `body_hash` and serialized metadata.
171///
172/// # Returns
173///
174/// The `rowid` assigned to the newly inserted memory.
175///
176/// # Errors
177///
178/// Returns `Err(AppError::Database)` on insertion failure and
179/// `Err(AppError::Json)` if metadata serialization fails.
180pub fn insert(conn: &Connection, m: &NewMemory) -> Result<i64, AppError> {
181    conn.execute(
182        "INSERT INTO memories (namespace, name, type, description, body, body_hash, session_id, source, metadata)
183         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
184        params![
185            m.namespace, m.name, m.memory_type, m.description, m.body,
186            m.body_hash, m.session_id, m.source,
187            serde_json::to_string(&m.metadata)?
188        ],
189    )?;
190    Ok(conn.last_insert_rowid())
191}
192
193/// Updates an existing memory optionally guarded by optimistic concurrency.
194///
195/// When `expected_updated_at` is `Some(ts)` the row is only updated if its
196/// current `updated_at` equals `ts`. This protects concurrent `edit` calls
197/// from silently clobbering each other.
198///
199/// # Returns
200///
201/// `Ok(true)` when exactly one row was updated, `Ok(false)` when the
202/// optimistic check failed or the memory does not exist.
203///
204/// # Errors
205///
206/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
207pub fn update(
208    conn: &Connection,
209    id: i64,
210    m: &NewMemory,
211    expected_updated_at: Option<i64>,
212) -> Result<bool, AppError> {
213    let affected = if let Some(ts) = expected_updated_at {
214        conn.execute(
215            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
216             session_id=?6, source=?7, metadata=?8
217             WHERE id=?1 AND updated_at=?9 AND deleted_at IS NULL",
218            params![
219                id,
220                m.memory_type,
221                m.description,
222                m.body,
223                m.body_hash,
224                m.session_id,
225                m.source,
226                serde_json::to_string(&m.metadata)?,
227                ts
228            ],
229        )?
230    } else {
231        conn.execute(
232            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
233             session_id=?6, source=?7, metadata=?8
234             WHERE id=?1 AND deleted_at IS NULL",
235            params![
236                id,
237                m.memory_type,
238                m.description,
239                m.body,
240                m.body_hash,
241                m.session_id,
242                m.source,
243                serde_json::to_string(&m.metadata)?
244            ],
245        )?
246    };
247    Ok(affected == 1)
248}
249
250/// Replaces the vector row for a memory in `vec_memories`.
251///
252/// `sqlite-vec` virtual tables do not implement `INSERT OR REPLACE`, so the
253/// existing row is deleted first and a fresh vector is inserted. Callers
254/// must pass an `embedding` with length [`crate::constants::EMBEDDING_DIM`].
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    // sqlite-vec virtual tables do not support INSERT OR REPLACE semantics.
269    // Must delete the existing row first, then insert.  Both statements are
270    // wrapped in `with_busy_retry` because WAL-mode concurrent writers can
271    // cause SQLITE_BUSY on vec0 virtual table writes.
272    let embedding_bytes = f32_to_bytes(embedding);
273    with_busy_retry(|| {
274        conn.execute(
275            "DELETE FROM vec_memories WHERE memory_id = ?1",
276            params![memory_id],
277        )?;
278        conn.execute(
279            "INSERT INTO vec_memories(memory_id, namespace, type, embedding, name, snippet)
280             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
281            params![
282                memory_id,
283                namespace,
284                memory_type,
285                &embedding_bytes,
286                name,
287                snippet
288            ],
289        )?;
290        Ok(())
291    })
292}
293
294/// Deletes the vector row for `memory_id` from `vec_memories`.
295///
296/// Called during `forget` and `purge` to keep the vector table consistent
297/// with the logical state of `memories`.
298///
299/// # Errors
300///
301/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
302pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
303    conn.execute(
304        "DELETE FROM vec_memories WHERE memory_id = ?1",
305        params![memory_id],
306    )?;
307    Ok(())
308}
309
310/// Fetches a live memory by `(namespace, name)` and returns all columns.
311///
312/// # Returns
313///
314/// `Ok(Some(row))` when found, `Ok(None)` when missing or soft-deleted.
315///
316/// # Errors
317///
318/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
319pub fn read_by_name(
320    conn: &Connection,
321    namespace: &str,
322    name: &str,
323) -> Result<Option<MemoryRow>, AppError> {
324    let mut stmt = conn.prepare_cached(
325        "SELECT id, namespace, name, type, description, body, body_hash,
326                session_id, source, metadata, created_at, updated_at, deleted_at
327         FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
328    )?;
329    match stmt.query_row(params![namespace, name], |r| {
330        Ok(MemoryRow {
331            id: r.get(0)?,
332            namespace: r.get(1)?,
333            name: r.get(2)?,
334            memory_type: r.get(3)?,
335            description: r.get(4)?,
336            body: r.get(5)?,
337            body_hash: r.get(6)?,
338            session_id: r.get(7)?,
339            source: r.get(8)?,
340            metadata: r.get(9)?,
341            created_at: r.get(10)?,
342            updated_at: r.get(11)?,
343            deleted_at: r.get(12)?,
344        })
345    }) {
346        Ok(m) => Ok(Some(m)),
347        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
348        Err(e) => Err(AppError::Database(e)),
349    }
350}
351
352/// Soft-deletes a memory by setting `deleted_at = unixepoch()`.
353///
354/// Versions and chunks are preserved so `restore` can undo the operation
355/// until a subsequent `purge` reclaims the storage permanently.
356///
357/// # Returns
358///
359/// `Ok(true)` when a live memory was soft-deleted, `Ok(false)` when no
360/// matching live row existed.
361///
362/// # Errors
363///
364/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
365pub fn soft_delete(conn: &Connection, namespace: &str, name: &str) -> Result<bool, AppError> {
366    let affected = conn.execute(
367        "UPDATE memories SET deleted_at = unixepoch() WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
368        params![namespace, name],
369    )?;
370    Ok(affected == 1)
371}
372
373/// Lists live memories in a namespace ordered by `updated_at` descending.
374///
375/// # Arguments
376///
377/// - `memory_type` — optional filter on the `type` column.
378/// - `limit` / `offset` — standard pagination controls in rows.
379///
380/// # Errors
381///
382/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
383pub fn list(
384    conn: &Connection,
385    namespace: &str,
386    memory_type: Option<&str>,
387    limit: usize,
388    offset: usize,
389    include_deleted: bool,
390) -> Result<Vec<MemoryRow>, AppError> {
391    if let Some(mt) = memory_type {
392        let sql = if include_deleted {
393            "SELECT id, namespace, name, type, description, body, body_hash,
394                    session_id, source, metadata, created_at, updated_at, deleted_at
395             FROM memories WHERE namespace=?1 AND type=?2
396             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
397        } else {
398            "SELECT id, namespace, name, type, description, body, body_hash,
399                    session_id, source, metadata, created_at, updated_at, deleted_at
400             FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL
401             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
402        };
403        let mut stmt = conn.prepare_cached(sql)?;
404        let rows = stmt
405            .query_map(params![namespace, mt, limit as i64, offset as i64], |r| {
406                Ok(MemoryRow {
407                    id: r.get(0)?,
408                    namespace: r.get(1)?,
409                    name: r.get(2)?,
410                    memory_type: r.get(3)?,
411                    description: r.get(4)?,
412                    body: r.get(5)?,
413                    body_hash: r.get(6)?,
414                    session_id: r.get(7)?,
415                    source: r.get(8)?,
416                    metadata: r.get(9)?,
417                    created_at: r.get(10)?,
418                    updated_at: r.get(11)?,
419                    deleted_at: r.get(12)?,
420                })
421            })?
422            .collect::<Result<Vec<_>, _>>()?;
423        Ok(rows)
424    } else {
425        let sql = if include_deleted {
426            "SELECT id, namespace, name, type, description, body, body_hash,
427                    session_id, source, metadata, created_at, updated_at, deleted_at
428             FROM memories WHERE namespace=?1
429             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
430        } else {
431            "SELECT id, namespace, name, type, description, body, body_hash,
432                    session_id, source, metadata, created_at, updated_at, deleted_at
433             FROM memories WHERE namespace=?1 AND deleted_at IS NULL
434             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
435        };
436        let mut stmt = conn.prepare_cached(sql)?;
437        let rows = stmt
438            .query_map(params![namespace, limit as i64, offset as i64], |r| {
439                Ok(MemoryRow {
440                    id: r.get(0)?,
441                    namespace: r.get(1)?,
442                    name: r.get(2)?,
443                    memory_type: r.get(3)?,
444                    description: r.get(4)?,
445                    body: r.get(5)?,
446                    body_hash: r.get(6)?,
447                    session_id: r.get(7)?,
448                    source: r.get(8)?,
449                    metadata: r.get(9)?,
450                    created_at: r.get(10)?,
451                    updated_at: r.get(11)?,
452                    deleted_at: r.get(12)?,
453                })
454            })?
455            .collect::<Result<Vec<_>, _>>()?;
456        Ok(rows)
457    }
458}
459
460/// Runs a KNN search over `vec_memories`, optionally restricted to namespaces.
461///
462/// # Arguments
463///
464/// - `embedding` — query vector of length [`crate::constants::EMBEDDING_DIM`].
465/// - `namespaces` — namespaces to search. Empty slice means "all namespaces".
466/// - `memory_type` — optional filter on the `type` column.
467/// - `k` — maximum number of hits to return.
468///
469/// # Returns
470///
471/// A vector of `(memory_id, distance)` pairs sorted by ascending distance.
472///
473/// # Errors
474///
475/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
476pub fn knn_search(
477    conn: &Connection,
478    embedding: &[f32],
479    namespaces: &[String],
480    memory_type: Option<&str>,
481    k: usize,
482) -> Result<Vec<(i64, f32)>, AppError> {
483    let bytes = f32_to_bytes(embedding);
484
485    match namespaces.len() {
486        0 => {
487            // No namespace filter — search all namespaces.
488            if let Some(mt) = memory_type {
489                let mut stmt = conn.prepare_cached(
490                    "SELECT memory_id, distance FROM vec_memories \
491                     WHERE embedding MATCH ?1 AND type = ?2 \
492                     ORDER BY distance LIMIT ?3",
493                )?;
494                let rows = stmt
495                    .query_map(params![bytes, mt, k as i64], |r| {
496                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
497                    })?
498                    .collect::<Result<Vec<_>, _>>()?;
499                Ok(rows)
500            } else {
501                let mut stmt = conn.prepare_cached(
502                    "SELECT memory_id, distance FROM vec_memories \
503                     WHERE embedding MATCH ?1 \
504                     ORDER BY distance LIMIT ?2",
505                )?;
506                let rows = stmt
507                    .query_map(params![bytes, k as i64], |r| {
508                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
509                    })?
510                    .collect::<Result<Vec<_>, _>>()?;
511                Ok(rows)
512            }
513        }
514        1 => {
515            // Fast single-namespace path (preserved from previous implementation).
516            let ns = &namespaces[0];
517            if let Some(mt) = memory_type {
518                let mut stmt = conn.prepare_cached(
519                    "SELECT memory_id, distance FROM vec_memories \
520                     WHERE embedding MATCH ?1 AND namespace = ?2 AND type = ?3 \
521                     ORDER BY distance LIMIT ?4",
522                )?;
523                let rows = stmt
524                    .query_map(params![bytes, ns, mt, k as i64], |r| {
525                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
526                    })?
527                    .collect::<Result<Vec<_>, _>>()?;
528                Ok(rows)
529            } else {
530                let mut stmt = conn.prepare_cached(
531                    "SELECT memory_id, distance FROM vec_memories \
532                     WHERE embedding MATCH ?1 AND namespace = ?2 \
533                     ORDER BY distance LIMIT ?3",
534                )?;
535                let rows = stmt
536                    .query_map(params![bytes, ns, k as i64], |r| {
537                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
538                    })?
539                    .collect::<Result<Vec<_>, _>>()?;
540                Ok(rows)
541            }
542        }
543        _ => {
544            // Multiple explicit namespaces: build IN clause with positional placeholders.
545            // rusqlite does not support array binding, so we generate "?,?,..." manually.
546            let placeholders = (0..namespaces.len())
547                .map(|_| "?")
548                .collect::<Vec<_>>()
549                .join(",");
550            if let Some(mt) = memory_type {
551                let query = format!(
552                    "SELECT memory_id, distance FROM vec_memories \
553                     WHERE embedding MATCH ? AND type = ? AND namespace IN ({placeholders}) \
554                     ORDER BY distance LIMIT ?"
555                );
556                let mut stmt = conn.prepare(&query)?;
557                // Params: [bytes, mt, ns0, ns1, ..., k]
558                let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> =
559                    vec![Box::new(bytes), Box::new(mt.to_string())];
560                for ns in namespaces {
561                    raw_params.push(Box::new(ns.clone()));
562                }
563                raw_params.push(Box::new(k as i64));
564                let param_refs: Vec<&dyn rusqlite::ToSql> =
565                    raw_params.iter().map(|b| b.as_ref()).collect();
566                let rows = stmt
567                    .query_map(param_refs.as_slice(), |r| {
568                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
569                    })?
570                    .collect::<Result<Vec<_>, _>>()?;
571                Ok(rows)
572            } else {
573                let query = format!(
574                    "SELECT memory_id, distance FROM vec_memories \
575                     WHERE embedding MATCH ? AND namespace IN ({placeholders}) \
576                     ORDER BY distance LIMIT ?"
577                );
578                let mut stmt = conn.prepare(&query)?;
579                // Params: [bytes, ns0, ns1, ..., k]
580                let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(bytes)];
581                for ns in namespaces {
582                    raw_params.push(Box::new(ns.clone()));
583                }
584                raw_params.push(Box::new(k as i64));
585                let param_refs: Vec<&dyn rusqlite::ToSql> =
586                    raw_params.iter().map(|b| b.as_ref()).collect();
587                let rows = stmt
588                    .query_map(param_refs.as_slice(), |r| {
589                        Ok((r.get::<_, i64>(0)?, r.get::<_, f32>(1)?))
590                    })?
591                    .collect::<Result<Vec<_>, _>>()?;
592                Ok(rows)
593            }
594        }
595    }
596}
597
598/// Fetches a live memory by primary key and returns all columns.
599///
600/// Mirrors [`read_by_name`] but keyed on `rowid` for use after a KNN search.
601///
602/// # Errors
603///
604/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
605pub fn read_full(conn: &Connection, memory_id: i64) -> Result<Option<MemoryRow>, AppError> {
606    let mut stmt = conn.prepare_cached(
607        "SELECT id, namespace, name, type, description, body, body_hash,
608                session_id, source, metadata, created_at, updated_at, deleted_at
609         FROM memories WHERE id=?1 AND deleted_at IS NULL",
610    )?;
611    match stmt.query_row(params![memory_id], |r| {
612        Ok(MemoryRow {
613            id: r.get(0)?,
614            namespace: r.get(1)?,
615            name: r.get(2)?,
616            memory_type: r.get(3)?,
617            description: r.get(4)?,
618            body: r.get(5)?,
619            body_hash: r.get(6)?,
620            session_id: r.get(7)?,
621            source: r.get(8)?,
622            metadata: r.get(9)?,
623            created_at: r.get(10)?,
624            updated_at: r.get(11)?,
625            deleted_at: r.get(12)?,
626        })
627    }) {
628        Ok(m) => Ok(Some(m)),
629        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
630        Err(e) => Err(AppError::Database(e)),
631    }
632}
633
634/// Fetches all memory_ids in a namespace that are soft-deleted and whose
635/// `deleted_at` is older than `before_ts` (unix epoch seconds).
636///
637/// Used by `purge` to collect stale rows for permanent deletion.
638///
639/// # Errors
640///
641/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
642pub fn list_deleted_before(
643    conn: &Connection,
644    namespace: &str,
645    before_ts: i64,
646) -> Result<Vec<i64>, AppError> {
647    let mut stmt = conn.prepare_cached(
648        "SELECT id FROM memories WHERE namespace = ?1 AND deleted_at IS NOT NULL AND deleted_at < ?2",
649    )?;
650    let ids = stmt
651        .query_map(params![namespace, before_ts], |r| r.get::<_, i64>(0))?
652        .collect::<Result<Vec<_>, _>>()?;
653    Ok(ids)
654}
655
656/// Preprocesses a raw user query for FTS5 `MATCH`.
657///
658/// Technical separators (`-`, `.`, `_`, `/`) are treated as word boundaries by
659/// the `unicode61` tokenizer.  When the query contains any of these characters
660/// the function builds a compound FTS5 expression:
661///   1. A phrase query with the separated tokens (exact compound matching).
662///   2. Individual prefix terms joined with OR (broader recall).
663///
664/// Queries without separators keep the original `term*` prefix behaviour.
665fn preprocess_fts_query(raw: &str) -> String {
666    const SEPARATORS: &[char] = &['-', '.', '_', '/'];
667    const FTS5_SYNTAX: &[char] = &['"', '*', '(', ')', '^', ':'];
668    const FTS5_KEYWORDS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
669
670    let sanitized: String = raw.chars().filter(|c| !FTS5_SYNTAX.contains(c)).collect();
671    let trimmed = sanitized.trim();
672    if trimmed.is_empty() {
673        return String::new();
674    }
675
676    let is_fts_keyword = |t: &str| FTS5_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(t));
677
678    if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
679        return trimmed
680            .split_whitespace()
681            .filter(|t| !is_fts_keyword(t))
682            .map(|t| format!("{t}*"))
683            .collect::<Vec<_>>()
684            .join(" ");
685    }
686    let tokens: Vec<&str> = trimmed
687        .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
688        .filter(|t| !t.is_empty() && !is_fts_keyword(t))
689        .collect();
690    if tokens.is_empty() {
691        return String::new();
692    }
693    let phrase = format!("\"{}\"", tokens.join(" "));
694    let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
695    format!("{phrase} OR {}", prefix_terms.join(" OR "))
696}
697
698/// Executes an FTS5 search against `fts_memories` with query preprocessing.
699///
700/// Technical separators in the query are converted to phrase + prefix OR
701/// expressions so compound terms like `graphrag-precompact.sh` match correctly.
702///
703/// # Errors
704///
705/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
706pub fn fts_search(
707    conn: &Connection,
708    query: &str,
709    namespace: &str,
710    memory_type: Option<&str>,
711    limit: usize,
712) -> Result<Vec<MemoryRow>, AppError> {
713    let fts_query = preprocess_fts_query(query);
714    if let Some(mt) = memory_type {
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.type = ?3 AND m.deleted_at IS NULL
721             ORDER BY rank LIMIT ?4",
722        )?;
723        let rows = stmt
724            .query_map(params![fts_query, namespace, mt, 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    } else {
744        let mut stmt = conn.prepare_cached(
745            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
746                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
747             FROM fts_memories fts
748             JOIN memories m ON m.id = fts.rowid
749             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
750             ORDER BY rank LIMIT ?3",
751        )?;
752        let rows = stmt
753            .query_map(params![fts_query, namespace, limit as i64], |r| {
754                Ok(MemoryRow {
755                    id: r.get(0)?,
756                    namespace: r.get(1)?,
757                    name: r.get(2)?,
758                    memory_type: r.get(3)?,
759                    description: r.get(4)?,
760                    body: r.get(5)?,
761                    body_hash: r.get(6)?,
762                    session_id: r.get(7)?,
763                    source: r.get(8)?,
764                    metadata: r.get(9)?,
765                    created_at: r.get(10)?,
766                    updated_at: r.get(11)?,
767                    deleted_at: r.get(12)?,
768                })
769            })?
770            .collect::<Result<Vec<_>, _>>()?;
771        Ok(rows)
772    }
773}
774
775/// Syncs FTS5 external-content index after an UPDATE on the memories table.
776///
777/// The AFTER UPDATE trigger (`trg_fts_au`) is intentionally absent because
778/// sqlite-vec loaded via `sqlite3_auto_extension` conflicts with FTS5 inside
779/// UPDATE triggers. This function performs the equivalent sync in Rust:
780/// DELETE the old entry, then INSERT the new one (external-content FTS5
781/// tables do not support in-place UPDATE).
782#[allow(clippy::too_many_arguments)]
783pub fn sync_fts_after_update(
784    conn: &Connection,
785    memory_id: i64,
786    old_name: &str,
787    old_desc: &str,
788    old_body: &str,
789    new_name: &str,
790    new_desc: &str,
791    new_body: &str,
792) -> Result<(), AppError> {
793    conn.execute(
794        "INSERT INTO fts_memories(fts_memories, rowid, name, description, body)
795         VALUES('delete', ?1, ?2, ?3, ?4)",
796        params![memory_id, old_name, old_desc, old_body],
797    )?;
798    conn.execute(
799        "INSERT INTO fts_memories(rowid, name, description, body)
800         VALUES(?1, ?2, ?3, ?4)",
801        params![memory_id, new_name, new_desc, new_body],
802    )?;
803    Ok(())
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use rusqlite::Connection;
810
811    type TestResult = Result<(), Box<dyn std::error::Error>>;
812
813    fn setup_conn() -> Result<Connection, Box<dyn std::error::Error>> {
814        crate::storage::connection::register_vec_extension();
815        let mut conn = Connection::open_in_memory()?;
816        conn.execute_batch(
817            "PRAGMA foreign_keys = ON;
818             PRAGMA temp_store = MEMORY;",
819        )?;
820        crate::migrations::runner().run(&mut conn)?;
821        Ok(conn)
822    }
823
824    fn new_memory(name: &str) -> NewMemory {
825        NewMemory {
826            namespace: "global".to_string(),
827            name: name.to_string(),
828            memory_type: "user".to_string(),
829            description: "descricao de teste".to_string(),
830            body: "test memory body".to_string(),
831            body_hash: format!("hash-{name}"),
832            session_id: None,
833            source: "agent".to_string(),
834            metadata: serde_json::json!({}),
835        }
836    }
837
838    #[test]
839    fn insert_and_find_by_name_return_id() -> TestResult {
840        let conn = setup_conn()?;
841        let m = new_memory("mem-alpha");
842        let id = insert(&conn, &m)?;
843        assert!(id > 0);
844
845        let found = find_by_name(&conn, "global", "mem-alpha")?;
846        assert!(found.is_some());
847        let (found_id, _, _) = found.ok_or("mem-alpha should exist")?;
848        assert_eq!(found_id, id);
849        Ok(())
850    }
851
852    #[test]
853    fn find_by_name_returns_none_when_not_found() -> TestResult {
854        let conn = setup_conn()?;
855        let result = find_by_name(&conn, "global", "inexistente")?;
856        assert!(result.is_none());
857        Ok(())
858    }
859
860    #[test]
861    fn find_by_hash_returns_correct_id() -> TestResult {
862        let conn = setup_conn()?;
863        let m = new_memory("mem-hash");
864        let id = insert(&conn, &m)?;
865
866        let found = find_by_hash(&conn, "global", "hash-mem-hash")?;
867        assert_eq!(found, Some(id));
868        Ok(())
869    }
870
871    #[test]
872    fn find_by_hash_returns_none_when_hash_not_found() -> TestResult {
873        let conn = setup_conn()?;
874        let result = find_by_hash(&conn, "global", "hash-inexistente")?;
875        assert!(result.is_none());
876        Ok(())
877    }
878
879    #[test]
880    fn find_by_hash_ignores_different_namespace() -> TestResult {
881        let conn = setup_conn()?;
882        let m = new_memory("mem-ns");
883        insert(&conn, &m)?;
884
885        let result = find_by_hash(&conn, "outro-namespace", "hash-mem-ns")?;
886        assert!(result.is_none());
887        Ok(())
888    }
889
890    #[test]
891    fn read_by_name_returns_full_memory() -> TestResult {
892        let conn = setup_conn()?;
893        let m = new_memory("mem-read");
894        let id = insert(&conn, &m)?;
895
896        let row = read_by_name(&conn, "global", "mem-read")?.ok_or("mem-read should exist")?;
897        assert_eq!(row.id, id);
898        assert_eq!(row.name, "mem-read");
899        assert_eq!(row.memory_type, "user");
900        assert_eq!(row.body, "test memory body");
901        assert_eq!(row.namespace, "global");
902        Ok(())
903    }
904
905    #[test]
906    fn read_by_name_returns_none_for_missing() -> TestResult {
907        let conn = setup_conn()?;
908        let result = read_by_name(&conn, "global", "nao-existe")?;
909        assert!(result.is_none());
910        Ok(())
911    }
912
913    #[test]
914    fn read_full_by_id_returns_memory() -> TestResult {
915        let conn = setup_conn()?;
916        let m = new_memory("mem-full");
917        let id = insert(&conn, &m)?;
918
919        let row = read_full(&conn, id)?.ok_or("mem-full should exist")?;
920        assert_eq!(row.id, id);
921        assert_eq!(row.name, "mem-full");
922        Ok(())
923    }
924
925    #[test]
926    fn read_full_returns_none_for_missing_id() -> TestResult {
927        let conn = setup_conn()?;
928        let result = read_full(&conn, 9999)?;
929        assert!(result.is_none());
930        Ok(())
931    }
932
933    #[test]
934    fn update_without_optimism_modifies_fields() -> TestResult {
935        let conn = setup_conn()?;
936        let m = new_memory("mem-upd");
937        let id = insert(&conn, &m)?;
938
939        let mut m2 = new_memory("mem-upd");
940        m2.body = "updated body".to_string();
941        m2.body_hash = "hash-novo".to_string();
942        let ok = update(&conn, id, &m2, None)?;
943        assert!(ok);
944
945        let row = read_full(&conn, id)?.ok_or("mem-upd should exist")?;
946        assert_eq!(row.body, "updated body");
947        assert_eq!(row.body_hash, "hash-novo");
948        Ok(())
949    }
950
951    #[test]
952    fn update_with_correct_expected_updated_at_succeeds() -> TestResult {
953        let conn = setup_conn()?;
954        let m = new_memory("mem-opt");
955        let id = insert(&conn, &m)?;
956
957        let (_, updated_at, _) =
958            find_by_name(&conn, "global", "mem-opt")?.ok_or("mem-opt should exist")?;
959
960        let mut m2 = new_memory("mem-opt");
961        m2.body = "optimistic body".to_string();
962        m2.body_hash = "hash-optimistic".to_string();
963        let ok = update(&conn, id, &m2, Some(updated_at))?;
964        assert!(ok);
965
966        let row = read_full(&conn, id)?.ok_or("mem-opt should exist after update")?;
967        assert_eq!(row.body, "optimistic body");
968        Ok(())
969    }
970
971    #[test]
972    fn update_with_wrong_expected_updated_at_returns_false() -> TestResult {
973        let conn = setup_conn()?;
974        let m = new_memory("mem-conflict");
975        let id = insert(&conn, &m)?;
976
977        let mut m2 = new_memory("mem-conflict");
978        m2.body = "must not appear".to_string();
979        m2.body_hash = "hash-x".to_string();
980        let ok = update(&conn, id, &m2, Some(0))?;
981        assert!(!ok);
982
983        let row = read_full(&conn, id)?.ok_or("mem-conflict should exist")?;
984        assert_eq!(row.body, "test memory body");
985        Ok(())
986    }
987
988    #[test]
989    fn update_missing_id_returns_false() -> TestResult {
990        let conn = setup_conn()?;
991        let m = new_memory("fantasma");
992        let ok = update(&conn, 9999, &m, None)?;
993        assert!(!ok);
994        Ok(())
995    }
996
997    #[test]
998    fn soft_delete_marks_deleted_at() -> TestResult {
999        let conn = setup_conn()?;
1000        let m = new_memory("mem-del");
1001        insert(&conn, &m)?;
1002
1003        let ok = soft_delete(&conn, "global", "mem-del")?;
1004        assert!(ok);
1005
1006        let result = find_by_name(&conn, "global", "mem-del")?;
1007        assert!(result.is_none());
1008
1009        let result_read = read_by_name(&conn, "global", "mem-del")?;
1010        assert!(result_read.is_none());
1011        Ok(())
1012    }
1013
1014    #[test]
1015    fn soft_delete_returns_false_when_not_found() -> TestResult {
1016        let conn = setup_conn()?;
1017        let ok = soft_delete(&conn, "global", "nao-existe")?;
1018        assert!(!ok);
1019        Ok(())
1020    }
1021
1022    #[test]
1023    fn double_soft_delete_returns_false_on_second_call() -> TestResult {
1024        let conn = setup_conn()?;
1025        let m = new_memory("mem-del2");
1026        insert(&conn, &m)?;
1027
1028        soft_delete(&conn, "global", "mem-del2")?;
1029        let ok = soft_delete(&conn, "global", "mem-del2")?;
1030        assert!(!ok);
1031        Ok(())
1032    }
1033
1034    #[test]
1035    fn list_returns_memories_from_namespace() -> TestResult {
1036        let conn = setup_conn()?;
1037        insert(&conn, &new_memory("mem-list-a"))?;
1038        insert(&conn, &new_memory("mem-list-b"))?;
1039
1040        let rows = list(&conn, "global", None, 10, 0, false)?;
1041        assert!(rows.len() >= 2);
1042        let nomes: Vec<_> = rows.iter().map(|r| r.name.as_str()).collect();
1043        assert!(nomes.contains(&"mem-list-a"));
1044        assert!(nomes.contains(&"mem-list-b"));
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn list_with_type_filter_returns_only_correct_type() -> TestResult {
1050        let conn = setup_conn()?;
1051        insert(&conn, &new_memory("mem-user"))?;
1052
1053        let mut m2 = new_memory("mem-feedback");
1054        m2.memory_type = "feedback".to_string();
1055        insert(&conn, &m2)?;
1056
1057        let rows_user = list(&conn, "global", Some("user"), 10, 0, false)?;
1058        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
1059
1060        let rows_fb = list(&conn, "global", Some("feedback"), 10, 0, false)?;
1061        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
1062        Ok(())
1063    }
1064
1065    #[test]
1066    fn list_exclui_soft_deleted() -> TestResult {
1067        let conn = setup_conn()?;
1068        let m = new_memory("mem-excluida");
1069        insert(&conn, &m)?;
1070        soft_delete(&conn, "global", "mem-excluida")?;
1071
1072        let rows = list(&conn, "global", None, 10, 0, false)?;
1073        assert!(rows.iter().all(|r| r.name != "mem-excluida"));
1074        Ok(())
1075    }
1076
1077    #[test]
1078    fn list_pagination_works() -> TestResult {
1079        let conn = setup_conn()?;
1080        for i in 0..5 {
1081            insert(&conn, &new_memory(&format!("mem-pag-{i}")))?;
1082        }
1083
1084        let pagina1 = list(&conn, "global", None, 2, 0, false)?;
1085        let pagina2 = list(&conn, "global", None, 2, 2, false)?;
1086        assert!(pagina1.len() <= 2);
1087        assert!(pagina2.len() <= 2);
1088        if !pagina1.is_empty() && !pagina2.is_empty() {
1089            assert_ne!(pagina1[0].id, pagina2[0].id);
1090        }
1091        Ok(())
1092    }
1093
1094    #[test]
1095    fn upsert_vec_and_delete_vec_work() -> TestResult {
1096        let conn = setup_conn()?;
1097        let m = new_memory("mem-vec");
1098        let id = insert(&conn, &m)?;
1099
1100        let embedding: Vec<f32> = vec![0.1; 384];
1101        upsert_vec(
1102            &conn, id, "global", "user", &embedding, "mem-vec", "snippet",
1103        )?;
1104
1105        let count: i64 = conn.query_row(
1106            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1107            params![id],
1108            |r| r.get(0),
1109        )?;
1110        assert_eq!(count, 1);
1111
1112        delete_vec(&conn, id)?;
1113
1114        let count_after: i64 = conn.query_row(
1115            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1116            params![id],
1117            |r| r.get(0),
1118        )?;
1119        assert_eq!(count_after, 0);
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn upsert_vec_replaces_existing_vector() -> TestResult {
1125        let conn = setup_conn()?;
1126        let m = new_memory("mem-vec-upsert");
1127        let id = insert(&conn, &m)?;
1128
1129        let emb1: Vec<f32> = vec![0.1; 384];
1130        upsert_vec(&conn, id, "global", "user", &emb1, "mem-vec-upsert", "s1")?;
1131
1132        let emb2: Vec<f32> = vec![0.9; 384];
1133        upsert_vec(&conn, id, "global", "user", &emb2, "mem-vec-upsert", "s2")?;
1134
1135        let count: i64 = conn.query_row(
1136            "SELECT COUNT(*) FROM vec_memories WHERE memory_id = ?1",
1137            params![id],
1138            |r| r.get(0),
1139        )?;
1140        assert_eq!(count, 1);
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn knn_search_returns_results_by_distance() -> TestResult {
1146        let conn = setup_conn()?;
1147
1148        // emb_a: predominantemente positivo — cosseno alto com query [1.0; 384]
1149        let ma = new_memory("mem-knn-a");
1150        let id_a = insert(&conn, &ma)?;
1151        let emb_a: Vec<f32> = vec![1.0; 384];
1152        upsert_vec(&conn, id_a, "global", "user", &emb_a, "mem-knn-a", "s")?;
1153
1154        // emb_b: predominantemente negativo — cosseno baixo com query [1.0; 384]
1155        let mb = new_memory("mem-knn-b");
1156        let id_b = insert(&conn, &mb)?;
1157        let emb_b: Vec<f32> = vec![-1.0; 384];
1158        upsert_vec(&conn, id_b, "global", "user", &emb_b, "mem-knn-b", "s")?;
1159
1160        let query: Vec<f32> = vec![1.0; 384];
1161        let results = knn_search(&conn, &query, &["global".to_string()], None, 2)?;
1162        assert!(!results.is_empty());
1163        assert_eq!(results[0].0, id_a);
1164        Ok(())
1165    }
1166
1167    #[test]
1168    fn knn_search_with_type_filter_restricts_result() -> TestResult {
1169        let conn = setup_conn()?;
1170
1171        let ma = new_memory("mem-knn-tipo-user");
1172        let id_a = insert(&conn, &ma)?;
1173        let emb: Vec<f32> = vec![1.0; 384];
1174        upsert_vec(
1175            &conn,
1176            id_a,
1177            "global",
1178            "user",
1179            &emb,
1180            "mem-knn-tipo-user",
1181            "s",
1182        )?;
1183
1184        let mut mb = new_memory("mem-knn-tipo-fb");
1185        mb.memory_type = "feedback".to_string();
1186        let id_b = insert(&conn, &mb)?;
1187        upsert_vec(
1188            &conn,
1189            id_b,
1190            "global",
1191            "feedback",
1192            &emb,
1193            "mem-knn-tipo-fb",
1194            "s",
1195        )?;
1196
1197        let query: Vec<f32> = vec![1.0; 384];
1198        let results_user = knn_search(&conn, &query, &["global".to_string()], Some("user"), 5)?;
1199        assert!(results_user.iter().all(|(id, _)| *id == id_a));
1200
1201        let results_fb = knn_search(&conn, &query, &["global".to_string()], Some("feedback"), 5)?;
1202        assert!(results_fb.iter().all(|(id, _)| *id == id_b));
1203        Ok(())
1204    }
1205
1206    #[test]
1207    fn fts_search_finds_by_prefix_in_body() -> TestResult {
1208        let conn = setup_conn()?;
1209        let mut m = new_memory("mem-fts");
1210        m.body = "linguagem de programacao rust".to_string();
1211        insert(&conn, &m)?;
1212
1213        conn.execute_batch(
1214            "INSERT INTO fts_memories(rowid, name, description, body)
1215             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1216        )?;
1217
1218        let rows = fts_search(&conn, "programacao", "global", None, 10)?;
1219        assert!(!rows.is_empty());
1220        assert!(rows.iter().any(|r| r.name == "mem-fts"));
1221        Ok(())
1222    }
1223
1224    #[test]
1225    fn fts_search_with_type_filter() -> TestResult {
1226        let conn = setup_conn()?;
1227        let mut m = new_memory("mem-fts-tipo");
1228        m.body = "linguagem especial para filtro".to_string();
1229        insert(&conn, &m)?;
1230
1231        let mut m2 = new_memory("mem-fts-feedback");
1232        m2.memory_type = "feedback".to_string();
1233        m2.body = "linguagem especial para filtro".to_string();
1234        insert(&conn, &m2)?;
1235
1236        conn.execute_batch(
1237            "INSERT INTO fts_memories(rowid, name, description, body)
1238             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1239        )?;
1240
1241        let rows_user = fts_search(&conn, "especial", "global", Some("user"), 10)?;
1242        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
1243
1244        let rows_fb = fts_search(&conn, "especial", "global", Some("feedback"), 10)?;
1245        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
1246        Ok(())
1247    }
1248
1249    #[test]
1250    fn fts_search_excludes_deleted() -> TestResult {
1251        let conn = setup_conn()?;
1252        let mut m = new_memory("mem-fts-del");
1253        m.body = "deleted fts content".to_string();
1254        insert(&conn, &m)?;
1255
1256        conn.execute_batch(
1257            "INSERT INTO fts_memories(rowid, name, description, body)
1258             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1259        )?;
1260
1261        soft_delete(&conn, "global", "mem-fts-del")?;
1262
1263        let rows = fts_search(&conn, "deleted", "global", None, 10)?;
1264        assert!(rows.iter().all(|r| r.name != "mem-fts-del"));
1265        Ok(())
1266    }
1267
1268    #[test]
1269    fn list_deleted_before_returns_correct_ids() -> TestResult {
1270        let conn = setup_conn()?;
1271        let m = new_memory("mem-purge");
1272        insert(&conn, &m)?;
1273        soft_delete(&conn, "global", "mem-purge")?;
1274
1275        let ids = list_deleted_before(&conn, "global", i64::MAX)?;
1276        assert!(!ids.is_empty());
1277
1278        let ids_antes = list_deleted_before(&conn, "global", 0)?;
1279        assert!(ids_antes.is_empty());
1280        Ok(())
1281    }
1282
1283    #[test]
1284    fn find_by_name_returns_correct_max_version() -> TestResult {
1285        let conn = setup_conn()?;
1286        let m = new_memory("mem-ver");
1287        let id = insert(&conn, &m)?;
1288
1289        let (_, _, v0) = find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist")?;
1290        assert_eq!(v0, 0);
1291
1292        conn.execute(
1293            "INSERT INTO memory_versions (memory_id, version, name, type, description, body, metadata, change_reason)
1294             VALUES (?1, 1, 'mem-ver', 'user', 'desc', 'body', '{}', 'create')",
1295            params![id],
1296        )?;
1297
1298        let (_, _, v1) =
1299            find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist after insert")?;
1300        assert_eq!(v1, 1);
1301        Ok(())
1302    }
1303
1304    #[test]
1305    fn insert_com_metadata_json() -> TestResult {
1306        let conn = setup_conn()?;
1307        let mut m = new_memory("mem-meta");
1308        m.metadata = serde_json::json!({"chave": "valor", "numero": 42});
1309        let id = insert(&conn, &m)?;
1310
1311        let row = read_full(&conn, id)?.ok_or("mem-meta should exist")?;
1312        let meta: serde_json::Value = serde_json::from_str(&row.metadata)?;
1313        assert_eq!(meta["chave"], "valor");
1314        assert_eq!(meta["numero"], 42);
1315        Ok(())
1316    }
1317
1318    #[test]
1319    fn insert_com_session_id() -> TestResult {
1320        let conn = setup_conn()?;
1321        let mut m = new_memory("mem-session");
1322        m.session_id = Some("sessao-xyz".to_string());
1323        let id = insert(&conn, &m)?;
1324
1325        let row = read_full(&conn, id)?.ok_or("mem-session should exist")?;
1326        assert_eq!(row.session_id, Some("sessao-xyz".to_string()));
1327        Ok(())
1328    }
1329
1330    #[test]
1331    fn delete_vec_for_nonexistent_id_does_not_fail() -> TestResult {
1332        let conn = setup_conn()?;
1333        let result = delete_vec(&conn, 99999);
1334        assert!(result.is_ok());
1335        Ok(())
1336    }
1337
1338    #[test]
1339    fn preprocess_fts_query_no_separators() {
1340        assert_eq!(preprocess_fts_query("hello"), "hello*");
1341        assert_eq!(preprocess_fts_query("hello world"), "hello* world*");
1342    }
1343
1344    #[test]
1345    fn preprocess_fts_query_with_hyphens() {
1346        let result = preprocess_fts_query("graphrag-precompact");
1347        assert!(result.contains("\"graphrag precompact\""));
1348        assert!(result.contains("graphrag*"));
1349        assert!(result.contains("precompact*"));
1350    }
1351
1352    #[test]
1353    fn preprocess_fts_query_with_dots() {
1354        let result = preprocess_fts_query("v1.0.44");
1355        assert!(result.contains("\"v1 0 44\""));
1356        assert!(result.contains("v1*"));
1357        assert!(result.contains("44*"));
1358    }
1359
1360    #[test]
1361    fn preprocess_fts_query_with_mixed_separators() {
1362        let result = preprocess_fts_query("graphrag-precompact.sh");
1363        assert!(result.contains("\"graphrag precompact sh\""));
1364        assert!(result.contains("graphrag*"));
1365    }
1366
1367    #[test]
1368    fn preprocess_fts_query_empty_and_whitespace() {
1369        assert_eq!(preprocess_fts_query(""), "");
1370        assert_eq!(preprocess_fts_query("  "), "");
1371    }
1372
1373    #[test]
1374    fn preprocess_fts_query_strips_quotes() {
1375        let result = preprocess_fts_query(r#"hello "world"#);
1376        assert!(result.contains("hello*"));
1377        assert!(result.contains("world*"));
1378    }
1379
1380    #[test]
1381    fn preprocess_fts_query_strips_asterisks() {
1382        assert_eq!(preprocess_fts_query("test*"), "test*");
1383    }
1384
1385    #[test]
1386    fn preprocess_fts_query_strips_parens() {
1387        let result = preprocess_fts_query("(hello)");
1388        assert!(result.contains("hello*"));
1389        assert!(!result.contains('('));
1390    }
1391
1392    #[test]
1393    fn preprocess_fts_query_filters_fts_keywords() {
1394        let result = preprocess_fts_query("foo OR bar");
1395        assert!(result.contains("foo*"));
1396        assert!(result.contains("bar*"));
1397        assert!(!result.contains("OR*"));
1398    }
1399
1400    #[test]
1401    fn preprocess_fts_query_only_fts_keywords() {
1402        assert_eq!(preprocess_fts_query("OR AND NOT"), "");
1403    }
1404
1405    #[test]
1406    fn preprocess_fts_query_keywords_with_separators() {
1407        let result = preprocess_fts_query("hello-OR-world");
1408        assert!(result.contains("hello*"));
1409        assert!(result.contains("world*"));
1410        assert!(!result.contains("OR*"));
1411    }
1412
1413    #[test]
1414    fn fts_search_finds_compound_term_with_hyphen() -> TestResult {
1415        let conn = setup_conn()?;
1416        let mut m = new_memory("mem-compound");
1417        m.body = "the graphrag-precompact script runs daily".to_string();
1418        insert(&conn, &m)?;
1419        conn.execute_batch(
1420            "INSERT INTO fts_memories(rowid, name, description, body)
1421             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1422        )?;
1423        let rows = fts_search(&conn, "graphrag-precompact", "global", None, 10)?;
1424        assert!(!rows.is_empty(), "should find compound hyphenated term");
1425        Ok(())
1426    }
1427
1428    #[test]
1429    fn find_by_name_any_state_returns_deleted_flag() -> TestResult {
1430        let conn = setup_conn()?;
1431        let m = new_memory("mem-soft-del");
1432        let id = insert(&conn, &m)?;
1433        conn.execute(
1434            "UPDATE memories SET deleted_at = unixepoch() WHERE id = ?1",
1435            rusqlite::params![id],
1436        )?;
1437        let result = find_by_name_any_state(&conn, "global", "mem-soft-del")?;
1438        assert_eq!(result, Some((id, true)));
1439        Ok(())
1440    }
1441
1442    #[test]
1443    fn find_by_name_any_state_returns_not_deleted() -> TestResult {
1444        let conn = setup_conn()?;
1445        let m = new_memory("mem-active");
1446        let id = insert(&conn, &m)?;
1447        let result = find_by_name_any_state(&conn, "global", "mem-active")?;
1448        assert_eq!(result, Some((id, false)));
1449        Ok(())
1450    }
1451
1452    #[test]
1453    fn find_by_name_any_state_returns_none_when_absent() -> TestResult {
1454        let conn = setup_conn()?;
1455        let result = find_by_name_any_state(&conn, "global", "does-not-exist")?;
1456        assert!(result.is_none());
1457        Ok(())
1458    }
1459
1460    #[test]
1461    fn clear_deleted_at_restores_memory() -> TestResult {
1462        let conn = setup_conn()?;
1463        let m = new_memory("mem-restore");
1464        let id = insert(&conn, &m)?;
1465        conn.execute(
1466            "UPDATE memories SET deleted_at = unixepoch() WHERE id = ?1",
1467            rusqlite::params![id],
1468        )?;
1469        // Soft-deleted: find_by_name should return None.
1470        assert!(find_by_name(&conn, "global", "mem-restore")?.is_none());
1471        clear_deleted_at(&conn, id)?;
1472        // Restored: find_by_name should return Some again.
1473        let found = find_by_name(&conn, "global", "mem-restore")?;
1474        assert!(found.is_some());
1475        assert_eq!(found.unwrap().0, id);
1476        Ok(())
1477    }
1478}