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//! `memory_embeddings` 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    // G29 Passo 2 (v1.0.69): runtime guard for the CHECK constraint on
182    // `source`. Even though `MemorySource` is the typed future, every
183    // legacy `NewMemory { source: "..." }` literal still flows through
184    // this function; validating here keeps the footgun from regressing
185    // for callers that have not yet migrated to the enum.
186    let validated_source = crate::memory_source::validate_source(&m.source)?;
187    conn.execute(
188        "INSERT INTO memories (namespace, name, type, description, body, body_hash, session_id, source, metadata)
189         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
190        params![
191            m.namespace, m.name, m.memory_type, m.description, m.body,
192            m.body_hash, m.session_id, validated_source,
193            serde_json::to_string(&m.metadata)?
194        ],
195    )?;
196    Ok(conn.last_insert_rowid())
197}
198
199/// Updates an existing memory optionally guarded by optimistic concurrency.
200///
201/// When `expected_updated_at` is `Some(ts)` the row is only updated if its
202/// current `updated_at` equals `ts`. This protects concurrent `edit` calls
203/// from silently clobbering each other.
204///
205/// # Returns
206///
207/// `Ok(true)` when exactly one row was updated, `Ok(false)` when the
208/// optimistic check failed or the memory does not exist.
209///
210/// # Errors
211///
212/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
213pub fn update(
214    conn: &Connection,
215    id: i64,
216    m: &NewMemory,
217    expected_updated_at: Option<i64>,
218) -> Result<bool, AppError> {
219    // G29 Passo 2 (v1.0.69): runtime guard for the CHECK constraint on
220    // `source`. Mirrors `insert` so `body-enrich` and other mutations
221    // cannot reintroduce the historical "enrich" literal that broke
222    // `body-enrich` in v1.0.55 - v1.0.68.
223    let validated_source = crate::memory_source::validate_source(&m.source)?;
224    let affected = if let Some(ts) = expected_updated_at {
225        conn.execute(
226            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
227             session_id=?6, source=?7, metadata=?8
228             WHERE id=?1 AND updated_at=?9 AND deleted_at IS NULL",
229            params![
230                id,
231                m.memory_type,
232                m.description,
233                m.body,
234                m.body_hash,
235                m.session_id,
236                validated_source,
237                serde_json::to_string(&m.metadata)?,
238                ts
239            ],
240        )?
241    } else {
242        conn.execute(
243            "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
244             session_id=?6, source=?7, metadata=?8
245             WHERE id=?1 AND deleted_at IS NULL",
246            params![
247                id,
248                m.memory_type,
249                m.description,
250                m.body,
251                m.body_hash,
252                m.session_id,
253                validated_source,
254                serde_json::to_string(&m.metadata)?
255            ],
256        )?
257    };
258    Ok(affected == 1)
259}
260
261/// Replaces the vector row for a memory in `memory_embeddings`.
262///
263/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
264/// table; cosine similarity is computed in pure Rust on demand. The
265/// `memory_type`, `name`, and `snippet` arguments are accepted for API
266/// compatibility but are not stored — the FTS5 shadow table is the
267/// source of truth for textual metadata.
268///
269/// # Errors
270///
271/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
272pub fn upsert_vec(
273    conn: &Connection,
274    memory_id: i64,
275    namespace: &str,
276    _memory_type: &str,
277    embedding: &[f32],
278    _name: &str,
279    _snippet: &str,
280) -> Result<(), AppError> {
281    let embedding_bytes = f32_to_bytes(embedding);
282    with_busy_retry(|| {
283        conn.execute(
284            "DELETE FROM memory_embeddings WHERE memory_id = ?1",
285            params![memory_id],
286        )?;
287        conn.execute(
288            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
289             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
290            params![
291                memory_id,
292                namespace,
293                &embedding_bytes,
294                "llm-headless",
295                crate::constants::SQLITE_GRAPHRAG_VERSION,
296                crate::constants::embedding_dim() as i64,
297            ],
298        )?;
299        Ok(())
300    })
301}
302
303/// Deletes the vector row for `memory_id` from `memory_embeddings`.
304///
305/// Called during `forget` and `purge` to keep the embeddings table
306/// consistent with the logical state of `memories`. FK CASCADE on
307/// `memory_embeddings.memory_id` handles the common case, but this
308/// function exists so callers can delete the embedding first
309/// (preserving the row in `memories` for audit).
310///
311/// # Errors
312///
313/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
314pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
315    conn.execute(
316        "DELETE FROM memory_embeddings WHERE memory_id = ?1",
317        params![memory_id],
318    )?;
319    Ok(())
320}
321
322/// Fetches a live memory by `(namespace, name)` and returns all columns.
323///
324/// # Returns
325///
326/// `Ok(Some(row))` when found, `Ok(None)` when missing or soft-deleted.
327///
328/// # Errors
329///
330/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
331pub fn read_by_name(
332    conn: &Connection,
333    namespace: &str,
334    name: &str,
335) -> Result<Option<MemoryRow>, AppError> {
336    let mut stmt = conn.prepare_cached(
337        "SELECT id, namespace, name, type, description, body, body_hash,
338                session_id, source, metadata, created_at, updated_at, deleted_at
339         FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
340    )?;
341    match stmt.query_row(params![namespace, name], |r| {
342        Ok(MemoryRow {
343            id: r.get(0)?,
344            namespace: r.get(1)?,
345            name: r.get(2)?,
346            memory_type: r.get(3)?,
347            description: r.get(4)?,
348            body: r.get(5)?,
349            body_hash: r.get(6)?,
350            session_id: r.get(7)?,
351            source: r.get(8)?,
352            metadata: r.get(9)?,
353            created_at: r.get(10)?,
354            updated_at: r.get(11)?,
355            deleted_at: r.get(12)?,
356        })
357    }) {
358        Ok(m) => Ok(Some(m)),
359        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
360        Err(e) => Err(AppError::Database(e)),
361    }
362}
363
364/// Soft-deletes a memory by setting `deleted_at = unixepoch()`.
365///
366/// Versions and chunks are preserved so `restore` can undo the operation
367/// until a subsequent `purge` reclaims the storage permanently.
368///
369/// # Returns
370///
371/// `Ok(true)` when a live memory was soft-deleted, `Ok(false)` when no
372/// matching live row existed.
373///
374/// # Errors
375///
376/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
377pub fn soft_delete(conn: &Connection, namespace: &str, name: &str) -> Result<bool, AppError> {
378    let affected = conn.execute(
379        "UPDATE memories SET deleted_at = unixepoch() WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
380        params![namespace, name],
381    )?;
382    Ok(affected == 1)
383}
384
385/// Lists live memories in a namespace ordered by `updated_at` descending.
386///
387/// # Arguments
388///
389/// - `memory_type` — optional filter on the `type` column.
390/// - `limit` / `offset` — standard pagination controls in rows.
391///
392/// # Errors
393///
394/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
395pub fn list(
396    conn: &Connection,
397    namespace: &str,
398    memory_type: Option<&str>,
399    limit: usize,
400    offset: usize,
401    include_deleted: bool,
402) -> Result<Vec<MemoryRow>, AppError> {
403    if let Some(mt) = memory_type {
404        let sql = if include_deleted {
405            "SELECT id, namespace, name, type, description, body, body_hash,
406                    session_id, source, metadata, created_at, updated_at, deleted_at
407             FROM memories WHERE namespace=?1 AND type=?2
408             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
409        } else {
410            "SELECT id, namespace, name, type, description, body, body_hash,
411                    session_id, source, metadata, created_at, updated_at, deleted_at
412             FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL
413             ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
414        };
415        let mut stmt = conn.prepare_cached(sql)?;
416        let rows = stmt
417            .query_map(params![namespace, mt, limit as i64, offset as i64], |r| {
418                Ok(MemoryRow {
419                    id: r.get(0)?,
420                    namespace: r.get(1)?,
421                    name: r.get(2)?,
422                    memory_type: r.get(3)?,
423                    description: r.get(4)?,
424                    body: r.get(5)?,
425                    body_hash: r.get(6)?,
426                    session_id: r.get(7)?,
427                    source: r.get(8)?,
428                    metadata: r.get(9)?,
429                    created_at: r.get(10)?,
430                    updated_at: r.get(11)?,
431                    deleted_at: r.get(12)?,
432                })
433            })?
434            .collect::<Result<Vec<_>, _>>()?;
435        Ok(rows)
436    } else {
437        let sql = if include_deleted {
438            "SELECT id, namespace, name, type, description, body, body_hash,
439                    session_id, source, metadata, created_at, updated_at, deleted_at
440             FROM memories WHERE namespace=?1
441             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
442        } else {
443            "SELECT id, namespace, name, type, description, body, body_hash,
444                    session_id, source, metadata, created_at, updated_at, deleted_at
445             FROM memories WHERE namespace=?1 AND deleted_at IS NULL
446             ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
447        };
448        let mut stmt = conn.prepare_cached(sql)?;
449        let rows = stmt
450            .query_map(params![namespace, limit as i64, offset as i64], |r| {
451                Ok(MemoryRow {
452                    id: r.get(0)?,
453                    namespace: r.get(1)?,
454                    name: r.get(2)?,
455                    memory_type: r.get(3)?,
456                    description: r.get(4)?,
457                    body: r.get(5)?,
458                    body_hash: r.get(6)?,
459                    session_id: r.get(7)?,
460                    source: r.get(8)?,
461                    metadata: r.get(9)?,
462                    created_at: r.get(10)?,
463                    updated_at: r.get(11)?,
464                    deleted_at: r.get(12)?,
465                })
466            })?
467            .collect::<Result<Vec<_>, _>>()?;
468        Ok(rows)
469    }
470}
471
472pub fn count(
473    conn: &Connection,
474    namespace: &str,
475    memory_type: Option<&str>,
476    include_deleted: bool,
477) -> Result<usize, AppError> {
478    let (sql, params_vec): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match (
479        memory_type,
480        include_deleted,
481    ) {
482        (Some(mt), true) => (
483            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2",
484            vec![
485                Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
486                Box::new(mt.to_string()),
487            ],
488        ),
489        (Some(mt), false) => (
490            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL",
491            vec![
492                Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
493                Box::new(mt.to_string()),
494            ],
495        ),
496        (None, true) => (
497            "SELECT COUNT(*) FROM memories WHERE namespace=?1",
498            vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
499        ),
500        (None, false) => (
501            "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND deleted_at IS NULL",
502            vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
503        ),
504    };
505    let params_refs: Vec<&dyn rusqlite::types::ToSql> =
506        params_vec.iter().map(|b| b.as_ref()).collect();
507    let n: i64 = conn.query_row(sql, params_refs.as_slice(), |r| r.get(0))?;
508    Ok(n as usize)
509}
510
511/// Runs a KNN search over `memory_embeddings`, optionally restricted to namespaces.
512///
513/// # Arguments
514///
515/// - `embedding` — query vector of length [`crate::constants::embedding_dim()`].
516/// - `namespaces` — namespaces to search. Empty slice means "all namespaces".
517/// - `memory_type` — optional filter on the `type` column.
518/// - `k` — maximum number of hits to return.
519///
520/// # Returns
521///
522/// A vector of `(memory_id, distance)` pairs sorted by ascending distance.
523///
524/// # Errors
525///
526/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
527pub fn knn_search(
528    conn: &Connection,
529    embedding: &[f32],
530    namespaces: &[String],
531    memory_type: Option<&str>,
532    k: usize,
533) -> Result<Vec<(i64, f32)>, AppError> {
534    if embedding.len() != crate::constants::embedding_dim() {
535        return Err(AppError::Embedding(format!(
536            "knn_search embedding has {} dims, expected {}",
537            embedding.len(),
538            crate::constants::embedding_dim()
539        )));
540    }
541    // v1.0.76: full table scan + in-process cosine similarity. The
542    // `memory_embeddings` table no longer has a `distance` column or a
543    // `type` column (the namespace/type filters were dropped for the
544    // BLOB-backed table — they live on the `memories` table). The
545    // cosine result is converted to a "distance" so callers that read
546    // `distance` keep working unchanged.
547
548    // Build the SQL once with the namespace IN clause shape.
549    let placeholders = (0..namespaces.len())
550        .map(|_| "?")
551        .collect::<Vec<_>>()
552        .join(",");
553    let sql = if namespaces.is_empty() {
554        "SELECT memory_id, embedding, namespace FROM memory_embeddings".to_string()
555    } else {
556        format!(
557            "SELECT memory_id, embedding, namespace FROM memory_embeddings \
558             WHERE namespace IN ({placeholders})"
559        )
560    };
561    let mut stmt = conn.prepare(&sql)?;
562    let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
563    for ns in namespaces {
564        raw_params.push(Box::new(ns.clone()));
565    }
566    let param_refs: Vec<&dyn rusqlite::ToSql> = raw_params.iter().map(|b| b.as_ref()).collect();
567    let rows = stmt.query_map(param_refs.as_slice(), |r| {
568        let id: i64 = r.get(0)?;
569        let bytes: Vec<u8> = r.get(1)?;
570        let ns: String = r.get(2)?;
571        Ok((id, bytes, ns))
572    })?;
573
574    // Optionally restrict to a memory type by joining against the
575    // `memories` table on the fly.
576    let type_filter = memory_type.map(|t| t.to_string());
577    let mut candidates: Vec<(i64, f32)> = Vec::new();
578    for row in rows {
579        let (id, bytes, ns) = row?;
580        let stored = crate::embedder::bytes_to_f32(&bytes);
581        if stored.len() != embedding.len() {
582            continue;
583        }
584        let sim = crate::similarity::cosine_similarity(embedding, &stored);
585        let dist = crate::similarity::similarity_to_distance(sim);
586        if let Some(mt) = &type_filter {
587            // Look up the memory's type via a per-row check. For very
588            // large candidate sets this should be batched; for the
589            // v1.0.76 default namespace size (<10k memories) the
590            // per-row lookup is acceptable.
591            let actual: Option<String> = conn
592                .query_row(
593                    "SELECT type FROM memories WHERE id = ?1",
594                    params![id],
595                    |r| r.get(0),
596                )
597                .ok();
598            if actual.as_deref() != Some(mt.as_str()) {
599                continue;
600            }
601        }
602        let _ = ns; // namespace already filtered at SQL level
603        candidates.push((id, dist));
604    }
605    // Sort by distance ascending (best matches first).
606    candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
607    candidates.truncate(k);
608    Ok(candidates)
609}
610
611/// Fetches a live memory by `(namespace, name)` and returns all columns.
612/// Fetches a live memory by primary key and returns all columns.
613///
614/// Mirrors [`read_by_name`] but keyed on `rowid` for use after a KNN search.
615///
616/// # Errors
617///
618/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
619pub fn read_full(conn: &Connection, memory_id: i64) -> Result<Option<MemoryRow>, AppError> {
620    let mut stmt = conn.prepare_cached(
621        "SELECT id, namespace, name, type, description, body, body_hash,
622                session_id, source, metadata, created_at, updated_at, deleted_at
623         FROM memories WHERE id=?1 AND deleted_at IS NULL",
624    )?;
625    match stmt.query_row(params![memory_id], |r| {
626        Ok(MemoryRow {
627            id: r.get(0)?,
628            namespace: r.get(1)?,
629            name: r.get(2)?,
630            memory_type: r.get(3)?,
631            description: r.get(4)?,
632            body: r.get(5)?,
633            body_hash: r.get(6)?,
634            session_id: r.get(7)?,
635            source: r.get(8)?,
636            metadata: r.get(9)?,
637            created_at: r.get(10)?,
638            updated_at: r.get(11)?,
639            deleted_at: r.get(12)?,
640        })
641    }) {
642        Ok(m) => Ok(Some(m)),
643        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
644        Err(e) => Err(AppError::Database(e)),
645    }
646}
647
648/// Fetches all memory_ids in a namespace that are soft-deleted and whose
649/// `deleted_at` is older than `before_ts` (unix epoch seconds).
650///
651/// Used by `purge` to collect stale rows for permanent deletion.
652///
653/// # Errors
654///
655/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
656pub fn list_deleted_before(
657    conn: &Connection,
658    namespace: &str,
659    before_ts: i64,
660) -> Result<Vec<i64>, AppError> {
661    let mut stmt = conn.prepare_cached(
662        "SELECT id FROM memories WHERE namespace = ?1 AND deleted_at IS NOT NULL AND deleted_at < ?2",
663    )?;
664    let ids = stmt
665        .query_map(params![namespace, before_ts], |r| r.get::<_, i64>(0))?
666        .collect::<Result<Vec<_>, _>>()?;
667    Ok(ids)
668}
669
670/// Preprocesses a raw user query for FTS5 `MATCH`.
671///
672/// Technical separators (`-`, `.`, `_`, `/`) are treated as word boundaries by
673/// the `unicode61` tokenizer.  When the query contains any of these characters
674/// the function builds a compound FTS5 expression:
675///   1. A phrase query with the separated tokens (exact compound matching).
676///   2. Individual prefix terms joined with OR (broader recall).
677///
678/// Queries without separators keep the original `term*` prefix behaviour.
679fn preprocess_fts_query(raw: &str) -> String {
680    const SEPARATORS: &[char] = &['-', '.', '_', '/'];
681    const FTS5_SYNTAX: &[char] = &['"', '*', '(', ')', '^', ':'];
682    const FTS5_KEYWORDS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
683
684    let sanitized: String = raw.chars().filter(|c| !FTS5_SYNTAX.contains(c)).collect();
685    let trimmed = sanitized.trim();
686    if trimmed.is_empty() {
687        return String::new();
688    }
689
690    let is_fts_keyword = |t: &str| FTS5_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(t));
691
692    if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
693        return trimmed
694            .split_whitespace()
695            .filter(|t| !is_fts_keyword(t))
696            .map(|t| format!("{t}*"))
697            .collect::<Vec<_>>()
698            .join(" ");
699    }
700    let tokens: Vec<&str> = trimmed
701        .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
702        .filter(|t| !t.is_empty() && !is_fts_keyword(t))
703        .collect();
704    if tokens.is_empty() {
705        return String::new();
706    }
707    let phrase = format!("\"{}\"", tokens.join(" "));
708    let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
709    format!("{phrase} OR {}", prefix_terms.join(" OR "))
710}
711
712/// Executes an FTS5 search against `fts_memories` with query preprocessing.
713///
714/// Technical separators in the query are converted to phrase + prefix OR
715/// expressions so compound terms like `graphrag-precompact.sh` match correctly.
716///
717/// # Errors
718///
719/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
720pub fn fts_search(
721    conn: &Connection,
722    query: &str,
723    namespace: &str,
724    memory_type: Option<&str>,
725    limit: usize,
726) -> Result<Vec<MemoryRow>, AppError> {
727    let fts_query = preprocess_fts_query(query);
728    if let Some(mt) = memory_type {
729        let mut stmt = conn.prepare_cached(
730            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
731                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
732             FROM fts_memories fts
733             JOIN memories m ON m.id = fts.rowid
734             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.type = ?3 AND m.deleted_at IS NULL
735             ORDER BY rank LIMIT ?4",
736        )?;
737        let rows = stmt
738            .query_map(params![fts_query, namespace, mt, limit as i64], |r| {
739                Ok(MemoryRow {
740                    id: r.get(0)?,
741                    namespace: r.get(1)?,
742                    name: r.get(2)?,
743                    memory_type: r.get(3)?,
744                    description: r.get(4)?,
745                    body: r.get(5)?,
746                    body_hash: r.get(6)?,
747                    session_id: r.get(7)?,
748                    source: r.get(8)?,
749                    metadata: r.get(9)?,
750                    created_at: r.get(10)?,
751                    updated_at: r.get(11)?,
752                    deleted_at: r.get(12)?,
753                })
754            })?
755            .collect::<Result<Vec<_>, _>>()?;
756        Ok(rows)
757    } else {
758        let mut stmt = conn.prepare_cached(
759            "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
760                    m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
761             FROM fts_memories fts
762             JOIN memories m ON m.id = fts.rowid
763             WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
764             ORDER BY rank LIMIT ?3",
765        )?;
766        let rows = stmt
767            .query_map(params![fts_query, namespace, limit as i64], |r| {
768                Ok(MemoryRow {
769                    id: r.get(0)?,
770                    namespace: r.get(1)?,
771                    name: r.get(2)?,
772                    memory_type: r.get(3)?,
773                    description: r.get(4)?,
774                    body: r.get(5)?,
775                    body_hash: r.get(6)?,
776                    session_id: r.get(7)?,
777                    source: r.get(8)?,
778                    metadata: r.get(9)?,
779                    created_at: r.get(10)?,
780                    updated_at: r.get(11)?,
781                    deleted_at: r.get(12)?,
782                })
783            })?
784            .collect::<Result<Vec<_>, _>>()?;
785        Ok(rows)
786    }
787}
788
789/// Syncs FTS5 external-content index after an UPDATE on the memories table.
790///
791/// The AFTER UPDATE trigger (`trg_fts_au`) is intentionally absent because
792/// sqlite-vec loaded via `sqlite3_auto_extension` conflicts with FTS5 inside
793/// UPDATE triggers. This function performs the equivalent sync in Rust:
794/// DELETE the old entry, then INSERT the new one (external-content FTS5
795/// tables do not support in-place UPDATE).
796#[allow(clippy::too_many_arguments)]
797pub fn sync_fts_after_update(
798    conn: &Connection,
799    memory_id: i64,
800    old_name: &str,
801    old_desc: &str,
802    old_body: &str,
803    new_name: &str,
804    new_desc: &str,
805    new_body: &str,
806) -> Result<(), AppError> {
807    conn.execute(
808        "INSERT INTO fts_memories(fts_memories, rowid, name, description, body)
809         VALUES('delete', ?1, ?2, ?3, ?4)",
810        params![memory_id, old_name, old_desc, old_body],
811    )?;
812    conn.execute(
813        "INSERT INTO fts_memories(rowid, name, description, body)
814         VALUES(?1, ?2, ?3, ?4)",
815        params![memory_id, new_name, new_desc, new_body],
816    )?;
817    Ok(())
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use rusqlite::Connection;
824
825    type TestResult = Result<(), Box<dyn std::error::Error>>;
826
827    fn setup_conn() -> Result<Connection, Box<dyn std::error::Error>> {
828        crate::storage::connection::register_vec_extension();
829        let mut conn = Connection::open_in_memory()?;
830        conn.execute_batch(
831            "PRAGMA foreign_keys = ON;
832             PRAGMA temp_store = MEMORY;",
833        )?;
834        crate::migrations::runner().run(&mut conn)?;
835        Ok(conn)
836    }
837
838    fn new_memory(name: &str) -> NewMemory {
839        NewMemory {
840            namespace: "global".to_string(),
841            name: name.to_string(),
842            memory_type: "user".to_string(),
843            description: "descricao de teste".to_string(),
844            body: "test memory body".to_string(),
845            body_hash: format!("hash-{name}"),
846            session_id: None,
847            source: "agent".to_string(),
848            metadata: serde_json::json!({}),
849        }
850    }
851
852    #[test]
853    fn insert_and_find_by_name_return_id() -> TestResult {
854        let conn = setup_conn()?;
855        let m = new_memory("mem-alpha");
856        let id = insert(&conn, &m)?;
857        assert!(id > 0);
858
859        let found = find_by_name(&conn, "global", "mem-alpha")?;
860        assert!(found.is_some());
861        let (found_id, _, _) = found.ok_or("mem-alpha should exist")?;
862        assert_eq!(found_id, id);
863        Ok(())
864    }
865
866    #[test]
867    fn find_by_name_returns_none_when_not_found() -> TestResult {
868        let conn = setup_conn()?;
869        let result = find_by_name(&conn, "global", "inexistente")?;
870        assert!(result.is_none());
871        Ok(())
872    }
873
874    #[test]
875    fn find_by_hash_returns_correct_id() -> TestResult {
876        let conn = setup_conn()?;
877        let m = new_memory("mem-hash");
878        let id = insert(&conn, &m)?;
879
880        let found = find_by_hash(&conn, "global", "hash-mem-hash")?;
881        assert_eq!(found, Some(id));
882        Ok(())
883    }
884
885    #[test]
886    fn find_by_hash_returns_none_when_hash_not_found() -> TestResult {
887        let conn = setup_conn()?;
888        let result = find_by_hash(&conn, "global", "hash-inexistente")?;
889        assert!(result.is_none());
890        Ok(())
891    }
892
893    #[test]
894    fn find_by_hash_ignores_different_namespace() -> TestResult {
895        let conn = setup_conn()?;
896        let m = new_memory("mem-ns");
897        insert(&conn, &m)?;
898
899        let result = find_by_hash(&conn, "outro-namespace", "hash-mem-ns")?;
900        assert!(result.is_none());
901        Ok(())
902    }
903
904    #[test]
905    fn read_by_name_returns_full_memory() -> TestResult {
906        let conn = setup_conn()?;
907        let m = new_memory("mem-read");
908        let id = insert(&conn, &m)?;
909
910        let row = read_by_name(&conn, "global", "mem-read")?.ok_or("mem-read should exist")?;
911        assert_eq!(row.id, id);
912        assert_eq!(row.name, "mem-read");
913        assert_eq!(row.memory_type, "user");
914        assert_eq!(row.body, "test memory body");
915        assert_eq!(row.namespace, "global");
916        Ok(())
917    }
918
919    #[test]
920    fn read_by_name_returns_none_for_missing() -> TestResult {
921        let conn = setup_conn()?;
922        let result = read_by_name(&conn, "global", "nao-existe")?;
923        assert!(result.is_none());
924        Ok(())
925    }
926
927    #[test]
928    fn read_full_by_id_returns_memory() -> TestResult {
929        let conn = setup_conn()?;
930        let m = new_memory("mem-full");
931        let id = insert(&conn, &m)?;
932
933        let row = read_full(&conn, id)?.ok_or("mem-full should exist")?;
934        assert_eq!(row.id, id);
935        assert_eq!(row.name, "mem-full");
936        Ok(())
937    }
938
939    #[test]
940    fn read_full_returns_none_for_missing_id() -> TestResult {
941        let conn = setup_conn()?;
942        let result = read_full(&conn, 9999)?;
943        assert!(result.is_none());
944        Ok(())
945    }
946
947    #[test]
948    fn update_without_optimism_modifies_fields() -> TestResult {
949        let conn = setup_conn()?;
950        let m = new_memory("mem-upd");
951        let id = insert(&conn, &m)?;
952
953        let mut m2 = new_memory("mem-upd");
954        m2.body = "updated body".to_string();
955        m2.body_hash = "hash-novo".to_string();
956        let ok = update(&conn, id, &m2, None)?;
957        assert!(ok);
958
959        let row = read_full(&conn, id)?.ok_or("mem-upd should exist")?;
960        assert_eq!(row.body, "updated body");
961        assert_eq!(row.body_hash, "hash-novo");
962        Ok(())
963    }
964
965    #[test]
966    fn update_with_correct_expected_updated_at_succeeds() -> TestResult {
967        let conn = setup_conn()?;
968        let m = new_memory("mem-opt");
969        let id = insert(&conn, &m)?;
970
971        let (_, updated_at, _) =
972            find_by_name(&conn, "global", "mem-opt")?.ok_or("mem-opt should exist")?;
973
974        let mut m2 = new_memory("mem-opt");
975        m2.body = "optimistic body".to_string();
976        m2.body_hash = "hash-optimistic".to_string();
977        let ok = update(&conn, id, &m2, Some(updated_at))?;
978        assert!(ok);
979
980        let row = read_full(&conn, id)?.ok_or("mem-opt should exist after update")?;
981        assert_eq!(row.body, "optimistic body");
982        Ok(())
983    }
984
985    #[test]
986    fn update_with_wrong_expected_updated_at_returns_false() -> TestResult {
987        let conn = setup_conn()?;
988        let m = new_memory("mem-conflict");
989        let id = insert(&conn, &m)?;
990
991        let mut m2 = new_memory("mem-conflict");
992        m2.body = "must not appear".to_string();
993        m2.body_hash = "hash-x".to_string();
994        let ok = update(&conn, id, &m2, Some(0))?;
995        assert!(!ok);
996
997        let row = read_full(&conn, id)?.ok_or("mem-conflict should exist")?;
998        assert_eq!(row.body, "test memory body");
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn update_missing_id_returns_false() -> TestResult {
1004        let conn = setup_conn()?;
1005        let m = new_memory("fantasma");
1006        let ok = update(&conn, 9999, &m, None)?;
1007        assert!(!ok);
1008        Ok(())
1009    }
1010
1011    #[test]
1012    fn soft_delete_marks_deleted_at() -> TestResult {
1013        let conn = setup_conn()?;
1014        let m = new_memory("mem-del");
1015        insert(&conn, &m)?;
1016
1017        let ok = soft_delete(&conn, "global", "mem-del")?;
1018        assert!(ok);
1019
1020        let result = find_by_name(&conn, "global", "mem-del")?;
1021        assert!(result.is_none());
1022
1023        let result_read = read_by_name(&conn, "global", "mem-del")?;
1024        assert!(result_read.is_none());
1025        Ok(())
1026    }
1027
1028    #[test]
1029    fn soft_delete_returns_false_when_not_found() -> TestResult {
1030        let conn = setup_conn()?;
1031        let ok = soft_delete(&conn, "global", "nao-existe")?;
1032        assert!(!ok);
1033        Ok(())
1034    }
1035
1036    #[test]
1037    fn double_soft_delete_returns_false_on_second_call() -> TestResult {
1038        let conn = setup_conn()?;
1039        let m = new_memory("mem-del2");
1040        insert(&conn, &m)?;
1041
1042        soft_delete(&conn, "global", "mem-del2")?;
1043        let ok = soft_delete(&conn, "global", "mem-del2")?;
1044        assert!(!ok);
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn list_returns_memories_from_namespace() -> TestResult {
1050        let conn = setup_conn()?;
1051        insert(&conn, &new_memory("mem-list-a"))?;
1052        insert(&conn, &new_memory("mem-list-b"))?;
1053
1054        let rows = list(&conn, "global", None, 10, 0, false)?;
1055        assert!(rows.len() >= 2);
1056        let nomes: Vec<_> = rows.iter().map(|r| r.name.as_str()).collect();
1057        assert!(nomes.contains(&"mem-list-a"));
1058        assert!(nomes.contains(&"mem-list-b"));
1059        Ok(())
1060    }
1061
1062    #[test]
1063    fn list_with_type_filter_returns_only_correct_type() -> TestResult {
1064        let conn = setup_conn()?;
1065        insert(&conn, &new_memory("mem-user"))?;
1066
1067        let mut m2 = new_memory("mem-feedback");
1068        m2.memory_type = "feedback".to_string();
1069        insert(&conn, &m2)?;
1070
1071        let rows_user = list(&conn, "global", Some("user"), 10, 0, false)?;
1072        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
1073
1074        let rows_fb = list(&conn, "global", Some("feedback"), 10, 0, false)?;
1075        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn list_exclui_soft_deleted() -> TestResult {
1081        let conn = setup_conn()?;
1082        let m = new_memory("mem-excluida");
1083        insert(&conn, &m)?;
1084        soft_delete(&conn, "global", "mem-excluida")?;
1085
1086        let rows = list(&conn, "global", None, 10, 0, false)?;
1087        assert!(rows.iter().all(|r| r.name != "mem-excluida"));
1088        Ok(())
1089    }
1090
1091    #[test]
1092    fn list_pagination_works() -> TestResult {
1093        let conn = setup_conn()?;
1094        for i in 0..5 {
1095            insert(&conn, &new_memory(&format!("mem-pag-{i}")))?;
1096        }
1097
1098        let pagina1 = list(&conn, "global", None, 2, 0, false)?;
1099        let pagina2 = list(&conn, "global", None, 2, 2, false)?;
1100        assert!(pagina1.len() <= 2);
1101        assert!(pagina2.len() <= 2);
1102        if !pagina1.is_empty() && !pagina2.is_empty() {
1103            assert_ne!(pagina1[0].id, pagina2[0].id);
1104        }
1105        Ok(())
1106    }
1107
1108    #[test]
1109    #[serial_test::serial(env)]
1110    fn upsert_vec_and_delete_vec_work() -> TestResult {
1111        let conn = setup_conn()?;
1112        let m = new_memory("mem-vec");
1113        let id = insert(&conn, &m)?;
1114
1115        let embedding: Vec<f32> = vec![0.1; crate::constants::embedding_dim()];
1116        upsert_vec(
1117            &conn, id, "global", "user", &embedding, "mem-vec", "snippet",
1118        )?;
1119
1120        let count: i64 = conn.query_row(
1121            "SELECT COUNT(*) FROM memory_embeddings WHERE memory_id = ?1",
1122            params![id],
1123            |r| r.get(0),
1124        )?;
1125        assert_eq!(count, 1);
1126
1127        delete_vec(&conn, id)?;
1128
1129        let count_after: i64 = conn.query_row(
1130            "SELECT COUNT(*) FROM memory_embeddings WHERE memory_id = ?1",
1131            params![id],
1132            |r| r.get(0),
1133        )?;
1134        assert_eq!(count_after, 0);
1135        Ok(())
1136    }
1137
1138    #[test]
1139    #[serial_test::serial(env)]
1140    fn upsert_vec_replaces_existing_vector() -> TestResult {
1141        let conn = setup_conn()?;
1142        let m = new_memory("mem-vec-upsert");
1143        let id = insert(&conn, &m)?;
1144
1145        let emb1: Vec<f32> = vec![0.1; crate::constants::embedding_dim()];
1146        upsert_vec(&conn, id, "global", "user", &emb1, "mem-vec-upsert", "s1")?;
1147
1148        let emb2: Vec<f32> = vec![0.9; crate::constants::embedding_dim()];
1149        upsert_vec(&conn, id, "global", "user", &emb2, "mem-vec-upsert", "s2")?;
1150
1151        let count: i64 = conn.query_row(
1152            "SELECT COUNT(*) FROM memory_embeddings WHERE memory_id = ?1",
1153            params![id],
1154            |r| r.get(0),
1155        )?;
1156        assert_eq!(count, 1);
1157        Ok(())
1158    }
1159
1160    #[test]
1161    #[serial_test::serial(env)]
1162    fn knn_search_returns_results_by_distance() -> TestResult {
1163        let conn = setup_conn()?;
1164
1165        // emb_a: predominantemente positivo — cosseno alto com a query toda-uns
1166        let ma = new_memory("mem-knn-a");
1167        let id_a = insert(&conn, &ma)?;
1168        let emb_a: Vec<f32> = vec![1.0; crate::constants::embedding_dim()];
1169        upsert_vec(&conn, id_a, "global", "user", &emb_a, "mem-knn-a", "s")?;
1170
1171        // emb_b: predominantemente negativo — cosseno baixo com a query toda-uns
1172        let mb = new_memory("mem-knn-b");
1173        let id_b = insert(&conn, &mb)?;
1174        let emb_b: Vec<f32> = vec![-1.0; crate::constants::embedding_dim()];
1175        upsert_vec(&conn, id_b, "global", "user", &emb_b, "mem-knn-b", "s")?;
1176
1177        let query: Vec<f32> = vec![1.0; crate::constants::embedding_dim()];
1178        let results = knn_search(&conn, &query, &["global".to_string()], None, 2)?;
1179        assert!(!results.is_empty());
1180        assert_eq!(results[0].0, id_a);
1181        Ok(())
1182    }
1183
1184    #[test]
1185    #[serial_test::serial(env)]
1186    fn knn_search_with_type_filter_restricts_result() -> TestResult {
1187        let conn = setup_conn()?;
1188
1189        let ma = new_memory("mem-knn-tipo-user");
1190        let id_a = insert(&conn, &ma)?;
1191        let emb: Vec<f32> = vec![1.0; crate::constants::embedding_dim()];
1192        upsert_vec(
1193            &conn,
1194            id_a,
1195            "global",
1196            "user",
1197            &emb,
1198            "mem-knn-tipo-user",
1199            "s",
1200        )?;
1201
1202        let mut mb = new_memory("mem-knn-tipo-fb");
1203        mb.memory_type = "feedback".to_string();
1204        let id_b = insert(&conn, &mb)?;
1205        upsert_vec(
1206            &conn,
1207            id_b,
1208            "global",
1209            "feedback",
1210            &emb,
1211            "mem-knn-tipo-fb",
1212            "s",
1213        )?;
1214
1215        let query: Vec<f32> = vec![1.0; crate::constants::embedding_dim()];
1216        let results_user = knn_search(&conn, &query, &["global".to_string()], Some("user"), 5)?;
1217        assert!(results_user.iter().all(|(id, _)| *id == id_a));
1218
1219        let results_fb = knn_search(&conn, &query, &["global".to_string()], Some("feedback"), 5)?;
1220        assert!(results_fb.iter().all(|(id, _)| *id == id_b));
1221        Ok(())
1222    }
1223
1224    #[test]
1225    fn fts_search_finds_by_prefix_in_body() -> TestResult {
1226        let conn = setup_conn()?;
1227        let mut m = new_memory("mem-fts");
1228        m.body = "linguagem de programacao rust".to_string();
1229        insert(&conn, &m)?;
1230
1231        conn.execute_batch(
1232            "INSERT INTO fts_memories(rowid, name, description, body)
1233             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1234        )?;
1235
1236        let rows = fts_search(&conn, "programacao", "global", None, 10)?;
1237        assert!(!rows.is_empty());
1238        assert!(rows.iter().any(|r| r.name == "mem-fts"));
1239        Ok(())
1240    }
1241
1242    #[test]
1243    fn fts_search_with_type_filter() -> TestResult {
1244        let conn = setup_conn()?;
1245        let mut m = new_memory("mem-fts-tipo");
1246        m.body = "linguagem especial para filtro".to_string();
1247        insert(&conn, &m)?;
1248
1249        let mut m2 = new_memory("mem-fts-feedback");
1250        m2.memory_type = "feedback".to_string();
1251        m2.body = "linguagem especial para filtro".to_string();
1252        insert(&conn, &m2)?;
1253
1254        conn.execute_batch(
1255            "INSERT INTO fts_memories(rowid, name, description, body)
1256             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1257        )?;
1258
1259        let rows_user = fts_search(&conn, "especial", "global", Some("user"), 10)?;
1260        assert!(rows_user.iter().all(|r| r.memory_type == "user"));
1261
1262        let rows_fb = fts_search(&conn, "especial", "global", Some("feedback"), 10)?;
1263        assert!(rows_fb.iter().all(|r| r.memory_type == "feedback"));
1264        Ok(())
1265    }
1266
1267    #[test]
1268    fn fts_search_excludes_deleted() -> TestResult {
1269        let conn = setup_conn()?;
1270        let mut m = new_memory("mem-fts-del");
1271        m.body = "deleted fts content".to_string();
1272        insert(&conn, &m)?;
1273
1274        conn.execute_batch(
1275            "INSERT INTO fts_memories(rowid, name, description, body)
1276             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1277        )?;
1278
1279        soft_delete(&conn, "global", "mem-fts-del")?;
1280
1281        let rows = fts_search(&conn, "deleted", "global", None, 10)?;
1282        assert!(rows.iter().all(|r| r.name != "mem-fts-del"));
1283        Ok(())
1284    }
1285
1286    #[test]
1287    fn list_deleted_before_returns_correct_ids() -> TestResult {
1288        let conn = setup_conn()?;
1289        let m = new_memory("mem-purge");
1290        insert(&conn, &m)?;
1291        soft_delete(&conn, "global", "mem-purge")?;
1292
1293        let ids = list_deleted_before(&conn, "global", i64::MAX)?;
1294        assert!(!ids.is_empty());
1295
1296        let ids_antes = list_deleted_before(&conn, "global", 0)?;
1297        assert!(ids_antes.is_empty());
1298        Ok(())
1299    }
1300
1301    #[test]
1302    fn find_by_name_returns_correct_max_version() -> TestResult {
1303        let conn = setup_conn()?;
1304        let m = new_memory("mem-ver");
1305        let id = insert(&conn, &m)?;
1306
1307        let (_, _, v0) = find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist")?;
1308        assert_eq!(v0, 0);
1309
1310        conn.execute(
1311            "INSERT INTO memory_versions (memory_id, version, name, type, description, body, metadata, change_reason)
1312             VALUES (?1, 1, 'mem-ver', 'user', 'desc', 'body', '{}', 'create')",
1313            params![id],
1314        )?;
1315
1316        let (_, _, v1) =
1317            find_by_name(&conn, "global", "mem-ver")?.ok_or("mem-ver should exist after insert")?;
1318        assert_eq!(v1, 1);
1319        Ok(())
1320    }
1321
1322    #[test]
1323    fn insert_com_metadata_json() -> TestResult {
1324        let conn = setup_conn()?;
1325        let mut m = new_memory("mem-meta");
1326        m.metadata = serde_json::json!({"chave": "valor", "numero": 42});
1327        let id = insert(&conn, &m)?;
1328
1329        let row = read_full(&conn, id)?.ok_or("mem-meta should exist")?;
1330        let meta: serde_json::Value = serde_json::from_str(&row.metadata)?;
1331        assert_eq!(meta["chave"], "valor");
1332        assert_eq!(meta["numero"], 42);
1333        Ok(())
1334    }
1335
1336    #[test]
1337    fn insert_com_session_id() -> TestResult {
1338        let conn = setup_conn()?;
1339        let mut m = new_memory("mem-session");
1340        m.session_id = Some("sessao-xyz".to_string());
1341        let id = insert(&conn, &m)?;
1342
1343        let row = read_full(&conn, id)?.ok_or("mem-session should exist")?;
1344        assert_eq!(row.session_id, Some("sessao-xyz".to_string()));
1345        Ok(())
1346    }
1347
1348    #[test]
1349    fn delete_vec_for_nonexistent_id_does_not_fail() -> TestResult {
1350        let conn = setup_conn()?;
1351        let result = delete_vec(&conn, 99999);
1352        assert!(result.is_ok());
1353        Ok(())
1354    }
1355
1356    #[test]
1357    fn preprocess_fts_query_no_separators() {
1358        assert_eq!(preprocess_fts_query("hello"), "hello*");
1359        assert_eq!(preprocess_fts_query("hello world"), "hello* world*");
1360    }
1361
1362    #[test]
1363    fn preprocess_fts_query_with_hyphens() {
1364        let result = preprocess_fts_query("graphrag-precompact");
1365        assert!(result.contains("\"graphrag precompact\""));
1366        assert!(result.contains("graphrag*"));
1367        assert!(result.contains("precompact*"));
1368    }
1369
1370    #[test]
1371    fn preprocess_fts_query_with_dots() {
1372        let result = preprocess_fts_query("v1.0.44");
1373        assert!(result.contains("\"v1 0 44\""));
1374        assert!(result.contains("v1*"));
1375        assert!(result.contains("44*"));
1376    }
1377
1378    #[test]
1379    fn preprocess_fts_query_with_mixed_separators() {
1380        let result = preprocess_fts_query("graphrag-precompact.sh");
1381        assert!(result.contains("\"graphrag precompact sh\""));
1382        assert!(result.contains("graphrag*"));
1383    }
1384
1385    #[test]
1386    fn preprocess_fts_query_empty_and_whitespace() {
1387        assert_eq!(preprocess_fts_query(""), "");
1388        assert_eq!(preprocess_fts_query("  "), "");
1389    }
1390
1391    #[test]
1392    fn preprocess_fts_query_strips_quotes() {
1393        let result = preprocess_fts_query(r#"hello "world"#);
1394        assert!(result.contains("hello*"));
1395        assert!(result.contains("world*"));
1396    }
1397
1398    #[test]
1399    fn preprocess_fts_query_strips_asterisks() {
1400        assert_eq!(preprocess_fts_query("test*"), "test*");
1401    }
1402
1403    #[test]
1404    fn preprocess_fts_query_strips_parens() {
1405        let result = preprocess_fts_query("(hello)");
1406        assert!(result.contains("hello*"));
1407        assert!(!result.contains('('));
1408    }
1409
1410    #[test]
1411    fn preprocess_fts_query_filters_fts_keywords() {
1412        let result = preprocess_fts_query("foo OR bar");
1413        assert!(result.contains("foo*"));
1414        assert!(result.contains("bar*"));
1415        assert!(!result.contains("OR*"));
1416    }
1417
1418    #[test]
1419    fn preprocess_fts_query_only_fts_keywords() {
1420        assert_eq!(preprocess_fts_query("OR AND NOT"), "");
1421    }
1422
1423    #[test]
1424    fn preprocess_fts_query_keywords_with_separators() {
1425        let result = preprocess_fts_query("hello-OR-world");
1426        assert!(result.contains("hello*"));
1427        assert!(result.contains("world*"));
1428        assert!(!result.contains("OR*"));
1429    }
1430
1431    #[test]
1432    fn fts_search_finds_compound_term_with_hyphen() -> TestResult {
1433        let conn = setup_conn()?;
1434        let mut m = new_memory("mem-compound");
1435        m.body = "the graphrag-precompact script runs daily".to_string();
1436        insert(&conn, &m)?;
1437        conn.execute_batch(
1438            "INSERT INTO fts_memories(rowid, name, description, body)
1439             SELECT id, name, description, body FROM memories WHERE deleted_at IS NULL",
1440        )?;
1441        let rows = fts_search(&conn, "graphrag-precompact", "global", None, 10)?;
1442        assert!(!rows.is_empty(), "should find compound hyphenated term");
1443        Ok(())
1444    }
1445
1446    #[test]
1447    fn find_by_name_any_state_returns_deleted_flag() -> TestResult {
1448        let conn = setup_conn()?;
1449        let m = new_memory("mem-soft-del");
1450        let id = insert(&conn, &m)?;
1451        conn.execute(
1452            "UPDATE memories SET deleted_at = unixepoch() WHERE id = ?1",
1453            rusqlite::params![id],
1454        )?;
1455        let result = find_by_name_any_state(&conn, "global", "mem-soft-del")?;
1456        assert_eq!(result, Some((id, true)));
1457        Ok(())
1458    }
1459
1460    #[test]
1461    fn find_by_name_any_state_returns_not_deleted() -> TestResult {
1462        let conn = setup_conn()?;
1463        let m = new_memory("mem-active");
1464        let id = insert(&conn, &m)?;
1465        let result = find_by_name_any_state(&conn, "global", "mem-active")?;
1466        assert_eq!(result, Some((id, false)));
1467        Ok(())
1468    }
1469
1470    #[test]
1471    fn find_by_name_any_state_returns_none_when_absent() -> TestResult {
1472        let conn = setup_conn()?;
1473        let result = find_by_name_any_state(&conn, "global", "does-not-exist")?;
1474        assert!(result.is_none());
1475        Ok(())
1476    }
1477
1478    #[test]
1479    fn clear_deleted_at_restores_memory() -> TestResult {
1480        let conn = setup_conn()?;
1481        let m = new_memory("mem-restore");
1482        let id = insert(&conn, &m)?;
1483        conn.execute(
1484            "UPDATE memories SET deleted_at = unixepoch() WHERE id = ?1",
1485            rusqlite::params![id],
1486        )?;
1487        // Soft-deleted: find_by_name should return None.
1488        assert!(find_by_name(&conn, "global", "mem-restore")?.is_none());
1489        clear_deleted_at(&conn, id)?;
1490        // Restored: find_by_name should return Some again.
1491        let found = find_by_name(&conn, "global", "mem-restore")?;
1492        assert!(found.is_some());
1493        assert_eq!(found.unwrap().0, id);
1494        Ok(())
1495    }
1496}