Skip to main content

blockworx/
collab.rs

1//! [`LocalHost`]: a [`Host`] and the [`ClientSession`] that edits it, wired
2//! together in this process. The editor's only link to an authority now
3//! that the server and its wire are gone (`docs/single-author-playbook.md`,
4//! D5 step one).
5
6use blockworx_doc::{
7    commit::Commit,
8    document::FoldError,
9    rev::Rev,
10    session::{ClientSession, Host, Nonce, SessionError, UndoRefusal},
11};
12
13/// One step over the session's undo/redo journal. A fn pointer rather than
14/// a flag: the two steps are the session's own methods, and naming them at
15/// the call site keeps the link from re-deciding which is which.
16type JournalStep = fn(&mut ClientSession, Nonce) -> Result<Nonce, UndoRefusal>;
17
18/// Why a log would not seed a [`LocalHost`].
19#[derive(Debug, thiserror::Error)]
20pub enum SeedError {
21    #[error("the seed log does not fold: {0}")]
22    Refused(#[from] FoldError),
23    #[error("the host's own welcome did not land: {0}")]
24    Welcome(#[from] SessionError),
25}
26
27/// The authority, in this process: a [`Host`] and the [`ClientSession`]
28/// that edits it, wired loopback. A submission's answer is delivered before
29/// `submit` returns, so the queue is empty at every call boundary.
30///
31/// `Default` is the empty document, which is also what [`Self::new`]
32/// reaches from an empty log (asserted, not assumed).
33#[derive(Default)]
34pub struct LocalHost {
35    host: Host,
36    session: ClientSession,
37    /// Ordinary edits submitted since the editor last drained this. Not
38    /// undo/redo submissions: those are steps the editor's own stack is
39    /// already taking, and recording them would make a step of a step.
40    submitted: Vec<Nonce>,
41}
42
43impl LocalHost {
44    /// Fold `commits` into the host, then welcome the session from its log.
45    ///
46    /// # Errors
47    /// A commit the fold refuses; or, unreachably, a welcome the session
48    /// will not take.
49    pub fn new(commits: Vec<Commit>) -> Result<Self, SeedError> {
50        let mut host = Host::default();
51        for commit in commits {
52            host.ingest(&commit)?;
53        }
54        let session = ClientSession::welcome(host.commits_after(Rev::ZERO))?;
55        Ok(Self {
56            host,
57            session,
58            submitted: Vec::new(),
59        })
60    }
61
62    /// Seal, predict, and answer.
63    ///
64    /// # Errors
65    /// The fold's refusal, checked against the prediction — the host would
66    /// refuse it too, so it is never sequenced.
67    pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
68        let nonce = self.session.submit(commit)?;
69        self.submitted.push(nonce);
70        self.ship();
71        Ok(nonce)
72    }
73
74    fn drain_submitted(&mut self) -> Vec<Nonce> {
75        std::mem::take(&mut self.submitted)
76    }
77
78    /// # Errors
79    /// The refusal, as [`ClientSession::undo`].
80    fn journal(&mut self, edit: Nonce, step: JournalStep) -> Result<Nonce, UndoRefusal> {
81        let nonce = step(&mut self.session, edit)?;
82        self.ship();
83        Ok(nonce)
84    }
85
86    /// Sequence whatever the session just queued and hand the answer back
87    /// to it, all before the caller returns.
88    fn ship(&mut self) {
89        let Some((nonce, outbound)) = self
90            .session
91            .last_submission()
92            .map(|(nonce, commit)| (nonce, commit.clone()))
93        else {
94            return;
95        };
96        let answered = match self.host.accept(outbound) {
97            Ok(accepted) => {
98                let rev = self.host.publish(accepted);
99                self.session.committed(nonce, rev)
100            }
101            // Unreachable by construction: the session folds every
102            // submission against its prediction first, and with the answer
103            // delivered before `submit` returns, that prediction *is* the
104            // host's document. Mirrored rather than asserted anyway — the
105            // session already knows how to revert a rejection, and dying
106            // over a broken invariant would cost the user the drawing.
107            Err(refusal) => {
108                tracing::error!("the in-process host refused a predicted commit: {refusal}");
109                self.session.rejected(nonce)
110            }
111        };
112        if let Err(refusal) = answered {
113            tracing::error!("the local session and its own host disagree: {refusal}");
114        }
115    }
116
117    pub fn session(&self) -> &ClientSession {
118        &self.session
119    }
120}
121
122/// The editor's link to the authority that sequences its commits. One
123/// variant since the transport left: callers read
124/// [`session`](Self::session) and never ask what is behind it.
125///
126/// Boxed because it carries a whole session, which every `App` would
127/// otherwise hold inline.
128pub enum Link {
129    Local(Box<LocalHost>),
130}
131
132impl Link {
133    pub fn local(host: LocalHost) -> Self {
134        Link::Local(Box::new(host))
135    }
136
137    /// # Errors
138    /// The fold's refusal, as [`LocalHost::submit`].
139    pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
140        match self {
141            Link::Local(local) => local.submit(commit),
142        }
143    }
144
145    /// Undo the last edit: the session builds the inverse and this ships it
146    /// as an ordinary commit, so the log cannot tell an undo from an edit.
147    /// Availability is [`ClientSession::can_undo`].
148    ///
149    /// # Errors
150    /// The inverse's refusal, as [`Self::submit`].
151    pub fn undo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
152        self.journal(edit, ClientSession::undo)
153    }
154
155    /// # Errors
156    /// As [`Self::undo`].
157    pub fn redo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
158        self.journal(edit, ClientSession::redo)
159    }
160
161    fn journal(&mut self, edit: Nonce, step: JournalStep) -> Result<Nonce, UndoRefusal> {
162        match self {
163            Link::Local(local) => local.journal(edit, step),
164        }
165    }
166
167    /// The ordinary edits submitted since the last drain — what the editor's
168    /// undo stack owes a step each.
169    pub fn drain_submitted(&mut self) -> Vec<Nonce> {
170        match self {
171            Link::Local(local) => local.drain_submitted(),
172        }
173    }
174
175    pub fn session(&self) -> &ClientSession {
176        match self {
177            Link::Local(local) => local.session(),
178        }
179    }
180
181    /// What the window title says about the link. The document is the
182    /// session, so where the session's commits go is what the title is
183    /// *for*.
184    pub fn summary(&self) -> String {
185        match self {
186            // D5's marker, true as of the flag day: the in-process host
187            // lives and dies with the process, and the container save path
188            // that used to share this title is gone (F5/F6).
189            Link::Local(_) => "nothing persisted".into(),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::path::Scope;
198    use blockworx_doc::{
199        block_model::{BlockInit, BlockUpdate, Icon, LabelInit},
200        fixtures::{block_id, commit},
201        geometry::{FracVal, GridPoint, GridRect, GridSize},
202        opcode::{Crud, OpCodes},
203        values::{LabelSide, Role},
204    };
205
206    fn rect(x: i32, y: i32) -> GridRect {
207        GridRect {
208            top_left: GridPoint { x, y },
209            size: GridSize { w: 4, h: 4 },
210        }
211    }
212
213    fn block_create(byte: u8) -> OpCodes {
214        OpCodes::Block(
215            block_id(byte),
216            Crud::Create(BlockInit {
217                parent: Scope::Root.wire_id(),
218                rect: rect(0, 0),
219                locked: false,
220                role: Role::default(),
221                title: LabelInit {
222                    name: format!("b{byte}"),
223                    side: LabelSide::default(),
224                    offset: FracVal::default(),
225                    hidden: false,
226                },
227                type_label: LabelInit {
228                    name: String::new(),
229                    side: LabelSide::default(),
230                    offset: FracVal::default(),
231                    hidden: false,
232                },
233                icon: Icon::default(),
234            }),
235        )
236    }
237
238    fn resize(byte: u8, to: GridRect) -> OpCodes {
239        OpCodes::Block(block_id(byte), Crud::Update(BlockUpdate::Rect(to)))
240    }
241
242    /// Nothing in flight, and the session's confirmed document is the
243    /// host's — the whole convergence claim, for a host of one.
244    fn assert_converged(local: &LocalHost) {
245        assert!(
246            local.session.last_submission().is_none(),
247            "the queue drains before submit returns",
248        );
249        assert_eq!(
250            local.session.confirmed().content_hash(),
251            local.host.state().content_hash(),
252            "the session's confirmed document is the host's, byte for byte",
253        );
254        assert_eq!(
255            local.session.optimistic().content_hash(),
256            local.session.confirmed().content_hash(),
257            "with nothing pending, the prediction is the confirmed document",
258        );
259        assert_eq!(local.session.rev(), local.host.rev());
260    }
261
262    #[test]
263    fn an_empty_seed_is_the_default_host() {
264        let seeded = LocalHost::new(Vec::new()).expect("an empty log seeds");
265        let default = LocalHost::default();
266        assert_eq!(
267            seeded.session.confirmed().content_hash(),
268            default.session.confirmed().content_hash(),
269        );
270        assert_eq!(seeded.session.rev(), Rev::ZERO);
271        assert_converged(&seeded);
272        assert_converged(&default);
273    }
274
275    #[test]
276    fn a_seeded_log_welcomes_the_session_at_its_head() {
277        let log = vec![
278            commit("Added a block", vec![block_create(1)]),
279            commit("Added another", vec![block_create(2)]),
280        ];
281        assert_eq!(log.len(), 2, "precondition: the seed log is not empty");
282
283        let local = LocalHost::new(log).expect("the seed log folds");
284        assert_eq!(
285            local.session.rev().get(),
286            2,
287            "the welcome's head is the log's length",
288        );
289        assert_converged(&local);
290        assert!(
291            local.session.confirmed().block(&block_id(2)).is_some(),
292            "the seeded blocks are in the session's document",
293        );
294    }
295
296    /// A submission is accepted, sequenced, and acknowledged inside the
297    /// call — and the next one stacks on top of it.
298    #[test]
299    fn a_submission_is_acked_before_it_returns() {
300        let mut local =
301            LocalHost::new(vec![commit("Added a block", vec![block_create(1)])]).expect("it folds");
302        let seeded = local.session.rev();
303
304        local
305            .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
306            .expect("the edit folds");
307        assert_eq!(local.session.rev(), seeded.next(), "one commit, one rev");
308        assert_converged(&local);
309        assert_eq!(
310            *local
311                .session
312                .confirmed()
313                .block(&block_id(1))
314                .expect("the block is confirmed")
315                .as_ref()
316                .rect
317                .as_ref(),
318            rect(5, 5),
319        );
320
321        local
322            .submit(commit("Moved it again", vec![resize(1, rect(9, 9))]))
323            .expect("the second edit folds on the first");
324        assert_eq!(local.session.rev(), seeded.next().next());
325        assert_converged(&local);
326        assert_eq!(
327            *local
328                .session
329                .confirmed()
330                .block(&block_id(1))
331                .expect("the block is confirmed")
332                .as_ref()
333                .rect
334                .as_ref(),
335            rect(9, 9),
336        );
337    }
338
339    /// The prediction check is the client's, and it fires before anything
340    /// reaches the host: a refused edit sequences nothing and journals
341    /// nothing.
342    #[test]
343    fn an_edit_the_fold_refuses_never_reaches_the_host() {
344        let mut local = LocalHost::default();
345        let before = local.host.rev();
346
347        assert!(
348            local
349                .submit(commit("Moved a stranger", vec![resize(9, rect(1, 1))]))
350                .is_err(),
351            "the target is not in the document",
352        );
353        assert_eq!(local.host.rev(), before, "a refusal mints no rev");
354        assert_converged(&local);
355    }
356
357    /// The title says what the in-process host costs: the session is the
358    /// document, and it goes with the process (D5, true as of the flag day).
359    #[test]
360    fn a_local_link_says_in_the_title_that_nothing_is_persisted() {
361        let link = Link::local(LocalHost::default());
362        assert_eq!(link.session().rev(), Rev::ZERO);
363        assert_eq!(link.summary(), "nothing persisted");
364    }
365
366    /// The block the journal tests move, as the session confirmed it.
367    fn block_rect(link: &Link) -> GridRect {
368        *link
369            .session()
370            .confirmed()
371            .block(&block_id(1))
372            .expect("the block is confirmed")
373            .as_ref()
374            .rect
375            .as_ref()
376    }
377
378    /// Undo and redo reach the document through the *link*, and travel as
379    /// ordinary commits: each one takes a rev, so the authority sequenced
380    /// it exactly as it sequences an edit.
381    #[test]
382    fn a_link_undoes_and_redoes_through_the_session_journal() {
383        let mut link = Link::local(
384            LocalHost::new(vec![commit("Added a block", vec![block_create(1)])]).expect("it folds"),
385        );
386        let (placed, moved) = (rect(0, 0), rect(5, 5));
387        assert_eq!(block_rect(&link), placed, "precondition: the seeded rect");
388        assert!(
389            !link.session().can_undo(),
390            "precondition: a welcome journals nothing",
391        );
392
393        link.submit(commit("Moved it", vec![resize(1, moved)]))
394            .expect("the edit folds");
395        assert_eq!(block_rect(&link), moved);
396        assert_eq!(link.session().rev().get(), 2);
397        assert!(link.session().can_undo());
398        assert!(!link.session().can_redo());
399
400        let edit = link.session().next_undo().expect("the edit is journalled");
401        link.undo(edit).expect("the inverse folds");
402        assert_eq!(block_rect(&link), placed, "undo put the block back");
403        assert_eq!(link.session().rev().get(), 3, "the inverse took a rev");
404        assert!(link.session().can_redo());
405        assert!(!link.session().can_undo(), "the past is spent");
406
407        let undone = link.session().next_redo().expect("the undo is journalled");
408        link.redo(undone).expect("the edit re-applies");
409        assert_eq!(block_rect(&link), moved, "redo moved it again");
410        assert_eq!(link.session().rev().get(), 4);
411        assert!(link.session().can_undo());
412        assert!(!link.session().can_redo(), "the future is spent");
413    }
414
415    /// An empty stack has no edit to name, so the editor never asks — and if
416    /// it did, the session says so rather than inventing a commit.
417    #[test]
418    fn an_empty_journal_has_no_step_to_name() {
419        let mut link = Link::local(LocalHost::default());
420        assert!(!link.session().can_undo() && !link.session().can_redo());
421        assert_eq!(link.session().next_undo(), None);
422        assert_eq!(link.session().next_redo(), None);
423
424        // An edit from another session, so the nonce names nothing here.
425        let mut elsewhere = Link::local(LocalHost::default());
426        let foreign = elsewhere
427            .submit(commit("Placed it", vec![block_create(1)]))
428            .expect("the edit folds");
429        assert!(link.undo(foreign).is_err(), "nothing stands ready");
430        assert!(link.redo(foreign).is_err());
431        assert_eq!(link.session().rev(), Rev::ZERO, "no commit was sequenced");
432    }
433
434    /// The editor's stack names the edit it made, so a submission's nonce has
435    /// to reach it. Undo and redo submissions must not: they are steps the
436    /// editor's stack is already taking, and a step of a step would leave the
437    /// two disagreeing about how many edits stand ready.
438    #[test]
439    fn only_ordinary_edits_are_reported_to_the_editor() {
440        let mut link = Link::local(LocalHost::default());
441        let placed = link
442            .submit(commit("Placed it", vec![block_create(1)]))
443            .expect("the edit folds");
444        assert_eq!(
445            link.drain_submitted(),
446            vec![placed],
447            "the edit is reported once, by name",
448        );
449        assert!(
450            link.drain_submitted().is_empty(),
451            "and draining is what makes it a frame's worth",
452        );
453
454        link.undo(placed).expect("the inverse folds");
455        assert!(
456            link.drain_submitted().is_empty(),
457            "the undo's own submission is not a new edit to remember",
458        );
459    }
460}