Skip to main content

sqlite_graphrag/storage/
memories.rs

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