Skip to main content

blockworx_store/
revs.rs

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