1use crate::db;
6use crate::db::{bytes_to_embedding, parse_optional_json, with_transaction};
7#[cfg(feature = "hnsw")]
8use crate::db::{enqueue_pending_index_op, PendingIndexOpKind};
9#[cfg(feature = "hnsw")]
10use crate::episodes;
11use crate::error::MemoryError;
12use crate::quantize::{self, Quantizer};
13use crate::types::{Fact, NamespaceDeleteReport};
14use crate::{merge_trace_ctx, MemoryStore};
15use rusqlite::{params, Connection};
16use stack_ids::TraceCtx;
17
18#[allow(dead_code)]
20pub fn insert_fact_with_fts(
21 conn: &Connection,
22 fact_id: &str,
23 namespace: &str,
24 content: &str,
25 embedding_bytes: &[u8],
26 source: Option<&str>,
27 metadata: Option<&serde_json::Value>,
28) -> Result<(), MemoryError> {
29 insert_fact_with_fts_q8(
30 conn,
31 fact_id,
32 namespace,
33 content,
34 embedding_bytes,
35 None,
36 source,
37 metadata,
38 None,
39 None,
40 )
41}
42
43#[allow(clippy::too_many_arguments)]
45pub fn insert_fact_with_fts_q8(
46 conn: &Connection,
47 fact_id: &str,
48 namespace: &str,
49 content: &str,
50 embedding_bytes: &[u8],
51 q8_bytes: Option<&[u8]>,
52 source: Option<&str>,
53 metadata: Option<&serde_json::Value>,
54 sparse: Option<(&crate::SparseWeights, &str)>,
55 journal: Option<(&str, &str)>,
56) -> Result<(), MemoryError> {
57 let metadata_str = metadata.map(|m| m.to_string());
58 with_transaction(conn, |tx| {
59 tx.execute(
60 "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
61 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
62 params![
63 fact_id,
64 namespace,
65 content,
66 source,
67 embedding_bytes,
68 q8_bytes,
69 metadata_str
70 ],
71 )?;
72
73 tx.execute(
74 "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
75 params![fact_id],
76 )?;
77 let fts_rowid = tx.last_insert_rowid();
78
79 tx.execute(
80 "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
81 params![fts_rowid, content],
82 )?;
83
84 #[cfg(feature = "hnsw")]
85 enqueue_pending_index_op(
86 tx,
87 &format!("fact:{}", fact_id),
88 "fact",
89 PendingIndexOpKind::Upsert,
90 )?;
91 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
92 if let Some((weights, representation)) = sparse {
93 db::store_sparse_vector(tx, &format!("fact:{fact_id}"), weights, representation)?;
94 }
95 if let Some((device_id, store_id)) = journal {
96 let payload = serde_json::json!({
97 "fact_id": fact_id,
98 "namespace": namespace,
99 "content": content,
100 "source": source,
101 "metadata": metadata,
102 });
103 crate::journal::append_journal_entry(
104 tx,
105 device_id,
106 store_id,
107 "add_fact",
108 payload.to_string().as_bytes(),
109 )?;
110 }
111
112 Ok(())
113 })
114}
115
116#[allow(clippy::too_many_arguments)]
120pub fn insert_fact_in_tx(
121 tx: &rusqlite::Transaction<'_>,
122 fact_id: &str,
123 namespace: &str,
124 content: &str,
125 embedding_bytes: &[u8],
126 q8_bytes: Option<&[u8]>,
127 source: Option<&str>,
128 metadata: Option<&serde_json::Value>,
129) -> Result<(), MemoryError> {
130 let metadata_str = metadata.map(|m| m.to_string());
131 tx.execute(
132 "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
133 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
134 params![
135 fact_id,
136 namespace,
137 content,
138 source,
139 embedding_bytes,
140 q8_bytes,
141 metadata_str
142 ],
143 )?;
144
145 tx.execute(
146 "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
147 params![fact_id],
148 )?;
149 let fts_rowid = tx.last_insert_rowid();
150
151 tx.execute(
152 "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
153 params![fts_rowid, content],
154 )?;
155
156 #[cfg(feature = "hnsw")]
157 enqueue_pending_index_op(
158 tx,
159 &format!("fact:{}", fact_id),
160 "fact",
161 PendingIndexOpKind::Upsert,
162 )?;
163 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
164
165 Ok(())
166}
167
168#[allow(dead_code)] pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
171 with_transaction(conn, |tx| {
172 let fts_rowid: i64 = tx
173 .query_row(
174 "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
175 params![fact_id],
176 |row| row.get(0),
177 )
178 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
179
180 let content: String = tx
181 .query_row(
182 "SELECT content FROM facts WHERE id = ?1",
183 params![fact_id],
184 |row| row.get(0),
185 )
186 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
187
188 tx.execute(
189 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
190 params![fts_rowid, content],
191 )?;
192 tx.execute(
193 "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
194 params![fact_id],
195 )?;
196 tx.execute(
197 "DELETE FROM episode_causes WHERE cause_node_id IN (?1, ?2)",
198 params![fact_id, format!("fact:{fact_id}")],
199 )?;
200 tx.execute(
201 "DELETE FROM derivation_edges
202 WHERE (source_kind = 'fact' AND source_id = ?1)
203 OR (target_kind = 'fact' AND target_id = ?1)",
204 params![fact_id],
205 )?;
206 tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
207
208 #[cfg(feature = "hnsw")]
209 enqueue_pending_index_op(
210 tx,
211 &format!("fact:{}", fact_id),
212 "fact",
213 PendingIndexOpKind::Delete,
214 )?;
215 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
216
217 Ok(())
218 })
219}
220
221#[allow(dead_code)] pub fn update_fact_with_fts(
224 conn: &Connection,
225 fact_id: &str,
226 new_content: &str,
227 new_embedding_bytes: &[u8],
228 new_q8_bytes: Option<&[u8]>,
229) -> Result<(), MemoryError> {
230 with_transaction(conn, |tx| {
231 let (fts_rowid, old_content): (i64, String) = tx
232 .query_row(
233 "SELECT fm.rowid, f.content
234 FROM facts f
235 JOIN facts_rowid_map fm ON fm.fact_id = f.id
236 WHERE f.id = ?1",
237 params![fact_id],
238 |row| Ok((row.get(0)?, row.get(1)?)),
239 )
240 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
241
242 tx.execute(
243 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
244 params![fts_rowid, old_content],
245 )?;
246
247 tx.execute(
248 "UPDATE facts
249 SET content = ?1,
250 embedding = ?2,
251 embedding_q8 = ?3,
252 updated_at = datetime('now')
253 WHERE id = ?4",
254 params![new_content, new_embedding_bytes, new_q8_bytes, fact_id],
255 )?;
256
257 tx.execute(
258 "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
259 params![fts_rowid, new_content],
260 )?;
261 tx.execute(
262 "DELETE FROM derivation_edges
263 WHERE (source_kind = 'fact' AND source_id = ?1)
264 OR (target_kind = 'fact' AND target_id = ?1)",
265 params![fact_id],
266 )?;
267
268 #[cfg(feature = "hnsw")]
269 enqueue_pending_index_op(
270 tx,
271 &format!("fact:{}", fact_id),
272 "fact",
273 PendingIndexOpKind::Upsert,
274 )?;
275 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
276
277 Ok(())
278 })
279}
280
281#[cfg(feature = "admin-ops")]
283pub fn delete_namespace(
284 conn: &Connection,
285 namespace: &str,
286) -> Result<NamespaceDeleteReport, MemoryError> {
287 with_transaction(conn, |tx| {
288 let mut report = NamespaceDeleteReport::default();
289 let delete_session = |session_id: &str| -> Result<(usize, usize), MemoryError> {
290 let message_data: Vec<(i64, String, i64, bool)> = {
291 let mut stmt = tx.prepare(
292 "SELECT m.id, m.content, mm.rowid, m.embedding IS NOT NULL
293 FROM messages m
294 JOIN messages_rowid_map mm ON mm.message_id = m.id
295 WHERE m.session_id = ?1",
296 )?;
297 let rows = stmt.query_map(params![session_id], |row| {
298 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
299 })?;
300 rows.collect::<Result<Vec<_>, _>>()?
301 };
302
303 for (message_id, content, fts_rowid, has_embedding) in &message_data {
304 #[cfg(not(feature = "hnsw"))]
305 let _ = (message_id, has_embedding);
306 tx.execute(
307 "INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ?1, ?2)",
308 params![fts_rowid, content],
309 )?;
310 #[cfg(feature = "hnsw")]
311 if *has_embedding {
312 enqueue_pending_index_op(
313 tx,
314 &format!("msg:{}", message_id),
315 "message",
316 PendingIndexOpKind::Delete,
317 )?;
318 }
319 }
320
321 let affected = tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
322 if affected == 0 {
323 return Err(MemoryError::SessionNotFound(session_id.to_string()));
324 }
325 let hnsw_ops = message_data
326 .iter()
327 .filter(|(_, _, _, has_embedding)| *has_embedding)
328 .count();
329 Ok((message_data.len(), hnsw_ops))
330 };
331
332 let document_ids: Vec<String> = {
333 let mut stmt = tx.prepare("SELECT id FROM documents WHERE namespace = ?1")?;
334 let ids = stmt
335 .query_map(params![namespace], |row| row.get(0))?
336 .collect::<Result<Vec<_>, _>>()?;
337 ids
338 };
339
340 let session_ids: Vec<String> = {
341 let mut stmt = tx.prepare("SELECT id, metadata FROM sessions")?;
342 let rows = stmt.query_map([], |row| {
343 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
344 })?;
345 let mut ids = Vec::new();
346 for row in rows {
347 let (session_id, metadata_raw) = row?;
348 let metadata = parse_optional_json(
349 "sessions",
350 &session_id,
351 "metadata",
352 metadata_raw.as_deref(),
353 )?;
354 let namespace_matches = metadata
355 .as_ref()
356 .and_then(|value| {
357 value
358 .get("namespace")
359 .or_else(|| value.get("scope_namespace"))
360 })
361 .and_then(|value| value.as_str())
362 == Some(namespace);
363 if namespace_matches {
364 ids.push(session_id);
365 }
366 }
367 ids
368 };
369
370 for session_id in &session_ids {
371 let (messages, hnsw_ops) = delete_session(session_id)?;
372 report.messages += messages;
373 report.hnsw_ops += hnsw_ops;
374 }
375 report.sessions = session_ids.len();
376
377 let delete_derivation_edges_for_id = |kind: &str, id: &str| -> Result<(), MemoryError> {
378 tx.execute(
379 "DELETE FROM derivation_edges
380 WHERE (source_kind = ?1 AND source_id = ?2)
381 OR (target_kind = ?1 AND target_id = ?2)",
382 params![kind, id],
383 )?;
384 Ok(())
385 };
386
387 let delete_derivation_edges_for_ids =
388 |kind: &str, ids: &[String]| -> Result<(), MemoryError> {
389 for id in ids {
390 delete_derivation_edges_for_id(kind, id)?;
391 }
392 Ok(())
393 };
394
395 let facts: Vec<(String, i64, String)> = {
396 let mut stmt = tx.prepare(
397 "SELECT f.id, fm.rowid, f.content
398 FROM facts f
399 JOIN facts_rowid_map fm ON fm.fact_id = f.id
400 WHERE f.namespace = ?1",
401 )?;
402 let facts = stmt
403 .query_map(params![namespace], |row| {
404 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
405 })?
406 .collect::<Result<Vec<_>, _>>()?;
407 facts
408 };
409
410 for (fact_id, fts_rowid, content) in &facts {
411 tx.execute(
412 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
413 params![fts_rowid, content],
414 )?;
415 tx.execute(
416 "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
417 params![fact_id],
418 )?;
419
420 #[cfg(feature = "hnsw")]
421 enqueue_pending_index_op(
422 tx,
423 &format!("fact:{}", fact_id),
424 "fact",
425 PendingIndexOpKind::Delete,
426 )?;
427 #[cfg(feature = "hnsw")]
428 {
429 report.hnsw_ops += 1;
430 }
431 }
432 tx.execute("DELETE FROM facts WHERE namespace = ?1", params![namespace])?;
433 report.facts = facts.len();
434
435 for doc_id in &document_ids {
436 let mut stmt = tx.prepare(
437 "SELECT c.id, c.content, cm.rowid
438 FROM chunks c
439 JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
440 WHERE c.document_id = ?1",
441 )?;
442 let chunk_rows: Vec<(String, String, i64)> = stmt
443 .query_map(params![doc_id], |row| {
444 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
445 })?
446 .collect::<Result<Vec<_>, _>>()?;
447 report.chunks += chunk_rows.len();
448
449 for (chunk_id, content, fts_rowid) in &chunk_rows {
450 tx.execute(
451 "INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
452 params![fts_rowid, content],
453 )?;
454 tx.execute(
455 "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
456 params![chunk_id],
457 )?;
458 #[cfg(feature = "hnsw")]
459 enqueue_pending_index_op(
460 tx,
461 &format!("chunk:{}", chunk_id),
462 "chunk",
463 PendingIndexOpKind::Delete,
464 )?;
465 #[cfg(feature = "hnsw")]
466 {
467 report.hnsw_ops += 1;
468 }
469 }
470
471 tx.execute("DELETE FROM chunks WHERE document_id = ?1", params![doc_id])?;
472 }
473
474 for doc_id in &document_ids {
475 let mut stmt = tx.prepare(
476 "SELECT e.episode_id, e.search_text, erm.rowid
477 FROM episodes e
478 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
479 WHERE e.document_id = ?1",
480 )?;
481 let episode_rows: Vec<(String, String, i64)> = stmt
482 .query_map(params![doc_id], |row| {
483 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
484 })?
485 .collect::<Result<Vec<_>, _>>()?;
486 report.episodes += episode_rows.len();
487
488 for (episode_id, search_text, fts_rowid) in &episode_rows {
489 tx.execute(
490 "INSERT INTO episodes_fts(episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
491 params![fts_rowid, search_text],
492 )?;
493 tx.execute(
494 "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
495 params![episode_id],
496 )?;
497 tx.execute(
498 "DELETE FROM episode_causes WHERE episode_id = ?1",
499 params![episode_id],
500 )?;
501 #[cfg(feature = "hnsw")]
502 enqueue_pending_index_op(
503 tx,
504 &episodes::episode_item_key(episode_id),
505 "episode",
506 PendingIndexOpKind::Delete,
507 )?;
508 #[cfg(feature = "hnsw")]
509 {
510 report.hnsw_ops += 1;
511 }
512 }
513
514 tx.execute(
515 "DELETE FROM episodes WHERE document_id = ?1",
516 params![doc_id],
517 )?;
518 tx.execute("DELETE FROM documents WHERE id = ?1", params![doc_id])?;
519 }
520 report.documents = document_ids.len();
521
522 let claim_ids: Vec<String> = {
523 let mut stmt =
524 tx.prepare("SELECT claim_id FROM claim_versions WHERE scope_namespace = ?1")?;
525 let ids = stmt
526 .query_map(params![namespace], |row| row.get(0))?
527 .collect::<Result<Vec<_>, _>>()?;
528 ids
529 };
530
531 let claim_version_ids: Vec<String> = {
532 let mut stmt = tx.prepare(
533 "SELECT claim_version_id FROM claim_versions WHERE scope_namespace = ?1",
534 )?;
535 let ids = stmt
536 .query_map(params![namespace], |row| row.get(0))?
537 .collect::<Result<Vec<_>, _>>()?;
538 ids
539 };
540
541 let relation_version_ids: Vec<String> = {
542 let mut stmt = tx.prepare(
543 "SELECT relation_version_id FROM relation_versions WHERE scope_namespace = ?1",
544 )?;
545 let ids = stmt
546 .query_map(params![namespace], |row| row.get(0))?
547 .collect::<Result<Vec<_>, _>>()?;
548 ids
549 };
550
551 let alias_entity_ids: Vec<String> = {
552 let mut stmt = tx.prepare(
553 "SELECT canonical_entity_id FROM entity_aliases WHERE scope_namespace = ?1",
554 )?;
555 let ids = stmt
556 .query_map(params![namespace], |row| row.get(0))?
557 .collect::<Result<Vec<_>, _>>()?;
558 ids
559 };
560
561 let evidence_handles: Vec<String> = {
562 let mut stmt = tx.prepare(
563 "SELECT er.fetch_handle FROM evidence_refs er
564 JOIN projection_import_log pil ON er.source_envelope_id = pil.source_envelope_id
565 WHERE pil.scope_namespace = ?1",
566 )?;
567 let handles = stmt
568 .query_map(params![namespace], |row| row.get(0))?
569 .collect::<Result<Vec<_>, _>>()?;
570 handles
571 };
572
573 let episode_ids: Vec<String> = {
574 let mut stmt = tx.prepare(
575 "SELECT episode_id FROM episode_links
576 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
577 )?;
578 let ids = stmt
579 .query_map(params![namespace], |row| row.get(0))?
580 .collect::<Result<Vec<_>, _>>()?;
581 ids
582 };
583
584 delete_derivation_edges_for_ids("claim", &claim_ids)?;
585 delete_derivation_edges_for_ids("claim_version", &claim_version_ids)?;
586 delete_derivation_edges_for_ids("relation_version", &relation_version_ids)?;
587 delete_derivation_edges_for_ids("entity", &alias_entity_ids)?;
588 delete_derivation_edges_for_ids("evidence_ref", &evidence_handles)?;
589 delete_derivation_edges_for_ids("episode", &episode_ids)?;
590
591 report.projection_rows += tx.execute(
592 "DELETE FROM claim_versions WHERE scope_namespace = ?1",
593 params![namespace],
594 )?;
595 report.projection_rows += tx.execute(
596 "DELETE FROM relation_versions WHERE scope_namespace = ?1",
597 params![namespace],
598 )?;
599 report.projection_rows += tx.execute(
600 "DELETE FROM entity_aliases WHERE scope_namespace = ?1",
601 params![namespace],
602 )?;
603 report.projection_rows += tx.execute(
604 "DELETE FROM evidence_refs
605 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
606 params![namespace],
607 )?;
608 report.projection_rows += tx.execute(
609 "DELETE FROM episode_links
610 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
611 params![namespace],
612 )?;
613 report.projection_rows += tx.execute(
614 "DELETE FROM projection_import_failures WHERE scope_namespace = ?1",
615 params![namespace],
616 )?;
617 report.projection_rows += tx.execute(
618 "DELETE FROM projection_import_log WHERE scope_namespace = ?1",
619 params![namespace],
620 )?;
621
622 Ok(report)
623 })
624}
625
626pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
628 let result = conn.query_row(
629 "SELECT id, namespace, content, source, created_at, updated_at, metadata
630 FROM facts WHERE id = ?1",
631 params![fact_id],
632 |row| {
633 Ok((
634 row.get::<_, String>(0)?,
635 row.get::<_, String>(1)?,
636 row.get::<_, String>(2)?,
637 row.get::<_, Option<String>>(3)?,
638 row.get::<_, String>(4)?,
639 row.get::<_, String>(5)?,
640 row.get::<_, Option<String>>(6)?,
641 ))
642 },
643 );
644
645 match result {
646 Ok((id, namespace, content, source, created_at, updated_at, metadata_raw)) => {
647 Ok(Some(Fact {
648 metadata: parse_optional_json("facts", &id, "metadata", metadata_raw.as_deref())?,
649 id,
650 namespace,
651 content,
652 source,
653 created_at,
654 updated_at,
655 }))
656 }
657 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
658 Err(err) => Err(MemoryError::Database(err)),
659 }
660}
661
662pub fn get_fact_embedding(
664 conn: &Connection,
665 fact_id: &str,
666) -> Result<Option<Vec<f32>>, MemoryError> {
667 let result: Result<Option<Vec<u8>>, _> = conn.query_row(
668 "SELECT embedding FROM facts WHERE id = ?1",
669 params![fact_id],
670 |row| row.get(0),
671 );
672
673 match result {
674 Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
675 Ok(None) => Ok(None),
676 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
677 Err(err) => Err(MemoryError::Database(err)),
678 }
679}
680
681pub fn list_fact_namespaces(conn: &Connection) -> Result<Vec<String>, MemoryError> {
683 let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM facts ORDER BY namespace")?;
684 let rows = stmt
685 .query_map([], |row| row.get::<_, String>(0))?
686 .collect::<Result<Vec<_>, _>>()?;
687 Ok(rows)
688}
689
690#[allow(dead_code)] pub fn list_facts(
693 conn: &Connection,
694 namespace: &str,
695 limit: usize,
696 offset: usize,
697) -> Result<Vec<Fact>, MemoryError> {
698 list_facts_with_view(conn, namespace, limit, offset, &StateView::Current)
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
703pub enum StateView {
704 Current,
705 HistoricalAt(String),
706 RecordedAsOf(String),
707 IncludeSuperseded,
708}
709
710pub(crate) fn fact_is_visible_with_view(
711 conn: &Connection,
712 fact_id: &str,
713 view: &StateView,
714) -> Result<bool, MemoryError> {
715 let forgotten: bool = conn.query_row(
716 "SELECT EXISTS(SELECT 1 FROM forgotten_facts WHERE fact_id = ?1)",
717 params![fact_id],
718 |row| row.get(0),
719 )?;
720 if forgotten {
721 return Ok(false);
722 }
723 let cutoff = match view {
724 StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
725 let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
726 MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
727 })?;
728 Some(
729 parsed
730 .with_timezone(&chrono::Utc)
731 .format("%Y-%m-%d %H:%M:%S%.6f")
732 .to_string(),
733 )
734 }
735 _ => None,
736 };
737 let include_superseded = matches!(view, StateView::IncludeSuperseded);
738 let visible: i64 = conn.query_row(
739 "SELECT EXISTS(
740 SELECT 1 FROM facts f
741 WHERE f.id = ?1
742 AND (?2 IS NULL OR f.created_at <= ?2)
743 AND (?3 = 1 OR NOT EXISTS (
744 SELECT 1 FROM graph_edges ge
745 WHERE ge.target = 'fact:' || f.id
746 AND ge.is_invalidated = 0
747 AND COALESCE(
748 json_extract(ge.edge_type, '$.relation'),
749 json_extract(ge.edge_type, '$.entity.relation')
750 ) IN ('supersedes', 'redacts')
751 AND (?2 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?2)
752 ))
753 )",
754 params![fact_id, cutoff, include_superseded],
755 |row| row.get(0),
756 )?;
757 Ok(visible != 0)
758}
759
760pub fn list_facts_with_view(
762 conn: &Connection,
763 namespace: &str,
764 limit: usize,
765 offset: usize,
766 view: &StateView,
767) -> Result<Vec<Fact>, MemoryError> {
768 let cutoff = match view {
769 StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
770 let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
771 MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
772 })?;
773 Some(
774 parsed
775 .with_timezone(&chrono::Utc)
776 .format("%Y-%m-%d %H:%M:%S%.6f")
777 .to_string(),
778 )
779 }
780 _ => None,
781 };
782 let inconsistent: i64 = conn.query_row(
783 "SELECT COUNT(*) FROM (
784 SELECT target FROM graph_edges
785 WHERE is_invalidated = 0
786 AND COALESCE(
787 json_extract(edge_type, '$.relation'),
788 json_extract(edge_type, '$.entity.relation')
789 ) IN ('supersedes', 'redacts')
790 AND (?1 IS NULL OR COALESCE(recorded_time, recorded_at) <= ?1)
791 GROUP BY target HAVING COUNT(DISTINCT source) > 1
792 )",
793 params![cutoff.as_deref()],
794 |row| row.get(0),
795 )?;
796 if inconsistent != 0 {
797 return Err(MemoryError::Other(
798 "inconsistent fact lineage: multiple active heads".into(),
799 ));
800 }
801 let include_superseded = matches!(view, StateView::IncludeSuperseded);
802 let mut stmt = conn.prepare(
803 "SELECT id, namespace, content, source, created_at, updated_at, metadata
804 FROM facts
805 WHERE namespace = ?1
806 AND NOT EXISTS (
807 SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = facts.id
808 )
809 AND (?4 IS NULL OR created_at <= ?4)
810 AND (?5 = 1 OR NOT EXISTS (
811 SELECT 1 FROM graph_edges ge
812 WHERE ge.target = 'fact:' || facts.id
813 AND ge.is_invalidated = 0
814 AND COALESCE(
815 json_extract(ge.edge_type, '$.relation'),
816 json_extract(ge.edge_type, '$.entity.relation')
817 ) IN ('supersedes', 'redacts')
818 AND (?4 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?4)
819 ))
820 ORDER BY updated_at DESC
821 LIMIT ?2 OFFSET ?3",
822 )?;
823
824 let facts = stmt
825 .query_map(
826 params![
827 namespace,
828 limit as i64,
829 offset as i64,
830 cutoff,
831 include_superseded
832 ],
833 |row| {
834 Ok((
835 row.get::<_, String>(0)?,
836 row.get::<_, String>(1)?,
837 row.get::<_, String>(2)?,
838 row.get::<_, Option<String>>(3)?,
839 row.get::<_, String>(4)?,
840 row.get::<_, String>(5)?,
841 row.get::<_, Option<String>>(6)?,
842 ))
843 },
844 )?
845 .collect::<Result<Vec<_>, _>>()?
846 .into_iter()
847 .map(
848 |(id, namespace, content, source, created_at, updated_at, metadata_raw)| {
849 Ok(Fact {
850 metadata: parse_optional_json(
851 "facts",
852 &id,
853 "metadata",
854 metadata_raw.as_deref(),
855 )?,
856 id,
857 namespace,
858 content,
859 source,
860 created_at,
861 updated_at,
862 })
863 },
864 )
865 .collect::<Result<Vec<_>, MemoryError>>()?;
866
867 Ok(facts)
868}
869
870impl MemoryStore {
871 pub async fn add_fact_raw_compat(
876 &self,
877 namespace: &str,
878 content: &str,
879 source: Option<&str>,
880 metadata: Option<serde_json::Value>,
881 trace_ctx: Option<&TraceCtx>,
882 ) -> Result<Fact, MemoryError> {
883 let id = self
884 .add_fact_with_trace(namespace, content, source, metadata, trace_ctx)
885 .await?;
886 self.get_fact(&id)
887 .await?
888 .ok_or(MemoryError::FactNotFound(id))
889 }
890
891 pub async fn add_fact(
896 &self,
897 namespace: &str,
898 content: &str,
899 source: Option<&str>,
900 metadata: Option<serde_json::Value>,
901 ) -> Result<String, MemoryError> {
902 self.add_fact_with_trace(namespace, content, source, metadata, None)
903 .await
904 }
905
906 pub async fn add_fact_with_trace(
908 &self,
909 namespace: &str,
910 content: &str,
911 source: Option<&str>,
912 metadata: Option<serde_json::Value>,
913 trace_ctx: Option<&TraceCtx>,
914 ) -> Result<String, MemoryError> {
915 self.validate_content("fact.content", content)?;
916
917 let ns_check = namespace.to_string();
920 let ct_check = content.to_string();
921 let existing_id = self
922 .with_read_conn(move |conn| {
923 let result: Option<String> = conn
924 .query_row(
925 "SELECT id FROM facts WHERE content = ?1 AND namespace = ?2 LIMIT 1",
926 rusqlite::params![&ct_check, &ns_check],
927 |row| row.get::<_, String>(0),
928 )
929 .ok();
930 Ok(result)
931 })
932 .await?;
933
934 if let Some(id) = existing_id {
935 return Ok(id);
936 }
937
938 let (embedding, sparse, sparse_representation) = self
939 .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
940 .await?;
941 self.validate_embedding_dimensions(&embedding)?;
942 let embedding_bytes = db::embedding_to_bytes(&embedding);
943 let fact_id = uuid::Uuid::new_v4().to_string();
944 let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
945
946 let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
947 let q8_bytes = quantizer
949 .quantize(&embedding)
950 .map(|qv| quantize::pack_quantized(&qv))
951 .ok();
952
953 let ns = namespace.to_string();
954 let ct = content.to_string();
955 let fid = fact_id.clone();
956 let src = source.map(|s| s.to_string());
957 let meta = merge_trace_ctx(metadata, trace_ctx);
958 let journal = self
959 .inner
960 .config
961 .journal_device_id
962 .as_deref()
963 .zip(self.inner.config.journal_store_id.as_deref())
964 .map(|(device_id, store_id)| (device_id.to_string(), store_id.to_string()));
965 self.with_write_conn(move |conn| {
966 let current_count: usize = conn.query_row(
967 "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
968 rusqlite::params![&ns],
969 |row| row.get(0),
970 )?;
971 if current_count >= max_facts_per_namespace {
972 return Err(MemoryError::NamespaceFull {
973 namespace: ns.clone(),
974 count: current_count,
975 limit: max_facts_per_namespace,
976 });
977 }
978 insert_fact_with_fts_q8(
979 conn,
980 &fid,
981 &ns,
982 &ct,
983 &embedding_bytes,
984 q8_bytes.as_deref(),
985 src.as_deref(),
986 meta.as_ref(),
987 sparse.as_ref().zip(sparse_representation.as_deref()),
988 journal
989 .as_ref()
990 .map(|(device_id, store_id)| (device_id.as_str(), store_id.as_str())),
991 )
992 })
993 .await?;
994
995 self.clear_search_cache();
996
997 #[cfg(feature = "hnsw")]
998 self.sync_pending_hnsw_ops_best_effort("add_fact").await;
999
1000 Ok(fact_id)
1001 }
1002
1003 pub async fn add_fact_with_embedding(
1005 &self,
1006 namespace: &str,
1007 content: &str,
1008 embedding: &[f32],
1009 source: Option<&str>,
1010 metadata: Option<serde_json::Value>,
1011 ) -> Result<String, MemoryError> {
1012 self.add_fact_with_embedding_and_trace(
1013 namespace, content, embedding, source, metadata, None,
1014 )
1015 .await
1016 }
1017
1018 pub async fn add_fact_with_embedding_and_trace(
1020 &self,
1021 namespace: &str,
1022 content: &str,
1023 embedding: &[f32],
1024 source: Option<&str>,
1025 metadata: Option<serde_json::Value>,
1026 trace_ctx: Option<&TraceCtx>,
1027 ) -> Result<String, MemoryError> {
1028 self.validate_content("fact.content", content)?;
1029 self.validate_embedding_dimensions(embedding)?;
1030 let embedding_bytes = db::embedding_to_bytes(embedding);
1031 let sparse = self.inner.config.search.derive_sparse_from_dense.then(|| {
1032 crate::SparseWeights::from_dense(
1033 embedding,
1034 self.inner.config.search.sparse_derive_top_k,
1035 self.inner.config.search.sparse_derive_min_weight,
1036 )
1037 });
1038 let fact_id = uuid::Uuid::new_v4().to_string();
1039 let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1040
1041 let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
1042 let q8_bytes = quantizer
1044 .quantize(embedding)
1045 .map(|qv| quantize::pack_quantized(&qv))
1046 .ok();
1047
1048 let ns = namespace.to_string();
1049 let ct = content.to_string();
1050 let fid = fact_id.clone();
1051 let src = source.map(|s| s.to_string());
1052 let meta = merge_trace_ctx(metadata, trace_ctx);
1053 let journal = self
1054 .inner
1055 .config
1056 .journal_device_id
1057 .as_deref()
1058 .zip(self.inner.config.journal_store_id.as_deref())
1059 .map(|(device_id, store_id)| (device_id.to_string(), store_id.to_string()));
1060 self.with_write_conn(move |conn| {
1061 let current_count: usize = conn.query_row(
1062 "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1063 rusqlite::params![&ns],
1064 |row| row.get(0),
1065 )?;
1066 if current_count >= max_facts_per_namespace {
1067 return Err(MemoryError::NamespaceFull {
1068 namespace: ns.clone(),
1069 count: current_count,
1070 limit: max_facts_per_namespace,
1071 });
1072 }
1073 insert_fact_with_fts_q8(
1074 conn,
1075 &fid,
1076 &ns,
1077 &ct,
1078 &embedding_bytes,
1079 q8_bytes.as_deref(),
1080 src.as_deref(),
1081 meta.as_ref(),
1082 sparse
1083 .as_ref()
1084 .map(|weights| (weights, "generic_dense_derived_sparse")),
1085 journal
1086 .as_ref()
1087 .map(|(device_id, store_id)| (device_id.as_str(), store_id.as_str())),
1088 )
1089 })
1090 .await?;
1091
1092 self.clear_search_cache();
1093
1094 #[cfg(feature = "hnsw")]
1095 self.sync_pending_hnsw_ops_best_effort("add_fact_with_embedding")
1096 .await;
1097
1098 Ok(fact_id)
1099 }
1100
1101 #[cfg(feature = "admin-ops")]
1106 pub async fn update_fact(&self, fact_id: &str, content: &str) -> Result<(), MemoryError> {
1107 self.validate_content("fact.content", content)?;
1108 let (embedding, sparse, sparse_representation) = self
1109 .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
1110 .await?;
1111 self.validate_embedding_dimensions(&embedding)?;
1112 let embedding_bytes = db::embedding_to_bytes(&embedding);
1113 let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1115 .quantize(&embedding)
1116 .map(|qv| quantize::pack_quantized(&qv))
1117 .ok();
1118
1119 let fid = fact_id.to_string();
1120 let ct = content.to_string();
1121 self.with_write_conn(move |conn| {
1122 update_fact_with_fts(conn, &fid, &ct, &embedding_bytes, q8_bytes.as_deref())?;
1123 let item_key = format!("fact:{fid}");
1124 if let Some((weights, representation)) =
1125 sparse.as_ref().zip(sparse_representation.as_deref())
1126 {
1127 db::store_sparse_vector(conn, &item_key, weights, representation)?;
1128 } else {
1129 db::delete_sparse_vector(conn, &item_key)?;
1130 }
1131 Ok(())
1132 })
1133 .await?;
1134
1135 #[cfg(feature = "hnsw")]
1136 self.sync_pending_hnsw_ops_best_effort("update_fact").await;
1137
1138 self.clear_search_cache();
1139
1140 Ok(())
1141 }
1142
1143 #[cfg(feature = "admin-ops")]
1148 pub async fn delete_fact(&self, fact_id: &str) -> Result<(), MemoryError> {
1149 let fid = fact_id.to_string();
1150 self.with_write_conn(move |conn| delete_fact_with_fts(conn, &fid))
1151 .await?;
1152
1153 #[cfg(feature = "hnsw")]
1154 self.sync_pending_hnsw_ops_best_effort("delete_fact").await;
1155
1156 self.clear_search_cache();
1157
1158 Ok(())
1159 }
1160
1161 #[cfg(feature = "admin-ops")]
1165 pub async fn delete_namespace(
1166 &self,
1167 namespace: &str,
1168 ) -> Result<NamespaceDeleteReport, MemoryError> {
1169 let ns = namespace.to_string();
1170 let count = self
1171 .with_write_conn(move |conn| delete_namespace(conn, &ns))
1172 .await?;
1173
1174 #[cfg(feature = "hnsw")]
1175 self.sync_pending_hnsw_ops_best_effort("delete_namespace")
1176 .await;
1177
1178 self.clear_search_cache();
1179
1180 Ok(count)
1181 }
1182
1183 pub async fn get_fact(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1185 let fid = fact_id.to_string();
1186 self.with_read_conn(move |conn| get_fact(conn, &fid)).await
1187 }
1188
1189 pub async fn get_fact_raw_compat(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1191 self.get_fact(fact_id).await
1192 }
1193
1194 pub async fn get_fact_embedding(&self, fact_id: &str) -> Result<Option<Vec<f32>>, MemoryError> {
1196 let fid = fact_id.to_string();
1197 self.with_read_conn(move |conn| get_fact_embedding(conn, &fid))
1198 .await
1199 }
1200
1201 pub async fn list_facts(
1203 &self,
1204 namespace: &str,
1205 limit: usize,
1206 offset: usize,
1207 ) -> Result<Vec<Fact>, MemoryError> {
1208 self.list_facts_with_view(namespace, limit, offset, StateView::Current)
1209 .await
1210 }
1211
1212 pub async fn list_facts_with_view(
1214 &self,
1215 namespace: &str,
1216 limit: usize,
1217 offset: usize,
1218 view: StateView,
1219 ) -> Result<Vec<Fact>, MemoryError> {
1220 let ns = namespace.to_string();
1221 self.with_read_conn(move |conn| list_facts_with_view(conn, &ns, limit, offset, &view))
1222 .await
1223 }
1224
1225 pub async fn list_fact_namespaces(&self) -> Result<Vec<String>, MemoryError> {
1227 self.with_read_conn(move |conn| list_fact_namespaces(conn))
1228 .await
1229 }
1230}
1231
1232#[cfg(test)]
1233mod state_view_regression_tests {
1234 use super::*;
1235 use crate::db::run_migrations;
1236 use rusqlite::Connection;
1237
1238 fn seeded() -> Connection {
1239 let conn = Connection::open_in_memory().unwrap();
1240 run_migrations(&conn).unwrap();
1241 for (id, content, created) in [
1242 ("old", "same topic old", "2026-07-10 21:00:00"),
1243 ("new", "same topic new", "2026-07-10 21:12:01"),
1244 ] {
1245 conn.execute(
1246 "INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES (?1, 'n', ?2, ?3, ?3)",
1247 params![id, content, created],
1248 ).unwrap();
1249 }
1250 conn
1251 }
1252
1253 fn supersedes(conn: &Connection, source: &str, target: &str, recorded: &str) {
1254 conn.execute(
1255 "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1256 VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"type\":\"entity\",\"relation\":\"supersedes\"}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1257 params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1258 ).unwrap();
1259 }
1260
1261 fn supersedes_canonical(conn: &Connection, source: &str, target: &str, recorded: &str) {
1262 conn.execute(
1263 "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1264 VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"entity\":{\"relation\":\"supersedes\"}}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1265 params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1266 ).unwrap();
1267 }
1268
1269 #[test]
1270 fn historical_view_excludes_future_fact_and_reconstructs_pre_supersession_head() {
1271 let conn = seeded();
1272 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1273 let rows = list_facts_with_view(
1274 &conn,
1275 "n",
1276 10,
1277 0,
1278 &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1279 )
1280 .unwrap();
1281 assert_eq!(
1282 rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1283 ["old"]
1284 );
1285 }
1286
1287 #[test]
1288 fn historical_view_preserves_pre_adjudication_conflict() {
1289 let conn = seeded();
1290 conn.execute(
1291 "UPDATE facts SET created_at = '2026-07-10 21:10:00', updated_at = '2026-07-10 21:10:00' WHERE id = 'new'",
1292 [],
1293 )
1294 .unwrap();
1295 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1296
1297 let rows = list_facts_with_view(
1298 &conn,
1299 "n",
1300 10,
1301 0,
1302 &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1303 )
1304 .unwrap();
1305 let ids = rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>();
1306 assert!(
1307 ids.contains(&"old"),
1308 "prior observation must remain visible"
1309 );
1310 assert!(ids.contains(&"new"), "conflicting observation created before the cutoff must remain visible until adjudication");
1311 }
1312
1313 #[test]
1314 fn current_view_excludes_superseded_fact() {
1315 let conn = seeded();
1316 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1317 let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1318 assert_eq!(
1319 rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1320 ["new"]
1321 );
1322 }
1323
1324 #[test]
1325 fn current_view_accepts_canonical_entity_edge_serialization() {
1326 let conn = seeded();
1327 supersedes_canonical(&conn, "new", "old", "2026-07-10 21:12:01");
1328 let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1329 assert_eq!(
1330 rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
1331 vec!["new"]
1332 );
1333 }
1334
1335 #[test]
1336 fn multiple_active_heads_fail_closed() {
1337 let conn = seeded();
1338 conn.execute("INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES ('other', 'n', 'same topic conflicting', '2026-07-10 21:13:00', '2026-07-10 21:13:00')", []).unwrap();
1339 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1340 supersedes(&conn, "other", "old", "2026-07-10 21:13:00");
1341 assert!(list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).is_err());
1342 }
1343}