1pub mod typed_store;
9
10use std::collections::HashMap;
11use std::path::Path;
12
13use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition, WriteTransaction};
14use serde::{Deserialize, Serialize};
15
16const FACTS_TABLE: TableDefinition<u64, &[u8]> = TableDefinition::new("facts");
19const PREDICATE_INDEX_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("predicate_index");
20const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("metadata");
21
22const SCHEMA_VERSION: u32 = 3;
32
33const MIGRATABLE_FROM_VERSION: u32 = 2;
35
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub enum StoredLogicalTerm {
45 Variable(String),
46 Constant(String),
47 Description(String),
48 Unspecified,
49 Number(f64),
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct StoredFactRecord {
55 pub id: u64,
56 pub payload: Vec<u8>,
60 pub label: String,
62 pub retracted: bool,
64 pub node_id: String,
66 pub hlc_timestamp: u64,
68 #[serde(default)]
70 pub predicates: Vec<String>,
71}
72
73fn decode_stored_fact_record(bytes: &[u8]) -> Result<StoredFactRecord, StoreError> {
74 Ok(postcard::from_bytes(bytes)?)
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
79pub enum StoredAssertion {
80 Text(String),
88 Direct {
90 relation: String,
91 args: Vec<StoredLogicalTerm>,
92 },
93 Buffer(Vec<u8>),
101}
102
103#[derive(Debug)]
105pub enum StoreError {
106 Io(String),
107 Serialization(String),
108 NotFound(u64),
109 SchemaVersion {
110 expected: u32,
111 found: u32,
112 },
113 Migration {
116 id: u64,
117 reason: String,
118 },
119}
120
121impl std::fmt::Display for StoreError {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 match self {
124 StoreError::Io(msg) => write!(f, "I/O error: {msg}"),
125 StoreError::Serialization(msg) => write!(f, "serialization error: {msg}"),
126 StoreError::NotFound(id) => write!(f, "fact {id} not found"),
127 StoreError::SchemaVersion { expected, found } => {
128 write!(
129 f,
130 "schema version mismatch: expected {expected}, found {found}"
131 )
132 }
133 StoreError::Migration { id, reason } => {
134 write!(f, "schema v2→v3 migration failed on fact {id}: {reason}")
135 }
136 }
137 }
138}
139
140impl std::error::Error for StoreError {}
141
142impl From<redb::Error> for StoreError {
143 fn from(e: redb::Error) -> Self {
144 StoreError::Io(e.to_string())
145 }
146}
147
148impl From<redb::DatabaseError> for StoreError {
149 fn from(e: redb::DatabaseError) -> Self {
150 StoreError::Io(e.to_string())
151 }
152}
153
154impl From<redb::TableError> for StoreError {
155 fn from(e: redb::TableError) -> Self {
156 StoreError::Io(e.to_string())
157 }
158}
159
160impl From<redb::TransactionError> for StoreError {
161 fn from(e: redb::TransactionError) -> Self {
162 StoreError::Io(e.to_string())
163 }
164}
165
166impl From<redb::StorageError> for StoreError {
167 fn from(e: redb::StorageError) -> Self {
168 StoreError::Io(e.to_string())
169 }
170}
171
172impl From<redb::CommitError> for StoreError {
173 fn from(e: redb::CommitError) -> Self {
174 StoreError::Io(e.to_string())
175 }
176}
177
178impl From<postcard::Error> for StoreError {
179 fn from(e: postcard::Error) -> Self {
180 StoreError::Serialization(e.to_string())
181 }
182}
183
184pub struct NibliStore {
188 db: Database,
189 node_id: String,
190 hlc: u64,
191 found_version: u32,
195}
196
197impl NibliStore {
198 pub fn open(path: &Path, node_id: String) -> Result<Self, StoreError> {
206 let db = Database::create(path)?;
207
208 let mut found_version = SCHEMA_VERSION;
210 let txn = db.begin_write()?;
211 {
212 let _ = txn.open_table(FACTS_TABLE)?;
214 let _ = txn.open_table(PREDICATE_INDEX_TABLE)?;
215 let mut meta = txn.open_table(META_TABLE)?;
216
217 let existing_version: Option<u32> = meta
219 .get("schema_version")?
220 .map(|g| postcard::from_bytes(g.value()))
221 .transpose()?;
222 match existing_version {
223 Some(version) if version == SCHEMA_VERSION => {
224 found_version = version;
225 }
226 Some(version) if version == MIGRATABLE_FROM_VERSION => {
230 found_version = version;
231 }
232 Some(version) => {
233 return Err(StoreError::SchemaVersion {
234 expected: SCHEMA_VERSION,
235 found: version,
236 });
237 }
238 None => {
239 let bytes = postcard::to_allocvec(&SCHEMA_VERSION)?;
240 meta.insert("schema_version", bytes.as_slice())?;
241 }
242 }
243 }
244 txn.commit()?;
245
246 let hlc = {
248 let rtxn = db.begin_read()?;
249 let table = rtxn.open_table(FACTS_TABLE)?;
250 let mut max_hlc = 0u64;
251 for entry in table.iter()? {
252 let (_, val) = entry?;
253 let record = decode_stored_fact_record(val.value())?;
254 if record.hlc_timestamp > max_hlc {
255 max_hlc = record.hlc_timestamp;
256 }
257 }
258 max_hlc
259 };
260
261 Ok(Self {
262 db,
263 node_id,
264 hlc,
265 found_version,
266 })
267 }
268
269 pub fn found_version(&self) -> u32 {
271 self.found_version
272 }
273
274 pub fn needs_migration(&self) -> bool {
277 self.found_version != SCHEMA_VERSION
278 }
279
280 fn stamp_current_version(txn: &WriteTransaction) -> Result<(), StoreError> {
283 let mut meta = txn.open_table(META_TABLE)?;
284 let bytes = postcard::to_allocvec(&SCHEMA_VERSION)?;
285 meta.insert("schema_version", bytes.as_slice())?;
286 Ok(())
287 }
288
289 pub fn finalize_v3(&mut self) -> Result<(), StoreError> {
293 let txn = self.db.begin_write()?;
294 Self::stamp_current_version(&txn)?;
295 txn.commit()?;
296 self.found_version = SCHEMA_VERSION;
297 Ok(())
298 }
299
300 pub fn migrate_v2_text_rows<F, E>(&mut self, mut recompile: F) -> Result<usize, StoreError>
314 where
315 F: FnMut(&str) -> Result<Vec<u8>, E>,
316 E: std::fmt::Display,
317 {
318 let pending: Vec<(StoredFactRecord, String)> = {
323 let rtxn = self.db.begin_read()?;
324 let table = rtxn.open_table(FACTS_TABLE)?;
325 let mut pending = Vec::new();
326 for entry in table.iter()? {
327 let (_, val) = entry?;
328 let record = decode_stored_fact_record(val.value())?;
329 if record.retracted {
330 continue;
331 }
332 let assertion: StoredAssertion =
333 postcard::from_bytes(&record.payload).map_err(|e| StoreError::Migration {
334 id: record.id,
335 reason: format!("payload is not a StoredAssertion ({e})"),
336 })?;
337 if let StoredAssertion::Text(text) = assertion {
338 pending.push((record, text));
339 }
340 }
341 pending
342 };
343
344 let mut rebuilt: Vec<(u64, Vec<u8>)> = Vec::with_capacity(pending.len());
347 for (record, text) in pending {
348 let inner = recompile(&text).map_err(|e| StoreError::Migration {
349 id: record.id,
350 reason: e.to_string(),
351 })?;
352 let payload = postcard::to_allocvec(&StoredAssertion::Buffer(inner))?;
353 let new_record = StoredFactRecord {
354 id: record.id,
355 payload,
356 label: text,
359 retracted: record.retracted,
360 node_id: record.node_id,
361 hlc_timestamp: record.hlc_timestamp,
362 predicates: record.predicates,
363 };
364 let bytes = postcard::to_allocvec(&new_record)?;
365 rebuilt.push((record.id, bytes));
366 }
367
368 let migrated = rebuilt.len();
371 let txn = self.db.begin_write()?;
372 {
373 let mut table = txn.open_table(FACTS_TABLE)?;
374 for (id, bytes) in &rebuilt {
375 table.insert(*id, bytes.as_slice())?;
376 }
377 }
378 Self::stamp_current_version(&txn)?;
379 txn.commit()?;
380 self.found_version = SCHEMA_VERSION;
381 Ok(migrated)
382 }
383
384 fn tick(&mut self) -> u64 {
386 self.hlc += 1;
387 self.hlc
388 }
389
390 fn normalize_predicates<I, S>(predicates: I) -> Vec<String>
391 where
392 I: IntoIterator<Item = S>,
393 S: AsRef<str>,
394 {
395 let mut normalized: Vec<String> = predicates
396 .into_iter()
397 .map(|pred| pred.as_ref().to_string())
398 .collect();
399 normalized.sort();
400 normalized.dedup();
401 normalized
402 }
403
404 fn rebuild_predicate_index(txn: &WriteTransaction) -> Result<(), StoreError> {
405 let mut index_entries: HashMap<String, Vec<u64>> = HashMap::new();
406 {
407 let facts = txn.open_table(FACTS_TABLE)?;
408 for entry in facts.iter()? {
409 let (_, val) = entry?;
410 let record = decode_stored_fact_record(val.value())?;
411 if record.retracted {
412 continue;
413 }
414 for predicate in Self::normalize_predicates(record.predicates.iter()) {
415 index_entries.entry(predicate).or_default().push(record.id);
416 }
417 }
418 }
419
420 let mut pred_idx = txn.open_table(PREDICATE_INDEX_TABLE)?;
421 let existing_keys: Vec<String> = pred_idx
422 .iter()?
423 .map(|e| e.map(|(k, _)| k.value().to_string()))
424 .collect::<Result<_, _>>()?;
425 for key in &existing_keys {
426 pred_idx.remove(key.as_str())?;
427 }
428
429 let mut predicates: Vec<String> = index_entries.keys().cloned().collect();
430 predicates.sort();
431 for predicate in &predicates {
432 let ids = index_entries.get(predicate).unwrap();
433 let idx_bytes = postcard::to_allocvec(ids)?;
434 pred_idx.insert(predicate.as_str(), idx_bytes.as_slice())?;
435 }
436
437 Ok(())
438 }
439
440 fn predicate_memberships_from_index(&self) -> Result<HashMap<u64, Vec<String>>, StoreError> {
441 let rtxn = self.db.begin_read()?;
442 let pred_idx = rtxn.open_table(PREDICATE_INDEX_TABLE)?;
443 let mut memberships: HashMap<u64, Vec<String>> = HashMap::new();
444 for entry in pred_idx.iter()? {
445 let (pred, val) = entry?;
446 let predicate = pred.value().to_string();
447 let ids: Vec<u64> = postcard::from_bytes(val.value())?;
448 for id in ids {
449 memberships.entry(id).or_default().push(predicate.clone());
450 }
451 }
452 for predicates in memberships.values_mut() {
453 *predicates = Self::normalize_predicates(predicates.iter());
454 }
455 Ok(memberships)
456 }
457
458 pub fn insert_fact(
460 &mut self,
461 id: u64,
462 label: String,
463 payload: Vec<u8>,
464 ) -> Result<(), StoreError> {
465 let ts = self.tick();
466 let record = StoredFactRecord {
467 id,
468 payload,
469 label,
470 retracted: false,
471 node_id: self.node_id.clone(),
472 hlc_timestamp: ts,
473 predicates: Vec::new(),
474 };
475 let bytes = postcard::to_allocvec(&record)?;
476
477 let txn = self.db.begin_write()?;
478 {
479 let mut table = txn.open_table(FACTS_TABLE)?;
480 table.insert(id, bytes.as_slice())?;
481 }
482 txn.commit()?;
483 Ok(())
484 }
485
486 pub fn delete_fact(&mut self, id: u64) -> Result<(), StoreError> {
488 let txn = self.db.begin_write()?;
489 {
490 let mut facts = txn.open_table(FACTS_TABLE)?;
491 if facts.remove(id)?.is_none() {
492 return Err(StoreError::NotFound(id));
493 }
494 }
495 Self::rebuild_predicate_index(&txn)?;
496 txn.commit()?;
497 Ok(())
498 }
499
500 pub fn insert_fact_with_predicates(
502 &mut self,
503 id: u64,
504 label: String,
505 payload: Vec<u8>,
506 predicates: &[&str],
507 ) -> Result<(), StoreError> {
508 let ts = self.tick();
509 let record = StoredFactRecord {
510 id,
511 payload,
512 label,
513 retracted: false,
514 node_id: self.node_id.clone(),
515 hlc_timestamp: ts,
516 predicates: Self::normalize_predicates(predicates.iter()),
517 };
518 let bytes = postcard::to_allocvec(&record)?;
519
520 let txn = self.db.begin_write()?;
521 {
522 let mut facts = txn.open_table(FACTS_TABLE)?;
523 facts.insert(id, bytes.as_slice())?;
524 }
525 Self::rebuild_predicate_index(&txn)?;
526 txn.commit()?;
527 Ok(())
528 }
529
530 pub fn retract_fact(&mut self, id: u64) -> Result<(), StoreError> {
532 let txn = self.db.begin_write()?;
533 {
534 let mut table = txn.open_table(FACTS_TABLE)?;
535 let record_opt: Option<StoredFactRecord> = table
537 .get(id)?
538 .map(|g| decode_stored_fact_record(g.value()))
539 .transpose()?;
540 match record_opt {
541 Some(mut record) => {
542 if !record.retracted {
543 record.retracted = true;
544 record.hlc_timestamp = self.tick();
545 let bytes = postcard::to_allocvec(&record)?;
546 table.insert(id, bytes.as_slice())?;
547 }
548 }
549 None => return Err(StoreError::NotFound(id)),
550 }
551 }
552 Self::rebuild_predicate_index(&txn)?;
553 txn.commit()?;
554 Ok(())
555 }
556
557 pub fn all_active_facts(&self) -> Result<Vec<StoredFactRecord>, StoreError> {
559 let rtxn = self.db.begin_read()?;
560 let table = rtxn.open_table(FACTS_TABLE)?;
561 let mut results = Vec::new();
562 for entry in table.iter()? {
563 let (_, val) = entry?;
564 let record = decode_stored_fact_record(val.value())?;
565 if !record.retracted {
566 results.push(record);
567 }
568 }
569 Ok(results)
570 }
571
572 pub fn get_fact(&self, id: u64) -> Result<Option<StoredFactRecord>, StoreError> {
574 let rtxn = self.db.begin_read()?;
575 let table = rtxn.open_table(FACTS_TABLE)?;
576 match table.get(id)? {
577 Some(val) => {
578 let record = decode_stored_fact_record(val.value())?;
579 Ok(Some(record))
580 }
581 None => Ok(None),
582 }
583 }
584
585 pub fn max_fact_id(&self) -> Result<Option<u64>, StoreError> {
587 let rtxn = self.db.begin_read()?;
588 let table = rtxn.open_table(FACTS_TABLE)?;
589 match table.last()? {
590 Some((key, _)) => Ok(Some(key.value())),
591 None => Ok(None),
592 }
593 }
594
595 pub fn next_fact_id(&self) -> Result<u64, StoreError> {
597 match self.max_fact_id()? {
598 Some(id) => id
599 .checked_add(1)
600 .ok_or_else(|| StoreError::Io("fact ID space exhausted".to_string())),
601 None => Ok(0),
602 }
603 }
604
605 pub fn active_fact_count(&self) -> Result<usize, StoreError> {
607 let rtxn = self.db.begin_read()?;
608 let table = rtxn.open_table(FACTS_TABLE)?;
609 let mut count = 0;
610 for entry in table.iter()? {
611 let (_, val) = entry?;
612 let record = decode_stored_fact_record(val.value())?;
613 if !record.retracted {
614 count += 1;
615 }
616 }
617 Ok(count)
618 }
619
620 pub fn facts_for_predicate(&self, pred: &str) -> Result<Vec<u64>, StoreError> {
622 let rtxn = self.db.begin_read()?;
623 let table = rtxn.open_table(PREDICATE_INDEX_TABLE)?;
624 match table.get(pred)? {
625 Some(val) => {
626 let ids: Vec<u64> = postcard::from_bytes(val.value())?;
627 Ok(ids)
628 }
629 None => Ok(Vec::new()),
630 }
631 }
632
633 pub fn clear(&mut self) -> Result<(), StoreError> {
635 let txn = self.db.begin_write()?;
636 {
637 let mut facts = txn.open_table(FACTS_TABLE)?;
638 let keys: Vec<u64> = facts
640 .iter()?
641 .map(|e| e.map(|(k, _)| k.value()))
642 .collect::<Result<_, _>>()?;
643 for key in keys {
644 facts.remove(key)?;
645 }
646
647 let mut pred_idx = txn.open_table(PREDICATE_INDEX_TABLE)?;
648 let pred_keys: Vec<String> = pred_idx
649 .iter()?
650 .map(|e| e.map(|(k, _)| k.value().to_string()))
651 .collect::<Result<_, _>>()?;
652 for key in &pred_keys {
653 pred_idx.remove(key.as_str())?;
654 }
655 }
656 txn.commit()?;
657 self.hlc = 0;
658 Ok(())
659 }
660
661 pub fn export_all(&self) -> Result<Vec<StoredFactRecord>, StoreError> {
663 let predicate_memberships = self.predicate_memberships_from_index()?;
664 let rtxn = self.db.begin_read()?;
665 let table = rtxn.open_table(FACTS_TABLE)?;
666 let mut results = Vec::new();
667 for entry in table.iter()? {
668 let (_, val) = entry?;
669 let mut record = decode_stored_fact_record(val.value())?;
670 if let Some(predicates) = predicate_memberships.get(&record.id) {
671 record.predicates =
672 Self::normalize_predicates(record.predicates.iter().chain(predicates.iter()));
673 }
674 results.push(record);
675 }
676 Ok(results)
677 }
678
679 pub fn node_id(&self) -> &str {
681 &self.node_id
682 }
683
684 pub fn total_fact_count(&self) -> Result<usize, StoreError> {
687 let rtxn = self.db.begin_read()?;
688 let table = rtxn.open_table(FACTS_TABLE)?;
689 Ok(table.len()? as usize)
690 }
691
692 pub fn export_to_file(&self, path: &Path) -> Result<usize, StoreError> {
694 let facts = self.export_all()?;
695 let target = NibliStore::open(path, self.node_id.clone())?;
696 let txn = target.db.begin_write()?;
697 {
698 let mut table = txn.open_table(FACTS_TABLE)?;
699 for fact in &facts {
700 let bytes = postcard::to_allocvec(fact)?;
701 table.insert(fact.id, bytes.as_slice())?;
702 }
703 }
704 Self::rebuild_predicate_index(&txn)?;
705 txn.commit()?;
706 Ok(facts.len())
707 }
708}
709
710#[cfg(test)]
713mod tests {
714 use super::*;
715 use std::fs;
716
717 fn temp_db_path(name: &str) -> std::path::PathBuf {
718 let dir = std::env::temp_dir().join("nibli_store_tests");
719 fs::create_dir_all(&dir).unwrap();
720 dir.join(format!("{name}.redb"))
721 }
722
723 fn cleanup(path: &Path) {
724 let _ = fs::remove_file(path);
725 }
726
727 #[test]
728 fn test_open_and_insert() {
729 let path = temp_db_path("open_insert");
730 cleanup(&path);
731
732 let mut store = NibliStore::open(&path, "test-node".into()).unwrap();
733 store
734 .insert_fact(1, "test fact".into(), b"payload1".to_vec())
735 .unwrap();
736
737 let fact = store.get_fact(1).unwrap().unwrap();
738 assert_eq!(fact.id, 1);
739 assert_eq!(fact.label, "test fact");
740 assert_eq!(fact.payload, b"payload1");
741 assert!(!fact.retracted);
742 assert_eq!(fact.node_id, "test-node");
743
744 cleanup(&path);
745 }
746
747 #[test]
748 fn open_rejects_mismatched_schema_version() {
749 let path = temp_db_path("schema_version_reject");
750 cleanup(&path);
751
752 {
757 let db = Database::create(&path).unwrap();
758 let txn = db.begin_write().unwrap();
759 {
760 let mut meta = txn.open_table(META_TABLE).unwrap();
761 let bytes = postcard::to_allocvec(&1u32).unwrap();
762 meta.insert("schema_version", bytes.as_slice()).unwrap();
763 }
764 txn.commit().unwrap();
765 }
766
767 match NibliStore::open(&path, "test-node".into()) {
768 Err(StoreError::SchemaVersion { expected, found }) => {
769 assert_eq!(expected, SCHEMA_VERSION);
770 assert_eq!(found, 1);
771 }
772 Err(other) => panic!("expected a SchemaVersion error, got: {other:?}"),
773 Ok(_) => panic!("a stale schema version must be rejected"),
774 }
775
776 cleanup(&path);
777 }
778
779 #[test]
780 fn test_retraction_filters() {
781 let path = temp_db_path("retract_filter");
782 cleanup(&path);
783
784 let mut store = NibliStore::open(&path, "node".into()).unwrap();
785 store.insert_fact(1, "a".into(), vec![1]).unwrap();
786 store.insert_fact(2, "b".into(), vec![2]).unwrap();
787 store.insert_fact(3, "c".into(), vec![3]).unwrap();
788
789 store.retract_fact(2).unwrap();
790
791 let active = store.all_active_facts().unwrap();
792 assert_eq!(active.len(), 2);
793 assert!(active.iter().all(|f| f.id != 2));
794
795 let all = store.export_all().unwrap();
797 assert_eq!(all.len(), 3);
798
799 cleanup(&path);
800 }
801
802 #[test]
803 fn test_max_fact_id() {
804 let path = temp_db_path("max_id");
805 cleanup(&path);
806
807 let mut store = NibliStore::open(&path, "node".into()).unwrap();
808 assert_eq!(store.max_fact_id().unwrap(), None);
809
810 store.insert_fact(5, "five".into(), vec![]).unwrap();
811 store.insert_fact(10, "ten".into(), vec![]).unwrap();
812 store.insert_fact(3, "three".into(), vec![]).unwrap();
813
814 assert_eq!(store.max_fact_id().unwrap(), Some(10));
815
816 cleanup(&path);
817 }
818
819 #[test]
820 fn test_next_fact_id_and_delete_fact() {
821 let path = temp_db_path("next_id_delete");
822 cleanup(&path);
823
824 let mut store = NibliStore::open(&path, "node".into()).unwrap();
825 assert_eq!(store.next_fact_id().unwrap(), 0);
826
827 store.insert_fact(0, "zero".into(), vec![0]).unwrap();
828 store.insert_fact(2, "two".into(), vec![2]).unwrap();
829 assert_eq!(store.next_fact_id().unwrap(), 3);
830
831 store.delete_fact(2).unwrap();
832 assert!(store.get_fact(2).unwrap().is_none());
833 assert_eq!(store.next_fact_id().unwrap(), 1);
834
835 cleanup(&path);
836 }
837
838 #[test]
839 fn test_clear() {
840 let path = temp_db_path("clear");
841 cleanup(&path);
842
843 let mut store = NibliStore::open(&path, "node".into()).unwrap();
844 store.insert_fact(1, "a".into(), vec![1]).unwrap();
845 store.insert_fact(2, "b".into(), vec![2]).unwrap();
846
847 store.clear().unwrap();
848
849 assert_eq!(store.all_active_facts().unwrap().len(), 0);
850 assert_eq!(store.max_fact_id().unwrap(), None);
851
852 cleanup(&path);
853 }
854
855 #[test]
856 fn test_idempotent_retract() {
857 let path = temp_db_path("idempotent");
858 cleanup(&path);
859
860 let mut store = NibliStore::open(&path, "node".into()).unwrap();
861 store.insert_fact(1, "a".into(), vec![1]).unwrap();
862
863 store.retract_fact(1).unwrap();
864 store.retract_fact(1).unwrap(); let fact = store.get_fact(1).unwrap().unwrap();
867 assert!(fact.retracted);
868
869 cleanup(&path);
870 }
871
872 #[test]
873 fn test_retract_not_found() {
874 let path = temp_db_path("not_found");
875 cleanup(&path);
876
877 let mut store = NibliStore::open(&path, "node".into()).unwrap();
878 let err = store.retract_fact(999).unwrap_err();
879 assert!(matches!(err, StoreError::NotFound(999)));
880
881 cleanup(&path);
882 }
883
884 #[test]
885 fn test_predicate_index() {
886 let path = temp_db_path("pred_idx");
887 cleanup(&path);
888
889 let mut store = NibliStore::open(&path, "node".into()).unwrap();
890 store
891 .insert_fact_with_predicates(1, "a".into(), vec![1], &["gerku", "danlu"])
892 .unwrap();
893 store
894 .insert_fact_with_predicates(2, "b".into(), vec![2], &["gerku"])
895 .unwrap();
896 store
897 .insert_fact_with_predicates(3, "c".into(), vec![3], &["mlatu"])
898 .unwrap();
899
900 let dog_ids = store.facts_for_predicate("gerku").unwrap();
901 assert_eq!(dog_ids, vec![1, 2]);
902
903 let danlu_ids = store.facts_for_predicate("danlu").unwrap();
904 assert_eq!(danlu_ids, vec![1]);
905
906 let mlatu_ids = store.facts_for_predicate("mlatu").unwrap();
907 assert_eq!(mlatu_ids, vec![3]);
908
909 let empty = store.facts_for_predicate("blanu").unwrap();
910 assert!(empty.is_empty());
911
912 cleanup(&path);
913 }
914
915 #[test]
916 fn test_retract_removes_fact_from_predicate_index() {
917 let path = temp_db_path("pred_idx_retract");
918 cleanup(&path);
919
920 let mut store = NibliStore::open(&path, "node".into()).unwrap();
921 store
922 .insert_fact_with_predicates(1, "a".into(), vec![1], &["gerku"])
923 .unwrap();
924 store
925 .insert_fact_with_predicates(2, "b".into(), vec![2], &["gerku"])
926 .unwrap();
927
928 store.retract_fact(1).unwrap();
929
930 let dog_ids = store.facts_for_predicate("gerku").unwrap();
931 assert_eq!(dog_ids, vec![2]);
932
933 cleanup(&path);
934 }
935
936 #[test]
937 fn test_stored_assertion_roundtrip() {
938 let text_assertion = StoredAssertion::Text("ro lo gerku cu danlu".into());
939 let bytes = postcard::to_allocvec(&text_assertion).unwrap();
940 let decoded: StoredAssertion = postcard::from_bytes(&bytes).unwrap();
941 match decoded {
942 StoredAssertion::Text(s) => assert_eq!(s, "ro lo gerku cu danlu"),
943 _ => panic!("expected Text variant"),
944 }
945
946 let direct_assertion = StoredAssertion::Direct {
947 relation: "gerku".into(),
948 args: vec![StoredLogicalTerm::Constant("adam".into())],
949 };
950 let bytes = postcard::to_allocvec(&direct_assertion).unwrap();
951 let decoded: StoredAssertion = postcard::from_bytes(&bytes).unwrap();
952 match decoded {
953 StoredAssertion::Direct { relation, args } => {
954 assert_eq!(relation, "gerku");
955 assert_eq!(args.len(), 1);
956 }
957 _ => panic!("expected Direct variant"),
958 }
959
960 let buffer_assertion = StoredAssertion::Buffer(vec![1, 2, 3, 4]);
965 let bytes = postcard::to_allocvec(&buffer_assertion).unwrap();
966 let decoded: StoredAssertion = postcard::from_bytes(&bytes).unwrap();
967 match decoded {
968 StoredAssertion::Buffer(payload) => assert_eq!(payload, vec![1, 2, 3, 4]),
969 _ => panic!("expected Buffer variant"),
970 }
971 }
972
973 fn seed_v2_db(path: &Path, records: &[StoredFactRecord]) {
978 let db = Database::create(path).unwrap();
979 let txn = db.begin_write().unwrap();
980 {
981 let mut facts = txn.open_table(FACTS_TABLE).unwrap();
982 for r in records {
983 let bytes = postcard::to_allocvec(r).unwrap();
984 facts.insert(r.id, bytes.as_slice()).unwrap();
985 }
986 let mut meta = txn.open_table(META_TABLE).unwrap();
987 let vb = postcard::to_allocvec(&2u32).unwrap();
988 meta.insert("schema_version", vb.as_slice()).unwrap();
989 }
990 txn.commit().unwrap();
991 }
992
993 fn text_record(id: u64, text: &str, retracted: bool) -> StoredFactRecord {
994 StoredFactRecord {
995 id,
996 payload: postcard::to_allocvec(&StoredAssertion::Text(text.into())).unwrap(),
997 label: text.into(),
998 retracted,
999 node_id: "seed-node".into(),
1000 hlc_timestamp: id, predicates: Vec::new(),
1002 }
1003 }
1004
1005 #[test]
1006 fn open_accepts_v2_and_flags_migration() {
1007 let path = temp_db_path("v2_migratable");
1008 cleanup(&path);
1009 seed_v2_db(&path, &[text_record(1, "dog(Adam).", false)]);
1010
1011 let store = NibliStore::open(&path, "node".into()).unwrap();
1012 assert_eq!(store.found_version(), 2);
1013 assert!(store.needs_migration());
1014 drop(store);
1015
1016 let store = NibliStore::open(&path, "node".into()).unwrap();
1018 assert_eq!(store.found_version(), 2);
1019 assert!(store.needs_migration());
1020
1021 cleanup(&path);
1022 }
1023
1024 #[test]
1025 fn open_rejects_future_schema_version() {
1026 let path = temp_db_path("v_future_reject");
1027 cleanup(&path);
1028 {
1029 let db = Database::create(&path).unwrap();
1030 let txn = db.begin_write().unwrap();
1031 {
1032 let mut meta = txn.open_table(META_TABLE).unwrap();
1033 let bytes = postcard::to_allocvec(&(SCHEMA_VERSION + 1)).unwrap();
1034 meta.insert("schema_version", bytes.as_slice()).unwrap();
1035 }
1036 txn.commit().unwrap();
1037 }
1038 match NibliStore::open(&path, "node".into()) {
1039 Err(StoreError::SchemaVersion { expected, found }) => {
1040 assert_eq!(expected, SCHEMA_VERSION);
1041 assert_eq!(found, SCHEMA_VERSION + 1);
1042 }
1043 Err(other) => panic!("expected a SchemaVersion error, got: {other:?}"),
1044 Ok(_) => panic!("a future schema version must be rejected"),
1045 }
1046 cleanup(&path);
1047 }
1048
1049 #[test]
1050 fn migrate_v2_text_rows_rewrites_only_active_text_and_stamps_v3() {
1051 let path = temp_db_path("v3_migrate");
1052 cleanup(&path);
1053 let direct = StoredFactRecord {
1054 id: 2,
1055 payload: postcard::to_allocvec(&StoredAssertion::Direct {
1056 relation: "cat".into(),
1057 args: vec![StoredLogicalTerm::Constant("bel".into())],
1058 })
1059 .unwrap(),
1060 label: ":assert cat bel".into(),
1061 retracted: false,
1062 node_id: "seed-node".into(),
1063 hlc_timestamp: 2,
1064 predicates: Vec::new(),
1065 };
1066 let buffer = StoredFactRecord {
1067 id: 3,
1068 payload: postcard::to_allocvec(&StoredAssertion::Buffer(vec![9, 9, 9])).unwrap(),
1069 label: "person(Kim).".into(),
1070 retracted: false,
1071 node_id: "seed-node".into(),
1072 hlc_timestamp: 3,
1073 predicates: Vec::new(),
1074 };
1075 seed_v2_db(
1076 &path,
1077 &[
1078 text_record(1, "dog(Adam).", false),
1079 direct.clone(),
1080 buffer.clone(),
1081 text_record(4, "obsolete(X).", true), ],
1083 );
1084
1085 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1086 let migrated = store
1088 .migrate_v2_text_rows(|text| Ok::<Vec<u8>, String>(format!("BUF:{text}").into_bytes()))
1089 .unwrap();
1090 assert_eq!(migrated, 1, "only the one ACTIVE Text row migrates");
1091 assert!(!store.needs_migration());
1092 assert_eq!(store.found_version(), SCHEMA_VERSION);
1093
1094 let r1 = store.get_fact(1).unwrap().unwrap();
1096 match postcard::from_bytes::<StoredAssertion>(&r1.payload).unwrap() {
1097 StoredAssertion::Buffer(inner) => assert_eq!(inner, b"BUF:dog(Adam)."),
1098 other => panic!("row 1 must be Buffer, got {other:?}"),
1099 }
1100 assert_eq!(r1.label, "dog(Adam).");
1101 assert!(!r1.retracted);
1102 assert_eq!(r1.node_id, "seed-node");
1103 assert_eq!(r1.hlc_timestamp, 1, "migration must not tick the HLC");
1104
1105 assert_eq!(store.get_fact(2).unwrap().unwrap().payload, direct.payload);
1107 assert_eq!(store.get_fact(3).unwrap().unwrap().payload, buffer.payload);
1108
1109 let r4 = store.get_fact(4).unwrap().unwrap();
1111 assert!(r4.retracted);
1112 assert!(matches!(
1113 postcard::from_bytes::<StoredAssertion>(&r4.payload).unwrap(),
1114 StoredAssertion::Text(_)
1115 ));
1116
1117 drop(store);
1119 let store = NibliStore::open(&path, "node".into()).unwrap();
1120 assert!(!store.needs_migration());
1121 cleanup(&path);
1122 }
1123
1124 #[test]
1125 fn migrate_v2_text_rows_fails_closed_and_leaves_db_at_v2() {
1126 let path = temp_db_path("v3_migrate_failclosed");
1127 cleanup(&path);
1128 seed_v2_db(&path, &[text_record(1, "unparseable lojban", false)]);
1129
1130 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1131 let err = store
1132 .migrate_v2_text_rows(|_| Err::<Vec<u8>, String>("parse error".into()))
1133 .unwrap_err();
1134 assert!(
1135 matches!(err, StoreError::Migration { id: 1, .. }),
1136 "{err:?}"
1137 );
1138 drop(store);
1139
1140 let store = NibliStore::open(&path, "node".into()).unwrap();
1142 assert_eq!(
1143 store.found_version(),
1144 2,
1145 "must remain v2 after a failed migration"
1146 );
1147 let r1 = store.get_fact(1).unwrap().unwrap();
1148 assert!(matches!(
1149 postcard::from_bytes::<StoredAssertion>(&r1.payload).unwrap(),
1150 StoredAssertion::Text(t) if t == "unparseable lojban"
1151 ));
1152 cleanup(&path);
1153 }
1154
1155 #[test]
1156 fn migrate_v2_text_rows_with_no_text_still_stamps_v3() {
1157 let path = temp_db_path("v3_migrate_notext");
1160 cleanup(&path);
1161 let buffer = StoredFactRecord {
1162 id: 1,
1163 payload: postcard::to_allocvec(&StoredAssertion::Buffer(vec![1, 2, 3])).unwrap(),
1164 label: "dog(Adam).".into(),
1165 retracted: false,
1166 node_id: "seed-node".into(),
1167 hlc_timestamp: 1,
1168 predicates: Vec::new(),
1169 };
1170 seed_v2_db(&path, &[buffer]);
1171
1172 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1173 let migrated = store
1174 .migrate_v2_text_rows(|text| Ok::<Vec<u8>, String>(text.as_bytes().to_vec()))
1175 .unwrap();
1176 assert_eq!(migrated, 0);
1177 assert!(!store.needs_migration());
1178 assert_eq!(store.found_version(), SCHEMA_VERSION);
1179 cleanup(&path);
1180 }
1181
1182 #[test]
1183 fn finalize_v3_restamps_without_touching_rows() {
1184 let path = temp_db_path("v3_finalize");
1186 cleanup(&path);
1187 let bare = StoredFactRecord {
1188 id: 1,
1189 payload: vec![42, 7, 7], label: "dog(Adam).".into(),
1191 retracted: false,
1192 node_id: "seed-node".into(),
1193 hlc_timestamp: 1,
1194 predicates: Vec::new(),
1195 };
1196 seed_v2_db(&path, std::slice::from_ref(&bare));
1197
1198 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1199 assert!(store.needs_migration());
1200 store.finalize_v3().unwrap();
1201 assert!(!store.needs_migration());
1202 assert_eq!(store.get_fact(1).unwrap().unwrap().payload, bare.payload);
1203 drop(store);
1204 let store = NibliStore::open(&path, "node".into()).unwrap();
1205 assert!(!store.needs_migration());
1206 cleanup(&path);
1207 }
1208
1209 #[test]
1210 fn test_persistence_across_reopen() {
1211 let path = temp_db_path("reopen");
1212 cleanup(&path);
1213
1214 {
1215 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1216 store.insert_fact(1, "a".into(), vec![10]).unwrap();
1217 store.insert_fact(2, "b".into(), vec![20]).unwrap();
1218 store.retract_fact(2).unwrap();
1219 }
1220
1221 {
1223 let store = NibliStore::open(&path, "node".into()).unwrap();
1224 let active = store.all_active_facts().unwrap();
1225 assert_eq!(active.len(), 1);
1226 assert_eq!(active[0].id, 1);
1227 assert_eq!(active[0].payload, vec![10]);
1228
1229 let retracted = store.get_fact(2).unwrap().unwrap();
1230 assert!(retracted.retracted);
1231 }
1232
1233 cleanup(&path);
1234 }
1235
1236 #[test]
1237 fn test_active_and_total_counts() {
1238 let path = temp_db_path("counts");
1239 cleanup(&path);
1240
1241 let mut store = NibliStore::open(&path, "node".into()).unwrap();
1242 store.insert_fact(1, "a".into(), vec![]).unwrap();
1243 store.insert_fact(2, "b".into(), vec![]).unwrap();
1244 store.insert_fact(3, "c".into(), vec![]).unwrap();
1245 store.retract_fact(2).unwrap();
1246
1247 assert_eq!(store.active_fact_count().unwrap(), 2);
1248 assert_eq!(store.total_fact_count().unwrap(), 3);
1249
1250 cleanup(&path);
1251 }
1252
1253 #[test]
1254 fn test_export_to_file() {
1255 let src_path = temp_db_path("export_src");
1256 let dst_path = temp_db_path("export_dst");
1257 cleanup(&src_path);
1258 cleanup(&dst_path);
1259
1260 let mut store = NibliStore::open(&src_path, "node-a".into()).unwrap();
1261 store
1262 .insert_fact_with_predicates(1, "a".into(), vec![10], &["gerku"])
1263 .unwrap();
1264 store
1265 .insert_fact_with_predicates(2, "b".into(), vec![20], &["mlatu"])
1266 .unwrap();
1267 store.retract_fact(2).unwrap();
1268
1269 let count = store.export_to_file(&dst_path).unwrap();
1270 assert_eq!(count, 2); let dst = NibliStore::open(&dst_path, "node-b".into()).unwrap();
1273 assert_eq!(dst.total_fact_count().unwrap(), 2);
1274 assert_eq!(dst.active_fact_count().unwrap(), 1);
1275 assert_eq!(dst.facts_for_predicate("gerku").unwrap(), vec![1]);
1276 assert!(dst.facts_for_predicate("mlatu").unwrap().is_empty());
1277
1278 cleanup(&src_path);
1279 cleanup(&dst_path);
1280 }
1281}