1use std::collections::HashSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use uuid::Uuid;
16
17use khive_db::SqliteError;
18use khive_storage::note::Note;
19use khive_storage::types::{EdgeFilter, TextDocument};
20use khive_storage::{EdgeRelation, Entity, SubstrateKind};
21use khive_types::EventKind;
22use rusqlite::OptionalExtension;
23
24use crate::error::{RuntimeError, RuntimeResult};
25use crate::operations::canonical_edge_endpoints;
26use crate::runtime::{KhiveRuntime, NamespaceToken};
27
28#[derive(Clone, Debug, Default)]
50pub struct EntityPatch {
51 pub name: Option<String>,
52 pub description: Option<Option<String>>,
53 pub properties: Option<Value>,
54 pub tags: Option<Vec<String>>,
55}
56
57#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum EntityDedupMergePolicy {
61 #[default]
64 PreferInto,
65 PreferFrom,
67 Union,
69}
70
71#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74pub enum ContentMergeStrategy {
75 #[default]
76 Append,
77 PreferInto,
78 PreferFrom,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct MergeSummary {
84 pub kept_id: Uuid,
85 pub removed_id: Uuid,
86 pub edges_rewired: usize,
87 pub properties_merged: usize,
88 pub tags_unioned: usize,
89 pub content_appended: bool,
90 pub dry_run: bool,
91}
92
93#[derive(Clone, Debug, Default)]
98pub struct EdgePatch {
99 pub relation: Option<EdgeRelation>,
100 pub weight: Option<f64>,
101 pub properties: Option<Value>,
102}
103
104#[derive(Clone, Debug, Default)]
111pub struct NotePatch {
112 pub name: Option<Option<String>>,
113 pub content: Option<String>,
114 pub salience: Option<Option<f64>>,
115 pub decay_factor: Option<Option<f64>>,
116 pub properties: Option<Value>,
117 pub(crate) kind_status: Option<String>,
118}
119
120impl NotePatch {
121 pub fn new(
124 name: Option<Option<String>>,
125 content: Option<String>,
126 salience: Option<Option<f64>>,
127 decay_factor: Option<Option<f64>>,
128 properties: Option<Value>,
129 ) -> Self {
130 Self {
131 name,
132 content,
133 salience,
134 decay_factor,
135 properties,
136 kind_status: None,
137 }
138 }
139}
140
141#[derive(Clone, Debug, Default)]
143pub struct EdgeListFilter {
144 pub source_id: Option<Uuid>,
145 pub target_id: Option<Uuid>,
146 pub relations: Vec<EdgeRelation>,
148 pub min_weight: Option<f64>,
149 pub max_weight: Option<f64>,
150}
151
152impl From<EdgeListFilter> for EdgeFilter {
153 fn from(f: EdgeListFilter) -> Self {
154 EdgeFilter {
155 source_ids: f.source_id.into_iter().collect(),
156 target_ids: f.target_id.into_iter().collect(),
157 relations: f.relations,
158 min_weight: f.min_weight,
159 max_weight: f.max_weight,
160 ..Default::default()
161 }
162 }
163}
164
165#[allow(dead_code)]
173struct EdgeRow {
174 id: Uuid,
175 source_id: Uuid,
176 target_id: Uuid,
177 relation: String,
178 weight: f64,
179 created_at: i64,
180 updated_at: i64,
181 deleted_at: Option<i64>,
182 target_backend: Option<String>,
183 metadata: Option<String>,
184}
185
186impl KhiveRuntime {
191 pub(crate) async fn prepare_update_entity(
202 &self,
203 token: &NamespaceToken,
204 id: Uuid,
205 patch: EntityPatch,
206 ) -> RuntimeResult<(Entity, bool, Vec<&'static str>)> {
207 if let Some(ref name) = patch.name {
208 crate::secret_gate::check(name)?;
209 }
210 if let Some(Some(ref desc)) = patch.description {
211 crate::secret_gate::check(desc)?;
212 }
213 if let Some(ref props) = patch.properties {
214 crate::secret_gate::check_json(props)?;
215 }
216 if let Some(ref tags) = patch.tags {
217 crate::secret_gate::check_tags(tags)?;
218 }
219 let store = self.entities(token)?;
220 let mut entity = store
221 .get_entity(id)
222 .await?
223 .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))?;
224
225 let mut text_changed = false;
226 let mut changed_fields: Vec<&'static str> = Vec::new();
227
228 if let Some(name) = patch.name {
229 text_changed |= entity.name != name;
230 entity.name = name;
231 changed_fields.push("name");
232 }
233 if let Some(desc_patch) = patch.description {
234 text_changed |= entity.description != desc_patch;
235 entity.description = desc_patch;
236 changed_fields.push("description");
237 }
238 if let Some(props) = patch.properties {
239 let (merged, _) = merge_properties(
240 &entity.properties,
241 &Some(props),
242 EntityDedupMergePolicy::PreferFrom,
243 );
244 entity.properties = merged;
245 changed_fields.push("properties");
246 }
247 if let Some(tags) = patch.tags {
248 entity.tags = tags;
249 changed_fields.push("tags");
250 }
251
252 entity.updated_at = chrono::Utc::now().timestamp_micros();
253 Ok((entity, text_changed, changed_fields))
254 }
255
256 pub async fn update_entity(
257 &self,
258 token: &NamespaceToken,
259 id: Uuid,
260 patch: EntityPatch,
261 ) -> RuntimeResult<Entity> {
262 let (entity, text_changed, changed_fields) =
263 self.prepare_update_entity(token, id, patch).await?;
264
265 let store = self.entities(token)?;
266 store.upsert_entity(entity.clone()).await?;
267
268 if text_changed {
269 self.reindex_entity(token, &entity).await?;
270 }
271
272 let event_store = self.events(token)?;
273 let event = khive_storage::event::Event::new(
274 entity.namespace.clone(),
275 "update",
276 EventKind::EntityUpdated,
277 SubstrateKind::Entity,
278 "",
279 )
280 .with_target(entity.id)
281 .with_payload(serde_json::json!({
282 "id": entity.id,
283 "namespace": entity.namespace,
284 "changed_fields": changed_fields,
285 }));
286 event_store.append_event(event).await.map_err(|e| {
287 RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
288 })?;
289
290 Ok(entity)
291 }
292
293 pub async fn merge_entity(
306 &self,
307 token: &NamespaceToken,
308 into_id: Uuid,
309 from_id: Uuid,
310 strategy: EntityDedupMergePolicy,
311 content_strategy: ContentMergeStrategy,
312 dry_run: bool,
313 ) -> RuntimeResult<MergeSummary> {
314 if into_id == from_id {
315 return Err(RuntimeError::InvalidInput(
316 "cannot merge an entity into itself".into(),
317 ));
318 }
319 {
322 let into_entity = self.get_entity(token, into_id).await?;
323 let from_entity = self.get_entity(token, from_id).await?;
324 if into_entity.kind != from_entity.kind {
325 return Err(RuntimeError::InvalidInput(format!(
326 "cannot merge entities of different kinds: into={} ({}), from={} ({}); \
327 merge requires both entities to share the same kind",
328 into_id, into_entity.kind, from_id, from_entity.kind
329 )));
330 }
331 }
332 let ns = token.namespace().as_str().to_owned();
333 let fts_table = "fts_entities".to_string();
334 let vec_tables: Vec<String> = self
335 .registered_embedding_model_names()
336 .iter()
337 .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
338 .collect();
339
340 let _ = self.entities(token)?;
342 let _ = self.graph(token)?;
343 let _ = self.text(token)?;
344 for model_name in &self.registered_embedding_model_names() {
347 let _ = self.vectors_for_model(token, model_name)?;
348 }
349
350 let pool = self.backend().pool_arc();
351 let writer_task = pool.writer_task_handle().ok().flatten();
355
356 let (summary, updated_entity) = if let Some(writer_task) = writer_task {
357 writer_task
358 .send(move |conn| {
359 merge_entity_sql(
360 conn,
361 ns,
362 fts_table,
363 vec_tables,
364 into_id,
365 from_id,
366 strategy,
367 content_strategy,
368 dry_run,
369 )
370 .map_err(|e| {
371 khive_storage::StorageError::driver(
372 khive_storage::StorageCapability::Entities,
373 "merge_entity",
374 e,
375 )
376 })
377 })
378 .await
379 .map_err(RuntimeError::Storage)?
380 } else {
381 tokio::task::spawn_blocking(move || {
382 let guard = pool.writer()?;
383 guard.transaction(|conn| {
384 merge_entity_sql(
385 conn,
386 ns,
387 fts_table,
388 vec_tables,
389 into_id,
390 from_id,
391 strategy,
392 content_strategy,
393 dry_run,
394 )
395 })
396 })
397 .await
398 .map_err(|e| RuntimeError::Internal(e.to_string()))??
399 };
400
401 if !dry_run && !self.registered_embedding_model_names().is_empty() {
404 self.reindex_entity(token, &updated_entity).await?;
405 }
406
407 if !dry_run {
409 let event_store = self.events(token)?;
410 let policy_str = match strategy {
413 EntityDedupMergePolicy::PreferInto => "prefer_into",
414 EntityDedupMergePolicy::PreferFrom => "prefer_from",
415 EntityDedupMergePolicy::Union => "union",
416 };
417 let event = khive_storage::event::Event::new(
418 updated_entity.namespace.clone(),
419 "merge",
420 EventKind::EntityMerged,
421 SubstrateKind::Entity,
422 "",
423 )
424 .with_target(summary.kept_id)
425 .with_payload(serde_json::json!({
426 "into_id": summary.kept_id,
427 "from_id": summary.removed_id,
428 "policy": policy_str,
429 "content_strategy": format!("{:?}", content_strategy),
430 "edges_rewired": summary.edges_rewired,
431 }));
432 event_store.append_event(event).await.map_err(|e| {
433 RuntimeError::Internal(format!("merge_entity: event store write failed: {e}"))
434 })?;
435 }
436
437 Ok(summary)
438 }
439
440 pub(crate) async fn reindex_entity(
455 &self,
456 token: &NamespaceToken,
457 entity: &Entity,
458 ) -> RuntimeResult<()> {
459 let ns = entity.namespace.clone();
461 let doc = entity_fts_document(entity);
462 let embed_body = doc.body.clone();
463 self.text(token)?.upsert_document(doc).await?;
464
465 let embed_model_names = self.registered_embedding_model_names();
466 for model_name in &embed_model_names {
467 match self
468 .embed_document_with_model(model_name, &embed_body)
469 .await
470 {
471 Ok(vector) => match self.vectors_for_model(token, model_name) {
472 Ok(vs) => {
473 if let Err(e) = vs
474 .insert(
475 entity.id,
476 SubstrateKind::Entity,
477 &ns,
478 "entity.body",
479 vec![vector],
480 )
481 .await
482 {
483 tracing::warn!(
484 model = model_name,
485 id = %entity.id,
486 "reindex_entity: vector insert failed, skipping model: {e}"
487 );
488 }
489 }
490 Err(e) => {
491 tracing::warn!(
492 model = model_name,
493 id = %entity.id,
494 "reindex_entity: could not access vector store for model, skipping: {e}"
495 );
496 }
497 },
498 Err(e) => {
499 tracing::warn!(
500 model = model_name,
501 id = %entity.id,
502 "reindex_entity: embed failed for model, skipping: {e}"
503 );
504 }
505 }
506 }
507
508 Ok(())
509 }
510
511 pub(crate) async fn remove_from_indexes(
513 &self,
514 token: &NamespaceToken,
515 id: Uuid,
516 ) -> RuntimeResult<()> {
517 let ns = token.namespace().as_str().to_owned();
518 self.text(token)?.delete_document(&ns, id).await?;
519 for model_name in self.registered_embedding_model_names() {
520 self.vectors_for_model(token, &model_name)?
521 .delete(id)
522 .await?;
523 }
524 Ok(())
525 }
526
527 pub(crate) async fn reindex_note(
531 &self,
532 token: &NamespaceToken,
533 note: &khive_storage::note::Note,
534 ) -> RuntimeResult<()> {
535 self.text_for_notes(token)?
536 .upsert_document(note_fts_document(note))
537 .await?;
538
539 let ns = note.namespace.clone();
540 let embed_model_names = self.registered_embedding_model_names();
541 for model_name in &embed_model_names {
542 match self
543 .embed_document_with_model(model_name, ¬e.content)
544 .await
545 {
546 Ok(vector) => match self.vectors_for_model(token, model_name) {
547 Ok(vs) => {
548 if let Err(e) = vs
549 .insert(
550 note.id,
551 SubstrateKind::Note,
552 &ns,
553 "note.content",
554 vec![vector],
555 )
556 .await
557 {
558 tracing::warn!(
559 model = model_name,
560 id = %note.id,
561 "reindex_note: vector insert failed, skipping model: {e}"
562 );
563 }
564 }
565 Err(e) => {
566 tracing::warn!(
567 model = model_name,
568 id = %note.id,
569 "reindex_note: could not access vector store for model, skipping: {e}"
570 );
571 }
572 },
573 Err(e) => {
574 tracing::warn!(
575 model = model_name,
576 id = %note.id,
577 "reindex_note: embed failed for model, skipping: {e}"
578 );
579 }
580 }
581 }
582 Ok(())
583 }
584
585 pub(crate) async fn prepare_update_note(
589 &self,
590 token: &NamespaceToken,
591 id: Uuid,
592 patch: NotePatch,
593 ) -> RuntimeResult<(khive_storage::note::Note, bool)> {
594 if let Some(ref content) = patch.content {
595 crate::secret_gate::check(content)?;
596 }
597 if let Some(Some(ref name)) = patch.name {
598 crate::secret_gate::check(name)?;
599 }
600 if let Some(ref props) = patch.properties {
601 crate::secret_gate::check_json(props)?;
602 }
603 let store = self.notes(token)?;
604 let mut note = store
605 .get_note(id)
606 .await?
607 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
608
609 let mut text_changed = false;
610
611 if let Some(name_patch) = patch.name {
612 text_changed |= note.name != name_patch;
613 note.name = name_patch;
614 }
615 if let Some(content) = patch.content {
616 text_changed |= note.content != content;
617 note.content = content;
618 }
619 if let Some(salience_patch) = patch.salience {
620 if let Some(s) = salience_patch {
622 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
623 return Err(crate::RuntimeError::InvalidInput(format!(
624 "salience must be a finite value in [0.0, 1.0]; got {s}"
625 )));
626 }
627 }
628 note.salience = salience_patch;
629 }
630 if let Some(decay_patch) = patch.decay_factor {
631 if let Some(d) = decay_patch {
633 if !d.is_finite() || d < 0.0 {
634 return Err(crate::RuntimeError::InvalidInput(format!(
635 "decay_factor must be a finite value >= 0.0; got {d}"
636 )));
637 }
638 }
639 note.decay_factor = decay_patch;
640 }
641 if let Some(props) = patch.properties {
642 let (merged, _) = merge_properties(
643 ¬e.properties,
644 &Some(props),
645 EntityDedupMergePolicy::PreferFrom,
646 );
647 note.properties = merged;
648 }
649 if let Some(status) = patch.kind_status {
650 note.status = status;
651 }
652
653 note.updated_at = chrono::Utc::now().timestamp_micros();
654 Ok((note, text_changed))
655 }
656
657 pub async fn update_note(
659 &self,
660 token: &NamespaceToken,
661 id: Uuid,
662 patch: NotePatch,
663 ) -> RuntimeResult<khive_storage::note::Note> {
664 let (note, text_changed) = self.prepare_update_note(token, id, patch).await?;
665
666 let store = self.notes(token)?;
667 store.upsert_note(note.clone()).await?;
668
669 if text_changed {
670 self.reindex_note(token, ¬e).await?;
671 self.fire_note_mutation_hook(¬e.kind, note.id).await;
675 }
676
677 Ok(note)
678 }
679
680 pub async fn merge_note(
689 &self,
690 token: &NamespaceToken,
691 into_id: Uuid,
692 from_id: Uuid,
693 strategy: EntityDedupMergePolicy,
694 content_strategy: ContentMergeStrategy,
695 dry_run: bool,
696 ) -> RuntimeResult<MergeSummary> {
697 if into_id == from_id {
698 return Err(RuntimeError::InvalidInput(
699 "cannot merge a note into itself".into(),
700 ));
701 }
702 let ns = token.namespace().as_str().to_string();
703 let fts_table = "fts_notes".to_string();
704 let vec_tables: Vec<String> = self
705 .registered_embedding_model_names()
706 .iter()
707 .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
708 .collect();
709
710 let note_store = self.notes(token)?;
711 let into_note = note_store
712 .get_note(into_id)
713 .await?
714 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
715 Self::ensure_namespace(&into_note.namespace, &ns)?;
716
717 let from_note = note_store
718 .get_note(from_id)
719 .await?
720 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
721 Self::ensure_namespace(&from_note.namespace, &ns)?;
722
723 let _ = self.graph(token)?;
724 let _ = self.text_for_notes(token)?;
725 for model_name in &self.registered_embedding_model_names() {
726 let _ = self.vectors_for_model(token, model_name)?;
727 }
728
729 let pool = self.backend().pool_arc();
730 let writer_task = pool.writer_task_handle().ok().flatten();
731
732 let (summary, updated_note) = if let Some(writer_task) = writer_task {
733 writer_task
734 .send(move |conn| {
735 merge_note_sql(
736 conn,
737 ns,
738 fts_table,
739 vec_tables,
740 into_id,
741 from_id,
742 strategy,
743 content_strategy,
744 dry_run,
745 )
746 .map_err(|e| {
747 khive_storage::StorageError::driver(
748 khive_storage::StorageCapability::Notes,
749 "merge_note",
750 e,
751 )
752 })
753 })
754 .await
755 .map_err(RuntimeError::Storage)?
756 } else {
757 tokio::task::spawn_blocking(move || {
758 let guard = pool.writer()?;
759 guard.transaction(|conn| {
760 merge_note_sql(
761 conn,
762 ns,
763 fts_table,
764 vec_tables,
765 into_id,
766 from_id,
767 strategy,
768 content_strategy,
769 dry_run,
770 )
771 })
772 })
773 .await
774 .map_err(|e| RuntimeError::Internal(e.to_string()))??
775 };
776
777 if !dry_run && !self.registered_embedding_model_names().is_empty() {
778 self.reindex_note(token, &updated_note).await?;
779 self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
783 .await;
784 }
785 Ok(summary)
786 }
787}
788
789pub fn entity_fts_document(entity: &Entity) -> TextDocument {
806 let body = match &entity.description {
807 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
808 _ => entity.name.clone(),
809 };
810 let updated_at =
811 chrono::DateTime::from_timestamp_micros(entity.updated_at).unwrap_or_else(chrono::Utc::now);
812 TextDocument {
813 subject_id: entity.id,
814 kind: SubstrateKind::Entity,
815 title: Some(entity.name.clone()),
816 body,
817 tags: entity.tags.clone(),
818 namespace: entity.namespace.clone(),
819 metadata: entity.properties.clone(),
820 updated_at,
821 }
822}
823
824pub fn note_fts_document(note: &Note) -> TextDocument {
837 let body = match ¬e.name {
838 Some(n) => format!("{n} {}", note.content),
839 None => note.content.clone(),
840 };
841 let updated_at =
842 chrono::DateTime::from_timestamp_micros(note.updated_at).unwrap_or_else(chrono::Utc::now);
843 TextDocument {
844 subject_id: note.id,
845 kind: SubstrateKind::Note,
846 title: note.name.clone(),
847 body,
848 tags: vec![],
849 namespace: note.namespace.clone(),
850 metadata: note.properties.clone(),
851 updated_at,
852 }
853}
854
855pub(crate) struct NoteFtsScalars {
861 pub title: String,
864 pub body: String,
865 pub tags: String,
867 pub metadata: Option<String>,
869 pub updated_at_micros: i64,
871}
872
873pub(crate) fn note_fts_scalars(note: &Note) -> NoteFtsScalars {
878 let doc = note_fts_document(note);
879 NoteFtsScalars {
880 title: doc.title.unwrap_or_default(),
881 body: doc.body,
882 tags: "[]".to_string(),
883 metadata: doc
884 .metadata
885 .as_ref()
886 .map(|v| serde_json::to_string(v).unwrap_or_default()),
887 updated_at_micros: doc.updated_at.timestamp_micros(),
888 }
889}
890
891fn read_merge_entity(
897 conn: &rusqlite::Connection,
898 id: Uuid,
899 namespace: &str,
900) -> Result<Entity, SqliteError> {
901 let id_str = id.to_string();
902 let mut stmt = conn.prepare(
903 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
904 created_at, updated_at, deleted_at, merged_into, merge_event_id \
905 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
906 )?;
907 let mut rows = stmt.query(rusqlite::params![id_str])?;
908 let row = rows
909 .next()?
910 .ok_or_else(|| SqliteError::InvalidData(format!("entity {id} not found")))?;
911
912 let id_s: String = row.get(0)?;
913 let ns: String = row.get(1)?;
914 let kind: String = row.get(2)?;
915 let entity_type: Option<String> = row.get(3)?;
916 let name: String = row.get(4)?;
917 let description: Option<String> = row.get(5)?;
918 let properties_str: Option<String> = row.get(6)?;
919 let tags_str: String = row.get(7)?;
920 let created_at: i64 = row.get(8)?;
921 let updated_at: i64 = row.get(9)?;
922 let deleted_at: Option<i64> = row.get(10)?;
923 let merged_into_str: Option<String> = row.get(11)?;
924 let merge_event_id_str: Option<String> = row.get(12)?;
925
926 if ns != namespace {
927 return Err(SqliteError::InvalidData(format!(
928 "entity {id} belongs to namespace '{ns}', not '{namespace}'"
929 )));
930 }
931
932 let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
933 let properties: Option<Value> = properties_str
934 .map(|s| {
935 serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
936 })
937 .transpose()?;
938 let tags: Vec<String> =
939 serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
940 let merged_into = merged_into_str
941 .as_deref()
942 .map(Uuid::parse_str)
943 .transpose()
944 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
945 let merge_event_id = merge_event_id_str
946 .as_deref()
947 .map(Uuid::parse_str)
948 .transpose()
949 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
950
951 Ok(Entity {
952 id: entity_id,
953 namespace: ns,
954 kind,
955 entity_type,
956 name,
957 description,
958 properties,
959 tags,
960 created_at,
961 updated_at,
962 deleted_at,
963 merged_into,
964 merge_event_id,
965 })
966}
967
968#[allow(clippy::too_many_arguments)]
979fn merge_entity_sql(
980 conn: &rusqlite::Connection,
981 namespace: String,
982 fts_table: String,
983 vec_tables: Vec<String>,
984 into_id: Uuid,
985 from_id: Uuid,
986 strategy: EntityDedupMergePolicy,
987 content_strategy: ContentMergeStrategy,
988 dry_run: bool,
989) -> Result<(MergeSummary, Entity), SqliteError> {
990 let into_entity = read_merge_entity(conn, into_id, &namespace)?;
991 let from_entity = read_merge_entity(conn, from_id, &namespace)?;
992
993 let parse_id =
995 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
996
997 let from_str = from_id.to_string();
998
999 let mut outbound: Vec<EdgeRow> = Vec::new();
1000 {
1001 let mut stmt = conn.prepare(
1002 "SELECT id, source_id, target_id, relation, weight, created_at, \
1003 updated_at, deleted_at, target_backend, metadata \
1004 FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1005 )?;
1006 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1007 while let Some(row) = rows.next()? {
1008 outbound.push(EdgeRow {
1009 id: parse_id(row.get(0)?)?,
1010 source_id: parse_id(row.get(1)?)?,
1011 target_id: parse_id(row.get(2)?)?,
1012 relation: row.get(3)?,
1013 weight: row.get(4)?,
1014 created_at: row.get(5)?,
1015 updated_at: row.get(6)?,
1016 deleted_at: row.get(7)?,
1017 target_backend: row.get(8)?,
1018 metadata: row.get(9)?,
1019 });
1020 }
1021 }
1022
1023 let mut inbound: Vec<EdgeRow> = Vec::new();
1024 {
1025 let mut stmt = conn.prepare(
1026 "SELECT id, source_id, target_id, relation, weight, created_at, \
1027 updated_at, deleted_at, target_backend, metadata \
1028 FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1029 )?;
1030 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1031 while let Some(row) = rows.next()? {
1032 inbound.push(EdgeRow {
1033 id: parse_id(row.get(0)?)?,
1034 source_id: parse_id(row.get(1)?)?,
1035 target_id: parse_id(row.get(2)?)?,
1036 relation: row.get(3)?,
1037 weight: row.get(4)?,
1038 created_at: row.get(5)?,
1039 updated_at: row.get(6)?,
1040 deleted_at: row.get(7)?,
1041 target_backend: row.get(8)?,
1042 metadata: row.get(9)?,
1043 });
1044 }
1045 }
1046
1047 let mut seen: HashSet<Uuid> = HashSet::new();
1049 let mut all_edges: Vec<EdgeRow> = Vec::new();
1050 for edge in outbound.into_iter().chain(inbound) {
1051 if seen.insert(edge.id) {
1052 all_edges.push(edge);
1053 }
1054 }
1055
1056 let (merged_props, properties_merged) =
1058 merge_properties(&into_entity.properties, &from_entity.properties, strategy);
1059 let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
1060 let (merged_description, content_appended) = match content_strategy {
1061 ContentMergeStrategy::Append => {
1062 let into_desc = into_entity.description.as_deref().unwrap_or("");
1063 let from_desc = from_entity.description.as_deref().unwrap_or("");
1064 if from_desc.is_empty() {
1065 (into_entity.description.clone(), false)
1066 } else if into_desc.is_empty() {
1067 (from_entity.description.clone(), true)
1068 } else {
1069 (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
1070 }
1071 }
1072 ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
1076 ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
1077 };
1078 let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
1079
1080 let now = chrono::Utc::now().timestamp_micros();
1081 let into_str = into_id.to_string();
1082 let props_str = merged_props
1083 .as_ref()
1084 .map(|v| serde_json::to_string(v).unwrap_or_default());
1085 let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
1086
1087 let mut edges_rewired = 0usize;
1090 for edge in all_edges {
1091 let raw_src = if edge.source_id == from_id {
1092 into_id
1093 } else {
1094 edge.source_id
1095 };
1096 let raw_tgt = if edge.target_id == from_id {
1097 into_id
1098 } else {
1099 edge.target_id
1100 };
1101 let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1104 Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1105 Err(_) => (raw_src, raw_tgt),
1106 };
1107
1108 if new_src == new_tgt {
1109 if !dry_run {
1110 conn.execute(
1111 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1112 rusqlite::params![&namespace, edge.id.to_string()],
1113 )?;
1114 }
1115 continue;
1116 }
1117
1118 if dry_run {
1119 edges_rewired += 1;
1121 continue;
1122 }
1123
1124 let now_ts = chrono::Utc::now().timestamp();
1125 let conflict_id: Option<String> = {
1131 let conflict_src = new_src.to_string();
1132 let conflict_tgt = new_tgt.to_string();
1133 conn.query_row(
1134 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1135 rusqlite::params![
1136 &namespace,
1137 &conflict_src,
1138 &conflict_tgt,
1139 &edge.relation,
1140 edge.id.to_string(),
1141 ],
1142 |row| row.get(0),
1143 )
1144 .optional()
1145 .map_err(SqliteError::Rusqlite)?
1146 };
1147
1148 let changed = if let Some(existing_id) = conflict_id {
1149 conn.execute(
1151 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1152 rusqlite::params![&namespace, edge.id.to_string()],
1153 )?;
1154 conn.execute(
1155 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1156 rusqlite::params![
1157 edge.weight,
1158 now_ts,
1159 edge.target_backend,
1160 edge.metadata,
1161 &namespace,
1162 &existing_id,
1163 ],
1164 )?
1165 } else {
1166 conn.execute(
1167 "UPDATE graph_edges SET \
1168 source_id = ?1, target_id = ?2, updated_at = ?3 \
1169 WHERE namespace = ?4 AND id = ?5",
1170 rusqlite::params![
1171 new_src.to_string(),
1172 new_tgt.to_string(),
1173 now_ts,
1174 &namespace,
1175 edge.id.to_string(),
1176 ],
1177 )?
1178 };
1179 if changed > 0 {
1180 edges_rewired += 1;
1181 }
1182 }
1183
1184 if !dry_run {
1185 conn.execute(
1186 "INSERT OR REPLACE INTO entities \
1187 (id, namespace, kind, name, description, properties, tags, \
1188 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
1189 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1190 rusqlite::params![
1191 &into_str,
1192 &namespace,
1193 &into_entity.kind,
1194 &merged_name,
1195 &merged_description,
1196 &props_str,
1197 &tags_json,
1198 into_entity.created_at,
1199 now,
1200 into_entity.deleted_at,
1201 Option::<String>::None,
1202 Option::<String>::None,
1203 ],
1204 )?;
1205
1206 let fts_body = match &merged_description {
1210 Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
1211 _ => merged_name.clone(),
1212 };
1213 let kind_str = SubstrateKind::Entity.to_string();
1214
1215 conn.execute(
1216 &format!(
1217 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1218 fts_table
1219 ),
1220 rusqlite::params![&namespace, &into_str],
1221 )?;
1222 conn.execute(
1223 &format!(
1224 "INSERT INTO {} \
1225 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1226 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1227 fts_table
1228 ),
1229 rusqlite::params![
1230 &into_str,
1231 &kind_str,
1232 &merged_name,
1233 &fts_body,
1234 &tags_json,
1235 &namespace,
1236 &props_str,
1237 now,
1238 ],
1239 )?;
1240
1241 conn.execute(
1242 &format!(
1243 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1244 fts_table
1245 ),
1246 rusqlite::params![&namespace, &from_str],
1247 )?;
1248
1249 khive_db::stores::vectors::delete_subject_from_vector_tables(
1250 conn,
1251 &vec_tables,
1252 from_id,
1253 &namespace,
1254 )?;
1255
1256 let merge_event_id = Uuid::new_v4();
1257 conn.execute(
1258 "UPDATE entities \
1259 SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1 \
1260 WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
1261 rusqlite::params![
1262 now,
1263 into_str,
1264 merge_event_id.to_string(),
1265 &namespace,
1266 &from_str,
1267 ],
1268 )?;
1269 }
1270
1271 let updated_entity = Entity {
1272 id: into_id,
1273 namespace,
1274 kind: into_entity.kind,
1275 entity_type: into_entity.entity_type,
1276 name: merged_name,
1277 description: merged_description,
1278 properties: merged_props,
1279 tags: merged_tags,
1280 created_at: into_entity.created_at,
1281 updated_at: now,
1282 deleted_at: into_entity.deleted_at,
1283 merged_into: None,
1284 merge_event_id: None,
1285 };
1286
1287 Ok((
1288 MergeSummary {
1289 kept_id: into_id,
1290 removed_id: from_id,
1291 edges_rewired,
1292 properties_merged,
1293 tags_unioned,
1294 content_appended,
1295 dry_run,
1296 },
1297 updated_entity,
1298 ))
1299}
1300
1301fn read_merge_note(
1307 conn: &rusqlite::Connection,
1308 id: Uuid,
1309 namespace: &str,
1310) -> Result<khive_storage::note::Note, SqliteError> {
1311 use khive_storage::note::Note;
1312 let id_str = id.to_string();
1313 let mut stmt = conn.prepare(
1314 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1315 expires_at, properties, created_at, updated_at, deleted_at \
1316 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1317 )?;
1318 let mut rows = stmt.query(rusqlite::params![id_str])?;
1319 let row = rows
1320 .next()?
1321 .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
1322
1323 let id_s: String = row.get(0)?;
1324 let ns: String = row.get(1)?;
1325 let kind: String = row.get(2)?;
1326 let status: String = row.get(3)?;
1327 let name: Option<String> = row.get(4)?;
1328 let content: String = row.get(5)?;
1329 let salience: Option<f64> = row.get(6)?;
1330 let decay_factor: Option<f64> = row.get(7)?;
1331 let expires_at: Option<i64> = row.get(8)?;
1332 let properties_str: Option<String> = row.get(9)?;
1333 let created_at: i64 = row.get(10)?;
1334 let updated_at: i64 = row.get(11)?;
1335 let deleted_at: Option<i64> = row.get(12)?;
1336
1337 if ns != namespace {
1338 return Err(SqliteError::InvalidData(format!(
1339 "note {id} belongs to namespace '{ns}', not '{namespace}'"
1340 )));
1341 }
1342
1343 let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
1344 let properties: Option<serde_json::Value> = properties_str
1345 .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
1346 .transpose()?;
1347
1348 Ok(Note {
1349 id: note_id,
1350 namespace: ns,
1351 kind,
1352 status,
1353 name,
1354 content,
1355 salience,
1356 decay_factor,
1357 expires_at,
1358 properties,
1359 created_at,
1360 updated_at,
1361 deleted_at,
1362 })
1363}
1364
1365fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
1366 match (a, b) {
1367 (Some(x), Some(y)) => Some(x.max(y)),
1368 (Some(x), None) => Some(x),
1369 (None, Some(y)) => Some(y),
1370 (None, None) => None,
1371 }
1372}
1373
1374fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
1375 use serde_json::{json, Map};
1376 let mut obj: Map<String, Value> = match props {
1377 Some(Value::Object(m)) => m,
1378 Some(other) => {
1379 let mut m = Map::new();
1380 m.insert("_value".into(), other);
1381 m
1382 }
1383 None => Map::new(),
1384 };
1385 let history = obj
1386 .entry("_merge_history".to_string())
1387 .or_insert_with(|| json!([]));
1388 if let Value::Array(arr) = history {
1389 arr.push(entry);
1390 }
1391 Ok(Some(Value::Object(obj)))
1392}
1393
1394#[allow(clippy::too_many_arguments)]
1404fn merge_note_sql(
1405 conn: &rusqlite::Connection,
1406 namespace: String,
1407 fts_table: String,
1408 vec_tables: Vec<String>,
1409 into_id: Uuid,
1410 from_id: Uuid,
1411 strategy: EntityDedupMergePolicy,
1412 content_strategy: ContentMergeStrategy,
1413 dry_run: bool,
1414) -> Result<(MergeSummary, khive_storage::note::Note), SqliteError> {
1415 let into_note = read_merge_note(conn, into_id, &namespace)?;
1416 let from_note = read_merge_note(conn, from_id, &namespace)?;
1417
1418 if into_note.kind != from_note.kind {
1419 return Err(SqliteError::InvalidData(format!(
1420 "cannot merge notes of different kinds: {} vs {}",
1421 into_note.kind, from_note.kind
1422 )));
1423 }
1424
1425 let now = chrono::Utc::now().timestamp_micros();
1426 let into_str = into_id.to_string();
1427 let from_str = from_id.to_string();
1428
1429 let parse_id =
1431 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
1432
1433 let mut outbound: Vec<EdgeRow> = Vec::new();
1434 {
1435 let mut stmt = conn.prepare(
1436 "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1437 FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1438 )?;
1439 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1440 while let Some(row) = rows.next()? {
1441 outbound.push(EdgeRow {
1442 id: parse_id(row.get(0)?)?,
1443 source_id: parse_id(row.get(1)?)?,
1444 target_id: parse_id(row.get(2)?)?,
1445 relation: row.get(3)?,
1446 weight: row.get(4)?,
1447 created_at: row.get(5)?,
1448 updated_at: row.get(6)?,
1449 deleted_at: row.get(7)?,
1450 target_backend: row.get(8)?,
1451 metadata: row.get(9)?,
1452 });
1453 }
1454 }
1455 let mut inbound: Vec<EdgeRow> = Vec::new();
1456 {
1457 let mut stmt = conn.prepare(
1458 "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1459 FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1460 )?;
1461 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1462 while let Some(row) = rows.next()? {
1463 inbound.push(EdgeRow {
1464 id: parse_id(row.get(0)?)?,
1465 source_id: parse_id(row.get(1)?)?,
1466 target_id: parse_id(row.get(2)?)?,
1467 relation: row.get(3)?,
1468 weight: row.get(4)?,
1469 created_at: row.get(5)?,
1470 updated_at: row.get(6)?,
1471 deleted_at: row.get(7)?,
1472 target_backend: row.get(8)?,
1473 metadata: row.get(9)?,
1474 });
1475 }
1476 }
1477 let mut seen: HashSet<Uuid> = HashSet::new();
1478 let mut all_edges: Vec<EdgeRow> = Vec::new();
1479 for edge in outbound.into_iter().chain(inbound) {
1480 if seen.insert(edge.id) {
1481 all_edges.push(edge);
1482 }
1483 }
1484
1485 let (merged_content, content_appended) = match content_strategy {
1487 ContentMergeStrategy::Append => {
1488 if from_note.content.is_empty() {
1489 (into_note.content.clone(), false)
1490 } else {
1491 (
1492 format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
1493 true,
1494 )
1495 }
1496 }
1497 ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
1498 ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
1499 };
1500
1501 let merged_name = match strategy {
1502 EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
1503 _ => into_note.name.clone().or(from_note.name.clone()),
1504 };
1505
1506 let (merged_props, properties_merged) =
1507 merge_properties(&into_note.properties, &from_note.properties, strategy);
1508
1509 let merge_history_entry = serde_json::json!({
1510 "merged_from": from_id.to_string(),
1511 "merged_at": now,
1512 "strategy": format!("{:?}", strategy),
1513 "content_strategy": format!("{:?}", content_strategy),
1514 });
1515 let merged_props = append_merge_history(merged_props, merge_history_entry)?;
1516
1517 let merged_salience = max_option_f64(into_note.salience, from_note.salience);
1518 let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
1519 (Some(a), Some(b)) => Some(a.max(b)),
1520 (Some(a), None) => Some(a),
1521 (None, Some(b)) => Some(b),
1522 (None, None) => None,
1523 };
1524
1525 let props_str = merged_props
1526 .as_ref()
1527 .map(|v| serde_json::to_string(v).unwrap_or_default());
1528
1529 let mut edges_rewired = 0usize;
1530 if !dry_run {
1531 for edge in all_edges {
1532 let raw_src = if edge.source_id == from_id {
1533 into_id
1534 } else {
1535 edge.source_id
1536 };
1537 let raw_tgt = if edge.target_id == from_id {
1538 into_id
1539 } else {
1540 edge.target_id
1541 };
1542 let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1544 Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1545 Err(_) => (raw_src, raw_tgt),
1546 };
1547 if new_src == new_tgt {
1548 conn.execute(
1549 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1550 rusqlite::params![&namespace, edge.id.to_string()],
1551 )?;
1552 continue;
1553 }
1554 let now_ts = chrono::Utc::now().timestamp();
1555 let conflict_id: Option<String> = {
1556 let conflict_src = new_src.to_string();
1557 let conflict_tgt = new_tgt.to_string();
1558 conn.query_row(
1559 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1560 rusqlite::params![
1561 &namespace,
1562 &conflict_src,
1563 &conflict_tgt,
1564 &edge.relation,
1565 edge.id.to_string(),
1566 ],
1567 |row| row.get(0),
1568 )
1569 .optional()
1570 .map_err(SqliteError::Rusqlite)?
1571 };
1572
1573 let changed = if let Some(existing_id) = conflict_id {
1574 conn.execute(
1575 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1576 rusqlite::params![&namespace, edge.id.to_string()],
1577 )?;
1578 conn.execute(
1579 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1580 rusqlite::params![
1581 edge.weight,
1582 now_ts,
1583 edge.target_backend,
1584 edge.metadata,
1585 &namespace,
1586 &existing_id,
1587 ],
1588 )?
1589 } else {
1590 conn.execute(
1591 "UPDATE graph_edges SET \
1592 source_id = ?1, target_id = ?2, updated_at = ?3 \
1593 WHERE namespace = ?4 AND id = ?5",
1594 rusqlite::params![
1595 new_src.to_string(),
1596 new_tgt.to_string(),
1597 now_ts,
1598 &namespace,
1599 edge.id.to_string(),
1600 ],
1601 )?
1602 };
1603 if changed > 0 {
1604 edges_rewired += 1;
1605 }
1606 }
1607
1608 conn.execute(
1609 "INSERT OR REPLACE INTO notes \
1610 (id, namespace, kind, status, name, content, salience, decay_factor, \
1611 expires_at, properties, created_at, updated_at, deleted_at) \
1612 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1613 rusqlite::params![
1614 &into_str,
1615 &namespace,
1616 &into_note.kind,
1617 &into_note.status,
1618 &merged_name,
1619 &merged_content,
1620 merged_salience,
1621 into_note.decay_factor,
1622 merged_expires_at,
1623 &props_str,
1624 into_note.created_at,
1625 now,
1626 into_note.deleted_at,
1627 ],
1628 )?;
1629
1630 conn.execute(
1631 &format!(
1632 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1633 fts_table
1634 ),
1635 rusqlite::params![&namespace, &into_str],
1636 )?;
1637 let fts_merged = {
1642 let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
1643 merged_note.id = into_id;
1644 merged_note.name = merged_name.clone();
1645 merged_note.properties = merged_props.clone();
1646 merged_note.updated_at = now;
1647 note_fts_scalars(&merged_note)
1648 };
1649 conn.execute(
1650 &format!(
1651 "INSERT INTO {} \
1652 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1653 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1654 fts_table
1655 ),
1656 rusqlite::params![
1657 &into_str,
1658 SubstrateKind::Note.to_string(),
1659 &fts_merged.title,
1660 &fts_merged.body,
1661 &fts_merged.tags,
1662 &namespace,
1663 &fts_merged.metadata,
1664 fts_merged.updated_at_micros,
1665 ],
1666 )?;
1667
1668 conn.execute(
1669 &format!(
1670 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1671 fts_table
1672 ),
1673 rusqlite::params![&namespace, &from_str],
1674 )?;
1675
1676 khive_db::stores::vectors::delete_subject_from_vector_tables(
1677 conn,
1678 &vec_tables,
1679 from_id,
1680 &namespace,
1681 )?;
1682
1683 conn.execute(
1684 "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
1685 WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
1686 rusqlite::params![now, &namespace, &from_str],
1687 )?;
1688 }
1689
1690 let updated_note = khive_storage::note::Note {
1691 id: into_id,
1692 namespace: namespace.clone(),
1693 kind: into_note.kind.clone(),
1694 status: into_note.status.clone(),
1695 name: merged_name,
1696 content: merged_content,
1697 salience: merged_salience,
1698 decay_factor: into_note.decay_factor,
1699 expires_at: merged_expires_at,
1700 properties: merged_props,
1701 created_at: into_note.created_at,
1702 updated_at: now,
1703 deleted_at: into_note.deleted_at,
1704 };
1705
1706 Ok((
1707 MergeSummary {
1708 kept_id: into_id,
1709 removed_id: from_id,
1710 edges_rewired,
1711 properties_merged,
1712 tags_unioned: 0,
1713 content_appended,
1714 dry_run,
1715 },
1716 updated_note,
1717 ))
1718}
1719
1720pub(crate) fn merge_string_field(
1727 into: &str,
1728 from: &str,
1729 strategy: EntityDedupMergePolicy,
1730) -> String {
1731 match strategy {
1732 EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
1733 EntityDedupMergePolicy::PreferFrom => from.to_string(),
1734 }
1735}
1736
1737pub(crate) fn merge_properties(
1742 into: &Option<Value>,
1743 from: &Option<Value>,
1744 strategy: EntityDedupMergePolicy,
1745) -> (Option<Value>, usize) {
1746 match (into, from) {
1747 (None, None) => (None, 0),
1748 (Some(a), None) => (Some(a.clone()), 0),
1749 (None, Some(b)) => {
1750 let count = if let Value::Object(m) = b { m.len() } else { 1 };
1751 (Some(b.clone()), count)
1752 }
1753 (Some(into_val), Some(from_val)) => {
1754 let (merged, added) = merge_json(into_val, from_val, strategy);
1755 (Some(merged), added)
1756 }
1757 }
1758}
1759
1760fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
1762 match (into, from, strategy) {
1763 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
1764 let mut result = a.clone();
1765 let mut added = 0usize;
1766 for (k, v_from) in b {
1767 if let Some(v_into) = a.get(k) {
1768 let (merged, sub_added) =
1769 merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
1770 result.insert(k.clone(), merged);
1771 added += sub_added;
1772 } else {
1773 result.insert(k.clone(), v_from.clone());
1774 added += 1;
1775 }
1776 }
1777 (Value::Object(result), added)
1778 }
1779 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
1780 let mut result = a.clone();
1781 let mut added = 0usize;
1782 for (k, v) in b {
1783 if !a.contains_key(k) {
1784 result.insert(k.clone(), v.clone());
1785 added += 1;
1786 }
1787 }
1788 (Value::Object(result), added)
1789 }
1790 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
1791 let mut result = a.clone();
1792 let mut added = 0usize;
1793 for (k, v) in b {
1794 result.insert(k.clone(), v.clone());
1795 if !a.contains_key(k) {
1796 added += 1;
1797 }
1798 }
1799 (Value::Object(result), added)
1800 }
1801 (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
1803 _ => (into.clone(), 0),
1804 }
1805}
1806
1807pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
1810 let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
1811 let mut result: Vec<String> = into.to_vec();
1812 let mut added = 0usize;
1813 for tag in from {
1814 if seen.insert(tag.as_str()) {
1815 result.push(tag.clone());
1816 added += 1;
1817 }
1818 }
1819 (result, added)
1820}
1821
1822#[cfg(test)]
1831mod tests {
1832 use super::*;
1833 use crate::runtime::{KhiveRuntime, NamespaceToken};
1834 use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
1835
1836 fn rt() -> KhiveRuntime {
1837 KhiveRuntime::memory().unwrap()
1838 }
1839
1840 async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
1842 let ns = token.namespace().as_str().to_string();
1843 rt.text(token)
1844 .unwrap()
1845 .search(TextSearchRequest {
1846 query: query.to_string(),
1847 mode: TextQueryMode::Plain,
1848 filter: Some(TextFilter {
1849 namespaces: vec![ns],
1850 ..Default::default()
1851 }),
1852 top_k: 50,
1853 snippet_chars: 100,
1854 })
1855 .await
1856 .unwrap()
1857 .into_iter()
1858 .map(|h| h.subject_id)
1859 .collect()
1860 }
1861
1862 #[tokio::test]
1863 async fn update_entity_patch_changes_only_specified_fields() {
1864 let rt = rt();
1865 let tok = NamespaceToken::local();
1866 let entity = rt
1867 .create_entity(
1868 &tok,
1869 "concept",
1870 None,
1871 "OriginalName",
1872 Some("orig desc"),
1873 Some(serde_json::json!({"k":"v"})),
1874 vec![],
1875 )
1876 .await
1877 .unwrap();
1878
1879 let updated = rt
1880 .update_entity(
1881 &tok,
1882 entity.id,
1883 EntityPatch {
1884 description: Some(Some("new desc".to_string())),
1885 ..Default::default()
1886 },
1887 )
1888 .await
1889 .unwrap();
1890
1891 assert_eq!(updated.name, "OriginalName");
1892 assert_eq!(updated.description.as_deref(), Some("new desc"));
1893 assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
1894 }
1895
1896 #[tokio::test]
1897 async fn update_entity_clear_description_with_some_none() {
1898 let rt = rt();
1899 let tok = NamespaceToken::local();
1900 let entity = rt
1901 .create_entity(
1902 &tok,
1903 "concept",
1904 None,
1905 "ClearDesc",
1906 Some("has description"),
1907 None,
1908 vec![],
1909 )
1910 .await
1911 .unwrap();
1912
1913 let updated = rt
1914 .update_entity(
1915 &tok,
1916 entity.id,
1917 EntityPatch {
1918 description: Some(None),
1919 ..Default::default()
1920 },
1921 )
1922 .await
1923 .unwrap();
1924
1925 assert!(
1926 updated.description.is_none(),
1927 "description should be cleared"
1928 );
1929 }
1930
1931 #[tokio::test]
1932 async fn update_entity_reindexes_when_name_changes() {
1933 let rt = rt();
1934 let tok = NamespaceToken::local();
1935 let entity = rt
1936 .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
1937 .await
1938 .unwrap();
1939
1940 let hits_before = fts_hit(&rt, &tok, "OldName").await;
1941 assert!(
1942 hits_before.contains(&entity.id),
1943 "entity should be findable by old name"
1944 );
1945
1946 rt.update_entity(
1947 &tok,
1948 entity.id,
1949 EntityPatch {
1950 name: Some("NewName".to_string()),
1951 ..Default::default()
1952 },
1953 )
1954 .await
1955 .unwrap();
1956
1957 let hits_old = fts_hit(&rt, &tok, "OldName").await;
1958 let hits_new = fts_hit(&rt, &tok, "NewName").await;
1959
1960 assert!(
1961 !hits_old.contains(&entity.id),
1962 "old name should no longer match after rename"
1963 );
1964 assert!(
1965 hits_new.contains(&entity.id),
1966 "new name should be findable after rename"
1967 );
1968 }
1969
1970 #[tokio::test]
1971 async fn update_entity_properties_merges_preserving_existing_keys() {
1972 let rt = rt();
1973 let tok = NamespaceToken::local();
1974 let entity = rt
1975 .create_entity(
1976 &tok,
1977 "concept",
1978 None,
1979 "MergeProps",
1980 None,
1981 Some(serde_json::json!({
1982 "domain": "inference",
1983 "repo": "lattice",
1984 "status": "researched",
1985 })),
1986 vec![],
1987 )
1988 .await
1989 .unwrap();
1990
1991 let updated = rt
1992 .update_entity(
1993 &tok,
1994 entity.id,
1995 EntityPatch {
1996 properties: Some(serde_json::json!({"status": "implemented"})),
1997 ..Default::default()
1998 },
1999 )
2000 .await
2001 .unwrap();
2002
2003 let props = updated.properties.expect("properties should remain set");
2004 assert_eq!(props["domain"], "inference", "domain key must be preserved");
2005 assert_eq!(props["repo"], "lattice", "repo key must be preserved");
2006 assert_eq!(
2007 props["status"], "implemented",
2008 "status key must be updated by patch"
2009 );
2010 }
2011
2012 #[tokio::test]
2013 async fn update_entity_skips_reindex_when_only_properties_change() {
2014 let rt = rt();
2015 let tok = NamespaceToken::local();
2016 let entity = rt
2017 .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
2018 .await
2019 .unwrap();
2020
2021 let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
2022 assert!(hits_before.contains(&entity.id));
2023
2024 rt.update_entity(
2025 &tok,
2026 entity.id,
2027 EntityPatch {
2028 properties: Some(serde_json::json!({"new": "prop"})),
2029 ..Default::default()
2030 },
2031 )
2032 .await
2033 .unwrap();
2034
2035 let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
2036 assert!(
2037 hits_after.contains(&entity.id),
2038 "still findable after props-only patch"
2039 );
2040 }
2041
2042 #[tokio::test]
2043 async fn merge_entity_rewires_edges() {
2044 let rt = rt();
2045 let tok = NamespaceToken::local();
2046 let a = rt
2047 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2048 .await
2049 .unwrap();
2050 let b = rt
2051 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2052 .await
2053 .unwrap();
2054 let c = rt
2055 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2056 .await
2057 .unwrap();
2058 let d = rt
2059 .create_entity(&tok, "concept", None, "D", None, None, vec![])
2060 .await
2061 .unwrap();
2062
2063 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2065 .await
2066 .unwrap();
2067 rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
2068 .await
2069 .unwrap();
2070
2071 let summary = rt
2072 .merge_entity(
2073 &tok,
2074 d.id,
2075 b.id,
2076 EntityDedupMergePolicy::PreferInto,
2077 ContentMergeStrategy::Append,
2078 false,
2079 )
2080 .await
2081 .unwrap();
2082
2083 assert_eq!(summary.kept_id, d.id);
2084 assert_eq!(summary.removed_id, b.id);
2085 assert_eq!(summary.edges_rewired, 2);
2086
2087 let a_neighbors = rt
2088 .neighbors(&tok, a.id, Direction::Out, None, None)
2089 .await
2090 .unwrap();
2091 assert_eq!(a_neighbors.len(), 1);
2092 assert_eq!(a_neighbors[0].node_id, d.id);
2093
2094 let c_neighbors = rt
2095 .neighbors(&tok, c.id, Direction::Out, None, None)
2096 .await
2097 .unwrap();
2098 assert_eq!(c_neighbors.len(), 1);
2099 assert_eq!(c_neighbors[0].node_id, d.id);
2100 }
2101
2102 #[tokio::test]
2103 async fn merge_entity_self_merge_rejected() {
2104 let rt = rt();
2105 let tok = NamespaceToken::local();
2106 let a = rt
2107 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2108 .await
2109 .unwrap();
2110 let err = rt
2111 .merge_entity(
2112 &tok,
2113 a.id,
2114 a.id,
2115 EntityDedupMergePolicy::PreferInto,
2116 ContentMergeStrategy::Append,
2117 false,
2118 )
2119 .await
2120 .unwrap_err();
2121 assert!(
2122 format!("{err:?}").contains("cannot merge an entity into itself"),
2123 "expected self-merge rejection, got: {err:?}"
2124 );
2125 }
2126
2127 #[tokio::test]
2128 async fn merge_entity_prefer_into_strategy() {
2129 let rt = rt();
2130 let tok = NamespaceToken::local();
2131 let into = rt
2132 .create_entity(
2133 &tok,
2134 "concept",
2135 None,
2136 "Into",
2137 None,
2138 Some(serde_json::json!({"a": 1})),
2139 vec![],
2140 )
2141 .await
2142 .unwrap();
2143 let from = rt
2144 .create_entity(
2145 &tok,
2146 "concept",
2147 None,
2148 "From",
2149 None,
2150 Some(serde_json::json!({"a": 2, "b": 3})),
2151 vec![],
2152 )
2153 .await
2154 .unwrap();
2155
2156 rt.merge_entity(
2157 &tok,
2158 into.id,
2159 from.id,
2160 EntityDedupMergePolicy::PreferInto,
2161 ContentMergeStrategy::Append,
2162 false,
2163 )
2164 .await
2165 .unwrap();
2166
2167 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2168 let props = kept.properties.unwrap();
2169 assert_eq!(props["a"], 1);
2171 assert_eq!(props["b"], 3);
2172 }
2173
2174 #[tokio::test]
2175 async fn merge_entity_prefer_from_strategy() {
2176 let rt = rt();
2177 let tok = NamespaceToken::local();
2178 let into = rt
2179 .create_entity(
2180 &tok,
2181 "concept",
2182 None,
2183 "Into",
2184 None,
2185 Some(serde_json::json!({"a": 1})),
2186 vec![],
2187 )
2188 .await
2189 .unwrap();
2190 let from = rt
2191 .create_entity(
2192 &tok,
2193 "concept",
2194 None,
2195 "From",
2196 None,
2197 Some(serde_json::json!({"a": 2, "b": 3})),
2198 vec![],
2199 )
2200 .await
2201 .unwrap();
2202
2203 rt.merge_entity(
2204 &tok,
2205 into.id,
2206 from.id,
2207 EntityDedupMergePolicy::PreferFrom,
2208 ContentMergeStrategy::Append,
2209 false,
2210 )
2211 .await
2212 .unwrap();
2213
2214 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2215 let props = kept.properties.unwrap();
2216 assert_eq!(props["a"], 2);
2218 assert_eq!(props["b"], 3);
2219 }
2220
2221 #[tokio::test]
2222 async fn merge_entity_union_strategy() {
2223 let rt = rt();
2224 let tok = NamespaceToken::local();
2225 let into = rt
2226 .create_entity(
2227 &tok,
2228 "concept",
2229 None,
2230 "Into",
2231 None,
2232 Some(serde_json::json!({"a": 1})),
2233 vec![],
2234 )
2235 .await
2236 .unwrap();
2237 let from = rt
2238 .create_entity(
2239 &tok,
2240 "concept",
2241 None,
2242 "From",
2243 None,
2244 Some(serde_json::json!({"a": 2, "b": 3})),
2245 vec![],
2246 )
2247 .await
2248 .unwrap();
2249
2250 rt.merge_entity(
2251 &tok,
2252 into.id,
2253 from.id,
2254 EntityDedupMergePolicy::Union,
2255 ContentMergeStrategy::Append,
2256 false,
2257 )
2258 .await
2259 .unwrap();
2260
2261 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2262 let props = kept.properties.unwrap();
2263 assert_eq!(props["a"], 1);
2265 assert_eq!(props["b"], 3);
2266 }
2267
2268 #[tokio::test]
2269 async fn merge_entity_unions_tags() {
2270 let rt = rt();
2271 let tok = NamespaceToken::local();
2272 let into = rt
2273 .create_entity(
2274 &tok,
2275 "concept",
2276 None,
2277 "Into",
2278 None,
2279 None,
2280 vec!["x".to_string(), "y".to_string()],
2281 )
2282 .await
2283 .unwrap();
2284 let from = rt
2285 .create_entity(
2286 &tok,
2287 "concept",
2288 None,
2289 "From",
2290 None,
2291 None,
2292 vec!["y".to_string(), "z".to_string()],
2293 )
2294 .await
2295 .unwrap();
2296
2297 rt.merge_entity(
2298 &tok,
2299 into.id,
2300 from.id,
2301 EntityDedupMergePolicy::PreferInto,
2302 ContentMergeStrategy::Append,
2303 false,
2304 )
2305 .await
2306 .unwrap();
2307
2308 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2309 let mut tags = kept.tags.clone();
2310 tags.sort();
2311 assert_eq!(tags, vec!["x", "y", "z"]);
2312 }
2313
2314 #[tokio::test]
2315 async fn merge_entity_drops_self_loops() {
2316 let rt = rt();
2317 let tok = NamespaceToken::local();
2318 let a = rt
2319 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2320 .await
2321 .unwrap();
2322 let b = rt
2323 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2324 .await
2325 .unwrap();
2326
2327 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2329 .await
2330 .unwrap();
2331
2332 let summary = rt
2333 .merge_entity(
2334 &tok,
2335 a.id,
2336 b.id,
2337 EntityDedupMergePolicy::PreferInto,
2338 ContentMergeStrategy::Append,
2339 false,
2340 )
2341 .await
2342 .unwrap();
2343
2344 assert_eq!(
2345 summary.edges_rewired, 0,
2346 "self-loop should be dropped, not rewired"
2347 );
2348
2349 let a_out = rt
2350 .neighbors(&tok, a.id, Direction::Out, None, None)
2351 .await
2352 .unwrap();
2353 assert!(a_out.is_empty(), "no self-loop should remain");
2354 }
2355
2356 #[tokio::test]
2359 async fn merge_entity_append_strategy_concatenates_descriptions() {
2360 let rt = rt();
2361 let tok = NamespaceToken::local();
2362 let into = rt
2363 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2364 .await
2365 .unwrap();
2366 let from = rt
2367 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2368 .await
2369 .unwrap();
2370
2371 let summary = rt
2372 .merge_entity(
2373 &tok,
2374 into.id,
2375 from.id,
2376 EntityDedupMergePolicy::PreferInto,
2377 ContentMergeStrategy::Append,
2378 false,
2379 )
2380 .await
2381 .unwrap();
2382
2383 assert!(
2384 summary.content_appended,
2385 "append strategy with two non-empty descriptions must report content_appended=true"
2386 );
2387 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2388 assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
2389 }
2390
2391 #[tokio::test]
2392 async fn merge_entity_append_strategy_from_empty_is_noop() {
2393 let rt = rt();
2394 let tok = NamespaceToken::local();
2395 let into = rt
2396 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2397 .await
2398 .unwrap();
2399 let from = rt
2400 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2401 .await
2402 .unwrap();
2403
2404 let summary = rt
2405 .merge_entity(
2406 &tok,
2407 into.id,
2408 from.id,
2409 EntityDedupMergePolicy::PreferInto,
2410 ContentMergeStrategy::Append,
2411 false,
2412 )
2413 .await
2414 .unwrap();
2415
2416 assert!(
2417 !summary.content_appended,
2418 "from's empty description means nothing was appended"
2419 );
2420 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2421 assert_eq!(kept.description.as_deref(), Some("desc A"));
2422 }
2423
2424 #[tokio::test]
2425 async fn merge_entity_append_strategy_into_empty_takes_from() {
2426 let rt = rt();
2427 let tok = NamespaceToken::local();
2428 let into = rt
2429 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2430 .await
2431 .unwrap();
2432 let from = rt
2433 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2434 .await
2435 .unwrap();
2436
2437 let summary = rt
2438 .merge_entity(
2439 &tok,
2440 into.id,
2441 from.id,
2442 EntityDedupMergePolicy::PreferInto,
2443 ContentMergeStrategy::Append,
2444 false,
2445 )
2446 .await
2447 .unwrap();
2448
2449 assert!(
2450 summary.content_appended,
2451 "taking from's description into an empty into is real content preservation"
2452 );
2453 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2454 assert_eq!(kept.description.as_deref(), Some("desc B"));
2455 }
2456
2457 #[tokio::test]
2458 async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
2459 let rt = rt();
2460 let tok = NamespaceToken::local();
2461 let into = rt
2462 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2463 .await
2464 .unwrap();
2465 let from = rt
2466 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2467 .await
2468 .unwrap();
2469
2470 let summary = rt
2471 .merge_entity(
2472 &tok,
2473 into.id,
2474 from.id,
2475 EntityDedupMergePolicy::PreferInto,
2476 ContentMergeStrategy::PreferInto,
2477 false,
2478 )
2479 .await
2480 .unwrap();
2481
2482 assert!(
2483 !summary.content_appended,
2484 "explicit PreferInto opt-out must not report an append"
2485 );
2486 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2487 assert_eq!(
2488 kept.description.as_deref(),
2489 Some("desc A"),
2490 "explicit PreferInto opt-out keeps the old discard behavior"
2491 );
2492 }
2493
2494 #[tokio::test]
2499 async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
2500 let rt = rt();
2501 let tok = NamespaceToken::local();
2502 let into = rt
2503 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2504 .await
2505 .unwrap();
2506 let from = rt
2507 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2508 .await
2509 .unwrap();
2510
2511 let summary = rt
2512 .merge_entity(
2513 &tok,
2514 into.id,
2515 from.id,
2516 EntityDedupMergePolicy::PreferInto,
2517 ContentMergeStrategy::PreferFrom,
2518 false,
2519 )
2520 .await
2521 .unwrap();
2522
2523 assert!(
2524 !summary.content_appended,
2525 "explicit PreferFrom is not an append"
2526 );
2527 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2528 assert_eq!(
2529 kept.description.as_deref(),
2530 Some("desc B"),
2531 "content_strategy=prefer_from must win over the default prefer_into entity policy"
2532 );
2533 }
2534
2535 #[tokio::test]
2536 async fn merge_entity_dry_run_previews_append() {
2537 let rt = rt();
2538 let tok = NamespaceToken::local();
2539 let into = rt
2540 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2541 .await
2542 .unwrap();
2543 let from = rt
2544 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2545 .await
2546 .unwrap();
2547
2548 let summary = rt
2549 .merge_entity(
2550 &tok,
2551 into.id,
2552 from.id,
2553 EntityDedupMergePolicy::PreferInto,
2554 ContentMergeStrategy::Append,
2555 true,
2556 )
2557 .await
2558 .unwrap();
2559
2560 assert!(summary.dry_run);
2561 assert!(
2562 summary.content_appended,
2563 "dry-run must preview the append outcome without writing"
2564 );
2565 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2566 assert_eq!(
2567 kept.description.as_deref(),
2568 Some("desc A"),
2569 "dry_run=true must not mutate the into entity's description"
2570 );
2571 }
2572
2573 #[tokio::test]
2576 async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
2577 use khive_storage::EdgeRelation;
2578
2579 let rt = rt();
2580 let tok = NamespaceToken::local();
2581 let a = rt
2582 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2583 .await
2584 .unwrap();
2585 let into = rt
2586 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2587 .await
2588 .unwrap();
2589 let from = rt
2590 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2591 .await
2592 .unwrap();
2593
2594 rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
2595 .await
2596 .unwrap();
2597
2598 let summary = rt
2599 .merge_entity(
2600 &tok,
2601 into.id,
2602 from.id,
2603 EntityDedupMergePolicy::PreferInto,
2604 ContentMergeStrategy::Append,
2605 true,
2606 )
2607 .await
2608 .unwrap();
2609
2610 assert!(summary.dry_run);
2611 assert_eq!(
2612 summary.edges_rewired, 1,
2613 "dry-run must predict the edge that would be rewired, not report zero"
2614 );
2615
2616 let a_neighbors = rt
2617 .neighbors(&tok, a.id, Direction::Out, None, None)
2618 .await
2619 .unwrap();
2620 assert_eq!(a_neighbors.len(), 1);
2621 assert_eq!(
2622 a_neighbors[0].node_id, from.id,
2623 "dry_run=true must not rewire any edges"
2624 );
2625
2626 let events = rt
2627 .events(&tok)
2628 .unwrap()
2629 .query_events(
2630 khive_storage::EventFilter {
2631 kinds: vec![EventKind::EntityMerged],
2632 ..Default::default()
2633 },
2634 khive_storage::types::PageRequest {
2635 offset: 0,
2636 limit: 10,
2637 },
2638 )
2639 .await
2640 .unwrap();
2641 assert!(
2642 events.items.is_empty(),
2643 "dry_run=true must not append an EntityMerged event"
2644 );
2645 }
2646
2647 #[test]
2650 fn union_tags_deduplicates() {
2651 let (tags, added) = union_tags(
2652 &["x".to_string(), "y".to_string()],
2653 &["y".to_string(), "z".to_string()],
2654 );
2655 let mut sorted = tags.clone();
2656 sorted.sort();
2657 assert_eq!(sorted, vec!["x", "y", "z"]);
2658 assert_eq!(added, 1);
2659 }
2660
2661 #[test]
2662 fn merge_properties_prefer_into_fills_missing_keys() {
2663 let a = serde_json::json!({"a": 1});
2664 let b = serde_json::json!({"a": 99, "b": 2});
2665 let (merged, added) =
2666 merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
2667 let m = merged.unwrap();
2668 assert_eq!(m["a"], 1);
2669 assert_eq!(m["b"], 2);
2670 assert_eq!(added, 1);
2671 }
2672
2673 #[tokio::test]
2676 async fn merge_entity_tombstones_source_with_provenance() {
2677 let rt = rt();
2678 let tok = NamespaceToken::local();
2679 let into = rt
2680 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2681 .await
2682 .unwrap();
2683 let from = rt
2684 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2685 .await
2686 .unwrap();
2687 let from_id = from.id;
2688
2689 rt.merge_entity(
2690 &tok,
2691 into.id,
2692 from_id,
2693 EntityDedupMergePolicy::PreferInto,
2694 ContentMergeStrategy::Append,
2695 false,
2696 )
2697 .await
2698 .unwrap();
2699
2700 assert!(
2701 rt.get_entity(&tok, from_id).await.is_err(),
2702 "tombstoned source should not be returned by get_entity"
2703 );
2704
2705 let pool = rt.backend().pool_arc();
2706 let (deleted_at, merged_into): (Option<i64>, Option<String>) =
2707 tokio::task::spawn_blocking(move || {
2708 let guard = pool.writer().unwrap();
2709 guard
2710 .conn()
2711 .query_row(
2712 "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
2713 [from_id.to_string()],
2714 |row| Ok((row.get(0)?, row.get(1)?)),
2715 )
2716 .unwrap()
2717 })
2718 .await
2719 .unwrap();
2720 assert!(
2721 deleted_at.is_some(),
2722 "tombstoned entity must have deleted_at set"
2723 );
2724 assert_eq!(
2725 merged_into.as_deref(),
2726 Some(into.id.to_string().as_str()),
2727 "merged_into must point to into_id"
2728 );
2729 }
2730
2731 #[tokio::test]
2732 async fn merge_note_same_kind_appends_content() {
2733 let rt = rt();
2734 let tok = NamespaceToken::local();
2735 let into = rt
2736 .create_note(
2737 &tok,
2738 "observation",
2739 None,
2740 "Into content",
2741 None,
2742 None,
2743 vec![],
2744 )
2745 .await
2746 .unwrap();
2747 let from = rt
2748 .create_note(
2749 &tok,
2750 "observation",
2751 None,
2752 "From content",
2753 None,
2754 None,
2755 vec![],
2756 )
2757 .await
2758 .unwrap();
2759 let from_id = from.id;
2760
2761 let summary = rt
2762 .merge_note(
2763 &tok,
2764 into.id,
2765 from_id,
2766 EntityDedupMergePolicy::PreferInto,
2767 ContentMergeStrategy::Append,
2768 false,
2769 )
2770 .await
2771 .unwrap();
2772
2773 assert_eq!(summary.kept_id, into.id);
2774 assert_eq!(summary.removed_id, from_id);
2775 assert!(summary.content_appended);
2776 assert!(!summary.dry_run);
2777
2778 let from_store = rt.notes(&tok).unwrap();
2779 assert!(
2780 from_store.get_note(from_id).await.unwrap().is_none(),
2781 "merged-from note should be soft-deleted"
2782 );
2783 }
2784
2785 #[tokio::test]
2788 async fn merge_note_survives_shared_edge_to_third_party() {
2789 use khive_storage::EdgeRelation;
2790 let rt = rt();
2791 let tok = NamespaceToken::local();
2792
2793 let into = rt
2794 .create_note(&tok, "observation", None, "Into", None, None, vec![])
2795 .await
2796 .unwrap();
2797 let from = rt
2798 .create_note(&tok, "observation", None, "From", None, None, vec![])
2799 .await
2800 .unwrap();
2801 let shared = rt
2802 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
2803 .await
2804 .unwrap();
2805
2806 rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2810 .await
2811 .unwrap();
2812 rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2813 .await
2814 .unwrap();
2815
2816 let summary = rt
2817 .merge_note(
2818 &tok,
2819 into.id,
2820 from.id,
2821 EntityDedupMergePolicy::PreferInto,
2822 ContentMergeStrategy::Append,
2823 false,
2824 )
2825 .await
2826 .expect("merge must succeed even when both notes annotate the same entity");
2827
2828 assert_eq!(summary.kept_id, into.id);
2829 assert_eq!(summary.removed_id, from.id);
2830
2831 let into_edges = rt
2832 .list_edges(
2833 &tok,
2834 crate::EdgeListFilter {
2835 source_id: Some(into.id),
2836 target_id: Some(shared.id),
2837 relations: vec![EdgeRelation::Annotates],
2838 ..Default::default()
2839 },
2840 10,
2841 0,
2842 )
2843 .await
2844 .unwrap();
2845 assert_eq!(
2846 into_edges.len(),
2847 1,
2848 "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
2849 );
2850 }
2851
2852 #[tokio::test]
2853 async fn merge_note_different_kinds_rejected() {
2854 let rt = rt();
2855 let tok = NamespaceToken::local();
2856 let into = rt
2857 .create_note(&tok, "observation", None, "Into", None, None, vec![])
2858 .await
2859 .unwrap();
2860 let from = rt
2861 .create_note(&tok, "decision", None, "From", None, None, vec![])
2862 .await
2863 .unwrap();
2864
2865 let result = rt
2866 .merge_note(
2867 &tok,
2868 into.id,
2869 from.id,
2870 EntityDedupMergePolicy::PreferInto,
2871 ContentMergeStrategy::Append,
2872 false,
2873 )
2874 .await;
2875 assert!(result.is_err(), "merging different note kinds must fail");
2876 }
2877
2878 #[tokio::test]
2879 async fn merge_note_dry_run_leaves_notes_unchanged() {
2880 let rt = rt();
2881 let tok = NamespaceToken::local();
2882 let into = rt
2883 .create_note(
2884 &tok,
2885 "observation",
2886 None,
2887 "Into content",
2888 None,
2889 None,
2890 vec![],
2891 )
2892 .await
2893 .unwrap();
2894 let from = rt
2895 .create_note(
2896 &tok,
2897 "observation",
2898 None,
2899 "From content",
2900 None,
2901 None,
2902 vec![],
2903 )
2904 .await
2905 .unwrap();
2906 let into_id = into.id;
2907 let from_id = from.id;
2908
2909 let summary = rt
2910 .merge_note(
2911 &tok,
2912 into_id,
2913 from_id,
2914 EntityDedupMergePolicy::PreferInto,
2915 ContentMergeStrategy::Append,
2916 true,
2917 )
2918 .await
2919 .unwrap();
2920
2921 assert!(summary.dry_run);
2922
2923 let store = rt.notes(&tok).unwrap();
2924 let into_after = store.get_note(into_id).await.unwrap().unwrap();
2925 let from_after = store.get_note(from_id).await.unwrap().unwrap();
2926 assert_eq!(
2927 into_after.content, "Into content",
2928 "dry_run must not mutate into-note"
2929 );
2930 assert_eq!(
2931 from_after.content, "From content",
2932 "dry_run must not mutate from-note"
2933 );
2934 }
2935
2936 #[tokio::test]
2941 async fn merge_nameless_notes_fts_document_is_parity_correct() {
2942 use khive_storage::types::TextSearchRequest;
2943
2944 let rt = rt(); let tok = NamespaceToken::local();
2946
2947 let into = rt
2948 .create_note(
2949 &tok,
2950 "observation",
2951 None,
2952 "intosentinelzxq body",
2953 None,
2954 Some(serde_json::json!({"src": "into"})),
2955 vec![],
2956 )
2957 .await
2958 .expect("create into-note");
2959 let from = rt
2960 .create_note(
2961 &tok,
2962 "observation",
2963 None,
2964 "fromsentinelzxq body",
2965 None,
2966 None,
2967 vec![],
2968 )
2969 .await
2970 .expect("create from-note");
2971
2972 let into_id = into.id;
2973 let from_id = from.id;
2974
2975 rt.merge_note(
2976 &tok,
2977 into_id,
2978 from_id,
2979 EntityDedupMergePolicy::PreferInto,
2980 ContentMergeStrategy::Append,
2981 false,
2982 )
2983 .await
2984 .expect("merge_note must succeed");
2985
2986 let note_store = rt.notes(&tok).expect("note store");
2987 let merged_note = note_store
2988 .get_note(into_id)
2989 .await
2990 .expect("get_note")
2991 .expect("merged note must exist");
2992
2993 let expected = note_fts_document(&merged_note);
2994
2995 let fts = rt.text_for_notes(&tok).expect("FTS store");
2996 let stored = fts
2997 .get_document("local", into_id)
2998 .await
2999 .expect("get_document must not error")
3000 .expect("FTS document must exist after merge");
3001
3002 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3003 assert_eq!(
3004 stored.title, expected.title,
3005 "title (None for nameless note)"
3006 );
3007 assert_eq!(stored.body, expected.body, "body");
3008 assert_eq!(stored.namespace, expected.namespace, "namespace");
3009 assert_eq!(stored.kind, expected.kind, "kind");
3010
3011 assert!(
3012 stored.title.is_none(),
3013 "nameless merged note must have title=None in FTS (was NULL before fix)"
3014 );
3015
3016 let hits = fts
3018 .search(TextSearchRequest {
3019 query: "intosentinelzxq".to_string(),
3020 mode: khive_storage::types::TextQueryMode::Plain,
3021 filter: None,
3022 top_k: 10,
3023 snippet_chars: 0,
3024 })
3025 .await
3026 .expect("search");
3027 assert!(
3028 hits.iter().any(|h| h.subject_id == into_id),
3029 "merged note must be searchable by into-note content"
3030 );
3031 }
3032
3033 #[tokio::test]
3034 async fn update_edge_updates_properties() {
3035 use khive_storage::EdgeRelation;
3036 let rt = rt();
3037 let tok = NamespaceToken::local();
3038 let a = rt
3039 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3040 .await
3041 .unwrap();
3042 let b = rt
3043 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3044 .await
3045 .unwrap();
3046 let edge = rt
3047 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
3048 .await
3049 .unwrap();
3050 let edge_id: Uuid = edge.id.into();
3051
3052 let updated = rt
3053 .update_edge(
3054 &tok,
3055 edge_id,
3056 EdgePatch {
3057 properties: Some(serde_json::json!({"source": "manual"})),
3058 ..Default::default()
3059 },
3060 )
3061 .await
3062 .unwrap();
3063
3064 assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
3065 assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
3066 }
3067
3068 #[tokio::test]
3072 async fn merge_entity_survives_shared_edge_to_third_party() {
3073 use khive_storage::EdgeRelation;
3074 let rt = rt();
3075 let tok = NamespaceToken::local();
3076
3077 let a = rt
3080 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3081 .await
3082 .unwrap();
3083 let b = rt
3084 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3085 .await
3086 .unwrap();
3087 let shared = rt
3088 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
3089 .await
3090 .unwrap();
3091
3092 rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
3095 .await
3096 .unwrap();
3097 rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
3098 .await
3099 .unwrap();
3100
3101 let summary = rt
3102 .merge_entity(
3103 &tok,
3104 a.id,
3105 b.id,
3106 crate::EntityDedupMergePolicy::PreferInto,
3107 ContentMergeStrategy::Append,
3108 false,
3109 )
3110 .await
3111 .expect(
3112 "C1: merge must succeed even when both entities share an edge to a third party",
3113 );
3114
3115 assert_eq!(summary.kept_id, a.id);
3116 assert_eq!(summary.removed_id, b.id);
3117 let a_edges = rt
3122 .list_edges(
3123 &tok,
3124 crate::EdgeListFilter {
3125 source_id: Some(a.id),
3126 target_id: Some(shared.id),
3127 relations: vec![EdgeRelation::Extends],
3128 ..Default::default()
3129 },
3130 10,
3131 0,
3132 )
3133 .await
3134 .unwrap();
3135 assert_eq!(
3136 a_edges.len(),
3137 1,
3138 "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
3139 );
3140
3141 let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
3143 assert!(
3144 b_after.is_none(),
3145 "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
3146 );
3147 }
3148
3149 #[tokio::test]
3153 async fn merge_entity_cross_kind_rejected_at_runtime() {
3154 let rt = rt();
3155 let tok = NamespaceToken::local();
3156
3157 let concept = rt
3158 .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
3159 .await
3160 .unwrap();
3161 let project = rt
3162 .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
3163 .await
3164 .unwrap();
3165
3166 let err = rt
3167 .merge_entity(
3168 &tok,
3169 concept.id,
3170 project.id,
3171 crate::EntityDedupMergePolicy::PreferInto,
3172 ContentMergeStrategy::Append,
3173 false,
3174 )
3175 .await
3176 .expect_err("H2: cross-kind merge must be rejected by runtime");
3177 assert!(
3178 matches!(err, crate::RuntimeError::InvalidInput(_)),
3179 "H2: expected InvalidInput, got: {err:?}"
3180 );
3181
3182 let concept_after = rt.get_entity(&tok, concept.id).await;
3183 let project_after = rt.get_entity(&tok, project.id).await;
3184 assert!(
3185 concept_after.is_ok(),
3186 "H2: concept must remain live after rejected merge; got: {concept_after:?}"
3187 );
3188 assert!(
3189 project_after.is_ok(),
3190 "H2: project must remain live after rejected merge; got: {project_after:?}"
3191 );
3192 }
3193
3194 #[tokio::test]
3196 async fn merge_entity_same_kind_succeeds() {
3197 let rt = rt();
3198 let tok = NamespaceToken::local();
3199
3200 let c1 = rt
3201 .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
3202 .await
3203 .unwrap();
3204 let c2 = rt
3205 .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
3206 .await
3207 .unwrap();
3208
3209 let summary = rt
3210 .merge_entity(
3211 &tok,
3212 c1.id,
3213 c2.id,
3214 crate::EntityDedupMergePolicy::PreferInto,
3215 ContentMergeStrategy::Append,
3216 false,
3217 )
3218 .await
3219 .expect("same-kind merge must succeed");
3220 assert_eq!(summary.kept_id, c1.id);
3221 assert_eq!(summary.removed_id, c2.id);
3222
3223 let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
3224 assert!(c2_after.is_none(), "from_entity must be tombstoned");
3225 }
3226
3227 #[tokio::test]
3230 async fn merge_note_cross_namespace_either_id_returns_not_found() {
3231 use crate::error::RuntimeError;
3232 use crate::Namespace;
3233
3234 let rt = rt();
3235 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3236 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3237
3238 let into_a = rt
3239 .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
3240 .await
3241 .unwrap();
3242 let from_a = rt
3243 .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
3244 .await
3245 .unwrap();
3246 let note_b = rt
3247 .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
3248 .await
3249 .unwrap();
3250
3251 let foreign_into = rt
3253 .merge_note(
3254 &ns_a,
3255 note_b.id,
3256 from_a.id,
3257 EntityDedupMergePolicy::PreferInto,
3258 ContentMergeStrategy::Append,
3259 false,
3260 )
3261 .await;
3262 assert!(
3263 matches!(foreign_into, Err(RuntimeError::NotFound(_))),
3264 "foreign into_id must be denied before merge, got {foreign_into:?}"
3265 );
3266
3267 let foreign_from = rt
3269 .merge_note(
3270 &ns_a,
3271 into_a.id,
3272 note_b.id,
3273 EntityDedupMergePolicy::PreferInto,
3274 ContentMergeStrategy::Append,
3275 false,
3276 )
3277 .await;
3278 assert!(
3279 matches!(foreign_from, Err(RuntimeError::NotFound(_))),
3280 "foreign from_id must be denied before merge, got {foreign_from:?}"
3281 );
3282 }
3283
3284 #[tokio::test]
3287 async fn update_entity_cross_namespace_succeeds() {
3288 use crate::Namespace;
3289
3290 let rt = rt();
3291 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3292 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3293
3294 let entity = rt
3295 .create_entity(
3296 &ns_a,
3297 "concept",
3298 None,
3299 "Alpha",
3300 Some("original"),
3301 None,
3302 vec![],
3303 )
3304 .await
3305 .unwrap();
3306
3307 let result = rt
3308 .update_entity(
3309 &ns_b,
3310 entity.id,
3311 EntityPatch {
3312 name: Some("Updated".into()),
3313 ..Default::default()
3314 },
3315 )
3316 .await;
3317
3318 assert!(
3319 result.is_ok(),
3320 "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
3321 );
3322 assert_eq!(result.unwrap().name, "Updated");
3323 }
3324
3325 #[tokio::test]
3329 async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
3330 use crate::Namespace;
3331
3332 let rt = rt();
3333 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3334 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3335
3336 let into_a = rt
3337 .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
3338 .await
3339 .unwrap();
3340 let from_a = rt
3341 .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
3342 .await
3343 .unwrap();
3344 let foreign_b = rt
3345 .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
3346 .await
3347 .unwrap();
3348
3349 let foreign_into = rt
3351 .merge_entity(
3352 &ns_a,
3353 foreign_b.id,
3354 from_a.id,
3355 EntityDedupMergePolicy::PreferInto,
3356 ContentMergeStrategy::Append,
3357 false,
3358 )
3359 .await;
3360 assert!(
3361 foreign_into.is_err(),
3362 "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
3363 );
3364
3365 let foreign_from = rt
3367 .merge_entity(
3368 &ns_a,
3369 into_a.id,
3370 foreign_b.id,
3371 EntityDedupMergePolicy::PreferInto,
3372 ContentMergeStrategy::Append,
3373 false,
3374 )
3375 .await;
3376 assert!(
3377 foreign_from.is_err(),
3378 "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
3379 );
3380
3381 assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
3383 assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
3384 assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
3385 }
3386
3387 #[test]
3390 fn entity_fts_document_with_description() {
3391 let mut entity = Entity::new("local", "concept", "MyEntity");
3392 entity = entity.with_description("some description text");
3393 let doc = entity_fts_document(&entity);
3394 assert_eq!(doc.subject_id, entity.id);
3395 assert_eq!(doc.namespace, "local");
3396 assert_eq!(doc.title.as_deref(), Some("MyEntity"));
3397 assert_eq!(doc.body, "MyEntity some description text");
3398 assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
3399 }
3400
3401 #[test]
3402 fn entity_fts_document_without_description() {
3403 let entity = Entity::new("local", "concept", "NameOnly");
3404 let doc = entity_fts_document(&entity);
3405 assert_eq!(doc.title.as_deref(), Some("NameOnly"));
3406 assert_eq!(doc.body, "NameOnly");
3407 }
3408
3409 #[test]
3410 fn entity_fts_document_empty_description_uses_name_only() {
3411 let mut entity = Entity::new("local", "concept", "TitleOnly");
3412 entity = entity.with_description("");
3413 let doc = entity_fts_document(&entity);
3414 assert_eq!(
3415 doc.body, "TitleOnly",
3416 "empty description must not be appended"
3417 );
3418 }
3419
3420 #[tokio::test]
3424 async fn entity_fts_document_matches_runtime_create_path() {
3425 let rt = rt();
3426 let tok = NamespaceToken::local();
3427
3428 let entity = rt
3429 .create_entity(
3430 &tok,
3431 "concept",
3432 None,
3433 "CrossPathTitle",
3434 Some("cross path description body"),
3435 Some(serde_json::json!({"key": "val"})),
3436 vec!["tag1".to_string()],
3437 )
3438 .await
3439 .expect("create_entity");
3440
3441 let fts = rt.text(&tok).expect("FTS store");
3442 let stored = fts
3443 .get_document("local", entity.id)
3444 .await
3445 .expect("get_document")
3446 .expect("document must exist after create_entity");
3447
3448 let expected = entity_fts_document(&entity);
3449
3450 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3451 assert_eq!(stored.kind, expected.kind, "kind");
3452 assert_eq!(stored.title, expected.title, "title");
3453 assert_eq!(stored.body, expected.body, "body");
3454 assert_eq!(stored.namespace, expected.namespace, "namespace");
3455 }
3456
3457 #[tokio::test]
3460 async fn entity_fts_document_matches_runtime_update_path() {
3461 let rt = rt();
3462 let tok = NamespaceToken::local();
3463
3464 let entity = rt
3465 .create_entity(
3466 &tok,
3467 "concept",
3468 None,
3469 "OldName",
3470 Some("old desc"),
3471 None,
3472 vec![],
3473 )
3474 .await
3475 .expect("create_entity");
3476
3477 let updated = rt
3478 .update_entity(
3479 &tok,
3480 entity.id,
3481 EntityPatch {
3482 name: Some("NewName".to_string()),
3483 description: Some(Some("new desc".to_string())),
3484 ..Default::default()
3485 },
3486 )
3487 .await
3488 .expect("update_entity");
3489
3490 let fts = rt.text(&tok).expect("FTS store");
3491 let stored = fts
3492 .get_document("local", updated.id)
3493 .await
3494 .expect("get_document")
3495 .expect("document must exist after update_entity");
3496
3497 let expected = entity_fts_document(&updated);
3498
3499 assert_eq!(stored.title, expected.title, "title after update");
3500 assert_eq!(stored.body, expected.body, "body after update");
3501 }
3502
3503 struct MergeTestVecService {
3509 dims: usize,
3510 }
3511
3512 #[async_trait::async_trait]
3513 impl lattice_embed::EmbeddingService for MergeTestVecService {
3514 async fn embed(
3515 &self,
3516 texts: &[String],
3517 _model: lattice_embed::EmbeddingModel,
3518 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
3519 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
3520 }
3521
3522 fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
3523 true
3524 }
3525
3526 fn name(&self) -> &'static str {
3527 "merge-test-const-vec"
3528 }
3529 }
3530
3531 struct MergeTestVecProvider {
3532 provider_name: String,
3533 dims: usize,
3534 }
3535
3536 impl MergeTestVecProvider {
3537 fn new(name: &str, dims: usize) -> Self {
3538 Self {
3539 provider_name: name.to_owned(),
3540 dims,
3541 }
3542 }
3543 }
3544
3545 #[async_trait::async_trait]
3546 impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
3547 fn name(&self) -> &str {
3548 &self.provider_name
3549 }
3550
3551 fn dimensions(&self) -> usize {
3552 self.dims
3553 }
3554
3555 async fn build(
3556 &self,
3557 ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
3558 {
3559 Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
3560 }
3561 }
3562
3563 #[tokio::test]
3569 async fn merge_entity_clears_vectors_from_all_registered_models() {
3570 const DIMS: usize = 4;
3571 let rt = KhiveRuntime::memory().unwrap();
3572 rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
3573 rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
3574
3575 let ns_str = "merge-entity-vec-cleanup";
3576 let ns = crate::Namespace::parse(ns_str).unwrap();
3577 let tok = NamespaceToken::for_namespace(ns);
3578
3579 let into_e = rt
3580 .create_entity(
3581 &tok,
3582 "concept",
3583 None,
3584 "IntoVecEntity",
3585 Some("desc a"),
3586 None,
3587 vec![],
3588 )
3589 .await
3590 .expect("create into");
3591 let from_e = rt
3592 .create_entity(
3593 &tok,
3594 "concept",
3595 None,
3596 "FromVecEntity",
3597 Some("desc b"),
3598 None,
3599 vec![],
3600 )
3601 .await
3602 .expect("create from");
3603
3604 let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
3606 let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
3607 use khive_storage::types::VectorSearchRequest;
3608 let query = vec![1.0_f32; DIMS];
3609 let pre_a = vs_a
3610 .search(VectorSearchRequest {
3611 query_vectors: vec![query.clone()],
3612 top_k: 100,
3613 namespace: Some(ns_str.to_string()),
3614 kind: Some(khive_types::SubstrateKind::Entity),
3615 embedding_model: Some("merge-vec-a".to_string()),
3616 filter: None,
3617 backend_hints: None,
3618 })
3619 .await
3620 .unwrap();
3621 assert!(
3622 pre_a.iter().any(|h| h.subject_id == into_e.id)
3623 && pre_a.iter().any(|h| h.subject_id == from_e.id),
3624 "both entities must be in model-a before merge; got {pre_a:?}"
3625 );
3626
3627 let pre_b = vs_b
3630 .search(VectorSearchRequest {
3631 query_vectors: vec![query.clone()],
3632 top_k: 100,
3633 namespace: Some(ns_str.to_string()),
3634 kind: Some(khive_types::SubstrateKind::Entity),
3635 embedding_model: Some("merge-vec-b".to_string()),
3636 filter: None,
3637 backend_hints: None,
3638 })
3639 .await
3640 .unwrap();
3641 assert!(
3642 pre_b.iter().any(|h| h.subject_id == into_e.id)
3643 && pre_b.iter().any(|h| h.subject_id == from_e.id),
3644 "both entities must be in model-b before merge; got {pre_b:?}"
3645 );
3646
3647 rt.merge_entity(
3648 &tok,
3649 into_e.id,
3650 from_e.id,
3651 EntityDedupMergePolicy::PreferInto,
3652 ContentMergeStrategy::Append,
3653 false,
3654 )
3655 .await
3656 .expect("merge_entity");
3657
3658 let post_a = vs_a
3659 .search(VectorSearchRequest {
3660 query_vectors: vec![query.clone()],
3661 top_k: 100,
3662 namespace: Some(ns_str.to_string()),
3663 kind: Some(khive_types::SubstrateKind::Entity),
3664 embedding_model: Some("merge-vec-a".to_string()),
3665 filter: None,
3666 backend_hints: None,
3667 })
3668 .await
3669 .unwrap();
3670 let from_ids_a: Vec<_> = post_a
3671 .iter()
3672 .filter(|h| h.subject_id == from_e.id)
3673 .collect();
3674 assert!(
3675 from_ids_a.is_empty(),
3676 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3677 );
3678
3679 let post_b = vs_b
3680 .search(VectorSearchRequest {
3681 query_vectors: vec![query],
3682 top_k: 100,
3683 namespace: Some(ns_str.to_string()),
3684 kind: Some(khive_types::SubstrateKind::Entity),
3685 embedding_model: Some("merge-vec-b".to_string()),
3686 filter: None,
3687 backend_hints: None,
3688 })
3689 .await
3690 .unwrap();
3691 let from_ids_b: Vec<_> = post_b
3692 .iter()
3693 .filter(|h| h.subject_id == from_e.id)
3694 .collect();
3695 assert!(
3696 from_ids_b.is_empty(),
3697 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3698 );
3699 }
3700
3701 #[tokio::test]
3707 async fn merge_note_clears_vectors_from_all_registered_models() {
3708 const DIMS: usize = 4;
3709 let rt = KhiveRuntime::memory().unwrap();
3710 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
3711 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
3712
3713 let ns_str = "merge-note-vec-cleanup";
3714 let ns = crate::Namespace::parse(ns_str).unwrap();
3715 let tok = NamespaceToken::for_namespace(ns);
3716
3717 let into_n = rt
3718 .create_note(
3719 &tok,
3720 "observation",
3721 None,
3722 "IntoVecNote content",
3723 None,
3724 None,
3725 vec![],
3726 )
3727 .await
3728 .expect("create into note");
3729 let from_n = rt
3730 .create_note(
3731 &tok,
3732 "observation",
3733 None,
3734 "FromVecNote content",
3735 None,
3736 None,
3737 vec![],
3738 )
3739 .await
3740 .expect("create from note");
3741
3742 let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
3743 let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
3744 use khive_storage::types::VectorSearchRequest;
3745 let query = vec![1.0_f32; DIMS];
3746
3747 let pre_a = vs_a
3748 .search(VectorSearchRequest {
3749 query_vectors: vec![query.clone()],
3750 top_k: 100,
3751 namespace: Some(ns_str.to_string()),
3752 kind: Some(khive_types::SubstrateKind::Note),
3753 embedding_model: Some("merge-note-vec-a".to_string()),
3754 filter: None,
3755 backend_hints: None,
3756 })
3757 .await
3758 .unwrap();
3759 assert!(
3760 pre_a.iter().any(|h| h.subject_id == into_n.id)
3761 && pre_a.iter().any(|h| h.subject_id == from_n.id),
3762 "both notes must be in model-a before merge; got {pre_a:?}"
3763 );
3764
3765 let pre_b = vs_b
3768 .search(VectorSearchRequest {
3769 query_vectors: vec![query.clone()],
3770 top_k: 100,
3771 namespace: Some(ns_str.to_string()),
3772 kind: Some(khive_types::SubstrateKind::Note),
3773 embedding_model: Some("merge-note-vec-b".to_string()),
3774 filter: None,
3775 backend_hints: None,
3776 })
3777 .await
3778 .unwrap();
3779 assert!(
3780 pre_b.iter().any(|h| h.subject_id == into_n.id)
3781 && pre_b.iter().any(|h| h.subject_id == from_n.id),
3782 "both notes must be in model-b before merge; got {pre_b:?}"
3783 );
3784
3785 rt.merge_note(
3786 &tok,
3787 into_n.id,
3788 from_n.id,
3789 EntityDedupMergePolicy::PreferInto,
3790 ContentMergeStrategy::PreferInto,
3791 false,
3792 )
3793 .await
3794 .expect("merge_note");
3795
3796 let post_a = vs_a
3797 .search(VectorSearchRequest {
3798 query_vectors: vec![query.clone()],
3799 top_k: 100,
3800 namespace: Some(ns_str.to_string()),
3801 kind: Some(khive_types::SubstrateKind::Note),
3802 embedding_model: Some("merge-note-vec-a".to_string()),
3803 filter: None,
3804 backend_hints: None,
3805 })
3806 .await
3807 .unwrap();
3808 let from_ids_a: Vec<_> = post_a
3809 .iter()
3810 .filter(|h| h.subject_id == from_n.id)
3811 .collect();
3812 assert!(
3813 from_ids_a.is_empty(),
3814 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3815 );
3816
3817 let post_b = vs_b
3818 .search(VectorSearchRequest {
3819 query_vectors: vec![query],
3820 top_k: 100,
3821 namespace: Some(ns_str.to_string()),
3822 kind: Some(khive_types::SubstrateKind::Note),
3823 embedding_model: Some("merge-note-vec-b".to_string()),
3824 filter: None,
3825 backend_hints: None,
3826 })
3827 .await
3828 .unwrap();
3829 let from_ids_b: Vec<_> = post_b
3830 .iter()
3831 .filter(|h| h.subject_id == from_n.id)
3832 .collect();
3833 assert!(
3834 from_ids_b.is_empty(),
3835 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3836 );
3837 }
3838
3839 #[tokio::test]
3842 async fn entity_fts_document_matches_runtime_merge_path() {
3843 let rt = rt();
3844 let tok = NamespaceToken::local();
3845
3846 let into_e = rt
3847 .create_entity(
3848 &tok,
3849 "concept",
3850 None,
3851 "IntoEntity",
3852 Some("into desc"),
3853 None,
3854 vec![],
3855 )
3856 .await
3857 .expect("create into");
3858 let from_e = rt
3859 .create_entity(
3860 &tok,
3861 "concept",
3862 None,
3863 "FromEntity",
3864 Some("from desc"),
3865 None,
3866 vec![],
3867 )
3868 .await
3869 .expect("create from");
3870
3871 let summary = rt
3872 .merge_entity(
3873 &tok,
3874 into_e.id,
3875 from_e.id,
3876 EntityDedupMergePolicy::PreferInto,
3877 ContentMergeStrategy::Append,
3878 false,
3879 )
3880 .await
3881 .expect("merge_entity");
3882
3883 let kept = rt
3884 .get_entity(&tok, summary.kept_id)
3885 .await
3886 .expect("get kept");
3887
3888 let fts = rt.text(&tok).expect("FTS store");
3889 let stored = fts
3890 .get_document("local", kept.id)
3891 .await
3892 .expect("get_document")
3893 .expect("FTS document must exist for kept entity after merge");
3894
3895 let expected = entity_fts_document(&kept);
3896
3897 assert_eq!(stored.title, expected.title, "title after merge");
3898 assert_eq!(stored.body, expected.body, "body after merge");
3899 }
3900}