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, OptionalExtension};
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, u64)>,
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, stream_epoch)) = journal {
96 let payload =
97 crate::journal::encode_fact_create_payload(&crate::journal::FactCreatePayloadV1 {
98 fact_id: fact_id.to_string(),
99 namespace: namespace.to_string(),
100 content: content.to_string(),
101 source: source.map(str::to_string),
102 metadata: metadata.cloned(),
103 })?;
104 crate::journal::append_verified_in_tx(
105 tx,
106 device_id,
107 store_id,
108 stream_epoch,
109 crate::journal::FACT_CREATE_OPERATION,
110 crate::journal::FACT_CREATE_PAYLOAD_SCHEMA,
111 &payload,
112 )?;
113 }
114
115 Ok(())
116 })
117}
118
119#[allow(clippy::too_many_arguments)]
123pub fn insert_fact_in_tx(
124 tx: &rusqlite::Transaction<'_>,
125 fact_id: &str,
126 namespace: &str,
127 content: &str,
128 embedding_bytes: &[u8],
129 q8_bytes: Option<&[u8]>,
130 source: Option<&str>,
131 metadata: Option<&serde_json::Value>,
132) -> Result<(), MemoryError> {
133 let metadata_str = metadata.map(|m| m.to_string());
134 tx.execute(
135 "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata)
136 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
137 params![
138 fact_id,
139 namespace,
140 content,
141 source,
142 embedding_bytes,
143 q8_bytes,
144 metadata_str
145 ],
146 )?;
147
148 tx.execute(
149 "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
150 params![fact_id],
151 )?;
152 let fts_rowid = tx.last_insert_rowid();
153
154 tx.execute(
155 "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
156 params![fts_rowid, content],
157 )?;
158
159 #[cfg(feature = "hnsw")]
160 enqueue_pending_index_op(
161 tx,
162 &format!("fact:{}", fact_id),
163 "fact",
164 PendingIndexOpKind::Upsert,
165 )?;
166 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
167
168 Ok(())
169}
170
171#[allow(dead_code)] pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
174 with_transaction(conn, |tx| {
175 let fts_rowid: i64 = tx
176 .query_row(
177 "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
178 params![fact_id],
179 |row| row.get(0),
180 )
181 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
182
183 let content: String = tx
184 .query_row(
185 "SELECT content FROM facts WHERE id = ?1",
186 params![fact_id],
187 |row| row.get(0),
188 )
189 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
190
191 tx.execute(
192 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
193 params![fts_rowid, content],
194 )?;
195 tx.execute(
196 "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
197 params![fact_id],
198 )?;
199 tx.execute(
200 "DELETE FROM episode_causes WHERE cause_node_id IN (?1, ?2)",
201 params![fact_id, format!("fact:{fact_id}")],
202 )?;
203 tx.execute(
204 "DELETE FROM derivation_edges
205 WHERE (source_kind = 'fact' AND source_id = ?1)
206 OR (target_kind = 'fact' AND target_id = ?1)",
207 params![fact_id],
208 )?;
209 tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
210
211 #[cfg(feature = "hnsw")]
212 enqueue_pending_index_op(
213 tx,
214 &format!("fact:{}", fact_id),
215 "fact",
216 PendingIndexOpKind::Delete,
217 )?;
218 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
219
220 Ok(())
221 })
222}
223
224#[allow(dead_code)] pub fn update_fact_with_fts(
227 conn: &Connection,
228 fact_id: &str,
229 new_content: &str,
230 new_embedding_bytes: &[u8],
231 new_q8_bytes: Option<&[u8]>,
232) -> Result<(), MemoryError> {
233 with_transaction(conn, |tx| {
234 let (fts_rowid, old_content): (i64, String) = tx
235 .query_row(
236 "SELECT fm.rowid, f.content
237 FROM facts f
238 JOIN facts_rowid_map fm ON fm.fact_id = f.id
239 WHERE f.id = ?1",
240 params![fact_id],
241 |row| Ok((row.get(0)?, row.get(1)?)),
242 )
243 .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", fact_id)))?;
244
245 tx.execute(
246 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
247 params![fts_rowid, old_content],
248 )?;
249
250 tx.execute(
251 "UPDATE facts
252 SET content = ?1,
253 embedding = ?2,
254 embedding_q8 = ?3,
255 updated_at = datetime('now')
256 WHERE id = ?4",
257 params![new_content, new_embedding_bytes, new_q8_bytes, fact_id],
258 )?;
259
260 tx.execute(
261 "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
262 params![fts_rowid, new_content],
263 )?;
264 tx.execute(
265 "DELETE FROM derivation_edges
266 WHERE (source_kind = 'fact' AND source_id = ?1)
267 OR (target_kind = 'fact' AND target_id = ?1)",
268 params![fact_id],
269 )?;
270
271 #[cfg(feature = "hnsw")]
272 enqueue_pending_index_op(
273 tx,
274 &format!("fact:{}", fact_id),
275 "fact",
276 PendingIndexOpKind::Upsert,
277 )?;
278 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fact_id}"))?;
279
280 Ok(())
281 })
282}
283
284#[cfg(feature = "admin-ops")]
286pub fn delete_namespace(
287 conn: &Connection,
288 namespace: &str,
289) -> Result<NamespaceDeleteReport, MemoryError> {
290 with_transaction(conn, |tx| {
291 let mut report = NamespaceDeleteReport::default();
292 let delete_session = |session_id: &str| -> Result<(usize, usize), MemoryError> {
293 let message_data: Vec<(i64, String, i64, bool)> = {
294 let mut stmt = tx.prepare(
295 "SELECT m.id, m.content, mm.rowid, m.embedding IS NOT NULL
296 FROM messages m
297 JOIN messages_rowid_map mm ON mm.message_id = m.id
298 WHERE m.session_id = ?1",
299 )?;
300 let rows = stmt.query_map(params![session_id], |row| {
301 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
302 })?;
303 rows.collect::<Result<Vec<_>, _>>()?
304 };
305
306 for (message_id, content, fts_rowid, has_embedding) in &message_data {
307 #[cfg(not(feature = "hnsw"))]
308 let _ = (message_id, has_embedding);
309 tx.execute(
310 "INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', ?1, ?2)",
311 params![fts_rowid, content],
312 )?;
313 #[cfg(feature = "hnsw")]
314 if *has_embedding {
315 enqueue_pending_index_op(
316 tx,
317 &format!("msg:{}", message_id),
318 "message",
319 PendingIndexOpKind::Delete,
320 )?;
321 }
322 }
323
324 let affected = tx.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
325 if affected == 0 {
326 return Err(MemoryError::SessionNotFound(session_id.to_string()));
327 }
328 let hnsw_ops = message_data
329 .iter()
330 .filter(|(_, _, _, has_embedding)| *has_embedding)
331 .count();
332 Ok((message_data.len(), hnsw_ops))
333 };
334
335 let document_ids: Vec<String> = {
336 let mut stmt = tx.prepare("SELECT id FROM documents WHERE namespace = ?1")?;
337 let ids = stmt
338 .query_map(params![namespace], |row| row.get(0))?
339 .collect::<Result<Vec<_>, _>>()?;
340 ids
341 };
342
343 let session_ids: Vec<String> = {
344 let mut stmt = tx.prepare("SELECT id, metadata FROM sessions")?;
345 let rows = stmt.query_map([], |row| {
346 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
347 })?;
348 let mut ids = Vec::new();
349 for row in rows {
350 let (session_id, metadata_raw) = row?;
351 let metadata = parse_optional_json(
352 "sessions",
353 &session_id,
354 "metadata",
355 metadata_raw.as_deref(),
356 )?;
357 let namespace_matches = metadata
358 .as_ref()
359 .and_then(|value| {
360 value
361 .get("namespace")
362 .or_else(|| value.get("scope_namespace"))
363 })
364 .and_then(|value| value.as_str())
365 == Some(namespace);
366 if namespace_matches {
367 ids.push(session_id);
368 }
369 }
370 ids
371 };
372
373 for session_id in &session_ids {
374 let (messages, hnsw_ops) = delete_session(session_id)?;
375 report.messages += messages;
376 report.hnsw_ops += hnsw_ops;
377 }
378 report.sessions = session_ids.len();
379
380 let delete_derivation_edges_for_id = |kind: &str, id: &str| -> Result<(), MemoryError> {
381 tx.execute(
382 "DELETE FROM derivation_edges
383 WHERE (source_kind = ?1 AND source_id = ?2)
384 OR (target_kind = ?1 AND target_id = ?2)",
385 params![kind, id],
386 )?;
387 Ok(())
388 };
389
390 let delete_derivation_edges_for_ids =
391 |kind: &str, ids: &[String]| -> Result<(), MemoryError> {
392 for id in ids {
393 delete_derivation_edges_for_id(kind, id)?;
394 }
395 Ok(())
396 };
397
398 let facts: Vec<(String, i64, String)> = {
399 let mut stmt = tx.prepare(
400 "SELECT f.id, fm.rowid, f.content
401 FROM facts f
402 JOIN facts_rowid_map fm ON fm.fact_id = f.id
403 WHERE f.namespace = ?1",
404 )?;
405 let facts = stmt
406 .query_map(params![namespace], |row| {
407 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
408 })?
409 .collect::<Result<Vec<_>, _>>()?;
410 facts
411 };
412
413 for (fact_id, fts_rowid, content) in &facts {
414 tx.execute(
415 "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
416 params![fts_rowid, content],
417 )?;
418 tx.execute(
419 "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
420 params![fact_id],
421 )?;
422
423 #[cfg(feature = "hnsw")]
424 enqueue_pending_index_op(
425 tx,
426 &format!("fact:{}", fact_id),
427 "fact",
428 PendingIndexOpKind::Delete,
429 )?;
430 #[cfg(feature = "hnsw")]
431 {
432 report.hnsw_ops += 1;
433 }
434 }
435 tx.execute("DELETE FROM facts WHERE namespace = ?1", params![namespace])?;
436 report.facts = facts.len();
437
438 for doc_id in &document_ids {
439 let mut stmt = tx.prepare(
440 "SELECT c.id, c.content, cm.rowid
441 FROM chunks c
442 JOIN chunks_rowid_map cm ON cm.chunk_id = c.id
443 WHERE c.document_id = ?1",
444 )?;
445 let chunk_rows: Vec<(String, String, i64)> = stmt
446 .query_map(params![doc_id], |row| {
447 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
448 })?
449 .collect::<Result<Vec<_>, _>>()?;
450 report.chunks += chunk_rows.len();
451
452 for (chunk_id, content, fts_rowid) in &chunk_rows {
453 tx.execute(
454 "INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', ?1, ?2)",
455 params![fts_rowid, content],
456 )?;
457 tx.execute(
458 "DELETE FROM chunks_rowid_map WHERE chunk_id = ?1",
459 params![chunk_id],
460 )?;
461 #[cfg(feature = "hnsw")]
462 enqueue_pending_index_op(
463 tx,
464 &format!("chunk:{}", chunk_id),
465 "chunk",
466 PendingIndexOpKind::Delete,
467 )?;
468 #[cfg(feature = "hnsw")]
469 {
470 report.hnsw_ops += 1;
471 }
472 }
473
474 tx.execute("DELETE FROM chunks WHERE document_id = ?1", params![doc_id])?;
475 }
476
477 for doc_id in &document_ids {
478 let mut stmt = tx.prepare(
479 "SELECT e.episode_id, e.search_text, erm.rowid
480 FROM episodes e
481 JOIN episodes_rowid_map erm ON erm.episode_id = e.episode_id
482 WHERE e.document_id = ?1",
483 )?;
484 let episode_rows: Vec<(String, String, i64)> = stmt
485 .query_map(params![doc_id], |row| {
486 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
487 })?
488 .collect::<Result<Vec<_>, _>>()?;
489 report.episodes += episode_rows.len();
490
491 for (episode_id, search_text, fts_rowid) in &episode_rows {
492 tx.execute(
493 "INSERT INTO episodes_fts(episodes_fts, rowid, content) VALUES ('delete', ?1, ?2)",
494 params![fts_rowid, search_text],
495 )?;
496 tx.execute(
497 "DELETE FROM episodes_rowid_map WHERE episode_id = ?1",
498 params![episode_id],
499 )?;
500 tx.execute(
501 "DELETE FROM episode_causes WHERE episode_id = ?1",
502 params![episode_id],
503 )?;
504 #[cfg(feature = "hnsw")]
505 enqueue_pending_index_op(
506 tx,
507 &episodes::episode_item_key(episode_id),
508 "episode",
509 PendingIndexOpKind::Delete,
510 )?;
511 #[cfg(feature = "hnsw")]
512 {
513 report.hnsw_ops += 1;
514 }
515 }
516
517 tx.execute(
518 "DELETE FROM episodes WHERE document_id = ?1",
519 params![doc_id],
520 )?;
521 tx.execute("DELETE FROM documents WHERE id = ?1", params![doc_id])?;
522 }
523 report.documents = document_ids.len();
524
525 let claim_ids: Vec<String> = {
526 let mut stmt =
527 tx.prepare("SELECT claim_id FROM claim_versions WHERE scope_namespace = ?1")?;
528 let ids = stmt
529 .query_map(params![namespace], |row| row.get(0))?
530 .collect::<Result<Vec<_>, _>>()?;
531 ids
532 };
533
534 let claim_version_ids: Vec<String> = {
535 let mut stmt = tx.prepare(
536 "SELECT claim_version_id FROM claim_versions WHERE scope_namespace = ?1",
537 )?;
538 let ids = stmt
539 .query_map(params![namespace], |row| row.get(0))?
540 .collect::<Result<Vec<_>, _>>()?;
541 ids
542 };
543
544 let relation_version_ids: Vec<String> = {
545 let mut stmt = tx.prepare(
546 "SELECT relation_version_id FROM relation_versions WHERE scope_namespace = ?1",
547 )?;
548 let ids = stmt
549 .query_map(params![namespace], |row| row.get(0))?
550 .collect::<Result<Vec<_>, _>>()?;
551 ids
552 };
553
554 let alias_entity_ids: Vec<String> = {
555 let mut stmt = tx.prepare(
556 "SELECT canonical_entity_id FROM entity_aliases WHERE scope_namespace = ?1",
557 )?;
558 let ids = stmt
559 .query_map(params![namespace], |row| row.get(0))?
560 .collect::<Result<Vec<_>, _>>()?;
561 ids
562 };
563
564 let evidence_handles: Vec<String> = {
565 let mut stmt = tx.prepare(
566 "SELECT er.fetch_handle FROM evidence_refs er
567 JOIN projection_import_log pil ON er.source_envelope_id = pil.source_envelope_id
568 WHERE pil.scope_namespace = ?1",
569 )?;
570 let handles = stmt
571 .query_map(params![namespace], |row| row.get(0))?
572 .collect::<Result<Vec<_>, _>>()?;
573 handles
574 };
575
576 let episode_ids: Vec<String> = {
577 let mut stmt = tx.prepare(
578 "SELECT episode_id FROM episode_links
579 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
580 )?;
581 let ids = stmt
582 .query_map(params![namespace], |row| row.get(0))?
583 .collect::<Result<Vec<_>, _>>()?;
584 ids
585 };
586
587 delete_derivation_edges_for_ids("claim", &claim_ids)?;
588 delete_derivation_edges_for_ids("claim_version", &claim_version_ids)?;
589 delete_derivation_edges_for_ids("relation_version", &relation_version_ids)?;
590 delete_derivation_edges_for_ids("entity", &alias_entity_ids)?;
591 delete_derivation_edges_for_ids("evidence_ref", &evidence_handles)?;
592 delete_derivation_edges_for_ids("episode", &episode_ids)?;
593
594 report.projection_rows += tx.execute(
595 "DELETE FROM claim_versions WHERE scope_namespace = ?1",
596 params![namespace],
597 )?;
598 report.projection_rows += tx.execute(
599 "DELETE FROM relation_versions WHERE scope_namespace = ?1",
600 params![namespace],
601 )?;
602 report.projection_rows += tx.execute(
603 "DELETE FROM entity_aliases WHERE scope_namespace = ?1",
604 params![namespace],
605 )?;
606 report.projection_rows += tx.execute(
607 "DELETE FROM evidence_refs
608 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
609 params![namespace],
610 )?;
611 report.projection_rows += tx.execute(
612 "DELETE FROM episode_links
613 WHERE source_envelope_id IN (SELECT source_envelope_id FROM projection_import_log WHERE scope_namespace = ?1)",
614 params![namespace],
615 )?;
616 report.projection_rows += tx.execute(
617 "DELETE FROM projection_import_failures WHERE scope_namespace = ?1",
618 params![namespace],
619 )?;
620 report.projection_rows += tx.execute(
621 "DELETE FROM projection_import_log WHERE scope_namespace = ?1",
622 params![namespace],
623 )?;
624
625 Ok(report)
626 })
627}
628
629pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
631 let result = conn.query_row(
632 "SELECT id, namespace, content, source, created_at, updated_at, metadata
633 FROM facts WHERE id = ?1",
634 params![fact_id],
635 |row| {
636 Ok((
637 row.get::<_, String>(0)?,
638 row.get::<_, String>(1)?,
639 row.get::<_, String>(2)?,
640 row.get::<_, Option<String>>(3)?,
641 row.get::<_, String>(4)?,
642 row.get::<_, String>(5)?,
643 row.get::<_, Option<String>>(6)?,
644 ))
645 },
646 );
647
648 match result {
649 Ok((id, namespace, content, source, created_at, updated_at, metadata_raw)) => {
650 Ok(Some(Fact {
651 metadata: parse_optional_json("facts", &id, "metadata", metadata_raw.as_deref())?,
652 id,
653 namespace,
654 content,
655 source,
656 created_at,
657 updated_at,
658 }))
659 }
660 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
661 Err(err) => Err(MemoryError::Database(err)),
662 }
663}
664
665pub fn get_fact_embedding(
667 conn: &Connection,
668 fact_id: &str,
669) -> Result<Option<Vec<f32>>, MemoryError> {
670 let result: Result<Option<Vec<u8>>, _> = conn.query_row(
671 "SELECT embedding FROM facts WHERE id = ?1",
672 params![fact_id],
673 |row| row.get(0),
674 );
675
676 match result {
677 Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
678 Ok(None) => Ok(None),
679 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
680 Err(err) => Err(MemoryError::Database(err)),
681 }
682}
683
684pub fn list_fact_namespaces(conn: &Connection) -> Result<Vec<String>, MemoryError> {
686 let mut stmt = conn.prepare("SELECT DISTINCT namespace FROM facts ORDER BY namespace")?;
687 let rows = stmt
688 .query_map([], |row| row.get::<_, String>(0))?
689 .collect::<Result<Vec<_>, _>>()?;
690 Ok(rows)
691}
692
693#[allow(dead_code)] pub fn list_facts(
696 conn: &Connection,
697 namespace: &str,
698 limit: usize,
699 offset: usize,
700) -> Result<Vec<Fact>, MemoryError> {
701 list_facts_with_view(conn, namespace, limit, offset, &StateView::Current)
702}
703
704#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
706pub enum StateView {
707 Current,
708 HistoricalAt(String),
709 RecordedAsOf(String),
710 IncludeSuperseded,
711}
712
713pub(crate) fn fact_is_visible_with_view(
714 conn: &Connection,
715 fact_id: &str,
716 view: &StateView,
717) -> Result<bool, MemoryError> {
718 let forgotten: bool = conn.query_row(
719 "SELECT EXISTS(SELECT 1 FROM forgotten_facts WHERE fact_id = ?1)",
720 params![fact_id],
721 |row| row.get(0),
722 )?;
723 if forgotten {
724 return Ok(false);
725 }
726 let cutoff = match view {
727 StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
728 let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
729 MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
730 })?;
731 Some(
732 parsed
733 .with_timezone(&chrono::Utc)
734 .format("%Y-%m-%d %H:%M:%S%.6f")
735 .to_string(),
736 )
737 }
738 _ => None,
739 };
740 let include_superseded = matches!(view, StateView::IncludeSuperseded);
741 let visible: i64 = conn.query_row(
742 "SELECT EXISTS(
743 SELECT 1 FROM facts f
744 WHERE f.id = ?1
745 AND (?2 IS NULL OR f.created_at <= ?2)
746 AND (?3 = 1 OR NOT EXISTS (
747 SELECT 1 FROM graph_edges ge
748 WHERE ge.target = 'fact:' || f.id
749 AND ge.is_invalidated = 0
750 AND COALESCE(
751 json_extract(ge.edge_type, '$.relation'),
752 json_extract(ge.edge_type, '$.entity.relation')
753 ) IN ('supersedes', 'redacts')
754 AND (?2 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?2)
755 ))
756 )",
757 params![fact_id, cutoff, include_superseded],
758 |row| row.get(0),
759 )?;
760 Ok(visible != 0)
761}
762
763pub fn list_facts_with_view(
765 conn: &Connection,
766 namespace: &str,
767 limit: usize,
768 offset: usize,
769 view: &StateView,
770) -> Result<Vec<Fact>, MemoryError> {
771 let cutoff = match view {
772 StateView::HistoricalAt(value) | StateView::RecordedAsOf(value) => {
773 let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|e| {
774 MemoryError::Other(format!("invalid StateView timestamp '{value}': {e}"))
775 })?;
776 Some(
777 parsed
778 .with_timezone(&chrono::Utc)
779 .format("%Y-%m-%d %H:%M:%S%.6f")
780 .to_string(),
781 )
782 }
783 _ => None,
784 };
785 let inconsistent: i64 = conn.query_row(
786 "SELECT COUNT(*) FROM (
787 SELECT target FROM graph_edges
788 WHERE is_invalidated = 0
789 AND COALESCE(
790 json_extract(edge_type, '$.relation'),
791 json_extract(edge_type, '$.entity.relation')
792 ) IN ('supersedes', 'redacts')
793 AND (?1 IS NULL OR COALESCE(recorded_time, recorded_at) <= ?1)
794 GROUP BY target HAVING COUNT(DISTINCT source) > 1
795 )",
796 params![cutoff.as_deref()],
797 |row| row.get(0),
798 )?;
799 if inconsistent != 0 {
800 return Err(MemoryError::Other(
801 "inconsistent fact lineage: multiple active heads".into(),
802 ));
803 }
804 let include_superseded = matches!(view, StateView::IncludeSuperseded);
805 let mut stmt = conn.prepare(
806 "SELECT id, namespace, content, source, created_at, updated_at, metadata
807 FROM facts
808 WHERE namespace = ?1
809 AND NOT EXISTS (
810 SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = facts.id
811 )
812 AND (?4 IS NULL OR created_at <= ?4)
813 AND (?5 = 1 OR NOT EXISTS (
814 SELECT 1 FROM graph_edges ge
815 WHERE ge.target = 'fact:' || facts.id
816 AND ge.is_invalidated = 0
817 AND COALESCE(
818 json_extract(ge.edge_type, '$.relation'),
819 json_extract(ge.edge_type, '$.entity.relation')
820 ) IN ('supersedes', 'redacts')
821 AND (?4 IS NULL OR COALESCE(ge.recorded_time, ge.recorded_at) <= ?4)
822 ))
823 ORDER BY updated_at DESC
824 LIMIT ?2 OFFSET ?3",
825 )?;
826
827 let facts = stmt
828 .query_map(
829 params![
830 namespace,
831 limit as i64,
832 offset as i64,
833 cutoff,
834 include_superseded
835 ],
836 |row| {
837 Ok((
838 row.get::<_, String>(0)?,
839 row.get::<_, String>(1)?,
840 row.get::<_, String>(2)?,
841 row.get::<_, Option<String>>(3)?,
842 row.get::<_, String>(4)?,
843 row.get::<_, String>(5)?,
844 row.get::<_, Option<String>>(6)?,
845 ))
846 },
847 )?
848 .collect::<Result<Vec<_>, _>>()?
849 .into_iter()
850 .map(
851 |(id, namespace, content, source, created_at, updated_at, metadata_raw)| {
852 Ok(Fact {
853 metadata: parse_optional_json(
854 "facts",
855 &id,
856 "metadata",
857 metadata_raw.as_deref(),
858 )?,
859 id,
860 namespace,
861 content,
862 source,
863 created_at,
864 updated_at,
865 })
866 },
867 )
868 .collect::<Result<Vec<_>, MemoryError>>()?;
869
870 Ok(facts)
871}
872
873impl MemoryStore {
874 pub async fn add_fact_raw_compat(
879 &self,
880 namespace: &str,
881 content: &str,
882 source: Option<&str>,
883 metadata: Option<serde_json::Value>,
884 trace_ctx: Option<&TraceCtx>,
885 ) -> Result<Fact, MemoryError> {
886 let id = self
887 .add_fact_with_trace(namespace, content, source, metadata, trace_ctx)
888 .await?;
889 self.get_fact(&id)
890 .await?
891 .ok_or(MemoryError::FactNotFound(id))
892 }
893
894 pub async fn add_fact(
899 &self,
900 namespace: &str,
901 content: &str,
902 source: Option<&str>,
903 metadata: Option<serde_json::Value>,
904 ) -> Result<String, MemoryError> {
905 self.add_fact_with_trace(namespace, content, source, metadata, None)
906 .await
907 }
908
909 pub async fn add_fact_with_trace(
911 &self,
912 namespace: &str,
913 content: &str,
914 source: Option<&str>,
915 metadata: Option<serde_json::Value>,
916 trace_ctx: Option<&TraceCtx>,
917 ) -> Result<String, MemoryError> {
918 self.validate_content("fact.content", content)?;
919
920 let ns_check = namespace.to_string();
923 let ct_check = content.to_string();
924 let existing_id = self
925 .with_read_conn(move |conn| {
926 let result: Option<String> = conn
927 .query_row(
928 "SELECT id FROM facts WHERE content = ?1 AND namespace = ?2 LIMIT 1",
929 rusqlite::params![&ct_check, &ns_check],
930 |row| row.get::<_, String>(0),
931 )
932 .ok();
933 Ok(result)
934 })
935 .await?;
936
937 if let Some(id) = existing_id {
938 return Ok(id);
939 }
940
941 let (embedding, sparse, sparse_representation) = self
942 .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
943 .await?;
944 self.validate_embedding_dimensions(&embedding)?;
945 let embedding_bytes = db::embedding_to_bytes(&embedding);
946 let fact_id = uuid::Uuid::new_v4().to_string();
947 let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
948
949 let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
950 let q8_bytes = quantizer
952 .quantize(&embedding)
953 .map(|qv| quantize::pack_quantized(&qv))
954 .ok();
955
956 let ns = namespace.to_string();
957 let ct = content.to_string();
958 let fid = fact_id.clone();
959 let src = source.map(|s| s.to_string());
960 let meta = merge_trace_ctx(metadata, trace_ctx);
961 let journal = self.replication_journal_identity();
962 self.with_write_conn(move |conn| {
963 let current_count: usize = conn.query_row(
964 "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
965 rusqlite::params![&ns],
966 |row| row.get(0),
967 )?;
968 if current_count >= max_facts_per_namespace {
969 return Err(MemoryError::NamespaceFull {
970 namespace: ns.clone(),
971 count: current_count,
972 limit: max_facts_per_namespace,
973 });
974 }
975 insert_fact_with_fts_q8(
976 conn,
977 &fid,
978 &ns,
979 &ct,
980 &embedding_bytes,
981 q8_bytes.as_deref(),
982 src.as_deref(),
983 meta.as_ref(),
984 sparse.as_ref().zip(sparse_representation.as_deref()),
985 journal.as_ref().map(|(device_id, store_id, stream_epoch)| {
986 (device_id.as_str(), store_id.as_str(), *stream_epoch)
987 }),
988 )
989 })
990 .await?;
991
992 self.clear_search_cache();
993
994 #[cfg(feature = "hnsw")]
995 self.sync_pending_hnsw_ops_best_effort("add_fact").await;
996
997 Ok(fact_id)
998 }
999
1000 pub async fn add_fact_with_embedding(
1002 &self,
1003 namespace: &str,
1004 content: &str,
1005 embedding: &[f32],
1006 source: Option<&str>,
1007 metadata: Option<serde_json::Value>,
1008 ) -> Result<String, MemoryError> {
1009 self.add_fact_with_embedding_and_trace(
1010 namespace, content, embedding, source, metadata, None,
1011 )
1012 .await
1013 }
1014
1015 pub async fn add_fact_with_embedding_and_trace(
1017 &self,
1018 namespace: &str,
1019 content: &str,
1020 embedding: &[f32],
1021 source: Option<&str>,
1022 metadata: Option<serde_json::Value>,
1023 trace_ctx: Option<&TraceCtx>,
1024 ) -> Result<String, MemoryError> {
1025 self.validate_content("fact.content", content)?;
1026 self.validate_embedding_dimensions(embedding)?;
1027 let embedding_bytes = db::embedding_to_bytes(embedding);
1028 let sparse = self.inner.config.search.derive_sparse_from_dense.then(|| {
1029 crate::SparseWeights::from_dense(
1030 embedding,
1031 self.inner.config.search.sparse_derive_top_k,
1032 self.inner.config.search.sparse_derive_min_weight,
1033 )
1034 });
1035 let fact_id = uuid::Uuid::new_v4().to_string();
1036 let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1037
1038 let quantizer = Quantizer::new(self.inner.config.embedding.dimensions);
1039 let q8_bytes = quantizer
1041 .quantize(embedding)
1042 .map(|qv| quantize::pack_quantized(&qv))
1043 .ok();
1044
1045 let ns = namespace.to_string();
1046 let ct = content.to_string();
1047 let fid = fact_id.clone();
1048 let src = source.map(|s| s.to_string());
1049 let meta = merge_trace_ctx(metadata, trace_ctx);
1050 let journal = self.replication_journal_identity();
1051 self.with_write_conn(move |conn| {
1052 let current_count: usize = conn.query_row(
1053 "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1054 rusqlite::params![&ns],
1055 |row| row.get(0),
1056 )?;
1057 if current_count >= max_facts_per_namespace {
1058 return Err(MemoryError::NamespaceFull {
1059 namespace: ns.clone(),
1060 count: current_count,
1061 limit: max_facts_per_namespace,
1062 });
1063 }
1064 insert_fact_with_fts_q8(
1065 conn,
1066 &fid,
1067 &ns,
1068 &ct,
1069 &embedding_bytes,
1070 q8_bytes.as_deref(),
1071 src.as_deref(),
1072 meta.as_ref(),
1073 sparse
1074 .as_ref()
1075 .map(|weights| (weights, "generic_dense_derived_sparse")),
1076 journal.as_ref().map(|(device_id, store_id, stream_epoch)| {
1077 (device_id.as_str(), store_id.as_str(), *stream_epoch)
1078 }),
1079 )
1080 })
1081 .await?;
1082
1083 self.clear_search_cache();
1084
1085 #[cfg(feature = "hnsw")]
1086 self.sync_pending_hnsw_ops_best_effort("add_fact_with_embedding")
1087 .await;
1088
1089 Ok(fact_id)
1090 }
1091
1092 pub async fn apply_verified_fact_create(
1101 &self,
1102 envelope: crate::journal::FactCreateReplicaEnvelopeV1,
1103 ) -> Result<crate::journal::ReplicaApplyOutcome, MemoryError> {
1104 use crate::journal::{
1105 validate_fact_create_replica_envelope, ReplicaApplyOutcome, GENESIS_PREDECESSOR,
1106 };
1107
1108 let payload = validate_fact_create_replica_envelope(&envelope)?;
1109
1110 let preflight_device_id = envelope.home_device_id.clone();
1116 let preflight_store_id = envelope.store_id.clone();
1117 let preflight_epoch =
1118 i64::try_from(envelope.stream_epoch).map_err(|_| MemoryError::InvalidConfig {
1119 field: "replication.stream_epoch",
1120 reason: "does not fit SQLite INTEGER".to_string(),
1121 })?;
1122 let preflight_stream_epoch = envelope.stream_epoch;
1123 let preflight_sequence = envelope.sequence;
1124 let preflight_envelope_digest = envelope.envelope_digest;
1125 let preflight_predecessor_digest = envelope.predecessor_digest;
1126 let preflight = self
1127 .with_read_conn(move |conn| {
1128 let existing_digest: Option<Vec<u8>> = conn
1129 .query_row(
1130 "SELECT envelope_digest FROM replication_inbox
1131 WHERE home_device_id = ?1 AND store_id = ?2
1132 AND stream_epoch = ?3 AND sequence = ?4",
1133 rusqlite::params![
1134 &preflight_device_id,
1135 &preflight_store_id,
1136 preflight_epoch,
1137 preflight_sequence,
1138 ],
1139 |row| row.get(0),
1140 )
1141 .optional()?;
1142 if let Some(existing_digest) = existing_digest {
1143 return if existing_digest.as_slice() == preflight_envelope_digest.as_slice() {
1144 Ok(Some(ReplicaApplyOutcome::Duplicate {
1145 sequence: preflight_sequence,
1146 }))
1147 } else {
1148 Ok(Some(ReplicaApplyOutcome::Fork {
1149 sequence: preflight_sequence,
1150 }))
1151 };
1152 }
1153
1154 let stream: Option<(i64, i64, Vec<u8>)> = conn
1155 .query_row(
1156 "SELECT stream_epoch, next_sequence, head_digest
1157 FROM replication_inbox_streams
1158 WHERE home_device_id = ?1 AND store_id = ?2",
1159 rusqlite::params![&preflight_device_id, &preflight_store_id],
1160 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1161 )
1162 .optional()?;
1163 let Some((active_epoch, expected, head_bytes)) = stream else {
1164 return if preflight_sequence > 1 {
1165 Ok(Some(ReplicaApplyOutcome::Gap {
1166 expected: 1,
1167 received: preflight_sequence,
1168 }))
1169 } else if preflight_predecessor_digest != GENESIS_PREDECESSOR {
1170 Ok(Some(ReplicaApplyOutcome::Fork {
1171 sequence: preflight_sequence,
1172 }))
1173 } else {
1174 Ok(None)
1175 };
1176 };
1177 let active = u64::try_from(active_epoch).map_err(|_| MemoryError::CorruptData {
1178 table: "replication_inbox_streams",
1179 row_id: format!("{preflight_device_id}/{preflight_store_id}"),
1180 detail: "active stream epoch is negative".to_string(),
1181 })?;
1182 if active != preflight_stream_epoch {
1183 return Ok(Some(ReplicaApplyOutcome::EpochConflict {
1184 active,
1185 received: preflight_stream_epoch,
1186 }));
1187 }
1188 if preflight_sequence > expected {
1189 return Ok(Some(ReplicaApplyOutcome::Gap {
1190 expected,
1191 received: preflight_sequence,
1192 }));
1193 }
1194 if preflight_sequence < expected {
1195 return Ok(Some(ReplicaApplyOutcome::Fork {
1196 sequence: preflight_sequence,
1197 }));
1198 }
1199 let head: [u8; 32] =
1200 head_bytes
1201 .try_into()
1202 .map_err(|bytes: Vec<u8>| MemoryError::CorruptData {
1203 table: "replication_inbox_streams",
1204 row_id: format!("{preflight_device_id}/{preflight_store_id}"),
1205 detail: format!("head digest must be 32 bytes, got {}", bytes.len()),
1206 })?;
1207 if preflight_predecessor_digest != head {
1208 return Ok(Some(ReplicaApplyOutcome::Fork {
1209 sequence: preflight_sequence,
1210 }));
1211 }
1212 Ok(None)
1213 })
1214 .await?;
1215 if let Some(outcome) = preflight {
1216 return Ok(outcome);
1217 }
1218
1219 self.validate_content("fact.content", &payload.content)?;
1220 let (embedding, sparse, sparse_representation) = self
1221 .embed_text_with_sparse_internal(&payload.content, crate::EmbeddingPurpose::Document)
1222 .await?;
1223 self.validate_embedding_dimensions(&embedding)?;
1224 let embedding_bytes = db::embedding_to_bytes(&embedding);
1225 let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1226 .quantize(&embedding)
1227 .map(|value| quantize::pack_quantized(&value))
1228 .ok();
1229 let max_facts_per_namespace = self.inner.config.limits.max_facts_per_namespace;
1230 let applied_fact_id = payload.fact_id.clone();
1231
1232 let outcome = self
1233 .with_write_conn(move |conn| {
1234 let epoch = i64::try_from(envelope.stream_epoch).map_err(|_| {
1235 MemoryError::InvalidConfig {
1236 field: "replication.stream_epoch",
1237 reason: "does not fit SQLite INTEGER".to_string(),
1238 }
1239 })?;
1240 let next_sequence =
1241 envelope
1242 .sequence
1243 .checked_add(1)
1244 .ok_or_else(|| MemoryError::CorruptData {
1245 table: "replication_inbox",
1246 row_id: envelope.sequence.to_string(),
1247 detail: "sequence overflow".to_string(),
1248 })?;
1249
1250 let tx = conn.unchecked_transaction()?;
1253
1254 let existing_digest: Option<Vec<u8>> = tx
1255 .query_row(
1256 "SELECT envelope_digest FROM replication_inbox
1257 WHERE home_device_id = ?1 AND store_id = ?2
1258 AND stream_epoch = ?3 AND sequence = ?4",
1259 rusqlite::params![
1260 &envelope.home_device_id,
1261 &envelope.store_id,
1262 epoch,
1263 envelope.sequence,
1264 ],
1265 |row| row.get(0),
1266 )
1267 .optional()?;
1268 if let Some(existing_digest) = existing_digest {
1269 return if existing_digest.as_slice() == envelope.envelope_digest.as_slice() {
1270 Ok(ReplicaApplyOutcome::Duplicate {
1271 sequence: envelope.sequence,
1272 })
1273 } else {
1274 Ok(ReplicaApplyOutcome::Fork {
1275 sequence: envelope.sequence,
1276 })
1277 };
1278 }
1279
1280 let stream: Option<(i64, i64, Vec<u8>)> = tx
1281 .query_row(
1282 "SELECT stream_epoch, next_sequence, head_digest
1283 FROM replication_inbox_streams
1284 WHERE home_device_id = ?1 AND store_id = ?2",
1285 rusqlite::params![&envelope.home_device_id, &envelope.store_id],
1286 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1287 )
1288 .optional()?;
1289
1290 let (expected, expected_predecessor, create_stream) =
1291 if let Some((active_epoch, expected, head_bytes)) = stream {
1292 let active =
1293 u64::try_from(active_epoch).map_err(|_| MemoryError::CorruptData {
1294 table: "replication_inbox_streams",
1295 row_id: format!(
1296 "{}/{}",
1297 envelope.home_device_id, envelope.store_id
1298 ),
1299 detail: "active stream epoch is negative".to_string(),
1300 })?;
1301 if active != envelope.stream_epoch {
1302 return Ok(ReplicaApplyOutcome::EpochConflict {
1303 active,
1304 received: envelope.stream_epoch,
1305 });
1306 }
1307 let head: [u8; 32] = head_bytes.try_into().map_err(|bytes: Vec<u8>| {
1308 MemoryError::CorruptData {
1309 table: "replication_inbox_streams",
1310 row_id: format!(
1311 "{}/{}",
1312 envelope.home_device_id, envelope.store_id
1313 ),
1314 detail: format!(
1315 "head digest must be 32 bytes, got {}",
1316 bytes.len()
1317 ),
1318 }
1319 })?;
1320 (expected, head, false)
1321 } else {
1322 (1, GENESIS_PREDECESSOR, true)
1323 };
1324
1325 if envelope.sequence > expected {
1326 return Ok(ReplicaApplyOutcome::Gap {
1327 expected,
1328 received: envelope.sequence,
1329 });
1330 }
1331 if envelope.sequence < expected
1332 || envelope.predecessor_digest != expected_predecessor
1333 {
1334 return Ok(ReplicaApplyOutcome::Fork {
1335 sequence: envelope.sequence,
1336 });
1337 }
1338
1339 let existing_fact: Option<(String, String, Option<String>, Option<String>)> = tx
1340 .query_row(
1341 "SELECT namespace, content, source, metadata FROM facts WHERE id = ?1",
1342 [&payload.fact_id],
1343 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1344 )
1345 .optional()?;
1346 if let Some((namespace, content, source, metadata_raw)) = existing_fact {
1347 let metadata = metadata_raw
1348 .map(|raw| {
1349 serde_json::from_str::<serde_json::Value>(&raw).map_err(|error| {
1350 MemoryError::CorruptData {
1351 table: "facts",
1352 row_id: payload.fact_id.clone(),
1353 detail: format!("invalid stored metadata JSON: {error}"),
1354 }
1355 })
1356 })
1357 .transpose()?;
1358 if namespace != payload.namespace
1359 || content != payload.content
1360 || source != payload.source
1361 || metadata != payload.metadata
1362 {
1363 return Ok(ReplicaApplyOutcome::Fork {
1364 sequence: envelope.sequence,
1365 });
1366 }
1367 } else {
1368 let current_count: usize = tx.query_row(
1369 "SELECT COUNT(*) FROM facts WHERE namespace = ?1",
1370 [&payload.namespace],
1371 |row| row.get(0),
1372 )?;
1373 if current_count >= max_facts_per_namespace {
1374 return Err(MemoryError::NamespaceFull {
1375 namespace: payload.namespace.clone(),
1376 count: current_count,
1377 limit: max_facts_per_namespace,
1378 });
1379 }
1380 insert_fact_in_tx(
1381 &tx,
1382 &payload.fact_id,
1383 &payload.namespace,
1384 &payload.content,
1385 &embedding_bytes,
1386 q8_bytes.as_deref(),
1387 payload.source.as_deref(),
1388 payload.metadata.as_ref(),
1389 )?;
1390 if let Some((weights, representation)) =
1391 sparse.as_ref().zip(sparse_representation.as_deref())
1392 {
1393 db::store_sparse_vector(
1394 &tx,
1395 &format!("fact:{}", payload.fact_id),
1396 weights,
1397 representation,
1398 )?;
1399 }
1400 }
1401
1402 if create_stream {
1403 tx.execute(
1404 "INSERT INTO replication_inbox_streams
1405 (home_device_id, store_id, stream_epoch, next_sequence, head_digest)
1406 VALUES (?1, ?2, ?3, 1, ?4)",
1407 rusqlite::params![
1408 &envelope.home_device_id,
1409 &envelope.store_id,
1410 epoch,
1411 GENESIS_PREDECESSOR.as_slice(),
1412 ],
1413 )?;
1414 }
1415
1416 tx.execute(
1417 "INSERT INTO replication_inbox
1418 (home_device_id, store_id, stream_epoch, sequence,
1419 operation_kind, payload_schema, payload, payload_digest,
1420 predecessor_digest, envelope_digest, fact_id)
1421 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1422 rusqlite::params![
1423 &envelope.home_device_id,
1424 &envelope.store_id,
1425 epoch,
1426 envelope.sequence,
1427 &envelope.operation_kind,
1428 &envelope.payload_schema,
1429 &envelope.payload,
1430 envelope.payload_digest.as_slice(),
1431 envelope.predecessor_digest.as_slice(),
1432 envelope.envelope_digest.as_slice(),
1433 &payload.fact_id,
1434 ],
1435 )?;
1436
1437 let advanced = tx.execute(
1438 "UPDATE replication_inbox_streams
1439 SET next_sequence = ?4, head_digest = ?5, updated_at = datetime('now')
1440 WHERE home_device_id = ?1 AND store_id = ?2 AND stream_epoch = ?3
1441 AND next_sequence = ?6 AND head_digest = ?7",
1442 rusqlite::params![
1443 &envelope.home_device_id,
1444 &envelope.store_id,
1445 epoch,
1446 next_sequence,
1447 envelope.envelope_digest.as_slice(),
1448 envelope.sequence,
1449 expected_predecessor.as_slice(),
1450 ],
1451 )?;
1452 if advanced != 1 {
1453 return Err(MemoryError::Other(
1454 "replication inbox stream allocator lost ownership".to_string(),
1455 ));
1456 }
1457 tx.commit()?;
1458 Ok(ReplicaApplyOutcome::Applied {
1459 sequence: envelope.sequence,
1460 fact_id: payload.fact_id,
1461 })
1462 })
1463 .await?;
1464
1465 if matches!(
1466 &outcome,
1467 crate::journal::ReplicaApplyOutcome::Applied { .. }
1468 ) {
1469 self.clear_search_cache();
1470 #[cfg(feature = "hnsw")]
1471 self.sync_pending_hnsw_ops_best_effort("apply_verified_fact_create")
1472 .await;
1473 }
1474 debug_assert!(
1475 !matches!(&outcome, crate::journal::ReplicaApplyOutcome::Applied { fact_id, .. } if fact_id != &applied_fact_id),
1476 "receiver returned a fact ID different from the validated payload"
1477 );
1478 Ok(outcome)
1479 }
1480
1481 #[cfg(feature = "admin-ops")]
1486 pub async fn update_fact(&self, fact_id: &str, content: &str) -> Result<(), MemoryError> {
1487 self.validate_content("fact.content", content)?;
1488 let (embedding, sparse, sparse_representation) = self
1489 .embed_text_with_sparse_internal(content, crate::EmbeddingPurpose::Document)
1490 .await?;
1491 self.validate_embedding_dimensions(&embedding)?;
1492 let embedding_bytes = db::embedding_to_bytes(&embedding);
1493 let q8_bytes = Quantizer::new(self.inner.config.embedding.dimensions)
1495 .quantize(&embedding)
1496 .map(|qv| quantize::pack_quantized(&qv))
1497 .ok();
1498
1499 let fid = fact_id.to_string();
1500 let ct = content.to_string();
1501 self.with_write_conn(move |conn| {
1502 update_fact_with_fts(conn, &fid, &ct, &embedding_bytes, q8_bytes.as_deref())?;
1503 let item_key = format!("fact:{fid}");
1504 if let Some((weights, representation)) =
1505 sparse.as_ref().zip(sparse_representation.as_deref())
1506 {
1507 db::store_sparse_vector(conn, &item_key, weights, representation)?;
1508 } else {
1509 db::delete_sparse_vector(conn, &item_key)?;
1510 }
1511 Ok(())
1512 })
1513 .await?;
1514
1515 #[cfg(feature = "hnsw")]
1516 self.sync_pending_hnsw_ops_best_effort("update_fact").await;
1517
1518 self.clear_search_cache();
1519
1520 Ok(())
1521 }
1522
1523 #[cfg(feature = "admin-ops")]
1528 pub async fn delete_fact(&self, fact_id: &str) -> Result<(), MemoryError> {
1529 let fid = fact_id.to_string();
1530 self.with_write_conn(move |conn| delete_fact_with_fts(conn, &fid))
1531 .await?;
1532
1533 #[cfg(feature = "hnsw")]
1534 self.sync_pending_hnsw_ops_best_effort("delete_fact").await;
1535
1536 self.clear_search_cache();
1537
1538 Ok(())
1539 }
1540
1541 #[cfg(feature = "admin-ops")]
1545 pub async fn delete_namespace(
1546 &self,
1547 namespace: &str,
1548 ) -> Result<NamespaceDeleteReport, MemoryError> {
1549 let ns = namespace.to_string();
1550 let count = self
1551 .with_write_conn(move |conn| delete_namespace(conn, &ns))
1552 .await?;
1553
1554 #[cfg(feature = "hnsw")]
1555 self.sync_pending_hnsw_ops_best_effort("delete_namespace")
1556 .await;
1557
1558 self.clear_search_cache();
1559
1560 Ok(count)
1561 }
1562
1563 pub async fn get_fact(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1565 let fid = fact_id.to_string();
1566 self.with_read_conn(move |conn| get_fact(conn, &fid)).await
1567 }
1568
1569 pub async fn get_fact_raw_compat(&self, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
1571 self.get_fact(fact_id).await
1572 }
1573
1574 pub async fn get_fact_embedding(&self, fact_id: &str) -> Result<Option<Vec<f32>>, MemoryError> {
1576 let fid = fact_id.to_string();
1577 self.with_read_conn(move |conn| get_fact_embedding(conn, &fid))
1578 .await
1579 }
1580
1581 pub async fn list_facts(
1583 &self,
1584 namespace: &str,
1585 limit: usize,
1586 offset: usize,
1587 ) -> Result<Vec<Fact>, MemoryError> {
1588 self.list_facts_with_view(namespace, limit, offset, StateView::Current)
1589 .await
1590 }
1591
1592 pub async fn list_facts_with_view(
1594 &self,
1595 namespace: &str,
1596 limit: usize,
1597 offset: usize,
1598 view: StateView,
1599 ) -> Result<Vec<Fact>, MemoryError> {
1600 let ns = namespace.to_string();
1601 self.with_read_conn(move |conn| list_facts_with_view(conn, &ns, limit, offset, &view))
1602 .await
1603 }
1604
1605 pub async fn list_fact_namespaces(&self) -> Result<Vec<String>, MemoryError> {
1607 self.with_read_conn(move |conn| list_fact_namespaces(conn))
1608 .await
1609 }
1610}
1611
1612#[cfg(test)]
1613mod state_view_regression_tests {
1614 use super::*;
1615 use crate::db::run_migrations;
1616 use rusqlite::Connection;
1617
1618 fn seeded() -> Connection {
1619 let conn = Connection::open_in_memory().unwrap();
1620 run_migrations(&conn).unwrap();
1621 for (id, content, created) in [
1622 ("old", "same topic old", "2026-07-10 21:00:00"),
1623 ("new", "same topic new", "2026-07-10 21:12:01"),
1624 ] {
1625 conn.execute(
1626 "INSERT INTO facts(id, namespace, content, created_at, updated_at) VALUES (?1, 'n', ?2, ?3, ?3)",
1627 params![id, content, created],
1628 ).unwrap();
1629 }
1630 conn
1631 }
1632
1633 fn supersedes(conn: &Connection, source: &str, target: &str, recorded: &str) {
1634 conn.execute(
1635 "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1636 VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"type\":\"entity\",\"relation\":\"supersedes\"}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1637 params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1638 ).unwrap();
1639 }
1640
1641 fn supersedes_canonical(conn: &Connection, source: &str, target: &str, recorded: &str) {
1642 conn.execute(
1643 "INSERT INTO graph_edges(id, source, target, edge_type, weight, content_digest, recorded_at, valid_time, recorded_time)
1644 VALUES (lower(hex(randomblob(16))), ?1, ?2, '{\"entity\":{\"relation\":\"supersedes\"}}', 1, lower(hex(randomblob(16))), ?3, ?3, ?3)",
1645 params![format!("fact:{source}"), format!("fact:{target}"), recorded],
1646 ).unwrap();
1647 }
1648
1649 #[test]
1650 fn historical_view_excludes_future_fact_and_reconstructs_pre_supersession_head() {
1651 let conn = seeded();
1652 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1653 let rows = list_facts_with_view(
1654 &conn,
1655 "n",
1656 10,
1657 0,
1658 &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1659 )
1660 .unwrap();
1661 assert_eq!(
1662 rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1663 ["old"]
1664 );
1665 }
1666
1667 #[test]
1668 fn historical_view_preserves_pre_adjudication_conflict() {
1669 let conn = seeded();
1670 conn.execute(
1671 "UPDATE facts SET created_at = '2026-07-10 21:10:00', updated_at = '2026-07-10 21:10:00' WHERE id = 'new'",
1672 [],
1673 )
1674 .unwrap();
1675 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1676
1677 let rows = list_facts_with_view(
1678 &conn,
1679 "n",
1680 10,
1681 0,
1682 &StateView::HistoricalAt("2026-07-10T21:11:50Z".into()),
1683 )
1684 .unwrap();
1685 let ids = rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>();
1686 assert!(
1687 ids.contains(&"old"),
1688 "prior observation must remain visible"
1689 );
1690 assert!(ids.contains(&"new"), "conflicting observation created before the cutoff must remain visible until adjudication");
1691 }
1692
1693 #[test]
1694 fn current_view_excludes_superseded_fact() {
1695 let conn = seeded();
1696 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1697 let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1698 assert_eq!(
1699 rows.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
1700 ["new"]
1701 );
1702 }
1703
1704 #[test]
1705 fn current_view_accepts_canonical_entity_edge_serialization() {
1706 let conn = seeded();
1707 supersedes_canonical(&conn, "new", "old", "2026-07-10 21:12:01");
1708 let rows = list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).unwrap();
1709 assert_eq!(
1710 rows.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
1711 vec!["new"]
1712 );
1713 }
1714
1715 #[test]
1716 fn multiple_active_heads_fail_closed() {
1717 let conn = seeded();
1718 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();
1719 supersedes(&conn, "new", "old", "2026-07-10 21:12:01");
1720 supersedes(&conn, "other", "old", "2026-07-10 21:13:00");
1721 assert!(list_facts_with_view(&conn, "n", 10, 0, &StateView::Current).is_err());
1722 }
1723}