Skip to main content

reddb_server/storage/unified/store/
builder.rs

1use super::*;
2
3impl Default for UnifiedStore {
4    fn default() -> Self {
5        Self::new()
6    }
7}
8
9#[cfg(test)]
10mod tests {
11    use super::*;
12    use reddb_types::Value;
13    use std::collections::HashMap;
14    use std::path::PathBuf;
15
16    #[test]
17    fn test_store_basic() {
18        let store = UnifiedStore::new();
19        store.create_collection("hosts").unwrap();
20
21        let entity = UnifiedEntity::table_row(
22            store.next_entity_id(),
23            "hosts",
24            1,
25            vec![Value::text("192.168.1.1".to_string())],
26        );
27
28        let id = store.insert("hosts", entity).unwrap();
29        assert!(store.get("hosts", id).is_some());
30    }
31
32    #[test]
33    fn test_store_auto_create() {
34        let store = UnifiedStore::new();
35
36        let entity =
37            UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
38
39        let id = store.insert_auto("new_collection", entity).unwrap();
40        assert!(store.get("new_collection", id).is_some());
41    }
42
43    #[test]
44    fn test_cross_references() {
45        let store = UnifiedStore::new();
46
47        // Create hosts collection
48        let host_entity = UnifiedEntity::table_row(
49            store.next_entity_id(),
50            "hosts",
51            1,
52            vec![Value::text("192.168.1.1".to_string())],
53        );
54        let host_id = store.insert_auto("hosts", host_entity).unwrap();
55
56        // Create vulns collection
57        let vuln_entity = UnifiedEntity::table_row(
58            store.next_entity_id(),
59            "vulns",
60            1,
61            vec![Value::text("CVE-2024-1234".to_string())],
62        );
63        let vuln_id = store.insert_auto("vulns", vuln_entity).unwrap();
64
65        // Add cross-reference
66        store
67            .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
68            .unwrap();
69
70        // Verify forward reference
71        let refs = store.get_refs_from(host_id);
72        assert_eq!(refs.len(), 1);
73        assert_eq!(refs[0].0, vuln_id);
74
75        // Verify reverse reference
76        let back_refs = store.get_refs_to(vuln_id);
77        assert_eq!(back_refs.len(), 1);
78        assert_eq!(back_refs[0].0, host_id);
79    }
80
81    /// Pins issue #113: when a delete batch covers entities with no
82    /// inbound *or* outbound cross-refs, `unindex_cross_refs_batch` must
83    /// take the read-only fast path and skip the `reverse_refs` write
84    /// lock entirely. Observable via `unindex_cross_refs_fast_path_hits`.
85    #[test]
86    fn unindex_cross_refs_batch_takes_fast_path_when_no_inbound_refs() {
87        let store = UnifiedStore::new();
88
89        // Insert plain rows with zero cross-refs.
90        let mut ids = Vec::new();
91        for i in 0..16 {
92            let row = UnifiedEntity::table_row(
93                store.next_entity_id(),
94                "rows",
95                (i + 1) as u64,
96                vec![Value::text(format!("v{i}"))],
97            );
98            ids.push(store.insert_auto("rows", row).unwrap());
99        }
100
101        let before = store.unindex_cross_refs_fast_path_hits();
102        store.delete_batch("rows", &ids).unwrap();
103        let after = store.unindex_cross_refs_fast_path_hits();
104
105        // Exactly one fast-path hit recorded for the single batch call.
106        assert_eq!(
107            after - before,
108            1,
109            "expected unindex_cross_refs_batch to take the read-only fast path"
110        );
111
112        // And of course, the rows are gone.
113        for id in &ids {
114            assert!(store.get("rows", *id).is_none());
115        }
116    }
117
118    /// Inverse of the above: when at least one deleted entity *does*
119    /// have an inbound cross-ref, the slow path must run and the
120    /// fast-path counter must NOT advance.
121    #[test]
122    fn unindex_cross_refs_batch_uses_slow_path_when_inbound_refs_exist() {
123        let store = UnifiedStore::new();
124
125        let host = UnifiedEntity::table_row(
126            store.next_entity_id(),
127            "hosts",
128            1,
129            vec![Value::text("h".to_string())],
130        );
131        let host_id = store.insert_auto("hosts", host).unwrap();
132
133        let vuln = UnifiedEntity::table_row(
134            store.next_entity_id(),
135            "vulns",
136            1,
137            vec![Value::text("v".to_string())],
138        );
139        let vuln_id = store.insert_auto("vulns", vuln).unwrap();
140
141        store
142            .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
143            .unwrap();
144
145        let before = store.unindex_cross_refs_fast_path_hits();
146        // Deleting the *target* (vuln_id) — it has an inbound ref from host_id,
147        // so reverse_refs has it as a key and the slow path is mandatory.
148        store.delete_batch("vulns", &[vuln_id]).unwrap();
149        let after = store.unindex_cross_refs_fast_path_hits();
150
151        assert_eq!(
152            after, before,
153            "fast-path counter must not advance when inbound refs exist"
154        );
155
156        // Reverse ref is gone.
157        assert!(store.get_refs_to(vuln_id).is_empty());
158    }
159
160    #[test]
161    fn test_expand_refs() {
162        let store = UnifiedStore::new();
163
164        // Create a chain: A → B → C
165        let _ = store.get_or_create_collection("test");
166
167        let a = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.1]);
168        let a_id = store.insert_auto("test", a).unwrap();
169
170        let b = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.2]);
171        let b_id = store.insert_auto("test", b).unwrap();
172
173        let c = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.3]);
174        let c_id = store.insert_auto("test", c).unwrap();
175
176        store
177            .add_cross_ref("test", a_id, "test", b_id, RefType::SimilarTo, 0.9)
178            .unwrap();
179        store
180            .add_cross_ref("test", b_id, "test", c_id, RefType::SimilarTo, 0.8)
181            .unwrap();
182
183        // Expand from A with depth 2
184        let expanded = store.expand_refs(a_id, 2, None);
185        assert_eq!(expanded.len(), 2); // Should find B and C
186    }
187
188    #[test]
189    fn test_query_all_collections() {
190        let store = UnifiedStore::new();
191
192        // Insert into multiple collections
193        store
194            .insert_auto(
195                "hosts",
196                UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]),
197            )
198            .unwrap();
199
200        store
201            .insert_auto(
202                "vulns",
203                UnifiedEntity::table_row(store.next_entity_id(), "vulns", 1, vec![]),
204            )
205            .unwrap();
206
207        // Query all
208        let results = store.query_all(|_| true);
209        assert_eq!(results.len(), 2);
210    }
211
212    #[test]
213    fn test_stats() {
214        let store = UnifiedStore::new();
215
216        let _ = store.get_or_create_collection("test");
217        for i in 0..5 {
218            store
219                .insert_auto(
220                    "test",
221                    UnifiedEntity::vector(store.next_entity_id(), "v", vec![i as f32]),
222                )
223                .unwrap();
224        }
225
226        let stats = store.stats();
227        assert_eq!(stats.collection_count, 1);
228        assert_eq!(stats.total_entities, 5);
229    }
230
231    struct FileGuard {
232        path: PathBuf,
233    }
234
235    impl Drop for FileGuard {
236        fn drop(&mut self) {
237            let _ = std::fs::remove_file(&self.path);
238        }
239    }
240
241    fn temp_path(name: &str) -> (FileGuard, PathBuf) {
242        let path =
243            std::env::temp_dir().join(format!("rb_store_{}_{}.rdb", name, std::process::id()));
244        let guard = FileGuard { path: path.clone() };
245        let _ = std::fs::remove_file(&path);
246        (guard, path)
247    }
248
249    #[test]
250    fn test_cross_refs_persist_file_mode() {
251        let (_guard, path) = temp_path("file");
252        let store = UnifiedStore::new();
253
254        let row = UnifiedEntity::table_row(
255            store.next_entity_id(),
256            "hosts",
257            1,
258            vec![Value::text("10.0.0.1".to_string())],
259        );
260        let row_id = store.insert_auto("hosts", row).unwrap();
261
262        let node =
263            UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
264        let node_id = store.insert_auto("graph", node).unwrap();
265
266        let vector =
267            UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
268        let vector_id = store.insert_auto("embeddings", vector).unwrap();
269
270        store
271            .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
272            .unwrap();
273        store
274            .add_cross_ref(
275                "graph",
276                node_id,
277                "embeddings",
278                vector_id,
279                RefType::NodeToVector,
280                1.0,
281            )
282            .unwrap();
283
284        store.save_to_file(&path).unwrap();
285
286        let loaded = UnifiedStore::load_from_file(&path).unwrap();
287        let refs = loaded.get_refs_from(row_id);
288        assert!(refs.iter().any(|(id, kind, coll)| {
289            *id == node_id && *kind == RefType::RowToNode && coll == "graph"
290        }));
291
292        let graph_refs = loaded.get_refs_from(node_id);
293        assert!(graph_refs.iter().any(|(id, kind, coll)| {
294            *id == vector_id && *kind == RefType::NodeToVector && coll == "embeddings"
295        }));
296
297        let expanded = loaded.expand_refs(row_id, 2, None);
298        assert!(expanded
299            .iter()
300            .any(|(entity, depth, _)| { entity.id == node_id && *depth == 1 }));
301        assert!(expanded
302            .iter()
303            .any(|(entity, depth, _)| { entity.id == vector_id && *depth == 2 }));
304    }
305
306    #[test]
307    fn test_cross_refs_persist_paged_mode() {
308        let (_guard, path) = temp_path("paged");
309        let store = UnifiedStore::open(&path).unwrap();
310
311        let row = UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]);
312        let row_id = store.insert_auto("hosts", row).unwrap();
313
314        let node =
315            UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
316        let node_id = store.insert_auto("graph", node).unwrap();
317
318        store
319            .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
320            .unwrap();
321
322        store.persist().unwrap();
323
324        drop(store);
325
326        let loaded = UnifiedStore::open(&path).unwrap();
327        let refs = loaded.get_refs_from(row_id);
328        assert!(refs.iter().any(|(id, kind, coll)| {
329            *id == node_id && *kind == RefType::RowToNode && coll == "graph"
330        }));
331    }
332
333    /// #2376: every DDL ends in `persist()`, which deletes and re-inserts
334    /// every key of every collection B-tree. Concurrent `insert_auto` calls
335    /// into the same tree (control events, index registry) raced that
336    /// rebuild: duplicate-key errors on both sides and torn trees that
337    /// broke every later DDL and the reopen.
338    #[test]
339    fn concurrent_insert_auto_and_persist_keep_paged_btree_consistent() {
340        const WRITER_COUNT: usize = 4;
341        const ROW_COUNT_PER_WRITER: usize = 150;
342        const COLLECTION: &str = "red.control_events";
343
344        let (_guard, path) = temp_path("concurrent_persist_2376");
345        let store = Arc::new(UnifiedStore::open(&path).unwrap());
346        let writers_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
347
348        let persister = {
349            let store = Arc::clone(&store);
350            let writers_done = Arc::clone(&writers_done);
351            std::thread::spawn(move || {
352                let mut errors = Vec::new();
353                while writers_done.load(std::sync::atomic::Ordering::Acquire) < WRITER_COUNT {
354                    if let Err(err) = store.persist() {
355                        errors.push(format!("persist: {err}"));
356                    }
357                }
358                errors
359            })
360        };
361        let writers: Vec<_> = (0..WRITER_COUNT)
362            .map(|writer| {
363                let store = Arc::clone(&store);
364                let writers_done = Arc::clone(&writers_done);
365                std::thread::spawn(move || {
366                    let mut errors = Vec::new();
367                    for i in 0..ROW_COUNT_PER_WRITER {
368                        let row = UnifiedEntity::table_row(
369                            EntityId::new(0),
370                            COLLECTION,
371                            0,
372                            vec![Value::text(format!("writer{writer}-row{i}"))],
373                        );
374                        if let Err(err) = store.insert_auto(COLLECTION, row) {
375                            errors.push(format!("insert_auto: {err}"));
376                        }
377                    }
378                    writers_done.fetch_add(1, std::sync::atomic::Ordering::Release);
379                    errors
380                })
381            })
382            .collect();
383        let mut errors: Vec<String> = writers
384            .into_iter()
385            .flat_map(|writer| writer.join().expect("writer thread panicked"))
386            .collect();
387        errors.extend(persister.join().expect("persist thread panicked"));
388        assert!(
389            errors.is_empty(),
390            "race produced errors:\n{}",
391            errors.join("\n")
392        );
393
394        store.persist().expect("final persist");
395        let row_count = WRITER_COUNT * ROW_COUNT_PER_WRITER;
396        let btree = store
397            .btree_indices
398            .read()
399            .get(COLLECTION)
400            .cloned()
401            .expect("collection B-tree exists");
402        assert_eq!(btree.count().expect("B-tree count"), row_count);
403        drop(btree);
404        drop(Arc::try_unwrap(store).ok().expect("all threads joined"));
405
406        let reopened = UnifiedStore::open(&path).expect("reopen after concurrent persist");
407        let reopened_count = reopened
408            .get_collection(COLLECTION)
409            .map(|manager| manager.query_all(|_| true).len())
410            .unwrap_or(0);
411        assert_eq!(reopened_count, row_count);
412    }
413
414    #[test]
415    fn test_paged_mode_survives_multiple_reopens() {
416        let (_guard, path) = temp_path("paged_multi_reopen");
417
418        let store = UnifiedStore::open(&path).unwrap();
419        store.set_config_tree(
420            "system",
421            &crate::json!({
422                "hostname": "test-host",
423                "arch": "x86_64",
424                "started_at": 123_u64
425            }),
426        );
427        let initial = store
428            .get_collection("red.config")
429            .map(|m| m.query_all(|_| true).len())
430            .unwrap_or(0);
431        assert!(initial >= 3);
432        drop(store);
433
434        let reopened = UnifiedStore::open(&path).unwrap();
435        let first_reopen = reopened
436            .get_collection("red.config")
437            .map(|m| m.query_all(|_| true).len())
438            .unwrap_or(0);
439        assert_eq!(first_reopen, initial);
440        drop(reopened);
441
442        let reopened_again = UnifiedStore::open(&path).unwrap();
443        let second_reopen = reopened_again
444            .get_collection("red.config")
445            .map(|m| m.query_all(|_| true).len())
446            .unwrap_or(0);
447        assert_eq!(second_reopen, initial);
448    }
449
450    #[test]
451    fn set_config_tree_replaces_earlier_rows_for_the_same_key() {
452        let store = UnifiedStore::new();
453        let config_rows = |store: &UnifiedStore| {
454            store
455                .get_collection("red.config")
456                .map(|m| m.query_all(|_| true).len())
457                .unwrap_or(0)
458        };
459
460        store.set_config_tree("system", &crate::json!({ "pid": 1_u64, "os": "linux" }));
461        let after_first = config_rows(&store);
462        for pid in 2..=5_u64 {
463            store.set_config_tree("system", &crate::json!({ "pid": pid, "os": "linux" }));
464        }
465
466        assert_eq!(config_rows(&store), after_first);
467        assert_eq!(
468            store.get_config("system.pid"),
469            Some(reddb_types::Value::UnsignedInteger(5))
470        );
471    }
472
473    #[test]
474    fn set_config_tree_skips_keys_that_already_hold_the_value() {
475        let store = UnifiedStore::new();
476        store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
477        let ids_of = |store: &UnifiedStore| {
478            let mut ids: Vec<u64> = store
479                .get_collection("red.config")
480                .map(|m| m.query_all(|_| true).iter().map(|e| e.id.raw()).collect())
481                .unwrap_or_default();
482            ids.sort_unstable();
483            ids
484        };
485        let before = ids_of(&store);
486
487        store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
488        assert_eq!(
489            ids_of(&store),
490            before,
491            "identical values must not rewrite rows"
492        );
493
494        store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 2_u64 }));
495        let after = ids_of(&store);
496        assert_eq!(after.len(), before.len());
497        assert_ne!(after, before, "a changed value is rewritten");
498        assert_eq!(
499            store.get_config("system.pid"),
500            Some(reddb_types::Value::UnsignedInteger(2))
501        );
502        assert_eq!(
503            store.get_config("system.os"),
504            Some(reddb_types::Value::text("linux"))
505        );
506    }
507
508    fn add_duplicate_system_value(store: &UnifiedStore) {
509        let mut row = UnifiedEntity::table_row(EntityId::new(0), "red.config", 0, vec![]);
510        row.data = EntityData::Row(RowData::with_names(
511            vec![Value::text("system.value"), Value::text("duplicate")],
512            vec!["key".into(), "value".into()],
513        ));
514        store.insert_auto("red.config", row).unwrap();
515    }
516
517    #[test]
518    fn embedded_config_replacement_is_one_wal_publication_and_replays() {
519        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
520            let directory = tempfile::tempdir().unwrap();
521            let path = directory.path().join("config.rdb");
522            let mut store = UnifiedStore::new();
523            store
524                .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
525                .unwrap();
526            add_duplicate_system_value(&store);
527            let snapshot = store.to_binary_dump_bytes();
528            let initial =
529                crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
530                    .unwrap();
531            store.config.embedded_wal_path = Some(path.clone());
532            store.config.durability_mode = mode;
533            store
534                .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
535                .unwrap();
536            let open = crate::storage::EmbeddedRdbArtifact::open(&path).unwrap();
537            assert_eq!(
538                open.selected_superblock.generation,
539                initial.selected_superblock.generation + 1
540            );
541            let replay = UnifiedStore::load_from_bytes(&snapshot).unwrap();
542            for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).unwrap() {
543                replay.apply_encoded_store_wal_action(&payload).unwrap();
544            }
545            assert_eq!(
546                replay.get_config("system.value"),
547                Some(reddb_types::Value::text("new"))
548            );
549            assert_eq!(
550                replay
551                    .get_collection("red.config")
552                    .unwrap()
553                    .query_all(|_| true)
554                    .len(),
555                2
556            );
557            assert_eq!(
558                replay.get_config("system.other"),
559                Some(reddb_types::Value::UnsignedInteger(2))
560            );
561        }
562    }
563
564    #[test]
565    fn embedded_config_replacement_restores_old_value_when_wal_fails() {
566        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
567            let directory = tempfile::tempdir().unwrap();
568            let mut store = UnifiedStore::new();
569            store
570                .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
571                .unwrap();
572            add_duplicate_system_value(&store);
573            // A directory cannot be opened as the embedded WAL artifact.
574            store.config.embedded_wal_path = Some(directory.path().to_path_buf());
575            store.config.durability_mode = mode;
576            assert!(store
577                .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
578                .is_err());
579            assert_eq!(
580                store.get_config("system.value"),
581                Some(reddb_types::Value::text("duplicate"))
582            );
583            assert_eq!(
584                store
585                    .get_collection("red.config")
586                    .unwrap()
587                    .query_all(|_| true)
588                    .len(),
589                3
590            );
591            assert_eq!(
592                store.get_config("system.other"),
593                Some(reddb_types::Value::UnsignedInteger(1))
594            );
595            assert!(!UnifiedStore::deferred_store_wal_capture_active());
596        }
597    }
598
599    #[test]
600    fn embedded_system_config_batch_survives_interrupted_wal_publication() {
601        const CHILD: &str = "REDDB_TEST_SYSTEM_CONFIG_CRASH_PATH";
602        if let Some(path) = std::env::var_os(CHILD) {
603            let path = PathBuf::from(path);
604            let (_, snapshot) =
605                crate::storage::EmbeddedRdbArtifact::open_with_snapshot(&path).unwrap();
606            let mut store = UnifiedStore::load_from_bytes(&snapshot.unwrap()).unwrap();
607            store.config.embedded_wal_path = Some(path);
608            store.config.durability_mode =
609                if std::env::var("REDDB_TEST_SYSTEM_CONFIG_GROUPED").is_ok() {
610                    DurabilityMode::WalDurableGrouped
611                } else {
612                    DurabilityMode::Strict
613                };
614            store
615                .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
616                .unwrap();
617            panic!("crash point was not reached");
618        }
619        let directory = tempfile::tempdir().unwrap();
620        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
621            for point in [
622                "wal_after_frame_write",
623                "wal_after_frame_sync",
624                "wal_after_superblock_write",
625            ] {
626                let path = directory.path().join(format!("{mode:?}-{point}.rdb"));
627                let store = UnifiedStore::new();
628                store
629                    .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
630                    .unwrap();
631                let snapshot = store.to_binary_dump_bytes();
632                crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
633                    .unwrap();
634                let mut command = std::process::Command::new(std::env::current_exe().unwrap());
635                command.args(["--exact", "storage::unified::store::builder::tests::embedded_system_config_batch_survives_interrupted_wal_publication", "--nocapture"])
636                    .env(CHILD, &path).env("REDDB_EMBEDDED_RDB_CRASH_AT", point);
637                if mode == DurabilityMode::WalDurableGrouped {
638                    command.env("REDDB_TEST_SYSTEM_CONFIG_GROUPED", "1");
639                }
640                let output = command.output().unwrap();
641                assert_eq!(
642                    output.status.code(),
643                    Some(173),
644                    "child did not reach {point}: {}",
645                    String::from_utf8_lossy(&output.stderr)
646                );
647                let (open, snapshot) =
648                    crate::storage::EmbeddedRdbArtifact::open_with_snapshot(&path).unwrap();
649                let recovered = UnifiedStore::load_from_bytes(&snapshot.unwrap()).unwrap();
650                for payload in
651                    crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).unwrap()
652                {
653                    recovered.apply_encoded_store_wal_action(&payload).unwrap();
654                }
655                let values = (
656                    recovered.get_config("system.value"),
657                    recovered.get_config("system.other"),
658                );
659                assert!(
660                    values
661                        == (
662                            Some(reddb_types::Value::text("old")),
663                            Some(reddb_types::Value::UnsignedInteger(1))
664                        )
665                        || values
666                            == (
667                                Some(reddb_types::Value::text("new")),
668                                Some(reddb_types::Value::UnsignedInteger(2))
669                            ),
670                    "partial config after {mode:?} {point}: {values:?}"
671                );
672                assert_eq!(
673                    recovered
674                        .get_collection("red.config")
675                        .unwrap()
676                        .query_all(|_| true)
677                        .len(),
678                    2
679                );
680            }
681        }
682    }
683
684    #[test]
685    fn test_global_ids_unique_across_collections() {
686        let store = UnifiedStore::new();
687
688        let entity_a = UnifiedEntity::table_row(EntityId::new(0), "alpha", 1, vec![]);
689        let entity_b = UnifiedEntity::table_row(EntityId::new(0), "beta", 1, vec![]);
690
691        let id_a = store.insert_auto("alpha", entity_a).unwrap();
692        let id_b = store.insert_auto("beta", entity_b).unwrap();
693
694        assert_ne!(id_a, id_b);
695
696        store
697            .add_cross_ref("alpha", id_a, "beta", id_b, RefType::RelatedTo, 1.0)
698            .unwrap();
699
700        let expanded = store.expand_refs(id_a, 1, None);
701        assert!(expanded.iter().any(|(entity, _, _)| entity.id == id_b));
702    }
703}