Skip to main content

blockworx/store/
projection.rs

1//! `document.json`: the readable projection beside the revs.
2//! Rationale: `docs/single-author-playbook.md`, D11.
3//!
4//! `revs/{head}` is the document; this file is a *view* of it, kept for
5//! diffing, searching and review (F5) — 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.zst>" },
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 import can read one back (ignoring the
23//! stamp) with no second shape to support.
24//!
25//! The same writer serves the *export* artifact (D19): a flattened document
26//! that leaves its container carries a [`Provenance`] block inside its stamp,
27//! naming the document, rev, author and tag it was taken from. `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::record::Digest;
35
36/// Which rev a projection was written from — D11's stamp. Both halves
37/// matter: the rev orders it against the history, and `state` — the
38/// blake3 of that rev's own file, the one state digest this format has —
39/// says the document at that rev was *this* one.
40/// [`Stamp::provenance`] joins them on an artifact that left home (D19),
41/// and only there.
42#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
43pub struct Stamp {
44    pub rev: Rev,
45    pub state: Digest,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub provenance: Option<Provenance>,
48}
49
50/// Where an export came from (D19). Advisory throughout: it is never folded,
51/// never written into the importing document's log, and a file that carries
52/// none is read exactly as one that does.
53#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
54pub struct Provenance {
55    /// The source document's name.
56    pub document: String,
57    /// The rev the export was taken at — [`Stamp::rev`], since the export
58    /// *is* the projection of that fold.
59    pub rev: Rev,
60    /// The exporting session's identity (D9).
61    pub author: String,
62    /// What the source called that rev (D18) — §8.1's tags, alphabetical,
63    /// and empty where it called it nothing.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub tags: Vec<String>,
66}
67
68impl Provenance {
69    /// The one line that names the artifact's origin — the title block's
70    /// "From" row, and the phrase D19 spells for it.
71    pub fn line(&self) -> String {
72        format!("Rev {} of {}", self.rev.get(), self.document)
73    }
74
75    /// What the line has no room for: who exported it, and the name the rev
76    /// carried.
77    pub fn detail(&self) -> String {
78        let by = format!("Exported by {}", self.author);
79        if self.tags.is_empty() {
80            return by;
81        }
82        format!(
83            "{by} \u{2014} \u{201c}{}\u{201d}",
84            self.tags.join("\u{201d}, \u{201c}")
85        )
86    }
87}
88
89/// What an exporting session knows about itself. The rev is deliberately
90/// absent: it is the stamp's own, so [`Stamp::from`] is the single place the
91/// two are joined and they cannot disagree.
92#[derive(Clone, PartialEq, Eq, Debug)]
93pub struct Source {
94    pub document: String,
95    pub author: String,
96    pub tags: Vec<String>,
97}
98
99impl Stamp {
100    /// A rev and the digest of the bytes it was written as.
101    pub fn at(rev: Rev, state: Digest) -> Self {
102        Self {
103            rev,
104            state,
105            provenance: None,
106        }
107    }
108
109    /// Mark this stamp as an artifact taken out of `source` (D19).
110    #[must_use]
111    pub fn from(self, source: Source) -> Self {
112        let Source {
113            document,
114            author,
115            tags,
116        } = source;
117        Self {
118            provenance: Some(Provenance {
119                document,
120                rev: self.rev,
121                author,
122                tags,
123            }),
124            ..self
125        }
126    }
127
128    /// Whether this stamp names the same rev as `other`, written the same
129    /// way. Provenance is advisory and says nothing about the document, so
130    /// it is not part of the answer.
131    fn names_the_rev_of(&self, other: &Stamp) -> bool {
132        self.rev == other.rev && self.state == other.state
133    }
134}
135
136/// The file, as written: the stamp, then the document flattened beside it.
137#[derive(Serialize)]
138struct Stamped<'a> {
139    stamp: Stamp,
140    #[serde(flatten)]
141    document: &'a Document,
142}
143
144/// Enough of the file to judge it. Deserializing this builds a stamp and
145/// nothing else — serde skips every other field rather than modelling it,
146/// which is what "load never parses the projection's body" means in code.
147#[derive(Deserialize)]
148struct Header {
149    stamp: Stamp,
150}
151
152/// What sits where the projection goes.
153#[derive(Clone, Debug)]
154pub enum Found {
155    /// Nothing written yet — a container nobody has saved.
156    Nothing,
157    Stamped(Stamp),
158    /// A file whose stamp this build cannot read.
159    Unstamped,
160}
161
162/// What the projection is, relative to the log's head. Not a bool: only
163/// [`Freshness::Unrecognized`] is worth a warning, and only
164/// [`Freshness::Fresh`] means the chrome says nothing.
165#[derive(Clone, Copy, PartialEq, Eq, Debug)]
166pub enum Freshness {
167    /// Its stamp names the rev the history heads at, as that rev was
168    /// written.
169    Fresh,
170    /// It is behind (or missing): regenerated on the next save, silently.
171    Stale,
172    /// Its stamp names a rev this history never wrote — hand-edited, or
173    /// written by another build. Overwritten on the next save, loudly; the
174    /// one road an edited projection has back into the document is D7's
175    /// explicit import.
176    Unrecognized,
177}
178
179impl Freshness {
180    pub fn of(found: &Found, head: &Stamp) -> Self {
181        match found {
182            Found::Nothing => Freshness::Stale,
183            Found::Stamped(stamp) if stamp.names_the_rev_of(head) => Freshness::Fresh,
184            Found::Stamped(stamp) if stamp.rev < head.rev => Freshness::Stale,
185            Found::Unstamped | Found::Stamped(_) => Freshness::Unrecognized,
186        }
187    }
188}
189
190/// The file's text: `document` under `stamp`, pretty-printed.
191// `serde_json` fails only on a non-string map key or a non-finite float,
192// and neither the stamp nor the document model holds one.
193#[expect(clippy::expect_used, clippy::missing_panics_doc)]
194pub fn text(stamp: Stamp, document: &Document) -> String {
195    let stamped = Stamped { stamp, document };
196    let mut text =
197        serde_json::to_string_pretty(&stamped).expect("the projection serializes infallibly");
198    text.push('\n');
199    text
200}
201
202/// The bytes an export writes (D19): `document` flattened under a stamp
203/// that says where it came from.
204///
205/// The one writer every artifact leaving a container goes through — the
206/// Export command, and the history row's Copy and Save — so an export at a
207/// past rev is the export at head with a different fold under it, rather
208/// than a second path that could drift.
209pub fn export_text(document: &Document, stamp: Stamp, source: Source) -> String {
210    text(stamp.from(source), document)
211}
212
213/// The stamp on the front of `text`, if it carries one this build reads.
214pub fn stamp_in(text: &str) -> Found {
215    match serde_json::from_str::<Header>(text) {
216        Ok(header) => Found::Stamped(header.stamp),
217        Err(_) => Found::Unstamped,
218    }
219}
220
221/// Whether `text` is a flattened document written as an export — the shape
222/// test D19's clipboard paste and its file import both read: a stamp on the
223/// front is what a document that left home has and a hand-written one does
224/// not.
225pub fn exported_stamp_in(text: &str) -> Option<Stamp> {
226    match stamp_in(text) {
227        Found::Stamped(stamp) => Some(stamp),
228        Found::Nothing | Found::Unstamped => None,
229    }
230}
231
232/// What is written beside the revs at `root`.
233#[cfg(not(target_arch = "wasm32"))]
234pub fn found_at(root: &std::path::Path) -> Found {
235    match std::fs::read_to_string(root.join(super::container::PROJECTION)) {
236        Ok(text) => stamp_in(&text),
237        Err(_) => Found::Nothing,
238    }
239}
240
241/// Write the projection beside the revs at `root`, atomically — a reader
242/// looking at the file mid-save sees the old one whole, never half of each.
243///
244/// # Errors
245/// The underlying write failure.
246#[cfg(not(target_arch = "wasm32"))]
247pub fn write_at(root: &std::path::Path, stamp: Stamp, document: &Document) -> std::io::Result<()> {
248    crate::atomic::write_atomically(
249        &root.join(super::container::PROJECTION),
250        text(stamp, document).as_bytes(),
251    )
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn stamp(rev: u64, seed: &[u8]) -> Stamp {
259        Stamp::at(blockworx_doc::fixtures::rev(rev), Digest::of(seed))
260    }
261
262    /// D11's read: the stamp comes off a file whose body this build could
263    /// never model, which is the proof that reading it does not model one.
264    #[test]
265    fn the_stamp_is_read_without_the_body_behind_it() {
266        let empty = Document::default();
267        let written = text(stamp(7, b"seven"), &empty);
268        assert!(
269            written.find("\"stamp\"") < written.find("\"version\""),
270            "the stamp is not the first field of the file:\n{written}",
271        );
272
273        let mutilated = written.replace(r#""version": 3"#, r#""version": "three""#);
274        assert!(
275            crate::document_file::parse(&mutilated, "document.json").is_err(),
276            "precondition: this body does not deserialize as a document",
277        );
278        assert!(matches!(
279            stamp_in(&mutilated),
280            Found::Stamped(found) if found == stamp(7, b"seven"),
281        ));
282    }
283
284    /// D19's boundary: the projection beside the revs is the document, so
285    /// it carries no provenance; an export is an excerpt of that document
286    /// taken somewhere else, so it does. The two are the same writer, and
287    /// this is the one thing that separates them.
288    #[test]
289    fn the_projection_carries_no_provenance_and_an_export_does() {
290        let empty = Document::default();
291        let projection = text(stamp(0, b"empty"), &empty);
292        assert!(
293            !projection.contains("provenance"),
294            "document.json was written as an excerpt of another document:\n{projection}",
295        );
296        assert!(matches!(
297            stamp_in(&projection),
298            Found::Stamped(stamp) if stamp.provenance.is_none(),
299        ));
300
301        let exported = export_text(
302            &empty,
303            stamp(0, b"empty"),
304            Source {
305                document: "motor-controller".to_owned(),
306                author: "ada".to_owned(),
307                tags: vec!["Initial Draft".to_owned()],
308            },
309        );
310        let stamp = exported_stamp_in(&exported).expect("the export is stamped");
311        let from = stamp.provenance.clone().expect("and carries provenance");
312        assert_eq!(from.document, "motor-controller");
313        assert_eq!(from.author, "ada");
314        assert_eq!(from.tags, ["Initial Draft"]);
315        assert_eq!(
316            from.rev, stamp.rev,
317            "the provenance names a rev the stamp does not",
318        );
319        assert_eq!(from.line(), "Rev 0 of motor-controller");
320    }
321
322    /// A stamp with no provenance and the same stamp with one name the same
323    /// rev: what makes a projection stale is the history moving under it,
324    /// never where some export said it came from.
325    #[test]
326    fn provenance_does_not_make_a_projection_unrecognized() {
327        let head = stamp(4, b"head");
328        let travelled = head.clone().from(Source {
329            document: "elsewhere".to_owned(),
330            author: "ada".to_owned(),
331            tags: Vec::new(),
332        });
333        assert_eq!(
334            Freshness::of(&Found::Stamped(travelled), &head),
335            Freshness::Fresh,
336        );
337    }
338
339    /// The projection is a document like any other on the way back in: the
340    /// stamp is an extra field an importer ignores.
341    #[test]
342    fn a_written_projection_reads_back_as_a_plain_document() {
343        let empty = Document::default();
344        let written = text(stamp(0, b"empty"), &empty);
345        assert_eq!(
346            crate::document_file::parse(&written, "document.json").expect("it parses"),
347            empty,
348        );
349    }
350
351    #[test]
352    fn a_projection_at_the_head_rev_is_fresh_and_an_older_one_is_stale() {
353        let head = stamp(9, b"head");
354        assert_eq!(
355            Freshness::of(&Found::Stamped(head.clone()), &head),
356            Freshness::Fresh,
357        );
358        assert_eq!(
359            Freshness::of(&Found::Stamped(stamp(8, b"older")), &head),
360            Freshness::Stale,
361        );
362        assert_eq!(Freshness::of(&Found::Nothing, &head), Freshness::Stale);
363    }
364
365    /// The two ways a projection stops being ours: a stamp at our rev
366    /// naming bytes we did not write, and a stamp from a future this
367    /// history has not reached.
368    #[test]
369    fn a_stamp_this_history_never_wrote_is_unrecognized() {
370        let head = stamp(9, b"head");
371        assert_eq!(
372            Freshness::of(&Found::Stamped(stamp(9, b"other bytes")), &head),
373            Freshness::Unrecognized,
374        );
375        assert_eq!(
376            Freshness::of(&Found::Stamped(stamp(10, b"the future")), &head),
377            Freshness::Unrecognized,
378        );
379        assert_eq!(
380            Freshness::of(&Found::Unstamped, &head),
381            Freshness::Unrecognized,
382        );
383    }
384}