1mod soft_delete;
8pub use soft_delete::{clear_deleted_at, find_by_name_any_state, list_deleted_before, soft_delete};
9
10use crate::embedder::f32_to_bytes;
11use crate::errors::AppError;
12use crate::storage::utils::with_busy_retry;
13use rusqlite::{params, Connection};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Serialize, Deserialize)]
21pub struct NewMemory {
22 pub namespace: String,
24 pub name: String,
26 pub memory_type: String,
28 pub description: String,
30 pub body: String,
32 pub body_hash: String,
34 pub session_id: Option<String>,
36 pub source: String,
38 pub metadata: serde_json::Value,
40}
41
42#[derive(Debug, Serialize)]
47pub struct MemoryRow {
48 pub id: i64,
50 pub namespace: String,
52 pub name: String,
54 pub memory_type: String,
56 pub description: String,
58 pub body: String,
60 pub body_hash: String,
62 pub session_id: Option<String>,
64 pub source: String,
66 pub metadata: String,
68 pub created_at: i64,
70 pub updated_at: i64,
72 #[serde(skip_serializing_if = "Option::is_none")]
76 pub deleted_at: Option<i64>,
77}
78
79pub fn find_by_name(
96 conn: &Connection,
97 namespace: &str,
98 name: &str,
99) -> Result<Option<(i64, i64, i64)>, AppError> {
100 let mut stmt = conn.prepare_cached(
101 "SELECT m.id, m.updated_at, COALESCE(MAX(v.version), 0)
102 FROM memories m
103 LEFT JOIN memory_versions v ON v.memory_id = m.id
104 WHERE m.namespace = ?1 AND m.name = ?2 AND m.deleted_at IS NULL
105 GROUP BY m.id",
106 )?;
107 let result = stmt.query_row(params![namespace, name], |r| {
108 Ok((
109 r.get::<_, i64>(0)?,
110 r.get::<_, i64>(1)?,
111 r.get::<_, i64>(2)?,
112 ))
113 });
114 match result {
115 Ok(row) => Ok(Some(row)),
116 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
117 Err(e) => Err(AppError::Database(e)),
118 }
119}
120
121pub fn find_by_hash(
135 conn: &Connection,
136 namespace: &str,
137 body_hash: &str,
138) -> Result<Option<i64>, AppError> {
139 let mut stmt = conn.prepare_cached(
140 "SELECT id FROM memories WHERE namespace = ?1 AND body_hash = ?2 AND deleted_at IS NULL",
141 )?;
142 match stmt.query_row(params![namespace, body_hash], |r| r.get(0)) {
143 Ok(id) => Ok(Some(id)),
144 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
145 Err(e) => Err(AppError::Database(e)),
146 }
147}
148
149pub fn insert(conn: &Connection, m: &NewMemory) -> Result<i64, AppError> {
165 let validated_source = crate::memory_source::validate_source(&m.source)?;
171 conn.execute(
172 "INSERT INTO memories (namespace, name, type, description, body, body_hash, session_id, source, metadata)
173 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
174 params![
175 m.namespace, m.name, m.memory_type, m.description, m.body,
176 m.body_hash, m.session_id, validated_source,
177 serde_json::to_string(&m.metadata)?
178 ],
179 )?;
180 Ok(conn.last_insert_rowid())
181}
182
183pub fn update(
198 conn: &Connection,
199 id: i64,
200 m: &NewMemory,
201 expected_updated_at: Option<i64>,
202) -> Result<bool, AppError> {
203 let validated_source = crate::memory_source::validate_source(&m.source)?;
208 let affected = if let Some(ts) = expected_updated_at {
209 conn.execute(
210 "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
211 session_id=?6, source=?7, metadata=?8
212 WHERE id=?1 AND updated_at=?9 AND deleted_at IS NULL",
213 params![
214 id,
215 m.memory_type,
216 m.description,
217 m.body,
218 m.body_hash,
219 m.session_id,
220 validated_source,
221 serde_json::to_string(&m.metadata)?,
222 ts
223 ],
224 )?
225 } else {
226 conn.execute(
227 "UPDATE memories SET type=?2, description=?3, body=?4, body_hash=?5,
228 session_id=?6, source=?7, metadata=?8
229 WHERE id=?1 AND deleted_at IS NULL",
230 params![
231 id,
232 m.memory_type,
233 m.description,
234 m.body,
235 m.body_hash,
236 m.session_id,
237 validated_source,
238 serde_json::to_string(&m.metadata)?
239 ],
240 )?
241 };
242 Ok(affected == 1)
243}
244
245pub fn upsert_vec(
257 conn: &Connection,
258 memory_id: i64,
259 namespace: &str,
260 _memory_type: &str,
261 embedding: &[f32],
262 _name: &str,
263 _snippet: &str,
264) -> Result<(), AppError> {
265 if embedding.is_empty() {
268 tracing::debug!(
269 memory_id,
270 "empty memory embedding: skipping memory_embeddings row (backfill via enrich re-embed)"
271 );
272 return Ok(());
273 }
274 let embedding_bytes = f32_to_bytes(embedding);
275 with_busy_retry(|| {
276 conn.execute(
277 "DELETE FROM memory_embeddings WHERE memory_id = ?1",
278 params![memory_id],
279 )?;
280 conn.execute(
281 "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
282 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
283 params![
284 memory_id,
285 namespace,
286 &embedding_bytes,
287 "llm-headless",
288 crate::constants::SQLITE_GRAPHRAG_VERSION,
289 crate::constants::embedding_dim() as i64,
290 ],
291 )?;
292 Ok(())
293 })
294}
295
296pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
308 conn.execute(
309 "DELETE FROM memory_embeddings WHERE memory_id = ?1",
310 params![memory_id],
311 )?;
312 Ok(())
313}
314
315pub fn read_by_name(
325 conn: &Connection,
326 namespace: &str,
327 name: &str,
328) -> Result<Option<MemoryRow>, AppError> {
329 let mut stmt = conn.prepare_cached(
330 "SELECT id, namespace, name, type, description, body, body_hash,
331 session_id, source, metadata, created_at, updated_at, deleted_at
332 FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
333 )?;
334 match stmt.query_row(params![namespace, name], |r| {
335 Ok(MemoryRow {
336 id: r.get(0)?,
337 namespace: r.get(1)?,
338 name: r.get(2)?,
339 memory_type: r.get(3)?,
340 description: r.get(4)?,
341 body: r.get(5)?,
342 body_hash: r.get(6)?,
343 session_id: r.get(7)?,
344 source: r.get(8)?,
345 metadata: r.get(9)?,
346 created_at: r.get(10)?,
347 updated_at: r.get(11)?,
348 deleted_at: r.get(12)?,
349 })
350 }) {
351 Ok(m) => Ok(Some(m)),
352 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
353 Err(e) => Err(AppError::Database(e)),
354 }
355}
356
357pub fn list(
368 conn: &Connection,
369 namespace: &str,
370 memory_type: Option<&str>,
371 limit: usize,
372 offset: usize,
373 include_deleted: bool,
374) -> Result<Vec<MemoryRow>, AppError> {
375 if let Some(mt) = memory_type {
376 let sql = if include_deleted {
377 "SELECT id, namespace, name, type, description, body, body_hash,
378 session_id, source, metadata, created_at, updated_at, deleted_at
379 FROM memories WHERE namespace=?1 AND type=?2
380 ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
381 } else {
382 "SELECT id, namespace, name, type, description, body, body_hash,
383 session_id, source, metadata, created_at, updated_at, deleted_at
384 FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL
385 ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4"
386 };
387 let mut stmt = conn.prepare_cached(sql)?;
388 let rows = stmt
389 .query_map(params![namespace, mt, limit as i64, offset as i64], |r| {
390 Ok(MemoryRow {
391 id: r.get(0)?,
392 namespace: r.get(1)?,
393 name: r.get(2)?,
394 memory_type: r.get(3)?,
395 description: r.get(4)?,
396 body: r.get(5)?,
397 body_hash: r.get(6)?,
398 session_id: r.get(7)?,
399 source: r.get(8)?,
400 metadata: r.get(9)?,
401 created_at: r.get(10)?,
402 updated_at: r.get(11)?,
403 deleted_at: r.get(12)?,
404 })
405 })?
406 .collect::<Result<Vec<_>, _>>()?;
407 Ok(rows)
408 } else {
409 let sql = if include_deleted {
410 "SELECT id, namespace, name, type, description, body, body_hash,
411 session_id, source, metadata, created_at, updated_at, deleted_at
412 FROM memories WHERE namespace=?1
413 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
414 } else {
415 "SELECT id, namespace, name, type, description, body, body_hash,
416 session_id, source, metadata, created_at, updated_at, deleted_at
417 FROM memories WHERE namespace=?1 AND deleted_at IS NULL
418 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3"
419 };
420 let mut stmt = conn.prepare_cached(sql)?;
421 let rows = stmt
422 .query_map(params![namespace, limit as i64, offset as i64], |r| {
423 Ok(MemoryRow {
424 id: r.get(0)?,
425 namespace: r.get(1)?,
426 name: r.get(2)?,
427 memory_type: r.get(3)?,
428 description: r.get(4)?,
429 body: r.get(5)?,
430 body_hash: r.get(6)?,
431 session_id: r.get(7)?,
432 source: r.get(8)?,
433 metadata: r.get(9)?,
434 created_at: r.get(10)?,
435 updated_at: r.get(11)?,
436 deleted_at: r.get(12)?,
437 })
438 })?
439 .collect::<Result<Vec<_>, _>>()?;
440 Ok(rows)
441 }
442}
443
444pub fn count(
446 conn: &Connection,
447 namespace: &str,
448 memory_type: Option<&str>,
449 include_deleted: bool,
450) -> Result<usize, AppError> {
451 let (sql, params_vec): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match (
452 memory_type,
453 include_deleted,
454 ) {
455 (Some(mt), true) => (
456 "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2",
457 vec![
458 Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
459 Box::new(mt.to_string()),
460 ],
461 ),
462 (Some(mt), false) => (
463 "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND type=?2 AND deleted_at IS NULL",
464 vec![
465 Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>,
466 Box::new(mt.to_string()),
467 ],
468 ),
469 (None, true) => (
470 "SELECT COUNT(*) FROM memories WHERE namespace=?1",
471 vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
472 ),
473 (None, false) => (
474 "SELECT COUNT(*) FROM memories WHERE namespace=?1 AND deleted_at IS NULL",
475 vec![Box::new(namespace.to_string()) as Box<dyn rusqlite::types::ToSql>],
476 ),
477 };
478 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
479 params_vec.iter().map(|b| b.as_ref()).collect();
480 let n: i64 = conn.query_row(sql, params_refs.as_slice(), |r| r.get(0))?;
481 Ok(n as usize)
482}
483
484pub fn knn_search(
501 conn: &Connection,
502 embedding: &[f32],
503 namespaces: &[String],
504 memory_type: Option<&str>,
505 k: usize,
506) -> Result<Vec<(i64, f32)>, AppError> {
507 if embedding.len() != crate::constants::embedding_dim() {
508 return Err(AppError::Embedding(
509 crate::i18n::validation::embedding_knn_search_dim_mismatch(
510 embedding.len(),
511 crate::constants::embedding_dim(),
512 ),
513 ));
514 }
515 let placeholders = (0..namespaces.len())
524 .map(|_| "?")
525 .collect::<Vec<_>>()
526 .join(",");
527 let sql = if namespaces.is_empty() {
528 "SELECT memory_id, embedding, namespace FROM memory_embeddings".to_string()
529 } else {
530 format!(
531 "SELECT memory_id, embedding, namespace FROM memory_embeddings \
532 WHERE namespace IN ({placeholders})"
533 )
534 };
535 let mut stmt = conn.prepare(&sql)?;
536 let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
537 for ns in namespaces {
538 raw_params.push(Box::new(ns.clone()));
539 }
540 let param_refs: Vec<&dyn rusqlite::ToSql> = raw_params.iter().map(|b| b.as_ref()).collect();
541 let rows = stmt.query_map(param_refs.as_slice(), |r| {
542 let id: i64 = r.get(0)?;
543 let bytes: Vec<u8> = r.get(1)?;
544 let ns: String = r.get(2)?;
545 Ok((id, bytes, ns))
546 })?;
547
548 let type_filter = memory_type.map(|t| t.to_string());
551 let mut candidates: Vec<(i64, f32)> = Vec::new();
552 for row in rows {
553 let (id, bytes, ns) = row?;
554 let stored = crate::embedder::bytes_to_f32(&bytes);
555 if stored.len() != embedding.len() {
556 continue;
557 }
558 let sim = crate::similarity::cosine_similarity(embedding, &stored);
559 let dist = crate::similarity::similarity_to_distance(sim);
560 if let Some(mt) = &type_filter {
561 let actual: Option<String> = conn
566 .query_row(
567 "SELECT type FROM memories WHERE id = ?1",
568 params![id],
569 |r| r.get(0),
570 )
571 .ok();
572 if actual.as_deref() != Some(mt.as_str()) {
573 continue;
574 }
575 }
576 let _ = ns; candidates.push((id, dist));
578 }
579 candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
581 candidates.truncate(k);
582 Ok(candidates)
583}
584
585pub fn read_full(conn: &Connection, memory_id: i64) -> Result<Option<MemoryRow>, AppError> {
594 let mut stmt = conn.prepare_cached(
595 "SELECT id, namespace, name, type, description, body, body_hash,
596 session_id, source, metadata, created_at, updated_at, deleted_at
597 FROM memories WHERE id=?1 AND deleted_at IS NULL",
598 )?;
599 match stmt.query_row(params![memory_id], |r| {
600 Ok(MemoryRow {
601 id: r.get(0)?,
602 namespace: r.get(1)?,
603 name: r.get(2)?,
604 memory_type: r.get(3)?,
605 description: r.get(4)?,
606 body: r.get(5)?,
607 body_hash: r.get(6)?,
608 session_id: r.get(7)?,
609 source: r.get(8)?,
610 metadata: r.get(9)?,
611 created_at: r.get(10)?,
612 updated_at: r.get(11)?,
613 deleted_at: r.get(12)?,
614 })
615 }) {
616 Ok(m) => Ok(Some(m)),
617 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
618 Err(e) => Err(AppError::Database(e)),
619 }
620}
621
622fn preprocess_fts_query(raw: &str) -> String {
632 const SEPARATORS: &[char] = &['-', '.', '_', '/'];
633 const FTS5_SYNTAX: &[char] = &['"', '*', '(', ')', '^', ':'];
634 const FTS5_KEYWORDS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
635
636 let sanitized: String = raw.chars().filter(|c| !FTS5_SYNTAX.contains(c)).collect();
637 let trimmed = sanitized.trim();
638 if trimmed.is_empty() {
639 return String::new();
640 }
641
642 let is_fts_keyword = |t: &str| FTS5_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(t));
643
644 if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
645 return trimmed
646 .split_whitespace()
647 .filter(|t| !is_fts_keyword(t))
648 .map(|t| format!("{t}*"))
649 .collect::<Vec<_>>()
650 .join(" ");
651 }
652 let tokens: Vec<&str> = trimmed
653 .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
654 .filter(|t| !t.is_empty() && !is_fts_keyword(t))
655 .collect();
656 if tokens.is_empty() {
657 return String::new();
658 }
659 let phrase = format!("\"{}\"", tokens.join(" "));
660 let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
661 format!("{phrase} OR {}", prefix_terms.join(" OR "))
662}
663
664pub fn fts_search(
673 conn: &Connection,
674 query: &str,
675 namespace: &str,
676 memory_type: Option<&str>,
677 limit: usize,
678) -> Result<Vec<MemoryRow>, AppError> {
679 let fts_query = preprocess_fts_query(query);
680 if let Some(mt) = memory_type {
681 let mut stmt = conn.prepare_cached(
682 "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
683 m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
684 FROM fts_memories fts
685 JOIN memories m ON m.id = fts.rowid
686 WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.type = ?3 AND m.deleted_at IS NULL
687 ORDER BY rank LIMIT ?4",
688 )?;
689 let rows = stmt
690 .query_map(params![fts_query, namespace, mt, limit as i64], |r| {
691 Ok(MemoryRow {
692 id: r.get(0)?,
693 namespace: r.get(1)?,
694 name: r.get(2)?,
695 memory_type: r.get(3)?,
696 description: r.get(4)?,
697 body: r.get(5)?,
698 body_hash: r.get(6)?,
699 session_id: r.get(7)?,
700 source: r.get(8)?,
701 metadata: r.get(9)?,
702 created_at: r.get(10)?,
703 updated_at: r.get(11)?,
704 deleted_at: r.get(12)?,
705 })
706 })?
707 .collect::<Result<Vec<_>, _>>()?;
708 Ok(rows)
709 } else {
710 let mut stmt = conn.prepare_cached(
711 "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
712 m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
713 FROM fts_memories fts
714 JOIN memories m ON m.id = fts.rowid
715 WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
716 ORDER BY rank LIMIT ?3",
717 )?;
718 let rows = stmt
719 .query_map(params![fts_query, namespace, limit as i64], |r| {
720 Ok(MemoryRow {
721 id: r.get(0)?,
722 namespace: r.get(1)?,
723 name: r.get(2)?,
724 memory_type: r.get(3)?,
725 description: r.get(4)?,
726 body: r.get(5)?,
727 body_hash: r.get(6)?,
728 session_id: r.get(7)?,
729 source: r.get(8)?,
730 metadata: r.get(9)?,
731 created_at: r.get(10)?,
732 updated_at: r.get(11)?,
733 deleted_at: r.get(12)?,
734 })
735 })?
736 .collect::<Result<Vec<_>, _>>()?;
737 Ok(rows)
738 }
739}
740
741#[allow(clippy::too_many_arguments)]
749pub fn sync_fts_after_update(
750 conn: &Connection,
751 memory_id: i64,
752 old_name: &str,
753 old_desc: &str,
754 old_body: &str,
755 new_name: &str,
756 new_desc: &str,
757 new_body: &str,
758) -> Result<(), AppError> {
759 conn.execute(
760 "INSERT INTO fts_memories(fts_memories, rowid, name, description, body)
761 VALUES('delete', ?1, ?2, ?3, ?4)",
762 params![memory_id, old_name, old_desc, old_body],
763 )?;
764 conn.execute(
765 "INSERT INTO fts_memories(rowid, name, description, body)
766 VALUES(?1, ?2, ?3, ?4)",
767 params![memory_id, new_name, new_desc, new_body],
768 )?;
769 Ok(())
770}
771#[cfg(test)]
772mod tests;