Skip to main content

velesdb_memory/
export.rs

1//! Export a store's facts as JSONL — one JSON object per line — WITHOUT an
2//! embedder.
3//!
4//! The design constraint is the second half of the store's ownership story:
5//! `list_memories` audits a store the daemon can serve, and THIS path reads
6//! a store the daemon may refuse — a provenance mismatch (#1751) blocks the
7//! service from opening, and blocking the user's own data behind the very
8//! misconfiguration they are trying to escape would make the refusal a trap
9//! instead of a guard. Reading content requires no vectors, so the export
10//! opens the engine directly and never builds an embedder at all.
11//!
12//! Same enumeration as the migration rebuild ([`crate::migration`], #1762):
13//! the id-keyed cursor walk, complete and TTL-skipping — an export must show
14//! what the store would still serve, and an expired fact is not it.
15
16use std::io::Write;
17use std::path::Path;
18
19use crate::error::MemoryError;
20use crate::storage::{is_internal_scaffolding, strip_reserved_keys};
21
22/// Page size of the export walk. Purely an I/O batch — no ranking, no
23/// scoring — so the only tradeoff is memory per page.
24const EXPORT_BATCH: usize = 512;
25
26/// Write every live fact of the store at `store_dir` to `out`, one JSON
27/// object per line: `{"id", "id_str", "content", "metadata"}`. Returns how
28/// many lines were written.
29///
30/// Visibility follows `list_memories`' policy: internal graph scaffolding is
31/// skipped and reserved keys are stripped (the auto-stamped date survives),
32/// unless `include_internal` — a backup wants everything verbatim.
33///
34/// No embedder is built and no provenance check runs: this is the one read
35/// path that must work on a store whose configured embedder no longer
36/// matches — your data stays yours even mid-misconfiguration.
37///
38/// # Errors
39/// Returns [`MemoryError`] when the store cannot be opened or walked, and
40/// I/O errors from `out` wrapped as [`MemoryError::InvalidFilter`]-free
41/// storage errors.
42pub fn export_jsonl<W: Write>(
43    store_dir: &Path,
44    out: &mut W,
45    include_internal: bool,
46) -> Result<u64, MemoryError> {
47    // Refused BEFORE the engine sees the path: `Database::open` creates a
48    // store that is not there, and an export that materialises an empty
49    // store at a typo'd path would corrupt the very question it answers
50    // ("what is in my store?" — nothing, now).
51    if !store_dir.is_dir() {
52        return Err(MemoryError::Storage(velesdb_core::Error::Query(format!(
53            "no store directory at {} — nothing to export",
54            store_dir.display()
55        ))));
56    }
57    let db = velesdb_core::Database::open(store_dir)?;
58    let mut written = 0_u64;
59    let mut cursor: Option<u64> = None;
60    loop {
61        let (facts, next) =
62            crate::migration::scroll_page(&db, "_semantic_memory", cursor, EXPORT_BATCH)?;
63        written += write_page(out, &facts, include_internal)?;
64        match next {
65            Some(id) => cursor = Some(id),
66            None => break,
67        }
68    }
69    Ok(written)
70}
71
72/// Write one page of the walk, returning how many lines it produced. Split
73/// from [`export_jsonl`] so the walk reads as a walk — page, write, advance.
74fn write_page<W: Write>(
75    out: &mut W,
76    facts: &[crate::migration::RawFact],
77    include_internal: bool,
78) -> Result<u64, MemoryError> {
79    let mut written = 0_u64;
80    for fact in facts {
81        if let Some(line) = jsonl_line(fact, include_internal) {
82            writeln!(out, "{line}").map_err(|err| {
83                MemoryError::Storage(velesdb_core::Error::Query(format!(
84                    "export write failed: {err}"
85                )))
86            })?;
87            written += 1;
88        }
89    }
90    Ok(written)
91}
92
93/// One fact as its JSONL line, or `None` when the visibility policy skips
94/// it (internal scaffolding under the default view). Split from the walk so
95/// the loop reads as what it is — enumerate, filter, write.
96fn jsonl_line(
97    fact: &crate::migration::RawFact,
98    include_internal: bool,
99) -> Option<serde_json::Value> {
100    let split = crate::storage::RawListedFact::from_raw(fact);
101    if !include_internal && is_internal_scaffolding(&split.payload) {
102        return None;
103    }
104    let metadata = if include_internal {
105        (!split.payload.is_empty()).then_some(split.payload)
106    } else {
107        strip_reserved_keys(Some(split.payload))
108    };
109    Some(serde_json::json!({
110        "id": split.id,
111        "id_str": split.id.to_string(),
112        "content": split.content,
113        "metadata": metadata,
114    }))
115}
116
117#[cfg(test)]
118#[path = "export_tests.rs"]
119mod tests;