Skip to main content

blockworx/edit/
restore.rs

1//! Inventory row "Restore rev": the ops that carry the head document back
2//! to what it held at some earlier rev.
3//! Rationale: `docs/single-author-playbook.md`, Phase 4.
4//!
5//! The log is never rewritten — restoring is one ordinary forward commit,
6//! so the trail records that the author went back rather than pretending
7//! they never went forward. What makes that a *diff* rather than a reload
8//! is D10: entity ids are uuids drawn from one space across the whole log,
9//! so "the block that was at rev N" and "the block at head" are the same
10//! entity, and the difference between them is per-entity.
11//!
12//! Per entity, then: alive at N and absent at head is a create, alive at N
13//! and tombstoned at head is a restore, alive at head and not at N is a
14//! delete, and everything alive on both sides has its registers carried
15//! over by [`Entity::updates_toward`] — generated from the field list, so
16//! a register added tomorrow is restored without anyone remembering to
17//! come back here.
18
19use ahash::{HashMap, HashSet, HashSetExt as _};
20use blockworx_doc::{
21    block_model::Live,
22    commit::CommitBuilder,
23    document::Document,
24    entity::Entity,
25    opcode::{Crud, OpCodes},
26};
27
28/// The label a restore commit lands under. Named here because the panel's
29/// button and the commit have to agree, and the log is what the user reads
30/// back.
31pub fn label(rev: blockworx_doc::rev::Rev) -> String {
32    format!("Restore rev {}", rev.get())
33}
34
35/// Carry `head` to `target`. Pushes nothing when the two already agree, so
36/// restoring the rev you are already at seals to no commit.
37pub fn restore(head: &Document, target: &Document, builder: &mut CommitBuilder) {
38    // Payloads first: an image or icon that comes back needs its bytes in
39    // the same commit, and an asset is create-only, so "already there" is
40    // simply nothing to push.
41    for (hash, asset) in target.assets() {
42        if head.asset(&hash).is_none() {
43            builder.push(OpCodes::Asset(hash, asset.clone()));
44        }
45    }
46    for update in head
47        .title_block()
48        .updates_toward(&target.title_block().to_init())
49    {
50        builder.push(OpCodes::Document(update));
51    }
52    kind(head.blocks(), target.blocks(), OpCodes::Block, builder);
53    kind(head.pins(), target.pins(), OpCodes::Pin, builder);
54    kind(head.routes(), target.routes(), OpCodes::Route, builder);
55    kind(
56        head.route_labels(),
57        target.route_labels(),
58        OpCodes::RouteLabel,
59        builder,
60    );
61    kind(head.texts(), target.texts(), OpCodes::Text, builder);
62    kind(head.areas(), target.areas(), OpCodes::Area, builder);
63    kind(head.images(), target.images(), OpCodes::Image, builder);
64}
65
66/// One entity kind's whole diff, in id order so a restore of the same two
67/// documents always emits the same ops.
68fn kind<'a, I, E: Entity + 'a>(
69    head: impl Iterator<Item = (I, &'a Live<E>)>,
70    target: impl Iterator<Item = (I, &'a Live<E>)>,
71    op: impl Fn(I, Crud<E::Init, E::Update>) -> OpCodes,
72    builder: &mut CommitBuilder,
73) where
74    I: Copy + Eq + std::hash::Hash + Ord,
75{
76    let head: HashMap<I, &Live<E>> = head.collect();
77    let mut target: Vec<(I, &Live<E>)> = target.collect();
78    target.sort_unstable_by_key(|(id, _)| *id);
79    let mut seen = HashSet::with_capacity(target.len());
80
81    for (id, want) in target {
82        seen.insert(id);
83        match (head.get(&id), want.is_alive()) {
84            // Never existed at head, and the fold cannot produce that from
85            // a prefix — but a diff that only works on prefixes is a diff
86            // waiting to be wrong.
87            (None, true) => builder.push(op(id, Crud::Create(want.as_ref().to_init()))),
88            (None, false) => {}
89            (Some(have), true) => {
90                if !have.is_alive() {
91                    builder.push(op(id, Crud::Restore));
92                }
93                let init = want.as_ref().to_init();
94                for update in have.as_ref().updates_toward(&init) {
95                    builder.push(op(id, Crud::Update(update)));
96                }
97            }
98            (Some(have), false) => {
99                if have.is_alive() {
100                    builder.push(op(id, Crud::Delete));
101                }
102            }
103        }
104    }
105    // Born after the rev being restored: tombstoned, never erased.
106    let mut born_since: Vec<I> = head
107        .iter()
108        .filter(|(id, live)| live.is_alive() && !seen.contains(*id))
109        .map(|(id, _)| *id)
110        .collect();
111    born_since.sort_unstable();
112    for id in born_since {
113        builder.push(op(id, Crud::Delete));
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::edit::harness as h;
121    use crate::store::tests::fixture as fx_store;
122    use blockworx_doc::{
123        commit::Commit,
124        fixtures::{block_id, pin_id},
125        repo::Repo,
126    };
127
128    /// The oracle: two documents whose *projections* agree hold the same
129    /// diagram. `content_hash` cannot serve here — it covers the rev and
130    /// every register's write order, and a restore is a forward commit, so
131    /// the two can never be bit-equal however right the restore is.
132    fn projected(repo: &Repo) -> String {
133        crate::schema::project::to_json(repo)
134    }
135
136    /// Fold `commits`, then restore the document as it stood after the
137    /// first `prefix` of them, and report the repos on both sides.
138    fn restore_to_prefix(commits: &[Commit], prefix: usize) -> (Repo, Repo) {
139        let head = Repo::folding(commits).expect("the history folds");
140        let target = Repo::folding(&commits[..prefix]).expect("the prefix folds");
141        let mut builder = CommitBuilder::new(label(target.rev()));
142        restore(head.document(), target.document(), &mut builder);
143        let mut restored = head;
144        if let Some(commit) = builder.seal() {
145            restored.submit(commit).expect("the restore folds");
146        }
147        (restored, target)
148    }
149
150    fn assert_restores(commits: &[Commit], prefix: usize) {
151        let (restored, target) = restore_to_prefix(commits, prefix);
152        assert_eq!(
153            projected(&restored),
154            projected(&target),
155            "the restored head is not the document rev {} held",
156            target.rev().get(),
157        );
158    }
159
160    fn block(byte: u8, name: &str) -> Commit {
161        fx_store::commit("Added a block", vec![fx_store::block_create(byte, name)])
162    }
163
164    /// The three lifecycle arms in one history: a block that survives, a
165    /// block born after the rev (so the restore must tombstone it), and a
166    /// block deleted before head (so the restore must bring it back).
167    fn history() -> Vec<Commit> {
168        vec![
169            block(1, "Adder"),
170            block(2, "Summer"),
171            // rev 2 is the target below: both blocks alive, unrenamed.
172            fx_store::commit("Renamed it", vec![fx_store::block_rename(1, "Renamed")]),
173            fx_store::commit(
174                "Deleted it",
175                vec![OpCodes::Block(block_id(2), Crud::Delete)],
176            ),
177            block(3, "Born later"),
178        ]
179    }
180
181    #[test]
182    fn a_restore_carries_deletes_creates_and_renames_back_together() {
183        let commits = history();
184        let (restored, target) = restore_to_prefix(&commits, 2);
185
186        assert_eq!(
187            restored.log().len(),
188            commits.len() + 1,
189            "a restore is one forward commit, never a rewrite",
190        );
191        assert!(
192            restored
193                .document()
194                .block(&block_id(2))
195                .expect("the deleted block is still an entity")
196                .is_alive(),
197            "the block deleted after the rev did not come back",
198        );
199        assert!(
200            !restored
201                .document()
202                .block(&blockworx_doc::fixtures::block_id(3))
203                .expect("the later block is still an entity")
204                .is_alive(),
205            "the block born after the rev was not tombstoned",
206        );
207        assert_eq!(
208            restored
209                .document()
210                .block(&block_id(1))
211                .expect("the surviving block")
212                .as_ref()
213                .title
214                .name
215                .as_ref(),
216            "Adder",
217            "the rename made after the rev was not carried back",
218        );
219        assert_eq!(projected(&restored), projected(&target));
220    }
221
222    /// Every rev of the same history, so no arm of the diff is only ever
223    /// exercised from one direction.
224    #[test]
225    fn every_rev_of_a_history_restores_to_itself() {
226        let commits = history();
227        for prefix in 0..=commits.len() {
228            assert_restores(&commits, prefix);
229        }
230    }
231
232    /// Restoring the rev you are already at must author nothing — the
233    /// no-op rule every emitter follows, and what keeps a stray click out
234    /// of the audit trail.
235    #[test]
236    fn restoring_the_head_seals_to_nothing() {
237        let head = Repo::folding(&history()).expect("the history folds");
238        let mut builder = CommitBuilder::new("Restore".to_owned());
239        restore(head.document(), head.document(), &mut builder);
240        h::seals_to_nothing(builder);
241    }
242
243    /// A pin and the wire on it: a restore that brings a route back has to
244    /// bring its endpoints back in the same commit, or the fold refuses
245    /// the whole thing.
246    #[test]
247    fn a_wire_and_its_endpoints_come_back_in_one_commit() {
248        use crate::path::Scope;
249        use crate::widget::test_fixtures as fx;
250        use blockworx_doc::values::PinSide;
251
252        let commits = vec![
253            fx_store::commit(
254                "Built a scene",
255                vec![
256                    fx::block_in(
257                        1,
258                        Scope::Root,
259                        egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(60.0, 150.0)),
260                    ),
261                    fx::block_in(
262                        2,
263                        Scope::Root,
264                        egui::Rect::from_min_max(egui::pos2(180.0, 0.0), egui::pos2(240.0, 150.0)),
265                    ),
266                    fx::pin(3, 1, PinSide::East, 0),
267                    fx::pin(4, 2, PinSide::West, 0),
268                    fx::route(5, Scope::Root, 3, 4, &[(20, 4)]),
269                ],
270            ),
271            // Deleting the pin takes the wire with it, so the restore must
272            // put both back.
273            fx_store::commit(
274                "Deleted a pin and its wire",
275                vec![
276                    OpCodes::Route(blockworx_doc::fixtures::route_id(5), Crud::Delete),
277                    OpCodes::Pin(pin_id(3), Crud::Delete),
278                ],
279            ),
280        ];
281        let (restored, target) = restore_to_prefix(&commits, 1);
282        assert!(
283            restored
284                .document()
285                .route(&blockworx_doc::fixtures::route_id(5))
286                .expect("the route is still an entity")
287                .is_alive(),
288            "the wire did not come back with its pin",
289        );
290        assert_eq!(projected(&restored), projected(&target));
291    }
292}