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}