1use super::*;
2
3impl Default for UnifiedStore {
4 fn default() -> Self {
5 Self::new()
6 }
7}
8
9pub struct EntityBuilder {
11 store: Arc<UnifiedStore>,
12 collection: String,
13 entity: UnifiedEntity,
14}
15
16impl EntityBuilder {
17 pub fn new(
19 store: Arc<UnifiedStore>,
20 collection: impl Into<String>,
21 kind: EntityKind,
22 data: EntityData,
23 ) -> Self {
24 let collection_name = collection.into();
25 let _ = store.get_or_create_collection(&collection_name);
26 let id = store.next_entity_id();
27
28 Self {
29 store,
30 collection: collection_name,
31 entity: UnifiedEntity::new(id, kind, data),
32 }
33 }
34
35 pub fn metadata(self, key: impl Into<String>, value: MetadataValue) -> Self {
37 self
39 }
40
41 pub fn embedding(
43 mut self,
44 name: impl Into<String>,
45 vector: Vec<f32>,
46 model: impl Into<String>,
47 ) -> Self {
48 self.entity
49 .add_embedding(EmbeddingSlot::new(name, vector, model));
50 self
51 }
52
53 pub fn cross_ref(
55 mut self,
56 target: EntityId,
57 target_collection: impl Into<String>,
58 ref_type: RefType,
59 ) -> Self {
60 self.entity.add_cross_ref(CrossRef::new(
61 self.entity.id,
62 target,
63 target_collection,
64 ref_type,
65 ));
66 self
67 }
68
69 pub fn insert(self) -> Result<EntityId, StoreError> {
71 self.store.insert(&self.collection, self.entity)
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use reddb_types::Value;
79 use std::collections::HashMap;
80 use std::path::PathBuf;
81
82 #[test]
83 fn test_store_basic() {
84 let store = UnifiedStore::new();
85 store.create_collection("hosts").unwrap();
86
87 let entity = UnifiedEntity::table_row(
88 store.next_entity_id(),
89 "hosts",
90 1,
91 vec![Value::text("192.168.1.1".to_string())],
92 );
93
94 let id = store.insert("hosts", entity).unwrap();
95 assert!(store.get("hosts", id).is_some());
96 }
97
98 #[test]
99 fn test_store_auto_create() {
100 let store = UnifiedStore::new();
101
102 let entity =
103 UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
104
105 let id = store.insert_auto("new_collection", entity).unwrap();
106 assert!(store.get("new_collection", id).is_some());
107 }
108
109 #[test]
110 fn test_cross_references() {
111 let store = UnifiedStore::new();
112
113 let host_entity = UnifiedEntity::table_row(
115 store.next_entity_id(),
116 "hosts",
117 1,
118 vec![Value::text("192.168.1.1".to_string())],
119 );
120 let host_id = store.insert_auto("hosts", host_entity).unwrap();
121
122 let vuln_entity = UnifiedEntity::table_row(
124 store.next_entity_id(),
125 "vulns",
126 1,
127 vec![Value::text("CVE-2024-1234".to_string())],
128 );
129 let vuln_id = store.insert_auto("vulns", vuln_entity).unwrap();
130
131 store
133 .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
134 .unwrap();
135
136 let refs = store.get_refs_from(host_id);
138 assert_eq!(refs.len(), 1);
139 assert_eq!(refs[0].0, vuln_id);
140
141 let back_refs = store.get_refs_to(vuln_id);
143 assert_eq!(back_refs.len(), 1);
144 assert_eq!(back_refs[0].0, host_id);
145 }
146
147 #[test]
152 fn unindex_cross_refs_batch_takes_fast_path_when_no_inbound_refs() {
153 let store = UnifiedStore::new();
154
155 let mut ids = Vec::new();
157 for i in 0..16 {
158 let row = UnifiedEntity::table_row(
159 store.next_entity_id(),
160 "rows",
161 (i + 1) as u64,
162 vec![Value::text(format!("v{i}"))],
163 );
164 ids.push(store.insert_auto("rows", row).unwrap());
165 }
166
167 let before = store.unindex_cross_refs_fast_path_hits();
168 store.delete_batch("rows", &ids).unwrap();
169 let after = store.unindex_cross_refs_fast_path_hits();
170
171 assert_eq!(
173 after - before,
174 1,
175 "expected unindex_cross_refs_batch to take the read-only fast path"
176 );
177
178 for id in &ids {
180 assert!(store.get("rows", *id).is_none());
181 }
182 }
183
184 #[test]
188 fn unindex_cross_refs_batch_uses_slow_path_when_inbound_refs_exist() {
189 let store = UnifiedStore::new();
190
191 let host = UnifiedEntity::table_row(
192 store.next_entity_id(),
193 "hosts",
194 1,
195 vec![Value::text("h".to_string())],
196 );
197 let host_id = store.insert_auto("hosts", host).unwrap();
198
199 let vuln = UnifiedEntity::table_row(
200 store.next_entity_id(),
201 "vulns",
202 1,
203 vec![Value::text("v".to_string())],
204 );
205 let vuln_id = store.insert_auto("vulns", vuln).unwrap();
206
207 store
208 .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
209 .unwrap();
210
211 let before = store.unindex_cross_refs_fast_path_hits();
212 store.delete_batch("vulns", &[vuln_id]).unwrap();
215 let after = store.unindex_cross_refs_fast_path_hits();
216
217 assert_eq!(
218 after, before,
219 "fast-path counter must not advance when inbound refs exist"
220 );
221
222 assert!(store.get_refs_to(vuln_id).is_empty());
224 }
225
226 #[test]
232 #[ignore]
233 fn unindex_cross_refs_batch_fast_path_perf() {
234 let store = UnifiedStore::new();
235
236 const SEED: usize = 50_000;
240 let mut sources = Vec::with_capacity(SEED);
241 let mut targets = Vec::with_capacity(SEED);
242 for i in 0..SEED {
243 let s = UnifiedEntity::table_row(store.next_entity_id(), "src", (i + 1) as u64, vec![]);
244 sources.push(store.insert_auto("src", s).unwrap());
245 let t = UnifiedEntity::table_row(store.next_entity_id(), "tgt", (i + 1) as u64, vec![]);
246 targets.push(store.insert_auto("tgt", t).unwrap());
247 }
248 for (s, t) in sources.iter().zip(targets.iter()) {
249 store
250 .add_cross_ref("src", *s, "tgt", *t, RefType::RelatedTo, 1.0)
251 .unwrap();
252 }
253
254 const BATCH: usize = 100;
256 let mut victims = Vec::with_capacity(BATCH);
257 for i in 0..BATCH {
258 let row =
259 UnifiedEntity::table_row(store.next_entity_id(), "rows", (i + 1) as u64, vec![]);
260 victims.push(store.insert_auto("rows", row).unwrap());
261 }
262
263 let before_hits = store.unindex_cross_refs_fast_path_hits();
264 let start = std::time::Instant::now();
265 store.delete_batch("rows", &victims).unwrap();
266 let elapsed = start.elapsed();
267 let after_hits = store.unindex_cross_refs_fast_path_hits();
268
269 eprintln!(
270 "delete_batch({BATCH}) over {SEED} unrelated reverse-refs: \
271 {elapsed:?}, fast_path_hits +{}",
272 after_hits - before_hits,
273 );
274 assert_eq!(after_hits - before_hits, 1);
275 }
276
277 #[test]
278 fn test_expand_refs() {
279 let store = UnifiedStore::new();
280
281 let _ = store.get_or_create_collection("test");
283
284 let a = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.1]);
285 let a_id = store.insert_auto("test", a).unwrap();
286
287 let b = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.2]);
288 let b_id = store.insert_auto("test", b).unwrap();
289
290 let c = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.3]);
291 let c_id = store.insert_auto("test", c).unwrap();
292
293 store
294 .add_cross_ref("test", a_id, "test", b_id, RefType::SimilarTo, 0.9)
295 .unwrap();
296 store
297 .add_cross_ref("test", b_id, "test", c_id, RefType::SimilarTo, 0.8)
298 .unwrap();
299
300 let expanded = store.expand_refs(a_id, 2, None);
302 assert_eq!(expanded.len(), 2); }
304
305 #[test]
306 fn test_query_all_collections() {
307 let store = UnifiedStore::new();
308
309 store
311 .insert_auto(
312 "hosts",
313 UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]),
314 )
315 .unwrap();
316
317 store
318 .insert_auto(
319 "vulns",
320 UnifiedEntity::table_row(store.next_entity_id(), "vulns", 1, vec![]),
321 )
322 .unwrap();
323
324 let results = store.query_all(|_| true);
326 assert_eq!(results.len(), 2);
327 }
328
329 #[test]
330 fn test_stats() {
331 let store = UnifiedStore::new();
332
333 let _ = store.get_or_create_collection("test");
334 for i in 0..5 {
335 store
336 .insert_auto(
337 "test",
338 UnifiedEntity::vector(store.next_entity_id(), "v", vec![i as f32]),
339 )
340 .unwrap();
341 }
342
343 let stats = store.stats();
344 assert_eq!(stats.collection_count, 1);
345 assert_eq!(stats.total_entities, 5);
346 }
347
348 struct FileGuard {
349 path: PathBuf,
350 }
351
352 impl Drop for FileGuard {
353 fn drop(&mut self) {
354 let _ = std::fs::remove_file(&self.path);
355 }
356 }
357
358 fn temp_path(name: &str) -> (FileGuard, PathBuf) {
359 let path =
360 std::env::temp_dir().join(format!("rb_store_{}_{}.rdb", name, std::process::id()));
361 let guard = FileGuard { path: path.clone() };
362 let _ = std::fs::remove_file(&path);
363 (guard, path)
364 }
365
366 #[test]
367 fn test_cross_refs_persist_file_mode() {
368 let (_guard, path) = temp_path("file");
369 let store = UnifiedStore::new();
370
371 let row = UnifiedEntity::table_row(
372 store.next_entity_id(),
373 "hosts",
374 1,
375 vec![Value::text("10.0.0.1".to_string())],
376 );
377 let row_id = store.insert_auto("hosts", row).unwrap();
378
379 let node =
380 UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
381 let node_id = store.insert_auto("graph", node).unwrap();
382
383 let vector =
384 UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
385 let vector_id = store.insert_auto("embeddings", vector).unwrap();
386
387 store
388 .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
389 .unwrap();
390 store
391 .add_cross_ref(
392 "graph",
393 node_id,
394 "embeddings",
395 vector_id,
396 RefType::NodeToVector,
397 1.0,
398 )
399 .unwrap();
400
401 store.save_to_file(&path).unwrap();
402
403 let loaded = UnifiedStore::load_from_file(&path).unwrap();
404 let refs = loaded.get_refs_from(row_id);
405 assert!(refs.iter().any(|(id, kind, coll)| {
406 *id == node_id && *kind == RefType::RowToNode && coll == "graph"
407 }));
408
409 let graph_refs = loaded.get_refs_from(node_id);
410 assert!(graph_refs.iter().any(|(id, kind, coll)| {
411 *id == vector_id && *kind == RefType::NodeToVector && coll == "embeddings"
412 }));
413
414 let expanded = loaded.expand_refs(row_id, 2, None);
415 assert!(expanded
416 .iter()
417 .any(|(entity, depth, _)| { entity.id == node_id && *depth == 1 }));
418 assert!(expanded
419 .iter()
420 .any(|(entity, depth, _)| { entity.id == vector_id && *depth == 2 }));
421 }
422
423 #[test]
424 fn test_cross_refs_persist_paged_mode() {
425 let (_guard, path) = temp_path("paged");
426 let store = UnifiedStore::open(&path).unwrap();
427
428 let row = UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]);
429 let row_id = store.insert_auto("hosts", row).unwrap();
430
431 let node =
432 UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
433 let node_id = store.insert_auto("graph", node).unwrap();
434
435 store
436 .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
437 .unwrap();
438
439 store.persist().unwrap();
440
441 drop(store);
442
443 let loaded = UnifiedStore::open(&path).unwrap();
444 let refs = loaded.get_refs_from(row_id);
445 assert!(refs.iter().any(|(id, kind, coll)| {
446 *id == node_id && *kind == RefType::RowToNode && coll == "graph"
447 }));
448 }
449
450 #[test]
456 fn concurrent_insert_auto_and_persist_keep_paged_btree_consistent() {
457 const WRITER_COUNT: usize = 4;
458 const ROW_COUNT_PER_WRITER: usize = 150;
459 const COLLECTION: &str = "red.control_events";
460
461 let (_guard, path) = temp_path("concurrent_persist_2376");
462 let store = Arc::new(UnifiedStore::open(&path).unwrap());
463 let writers_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
464
465 let persister = {
466 let store = Arc::clone(&store);
467 let writers_done = Arc::clone(&writers_done);
468 std::thread::spawn(move || {
469 let mut errors = Vec::new();
470 while writers_done.load(std::sync::atomic::Ordering::Acquire) < WRITER_COUNT {
471 if let Err(err) = store.persist() {
472 errors.push(format!("persist: {err}"));
473 }
474 }
475 errors
476 })
477 };
478 let writers: Vec<_> = (0..WRITER_COUNT)
479 .map(|writer| {
480 let store = Arc::clone(&store);
481 let writers_done = Arc::clone(&writers_done);
482 std::thread::spawn(move || {
483 let mut errors = Vec::new();
484 for i in 0..ROW_COUNT_PER_WRITER {
485 let row = UnifiedEntity::table_row(
486 EntityId::new(0),
487 COLLECTION,
488 0,
489 vec![Value::text(format!("writer{writer}-row{i}"))],
490 );
491 if let Err(err) = store.insert_auto(COLLECTION, row) {
492 errors.push(format!("insert_auto: {err}"));
493 }
494 }
495 writers_done.fetch_add(1, std::sync::atomic::Ordering::Release);
496 errors
497 })
498 })
499 .collect();
500 let mut errors: Vec<String> = writers
501 .into_iter()
502 .flat_map(|writer| writer.join().expect("writer thread panicked"))
503 .collect();
504 errors.extend(persister.join().expect("persist thread panicked"));
505 assert!(
506 errors.is_empty(),
507 "race produced errors:\n{}",
508 errors.join("\n")
509 );
510
511 store.persist().expect("final persist");
512 let row_count = WRITER_COUNT * ROW_COUNT_PER_WRITER;
513 let btree = store
514 .btree_indices
515 .read()
516 .get(COLLECTION)
517 .cloned()
518 .expect("collection B-tree exists");
519 assert_eq!(btree.count().expect("B-tree count"), row_count);
520 drop(btree);
521 drop(Arc::try_unwrap(store).ok().expect("all threads joined"));
522
523 let reopened = UnifiedStore::open(&path).expect("reopen after concurrent persist");
524 let reopened_count = reopened
525 .get_collection(COLLECTION)
526 .map(|manager| manager.query_all(|_| true).len())
527 .unwrap_or(0);
528 assert_eq!(reopened_count, row_count);
529 }
530
531 #[test]
532 fn test_paged_mode_survives_multiple_reopens() {
533 let (_guard, path) = temp_path("paged_multi_reopen");
534
535 let store = UnifiedStore::open(&path).unwrap();
536 store.set_config_tree(
537 "red.system",
538 &crate::json!({
539 "hostname": "test-host",
540 "arch": "x86_64",
541 "started_at": 123_u64
542 }),
543 );
544 let initial = store
545 .get_collection("red_config")
546 .map(|m| m.query_all(|_| true).len())
547 .unwrap_or(0);
548 assert!(initial >= 3);
549 drop(store);
550
551 let reopened = UnifiedStore::open(&path).unwrap();
552 let first_reopen = reopened
553 .get_collection("red_config")
554 .map(|m| m.query_all(|_| true).len())
555 .unwrap_or(0);
556 assert_eq!(first_reopen, initial);
557 drop(reopened);
558
559 let reopened_again = UnifiedStore::open(&path).unwrap();
560 let second_reopen = reopened_again
561 .get_collection("red_config")
562 .map(|m| m.query_all(|_| true).len())
563 .unwrap_or(0);
564 assert_eq!(second_reopen, initial);
565 }
566
567 #[test]
568 fn set_config_tree_replaces_earlier_rows_for_the_same_key() {
569 let store = UnifiedStore::new();
570 let config_rows = |store: &UnifiedStore| {
571 store
572 .get_collection("red_config")
573 .map(|m| m.query_all(|_| true).len())
574 .unwrap_or(0)
575 };
576
577 store.set_config_tree("red.system", &crate::json!({ "pid": 1_u64, "os": "linux" }));
578 let after_first = config_rows(&store);
579 for pid in 2..=5_u64 {
580 store.set_config_tree("red.system", &crate::json!({ "pid": pid, "os": "linux" }));
581 }
582
583 assert_eq!(config_rows(&store), after_first);
584 assert_eq!(
585 store.get_config("red.system.pid"),
586 Some(reddb_types::Value::UnsignedInteger(5))
587 );
588 }
589
590 #[test]
591 fn set_config_tree_skips_keys_that_already_hold_the_value() {
592 let store = UnifiedStore::new();
593 store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
594 let ids_of = |store: &UnifiedStore| {
595 let mut ids: Vec<u64> = store
596 .get_collection("red_config")
597 .map(|m| m.query_all(|_| true).iter().map(|e| e.id.raw()).collect())
598 .unwrap_or_default();
599 ids.sort_unstable();
600 ids
601 };
602 let before = ids_of(&store);
603
604 store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
605 assert_eq!(
606 ids_of(&store),
607 before,
608 "identical values must not rewrite rows"
609 );
610
611 store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 2_u64 }));
612 let after = ids_of(&store);
613 assert_eq!(after.len(), before.len());
614 assert_ne!(after, before, "a changed value is rewritten");
615 assert_eq!(
616 store.get_config("red.system.pid"),
617 Some(reddb_types::Value::UnsignedInteger(2))
618 );
619 assert_eq!(
620 store.get_config("red.system.os"),
621 Some(reddb_types::Value::text("linux"))
622 );
623 }
624
625 #[test]
626 fn test_global_ids_unique_across_collections() {
627 let store = UnifiedStore::new();
628
629 let entity_a = UnifiedEntity::table_row(EntityId::new(0), "alpha", 1, vec![]);
630 let entity_b = UnifiedEntity::table_row(EntityId::new(0), "beta", 1, vec![]);
631
632 let id_a = store.insert_auto("alpha", entity_a).unwrap();
633 let id_b = store.insert_auto("beta", entity_b).unwrap();
634
635 assert_ne!(id_a, id_b);
636
637 store
638 .add_cross_ref("alpha", id_a, "beta", id_b, RefType::RelatedTo, 1.0)
639 .unwrap();
640
641 let expanded = store.expand_refs(id_a, 1, None);
642 assert!(expanded.iter().any(|(entity, _, _)| entity.id == id_b));
643 }
644}