Skip to main content

blockworx_store/
projection.rs

1//! `document.json`: the readable projection beside the revs.
2//! Rationale: `docs/single-author-playbook.md`.
3//!
4//! `revs/{head}` is the document; this file is a *view* of it, kept for
5//! diffing, searching and review — and, unlike a rev, self-contained:
6//! it embeds its payloads, so a copy of it out of the container is still a
7//! whole drawing. It is therefore **write-only**: load reads the head rev
8//! and reads nothing here but the stamp on the front, which says which rev
9//! wrote the file, so a projection that has fallen behind can be told from
10//! one somebody hand-edited without the body ever being modelled.
11//!
12//! ```text
13//! {
14//!   "stamp": { "rev": 12, "state": "<blake3 of revs/000012.json.gz>" },
15//!   "version": 3,
16//!   …the document…
17//! }
18//! ```
19//!
20//! The stamp is the first field so a reader takes it off the front; the
21//! document's own fields follow flattened beside it, so the file is a
22//! document like any other and the command line can open one (ignoring the
23//! stamp) with no second shape to support.
24//!
25//! A stamp can also carry a [`Provenance`] block naming the document, rev,
26//! author and tag a document was taken from — what a PDF's metadata says, and
27//! what a stamped file opened from the command line remembers. `document.json`
28//! never carries one — it is the document, not an excerpt of another.
29
30use serde::{Deserialize, Serialize};
31
32use blockworx_doc::{document::Document, rev::Rev};
33
34use super::container::{Container, PROJECTION};
35use super::record::Digest;
36use super::storage::Storage;
37
38/// Which rev a projection was written from. Both halves matter: the rev
39/// orders it against the history, and `state` — the blake3 of that rev's own
40/// file, the one state digest this format has — says the document at that rev
41/// was *this* one. [`Stamp::provenance`] joins them on an artifact that left
42/// home, and only there.
43#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
44pub struct Stamp {
45    pub rev: Rev,
46    pub state: Digest,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub provenance: Option<Provenance>,
49}
50
51/// Where an export came from. Advisory throughout: it is never folded, never
52/// written into the importing document's log, and a file that carries none is
53/// read exactly as one that does.
54#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
55pub struct Provenance {
56    /// The source document's name.
57    pub document: String,
58    /// The rev the export was taken at — [`Stamp::rev`], since the export
59    /// *is* the projection of that fold.
60    pub rev: Rev,
61    /// The exporting session's identity.
62    pub author: String,
63    /// What the source called that rev — its tags, alphabetical, and empty
64    /// where it called it nothing.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub tags: Vec<String>,
67}
68
69impl Provenance {
70    /// The one line that names the artifact's origin — the title block's
71    /// "From" row.
72    pub fn line(&self) -> String {
73        format!("Rev {} of {}", self.rev.get(), self.document)
74    }
75
76    /// What the line has no room for: who exported it, and the name the rev
77    /// carried.
78    pub fn detail(&self) -> String {
79        let by = format!("Exported by {}", self.author);
80        if self.tags.is_empty() {
81            return by;
82        }
83        format!(
84            "{by} \u{2014} \u{201c}{}\u{201d}",
85            self.tags.join("\u{201d}, \u{201c}")
86        )
87    }
88}
89
90/// What an exporting session knows about itself. The rev is deliberately
91/// absent: it is the stamp's own, so [`Stamp::from`] is the single place the
92/// two are joined and they cannot disagree.
93#[derive(Clone, PartialEq, Eq, Debug)]
94pub struct Source {
95    pub document: String,
96    pub author: String,
97    pub tags: Vec<String>,
98}
99
100impl Stamp {
101    /// A rev and the digest of the bytes it was written as.
102    pub fn at(rev: Rev, state: Digest) -> Self {
103        Self {
104            rev,
105            state,
106            provenance: None,
107        }
108    }
109
110    /// Mark this stamp as an artifact taken out of `source`.
111    #[must_use]
112    pub fn from(self, source: Source) -> Self {
113        let Source {
114            document,
115            author,
116            tags,
117        } = source;
118        Self {
119            provenance: Some(Provenance {
120                document,
121                rev: self.rev,
122                author,
123                tags,
124            }),
125            ..self
126        }
127    }
128
129    /// Whether this stamp names the same rev as `other`, written the same
130    /// way. Provenance is advisory and says nothing about the document, so
131    /// it is not part of the answer.
132    fn names_the_rev_of(&self, other: &Stamp) -> bool {
133        self.rev == other.rev && self.state == other.state
134    }
135}
136
137/// The file, as written: the stamp, then the document flattened beside it.
138#[derive(Serialize)]
139struct Stamped<'a> {
140    stamp: Stamp,
141    #[serde(flatten)]
142    document: &'a Document,
143}
144
145/// Enough of the file to judge it. Deserializing this builds a stamp and
146/// nothing else — serde skips every other field rather than modelling it,
147/// which is what "load never parses the projection's body" means in code.
148#[derive(Deserialize)]
149struct Header {
150    stamp: Stamp,
151}
152
153/// What sits where the projection goes.
154#[derive(Clone, Debug)]
155pub enum Found {
156    /// Nothing written yet — a container nobody has saved.
157    Nothing,
158    Stamped(Stamp),
159    /// A file whose stamp this build cannot read.
160    Unstamped,
161}
162
163/// What the projection is, relative to the log's head. Not a bool: only
164/// [`Freshness::Unrecognized`] is worth a warning, and only
165/// [`Freshness::Fresh`] means the chrome says nothing.
166#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167pub enum Freshness {
168    /// Its stamp names the rev the history heads at, as that rev was
169    /// written.
170    Fresh,
171    /// It is behind (or missing): regenerated on the next save, silently.
172    Stale,
173    /// Its stamp names a rev this history never wrote — hand-edited, or
174    /// written by another build. Overwritten on the next save, loudly; the
175    /// one road an edited projection has back into the document is an
176    /// explicit import.
177    Unrecognized,
178}
179
180/// How long the log's head sits still before the projection follows it.
181///
182/// The commits are already durable, so a refresh can lose nothing; what the
183/// wait buys is not rewriting the whole projection once per keystroke.
184pub const SETTLE: core::time::Duration = core::time::Duration::from_secs(2);
185
186/// What a host should do about a projection that has fallen behind its log.
187#[derive(Clone, Copy, PartialEq, Eq, Debug)]
188pub enum Refresh {
189    /// Nothing to write, or nothing this session may write.
190    Settled,
191    /// Ask again in this long: the head has not sat still yet.
192    Wait(core::time::Duration),
193    Write,
194}
195
196/// Whether `document.json` is owed a rewrite, `waited` after the head last
197/// moved. One rule, because two shells keep one file in step.
198///
199/// A projection nobody recognizes stays behind an explicit save: it was
200/// hand-edited or written by another build, and a timer must not quietly
201/// overwrite it.
202#[must_use]
203pub fn refreshed(
204    freshness: Option<Freshness>,
205    may_write: crate::doc::Writability,
206    waited: core::time::Duration,
207) -> Refresh {
208    if freshness != Some(Freshness::Stale) || may_write == crate::doc::Writability::ReadOnly {
209        return Refresh::Settled;
210    }
211    match SETTLE.checked_sub(waited) {
212        None | Some(core::time::Duration::ZERO) => Refresh::Write,
213        Some(remaining) => Refresh::Wait(remaining),
214    }
215}
216
217impl Freshness {
218    pub fn of(found: &Found, head: &Stamp) -> Self {
219        match found {
220            Found::Nothing => Freshness::Stale,
221            Found::Stamped(stamp) if stamp.names_the_rev_of(head) => Freshness::Fresh,
222            Found::Stamped(stamp) if stamp.rev < head.rev => Freshness::Stale,
223            Found::Unstamped | Found::Stamped(_) => Freshness::Unrecognized,
224        }
225    }
226}
227
228/// The file's text: `document` under `stamp`, pretty-printed.
229// `serde_json` fails only on a non-string map key or a non-finite float,
230// and neither the stamp nor the document model holds one.
231#[expect(clippy::expect_used, clippy::missing_panics_doc)]
232pub fn text(stamp: Stamp, document: &Document) -> String {
233    let stamped = Stamped { stamp, document };
234    let mut text =
235        serde_json::to_string_pretty(&stamped).expect("the projection serializes infallibly");
236    text.push('\n');
237    text
238}
239
240/// `document` flattened under a stamp that says where it came from — the
241/// shape a stamped file opened from the command line is read as.
242pub fn export_text(document: &Document, stamp: Stamp, source: Source) -> String {
243    text(stamp.from(source), document)
244}
245
246/// The stamp on the front of `text`, if it carries one this build reads.
247pub fn stamp_in(text: &str) -> Found {
248    match serde_json::from_str::<Header>(text) {
249        Ok(header) => Found::Stamped(header.stamp),
250        Err(_) => Found::Unstamped,
251    }
252}
253
254/// Whether `text` is a flattened document carrying a stamp — what a document
255/// that left home has and a hand-written one does not.
256pub fn exported_stamp_in(text: &str) -> Option<Stamp> {
257    match stamp_in(text) {
258        Found::Stamped(stamp) => Some(stamp),
259        Found::Nothing | Found::Unstamped => None,
260    }
261}
262
263/// What is written beside `container`'s revs.
264pub fn found_in<S: Storage>(container: &Container<S>) -> Found {
265    match container.read(&PROJECTION) {
266        Ok(Some(bytes)) => {
267            String::from_utf8(bytes).map_or(Found::Unstamped, |text| stamp_in(&text))
268        }
269        Ok(None) | Err(_) => Found::Nothing,
270    }
271}
272
273/// Write the projection beside the revs, whole or not at all — a reader
274/// looking at the file mid-save sees the old one whole, never half of each.
275///
276/// # Errors
277/// The underlying write failure.
278pub fn write_in<S: Storage>(
279    container: &Container<S>,
280    stamp: Stamp,
281    document: &Document,
282) -> std::io::Result<()> {
283    container.write(&PROJECTION, text(stamp, document).as_bytes())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn stamp(rev: u64, seed: &[u8]) -> Stamp {
291        Stamp::at(blockworx_doc::fixtures::rev(rev), Digest::of(seed))
292    }
293
294    /// The stamp comes off a file whose body this build could never model,
295    /// which is the proof that reading it does not model one.
296    #[test]
297    fn the_stamp_is_read_without_the_body_behind_it() {
298        let empty = Document::default();
299        let written = text(stamp(7, b"seven"), &empty);
300        assert!(
301            written.find("\"stamp\"") < written.find("\"version\""),
302            "the stamp is not the first field of the file:\n{written}",
303        );
304
305        let mutilated = written.replace(r#""version": 3"#, r#""version": "three""#);
306        assert!(
307            crate::document_file::parse(&mutilated, "document.json").is_err(),
308            "precondition: this body does not deserialize as a document",
309        );
310        assert!(matches!(
311            stamp_in(&mutilated),
312            Found::Stamped(found) if found == stamp(7, b"seven"),
313        ));
314    }
315
316    /// The projection beside the revs is the document, so it carries no
317    /// provenance; an export is an excerpt of that document taken somewhere
318    /// else, so it does. The two are the same writer, and this is the one
319    /// thing that separates them.
320    #[test]
321    fn the_projection_carries_no_provenance_and_an_export_does() {
322        let empty = Document::default();
323        let projection = text(stamp(0, b"empty"), &empty);
324        assert!(
325            !projection.contains("provenance"),
326            "document.json was written as an excerpt of another document:\n{projection}",
327        );
328        assert!(matches!(
329            stamp_in(&projection),
330            Found::Stamped(stamp) if stamp.provenance.is_none(),
331        ));
332
333        let exported = export_text(
334            &empty,
335            stamp(0, b"empty"),
336            Source {
337                document: "motor-controller".to_owned(),
338                author: "ada".to_owned(),
339                tags: vec!["Initial Draft".to_owned()],
340            },
341        );
342        let stamp = exported_stamp_in(&exported).expect("the export is stamped");
343        let from = stamp.provenance.clone().expect("and carries provenance");
344        assert_eq!(from.document, "motor-controller");
345        assert_eq!(from.author, "ada");
346        assert_eq!(from.tags, ["Initial Draft"]);
347        assert_eq!(
348            from.rev, stamp.rev,
349            "the provenance names a rev the stamp does not",
350        );
351        assert_eq!(from.line(), "Rev 0 of motor-controller");
352    }
353
354    /// A stamp with no provenance and the same stamp with one name the same
355    /// rev: what makes a projection stale is the history moving under it,
356    /// never where some export said it came from.
357    #[test]
358    fn provenance_does_not_make_a_projection_unrecognized() {
359        let head = stamp(4, b"head");
360        let travelled = head.clone().from(Source {
361            document: "elsewhere".to_owned(),
362            author: "ada".to_owned(),
363            tags: Vec::new(),
364        });
365        assert_eq!(
366            Freshness::of(&Found::Stamped(travelled), &head),
367            Freshness::Fresh,
368        );
369    }
370
371    /// The projection is a document like any other on the way back in: the
372    /// stamp is an extra field an importer ignores.
373    #[test]
374    fn a_written_projection_reads_back_as_a_plain_document() {
375        let empty = Document::default();
376        let written = text(stamp(0, b"empty"), &empty);
377        assert_eq!(
378            crate::document_file::parse(&written, "document.json").expect("it parses"),
379            empty,
380        );
381    }
382
383    #[test]
384    fn a_projection_at_the_head_rev_is_fresh_and_an_older_one_is_stale() {
385        let head = stamp(9, b"head");
386        assert_eq!(
387            Freshness::of(&Found::Stamped(head.clone()), &head),
388            Freshness::Fresh,
389        );
390        assert_eq!(
391            Freshness::of(&Found::Stamped(stamp(8, b"older")), &head),
392            Freshness::Stale,
393        );
394        assert_eq!(Freshness::of(&Found::Nothing, &head), Freshness::Stale);
395    }
396
397    /// The two ways a projection stops being ours: a stamp at our rev
398    /// naming bytes we did not write, and a stamp from a future this
399    /// history has not reached.
400    #[test]
401    fn a_stamp_this_history_never_wrote_is_unrecognized() {
402        let head = stamp(9, b"head");
403        assert_eq!(
404            Freshness::of(&Found::Stamped(stamp(9, b"other bytes")), &head),
405            Freshness::Unrecognized,
406        );
407        assert_eq!(
408            Freshness::of(&Found::Stamped(stamp(10, b"the future")), &head),
409            Freshness::Unrecognized,
410        );
411        assert_eq!(
412            Freshness::of(&Found::Unstamped, &head),
413            Freshness::Unrecognized,
414        );
415    }
416
417    /// The settle both shells read: a stale projection on a writable
418    /// container is written once the head has sat still, and a hand-edited
419    /// one never is.
420    #[test]
421    fn a_stale_projection_is_rewritten_once_the_head_has_sat_still() {
422        use crate::doc::Writability::{ReadOnly, Writable};
423        let stale = Some(Freshness::Stale);
424        assert_eq!(refreshed(stale, Writable, SETTLE), Refresh::Write);
425        assert_eq!(refreshed(stale, Writable, SETTLE * 2), Refresh::Write);
426        assert_eq!(
427            refreshed(stale, Writable, SETTLE / 2),
428            Refresh::Wait(SETTLE / 2),
429            "the wait answers what is left of it, not the whole of it",
430        );
431        assert_eq!(
432            refreshed(stale, ReadOnly, SETTLE * 2),
433            Refresh::Settled,
434            "a session that may not write the container wrote its projection",
435        );
436        for settled in [None, Some(Freshness::Fresh), Some(Freshness::Unrecognized)] {
437            assert_eq!(
438                refreshed(settled, Writable, SETTLE * 2),
439                Refresh::Settled,
440                "{settled:?} was taken for a projection owed a rewrite",
441            );
442        }
443    }
444}