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