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, content_ref \
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 let content_ref: Option<String> = row.get(13)?;
926
927 if ns != namespace {
928 return Err(SqliteError::InvalidData(format!(
929 "entity {id} belongs to namespace '{ns}', not '{namespace}'"
930 )));
931 }
932
933 let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
934 let properties: Option<Value> = properties_str
935 .map(|s| {
936 serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
937 })
938 .transpose()?;
939 let tags: Vec<String> =
940 serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
941 let merged_into = merged_into_str
942 .as_deref()
943 .map(Uuid::parse_str)
944 .transpose()
945 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
946 let merge_event_id = merge_event_id_str
947 .as_deref()
948 .map(Uuid::parse_str)
949 .transpose()
950 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
951
952 Ok(Entity {
953 id: entity_id,
954 namespace: ns,
955 kind,
956 entity_type,
957 name,
958 description,
959 properties,
960 tags,
961 created_at,
962 updated_at,
963 deleted_at,
964 merged_into,
965 merge_event_id,
966 content_ref,
967 })
968}
969
970#[allow(clippy::too_many_arguments)]
981fn merge_entity_sql(
982 conn: &rusqlite::Connection,
983 namespace: String,
984 fts_table: String,
985 vec_tables: Vec<String>,
986 into_id: Uuid,
987 from_id: Uuid,
988 strategy: EntityDedupMergePolicy,
989 content_strategy: ContentMergeStrategy,
990 dry_run: bool,
991) -> Result<(MergeSummary, Entity), SqliteError> {
992 let into_entity = read_merge_entity(conn, into_id, &namespace)?;
993 let from_entity = read_merge_entity(conn, from_id, &namespace)?;
994
995 let parse_id =
997 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
998
999 let from_str = from_id.to_string();
1000
1001 let mut outbound: Vec<EdgeRow> = Vec::new();
1002 {
1003 let mut stmt = conn.prepare(
1004 "SELECT id, source_id, target_id, relation, weight, created_at, \
1005 updated_at, deleted_at, target_backend, metadata \
1006 FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1007 )?;
1008 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1009 while let Some(row) = rows.next()? {
1010 outbound.push(EdgeRow {
1011 id: parse_id(row.get(0)?)?,
1012 source_id: parse_id(row.get(1)?)?,
1013 target_id: parse_id(row.get(2)?)?,
1014 relation: row.get(3)?,
1015 weight: row.get(4)?,
1016 created_at: row.get(5)?,
1017 updated_at: row.get(6)?,
1018 deleted_at: row.get(7)?,
1019 target_backend: row.get(8)?,
1020 metadata: row.get(9)?,
1021 });
1022 }
1023 }
1024
1025 let mut inbound: Vec<EdgeRow> = Vec::new();
1026 {
1027 let mut stmt = conn.prepare(
1028 "SELECT id, source_id, target_id, relation, weight, created_at, \
1029 updated_at, deleted_at, target_backend, metadata \
1030 FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1031 )?;
1032 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1033 while let Some(row) = rows.next()? {
1034 inbound.push(EdgeRow {
1035 id: parse_id(row.get(0)?)?,
1036 source_id: parse_id(row.get(1)?)?,
1037 target_id: parse_id(row.get(2)?)?,
1038 relation: row.get(3)?,
1039 weight: row.get(4)?,
1040 created_at: row.get(5)?,
1041 updated_at: row.get(6)?,
1042 deleted_at: row.get(7)?,
1043 target_backend: row.get(8)?,
1044 metadata: row.get(9)?,
1045 });
1046 }
1047 }
1048
1049 let mut seen: HashSet<Uuid> = HashSet::new();
1051 let mut all_edges: Vec<EdgeRow> = Vec::new();
1052 for edge in outbound.into_iter().chain(inbound) {
1053 if seen.insert(edge.id) {
1054 all_edges.push(edge);
1055 }
1056 }
1057
1058 let (merged_props, properties_merged) =
1060 merge_properties(&into_entity.properties, &from_entity.properties, strategy);
1061 let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
1062 let (merged_description, content_appended) = match content_strategy {
1063 ContentMergeStrategy::Append => {
1064 let into_desc = into_entity.description.as_deref().unwrap_or("");
1065 let from_desc = from_entity.description.as_deref().unwrap_or("");
1066 if from_desc.is_empty() {
1067 (into_entity.description.clone(), false)
1068 } else if into_desc.is_empty() {
1069 (from_entity.description.clone(), true)
1070 } else {
1071 (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
1072 }
1073 }
1074 ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
1078 ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
1079 };
1080 let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
1081
1082 let now = chrono::Utc::now().timestamp_micros();
1083 let into_str = into_id.to_string();
1084 let props_str = merged_props
1085 .as_ref()
1086 .map(|v| serde_json::to_string(v).unwrap_or_default());
1087 let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
1088
1089 let mut edges_rewired = 0usize;
1092 for edge in all_edges {
1093 let raw_src = if edge.source_id == from_id {
1094 into_id
1095 } else {
1096 edge.source_id
1097 };
1098 let raw_tgt = if edge.target_id == from_id {
1099 into_id
1100 } else {
1101 edge.target_id
1102 };
1103 let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1106 Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1107 Err(_) => (raw_src, raw_tgt),
1108 };
1109
1110 if new_src == new_tgt {
1111 if !dry_run {
1112 conn.execute(
1113 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1114 rusqlite::params![&namespace, edge.id.to_string()],
1115 )?;
1116 }
1117 continue;
1118 }
1119
1120 if dry_run {
1121 edges_rewired += 1;
1123 continue;
1124 }
1125
1126 let now_ts = chrono::Utc::now().timestamp();
1127 let conflict_id: Option<String> = {
1133 let conflict_src = new_src.to_string();
1134 let conflict_tgt = new_tgt.to_string();
1135 conn.query_row(
1136 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1137 rusqlite::params![
1138 &namespace,
1139 &conflict_src,
1140 &conflict_tgt,
1141 &edge.relation,
1142 edge.id.to_string(),
1143 ],
1144 |row| row.get(0),
1145 )
1146 .optional()
1147 .map_err(SqliteError::Rusqlite)?
1148 };
1149
1150 let changed = if let Some(existing_id) = conflict_id {
1151 conn.execute(
1153 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1154 rusqlite::params![&namespace, edge.id.to_string()],
1155 )?;
1156 conn.execute(
1157 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1158 rusqlite::params![
1159 edge.weight,
1160 now_ts,
1161 edge.target_backend,
1162 edge.metadata,
1163 &namespace,
1164 &existing_id,
1165 ],
1166 )?
1167 } else {
1168 conn.execute(
1169 "UPDATE graph_edges SET \
1170 source_id = ?1, target_id = ?2, updated_at = ?3 \
1171 WHERE namespace = ?4 AND id = ?5",
1172 rusqlite::params![
1173 new_src.to_string(),
1174 new_tgt.to_string(),
1175 now_ts,
1176 &namespace,
1177 edge.id.to_string(),
1178 ],
1179 )?
1180 };
1181 if changed > 0 {
1182 edges_rewired += 1;
1183 }
1184 }
1185
1186 if !dry_run {
1187 conn.execute(
1188 "INSERT OR REPLACE INTO entities \
1189 (id, namespace, kind, name, description, properties, tags, \
1190 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
1191 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1192 rusqlite::params![
1193 &into_str,
1194 &namespace,
1195 &into_entity.kind,
1196 &merged_name,
1197 &merged_description,
1198 &props_str,
1199 &tags_json,
1200 into_entity.created_at,
1201 now,
1202 into_entity.deleted_at,
1203 Option::<String>::None,
1204 Option::<String>::None,
1205 ],
1206 )?;
1207
1208 let fts_body = match &merged_description {
1212 Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
1213 _ => merged_name.clone(),
1214 };
1215 let kind_str = SubstrateKind::Entity.to_string();
1216
1217 conn.execute(
1218 &format!(
1219 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1220 fts_table
1221 ),
1222 rusqlite::params![&namespace, &into_str],
1223 )?;
1224 conn.execute(
1225 &format!(
1226 "INSERT INTO {} \
1227 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1228 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1229 fts_table
1230 ),
1231 rusqlite::params![
1232 &into_str,
1233 &kind_str,
1234 &merged_name,
1235 &fts_body,
1236 &tags_json,
1237 &namespace,
1238 &props_str,
1239 now,
1240 ],
1241 )?;
1242
1243 conn.execute(
1244 &format!(
1245 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1246 fts_table
1247 ),
1248 rusqlite::params![&namespace, &from_str],
1249 )?;
1250
1251 khive_db::stores::vectors::delete_subject_from_vector_tables(
1252 conn,
1253 &vec_tables,
1254 from_id,
1255 &namespace,
1256 )?;
1257
1258 let merge_event_id = Uuid::new_v4();
1259 conn.execute(
1260 "UPDATE entities \
1261 SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1 \
1262 WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
1263 rusqlite::params![
1264 now,
1265 into_str,
1266 merge_event_id.to_string(),
1267 &namespace,
1268 &from_str,
1269 ],
1270 )?;
1271 }
1272
1273 let updated_entity = Entity {
1274 id: into_id,
1275 namespace,
1276 kind: into_entity.kind,
1277 entity_type: into_entity.entity_type,
1278 name: merged_name,
1279 description: merged_description,
1280 properties: merged_props,
1281 tags: merged_tags,
1282 created_at: into_entity.created_at,
1283 updated_at: now,
1284 deleted_at: into_entity.deleted_at,
1285 merged_into: None,
1286 merge_event_id: None,
1287 content_ref: into_entity.content_ref,
1288 };
1289
1290 Ok((
1291 MergeSummary {
1292 kept_id: into_id,
1293 removed_id: from_id,
1294 edges_rewired,
1295 properties_merged,
1296 tags_unioned,
1297 content_appended,
1298 dry_run,
1299 },
1300 updated_entity,
1301 ))
1302}
1303
1304fn read_merge_note(
1310 conn: &rusqlite::Connection,
1311 id: Uuid,
1312 namespace: &str,
1313) -> Result<khive_storage::note::Note, SqliteError> {
1314 use khive_storage::note::Note;
1315 let id_str = id.to_string();
1316 let mut stmt = conn.prepare(
1317 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1318 expires_at, properties, created_at, updated_at, deleted_at \
1319 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1320 )?;
1321 let mut rows = stmt.query(rusqlite::params![id_str])?;
1322 let row = rows
1323 .next()?
1324 .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
1325
1326 let id_s: String = row.get(0)?;
1327 let ns: String = row.get(1)?;
1328 let kind: String = row.get(2)?;
1329 let status: String = row.get(3)?;
1330 let name: Option<String> = row.get(4)?;
1331 let content: String = row.get(5)?;
1332 let salience: Option<f64> = row.get(6)?;
1333 let decay_factor: Option<f64> = row.get(7)?;
1334 let expires_at: Option<i64> = row.get(8)?;
1335 let properties_str: Option<String> = row.get(9)?;
1336 let created_at: i64 = row.get(10)?;
1337 let updated_at: i64 = row.get(11)?;
1338 let deleted_at: Option<i64> = row.get(12)?;
1339
1340 if ns != namespace {
1341 return Err(SqliteError::InvalidData(format!(
1342 "note {id} belongs to namespace '{ns}', not '{namespace}'"
1343 )));
1344 }
1345
1346 let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
1347 let properties: Option<serde_json::Value> = properties_str
1348 .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
1349 .transpose()?;
1350
1351 Ok(Note {
1352 id: note_id,
1353 namespace: ns,
1354 kind,
1355 status,
1356 name,
1357 content,
1358 salience,
1359 decay_factor,
1360 expires_at,
1361 properties,
1362 created_at,
1363 updated_at,
1364 deleted_at,
1365 })
1366}
1367
1368fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
1369 match (a, b) {
1370 (Some(x), Some(y)) => Some(x.max(y)),
1371 (Some(x), None) => Some(x),
1372 (None, Some(y)) => Some(y),
1373 (None, None) => None,
1374 }
1375}
1376
1377fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
1378 use serde_json::{json, Map};
1379 let mut obj: Map<String, Value> = match props {
1380 Some(Value::Object(m)) => m,
1381 Some(other) => {
1382 let mut m = Map::new();
1383 m.insert("_value".into(), other);
1384 m
1385 }
1386 None => Map::new(),
1387 };
1388 let history = obj
1389 .entry("_merge_history".to_string())
1390 .or_insert_with(|| json!([]));
1391 if let Value::Array(arr) = history {
1392 arr.push(entry);
1393 }
1394 Ok(Some(Value::Object(obj)))
1395}
1396
1397#[allow(clippy::too_many_arguments)]
1407fn merge_note_sql(
1408 conn: &rusqlite::Connection,
1409 namespace: String,
1410 fts_table: String,
1411 vec_tables: Vec<String>,
1412 into_id: Uuid,
1413 from_id: Uuid,
1414 strategy: EntityDedupMergePolicy,
1415 content_strategy: ContentMergeStrategy,
1416 dry_run: bool,
1417) -> Result<(MergeSummary, khive_storage::note::Note), SqliteError> {
1418 let into_note = read_merge_note(conn, into_id, &namespace)?;
1419 let from_note = read_merge_note(conn, from_id, &namespace)?;
1420
1421 if into_note.kind != from_note.kind {
1422 return Err(SqliteError::InvalidData(format!(
1423 "cannot merge notes of different kinds: {} vs {}",
1424 into_note.kind, from_note.kind
1425 )));
1426 }
1427
1428 let now = chrono::Utc::now().timestamp_micros();
1429 let into_str = into_id.to_string();
1430 let from_str = from_id.to_string();
1431
1432 let parse_id =
1434 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
1435
1436 let mut outbound: Vec<EdgeRow> = Vec::new();
1437 {
1438 let mut stmt = conn.prepare(
1439 "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1440 FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1441 )?;
1442 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1443 while let Some(row) = rows.next()? {
1444 outbound.push(EdgeRow {
1445 id: parse_id(row.get(0)?)?,
1446 source_id: parse_id(row.get(1)?)?,
1447 target_id: parse_id(row.get(2)?)?,
1448 relation: row.get(3)?,
1449 weight: row.get(4)?,
1450 created_at: row.get(5)?,
1451 updated_at: row.get(6)?,
1452 deleted_at: row.get(7)?,
1453 target_backend: row.get(8)?,
1454 metadata: row.get(9)?,
1455 });
1456 }
1457 }
1458 let mut inbound: Vec<EdgeRow> = Vec::new();
1459 {
1460 let mut stmt = conn.prepare(
1461 "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1462 FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1463 )?;
1464 let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1465 while let Some(row) = rows.next()? {
1466 inbound.push(EdgeRow {
1467 id: parse_id(row.get(0)?)?,
1468 source_id: parse_id(row.get(1)?)?,
1469 target_id: parse_id(row.get(2)?)?,
1470 relation: row.get(3)?,
1471 weight: row.get(4)?,
1472 created_at: row.get(5)?,
1473 updated_at: row.get(6)?,
1474 deleted_at: row.get(7)?,
1475 target_backend: row.get(8)?,
1476 metadata: row.get(9)?,
1477 });
1478 }
1479 }
1480 let mut seen: HashSet<Uuid> = HashSet::new();
1481 let mut all_edges: Vec<EdgeRow> = Vec::new();
1482 for edge in outbound.into_iter().chain(inbound) {
1483 if seen.insert(edge.id) {
1484 all_edges.push(edge);
1485 }
1486 }
1487
1488 let (merged_content, content_appended) = match content_strategy {
1490 ContentMergeStrategy::Append => {
1491 if from_note.content.is_empty() {
1492 (into_note.content.clone(), false)
1493 } else {
1494 (
1495 format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
1496 true,
1497 )
1498 }
1499 }
1500 ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
1501 ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
1502 };
1503
1504 let merged_name = match strategy {
1505 EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
1506 _ => into_note.name.clone().or(from_note.name.clone()),
1507 };
1508
1509 let (merged_props, properties_merged) =
1510 merge_properties(&into_note.properties, &from_note.properties, strategy);
1511
1512 let merge_history_entry = serde_json::json!({
1513 "merged_from": from_id.to_string(),
1514 "merged_at": now,
1515 "strategy": format!("{:?}", strategy),
1516 "content_strategy": format!("{:?}", content_strategy),
1517 });
1518 let merged_props = append_merge_history(merged_props, merge_history_entry)?;
1519
1520 let merged_salience = max_option_f64(into_note.salience, from_note.salience);
1521 let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
1522 (Some(a), Some(b)) => Some(a.max(b)),
1523 (Some(a), None) => Some(a),
1524 (None, Some(b)) => Some(b),
1525 (None, None) => None,
1526 };
1527
1528 let props_str = merged_props
1529 .as_ref()
1530 .map(|v| serde_json::to_string(v).unwrap_or_default());
1531
1532 let mut edges_rewired = 0usize;
1533 if !dry_run {
1534 for edge in all_edges {
1535 let raw_src = if edge.source_id == from_id {
1536 into_id
1537 } else {
1538 edge.source_id
1539 };
1540 let raw_tgt = if edge.target_id == from_id {
1541 into_id
1542 } else {
1543 edge.target_id
1544 };
1545 let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1547 Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1548 Err(_) => (raw_src, raw_tgt),
1549 };
1550 if new_src == new_tgt {
1551 conn.execute(
1552 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1553 rusqlite::params![&namespace, edge.id.to_string()],
1554 )?;
1555 continue;
1556 }
1557 let now_ts = chrono::Utc::now().timestamp();
1558 let conflict_id: Option<String> = {
1559 let conflict_src = new_src.to_string();
1560 let conflict_tgt = new_tgt.to_string();
1561 conn.query_row(
1562 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1563 rusqlite::params![
1564 &namespace,
1565 &conflict_src,
1566 &conflict_tgt,
1567 &edge.relation,
1568 edge.id.to_string(),
1569 ],
1570 |row| row.get(0),
1571 )
1572 .optional()
1573 .map_err(SqliteError::Rusqlite)?
1574 };
1575
1576 let changed = if let Some(existing_id) = conflict_id {
1577 conn.execute(
1578 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1579 rusqlite::params![&namespace, edge.id.to_string()],
1580 )?;
1581 conn.execute(
1582 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1583 rusqlite::params![
1584 edge.weight,
1585 now_ts,
1586 edge.target_backend,
1587 edge.metadata,
1588 &namespace,
1589 &existing_id,
1590 ],
1591 )?
1592 } else {
1593 conn.execute(
1594 "UPDATE graph_edges SET \
1595 source_id = ?1, target_id = ?2, updated_at = ?3 \
1596 WHERE namespace = ?4 AND id = ?5",
1597 rusqlite::params![
1598 new_src.to_string(),
1599 new_tgt.to_string(),
1600 now_ts,
1601 &namespace,
1602 edge.id.to_string(),
1603 ],
1604 )?
1605 };
1606 if changed > 0 {
1607 edges_rewired += 1;
1608 }
1609 }
1610
1611 conn.execute(
1612 "INSERT OR REPLACE INTO notes \
1613 (id, namespace, kind, status, name, content, salience, decay_factor, \
1614 expires_at, properties, created_at, updated_at, deleted_at) \
1615 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1616 rusqlite::params![
1617 &into_str,
1618 &namespace,
1619 &into_note.kind,
1620 &into_note.status,
1621 &merged_name,
1622 &merged_content,
1623 merged_salience,
1624 into_note.decay_factor,
1625 merged_expires_at,
1626 &props_str,
1627 into_note.created_at,
1628 now,
1629 into_note.deleted_at,
1630 ],
1631 )?;
1632
1633 conn.execute(
1634 &format!(
1635 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1636 fts_table
1637 ),
1638 rusqlite::params![&namespace, &into_str],
1639 )?;
1640 let fts_merged = {
1645 let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
1646 merged_note.id = into_id;
1647 merged_note.name = merged_name.clone();
1648 merged_note.properties = merged_props.clone();
1649 merged_note.updated_at = now;
1650 note_fts_scalars(&merged_note)
1651 };
1652 conn.execute(
1653 &format!(
1654 "INSERT INTO {} \
1655 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1656 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1657 fts_table
1658 ),
1659 rusqlite::params![
1660 &into_str,
1661 SubstrateKind::Note.to_string(),
1662 &fts_merged.title,
1663 &fts_merged.body,
1664 &fts_merged.tags,
1665 &namespace,
1666 &fts_merged.metadata,
1667 fts_merged.updated_at_micros,
1668 ],
1669 )?;
1670
1671 conn.execute(
1672 &format!(
1673 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1674 fts_table
1675 ),
1676 rusqlite::params![&namespace, &from_str],
1677 )?;
1678
1679 khive_db::stores::vectors::delete_subject_from_vector_tables(
1680 conn,
1681 &vec_tables,
1682 from_id,
1683 &namespace,
1684 )?;
1685
1686 conn.execute(
1687 "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
1688 WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
1689 rusqlite::params![now, &namespace, &from_str],
1690 )?;
1691 }
1692
1693 let updated_note = khive_storage::note::Note {
1694 id: into_id,
1695 namespace: namespace.clone(),
1696 kind: into_note.kind.clone(),
1697 status: into_note.status.clone(),
1698 name: merged_name,
1699 content: merged_content,
1700 salience: merged_salience,
1701 decay_factor: into_note.decay_factor,
1702 expires_at: merged_expires_at,
1703 properties: merged_props,
1704 created_at: into_note.created_at,
1705 updated_at: now,
1706 deleted_at: into_note.deleted_at,
1707 };
1708
1709 Ok((
1710 MergeSummary {
1711 kept_id: into_id,
1712 removed_id: from_id,
1713 edges_rewired,
1714 properties_merged,
1715 tags_unioned: 0,
1716 content_appended,
1717 dry_run,
1718 },
1719 updated_note,
1720 ))
1721}
1722
1723pub(crate) fn merge_string_field(
1730 into: &str,
1731 from: &str,
1732 strategy: EntityDedupMergePolicy,
1733) -> String {
1734 match strategy {
1735 EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
1736 EntityDedupMergePolicy::PreferFrom => from.to_string(),
1737 }
1738}
1739
1740pub(crate) fn merge_properties(
1745 into: &Option<Value>,
1746 from: &Option<Value>,
1747 strategy: EntityDedupMergePolicy,
1748) -> (Option<Value>, usize) {
1749 match (into, from) {
1750 (None, None) => (None, 0),
1751 (Some(a), None) => (Some(a.clone()), 0),
1752 (None, Some(b)) => {
1753 let count = if let Value::Object(m) = b { m.len() } else { 1 };
1754 (Some(b.clone()), count)
1755 }
1756 (Some(into_val), Some(from_val)) => {
1757 let (merged, added) = merge_json(into_val, from_val, strategy);
1758 (Some(merged), added)
1759 }
1760 }
1761}
1762
1763fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
1765 match (into, from, strategy) {
1766 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
1767 let mut result = a.clone();
1768 let mut added = 0usize;
1769 for (k, v_from) in b {
1770 if let Some(v_into) = a.get(k) {
1771 let (merged, sub_added) =
1772 merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
1773 result.insert(k.clone(), merged);
1774 added += sub_added;
1775 } else {
1776 result.insert(k.clone(), v_from.clone());
1777 added += 1;
1778 }
1779 }
1780 (Value::Object(result), added)
1781 }
1782 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
1783 let mut result = a.clone();
1784 let mut added = 0usize;
1785 for (k, v) in b {
1786 if !a.contains_key(k) {
1787 result.insert(k.clone(), v.clone());
1788 added += 1;
1789 }
1790 }
1791 (Value::Object(result), added)
1792 }
1793 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
1794 let mut result = a.clone();
1795 let mut added = 0usize;
1796 for (k, v) in b {
1797 result.insert(k.clone(), v.clone());
1798 if !a.contains_key(k) {
1799 added += 1;
1800 }
1801 }
1802 (Value::Object(result), added)
1803 }
1804 (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
1806 _ => (into.clone(), 0),
1807 }
1808}
1809
1810pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
1813 let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
1814 let mut result: Vec<String> = into.to_vec();
1815 let mut added = 0usize;
1816 for tag in from {
1817 if seen.insert(tag.as_str()) {
1818 result.push(tag.clone());
1819 added += 1;
1820 }
1821 }
1822 (result, added)
1823}
1824
1825#[cfg(test)]
1834mod tests {
1835 use super::*;
1836 use crate::runtime::{KhiveRuntime, NamespaceToken};
1837 use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
1838
1839 fn rt() -> KhiveRuntime {
1840 KhiveRuntime::memory().unwrap()
1841 }
1842
1843 async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
1845 let ns = token.namespace().as_str().to_string();
1846 rt.text(token)
1847 .unwrap()
1848 .search(TextSearchRequest {
1849 query: query.to_string(),
1850 mode: TextQueryMode::Plain,
1851 filter: Some(TextFilter {
1852 namespaces: vec![ns],
1853 ..Default::default()
1854 }),
1855 top_k: 50,
1856 snippet_chars: 100,
1857 })
1858 .await
1859 .unwrap()
1860 .into_iter()
1861 .map(|h| h.subject_id)
1862 .collect()
1863 }
1864
1865 #[tokio::test]
1866 async fn update_entity_patch_changes_only_specified_fields() {
1867 let rt = rt();
1868 let tok = NamespaceToken::local();
1869 let entity = rt
1870 .create_entity(
1871 &tok,
1872 "concept",
1873 None,
1874 "OriginalName",
1875 Some("orig desc"),
1876 Some(serde_json::json!({"k":"v"})),
1877 vec![],
1878 )
1879 .await
1880 .unwrap();
1881
1882 let updated = rt
1883 .update_entity(
1884 &tok,
1885 entity.id,
1886 EntityPatch {
1887 description: Some(Some("new desc".to_string())),
1888 ..Default::default()
1889 },
1890 )
1891 .await
1892 .unwrap();
1893
1894 assert_eq!(updated.name, "OriginalName");
1895 assert_eq!(updated.description.as_deref(), Some("new desc"));
1896 assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
1897 }
1898
1899 #[tokio::test]
1900 async fn update_entity_clear_description_with_some_none() {
1901 let rt = rt();
1902 let tok = NamespaceToken::local();
1903 let entity = rt
1904 .create_entity(
1905 &tok,
1906 "concept",
1907 None,
1908 "ClearDesc",
1909 Some("has description"),
1910 None,
1911 vec![],
1912 )
1913 .await
1914 .unwrap();
1915
1916 let updated = rt
1917 .update_entity(
1918 &tok,
1919 entity.id,
1920 EntityPatch {
1921 description: Some(None),
1922 ..Default::default()
1923 },
1924 )
1925 .await
1926 .unwrap();
1927
1928 assert!(
1929 updated.description.is_none(),
1930 "description should be cleared"
1931 );
1932 }
1933
1934 #[tokio::test]
1935 async fn update_entity_reindexes_when_name_changes() {
1936 let rt = rt();
1937 let tok = NamespaceToken::local();
1938 let entity = rt
1939 .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
1940 .await
1941 .unwrap();
1942
1943 let hits_before = fts_hit(&rt, &tok, "OldName").await;
1944 assert!(
1945 hits_before.contains(&entity.id),
1946 "entity should be findable by old name"
1947 );
1948
1949 rt.update_entity(
1950 &tok,
1951 entity.id,
1952 EntityPatch {
1953 name: Some("NewName".to_string()),
1954 ..Default::default()
1955 },
1956 )
1957 .await
1958 .unwrap();
1959
1960 let hits_old = fts_hit(&rt, &tok, "OldName").await;
1961 let hits_new = fts_hit(&rt, &tok, "NewName").await;
1962
1963 assert!(
1964 !hits_old.contains(&entity.id),
1965 "old name should no longer match after rename"
1966 );
1967 assert!(
1968 hits_new.contains(&entity.id),
1969 "new name should be findable after rename"
1970 );
1971 }
1972
1973 #[tokio::test]
1974 async fn update_entity_properties_merges_preserving_existing_keys() {
1975 let rt = rt();
1976 let tok = NamespaceToken::local();
1977 let entity = rt
1978 .create_entity(
1979 &tok,
1980 "concept",
1981 None,
1982 "MergeProps",
1983 None,
1984 Some(serde_json::json!({
1985 "domain": "inference",
1986 "repo": "lattice",
1987 "status": "researched",
1988 })),
1989 vec![],
1990 )
1991 .await
1992 .unwrap();
1993
1994 let updated = rt
1995 .update_entity(
1996 &tok,
1997 entity.id,
1998 EntityPatch {
1999 properties: Some(serde_json::json!({"status": "implemented"})),
2000 ..Default::default()
2001 },
2002 )
2003 .await
2004 .unwrap();
2005
2006 let props = updated.properties.expect("properties should remain set");
2007 assert_eq!(props["domain"], "inference", "domain key must be preserved");
2008 assert_eq!(props["repo"], "lattice", "repo key must be preserved");
2009 assert_eq!(
2010 props["status"], "implemented",
2011 "status key must be updated by patch"
2012 );
2013 }
2014
2015 #[tokio::test]
2016 async fn update_entity_skips_reindex_when_only_properties_change() {
2017 let rt = rt();
2018 let tok = NamespaceToken::local();
2019 let entity = rt
2020 .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
2021 .await
2022 .unwrap();
2023
2024 let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
2025 assert!(hits_before.contains(&entity.id));
2026
2027 rt.update_entity(
2028 &tok,
2029 entity.id,
2030 EntityPatch {
2031 properties: Some(serde_json::json!({"new": "prop"})),
2032 ..Default::default()
2033 },
2034 )
2035 .await
2036 .unwrap();
2037
2038 let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
2039 assert!(
2040 hits_after.contains(&entity.id),
2041 "still findable after props-only patch"
2042 );
2043 }
2044
2045 #[tokio::test]
2046 async fn merge_entity_rewires_edges() {
2047 let rt = rt();
2048 let tok = NamespaceToken::local();
2049 let a = rt
2050 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2051 .await
2052 .unwrap();
2053 let b = rt
2054 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2055 .await
2056 .unwrap();
2057 let c = rt
2058 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2059 .await
2060 .unwrap();
2061 let d = rt
2062 .create_entity(&tok, "concept", None, "D", None, None, vec![])
2063 .await
2064 .unwrap();
2065
2066 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2068 .await
2069 .unwrap();
2070 rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
2071 .await
2072 .unwrap();
2073
2074 let summary = rt
2075 .merge_entity(
2076 &tok,
2077 d.id,
2078 b.id,
2079 EntityDedupMergePolicy::PreferInto,
2080 ContentMergeStrategy::Append,
2081 false,
2082 )
2083 .await
2084 .unwrap();
2085
2086 assert_eq!(summary.kept_id, d.id);
2087 assert_eq!(summary.removed_id, b.id);
2088 assert_eq!(summary.edges_rewired, 2);
2089
2090 let a_neighbors = rt
2091 .neighbors(&tok, a.id, Direction::Out, None, None)
2092 .await
2093 .unwrap();
2094 assert_eq!(a_neighbors.len(), 1);
2095 assert_eq!(a_neighbors[0].node_id, d.id);
2096
2097 let c_neighbors = rt
2098 .neighbors(&tok, c.id, Direction::Out, None, None)
2099 .await
2100 .unwrap();
2101 assert_eq!(c_neighbors.len(), 1);
2102 assert_eq!(c_neighbors[0].node_id, d.id);
2103 }
2104
2105 #[tokio::test]
2106 async fn merge_entity_self_merge_rejected() {
2107 let rt = rt();
2108 let tok = NamespaceToken::local();
2109 let a = rt
2110 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2111 .await
2112 .unwrap();
2113 let err = rt
2114 .merge_entity(
2115 &tok,
2116 a.id,
2117 a.id,
2118 EntityDedupMergePolicy::PreferInto,
2119 ContentMergeStrategy::Append,
2120 false,
2121 )
2122 .await
2123 .unwrap_err();
2124 assert!(
2125 format!("{err:?}").contains("cannot merge an entity into itself"),
2126 "expected self-merge rejection, got: {err:?}"
2127 );
2128 }
2129
2130 #[tokio::test]
2131 async fn merge_entity_prefer_into_strategy() {
2132 let rt = rt();
2133 let tok = NamespaceToken::local();
2134 let into = rt
2135 .create_entity(
2136 &tok,
2137 "concept",
2138 None,
2139 "Into",
2140 None,
2141 Some(serde_json::json!({"a": 1})),
2142 vec![],
2143 )
2144 .await
2145 .unwrap();
2146 let from = rt
2147 .create_entity(
2148 &tok,
2149 "concept",
2150 None,
2151 "From",
2152 None,
2153 Some(serde_json::json!({"a": 2, "b": 3})),
2154 vec![],
2155 )
2156 .await
2157 .unwrap();
2158
2159 rt.merge_entity(
2160 &tok,
2161 into.id,
2162 from.id,
2163 EntityDedupMergePolicy::PreferInto,
2164 ContentMergeStrategy::Append,
2165 false,
2166 )
2167 .await
2168 .unwrap();
2169
2170 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2171 let props = kept.properties.unwrap();
2172 assert_eq!(props["a"], 1);
2174 assert_eq!(props["b"], 3);
2175 }
2176
2177 #[tokio::test]
2178 async fn merge_entity_prefer_from_strategy() {
2179 let rt = rt();
2180 let tok = NamespaceToken::local();
2181 let into = rt
2182 .create_entity(
2183 &tok,
2184 "concept",
2185 None,
2186 "Into",
2187 None,
2188 Some(serde_json::json!({"a": 1})),
2189 vec![],
2190 )
2191 .await
2192 .unwrap();
2193 let from = rt
2194 .create_entity(
2195 &tok,
2196 "concept",
2197 None,
2198 "From",
2199 None,
2200 Some(serde_json::json!({"a": 2, "b": 3})),
2201 vec![],
2202 )
2203 .await
2204 .unwrap();
2205
2206 rt.merge_entity(
2207 &tok,
2208 into.id,
2209 from.id,
2210 EntityDedupMergePolicy::PreferFrom,
2211 ContentMergeStrategy::Append,
2212 false,
2213 )
2214 .await
2215 .unwrap();
2216
2217 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2218 let props = kept.properties.unwrap();
2219 assert_eq!(props["a"], 2);
2221 assert_eq!(props["b"], 3);
2222 }
2223
2224 #[tokio::test]
2225 async fn merge_entity_union_strategy() {
2226 let rt = rt();
2227 let tok = NamespaceToken::local();
2228 let into = rt
2229 .create_entity(
2230 &tok,
2231 "concept",
2232 None,
2233 "Into",
2234 None,
2235 Some(serde_json::json!({"a": 1})),
2236 vec![],
2237 )
2238 .await
2239 .unwrap();
2240 let from = rt
2241 .create_entity(
2242 &tok,
2243 "concept",
2244 None,
2245 "From",
2246 None,
2247 Some(serde_json::json!({"a": 2, "b": 3})),
2248 vec![],
2249 )
2250 .await
2251 .unwrap();
2252
2253 rt.merge_entity(
2254 &tok,
2255 into.id,
2256 from.id,
2257 EntityDedupMergePolicy::Union,
2258 ContentMergeStrategy::Append,
2259 false,
2260 )
2261 .await
2262 .unwrap();
2263
2264 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2265 let props = kept.properties.unwrap();
2266 assert_eq!(props["a"], 1);
2268 assert_eq!(props["b"], 3);
2269 }
2270
2271 #[tokio::test]
2272 async fn merge_entity_unions_tags() {
2273 let rt = rt();
2274 let tok = NamespaceToken::local();
2275 let into = rt
2276 .create_entity(
2277 &tok,
2278 "concept",
2279 None,
2280 "Into",
2281 None,
2282 None,
2283 vec!["x".to_string(), "y".to_string()],
2284 )
2285 .await
2286 .unwrap();
2287 let from = rt
2288 .create_entity(
2289 &tok,
2290 "concept",
2291 None,
2292 "From",
2293 None,
2294 None,
2295 vec!["y".to_string(), "z".to_string()],
2296 )
2297 .await
2298 .unwrap();
2299
2300 rt.merge_entity(
2301 &tok,
2302 into.id,
2303 from.id,
2304 EntityDedupMergePolicy::PreferInto,
2305 ContentMergeStrategy::Append,
2306 false,
2307 )
2308 .await
2309 .unwrap();
2310
2311 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2312 let mut tags = kept.tags.clone();
2313 tags.sort();
2314 assert_eq!(tags, vec!["x", "y", "z"]);
2315 }
2316
2317 #[tokio::test]
2318 async fn merge_entity_drops_self_loops() {
2319 let rt = rt();
2320 let tok = NamespaceToken::local();
2321 let a = rt
2322 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2323 .await
2324 .unwrap();
2325 let b = rt
2326 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2327 .await
2328 .unwrap();
2329
2330 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2332 .await
2333 .unwrap();
2334
2335 let summary = rt
2336 .merge_entity(
2337 &tok,
2338 a.id,
2339 b.id,
2340 EntityDedupMergePolicy::PreferInto,
2341 ContentMergeStrategy::Append,
2342 false,
2343 )
2344 .await
2345 .unwrap();
2346
2347 assert_eq!(
2348 summary.edges_rewired, 0,
2349 "self-loop should be dropped, not rewired"
2350 );
2351
2352 let a_out = rt
2353 .neighbors(&tok, a.id, Direction::Out, None, None)
2354 .await
2355 .unwrap();
2356 assert!(a_out.is_empty(), "no self-loop should remain");
2357 }
2358
2359 #[tokio::test]
2362 async fn merge_entity_append_strategy_concatenates_descriptions() {
2363 let rt = rt();
2364 let tok = NamespaceToken::local();
2365 let into = rt
2366 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2367 .await
2368 .unwrap();
2369 let from = rt
2370 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2371 .await
2372 .unwrap();
2373
2374 let summary = rt
2375 .merge_entity(
2376 &tok,
2377 into.id,
2378 from.id,
2379 EntityDedupMergePolicy::PreferInto,
2380 ContentMergeStrategy::Append,
2381 false,
2382 )
2383 .await
2384 .unwrap();
2385
2386 assert!(
2387 summary.content_appended,
2388 "append strategy with two non-empty descriptions must report content_appended=true"
2389 );
2390 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2391 assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
2392 }
2393
2394 #[tokio::test]
2395 async fn merge_entity_append_strategy_from_empty_is_noop() {
2396 let rt = rt();
2397 let tok = NamespaceToken::local();
2398 let into = rt
2399 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2400 .await
2401 .unwrap();
2402 let from = rt
2403 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2404 .await
2405 .unwrap();
2406
2407 let summary = rt
2408 .merge_entity(
2409 &tok,
2410 into.id,
2411 from.id,
2412 EntityDedupMergePolicy::PreferInto,
2413 ContentMergeStrategy::Append,
2414 false,
2415 )
2416 .await
2417 .unwrap();
2418
2419 assert!(
2420 !summary.content_appended,
2421 "from's empty description means nothing was appended"
2422 );
2423 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2424 assert_eq!(kept.description.as_deref(), Some("desc A"));
2425 }
2426
2427 #[tokio::test]
2428 async fn merge_entity_append_strategy_into_empty_takes_from() {
2429 let rt = rt();
2430 let tok = NamespaceToken::local();
2431 let into = rt
2432 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2433 .await
2434 .unwrap();
2435 let from = rt
2436 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2437 .await
2438 .unwrap();
2439
2440 let summary = rt
2441 .merge_entity(
2442 &tok,
2443 into.id,
2444 from.id,
2445 EntityDedupMergePolicy::PreferInto,
2446 ContentMergeStrategy::Append,
2447 false,
2448 )
2449 .await
2450 .unwrap();
2451
2452 assert!(
2453 summary.content_appended,
2454 "taking from's description into an empty into is real content preservation"
2455 );
2456 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2457 assert_eq!(kept.description.as_deref(), Some("desc B"));
2458 }
2459
2460 #[tokio::test]
2461 async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
2462 let rt = rt();
2463 let tok = NamespaceToken::local();
2464 let into = rt
2465 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2466 .await
2467 .unwrap();
2468 let from = rt
2469 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2470 .await
2471 .unwrap();
2472
2473 let summary = rt
2474 .merge_entity(
2475 &tok,
2476 into.id,
2477 from.id,
2478 EntityDedupMergePolicy::PreferInto,
2479 ContentMergeStrategy::PreferInto,
2480 false,
2481 )
2482 .await
2483 .unwrap();
2484
2485 assert!(
2486 !summary.content_appended,
2487 "explicit PreferInto opt-out must not report an append"
2488 );
2489 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2490 assert_eq!(
2491 kept.description.as_deref(),
2492 Some("desc A"),
2493 "explicit PreferInto opt-out keeps the old discard behavior"
2494 );
2495 }
2496
2497 #[tokio::test]
2502 async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
2503 let rt = rt();
2504 let tok = NamespaceToken::local();
2505 let into = rt
2506 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2507 .await
2508 .unwrap();
2509 let from = rt
2510 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2511 .await
2512 .unwrap();
2513
2514 let summary = rt
2515 .merge_entity(
2516 &tok,
2517 into.id,
2518 from.id,
2519 EntityDedupMergePolicy::PreferInto,
2520 ContentMergeStrategy::PreferFrom,
2521 false,
2522 )
2523 .await
2524 .unwrap();
2525
2526 assert!(
2527 !summary.content_appended,
2528 "explicit PreferFrom is not an append"
2529 );
2530 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2531 assert_eq!(
2532 kept.description.as_deref(),
2533 Some("desc B"),
2534 "content_strategy=prefer_from must win over the default prefer_into entity policy"
2535 );
2536 }
2537
2538 #[tokio::test]
2539 async fn merge_entity_dry_run_previews_append() {
2540 let rt = rt();
2541 let tok = NamespaceToken::local();
2542 let into = rt
2543 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2544 .await
2545 .unwrap();
2546 let from = rt
2547 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2548 .await
2549 .unwrap();
2550
2551 let summary = rt
2552 .merge_entity(
2553 &tok,
2554 into.id,
2555 from.id,
2556 EntityDedupMergePolicy::PreferInto,
2557 ContentMergeStrategy::Append,
2558 true,
2559 )
2560 .await
2561 .unwrap();
2562
2563 assert!(summary.dry_run);
2564 assert!(
2565 summary.content_appended,
2566 "dry-run must preview the append outcome without writing"
2567 );
2568 let kept = rt.get_entity(&tok, into.id).await.unwrap();
2569 assert_eq!(
2570 kept.description.as_deref(),
2571 Some("desc A"),
2572 "dry_run=true must not mutate the into entity's description"
2573 );
2574 }
2575
2576 #[tokio::test]
2579 async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
2580 use khive_storage::EdgeRelation;
2581
2582 let rt = rt();
2583 let tok = NamespaceToken::local();
2584 let a = rt
2585 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2586 .await
2587 .unwrap();
2588 let into = rt
2589 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2590 .await
2591 .unwrap();
2592 let from = rt
2593 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2594 .await
2595 .unwrap();
2596
2597 rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
2598 .await
2599 .unwrap();
2600
2601 let summary = rt
2602 .merge_entity(
2603 &tok,
2604 into.id,
2605 from.id,
2606 EntityDedupMergePolicy::PreferInto,
2607 ContentMergeStrategy::Append,
2608 true,
2609 )
2610 .await
2611 .unwrap();
2612
2613 assert!(summary.dry_run);
2614 assert_eq!(
2615 summary.edges_rewired, 1,
2616 "dry-run must predict the edge that would be rewired, not report zero"
2617 );
2618
2619 let a_neighbors = rt
2620 .neighbors(&tok, a.id, Direction::Out, None, None)
2621 .await
2622 .unwrap();
2623 assert_eq!(a_neighbors.len(), 1);
2624 assert_eq!(
2625 a_neighbors[0].node_id, from.id,
2626 "dry_run=true must not rewire any edges"
2627 );
2628
2629 let events = rt
2630 .events(&tok)
2631 .unwrap()
2632 .query_events(
2633 khive_storage::EventFilter {
2634 kinds: vec![EventKind::EntityMerged],
2635 ..Default::default()
2636 },
2637 khive_storage::types::PageRequest {
2638 offset: 0,
2639 limit: 10,
2640 },
2641 )
2642 .await
2643 .unwrap();
2644 assert!(
2645 events.items.is_empty(),
2646 "dry_run=true must not append an EntityMerged event"
2647 );
2648 }
2649
2650 #[test]
2653 fn union_tags_deduplicates() {
2654 let (tags, added) = union_tags(
2655 &["x".to_string(), "y".to_string()],
2656 &["y".to_string(), "z".to_string()],
2657 );
2658 let mut sorted = tags.clone();
2659 sorted.sort();
2660 assert_eq!(sorted, vec!["x", "y", "z"]);
2661 assert_eq!(added, 1);
2662 }
2663
2664 #[test]
2665 fn merge_properties_prefer_into_fills_missing_keys() {
2666 let a = serde_json::json!({"a": 1});
2667 let b = serde_json::json!({"a": 99, "b": 2});
2668 let (merged, added) =
2669 merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
2670 let m = merged.unwrap();
2671 assert_eq!(m["a"], 1);
2672 assert_eq!(m["b"], 2);
2673 assert_eq!(added, 1);
2674 }
2675
2676 #[tokio::test]
2679 async fn merge_entity_tombstones_source_with_provenance() {
2680 let rt = rt();
2681 let tok = NamespaceToken::local();
2682 let into = rt
2683 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2684 .await
2685 .unwrap();
2686 let from = rt
2687 .create_entity(&tok, "concept", None, "From", None, None, vec![])
2688 .await
2689 .unwrap();
2690 let from_id = from.id;
2691
2692 rt.merge_entity(
2693 &tok,
2694 into.id,
2695 from_id,
2696 EntityDedupMergePolicy::PreferInto,
2697 ContentMergeStrategy::Append,
2698 false,
2699 )
2700 .await
2701 .unwrap();
2702
2703 assert!(
2704 rt.get_entity(&tok, from_id).await.is_err(),
2705 "tombstoned source should not be returned by get_entity"
2706 );
2707
2708 let pool = rt.backend().pool_arc();
2709 let (deleted_at, merged_into): (Option<i64>, Option<String>) =
2710 tokio::task::spawn_blocking(move || {
2711 let guard = pool.writer().unwrap();
2712 guard
2713 .conn()
2714 .query_row(
2715 "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
2716 [from_id.to_string()],
2717 |row| Ok((row.get(0)?, row.get(1)?)),
2718 )
2719 .unwrap()
2720 })
2721 .await
2722 .unwrap();
2723 assert!(
2724 deleted_at.is_some(),
2725 "tombstoned entity must have deleted_at set"
2726 );
2727 assert_eq!(
2728 merged_into.as_deref(),
2729 Some(into.id.to_string().as_str()),
2730 "merged_into must point to into_id"
2731 );
2732 }
2733
2734 #[tokio::test]
2735 async fn merge_note_same_kind_appends_content() {
2736 let rt = rt();
2737 let tok = NamespaceToken::local();
2738 let into = rt
2739 .create_note(
2740 &tok,
2741 "observation",
2742 None,
2743 "Into content",
2744 None,
2745 None,
2746 vec![],
2747 )
2748 .await
2749 .unwrap();
2750 let from = rt
2751 .create_note(
2752 &tok,
2753 "observation",
2754 None,
2755 "From content",
2756 None,
2757 None,
2758 vec![],
2759 )
2760 .await
2761 .unwrap();
2762 let from_id = from.id;
2763
2764 let summary = rt
2765 .merge_note(
2766 &tok,
2767 into.id,
2768 from_id,
2769 EntityDedupMergePolicy::PreferInto,
2770 ContentMergeStrategy::Append,
2771 false,
2772 )
2773 .await
2774 .unwrap();
2775
2776 assert_eq!(summary.kept_id, into.id);
2777 assert_eq!(summary.removed_id, from_id);
2778 assert!(summary.content_appended);
2779 assert!(!summary.dry_run);
2780
2781 let from_store = rt.notes(&tok).unwrap();
2782 assert!(
2783 from_store.get_note(from_id).await.unwrap().is_none(),
2784 "merged-from note should be soft-deleted"
2785 );
2786 }
2787
2788 #[tokio::test]
2791 async fn merge_note_survives_shared_edge_to_third_party() {
2792 use khive_storage::EdgeRelation;
2793 let rt = rt();
2794 let tok = NamespaceToken::local();
2795
2796 let into = rt
2797 .create_note(&tok, "observation", None, "Into", None, None, vec![])
2798 .await
2799 .unwrap();
2800 let from = rt
2801 .create_note(&tok, "observation", None, "From", None, None, vec![])
2802 .await
2803 .unwrap();
2804 let shared = rt
2805 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
2806 .await
2807 .unwrap();
2808
2809 rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2813 .await
2814 .unwrap();
2815 rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2816 .await
2817 .unwrap();
2818
2819 let summary = rt
2820 .merge_note(
2821 &tok,
2822 into.id,
2823 from.id,
2824 EntityDedupMergePolicy::PreferInto,
2825 ContentMergeStrategy::Append,
2826 false,
2827 )
2828 .await
2829 .expect("merge must succeed even when both notes annotate the same entity");
2830
2831 assert_eq!(summary.kept_id, into.id);
2832 assert_eq!(summary.removed_id, from.id);
2833
2834 let into_edges = rt
2835 .list_edges(
2836 &tok,
2837 crate::EdgeListFilter {
2838 source_id: Some(into.id),
2839 target_id: Some(shared.id),
2840 relations: vec![EdgeRelation::Annotates],
2841 ..Default::default()
2842 },
2843 10,
2844 0,
2845 )
2846 .await
2847 .unwrap();
2848 assert_eq!(
2849 into_edges.len(),
2850 1,
2851 "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
2852 );
2853 }
2854
2855 #[tokio::test]
2856 async fn merge_note_different_kinds_rejected() {
2857 let rt = rt();
2858 let tok = NamespaceToken::local();
2859 let into = rt
2860 .create_note(&tok, "observation", None, "Into", None, None, vec![])
2861 .await
2862 .unwrap();
2863 let from = rt
2864 .create_note(&tok, "decision", None, "From", None, None, vec![])
2865 .await
2866 .unwrap();
2867
2868 let result = rt
2869 .merge_note(
2870 &tok,
2871 into.id,
2872 from.id,
2873 EntityDedupMergePolicy::PreferInto,
2874 ContentMergeStrategy::Append,
2875 false,
2876 )
2877 .await;
2878 assert!(result.is_err(), "merging different note kinds must fail");
2879 }
2880
2881 #[tokio::test]
2882 async fn merge_note_dry_run_leaves_notes_unchanged() {
2883 let rt = rt();
2884 let tok = NamespaceToken::local();
2885 let into = rt
2886 .create_note(
2887 &tok,
2888 "observation",
2889 None,
2890 "Into content",
2891 None,
2892 None,
2893 vec![],
2894 )
2895 .await
2896 .unwrap();
2897 let from = rt
2898 .create_note(
2899 &tok,
2900 "observation",
2901 None,
2902 "From content",
2903 None,
2904 None,
2905 vec![],
2906 )
2907 .await
2908 .unwrap();
2909 let into_id = into.id;
2910 let from_id = from.id;
2911
2912 let summary = rt
2913 .merge_note(
2914 &tok,
2915 into_id,
2916 from_id,
2917 EntityDedupMergePolicy::PreferInto,
2918 ContentMergeStrategy::Append,
2919 true,
2920 )
2921 .await
2922 .unwrap();
2923
2924 assert!(summary.dry_run);
2925
2926 let store = rt.notes(&tok).unwrap();
2927 let into_after = store.get_note(into_id).await.unwrap().unwrap();
2928 let from_after = store.get_note(from_id).await.unwrap().unwrap();
2929 assert_eq!(
2930 into_after.content, "Into content",
2931 "dry_run must not mutate into-note"
2932 );
2933 assert_eq!(
2934 from_after.content, "From content",
2935 "dry_run must not mutate from-note"
2936 );
2937 }
2938
2939 #[tokio::test]
2944 async fn merge_nameless_notes_fts_document_is_parity_correct() {
2945 use khive_storage::types::TextSearchRequest;
2946
2947 let rt = rt(); let tok = NamespaceToken::local();
2949
2950 let into = rt
2951 .create_note(
2952 &tok,
2953 "observation",
2954 None,
2955 "intosentinelzxq body",
2956 None,
2957 Some(serde_json::json!({"src": "into"})),
2958 vec![],
2959 )
2960 .await
2961 .expect("create into-note");
2962 let from = rt
2963 .create_note(
2964 &tok,
2965 "observation",
2966 None,
2967 "fromsentinelzxq body",
2968 None,
2969 None,
2970 vec![],
2971 )
2972 .await
2973 .expect("create from-note");
2974
2975 let into_id = into.id;
2976 let from_id = from.id;
2977
2978 rt.merge_note(
2979 &tok,
2980 into_id,
2981 from_id,
2982 EntityDedupMergePolicy::PreferInto,
2983 ContentMergeStrategy::Append,
2984 false,
2985 )
2986 .await
2987 .expect("merge_note must succeed");
2988
2989 let note_store = rt.notes(&tok).expect("note store");
2990 let merged_note = note_store
2991 .get_note(into_id)
2992 .await
2993 .expect("get_note")
2994 .expect("merged note must exist");
2995
2996 let expected = note_fts_document(&merged_note);
2997
2998 let fts = rt.text_for_notes(&tok).expect("FTS store");
2999 let stored = fts
3000 .get_document("local", into_id)
3001 .await
3002 .expect("get_document must not error")
3003 .expect("FTS document must exist after merge");
3004
3005 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3006 assert_eq!(
3007 stored.title, expected.title,
3008 "title (None for nameless note)"
3009 );
3010 assert_eq!(stored.body, expected.body, "body");
3011 assert_eq!(stored.namespace, expected.namespace, "namespace");
3012 assert_eq!(stored.kind, expected.kind, "kind");
3013
3014 assert!(
3015 stored.title.is_none(),
3016 "nameless merged note must have title=None in FTS (was NULL before fix)"
3017 );
3018
3019 let hits = fts
3021 .search(TextSearchRequest {
3022 query: "intosentinelzxq".to_string(),
3023 mode: khive_storage::types::TextQueryMode::Plain,
3024 filter: None,
3025 top_k: 10,
3026 snippet_chars: 0,
3027 })
3028 .await
3029 .expect("search");
3030 assert!(
3031 hits.iter().any(|h| h.subject_id == into_id),
3032 "merged note must be searchable by into-note content"
3033 );
3034 }
3035
3036 #[tokio::test]
3037 async fn update_edge_updates_properties() {
3038 use khive_storage::EdgeRelation;
3039 let rt = rt();
3040 let tok = NamespaceToken::local();
3041 let a = rt
3042 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3043 .await
3044 .unwrap();
3045 let b = rt
3046 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3047 .await
3048 .unwrap();
3049 let edge = rt
3050 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
3051 .await
3052 .unwrap();
3053 let edge_id: Uuid = edge.id.into();
3054
3055 let updated = rt
3056 .update_edge(
3057 &tok,
3058 edge_id,
3059 EdgePatch {
3060 properties: Some(serde_json::json!({"source": "manual"})),
3061 ..Default::default()
3062 },
3063 )
3064 .await
3065 .unwrap();
3066
3067 assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
3068 assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
3069 }
3070
3071 #[tokio::test]
3075 async fn merge_entity_survives_shared_edge_to_third_party() {
3076 use khive_storage::EdgeRelation;
3077 let rt = rt();
3078 let tok = NamespaceToken::local();
3079
3080 let a = rt
3083 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3084 .await
3085 .unwrap();
3086 let b = rt
3087 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3088 .await
3089 .unwrap();
3090 let shared = rt
3091 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
3092 .await
3093 .unwrap();
3094
3095 rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
3098 .await
3099 .unwrap();
3100 rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
3101 .await
3102 .unwrap();
3103
3104 let summary = rt
3105 .merge_entity(
3106 &tok,
3107 a.id,
3108 b.id,
3109 crate::EntityDedupMergePolicy::PreferInto,
3110 ContentMergeStrategy::Append,
3111 false,
3112 )
3113 .await
3114 .expect(
3115 "C1: merge must succeed even when both entities share an edge to a third party",
3116 );
3117
3118 assert_eq!(summary.kept_id, a.id);
3119 assert_eq!(summary.removed_id, b.id);
3120 let a_edges = rt
3125 .list_edges(
3126 &tok,
3127 crate::EdgeListFilter {
3128 source_id: Some(a.id),
3129 target_id: Some(shared.id),
3130 relations: vec![EdgeRelation::Extends],
3131 ..Default::default()
3132 },
3133 10,
3134 0,
3135 )
3136 .await
3137 .unwrap();
3138 assert_eq!(
3139 a_edges.len(),
3140 1,
3141 "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
3142 );
3143
3144 let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
3146 assert!(
3147 b_after.is_none(),
3148 "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
3149 );
3150 }
3151
3152 #[tokio::test]
3156 async fn merge_entity_cross_kind_rejected_at_runtime() {
3157 let rt = rt();
3158 let tok = NamespaceToken::local();
3159
3160 let concept = rt
3161 .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
3162 .await
3163 .unwrap();
3164 let project = rt
3165 .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
3166 .await
3167 .unwrap();
3168
3169 let err = rt
3170 .merge_entity(
3171 &tok,
3172 concept.id,
3173 project.id,
3174 crate::EntityDedupMergePolicy::PreferInto,
3175 ContentMergeStrategy::Append,
3176 false,
3177 )
3178 .await
3179 .expect_err("H2: cross-kind merge must be rejected by runtime");
3180 assert!(
3181 matches!(err, crate::RuntimeError::InvalidInput(_)),
3182 "H2: expected InvalidInput, got: {err:?}"
3183 );
3184
3185 let concept_after = rt.get_entity(&tok, concept.id).await;
3186 let project_after = rt.get_entity(&tok, project.id).await;
3187 assert!(
3188 concept_after.is_ok(),
3189 "H2: concept must remain live after rejected merge; got: {concept_after:?}"
3190 );
3191 assert!(
3192 project_after.is_ok(),
3193 "H2: project must remain live after rejected merge; got: {project_after:?}"
3194 );
3195 }
3196
3197 #[tokio::test]
3199 async fn merge_entity_same_kind_succeeds() {
3200 let rt = rt();
3201 let tok = NamespaceToken::local();
3202
3203 let c1 = rt
3204 .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
3205 .await
3206 .unwrap();
3207 let c2 = rt
3208 .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
3209 .await
3210 .unwrap();
3211
3212 let summary = rt
3213 .merge_entity(
3214 &tok,
3215 c1.id,
3216 c2.id,
3217 crate::EntityDedupMergePolicy::PreferInto,
3218 ContentMergeStrategy::Append,
3219 false,
3220 )
3221 .await
3222 .expect("same-kind merge must succeed");
3223 assert_eq!(summary.kept_id, c1.id);
3224 assert_eq!(summary.removed_id, c2.id);
3225
3226 let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
3227 assert!(c2_after.is_none(), "from_entity must be tombstoned");
3228 }
3229
3230 #[tokio::test]
3233 async fn merge_note_cross_namespace_either_id_returns_not_found() {
3234 use crate::error::RuntimeError;
3235 use crate::Namespace;
3236
3237 let rt = rt();
3238 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3239 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3240
3241 let into_a = rt
3242 .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
3243 .await
3244 .unwrap();
3245 let from_a = rt
3246 .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
3247 .await
3248 .unwrap();
3249 let note_b = rt
3250 .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
3251 .await
3252 .unwrap();
3253
3254 let foreign_into = rt
3256 .merge_note(
3257 &ns_a,
3258 note_b.id,
3259 from_a.id,
3260 EntityDedupMergePolicy::PreferInto,
3261 ContentMergeStrategy::Append,
3262 false,
3263 )
3264 .await;
3265 assert!(
3266 matches!(foreign_into, Err(RuntimeError::NotFound(_))),
3267 "foreign into_id must be denied before merge, got {foreign_into:?}"
3268 );
3269
3270 let foreign_from = rt
3272 .merge_note(
3273 &ns_a,
3274 into_a.id,
3275 note_b.id,
3276 EntityDedupMergePolicy::PreferInto,
3277 ContentMergeStrategy::Append,
3278 false,
3279 )
3280 .await;
3281 assert!(
3282 matches!(foreign_from, Err(RuntimeError::NotFound(_))),
3283 "foreign from_id must be denied before merge, got {foreign_from:?}"
3284 );
3285 }
3286
3287 #[tokio::test]
3290 async fn update_entity_cross_namespace_succeeds() {
3291 use crate::Namespace;
3292
3293 let rt = rt();
3294 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3295 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3296
3297 let entity = rt
3298 .create_entity(
3299 &ns_a,
3300 "concept",
3301 None,
3302 "Alpha",
3303 Some("original"),
3304 None,
3305 vec![],
3306 )
3307 .await
3308 .unwrap();
3309
3310 let result = rt
3311 .update_entity(
3312 &ns_b,
3313 entity.id,
3314 EntityPatch {
3315 name: Some("Updated".into()),
3316 ..Default::default()
3317 },
3318 )
3319 .await;
3320
3321 assert!(
3322 result.is_ok(),
3323 "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
3324 );
3325 assert_eq!(result.unwrap().name, "Updated");
3326 }
3327
3328 #[tokio::test]
3332 async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
3333 use crate::Namespace;
3334
3335 let rt = rt();
3336 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3337 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3338
3339 let into_a = rt
3340 .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
3341 .await
3342 .unwrap();
3343 let from_a = rt
3344 .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
3345 .await
3346 .unwrap();
3347 let foreign_b = rt
3348 .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
3349 .await
3350 .unwrap();
3351
3352 let foreign_into = rt
3354 .merge_entity(
3355 &ns_a,
3356 foreign_b.id,
3357 from_a.id,
3358 EntityDedupMergePolicy::PreferInto,
3359 ContentMergeStrategy::Append,
3360 false,
3361 )
3362 .await;
3363 assert!(
3364 foreign_into.is_err(),
3365 "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
3366 );
3367
3368 let foreign_from = rt
3370 .merge_entity(
3371 &ns_a,
3372 into_a.id,
3373 foreign_b.id,
3374 EntityDedupMergePolicy::PreferInto,
3375 ContentMergeStrategy::Append,
3376 false,
3377 )
3378 .await;
3379 assert!(
3380 foreign_from.is_err(),
3381 "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
3382 );
3383
3384 assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
3386 assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
3387 assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
3388 }
3389
3390 #[test]
3393 fn entity_fts_document_with_description() {
3394 let mut entity = Entity::new("local", "concept", "MyEntity");
3395 entity = entity.with_description("some description text");
3396 let doc = entity_fts_document(&entity);
3397 assert_eq!(doc.subject_id, entity.id);
3398 assert_eq!(doc.namespace, "local");
3399 assert_eq!(doc.title.as_deref(), Some("MyEntity"));
3400 assert_eq!(doc.body, "MyEntity some description text");
3401 assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
3402 }
3403
3404 #[test]
3405 fn entity_fts_document_without_description() {
3406 let entity = Entity::new("local", "concept", "NameOnly");
3407 let doc = entity_fts_document(&entity);
3408 assert_eq!(doc.title.as_deref(), Some("NameOnly"));
3409 assert_eq!(doc.body, "NameOnly");
3410 }
3411
3412 #[test]
3413 fn entity_fts_document_empty_description_uses_name_only() {
3414 let mut entity = Entity::new("local", "concept", "TitleOnly");
3415 entity = entity.with_description("");
3416 let doc = entity_fts_document(&entity);
3417 assert_eq!(
3418 doc.body, "TitleOnly",
3419 "empty description must not be appended"
3420 );
3421 }
3422
3423 #[tokio::test]
3427 async fn entity_fts_document_matches_runtime_create_path() {
3428 let rt = rt();
3429 let tok = NamespaceToken::local();
3430
3431 let entity = rt
3432 .create_entity(
3433 &tok,
3434 "concept",
3435 None,
3436 "CrossPathTitle",
3437 Some("cross path description body"),
3438 Some(serde_json::json!({"key": "val"})),
3439 vec!["tag1".to_string()],
3440 )
3441 .await
3442 .expect("create_entity");
3443
3444 let fts = rt.text(&tok).expect("FTS store");
3445 let stored = fts
3446 .get_document("local", entity.id)
3447 .await
3448 .expect("get_document")
3449 .expect("document must exist after create_entity");
3450
3451 let expected = entity_fts_document(&entity);
3452
3453 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3454 assert_eq!(stored.kind, expected.kind, "kind");
3455 assert_eq!(stored.title, expected.title, "title");
3456 assert_eq!(stored.body, expected.body, "body");
3457 assert_eq!(stored.namespace, expected.namespace, "namespace");
3458 }
3459
3460 #[tokio::test]
3463 async fn entity_fts_document_matches_runtime_update_path() {
3464 let rt = rt();
3465 let tok = NamespaceToken::local();
3466
3467 let entity = rt
3468 .create_entity(
3469 &tok,
3470 "concept",
3471 None,
3472 "OldName",
3473 Some("old desc"),
3474 None,
3475 vec![],
3476 )
3477 .await
3478 .expect("create_entity");
3479
3480 let updated = rt
3481 .update_entity(
3482 &tok,
3483 entity.id,
3484 EntityPatch {
3485 name: Some("NewName".to_string()),
3486 description: Some(Some("new desc".to_string())),
3487 ..Default::default()
3488 },
3489 )
3490 .await
3491 .expect("update_entity");
3492
3493 let fts = rt.text(&tok).expect("FTS store");
3494 let stored = fts
3495 .get_document("local", updated.id)
3496 .await
3497 .expect("get_document")
3498 .expect("document must exist after update_entity");
3499
3500 let expected = entity_fts_document(&updated);
3501
3502 assert_eq!(stored.title, expected.title, "title after update");
3503 assert_eq!(stored.body, expected.body, "body after update");
3504 }
3505
3506 struct MergeTestVecService {
3512 dims: usize,
3513 }
3514
3515 #[async_trait::async_trait]
3516 impl lattice_embed::EmbeddingService for MergeTestVecService {
3517 async fn embed(
3518 &self,
3519 texts: &[String],
3520 _model: lattice_embed::EmbeddingModel,
3521 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
3522 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
3523 }
3524
3525 fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
3526 true
3527 }
3528
3529 fn name(&self) -> &'static str {
3530 "merge-test-const-vec"
3531 }
3532 }
3533
3534 struct MergeTestVecProvider {
3535 provider_name: String,
3536 dims: usize,
3537 }
3538
3539 impl MergeTestVecProvider {
3540 fn new(name: &str, dims: usize) -> Self {
3541 Self {
3542 provider_name: name.to_owned(),
3543 dims,
3544 }
3545 }
3546 }
3547
3548 #[async_trait::async_trait]
3549 impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
3550 fn name(&self) -> &str {
3551 &self.provider_name
3552 }
3553
3554 fn dimensions(&self) -> usize {
3555 self.dims
3556 }
3557
3558 async fn build(
3559 &self,
3560 ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
3561 {
3562 Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
3563 }
3564 }
3565
3566 #[tokio::test]
3572 async fn merge_entity_clears_vectors_from_all_registered_models() {
3573 const DIMS: usize = 4;
3574 let rt = KhiveRuntime::memory().unwrap();
3575 rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
3576 rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
3577
3578 let ns_str = "merge-entity-vec-cleanup";
3579 let ns = crate::Namespace::parse(ns_str).unwrap();
3580 let tok = NamespaceToken::for_namespace(ns);
3581
3582 let into_e = rt
3583 .create_entity(
3584 &tok,
3585 "concept",
3586 None,
3587 "IntoVecEntity",
3588 Some("desc a"),
3589 None,
3590 vec![],
3591 )
3592 .await
3593 .expect("create into");
3594 let from_e = rt
3595 .create_entity(
3596 &tok,
3597 "concept",
3598 None,
3599 "FromVecEntity",
3600 Some("desc b"),
3601 None,
3602 vec![],
3603 )
3604 .await
3605 .expect("create from");
3606
3607 let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
3609 let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
3610 use khive_storage::types::VectorSearchRequest;
3611 let query = vec![1.0_f32; DIMS];
3612 let pre_a = vs_a
3613 .search(VectorSearchRequest {
3614 query_vectors: vec![query.clone()],
3615 top_k: 100,
3616 namespace: Some(ns_str.to_string()),
3617 kind: Some(khive_types::SubstrateKind::Entity),
3618 embedding_model: Some("merge-vec-a".to_string()),
3619 filter: None,
3620 backend_hints: None,
3621 })
3622 .await
3623 .unwrap();
3624 assert!(
3625 pre_a.iter().any(|h| h.subject_id == into_e.id)
3626 && pre_a.iter().any(|h| h.subject_id == from_e.id),
3627 "both entities must be in model-a before merge; got {pre_a:?}"
3628 );
3629
3630 let pre_b = vs_b
3633 .search(VectorSearchRequest {
3634 query_vectors: vec![query.clone()],
3635 top_k: 100,
3636 namespace: Some(ns_str.to_string()),
3637 kind: Some(khive_types::SubstrateKind::Entity),
3638 embedding_model: Some("merge-vec-b".to_string()),
3639 filter: None,
3640 backend_hints: None,
3641 })
3642 .await
3643 .unwrap();
3644 assert!(
3645 pre_b.iter().any(|h| h.subject_id == into_e.id)
3646 && pre_b.iter().any(|h| h.subject_id == from_e.id),
3647 "both entities must be in model-b before merge; got {pre_b:?}"
3648 );
3649
3650 rt.merge_entity(
3651 &tok,
3652 into_e.id,
3653 from_e.id,
3654 EntityDedupMergePolicy::PreferInto,
3655 ContentMergeStrategy::Append,
3656 false,
3657 )
3658 .await
3659 .expect("merge_entity");
3660
3661 let post_a = vs_a
3662 .search(VectorSearchRequest {
3663 query_vectors: vec![query.clone()],
3664 top_k: 100,
3665 namespace: Some(ns_str.to_string()),
3666 kind: Some(khive_types::SubstrateKind::Entity),
3667 embedding_model: Some("merge-vec-a".to_string()),
3668 filter: None,
3669 backend_hints: None,
3670 })
3671 .await
3672 .unwrap();
3673 let from_ids_a: Vec<_> = post_a
3674 .iter()
3675 .filter(|h| h.subject_id == from_e.id)
3676 .collect();
3677 assert!(
3678 from_ids_a.is_empty(),
3679 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3680 );
3681
3682 let post_b = vs_b
3683 .search(VectorSearchRequest {
3684 query_vectors: vec![query],
3685 top_k: 100,
3686 namespace: Some(ns_str.to_string()),
3687 kind: Some(khive_types::SubstrateKind::Entity),
3688 embedding_model: Some("merge-vec-b".to_string()),
3689 filter: None,
3690 backend_hints: None,
3691 })
3692 .await
3693 .unwrap();
3694 let from_ids_b: Vec<_> = post_b
3695 .iter()
3696 .filter(|h| h.subject_id == from_e.id)
3697 .collect();
3698 assert!(
3699 from_ids_b.is_empty(),
3700 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3701 );
3702 }
3703
3704 #[tokio::test]
3710 async fn merge_note_clears_vectors_from_all_registered_models() {
3711 const DIMS: usize = 4;
3712 let rt = KhiveRuntime::memory().unwrap();
3713 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
3714 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
3715
3716 let ns_str = "merge-note-vec-cleanup";
3717 let ns = crate::Namespace::parse(ns_str).unwrap();
3718 let tok = NamespaceToken::for_namespace(ns);
3719
3720 let into_n = rt
3721 .create_note(
3722 &tok,
3723 "observation",
3724 None,
3725 "IntoVecNote content",
3726 None,
3727 None,
3728 vec![],
3729 )
3730 .await
3731 .expect("create into note");
3732 let from_n = rt
3733 .create_note(
3734 &tok,
3735 "observation",
3736 None,
3737 "FromVecNote content",
3738 None,
3739 None,
3740 vec![],
3741 )
3742 .await
3743 .expect("create from note");
3744
3745 let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
3746 let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
3747 use khive_storage::types::VectorSearchRequest;
3748 let query = vec![1.0_f32; DIMS];
3749
3750 let pre_a = vs_a
3751 .search(VectorSearchRequest {
3752 query_vectors: vec![query.clone()],
3753 top_k: 100,
3754 namespace: Some(ns_str.to_string()),
3755 kind: Some(khive_types::SubstrateKind::Note),
3756 embedding_model: Some("merge-note-vec-a".to_string()),
3757 filter: None,
3758 backend_hints: None,
3759 })
3760 .await
3761 .unwrap();
3762 assert!(
3763 pre_a.iter().any(|h| h.subject_id == into_n.id)
3764 && pre_a.iter().any(|h| h.subject_id == from_n.id),
3765 "both notes must be in model-a before merge; got {pre_a:?}"
3766 );
3767
3768 let pre_b = vs_b
3771 .search(VectorSearchRequest {
3772 query_vectors: vec![query.clone()],
3773 top_k: 100,
3774 namespace: Some(ns_str.to_string()),
3775 kind: Some(khive_types::SubstrateKind::Note),
3776 embedding_model: Some("merge-note-vec-b".to_string()),
3777 filter: None,
3778 backend_hints: None,
3779 })
3780 .await
3781 .unwrap();
3782 assert!(
3783 pre_b.iter().any(|h| h.subject_id == into_n.id)
3784 && pre_b.iter().any(|h| h.subject_id == from_n.id),
3785 "both notes must be in model-b before merge; got {pre_b:?}"
3786 );
3787
3788 rt.merge_note(
3789 &tok,
3790 into_n.id,
3791 from_n.id,
3792 EntityDedupMergePolicy::PreferInto,
3793 ContentMergeStrategy::PreferInto,
3794 false,
3795 )
3796 .await
3797 .expect("merge_note");
3798
3799 let post_a = vs_a
3800 .search(VectorSearchRequest {
3801 query_vectors: vec![query.clone()],
3802 top_k: 100,
3803 namespace: Some(ns_str.to_string()),
3804 kind: Some(khive_types::SubstrateKind::Note),
3805 embedding_model: Some("merge-note-vec-a".to_string()),
3806 filter: None,
3807 backend_hints: None,
3808 })
3809 .await
3810 .unwrap();
3811 let from_ids_a: Vec<_> = post_a
3812 .iter()
3813 .filter(|h| h.subject_id == from_n.id)
3814 .collect();
3815 assert!(
3816 from_ids_a.is_empty(),
3817 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3818 );
3819
3820 let post_b = vs_b
3821 .search(VectorSearchRequest {
3822 query_vectors: vec![query],
3823 top_k: 100,
3824 namespace: Some(ns_str.to_string()),
3825 kind: Some(khive_types::SubstrateKind::Note),
3826 embedding_model: Some("merge-note-vec-b".to_string()),
3827 filter: None,
3828 backend_hints: None,
3829 })
3830 .await
3831 .unwrap();
3832 let from_ids_b: Vec<_> = post_b
3833 .iter()
3834 .filter(|h| h.subject_id == from_n.id)
3835 .collect();
3836 assert!(
3837 from_ids_b.is_empty(),
3838 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3839 );
3840 }
3841
3842 #[tokio::test]
3845 async fn entity_fts_document_matches_runtime_merge_path() {
3846 let rt = rt();
3847 let tok = NamespaceToken::local();
3848
3849 let into_e = rt
3850 .create_entity(
3851 &tok,
3852 "concept",
3853 None,
3854 "IntoEntity",
3855 Some("into desc"),
3856 None,
3857 vec![],
3858 )
3859 .await
3860 .expect("create into");
3861 let from_e = rt
3862 .create_entity(
3863 &tok,
3864 "concept",
3865 None,
3866 "FromEntity",
3867 Some("from desc"),
3868 None,
3869 vec![],
3870 )
3871 .await
3872 .expect("create from");
3873
3874 let summary = rt
3875 .merge_entity(
3876 &tok,
3877 into_e.id,
3878 from_e.id,
3879 EntityDedupMergePolicy::PreferInto,
3880 ContentMergeStrategy::Append,
3881 false,
3882 )
3883 .await
3884 .expect("merge_entity");
3885
3886 let kept = rt
3887 .get_entity(&tok, summary.kept_id)
3888 .await
3889 .expect("get kept");
3890
3891 let fts = rt.text(&tok).expect("FTS store");
3892 let stored = fts
3893 .get_document("local", kept.id)
3894 .await
3895 .expect("get_document")
3896 .expect("FTS document must exist for kept entity after merge");
3897
3898 let expected = entity_fts_document(&kept);
3899
3900 assert_eq!(stored.title, expected.title, "title after merge");
3901 assert_eq!(stored.body, expected.body, "body after merge");
3902 }
3903}