Skip to main content

rete_core/
manifest.rs

1//! **Manifest** — a writable *logical graph* as an ordered log of immutable
2//! `.rete` segments.
3//!
4//! A `.rete` file is deliberately immutable: sorted delta-encoded tiles,
5//! offset-chained sections, a content hash over the whole payload — that is
6//! what makes it range-cacheable forever. Mutation therefore lives *outside*
7//! the file, LSM-style: a small JSON **manifest** lists an ordered log of
8//! entries, each contributing an optional **adds** segment and an optional
9//! **dels** segment (a *tombstone* file — a plain `.rete` whose quads are the
10//! deleted quads). The visible graph is the ordered fold
11//!
12//! ```text
13//! visible = ∅
14//! for entry in log:  visible = (visible ∖ entry.dels) ∪ entry.adds
15//! ```
16//!
17//! so deletion, update (delete + insert — RDF has no in-place update), and
18//! re-add-after-delete all follow from entry order. Compaction folds the log
19//! into one fresh `.rete` and resets it to a single entry. The manifest is the
20//! only mutable object; segments are content-addressed by the 16-byte blake3
21//! content hash every `.rete` already carries in its header.
22//!
23//! This module is the pure data model: parse/serialize (`serde_json`, matching
24//! the Dataset Card convention) and the fold. All I/O — resolving segment
25//! URLs, verifying descriptors, journals — lives in the CLI layer.
26
27use std::collections::BTreeSet;
28
29use serde_json::{json, Value};
30
31use crate::ingest::RawQuad;
32
33/// The manifest format version this build reads and writes.
34pub const MANIFEST_VERSION: u64 = 1;
35
36/// Conventional manifest file suffix (a plain `.json` also parses).
37pub const MANIFEST_SUFFIX: &str = ".rete-manifest.json";
38
39/// One immutable segment: where it is and what it must be. `url` is either
40/// absolute `http(s)://` or relative to the manifest's own location. `size`
41/// and `blake3_16` (hex of header bytes 8..24, the content hash) pin the exact
42/// artifact — the same release contract `datasets.lock.json` uses.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SegmentRef {
45    /// Where the segment lives: absolute `http(s)://`, or relative to the manifest.
46    pub url: String,
47    /// The exact file size in bytes.
48    pub size: u64,
49    /// Hex of the 16-byte blake3 content hash (header bytes 8..24).
50    pub blake3_16: String,
51}
52
53/// One log entry: net additions and/or net deletions (tombstones). Within an
54/// entry `adds ∩ dels = ∅` by construction (a seal nets the journal per quad),
55/// so their relative order does not matter; *entries* are strictly ordered.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct LogEntry {
58    /// Quads this entry adds to the visible graph.
59    pub adds: Option<SegmentRef>,
60    /// Quads this entry deletes (a tombstone segment).
61    pub dels: Option<SegmentRef>,
62}
63
64/// A parsed manifest: the ordered segment log plus the generation counter
65/// readers use to detect staleness (one ETag-sized poll, never the data).
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Manifest {
68    /// The logical graph's name (segment files are conventionally named from it).
69    pub name: String,
70    /// Monotonic change counter: bumped by every append/seal/compact.
71    pub generation: u64,
72    /// The ordered segment log; the visible graph is its fold.
73    pub log: Vec<LogEntry>,
74}
75
76/// Manifest parse/validation failure — the message says what and where.
77#[derive(Debug)]
78pub struct ManifestError(/** what failed, and where in the document */ pub String);
79
80impl std::fmt::Display for ManifestError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "manifest: {}", self.0)
83    }
84}
85
86impl std::error::Error for ManifestError {}
87
88fn err(msg: impl Into<String>) -> ManifestError {
89    ManifestError(msg.into())
90}
91
92fn segment_from_json(v: &Value, at: &str) -> Result<SegmentRef, ManifestError> {
93    let url = v["url"]
94        .as_str()
95        .ok_or_else(|| err(format!("{at}: missing string `url`")))?;
96    let size = v["size"]
97        .as_u64()
98        .ok_or_else(|| err(format!("{at}: missing integer `size`")))?;
99    let blake3_16 = v["blake3_16"]
100        .as_str()
101        .ok_or_else(|| err(format!("{at}: missing string `blake3_16`")))?;
102    if blake3_16.len() != 32 || !blake3_16.bytes().all(|b| b.is_ascii_hexdigit()) {
103        return Err(err(format!(
104            "{at}: `blake3_16` must be 32 hex chars, got {blake3_16:?}"
105        )));
106    }
107    Ok(SegmentRef {
108        url: url.to_string(),
109        size,
110        blake3_16: blake3_16.to_ascii_lowercase(),
111    })
112}
113
114fn segment_to_json(s: &SegmentRef) -> Value {
115    json!({ "url": s.url, "size": s.size, "blake3_16": s.blake3_16 })
116}
117
118impl Manifest {
119    /// Parse and validate a manifest JSON document.
120    pub fn parse(text: &str) -> Result<Manifest, ManifestError> {
121        let v: Value = serde_json::from_str(text).map_err(|e| err(format!("invalid JSON: {e}")))?;
122        let version = v["rete_manifest"]
123            .as_u64()
124            .ok_or_else(|| err("missing integer `rete_manifest` version field"))?;
125        if version != MANIFEST_VERSION {
126            return Err(err(format!(
127                "version {version} not supported (this build reads {MANIFEST_VERSION})"
128            )));
129        }
130        let name = v["name"]
131            .as_str()
132            .ok_or_else(|| err("missing string `name`"))?
133            .to_string();
134        let generation = v["generation"]
135            .as_u64()
136            .ok_or_else(|| err("missing integer `generation`"))?;
137        let log_json = v["log"]
138            .as_array()
139            .ok_or_else(|| err("missing array `log`"))?;
140        let mut log = Vec::with_capacity(log_json.len());
141        for (i, entry) in log_json.iter().enumerate() {
142            let adds = match &entry["adds"] {
143                Value::Null => None,
144                other => Some(segment_from_json(other, &format!("log[{i}].adds"))?),
145            };
146            let dels = match &entry["dels"] {
147                Value::Null => None,
148                other => Some(segment_from_json(other, &format!("log[{i}].dels"))?),
149            };
150            if adds.is_none() && dels.is_none() {
151                return Err(err(format!("log[{i}]: needs `adds` and/or `dels`")));
152            }
153            log.push(LogEntry { adds, dels });
154        }
155        Ok(Manifest {
156            name,
157            generation,
158            log,
159        })
160    }
161
162    /// Serialize back to the canonical pretty JSON document.
163    pub fn to_json_pretty(&self) -> String {
164        let log: Vec<Value> = self
165            .log
166            .iter()
167            .map(|e| {
168                let mut entry = serde_json::Map::new();
169                if let Some(a) = &e.adds {
170                    entry.insert("adds".into(), segment_to_json(a));
171                }
172                if let Some(d) = &e.dels {
173                    entry.insert("dels".into(), segment_to_json(d));
174                }
175                Value::Object(entry)
176            })
177            .collect();
178        let doc = json!({
179            "rete_manifest": MANIFEST_VERSION,
180            "name": self.name,
181            "generation": self.generation,
182            "log": log,
183        });
184        serde_json::to_string_pretty(&doc).expect("static shape") + "\n"
185    }
186
187    /// Every segment reference in log order (adds before dels within an entry),
188    /// for verification/listing.
189    pub fn segments(&self) -> impl Iterator<Item = (&SegmentRef, /* is_dels: */ bool)> {
190        self.log.iter().flat_map(|e| {
191            e.adds
192                .iter()
193                .map(|s| (s, false))
194                .chain(e.dels.iter().map(|s| (s, true)))
195        })
196    }
197}
198
199/// Apply one log entry to the visible set: `(visible ∖ dels) ∪ adds`. Entries
200/// must be applied in log order — that order is what makes delete-then-re-add
201/// (and the reverse) mean what the writer meant.
202pub fn fold_entry(
203    visible: &mut BTreeSet<RawQuad>,
204    dels: impl IntoIterator<Item = RawQuad>,
205    adds: impl IntoIterator<Item = RawQuad>,
206) {
207    for q in dels {
208        visible.remove(&q);
209    }
210    for q in adds {
211        visible.insert(q);
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn q(s: &str) -> RawQuad {
220        (
221            format!("<http://ex/{s}>"),
222            "<http://ex/p>".into(),
223            "<http://ex/o>".into(),
224            None,
225        )
226    }
227
228    fn seg(url: &str) -> SegmentRef {
229        SegmentRef {
230            url: url.into(),
231            size: 123,
232            blake3_16: "00112233445566778899aabbccddeeff".into(),
233        }
234    }
235
236    #[test]
237    fn roundtrip() {
238        let m = Manifest {
239            name: "demo".into(),
240            generation: 7,
241            log: vec![
242                LogEntry {
243                    adds: Some(seg("base.rete")),
244                    dels: None,
245                },
246                LogEntry {
247                    adds: Some(seg("delta-g2.rete")),
248                    dels: Some(seg("delta-g2.tomb.rete")),
249                },
250            ],
251        };
252        let parsed = Manifest::parse(&m.to_json_pretty()).unwrap();
253        assert_eq!(parsed, m);
254    }
255
256    #[test]
257    fn rejects_bad_documents() {
258        assert!(Manifest::parse("{}").is_err());
259        assert!(Manifest::parse(
260            r#"{"rete_manifest": 99, "name": "x", "generation": 0, "log": []}"#
261        )
262        .is_err());
263        // An entry with neither adds nor dels is meaningless.
264        let empty_entry = r#"{"rete_manifest": 1, "name": "x", "generation": 0, "log": [{}]}"#;
265        assert!(Manifest::parse(empty_entry).is_err());
266        // Hash must be 32 hex chars.
267        let bad_hash = r#"{"rete_manifest": 1, "name": "x", "generation": 0,
268            "log": [{"adds": {"url": "a.rete", "size": 1, "blake3_16": "zz"}}]}"#;
269        assert!(Manifest::parse(bad_hash).is_err());
270    }
271
272    #[test]
273    fn fold_is_ordered_last_writer_wins() {
274        let mut visible = BTreeSet::new();
275        // gen 1: add a, b
276        fold_entry(&mut visible, [], [q("a"), q("b")]);
277        // gen 2: delete a
278        fold_entry(&mut visible, [q("a")], []);
279        assert!(!visible.contains(&q("a")) && visible.contains(&q("b")));
280        // gen 3: re-add a — later entry wins over the older tombstone
281        fold_entry(&mut visible, [], [q("a")]);
282        assert!(visible.contains(&q("a")));
283        // deleting a quad that was never present is a no-op
284        fold_entry(&mut visible, [q("ghost")], []);
285        assert_eq!(visible.len(), 2);
286    }
287
288    #[test]
289    fn update_is_delete_plus_insert() {
290        let old = (
291            "<http://ex/a>".to_string(),
292            "<http://ex/name>".to_string(),
293            "\"Old\"".to_string(),
294            None,
295        );
296        let new = (
297            "<http://ex/a>".to_string(),
298            "<http://ex/name>".to_string(),
299            "\"New\"".to_string(),
300            None,
301        );
302        let mut visible = BTreeSet::from([old.clone()]);
303        fold_entry(&mut visible, [old], [new.clone()]);
304        assert_eq!(visible, BTreeSet::from([new]));
305    }
306}