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;