Skip to main content

blockworx_store/
prefix.rs

1//! Save-as over a container: a new one holding the source's manifest up
2//! to a rev, line for line, and the rev files those rows name.
3//!
4//! The user, dropping Restore for this: *"a cleaner solution is to have a
5//! way to save a log up to a given rev as a new document. This allows you to
6//! time-machine back to an earlier rev and make clean edits on top of it."*
7//!
8//! The rows are copied **verbatim**. The hash chain is over the bytes a file
9//! holds, so a save that re-serialized them would be a save that could
10//! break the chain that verifies it; copying a *contiguous prefix* keeps
11//! it whole by construction — every row still follows the one it named as
12//! its parent — and carries every column a re-serialization would have to
13//! invent: the wall times, the authors, the cameras, the edit/undo/redo
14//! kinds, the tag rows, and any field a later build adds.
15//!
16//! What is left behind is everything after the cut, which is what the user
17//! asked for: the revs past it, and the names given to them.
18//!
19//! Names given to revs *inside* the cut are kept whatever order they were
20//! written in — a rev tagged from the head, long after it was made, still
21//! arrives named. Those tag rows cannot be copied (their parent link names
22//! a row that is not here), so they are written again, by whoever asked
23//! for the save: a tag is a claim about history rather than part of it, so
24//! re-stating one is honest where re-stating an edit would not be.
25
26use std::collections::BTreeSet;
27
28use blockworx_doc::{hash::AssetHash, rev::Rev};
29
30use super::assets::AssetFormat;
31use super::container::{Container, ContainerError, MANIFEST, asset_entry};
32use super::handle::{Clock, Store};
33use super::manifest::{Replays, Row};
34use super::record::Identity;
35use super::revs;
36use super::storage::{Storage, ready_now};
37use super::tags::{Tagging, Tags};
38
39/// Why a document could not be saved as it stood at a rev.
40#[derive(Debug, thiserror::Error)]
41pub enum PrefixFailure {
42    #[error("the manifest could not be read: {0}")]
43    Read(std::io::Error),
44    #[error("line {line} of the manifest is not a row this build can read: {why}")]
45    NotARow { line: usize, why: serde_json::Error },
46    #[error("this history has no rev {}", .0.get())]
47    NoSuchRev(Rev),
48    #[error(transparent)]
49    Create(#[from] ContainerError),
50    #[error("{name} could not be carried over: {why}")]
51    Asset { name: String, why: std::io::Error },
52    #[error("rev {}'s copy could not be carried over: {why}", .at.get())]
53    Rev { at: Rev, why: std::io::Error },
54    #[error("the new container's projection did not land: {0}")]
55    Projection(super::Refusal),
56    #[error("rev {}'s name did not come over: {why}", .at.get())]
57    Tag { at: Rev, why: super::Refusal },
58}
59
60/// Write the document as it stood at `at` into a new container at `to`,
61/// and open it.
62///
63/// The head of the new history is `at`, so the session that lands in it is
64/// writable at the rev it was looking at — which is the whole point.
65///
66/// # Errors
67/// [`PrefixFailure`]: the source manifest could not be read or does not
68/// hold `at`, the container could not be laid out, revs or artwork the
69/// prefix names could not be carried over, or the projection did not land.
70pub fn save_through<S: Storage, T: Storage>(
71    source: &S,
72    at: Rev,
73    to: T,
74    mut clock: Clock,
75    by: &Identity,
76) -> Result<Store<T>, PrefixFailure> {
77    let text = read_manifest(source)?;
78    let prefix = through(&text, at)?;
79    let container = Container::create_holding(to, clock.tick(), prefix.as_bytes())?;
80    carry_revs(source, &container, at)?;
81    carry_assets(source, &container, at)?;
82    let mut store = Store::over(container, clock)?;
83    rename_revs(&mut store, &named_through(&text, at)?, by)?;
84    Ok(store)
85}
86
87/// Make the saved container call its revs what `wanted` calls them.
88///
89/// Two directions, because the copied rows can be wrong either way: a name
90/// given after the cut is missing from them, and a name *taken off* after
91/// the cut is still in them. Both are one appended tag row, which is what
92/// a tag has always been.
93fn rename_revs<T: Storage>(
94    store: &mut Store<T>,
95    wanted: &Tags,
96    by: &Identity,
97) -> Result<(), PrefixFailure> {
98    let carried: Vec<(Rev, Vec<String>)> = store
99        .tags()
100        .iter()
101        .map(|(rev, names)| (rev, names.to_vec()))
102        .collect();
103    let mut restated: Vec<(Rev, String, Tagging)> = Vec::new();
104    for (rev, names) in &carried {
105        for name in names {
106            if !wanted.of(*rev).contains(name) {
107                restated.push((*rev, name.clone(), Tagging::Removed));
108            }
109        }
110    }
111    for (rev, names) in wanted.iter() {
112        for name in names {
113            if !store.tags().of(rev).contains(name) {
114                restated.push((rev, name.clone(), Tagging::Added));
115            }
116        }
117    }
118    for (rev, name, how) in restated {
119        store
120            .tag(rev, &name, how, by)
121            .map_err(|why| PrefixFailure::Tag { at: rev, why })?;
122    }
123    Ok(())
124}
125
126/// What the whole manifest calls the revs at or before `at` — the names
127/// the saved container owes, wherever in the file they were written. A
128/// later row that *clears* a name is honoured like any other: the rev
129/// arrives unnamed, because that is what the source says it is called.
130fn named_through(text: &str, at: Rev) -> Result<Tags, PrefixFailure> {
131    let mut tags = Tags::default();
132    for (nth, line) in whole_lines(text) {
133        let row = parse(nth, line)?;
134        if row.rev <= at
135            && let Replays::Vocabulary(how) = row.kind.replays()
136        {
137            tags.apply(row.rev, &row.label, how);
138        }
139    }
140    Ok(tags)
141}
142
143/// The leading run of `text`'s lines that reaches no further than `at`.
144///
145/// A *contiguous* prefix, because that is what keeps the hash chain whole: a
146/// line dropped from the middle would leave its successor naming a parent
147/// that is no longer there. Which means the tags that survive are the ones
148/// written before the cut — and a tag can only ever name a rev the history
149/// already held, so every one of them names a rev at or before `at`. The
150/// names given later to revs inside the cut are [`named_through`]'s to put
151/// back.
152fn through(text: &str, at: Rev) -> Result<&str, PrefixFailure> {
153    let mut end = 0;
154    let mut reached = Rev::ZERO;
155    for (nth, line) in whole_lines(text) {
156        let row = parse(nth, line)?;
157        if row.kind.takes_a_rev() {
158            if row.rev > at {
159                break;
160            }
161            reached = row.rev;
162        }
163        end += line.len();
164    }
165    if reached != at {
166        return Err(PrefixFailure::NoSuchRev(at));
167    }
168    Ok(&text[..end])
169}
170
171fn parse(nth: usize, line: &str) -> Result<Row, PrefixFailure> {
172    serde_json::from_str(line.trim_end())
173        .map_err(|why| PrefixFailure::NotARow { line: nth + 1, why })
174}
175
176/// The manifest's finished lines, numbered from one. A trailing line with
177/// no newline is left out, exactly as a load drops it: it is a row a crash
178/// caught half-written.
179fn whole_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
180    text.split_inclusive('\n')
181        .take_while(|line| line.ends_with('\n'))
182        .enumerate()
183}
184
185/// The source manifest, as text.
186fn read_manifest<S: Storage>(source: &S) -> Result<String, PrefixFailure> {
187    let bytes = ready_now(source.read(&MANIFEST))
188        .map_err(PrefixFailure::Read)?
189        .ok_or_else(|| PrefixFailure::Read(std::io::Error::from(std::io::ErrorKind::NotFound)))?;
190    String::from_utf8(bytes).map_err(|why| {
191        PrefixFailure::Read(std::io::Error::new(
192            std::io::ErrorKind::InvalidData,
193            why.to_string(),
194        ))
195    })
196}
197
198/// Copy the rev files at or below the cut, so the saved container arrives
199/// with the history it inherited already written.
200fn carry_revs<S: Storage, T: Storage>(
201    from: &S,
202    to: &Container<T>,
203    at: Rev,
204) -> Result<(), PrefixFailure> {
205    for rev in revs::through(at) {
206        let missing = || std::io::Error::from(std::io::ErrorKind::NotFound);
207        let entry = revs::entry(rev);
208        let carried = ready_now(from.read(&entry))
209            .and_then(|bytes| bytes.ok_or_else(missing))
210            .and_then(|bytes| to.write(&entry, &bytes));
211        carried.map_err(|why| PrefixFailure::Rev { at: rev, why })?;
212    }
213    Ok(())
214}
215
216/// Copy the artwork the carried revs reference from one container's
217/// `assets/` to another's. Content-addressed, so the file's name in the
218/// new container is the name it had in the old one and nothing has to be
219/// rewritten.
220///
221/// The revs are read back rather than the whole directory copied: what a
222/// save-as owes is the payloads its own history points at, and a payload
223/// nothing past the cut references is not part of what was asked for.
224fn carry_assets<S: Storage, T: Storage>(
225    from: &S,
226    to: &Container<T>,
227    at: Rev,
228) -> Result<(), PrefixFailure> {
229    let backing = to.revs();
230    let mut wanted: BTreeSet<AssetHash> = BTreeSet::new();
231    for rev in revs::through(at) {
232        let Ok(document) = revs::read(&backing, rev) else {
233            continue;
234        };
235        wanted.extend(document.referenced_assets());
236    }
237    for (hash, format) in wanted
238        .into_iter()
239        .flat_map(|hash| AssetFormat::ALL.map(move |format| (hash, format)))
240    {
241        let entry = asset_entry(hash, format);
242        let carried = || -> std::io::Result<()> {
243            if to.exists(&entry)? {
244                return Ok(());
245            }
246            match ready_now(from.read(&entry))? {
247                Some(bytes) => to.write(&entry, &bytes),
248                None => Ok(()),
249            }
250        };
251        carried().map_err(|why| PrefixFailure::Asset {
252            name: format.file_name(hash),
253            why,
254        })?;
255    }
256    Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::container::ASSETS;
263    use crate::fixture;
264    use crate::record::Identity;
265    use crate::storage::Native;
266    use blockworx_doc::fixtures::rev;
267    use std::path::Path;
268
269    /// A container of three edits with a tag on the first and another on
270    /// the third, written by a pinned clock so the bytes are stable.
271    fn source(root: &Path) -> Store<Native> {
272        let mut store = Store::create(Native::at(root), fixture::clock()).expect("the container");
273        let author = Identity::new("ada");
274        for (n, commit) in fixture::edits(3).iter().enumerate() {
275            store
276                .submit_edit(commit.clone(), &author)
277                .expect("the edit lands");
278            if n == 0 {
279                store
280                    .tag(rev(1), "the start", Tagging::Added, &author)
281                    .expect("the tag");
282            }
283        }
284        store
285            .tag(rev(3), "the end", Tagging::Added, &author)
286            .expect("the tag");
287        store
288    }
289
290    /// The load-bearing claim: the new log *is* the old log's first lines,
291    /// byte for byte. Anything less and the chain, the wall times, the
292    /// authors and the record kinds would all be this module's to reproduce.
293    #[test]
294    fn the_saved_log_is_the_sources_own_lines() {
295        let dir = fixture::dir("prefix-bytes");
296        let from = dir.join("source.bwx");
297        drop(source(&from));
298        let to = dir.join("through-2.bwx");
299
300        let saved = save_through(
301            &Native::at(&from),
302            rev(2),
303            Native::at(&to),
304            fixture::clock(),
305            &Identity::new("grace"),
306        )
307        .expect("the prefix saves");
308        drop(saved);
309
310        let whole =
311            std::fs::read_to_string(from.join(MANIFEST.as_str())).expect("the source manifest");
312        let prefix =
313            std::fs::read_to_string(to.join(MANIFEST.as_str())).expect("the saved manifest");
314        assert!(
315            whole.starts_with(&prefix),
316            "the saved log is not a prefix of the one it came from",
317        );
318        assert_eq!(
319            prefix.lines().count(),
320            3,
321            "expected two edits and the tag between them, not {prefix}",
322        );
323        assert!(
324            whole.len() > prefix.len(),
325            "precondition: the source runs past rev 2",
326        );
327    }
328
329    /// The rev files come over as files, and only the ones at or below the
330    /// cut: the saved container arrives with the history it inherited
331    /// already written, and carries nothing of the revs it left behind.
332    #[test]
333    fn the_saved_container_carries_exactly_the_revs_through_the_cut() {
334        let dir = fixture::dir("prefix-revs");
335        let from = dir.join("source.bwx");
336        drop(source(&from));
337        let to = dir.join("through-2.bwx");
338        assert!(
339            from.join(revs::entry(rev(3)).as_str()).is_file(),
340            "precondition: the source holds a rev past the cut",
341        );
342
343        let saved = save_through(
344            &Native::at(&from),
345            rev(2),
346            Native::at(&to),
347            fixture::clock(),
348            &Identity::new("grace"),
349        )
350        .expect("the prefix saves");
351        drop(saved);
352
353        for at in revs::through(rev(2)) {
354            assert_eq!(
355                std::fs::read(from.join(revs::entry(at).as_str())).expect("the source rev"),
356                std::fs::read(to.join(revs::entry(at).as_str())).expect("the saved rev"),
357                "rev {} did not come over as the bytes it was",
358                at.get(),
359            );
360        }
361        assert!(
362            !to.join(revs::entry(rev(3)).as_str()).exists(),
363            "a rev past the cut came over with the save",
364        );
365    }
366
367    /// Verify-grade: the container replays, its head is the rev that was
368    /// asked for, and the document it folds to is the one that rev held.
369    #[test]
370    fn the_saved_container_replays_to_the_rev_it_was_cut_at() {
371        let dir = fixture::dir("prefix-replay");
372        let from = dir.join("source.bwx");
373        let held = source(&from);
374        let at_two = blockworx_doc::repo::Repo::folding(&held.repo().log()[..2])
375            .expect("the prefix folds")
376            .document()
377            .clone();
378        drop(held);
379
380        let to = dir.join("through-2.bwx");
381        let saved = save_through(
382            &Native::at(&from),
383            rev(2),
384            Native::at(&to),
385            fixture::clock(),
386            &Identity::new("grace"),
387        )
388        .expect("the prefix saves");
389        assert_eq!(saved.repo().rev(), rev(2), "the head is not the cut");
390        drop(saved);
391
392        let reopened =
393            Store::open(Native::at(&to), fixture::clock()).expect("the container reopens");
394        assert!(
395            reopened.read_only_reason().is_none(),
396            "the saved container did not verify: {:?}",
397            reopened.read_only_reason(),
398        );
399        assert_eq!(
400            reopened.document().clone(),
401            at_two,
402            "the saved container is not the document that rev held",
403        );
404    }
405
406    /// The tags the user asked to keep, and the ones they asked to lose:
407    /// a name for a rev inside the prefix survives, and one for a rev past
408    /// it is not there to be found.
409    #[test]
410    fn a_tag_inside_the_cut_survives_and_one_past_it_does_not() {
411        let dir = fixture::dir("prefix-tags");
412        let from = dir.join("source.bwx");
413        let held = source(&from);
414        assert_eq!(held.tags().len(), 2, "precondition: both revs are named");
415        drop(held);
416
417        let to = dir.join("through-2.bwx");
418        let saved = save_through(
419            &Native::at(&from),
420            rev(2),
421            Native::at(&to),
422            fixture::clock(),
423            &Identity::new("grace"),
424        )
425        .expect("the prefix saves");
426        assert_eq!(saved.tags().of(rev(1)), ["the start"]);
427        assert_eq!(
428            saved.tags().of(rev(3)),
429            Vec::<String>::new(),
430            "a rev past the cut came over with its name",
431        );
432    }
433
434    /// Editing on top of a saved prefix is ordinary authoring: the new
435    /// container is writable at the rev it was cut at, and the next commit
436    /// is rev N+1 of *its* log.
437    #[test]
438    fn a_saved_prefix_takes_the_next_edit_normally() {
439        let dir = fixture::dir("prefix-authoring");
440        let from = dir.join("source.bwx");
441        drop(source(&from));
442
443        let to = dir.join("through-1.bwx");
444        let mut saved = save_through(
445            &Native::at(&from),
446            rev(1),
447            Native::at(&to),
448            fixture::clock(),
449            &Identity::new("grace"),
450        )
451        .expect("the prefix");
452        let next = saved
453            .submit_edit(fixture::edits(3)[1].clone(), &Identity::new("grace"))
454            .expect("the edit lands on the saved prefix");
455        assert_eq!(
456            next,
457            rev(2),
458            "the saved container did not author from its own head"
459        );
460        drop(saved);
461
462        let reopened =
463            Store::open(Native::at(&to), fixture::clock()).expect("the container reopens");
464        assert_eq!(reopened.repo().rev(), rev(2));
465        assert!(
466            reopened.read_only_reason().is_none(),
467            "the edit broke the chain"
468        );
469    }
470
471    /// A rev named from the head — long after it was made — is still named
472    /// in the container the cut writes. Its record cannot be copied (its
473    /// parent link names a line that is not there), so it is written again
474    /// by whoever asked for the save.
475    #[test]
476    fn a_name_given_later_to_a_rev_inside_the_cut_is_written_again() {
477        let dir = fixture::dir("prefix-late-tag");
478        let from = dir.join("source.bwx");
479        {
480            let mut store = source(&from);
481            store
482                .tag(
483                    rev(2),
484                    "worth keeping",
485                    Tagging::Added,
486                    &Identity::new("ada"),
487                )
488                .expect("the late tag");
489        }
490
491        let to = dir.join("through-2.bwx");
492        let saved = save_through(
493            &Native::at(&from),
494            rev(2),
495            Native::at(&to),
496            fixture::clock(),
497            &Identity::new("grace"),
498        )
499        .expect("the prefix saves");
500        assert_eq!(
501            saved.tags().of(rev(2)),
502            ["worth keeping"],
503            "a name given after the cut's own rev was left behind with it",
504        );
505        assert_eq!(saved.repo().rev(), rev(2), "a re-written tag spent a rev");
506        drop(saved);
507
508        let reopened =
509            Store::open(Native::at(&to), fixture::clock()).expect("the container reopens");
510        assert!(
511            reopened.read_only_reason().is_none(),
512            "the re-written tag broke the chain: {:?}",
513            reopened.read_only_reason(),
514        );
515        assert_eq!(reopened.tags().of(rev(2)), ["worth keeping"]);
516    }
517
518    /// A name the source later *cleared* is not put back: what the saved
519    /// container calls a rev is what the source calls it now.
520    #[test]
521    fn a_name_the_source_took_back_does_not_come_over() {
522        let dir = fixture::dir("prefix-untag");
523        let from = dir.join("source.bwx");
524        {
525            let mut store = source(&from);
526            store
527                .tag(rev(1), "the start", Tagging::Removed, &Identity::new("ada"))
528                .expect("the untag");
529        }
530
531        let to = dir.join("through-2.bwx");
532        let saved = save_through(
533            &Native::at(&from),
534            rev(2),
535            Native::at(&to),
536            fixture::clock(),
537            &Identity::new("grace"),
538        )
539        .expect("the prefix saves");
540        assert_eq!(
541            saved.tags().of(rev(1)),
542            Vec::<String>::new(),
543            "the saved container kept a name the source had dropped",
544        );
545    }
546
547    /// Artwork the prefix names travels with it — content-addressed, so
548    /// the file keeps its name — and the saved container hydrates it on
549    /// replay rather than opening read-only over a reference it cannot
550    /// honour.
551    #[test]
552    fn the_artwork_the_prefix_names_comes_with_it() {
553        let dir = fixture::dir("prefix-assets");
554        let from = dir.join("source.bwx");
555        let asset = {
556            let mut store =
557                Store::create(Native::at(&from), fixture::clock()).expect("the container");
558            let author = Identity::new("ada");
559            store
560                .submit_edit(fixture::edits(1)[0].clone(), &author)
561                .expect("the block lands");
562            let asset = fixture::svg(1);
563            store
564                .submit_edit(
565                    fixture::commit("Added an icon", fixture::icon(1, &asset)),
566                    &author,
567                )
568                .expect("the icon lands");
569            asset
570        };
571        assert_eq!(
572            std::fs::read_dir(from.join(ASSETS))
573                .expect("the source assets")
574                .count(),
575            1,
576            "precondition: the payload was extracted beside the source log",
577        );
578
579        let to = dir.join("through-2.bwx");
580        let saved = save_through(
581            &Native::at(&from),
582            rev(2),
583            Native::at(&to),
584            fixture::clock(),
585            &Identity::new("grace"),
586        )
587        .expect("the prefix saves");
588        assert!(
589            saved.read_only_reason().is_none(),
590            "the saved container could not honour its own artwork: {:?}",
591            saved.read_only_reason(),
592        );
593        drop(saved);
594        let carried: Vec<Vec<u8>> = std::fs::read_dir(to.join(ASSETS))
595            .expect("the saved assets")
596            .map(|entry| std::fs::read(entry.expect("an entry").path()).expect("the payload"))
597            .collect();
598        assert_eq!(carried, vec![asset.bytes().to_vec()]);
599    }
600
601    /// The cut is a rev this log holds, or it is refused — a container half
602    /// written is worse than a save that did not happen.
603    #[test]
604    fn a_rev_the_log_does_not_hold_is_refused() {
605        let dir = fixture::dir("prefix-no-such-rev");
606        let from = dir.join("source.bwx");
607        drop(source(&from));
608
609        let to = dir.join("through-9.bwx");
610        assert!(matches!(
611            save_through(
612                &Native::at(&from),
613                rev(9),
614                Native::at(&to),
615                fixture::clock(),
616                &Identity::new("grace")
617            ),
618            Err(PrefixFailure::NoSuchRev(_)),
619        ));
620        assert!(!to.exists(), "a refused save left a container behind");
621    }
622
623    /// Cutting at the head copies the whole log — which is what an ordinary
624    /// Save-as of an open container now is.
625    #[test]
626    fn cutting_at_the_head_copies_every_line() {
627        let dir = fixture::dir("prefix-whole");
628        let from = dir.join("source.bwx");
629        drop(source(&from));
630
631        let to = dir.join("whole.bwx");
632        let saved = save_through(
633            &Native::at(&from),
634            rev(3),
635            Native::at(&to),
636            fixture::clock(),
637            &Identity::new("grace"),
638        )
639        .expect("the whole log");
640        assert_eq!(saved.repo().rev(), rev(3));
641        assert_eq!(saved.tags().len(), 2, "a whole copy dropped a name");
642        drop(saved);
643        assert_eq!(
644            std::fs::read(from.join(MANIFEST.as_str())).expect("the source manifest"),
645            std::fs::read(to.join(MANIFEST.as_str())).expect("the copy"),
646            "a whole copy is not the same bytes",
647        );
648    }
649}