1use std::collections::HashSet;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use khive_storage::types::{EdgeFilter, LinkId, PageRequest};
13use khive_storage::{EdgeRelation, EntityFilter};
14
15use crate::error::{RuntimeError, RuntimeResult};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct KgArchive {
26 pub format: String,
27 pub version: String,
28 pub namespace: String,
29 pub exported_at: DateTime<Utc>,
30 pub entities: Vec<ExportedEntity>,
31 pub edges: Vec<ExportedEdge>,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ExportedEntity {
37 pub id: Uuid,
38 pub kind: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub entity_type: Option<String>,
43 pub name: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub description: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub properties: Option<serde_json::Value>,
48 #[serde(default)]
49 pub tags: Vec<String>,
50 pub created_at: DateTime<Utc>,
51 pub updated_at: DateTime<Utc>,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct ExportedEdge {
57 #[serde(default = "Uuid::new_v4")]
62 pub edge_id: Uuid,
63 pub source: Uuid,
64 pub target: Uuid,
65 pub relation: EdgeRelation,
67 pub weight: f64,
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ImportSummary {
73 pub entities_imported: usize,
74 pub edges_imported: usize,
75 pub edges_skipped: usize,
81}
82
83impl KhiveRuntime {
86 pub async fn export_kg(&self, token: &NamespaceToken) -> RuntimeResult<KgArchive> {
92 let ns = token.namespace().as_str().to_owned();
93
94 let entity_page = self
96 .entities(token)?
97 .query_entities(
98 &ns,
99 EntityFilter::default(),
100 PageRequest {
101 offset: 0,
102 limit: u32::MAX,
103 },
104 )
105 .await?;
106
107 let entities: Vec<ExportedEntity> = entity_page
108 .items
109 .into_iter()
110 .map(|e| {
111 let created_at =
112 DateTime::from_timestamp_micros(e.created_at).unwrap_or_else(Utc::now);
113 let updated_at =
114 DateTime::from_timestamp_micros(e.updated_at).unwrap_or_else(Utc::now);
115 ExportedEntity {
116 id: e.id,
117 kind: e.kind.to_string(),
118 entity_type: e.entity_type,
119 name: e.name,
120 description: e.description,
121 properties: e.properties,
122 tags: e.tags,
123 created_at,
124 updated_at,
125 }
126 })
127 .collect();
128
129 let source_ids: Vec<Uuid> = entities.iter().map(|e| e.id).collect();
131 let edges = if source_ids.is_empty() {
132 Vec::new()
133 } else {
134 let filter = EdgeFilter {
135 source_ids: source_ids.clone(),
136 ..Default::default()
137 };
138 let edge_page = self
139 .graph(token)?
140 .query_edges(
141 filter,
142 Vec::new(),
143 PageRequest {
144 offset: 0,
145 limit: u32::MAX,
146 },
147 )
148 .await?;
149
150 let id_set: HashSet<Uuid> = source_ids.into_iter().collect();
151 edge_page
152 .items
153 .into_iter()
154 .filter(|e| id_set.contains(&e.source_id))
155 .map(|e| ExportedEdge {
156 edge_id: e.id.into(),
157 source: e.source_id,
158 target: e.target_id,
159 relation: e.relation,
160 weight: e.weight,
161 })
162 .collect()
163 };
164
165 Ok(KgArchive {
166 format: "khive-kg".to_string(),
167 version: "0.1".to_string(),
168 namespace: ns,
169 exported_at: Utc::now(),
170 entities,
171 edges,
172 })
173 }
174
175 pub async fn export_kg_json(&self, token: &NamespaceToken) -> RuntimeResult<String> {
177 let archive = self.export_kg(token).await?;
178 serde_json::to_string(&archive).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
179 }
180
181 pub async fn import_kg(
190 &self,
191 archive: &KgArchive,
192 token: &NamespaceToken,
193 ) -> RuntimeResult<ImportSummary> {
194 if archive.format != "khive-kg" {
196 return Err(RuntimeError::InvalidInput(format!(
197 "unsupported archive format {:?}; expected \"khive-kg\"",
198 archive.format
199 )));
200 }
201 if archive.version != "0.1" {
202 return Err(RuntimeError::InvalidInput(format!(
203 "unsupported archive version {:?}; supported: \"0.1\"",
204 archive.version
205 )));
206 }
207
208 let ns = token.namespace().as_str().to_owned();
209
210 let store = self.entities(token)?;
212 let mut entities_imported = 0usize;
213 for ee in &archive.entities {
214 self.validate_entity_kind(&ee.kind)?;
215 let created_micros = ee.created_at.timestamp_micros();
216 let updated_micros = ee.updated_at.timestamp_micros();
217 let entity = khive_storage::entity::Entity {
218 id: ee.id,
219 namespace: ns.clone(),
220 kind: ee.kind.clone(),
221 entity_type: ee.entity_type.clone(),
222 name: ee.name.clone(),
223 description: ee.description.clone(),
224 properties: ee.properties.clone(),
225 tags: ee.tags.clone(),
226 created_at: created_micros,
227 updated_at: updated_micros,
228 deleted_at: None,
229 merged_into: None,
230 merge_event_id: None,
231 };
232 store.upsert_entity(entity.clone()).await?;
233 self.reindex_entity(token, &entity).await?;
236 entities_imported += 1;
237 }
238
239 let graph = self.graph(token)?;
247 let mut edges_imported = 0usize;
248 let mut edges_skipped = 0usize;
249 for ee in &archive.edges {
250 crate::operations::validate_edge_weight(ee.weight)?;
251 let source_ok = match self.get_entity(token, ee.source).await {
252 Ok(_) => true,
253 Err(RuntimeError::NotFound(_)) => false,
254 Err(e) => return Err(e),
255 };
256 if !source_ok {
257 tracing::warn!(
258 source = %ee.source,
259 target = %ee.target,
260 relation = ?ee.relation,
261 "import_kg: skipping edge — source entity not found in namespace {ns:?}"
262 );
263 edges_skipped += 1;
264 continue;
265 }
266 let target_ok = match self.get_entity(token, ee.target).await {
267 Ok(_) => true,
268 Err(RuntimeError::NotFound(_)) => false,
269 Err(e) => return Err(e),
270 };
271 if !target_ok {
272 tracing::warn!(
273 source = %ee.source,
274 target = %ee.target,
275 relation = ?ee.relation,
276 "import_kg: skipping edge — target entity not found in namespace {ns:?}"
277 );
278 edges_skipped += 1;
279 continue;
280 }
281 let now = Utc::now();
282 let edge = khive_storage::types::Edge {
283 id: LinkId::from(ee.edge_id),
284 namespace: ns.clone(),
285 source_id: ee.source,
286 target_id: ee.target,
287 relation: ee.relation,
288 weight: ee.weight,
289 created_at: now,
290 updated_at: now,
291 deleted_at: None,
292 metadata: None,
293 target_backend: None,
294 };
295 graph.upsert_edge(edge).await?;
296 edges_imported += 1;
297 }
298
299 Ok(ImportSummary {
300 entities_imported,
301 edges_imported,
302 edges_skipped,
303 })
304 }
305
306 pub async fn import_kg_json(
308 &self,
309 json: &str,
310 token: &NamespaceToken,
311 ) -> RuntimeResult<ImportSummary> {
312 let archive: KgArchive =
313 serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
314 self.import_kg(&archive, token).await
315 }
316}
317
318#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::runtime::{KhiveRuntime, NamespaceToken};
327 use crate::Namespace;
328 use khive_storage::EdgeRelation;
329
330 async fn make_rt() -> KhiveRuntime {
331 KhiveRuntime::memory().expect("in-memory runtime")
332 }
333
334 #[tokio::test]
336 async fn roundtrip_entities_and_edges() {
337 let src = make_rt().await;
338 let tok = NamespaceToken::local();
339 let e1 = src
340 .create_entity(
341 &tok,
342 "concept",
343 None,
344 "FlashAttention",
345 Some("fast attention"),
346 None,
347 vec![],
348 )
349 .await
350 .unwrap();
351 let e2 = src
352 .create_entity(
353 &tok,
354 "concept",
355 None,
356 "FlashAttention-2",
357 None,
358 None,
359 vec![],
360 )
361 .await
362 .unwrap();
363 let e3 = src
364 .create_entity(
365 &tok,
366 "person",
367 None,
368 "Tri Dao",
369 None,
370 None,
371 vec!["author".into()],
372 )
373 .await
374 .unwrap();
375 src.link(&tok, e2.id, e1.id, EdgeRelation::Extends, 1.0, None)
376 .await
377 .unwrap();
378 src.link(&tok, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9, None)
379 .await
380 .unwrap();
381
382 let archive = src.export_kg(&tok).await.unwrap();
383 assert_eq!(archive.entities.len(), 3);
384 assert_eq!(archive.edges.len(), 2);
385 assert_eq!(archive.format, "khive-kg");
386 assert_eq!(archive.version, "0.1");
387
388 let dst = make_rt().await;
389 let summary = dst.import_kg(&archive, &tok).await.unwrap();
390 assert_eq!(summary.entities_imported, 3);
391 assert_eq!(summary.edges_imported, 2);
392
393 let got = dst.get_entity(&tok, e1.id).await.unwrap();
395 assert_eq!(got.name, "FlashAttention");
396 assert_eq!(got.description.as_deref(), Some("fast attention"));
397 }
398
399 #[tokio::test]
401 async fn json_roundtrip() {
402 let src = make_rt().await;
403 let tok = NamespaceToken::local();
404 let e1 = src
405 .create_entity(
406 &tok,
407 "concept",
408 None,
409 "LoRA",
410 Some("low-rank adaptation"),
411 Some(serde_json::json!({"year": "2021"})),
412 vec!["fine-tuning".into()],
413 )
414 .await
415 .unwrap();
416 let e2 = src
417 .create_entity(&tok, "concept", None, "QLoRA", None, None, vec![])
418 .await
419 .unwrap();
420 src.link(&tok, e2.id, e1.id, EdgeRelation::VariantOf, 0.9, None)
421 .await
422 .unwrap();
423
424 let json_str = src.export_kg_json(&tok).await.unwrap();
425 assert!(json_str.contains("khive-kg"));
426
427 let dst = make_rt().await;
428 let summary = dst.import_kg_json(&json_str, &tok).await.unwrap();
429 assert_eq!(summary.entities_imported, 2);
430 assert_eq!(summary.edges_imported, 1);
431
432 let got = dst.get_entity(&tok, e1.id).await.unwrap();
433 assert_eq!(got.tags, vec!["fine-tuning"]);
434 }
435
436 #[tokio::test]
443 async fn namespace_targeting() {
444 let src = make_rt().await;
445 let tok_a = NamespaceToken::for_namespace(Namespace::parse("a").unwrap());
446 let tok_b = NamespaceToken::for_namespace(Namespace::parse("b").unwrap());
447 src.create_entity(&tok_a, "concept", None, "Sinkhorn", None, None, vec![])
448 .await
449 .unwrap();
450
451 let archive = src.export_kg(&tok_a).await.unwrap();
452 assert_eq!(archive.namespace, "a");
453
454 let dst = make_rt().await;
456 let summary = dst.import_kg(&archive, &tok_b).await.unwrap();
457 assert_eq!(summary.entities_imported, 1);
458
459 let in_b = dst.list_entities(&tok_b, None, None, 100, 0).await.unwrap();
461 assert_eq!(in_b.len(), 1);
462 assert_eq!(in_b[0].name, "Sinkhorn");
463
464 let in_a = src.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
466 assert_eq!(in_a.len(), 1);
467
468 let dst_a = dst.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
470 assert_eq!(dst_a.len(), 0);
471 }
472
473 #[tokio::test]
475 async fn format_validation_rejects_wrong_format() {
476 let rt = make_rt().await;
477 let tok = NamespaceToken::local();
478 let bad = KgArchive {
479 format: "wrong".to_string(),
480 version: "0.1".to_string(),
481 namespace: "local".to_string(),
482 exported_at: Utc::now(),
483 entities: vec![],
484 edges: vec![],
485 };
486 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
487 assert!(matches!(err, RuntimeError::InvalidInput(_)));
488 }
489
490 #[tokio::test]
492 async fn import_unsupported_archive_version_returns_error() {
493 let rt = make_rt().await;
494 let tok = NamespaceToken::local();
495 let bad = KgArchive {
496 format: "khive-kg".to_string(),
497 version: "999.0".to_string(),
498 namespace: "local".to_string(),
499 exported_at: Utc::now(),
500 entities: vec![],
501 edges: vec![],
502 };
503 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
504 assert!(
505 matches!(err, RuntimeError::InvalidInput(_)),
506 "expected InvalidInput, got {err:?}"
507 );
508 if let RuntimeError::InvalidInput(msg) = err {
509 assert!(
510 msg.contains("999.0"),
511 "error message should mention the unsupported version, got: {msg:?}"
512 );
513 }
514 }
515
516 #[test]
518 fn invalid_relation_rejected_at_deserialize() {
519 let json = r#"{
520 "format":"khive-kg","version":"0.1","namespace":"local",
521 "exported_at":"2026-01-01T00:00:00Z",
522 "entities":[],
523 "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
524 "source":"00000000-0000-0000-0000-000000000001",
525 "target":"00000000-0000-0000-0000-000000000002",
526 "relation":"related_to","weight":0.5}]
527 }"#;
528 let result: Result<KgArchive, _> = serde_json::from_str(json);
529 assert!(
530 result.is_err(),
531 "non-canonical relation should fail to deserialize"
532 );
533 }
534
535 #[tokio::test]
542 async fn import_edge_with_dangling_source_is_skipped() {
543 let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
544
545 let rt = make_rt().await;
546 let tok = NamespaceToken::local();
547 let real = rt
549 .create_entity(&tok, "concept", None, "Real", None, None, vec![])
550 .await
551 .unwrap();
552
553 let archive = KgArchive {
555 format: "khive-kg".to_string(),
556 version: "0.1".to_string(),
557 namespace: "local".to_string(),
558 exported_at: Utc::now(),
559 entities: vec![ExportedEntity {
560 id: real.id,
561 kind: "concept".to_string(),
562 entity_type: None,
563 name: "Real".to_string(),
564 description: None,
565 properties: None,
566 tags: vec![],
567 created_at: Utc::now(),
568 updated_at: Utc::now(),
569 }],
570 edges: vec![ExportedEdge {
571 edge_id: Uuid::new_v4(),
572 source: phantom_source,
573 target: real.id,
574 relation: EdgeRelation::Extends,
575 weight: 1.0,
576 }],
577 };
578
579 let dst = make_rt().await;
580 let summary = dst.import_kg(&archive, &tok).await.unwrap();
581 assert_eq!(summary.entities_imported, 1);
582 assert_eq!(
583 summary.edges_imported, 0,
584 "dangling source must not be imported"
585 );
586 assert_eq!(
587 summary.edges_skipped, 1,
588 "dangling source must be counted as skipped"
589 );
590 }
591
592 #[tokio::test]
597 async fn import_edge_with_dangling_target_is_skipped() {
598 let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
599
600 let rt = make_rt().await;
601 let tok = NamespaceToken::local();
602 let real = rt
603 .create_entity(&tok, "concept", None, "Source", None, None, vec![])
604 .await
605 .unwrap();
606
607 let archive = KgArchive {
608 format: "khive-kg".to_string(),
609 version: "0.1".to_string(),
610 namespace: "local".to_string(),
611 exported_at: Utc::now(),
612 entities: vec![ExportedEntity {
613 id: real.id,
614 kind: "concept".to_string(),
615 entity_type: None,
616 name: "Source".to_string(),
617 description: None,
618 properties: None,
619 tags: vec![],
620 created_at: Utc::now(),
621 updated_at: Utc::now(),
622 }],
623 edges: vec![ExportedEdge {
624 edge_id: Uuid::new_v4(),
625 source: real.id,
626 target: phantom_target,
627 relation: EdgeRelation::DependsOn,
628 weight: 0.8,
629 }],
630 };
631
632 let dst = make_rt().await;
633 let summary = dst.import_kg(&archive, &tok).await.unwrap();
634 assert_eq!(summary.entities_imported, 1);
635 assert_eq!(
636 summary.edges_imported, 0,
637 "dangling target must not be imported"
638 );
639 assert_eq!(
640 summary.edges_skipped, 1,
641 "dangling target must be counted as skipped"
642 );
643 }
644
645 #[tokio::test]
650 async fn import_mixed_edges_reports_correct_counts() {
651 let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
652
653 let src = make_rt().await;
654 let tok = NamespaceToken::local();
655 let a = src
656 .create_entity(&tok, "concept", None, "A", None, None, vec![])
657 .await
658 .unwrap();
659 let b = src
660 .create_entity(&tok, "concept", None, "B", None, None, vec![])
661 .await
662 .unwrap();
663 let c = src
664 .create_entity(&tok, "concept", None, "C", None, None, vec![])
665 .await
666 .unwrap();
667
668 let archive = KgArchive {
670 format: "khive-kg".to_string(),
671 version: "0.1".to_string(),
672 namespace: "local".to_string(),
673 exported_at: Utc::now(),
674 entities: vec![
675 ExportedEntity {
676 id: a.id,
677 kind: "concept".to_string(),
678 entity_type: None,
679 name: "A".to_string(),
680 description: None,
681 properties: None,
682 tags: vec![],
683 created_at: Utc::now(),
684 updated_at: Utc::now(),
685 },
686 ExportedEntity {
687 id: b.id,
688 kind: "concept".to_string(),
689 entity_type: None,
690 name: "B".to_string(),
691 description: None,
692 properties: None,
693 tags: vec![],
694 created_at: Utc::now(),
695 updated_at: Utc::now(),
696 },
697 ExportedEntity {
698 id: c.id,
699 kind: "concept".to_string(),
700 entity_type: None,
701 name: "C".to_string(),
702 description: None,
703 properties: None,
704 tags: vec![],
705 created_at: Utc::now(),
706 updated_at: Utc::now(),
707 },
708 ],
709 edges: vec![
710 ExportedEdge {
712 edge_id: Uuid::new_v4(),
713 source: a.id,
714 target: b.id,
715 relation: EdgeRelation::Extends,
716 weight: 1.0,
717 },
718 ExportedEdge {
720 edge_id: Uuid::new_v4(),
721 source: b.id,
722 target: c.id,
723 relation: EdgeRelation::DependsOn,
724 weight: 0.9,
725 },
726 ExportedEdge {
728 edge_id: Uuid::new_v4(),
729 source: a.id,
730 target: phantom,
731 relation: EdgeRelation::Enables,
732 weight: 0.5,
733 },
734 ],
735 };
736
737 let dst = make_rt().await;
738 let summary = dst.import_kg(&archive, &tok).await.unwrap();
739 assert_eq!(summary.entities_imported, 3);
740 assert_eq!(
741 summary.edges_imported, 2,
742 "only valid edges must be imported"
743 );
744 assert_eq!(
745 summary.edges_skipped, 1,
746 "one dangling edge must be reported"
747 );
748 }
749
750 #[tokio::test]
752 async fn import_all_valid_edges_reports_zero_skipped() {
753 let src = make_rt().await;
754 let tok = NamespaceToken::local();
755 let e1 = src
756 .create_entity(&tok, "concept", None, "E1", None, None, vec![])
757 .await
758 .unwrap();
759 let e2 = src
760 .create_entity(&tok, "concept", None, "E2", None, None, vec![])
761 .await
762 .unwrap();
763 src.link(&tok, e1.id, e2.id, EdgeRelation::VariantOf, 0.7, None)
764 .await
765 .unwrap();
766
767 let archive = src.export_kg(&tok).await.unwrap();
768 let dst = make_rt().await;
769 let summary = dst.import_kg(&archive, &tok).await.unwrap();
770 assert_eq!(summary.edges_imported, 1);
771 assert_eq!(
772 summary.edges_skipped, 0,
773 "no edges should be skipped when all endpoints exist"
774 );
775 }
776
777 #[tokio::test]
781 async fn export_kg_preserves_edge_id() {
782 let rt = make_rt().await;
783 let tok = NamespaceToken::local();
784 let a = rt
785 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
786 .await
787 .unwrap();
788 let b = rt
789 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
790 .await
791 .unwrap();
792 let stored_edge = rt
793 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
794 .await
795 .unwrap();
796 let stored_id: Uuid = stored_edge.id.into();
797
798 let archive = rt.export_kg(&tok).await.unwrap();
799 assert_eq!(archive.edges.len(), 1);
800 assert_eq!(
801 archive.edges[0].edge_id, stored_id,
802 "exported edge_id must equal the LinkId returned by link"
803 );
804 }
805
806 #[tokio::test]
808 async fn import_kg_persists_edge_id() {
809 let src = make_rt().await;
810 let tok = NamespaceToken::local();
811 let a = src
812 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
813 .await
814 .unwrap();
815 let b = src
816 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
817 .await
818 .unwrap();
819 let stored_edge = src
820 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
821 .await
822 .unwrap();
823 let original_id: Uuid = stored_edge.id.into();
824
825 let archive = src.export_kg(&tok).await.unwrap();
826 let dst = make_rt().await;
827 dst.import_kg(&archive, &tok).await.unwrap();
828
829 let imported_edge = dst.get_edge(&tok, original_id).await.unwrap();
831 assert!(
832 imported_edge.is_some(),
833 "imported edge must be retrievable by the original edge_id"
834 );
835 let imported_edge = imported_edge.unwrap();
836 assert_eq!(
837 Uuid::from(imported_edge.id),
838 original_id,
839 "stored edge id must equal the archive edge_id"
840 );
841 }
842
843 #[tokio::test]
848 async fn old_archive_missing_edge_id_round_trips() {
849 let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
851 let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
852
853 let json = format!(
855 r#"{{
856 "format": "khive-kg",
857 "version": "0.1",
858 "namespace": "local",
859 "exported_at": "2026-01-01T00:00:00Z",
860 "entities": [
861 {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
862 {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
863 ],
864 "edges": [
865 {{
866 "source": "{src_id}",
867 "target": "{tgt_id}",
868 "relation": "extends",
869 "weight": 0.9
870 }}
871 ]
872 }}"#
873 );
874
875 let archive: KgArchive = serde_json::from_str(&json)
877 .expect("old archive without edge_id must deserialize successfully");
878 assert_eq!(archive.edges.len(), 1);
879 let generated_id = archive.edges[0].edge_id;
880 assert_ne!(
881 generated_id,
882 Uuid::nil(),
883 "missing edge_id in old archive must get a fresh non-nil UUID"
884 );
885
886 let rt = make_rt().await;
888 let tok = NamespaceToken::local();
889 let summary = rt.import_kg(&archive, &tok).await.unwrap();
890 assert_eq!(summary.entities_imported, 2);
891 assert_eq!(
892 summary.edges_imported, 1,
893 "edge must be imported when both endpoints exist"
894 );
895
896 let stored = rt.get_edge(&tok, generated_id).await.unwrap();
897 assert!(
898 stored.is_some(),
899 "imported edge must be retrievable by the generated edge_id"
900 );
901 assert_eq!(
902 Uuid::from(stored.unwrap().id),
903 generated_id,
904 "stored edge id must equal the generated edge_id"
905 );
906
907 let re_archive = rt.export_kg(&tok).await.unwrap();
909 assert_eq!(re_archive.edges.len(), 1);
910 assert_eq!(
911 re_archive.edges[0].edge_id, generated_id,
912 "re-exported edge_id must equal the ID generated on first import"
913 );
914 }
915
916 #[tokio::test]
921 async fn export_import_export_edge_id_equality() {
922 let src = make_rt().await;
924 let tok = NamespaceToken::local();
925 let a = src
926 .create_entity(&tok, "concept", None, "NodeA", None, None, vec![])
927 .await
928 .unwrap();
929 let b = src
930 .create_entity(&tok, "concept", None, "NodeB", None, None, vec![])
931 .await
932 .unwrap();
933 let stored = src
934 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
935 .await
936 .unwrap();
937 let original_edge_id: Uuid = stored.id.into();
938
939 let archive1 = src.export_kg(&tok).await.unwrap();
941 assert_eq!(archive1.edges.len(), 1);
942 assert_eq!(
943 archive1.edges[0].edge_id, original_edge_id,
944 "first export must carry the stored edge_id"
945 );
946
947 let dst = make_rt().await;
949 dst.import_kg(&archive1, &tok).await.unwrap();
950
951 let archive2 = dst.export_kg(&tok).await.unwrap();
953 assert_eq!(archive2.edges.len(), 1);
954
955 let re_edge = archive2
957 .edges
958 .iter()
959 .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
960 .expect(
961 "re-exported archive must contain the original edge by (source,target,relation)",
962 );
963 assert_eq!(
964 re_edge.edge_id, original_edge_id,
965 "edge_id must be identical across export → import → export"
966 );
967 }
968}