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