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