Skip to main content

nibli_store/
lib.rs

1//! Persistent disk-backed knowledge base store for Nibli.
2//!
3//! Uses redb (pure Rust, ACID, embedded key-value store) with postcard
4//! serialization. The store persists FactRecords to disk with soft-delete
5//! (tombstone) retraction.
6
7/// Persistent typed fact store (StoredFact → redb) with lazy loading.
8pub 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
16// ─── redb table definitions ───────────────────────────────────────
17
18const 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
22/// On-disk schema version of the durable fact registry.
23///
24/// - **v3 (current):** every ACTIVE fact row is `StoredAssertion::Buffer`/`Direct`
25///   (or a bare `LogicBuffer` for engine-written DBs) — no active `Text` rows. The
26///   legacy `Text` recompile-on-replay path is gone; a v2 DB is migrated once on open
27///   (`migrate_v2_text_rows`, host) or restamped (`finalize_v3`, engine).
28/// - **v2:** accepted as *migratable* (see `open`), not rejected.
29/// - **v1:** hard-rejected — its payload byte layout predates the nibli-engine
30///   StoredLogicBuffer removal and cannot be reinterpreted.
31const SCHEMA_VERSION: u32 = 3;
32
33/// The immediately-prior schema version, accepted by `open` as migratable.
34const MIGRATABLE_FROM_VERSION: u32 = 2;
35
36// ─── Serializable mirror types ────────────────────────────────────
37
38/// A logical term, mirroring WIT `logical-term` for serialization.
39///
40/// Still used by `StoredAssertion::Direct` (nibli-host's direct-fact injection path).
41/// nibli-engine no longer mirrors the full logic graph here — it persists
42/// `nibli_types::logic::LogicBuffer` directly (serde-derived) as the opaque payload.
43#[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/// A fact record persisted to disk.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct StoredFactRecord {
55    pub id: u64,
56    /// Opaque payload — caller decides format.
57    /// nibli-engine: postcard-serialized `nibli_types::logic::LogicBuffer`.
58    /// nibli-host: postcard-serialized `StoredAssertion`.
59    pub payload: Vec<u8>,
60    /// Human-readable label (nibli KR source or `:assert rel args`).
61    pub label: String,
62    /// Soft-delete (tombstone) flag.
63    pub retracted: bool,
64    /// Provenance: which node asserted this fact.
65    pub node_id: String,
66    /// Monotonic logical (HLC) clock, stamped per fact for ordering.
67    pub hlc_timestamp: u64,
68    /// Predicate names referenced by this fact for index rebuilds.
69    #[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/// Assertion type for nibli-host (WASM host) persistence.
78#[derive(Clone, Debug, Serialize, Deserialize)]
79pub enum StoredAssertion {
80    /// LEGACY, MIGRATION-DECODE-ONLY: pre-buffer-persistence source text.
81    /// NEVER constructed at runtime. Retained at discriminant 0 so `Direct`=1 /
82    /// `Buffer`=2 stay stable (postcard discriminants are declaration-ordered —
83    /// removing this variant would silently misread every existing row), and so
84    /// the v2→v3 migration can recover the source string to recompile it into a
85    /// `Buffer` (`migrate_v2_text_rows`). v3 has no ACTIVE `Text` rows; a
86    /// surviving active `Text` row on replay is a migration bug (fail-closed).
87    Text(String),
88    /// Direct fact injection — replayed via `assert_fact`.
89    Direct {
90        relation: String,
91        args: Vec<StoredLogicalTerm>,
92    },
93    /// A compiled single-root fact buffer (postcard-serialized
94    /// `nibli_types::logic::LogicBuffer`, nested opaquely so this crate keeps
95    /// no nibli-types dependency) — replayed recompile-free via the WIT
96    /// `assert-buffer-with-id`. One record per root of a multi-`.i` assert.
97    /// APPENDED variant: postcard discriminants are declaration-ordered, so
98    /// `Text`=0 / `Direct`=1 stay stable and old rows decode unchanged (no
99    /// SCHEMA_VERSION bump — existing row bytes are not reinterpreted).
100    Buffer(Vec<u8>),
101}
102
103/// Store error type.
104#[derive(Debug)]
105pub enum StoreError {
106    Io(String),
107    Serialization(String),
108    NotFound(u64),
109    SchemaVersion {
110        expected: u32,
111        found: u32,
112    },
113    /// A v2→v3 migration could not recompile a legacy `Text` row. Fail-closed: the
114    /// store is left untouched at v2 so the row is never silently dropped.
115    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
184// ─── NibliStore ───────────────────────────────────────────────────
185
186/// Persistent fact store backed by redb.
187pub struct NibliStore {
188    db: Database,
189    node_id: String,
190    hlc: u64,
191    /// The schema version found on disk at `open`. Equals `SCHEMA_VERSION` for a
192    /// fresh/current DB; `MIGRATABLE_FROM_VERSION` (2) when a migration is pending
193    /// (see `needs_migration`). Updated in place by `finalize_v3`/`migrate_v2_text_rows`.
194    found_version: u32,
195}
196
197impl NibliStore {
198    /// Open or create a store at the given path.
199    ///
200    /// Version handling is three-way: the current `SCHEMA_VERSION` (3) opens as-is;
201    /// `MIGRATABLE_FROM_VERSION` (2) opens but flags a pending migration
202    /// (`needs_migration` → the caller runs `migrate_v2_text_rows` (host) or
203    /// `finalize_v3` (engine)); a fresh (versionless) DB is stamped current; anything
204    /// else (v1, or a future version) is hard-rejected with `SchemaVersion`.
205    pub fn open(path: &Path, node_id: String) -> Result<Self, StoreError> {
206        let db = Database::create(path)?;
207
208        // Ensure tables exist and check schema version.
209        let mut found_version = SCHEMA_VERSION;
210        let txn = db.begin_write()?;
211        {
212            // Create tables if they don't exist.
213            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            // Check or set schema version.
218            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                // A v2 DB is migratable: open it, record the pending state, and do NOT
227                // stamp v3 yet — the caller migrates (recompiling any legacy Text rows)
228                // and finalizes. Stamping here would lose the "needs migration" signal.
229                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        // Recover HLC from max fact timestamp.
247        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    /// The schema version found on disk at `open` (before any migration).
270    pub fn found_version(&self) -> u32 {
271        self.found_version
272    }
273
274    /// Whether the DB opened at an older, migratable schema version and still needs
275    /// its migration run + finalize (`migrate_v2_text_rows` or `finalize_v3`).
276    pub fn needs_migration(&self) -> bool {
277        self.found_version != SCHEMA_VERSION
278    }
279
280    /// Stamp the on-disk schema version to `SCHEMA_VERSION` in one write transaction.
281    /// Shared by `finalize_v3` and the migration's final txn.
282    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    /// Finalize a v2→v3 upgrade with NO row changes — the engine path, whose payloads
290    /// are bare `LogicBuffer`s (no `StoredAssertion::Text` rows to recompile). Just
291    /// stamps the version. Idempotent.
292    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    /// Migrate a v2 host DB to v3: recompile every ACTIVE `StoredAssertion::Text` row
301    /// into a `StoredAssertion::Buffer` row (via the caller-supplied `recompile`, which
302    /// turns the source text into a postcard-serialized `LogicBuffer`), then stamp v3 —
303    /// all in one atomic write transaction.
304    ///
305    /// Fail-closed, never a silent drop: if `recompile` errors on any active Text row,
306    /// this returns `StoreError::Migration` BEFORE any write, so the DB is left untouched
307    /// at v2 and the row survives for recovery. Retracted Text rows are left as-is (they
308    /// are never replayed); the resulting invariant is "v3 has no ACTIVE Text rows".
309    /// A migrated row keeps its id/retracted/node_id/hlc/predicates and takes its label
310    /// from the recovered source text; the HLC is NOT ticked (a rewrite is not a new
311    /// logical event). Returns the number of rows migrated. Idempotent on a DB with no
312    /// active Text rows (it just stamps v3).
313    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        // Phase 1 (read): collect active Text rows as (record, source-text). Direct/Buffer
319        // rows and retracted rows are left untouched. A payload that does not decode as
320        // StoredAssertion is fail-closed (a corrupt or wrong-encoding row — not silently
321        // skipped).
322        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        // Phase 2 (recompile, no writes yet): build every replacement record. Any failure
345        // returns here — before the write txn — so the DB stays at v2 untouched.
346        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                // Source the label from the recovered payload text (byte-identical to the
357                // KB label the legacy Text replay used), independent of record.label.
358                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        // Phase 3 (one atomic write): rewrite the rows and stamp v3 together. A crash
369        // before commit leaves a clean v2 DB.
370        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    /// Advance the HLC and return the new timestamp.
385    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    /// Insert a new fact record.
459    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    /// Permanently remove a fact record. Used to roll back failed local assertions.
487    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    /// Insert a fact with predicate index entries.
501    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    /// Mark a fact as retracted (tombstone). Idempotent.
531    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            // Read first, drop the guard, then mutate.
536            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    /// Load all active (non-retracted) facts, ordered by ID.
558    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    /// Load a single fact by ID.
573    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    /// Get the highest fact ID stored (for counter recovery).
586    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    /// Return the next unused fact ID.
596    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    /// Get the count of active (non-retracted) facts.
606    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    /// Get fact IDs for a given predicate name.
621    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    /// Clear all facts and indices. Used for `:reset`.
634    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            // Collect keys first to avoid borrow conflict.
639            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    /// Export all facts (including retracted) for CRDT sync.
662    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    /// Get the store's node ID.
680    pub fn node_id(&self) -> &str {
681        &self.node_id
682    }
683
684    /// Get the current DB path (not stored — caller tracks this).
685    /// Returns the total number of fact records (including retracted).
686    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    /// Export all facts (including retracted) to a new redb file.
693    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// ─── Tests ────────────────────────────────────────────────────────
711
712#[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        // Simulate an older on-disk database by writing schema_version = 1
753        // directly into the metadata table (the payload byte layout changed
754        // when nibli-engine moved off the StoredLogicBuffer mirror, so v1 DBs
755        // must be hard-rejected rather than silently misread).
756        {
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        // Retracted fact still in export_all.
796        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(); // Second retraction is no-op.
865
866        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        // The appended Buffer variant round-trips its opaque payload, and —
961        // discriminant stability — bytes written BEFORE the variant existed
962        // still decode: Text=0 / Direct=1 are declaration-ordered postcard
963        // tags, unchanged by the append.
964        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    // ─── v2 → v3 migration ────────────────────────────────────────
974
975    /// Write records + stamp `schema_version = 2` directly, simulating a pre-migration
976    /// host DB (mirrors the raw-redb seed in `open_rejects_mismatched_schema_version`).
977    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, // arbitrary but distinct
1001            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        // Opening must NOT auto-stamp: a reopen still reports the migratable v2 state.
1017        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), // retracted Text — left as-is
1082            ],
1083        );
1084
1085        let mut store = NibliStore::open(&path, "node".into()).unwrap();
1086        // Mock recompile: text → deterministic inner bytes (the store treats them opaquely).
1087        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        // Row 1: Text → Buffer(inner), label from the payload text, other fields preserved.
1095        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        // Direct + Buffer rows untouched.
1106        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        // Retracted Text row is left as-is (still Text, still tombstoned).
1110        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        // Reopen: now a plain v3 store, no migration pending.
1118        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        // Fail-closed: the DB is untouched — still v2, the Text row intact.
1141        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        // A v2 DB that is already all-Buffer/Direct (the common case) migrates zero rows
1158        // but MUST still advance to v3.
1159        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        // The engine path: bare-payload v2 DB → finalize_v3 restamps, rows untouched.
1185        let path = temp_db_path("v3_finalize");
1186        cleanup(&path);
1187        let bare = StoredFactRecord {
1188            id: 1,
1189            payload: vec![42, 7, 7], // a bare LogicBuffer stand-in (not a StoredAssertion)
1190            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        // Reopen — data should survive.
1222        {
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); // includes retracted
1271
1272        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}