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