Skip to main content

blockworx/store/
revs.rs

1//! `revs/`: the whole document at every rev — the document itself, since
2//! P5 (`docs/log-vs-snapshot.md` S3/S4).
3//!
4//! One file per rev the manifest holds — `revs/000012.json.zst`, zstd −1
5//! over the compact `serde_json` bytes of the document at that rev
6//! (§14.2). Rev 0 is the empty document and is written nothing, so a
7//! container with nothing in it holds an empty directory.
8//!
9//! **A rev carries no payloads.** `assets/` is the one home for bytes, so
10//! a 1 MB PNG is written once under its own hash rather than into every
11//! rev that stands after it; a rev file goes out stripped
12//! ([`Document::without_assets`]) and comes back with everything it
13//! *references* re-attached. `document.json`, the self-contained export
14//! form, keeps embedding its payloads.
15//!
16//! The name and the encoding live here alone, over a [`Backing`] that is
17//! a directory for a container and a map for a session with no files — so
18//! a scratch session steps exactly as a container does, and the store, the
19//! fsck, Save-as and the share bundle cannot spell a rev differently from
20//! each other.
21
22use blockworx_doc::{document::Document, rev::Rev};
23
24use super::assets::{AssetFault, AssetSource, hydrate};
25use super::record::Digest;
26
27/// The directory a container keeps its rev files in.
28pub const REVS: &str = "revs";
29
30/// Where a session keeps the document at every rev: a directory, or a map
31/// that dies with the process. The same bytes either way.
32pub trait Backing {
33    /// # Errors
34    /// The write that did not land.
35    fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()>;
36
37    /// The bytes rev `at` was written as, or `None` where nothing was.
38    ///
39    /// # Errors
40    /// The read that did not work — which is not the same as nothing
41    /// being there.
42    fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>>;
43
44    /// What a report calls rev `at`'s home.
45    fn names(&self, at: Rev) -> String;
46}
47
48/// A session with no files: the same compressed bytes, in a map.
49///
50/// Bounded by what the bytes cost rather than by what the documents do —
51/// 121 KB per rev at 2,500 blocks (`TUNING.md`, Finding 8) — which is
52/// what makes stepping a scratch session a decode rather than a fold of
53/// its whole log prefix.
54#[derive(Default, Debug)]
55pub struct Memory(std::collections::BTreeMap<Rev, Vec<u8>>);
56
57impl Backing for Memory {
58    fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()> {
59        self.0.insert(at, bytes.to_vec());
60        Ok(())
61    }
62
63    fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>> {
64        Ok(self.0.get(&at).cloned())
65    }
66
67    fn names(&self, at: Rev) -> String {
68        format!("rev {} of this session", at.get())
69    }
70}
71
72/// What went wrong between a session's revs and the history that names
73/// them.
74#[derive(Debug, thiserror::Error)]
75pub enum RevFault {
76    #[error("rev {} was never written to {where_it_would_be}", .at.get())]
77    Missing { at: Rev, where_it_would_be: String },
78    #[error("rev {}'s copy could not be read: {why}", .at.get())]
79    Unreadable { at: Rev, why: std::io::Error },
80    #[error("rev {}'s copy is not a document this build reads: {why}", .at.get())]
81    NotADocument { at: Rev, why: serde_json::Error },
82    #[error("rev {}'s copy holds bytes that hash to {found}, not {named}", .at.get())]
83    Tampered {
84        at: Rev,
85        named: Digest,
86        found: Digest,
87    },
88    #[error(transparent)]
89    Artwork(#[from] AssetFault),
90    #[error("rev {} could not be written: {why}", .at.get())]
91    NotWritten { at: Rev, why: std::io::Error },
92}
93
94/// Write `document` as rev `at` and hand back the digest of the bytes that
95/// landed — which is what the manifest row stamps, and the only state
96/// digest this format has.
97///
98/// The payloads go to `assets/` first: a rev may not name bytes the store
99/// does not already hold, and every payload the document *holds* is put
100/// there, not only the ones it currently references, so a payload outlives
101/// the reference that brought it in.
102///
103/// # Errors
104/// The write that did not land.
105pub fn write(
106    into: &mut dyn Backing,
107    at: Rev,
108    document: &Document,
109    payloads: &mut dyn super::assets::AssetSink,
110) -> std::io::Result<Digest> {
111    for (hash, asset) in document.assets() {
112        payloads.put(hash, asset)?;
113    }
114    let bytes = encode(&document.without_assets())?;
115    into.put(at, &bytes)?;
116    Ok(Digest::of(&bytes))
117}
118
119/// The document rev `at` holds, as the file holds it: no payloads.
120/// [`attached`] is what puts them back.
121///
122/// Rev 0 is the empty document and was never written.
123///
124/// # Errors
125/// [`RevFault`]: nothing there, bytes that will not decompress, or bytes
126/// that are not a document this build reads.
127pub fn read(from: &dyn Backing, at: Rev) -> Result<Document, RevFault> {
128    if at == Rev::ZERO {
129        return Ok(Document::default());
130    }
131    let bytes = bytes(from, at)?;
132    let json = unpack(&bytes).map_err(|why| RevFault::Unreadable { at, why })?;
133    let mut document: Document =
134        serde_json::from_slice(&json).map_err(|why| RevFault::NotADocument { at, why })?;
135    document.positioned_at(at);
136    Ok(document)
137}
138
139/// Put back every payload `document` references, out of the one place
140/// bytes live.
141///
142/// # Errors
143/// [`AssetFault`] for a payload the store cannot hand back, or hands back
144/// under a hash that is not its own.
145pub fn attached(mut document: Document, assets: &dyn AssetSource) -> Result<Document, AssetFault> {
146    document.attach_assets(|hash| hydrate(assets, hash))?;
147    Ok(document)
148}
149
150/// The digest of the bytes rev `at` holds — what a manifest row's `hash`
151/// is checked against.
152///
153/// # Errors
154/// [`RevFault`] for a rev whose bytes are not there to be hashed.
155pub fn stamp(from: &dyn Backing, at: Rev) -> Result<Digest, RevFault> {
156    // The empty document is written nothing, so rev 0 stamps the nothing
157    // that was written.
158    if at == Rev::ZERO {
159        return Ok(Digest::of(&[]));
160    }
161    Ok(Digest::of(&bytes(from, at)?))
162}
163
164/// The bytes rev `at` holds, or the fault that says why they are not
165/// there.
166///
167/// # Errors
168/// [`RevFault`] naming the rev.
169pub fn bytes(from: &dyn Backing, at: Rev) -> Result<Vec<u8>, RevFault> {
170    from.get(at)
171        .map_err(|why| RevFault::Unreadable { at, why })?
172        .ok_or_else(|| RevFault::Missing {
173            at,
174            where_it_would_be: from.names(at),
175        })
176}
177
178/// Whether rev `at` holds the bytes `named` says it does — the fsck's
179/// question, and the one a writable open asks of its head.
180///
181/// # Errors
182/// [`RevFault`] for a rev that is missing, unreadable, or not what it is
183/// named for.
184pub fn witnessed(from: &dyn Backing, at: Rev, named: Digest) -> Result<(), RevFault> {
185    let found = stamp(from, at)?;
186    if found == named {
187        Ok(())
188    } else {
189        Err(RevFault::Tampered { at, named, found })
190    }
191}
192
193/// Every rev a history through `at` holds, oldest first. Rev 0 is the
194/// empty document and was never written, so counting starts at one.
195pub fn through(at: Rev) -> impl Iterator<Item = Rev> {
196    std::iter::successors(Some(Rev::ZERO), |rev| Some(rev.next()))
197        .skip(1)
198        .take_while(move |rev| *rev <= at)
199}
200
201/// # Errors
202/// The serialization or compression that did not work.
203fn encode(document: &Document) -> std::io::Result<Vec<u8>> {
204    let json = serde_json::to_vec(document).map_err(std::io::Error::other)?;
205    pack(&json)
206}
207
208/// §14.2: measured at 30 ms for 13.3 MB, where −9 saves a fifth of the
209/// bytes for five times the time.
210#[cfg(not(target_arch = "wasm32"))]
211const LEVEL: i32 = 1;
212
213#[cfg(not(target_arch = "wasm32"))]
214fn pack(json: &[u8]) -> std::io::Result<Vec<u8>> {
215    zstd::encode_all(json, LEVEL)
216}
217
218#[cfg(not(target_arch = "wasm32"))]
219fn unpack(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
220    zstd::decode_all(bytes)
221}
222
223/// The browser has no zstd (the container it would compress for is
224/// native-only), so there a rev is its own JSON. Nothing reads these
225/// bytes but the session that wrote them, so the two spellings never meet.
226#[cfg(target_arch = "wasm32")]
227#[expect(clippy::unnecessary_wraps, reason = "the native pair can fail")]
228fn pack(json: &[u8]) -> std::io::Result<Vec<u8>> {
229    Ok(json.to_vec())
230}
231
232#[cfg(target_arch = "wasm32")]
233#[expect(clippy::unnecessary_wraps, reason = "the native pair can fail")]
234fn unpack(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
235    Ok(bytes.to_vec())
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239pub use native::{Dir, path};
240
241#[cfg(not(target_arch = "wasm32"))]
242mod native {
243    use super::{Backing, REVS, Rev};
244    use std::path::{Path, PathBuf};
245
246    /// Where rev `at` is written. Zero-padded so the directory sorts as
247    /// the history reads.
248    pub fn path(root: &Path, at: Rev) -> PathBuf {
249        root.join(REVS).join(format!("{:06}.json.zst", at.get()))
250    }
251
252    /// A container's `revs/` directory.
253    pub struct Dir(PathBuf);
254
255    impl Dir {
256        pub fn at(root: &Path) -> Self {
257            Self(root.to_path_buf())
258        }
259    }
260
261    impl Backing for Dir {
262        fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()> {
263            // The directory is laid down at creation; a container whose
264            // `revs/` somebody removed is repaired by the write that
265            // needs it.
266            std::fs::create_dir_all(self.0.join(REVS))?;
267            crate::atomic::write_atomically(&path(&self.0, at), bytes)
268        }
269
270        fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>> {
271            match std::fs::read(path(&self.0, at)) {
272                Ok(bytes) => Ok(Some(bytes)),
273                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
274                Err(error) => Err(error),
275            }
276        }
277
278        fn names(&self, at: Rev) -> String {
279            path(&self.0, at).display().to_string()
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::store::assets::Held;
288    use crate::store::tests::fixture;
289    use blockworx_doc::fixtures::rev;
290
291    /// The encoding is a round trip, and a rev's own digest is the digest
292    /// of the bytes that landed.
293    #[test]
294    fn a_rev_round_trips_and_stamps_the_bytes_it_was_written_as() {
295        let document = fixture::documents(2).pop().expect("two documents");
296        let mut backing = Memory::default();
297        let mut payloads = Held::default();
298        let stamped = write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
299
300        assert_eq!(
301            stamp(&backing, rev(1)).expect("the bytes are there"),
302            stamped
303        );
304        assert_eq!(read(&backing, rev(1)).expect("it reads back"), document);
305        witnessed(&backing, rev(1), stamped).expect("the rev is what it says");
306    }
307
308    /// Rev 0 was never written, and says so rather than being invented.
309    #[test]
310    fn rev_zero_is_the_empty_document_and_holds_no_bytes() {
311        let backing = Memory::default();
312        assert_eq!(
313            read(&backing, Rev::ZERO).expect("the empty document"),
314            Document::default(),
315        );
316        assert!(matches!(
317            read(&backing, rev(4)),
318            Err(RevFault::Missing { .. }),
319        ));
320    }
321
322    /// The payload rule: a rev file holds none, `assets/` holds them all,
323    /// and reading a rev puts back exactly the ones it references.
324    #[test]
325    fn a_rev_file_holds_no_payloads_and_reads_back_the_ones_it_references() {
326        let asset = fixture::svg(1);
327        let document = fixture::with_icon(&asset);
328        assert_eq!(document.assets().count(), 1, "precondition: it holds one");
329
330        let mut backing = Memory::default();
331        let mut payloads = Held::default();
332        write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
333
334        let stripped = read(&backing, rev(1)).expect("it reads back");
335        assert_eq!(stripped.assets().count(), 0, "the rev carries bytes");
336        assert_eq!(
337            attached(stripped, &payloads).expect("the payload is in the store"),
338            document,
339        );
340    }
341
342    /// Tampering with a rev's bytes is caught by the hash its row stamps,
343    /// which is the whole of what replaced D12's folded-state stamp.
344    #[test]
345    fn bytes_that_are_not_what_the_row_names_are_refused() {
346        let document = fixture::documents(1).pop().expect("one document");
347        let mut backing = Memory::default();
348        let mut payloads = Held::default();
349        let stamped = write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
350
351        backing
352            .put(rev(1), b"not a document")
353            .expect("it is rewritten");
354        assert!(matches!(
355            witnessed(&backing, rev(1), stamped),
356            Err(RevFault::Tampered { .. }),
357        ));
358        assert!(matches!(
359            read(&backing, rev(1)),
360            Err(RevFault::Unreadable { .. } | RevFault::NotADocument { .. }),
361        ));
362    }
363
364    #[test]
365    fn the_revs_through_a_head_start_at_one() {
366        assert_eq!(
367            through(rev(3)).collect::<Vec<_>>(),
368            [rev(1), rev(2), rev(3)]
369        );
370        assert_eq!(through(Rev::ZERO).count(), 0);
371    }
372}