Skip to main content

blockworx_doc/
session.rs

1//! The two ends of the document: [`Host`], the authority that sequences
2//! commits, and [`ClientSession`], the edit surface that predicts them.
3//! Neither knows about storage — that wraps them.
4//! Rationale: `docs/collab-architecture.md` §6-§7.
5
6use std::collections::VecDeque;
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    block_model::Live,
12    commit::Commit,
13    document::{Document, FoldError},
14    entity::Entity,
15    opcode::{Crud, OpCodes},
16    rev::{Confirmed, Provisional, Rev, RevKind},
17};
18
19/// Matches an answer to the submission that produced it, and names the
20/// journal entry that submission left behind.
21#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
22pub struct Nonce(u64);
23
24impl Nonce {
25    pub(crate) const fn new(count: u64) -> Self {
26        Self(count)
27    }
28}
29
30#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
31pub enum SessionError {
32    /// The host lost or reordered an answer. Resync.
33    #[error("expected rev {expected:?} on this connection, got {got:?}")]
34    RevGap { expected: Rev, got: Rev },
35    /// The queue and the host disagree about what is in flight.
36    #[error("expected an answer for {expected:?}, got one for {got:?}")]
37    NonceMismatch { expected: Option<Nonce>, got: Nonce },
38    /// A sequenced commit must fold; reaching this means the log or the
39    /// host is wrong. Halt and resync, never repair.
40    #[error("a sequenced commit was refused: {0}")]
41    Corrupt(#[from] FoldError),
42}
43
44/// The authority: what `LocalHost` wraps.
45#[derive(Default)]
46pub struct Host {
47    state: Document<Confirmed>,
48    log: Vec<Commit>,
49}
50
51/// A commit that has folded but is not yet visible. The server persists it
52/// before publishing, so a commit it has broadcast always survives a
53/// restart — folding first and writing afterwards would let a failed
54/// `INSERT` leave clients holding a commit the log does not have.
55pub struct Accepted {
56    state: Document<Confirmed>,
57    commit: Commit,
58}
59
60impl Accepted {
61    pub fn rev(&self) -> Rev {
62        self.state.rev()
63    }
64
65    pub fn commit(&self) -> &Commit {
66        &self.commit
67    }
68}
69
70impl Host {
71    /// Fold without publishing: validates, mints the rev, and hands back a
72    /// successor the caller can persist before making it visible.
73    ///
74    /// # Errors
75    /// The fold's refusal, which consumes no rev and logs nothing.
76    pub fn accept(&self, commit: Commit) -> Result<Accepted, FoldError> {
77        Ok(Accepted {
78            state: self.state.try_apply(&commit)?,
79            commit,
80        })
81    }
82
83    pub fn publish(&mut self, accepted: Accepted) -> Rev {
84        self.state = accepted.state;
85        self.log.push(accepted.commit);
86        self.state.rev()
87    }
88
89    /// Accept and publish in one step, for hosts with nothing to persist.
90    ///
91    /// # Errors
92    /// As [`Self::accept`].
93    pub fn ingest(&mut self, commit: &Commit) -> Result<Rev, FoldError> {
94        let accepted = self.accept(commit.clone())?;
95        Ok(self.publish(accepted))
96    }
97
98    pub fn state(&self) -> &Document<Confirmed> {
99        &self.state
100    }
101
102    pub fn rev(&self) -> Rev {
103        self.state.rev()
104    }
105
106    /// `log[i]` holds `Rev(i + 1)` — contiguous because a refused commit
107    /// consumes no rev, which is what lets the log be a `Vec`.
108    pub fn commits_after(&self, rev: Rev) -> &[Commit] {
109        let already_held = (rev.get() as usize).min(self.log.len());
110        &self.log[already_held..]
111    }
112}
113
114struct Pending {
115    nonce: Nonce,
116    commit: Commit,
117}
118
119/// Which stack a submission's inverse lands on, and what it does to the
120/// other — the whole undo/redo policy, stated once. Inverses are built
121/// from the values current at use time, not at edit time, which is what
122/// keeps an undo/redo round trip from moving the document.
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124enum JournalAs {
125    Edit,
126    Undo,
127    Redo,
128}
129
130struct JournalEntry {
131    /// The submission this entry inverts, and the entry's identity.
132    ///
133    /// Kept after the acknowledgement, unlike [`Self::pending`], because the
134    /// editor's own stack names its steps by it: a rejection removes an entry
135    /// from the *middle* of this stack, so a caller that identified an entry
136    /// by position would afterwards invert a different edit than the one it
137    /// meant.
138    nonce: Nonce,
139    inverse: Commit,
140    /// Still in flight, so a rejection can still take this entry away.
141    pending: bool,
142}
143
144#[derive(Default)]
145struct Journal {
146    undo: Vec<JournalEntry>,
147    redo: Vec<JournalEntry>,
148}
149
150impl Journal {
151    fn push(&mut self, entry: JournalEntry, journal_as: JournalAs) {
152        match journal_as {
153            JournalAs::Edit => {
154                self.redo.clear();
155                self.undo.push(entry);
156            }
157            JournalAs::Undo => self.redo.push(entry),
158            JournalAs::Redo => self.undo.push(entry),
159        }
160    }
161
162    fn ack(&mut self, nonce: Nonce) {
163        for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) {
164            if entry.nonce == nonce {
165                entry.pending = false;
166            }
167        }
168    }
169
170    /// The inverse of an edit that never landed would write stale values
171    /// at a fresh order, and a refused create's inverse cannot fold.
172    fn discard(&mut self, nonce: Nonce) {
173        self.undo
174            .retain(|entry| !(entry.pending && entry.nonce == nonce));
175        self.redo
176            .retain(|entry| !(entry.pending && entry.nonce == nonce));
177    }
178}
179
180/// Why a history step did not happen.
181#[derive(Debug, thiserror::Error, PartialEq)]
182pub enum UndoRefusal {
183    #[error("nothing stands ready to be inverted")]
184    Spent,
185    #[error("the step names an edit this session no longer holds on top ({top:?})")]
186    Stale { top: Nonce },
187    #[error("the inverse was refused: {0}")]
188    Refused(#[from] FoldError),
189}
190
191/// The edit surface: what the editor wraps.
192#[derive(Default)]
193pub struct ClientSession {
194    confirmed: Document<Confirmed>,
195    /// Sealed, submitted, unacked — in the order the server answers in.
196    pending: VecDeque<Pending>,
197    /// `confirmed ⊕ pending`: what the editor draws.
198    optimistic: Document<Provisional>,
199    journal: Journal,
200    next_nonce: u64,
201}
202
203impl ClientSession {
204    /// # Errors
205    /// [`SessionError::Corrupt`] if the log and this build disagree.
206    pub fn welcome(commits: &[Commit]) -> Result<Self, SessionError> {
207        let mut session = Self::default();
208        for commit in commits {
209            session.confirmed = session.confirmed.try_apply(commit)?;
210        }
211        session.rebuild();
212        Ok(session)
213    }
214
215    /// The only rev that means anything off this machine.
216    pub fn rev(&self) -> Rev {
217        self.confirmed.rev()
218    }
219
220    pub fn confirmed(&self) -> &Document<Confirmed> {
221        &self.confirmed
222    }
223
224    pub fn optimistic(&self) -> &Document<Provisional> {
225        &self.optimistic
226    }
227
228    /// What the transport sends. Undo and redo build their own commits,
229    /// so this is the caller's only handle on them.
230    pub fn last_submission(&self) -> Option<(Nonce, &Commit)> {
231        self.pending
232            .back()
233            .map(|pending| (pending.nonce, &pending.commit))
234    }
235
236    pub fn can_undo(&self) -> bool {
237        !self.journal.undo.is_empty()
238    }
239
240    /// How many of this client's own edits stand ready to be inverted. The
241    /// editor keeps a parallel stack that interleaves view steps with these,
242    /// and the two depths must agree — exposed so the mismatch is checkable
243    /// rather than silent.
244    pub fn undo_depth(&self) -> usize {
245        self.journal.undo.len()
246    }
247
248    pub fn redo_depth(&self) -> usize {
249        self.journal.redo.len()
250    }
251
252    pub fn can_redo(&self) -> bool {
253        !self.journal.redo.is_empty()
254    }
255
256    /// # Errors
257    /// The fold's refusal, checked against the prediction: the server
258    /// would refuse it too, so it is never queued.
259    pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
260        self.submit_journaled(commit, JournalAs::Edit)
261    }
262
263    fn submit_journaled(
264        &mut self,
265        commit: Commit,
266        journal_as: JournalAs,
267    ) -> Result<Nonce, FoldError> {
268        // One binding for both reads: the baseline must come from the
269        // document the commit is predicted against, never from `confirmed`.
270        let pre_image = &self.optimistic;
271        let predicted = pre_image.try_apply(&commit)?;
272        let inverse = inverse_of(pre_image, &commit);
273
274        let nonce = Nonce::new(self.next_nonce);
275        self.next_nonce += 1;
276        self.journal.push(
277            JournalEntry {
278                nonce,
279                inverse,
280                pending: true,
281            },
282            journal_as,
283        );
284        self.pending.push_back(Pending { nonce, commit });
285        // Appending to the queue makes this what a rebuild would produce.
286        self.optimistic = predicted;
287        Ok(nonce)
288    }
289
290    /// The edit an [`undo`](Self::undo) would invert, and the one a
291    /// [`redo`](Self::redo) would replay — so a caller holding its own
292    /// history can tell whether the step it is about to take is still the
293    /// step this session would take.
294    pub fn next_undo(&self) -> Option<Nonce> {
295        self.journal.undo.last().map(|entry| entry.nonce)
296    }
297
298    pub fn next_redo(&self) -> Option<Nonce> {
299        self.journal.redo.last().map(|entry| entry.nonce)
300    }
301
302    /// Every edit standing ready to be inverted, oldest first — for a caller
303    /// whose own stack must agree with this one, entry for entry.
304    pub fn undo_nonces(&self) -> Vec<Nonce> {
305        self.journal.undo.iter().map(|entry| entry.nonce).collect()
306    }
307
308    /// Submits the inverse of `edit` as an ordinary commit. The entry stays
309    /// on the stack if its inverse will not fold.
310    ///
311    /// `edit` names which submission the caller believes it is inverting,
312    /// and a mismatch is refused rather than silently taken as "the top one".
313    /// Identity is the point: a rejected commit is discarded from the middle
314    /// of the stack, so an "undo the top" call from a caller whose own
315    /// history still counts that edit would invert its neighbour.
316    ///
317    /// # Errors
318    /// [`UndoRefusal::Spent`] when nothing stands ready, [`UndoRefusal::Stale`]
319    /// when the top is a different edit than `edit` — its own nonce reported
320    /// so the caller can resynchronise — and [`UndoRefusal::Refused`] for a
321    /// fold refusal, as [`Self::submit`].
322    pub fn undo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
323        self.step(edit, JournalAs::Undo)
324    }
325
326    /// # Errors
327    /// As [`Self::undo`].
328    pub fn redo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
329        self.step(edit, JournalAs::Redo)
330    }
331
332    fn step(&mut self, edit: Nonce, direction: JournalAs) -> Result<Nonce, UndoRefusal> {
333        let stack = match direction {
334            JournalAs::Undo => &mut self.journal.undo,
335            JournalAs::Redo => &mut self.journal.redo,
336            JournalAs::Edit => unreachable!("an edit is not a history step"),
337        };
338        let top = stack.last().ok_or(UndoRefusal::Spent)?;
339        if top.nonce != edit {
340            return Err(UndoRefusal::Stale { top: top.nonce });
341        }
342        let inverse = top.inverse.clone();
343        let submitted = self.submit_journaled(inverse, direction)?;
344        match direction {
345            JournalAs::Undo => self.journal.undo.pop(),
346            JournalAs::Redo => self.journal.redo.pop(),
347            JournalAs::Edit => None,
348        };
349        Ok(submitted)
350    }
351
352    /// Someone else's edit.
353    ///
354    /// # Errors
355    /// [`SessionError::RevGap`] out of sequence, [`SessionError::Corrupt`]
356    /// if a sequenced commit refuses.
357    pub fn apply_foreign(&mut self, rev: Rev, commit: &Commit) -> Result<(), SessionError> {
358        self.expect_next(rev)?;
359        self.confirmed = self.confirmed.try_apply(commit)?;
360        self.rebuild();
361        Ok(())
362    }
363
364    /// Our own edit, accepted and sequenced as `rev`.
365    ///
366    /// # Errors
367    /// [`SessionError::NonceMismatch`] if the answer is not for the front
368    /// of the queue, plus [`Self::apply_foreign`]'s.
369    pub fn committed(&mut self, nonce: Nonce, rev: Rev) -> Result<(), SessionError> {
370        self.expect_front(nonce)?;
371        self.expect_next(rev)?;
372        if let Some(acked) = self.pending.front() {
373            self.confirmed = self.confirmed.try_apply(&acked.commit)?;
374            self.pending.pop_front();
375            self.journal.ack(nonce);
376            self.rebuild();
377        }
378        Ok(())
379    }
380
381    /// Our own edit, refused at ingress: nothing was sequenced.
382    ///
383    /// # Errors
384    /// [`SessionError::NonceMismatch`], as [`Self::committed`].
385    pub fn rejected(&mut self, nonce: Nonce) -> Result<(), SessionError> {
386        self.expect_front(nonce)?;
387        self.pending.pop_front();
388        self.journal.discard(nonce);
389        self.rebuild();
390        Ok(())
391    }
392
393    fn expect_next(&self, rev: Rev) -> Result<(), SessionError> {
394        let expected = self.confirmed.rev().next();
395        debug_assert_eq!(rev, expected, "a gap in the per-connection rev sequence");
396        if rev == expected {
397            Ok(())
398        } else {
399            Err(SessionError::RevGap { expected, got: rev })
400        }
401    }
402
403    fn expect_front(&self, nonce: Nonce) -> Result<(), SessionError> {
404        let front = self.pending.front().map(|pending| pending.nonce);
405        if front == Some(nonce) {
406            Ok(())
407        } else {
408            Err(SessionError::NonceMismatch {
409                expected: front,
410                got: nonce,
411            })
412        }
413    }
414
415    /// `confirmed ⊕ pending`, from scratch. Each fold mints its own
416    /// provisional rev, so there is no rev arithmetic here.
417    fn rebuild(&mut self) {
418        self.optimistic = self
419            .pending
420            .iter()
421            .fold(self.confirmed.predict(), |doc, pending| {
422                match doc.try_apply(&pending.commit) {
423                    Ok(predicted) => predicted,
424                    // Skipped, not dropped: a foreign commit can invalidate
425                    // a pending one, and only the server may retire a queue
426                    // entry. The edit reappears if a later commit revalidates
427                    // it.
428                    Err(_) => doc,
429                }
430            });
431    }
432}
433
434/// The commit that undoes `commit`, read against the document as it stood
435/// *before* it, ops walked forward and the list reversed.
436///
437/// The reversal is not about repeated writes to one register — those carry
438/// the same pre-image value either way round. It is about lifecycle
439/// *pairs*: `[Create, Delete]` inverts to `[Delete, Restore]`, which run
440/// forwards would leave alive an entity that never existed.
441fn inverse_of<R: RevKind>(pre_image: &Document<R>, commit: &Commit) -> Commit {
442    let mut ops: Vec<OpCodes> = commit
443        .ops()
444        .iter()
445        .filter_map(|op| invert_op(pre_image, op))
446        .collect();
447    ops.reverse();
448    Commit::new(format!("Undo {}", commit.label()), ops)
449}
450
451fn invert_op<R: RevKind>(pre_image: &Document<R>, op: &OpCodes) -> Option<OpCodes> {
452    match op {
453        OpCodes::Document(update) => {
454            Some(OpCodes::Document(pre_image.title_block().invert(update)))
455        }
456        OpCodes::Block(id, crud) => {
457            invert_crud(pre_image.block(id), crud).map(|crud| OpCodes::Block(*id, crud))
458        }
459        OpCodes::Pin(id, crud) => {
460            invert_crud(pre_image.pin(id), crud).map(|crud| OpCodes::Pin(*id, crud))
461        }
462        OpCodes::Route(id, crud) => {
463            invert_crud(pre_image.route(id), crud).map(|crud| OpCodes::Route(*id, crud))
464        }
465        OpCodes::RouteLabel(id, crud) => {
466            invert_crud(pre_image.route_label(id), crud).map(|crud| OpCodes::RouteLabel(*id, crud))
467        }
468        OpCodes::Text(id, crud) => {
469            invert_crud(pre_image.text(id), crud).map(|crud| OpCodes::Text(*id, crud))
470        }
471        OpCodes::Comment(id, crud) => {
472            invert_crud(pre_image.comment(id), crud).map(|crud| OpCodes::Comment(*id, crud))
473        }
474        OpCodes::Image(id, crud) => {
475            invert_crud(pre_image.image(id), crud).map(|crud| OpCodes::Image(*id, crud))
476        }
477        // An asset payload has no inverse to build: it is create-only, so
478        // there is no un-create op to emit, and the bytes are meant to
479        // outlive the reference that brought them in — undoing the
480        // placement leaves an unreferenced payload for compaction, not a
481        // dangling reference for the next redo.
482        OpCodes::Asset(..) => None,
483    }
484}
485
486/// `None` drops the op from the inverse: an update to an entity the same
487/// commit created has nothing to restore, and the create's `Delete` —
488/// which runs after it once the list is reversed — removes it wholesale.
489/// Presence is map presence, not liveness: the tombstone retains the
490/// inner, which is what makes the lifecycle arms baseline-free.
491fn invert_crud<E: Entity>(
492    pre_image: Option<&Live<E>>,
493    crud: &Crud<E::Init, E::Update>,
494) -> Option<Crud<E::Init, E::Update>> {
495    match crud {
496        Crud::Update(update) => Some(Crud::Update(pre_image?.as_ref().invert(update))),
497        lifecycle => lifecycle.invert_lifecycle(),
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::fixtures::{block_id, commit, image_id, pin_id, projection};
505    use crate::{
506        block_model::{Asset, BlockInit, BlockUpdate, Icon, ImageInit, LabelInit, PinInit},
507        document::TitleBlockUpdate,
508        geometry::{FracVal, GridPoint, GridRect, GridSize, PinSlot, ScreenRect},
509        id::BlockId,
510        values::{LabelSide, PinDir, Role},
511    };
512
513    fn rect(x: i32, y: i32) -> GridRect {
514        GridRect {
515            top_left: GridPoint { x, y },
516            size: GridSize { w: 4, h: 4 },
517        }
518    }
519
520    fn label_init(name: String) -> LabelInit {
521        LabelInit {
522            name,
523            side: LabelSide::default(),
524            offset: FracVal::default(),
525            hidden: false,
526        }
527    }
528
529    fn block_create(byte: u8) -> OpCodes {
530        OpCodes::Block(
531            block_id(byte),
532            Crud::Create(BlockInit {
533                parent: BlockId::NULL,
534                rect: rect(0, 0),
535                locked: false,
536                role: Role::default(),
537                title: label_init(format!("b{byte}")),
538                type_label: label_init(String::new()),
539                icon: Icon::default(),
540            }),
541        )
542    }
543
544    fn reparent(child: u8, parent: BlockId) -> OpCodes {
545        OpCodes::Block(block_id(child), Crud::Update(BlockUpdate::Parent(parent)))
546    }
547
548    fn resize(byte: u8, to: GridRect) -> OpCodes {
549        OpCodes::Block(block_id(byte), Crud::Update(BlockUpdate::Rect(to)))
550    }
551
552    /// A pin whose owner block does not exist — refused by the fold's
553    /// commit-end validation, on either end of the wire.
554    fn orphan_pin(byte: u8) -> OpCodes {
555        OpCodes::Pin(
556            pin_id(byte),
557            Crud::Create(PinInit {
558                owner: block_id(200),
559                name: format!("p{byte}"),
560                type_name: String::new(),
561                tag: String::new(),
562                tag_hidden: false,
563                rect: rect(0, 0),
564                slot: PinSlot::default(),
565                dir: PinDir::default(),
566                port_accent: Role::default(),
567                flip_lr: false,
568            }),
569        )
570    }
571
572    fn parent_of(session: &ClientSession, byte: u8) -> BlockId {
573        *session
574            .optimistic()
575            .block(&block_id(byte))
576            .expect("the block is in the document")
577            .as_ref()
578            .parent
579            .as_ref()
580    }
581
582    /// Sequence the session's last submission at the host and ack it —
583    /// one round trip with nothing else in flight.
584    fn ack_last(host: &mut Host, session: &mut ClientSession) {
585        let (nonce, commit) = session.last_submission().expect("a submission is queued");
586        let commit = commit.clone();
587        let rev = host.ingest(&commit).expect("the host accepts it");
588        session.committed(nonce, rev).expect("the ack lands");
589    }
590
591    fn edit(host: &mut Host, session: &mut ClientSession, commit: Commit) {
592        session.submit(commit).expect("the edit folds");
593        ack_last(host, session);
594    }
595
596    /// A host and a session agreeing on one top-level block.
597    fn one_block() -> (Host, ClientSession) {
598        let (mut host, mut session) = (Host::default(), ClientSession::default());
599        edit(
600            &mut host,
601            &mut session,
602            commit("Added a block", vec![block_create(1)]),
603        );
604        (host, session)
605    }
606
607    #[test]
608    fn an_ack_leaves_the_prediction_equal_to_the_confirmed_document() {
609        let (host, session) = one_block();
610
611        assert!(session.last_submission().is_none(), "the queue drains");
612        assert_eq!(
613            session.optimistic().content_hash(),
614            session.confirmed().content_hash(),
615            "with nothing pending the prediction is the confirmed document",
616        );
617        assert_eq!(
618            session.confirmed().content_hash(),
619            host.state().content_hash(),
620            "and matches the host's fold, revs included",
621        );
622    }
623
624    /// The reconciliation oracle in miniature: while an edit is in flight,
625    /// the prediction is exactly the confirmed head with the queue folded
626    /// on top.
627    #[test]
628    fn a_foreign_commit_rebuilds_the_prediction_over_the_new_head() {
629        let (mut host, mut session) = one_block();
630        session
631            .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
632            .expect("the edit folds");
633
634        let foreign = commit("Added another", vec![block_create(2)]);
635        let rev = host.ingest(&foreign).expect("the host accepts it");
636        session
637            .apply_foreign(rev, &foreign)
638            .expect("the delivery lands");
639
640        let pending = session
641            .last_submission()
642            .expect("the edit is still in flight")
643            .1
644            .clone();
645        let from_scratch = session
646            .confirmed()
647            .try_apply(&pending)
648            .expect("the pending edit folds on the new head");
649        assert_eq!(
650            session.optimistic().content_hash(),
651            from_scratch.content_hash(),
652            "optimistic == confirmed ⊕ pending, byte for byte",
653        );
654        assert_eq!(
655            *session
656                .optimistic()
657                .block(&block_id(1))
658                .expect("block 1 is present")
659                .as_ref()
660                .rect
661                .as_ref(),
662            rect(5, 5),
663            "the unacked local edit still stands above the foreign one",
664        );
665    }
666
667    #[test]
668    #[should_panic(expected = "gap in the per-connection rev sequence")]
669    fn a_rev_gap_panics_in_debug() {
670        let (_host, mut session) = one_block();
671        let skipped = session.rev().next().next();
672        let _ = session.apply_foreign(skipped, &commit("Skipped ahead", vec![block_create(9)]));
673    }
674
675    #[test]
676    fn an_answer_for_anything_but_the_front_of_the_queue_is_refused() {
677        let (host, mut session) = one_block();
678        let first = session
679            .submit(commit("First", vec![resize(1, rect(1, 1))]))
680            .expect("the edit folds");
681        let second = session
682            .submit(commit("Second", vec![resize(1, rect(2, 2))]))
683            .expect("the edit folds");
684        assert_ne!(first, second, "each submission gets its own nonce");
685
686        assert_eq!(
687            session.committed(second, host.rev().next()),
688            Err(SessionError::NonceMismatch {
689                expected: Some(first),
690                got: second,
691            }),
692            "answers arrive in submission order",
693        );
694    }
695
696    #[test]
697    fn a_local_edit_that_cannot_fold_is_never_queued() {
698        let mut session = ClientSession::default();
699        let before = session.optimistic().content_hash();
700
701        assert!(
702            session
703                .submit(commit("Orphan pin", vec![orphan_pin(3)]))
704                .is_err(),
705            "the client refuses what the server would refuse",
706        );
707        assert!(session.last_submission().is_none(), "nothing was queued");
708        assert!(!session.can_undo(), "and nothing was journalled");
709        assert_eq!(
710            session.optimistic().content_hash(),
711            before,
712            "the prediction is untouched",
713        );
714    }
715
716    /// The case identity exists for. A rejection discards its entry from the
717    /// *middle* of the stack, so after one lands the stack's positions no
718    /// longer mean what a caller counting its own steps thinks they mean.
719    ///
720    /// A caller that says which edit it means gets told; one that said "the
721    /// top" would silently invert its neighbour.
722    #[test]
723    fn a_rejection_in_the_middle_makes_a_step_stale_rather_than_wrong() {
724        let (mut host, mut session) = one_block();
725        // Three edits: the first acknowledged, the next two still in flight.
726        edit(
727            &mut host,
728            &mut session,
729            commit("Moved it", vec![resize(1, rect(5, 5))]),
730        );
731        let first = session
732            .next_undo()
733            .expect("the acknowledged edit is journalled");
734        let doomed = session
735            .submit(commit("Moved it again", vec![resize(1, rect(6, 6))]))
736            .expect("the edit folds locally");
737        let last = session
738            .submit(commit("And again", vec![resize(1, rect(7, 7))]))
739            .expect("the edit folds locally");
740        assert!(
741            session.undo_nonces().ends_with(&[first, doomed, last]),
742            "precondition: the doomed edit sits in the middle, not on top",
743        );
744
745        session.rejected(doomed).expect("the rejection lands");
746
747        let after = session.undo_nonces();
748        assert!(
749            after.ends_with(&[first, last]),
750            "the rejected entry left from the middle, not from the top: {after:?}",
751        );
752        assert!(!after.contains(&doomed));
753        assert_eq!(
754            session.undo(first),
755            Err(UndoRefusal::Stale { top: last }),
756            "a step naming the wrong edit is refused, not taken as the top",
757        );
758        assert!(
759            session.undo(last).is_ok(),
760            "and the step that names the top is taken",
761        );
762    }
763
764    /// A spent stack refuses rather than reporting a step it did not take.
765    #[test]
766    fn a_step_on_a_spent_stack_is_refused() {
767        let (_host, mut session) = one_block();
768        let only = session.next_undo().expect("the setup edit is journalled");
769        session.undo(only).expect("the inverse folds");
770        assert_eq!(session.next_undo(), None);
771        assert_eq!(session.undo(only), Err(UndoRefusal::Spent));
772    }
773
774    #[test]
775    fn a_rejection_drops_the_edit_and_its_journal_entry() {
776        let (mut host, mut session) = one_block();
777        let confirmed = session.confirmed().content_hash();
778
779        let doomed = session
780            .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
781            .expect("the edit folds locally");
782        session.rejected(doomed).expect("the rejection lands");
783
784        assert_eq!(
785            session.optimistic().content_hash(),
786            confirmed,
787            "the edit visibly reverts",
788        );
789        assert!(session.can_undo(), "the accepted create is still undoable");
790
791        // The surviving entry must be the create's, not the rejected move's:
792        // undoing an edit that never landed would write stale values at a
793        // fresh order.
794        let top = session.next_undo().expect("something to undo");
795        session.undo(top).expect("the inverse folds");
796        ack_last(&mut host, &mut session);
797        assert!(
798            projection(session.confirmed()).is_empty(),
799            "undo removed the block, so the entry inverted the create",
800        );
801    }
802
803    /// The decision this step turns on: a foreign commit can invalidate a
804    /// pending one, and the queue answers to the server rather than to
805    /// this frame's paint.
806    #[test]
807    fn a_pending_commit_the_head_invalidated_is_skipped_then_returns() {
808        let (mut host, mut session) = (Host::default(), ClientSession::default());
809        let setup = commit("Two blocks", vec![block_create(1), block_create(2)]);
810        let rev = host.ingest(&setup).expect("the host accepts it");
811        session.apply_foreign(rev, &setup).expect("the setup lands");
812
813        let nonce = session
814            .submit(commit("Nested 1 under 2", vec![reparent(1, block_id(2))]))
815            .expect("the edit folds on today's head");
816        assert_eq!(parent_of(&session, 1), block_id(2), "predicted immediately");
817
818        let cycle = commit("Nested 2 under 1", vec![reparent(2, block_id(1))]);
819        let rev = host.ingest(&cycle).expect("no cycle at the host yet");
820        session
821            .apply_foreign(rev, &cycle)
822            .expect("the delivery lands");
823        assert_eq!(
824            parent_of(&session, 1),
825            BlockId::NULL,
826            "the pending edit would close a cycle, so it is not predicted",
827        );
828
829        let undo_cycle = commit("Freed 2", vec![reparent(2, BlockId::NULL)]);
830        let rev = host.ingest(&undo_cycle).expect("the host accepts it");
831        session
832            .apply_foreign(rev, &undo_cycle)
833            .expect("the delivery lands");
834        assert_eq!(
835            parent_of(&session, 1),
836            block_id(2),
837            "and reappears once the head makes it valid again",
838        );
839
840        let pending = session
841            .last_submission()
842            .expect("the edit stayed queued through both rebuilds")
843            .1
844            .clone();
845        let rev = host.ingest(&pending).expect("the host accepts it now");
846        session.committed(nonce, rev).expect("the ack lands");
847        assert_eq!(
848            *session
849                .confirmed()
850                .block(&block_id(1))
851                .expect("block 1 is present")
852                .as_ref()
853                .parent
854                .as_ref(),
855            block_id(2),
856        );
857    }
858
859    #[test]
860    fn a_refused_ingest_consumes_no_rev_and_leaves_the_log_alone() {
861        let mut host = Host::default();
862        let accepted = host
863            .ingest(&commit("Added a block", vec![block_create(1)]))
864            .expect("the host accepts it");
865
866        assert!(
867            host.ingest(&commit("Orphan pin", vec![orphan_pin(3)]))
868                .is_err(),
869        );
870        assert_eq!(host.rev(), accepted, "a refusal mints no rev");
871        assert_eq!(host.commits_after(Rev::ZERO).len(), 1, "and logs nothing");
872
873        let next = host
874            .ingest(&commit("Added another", vec![block_create(2)]))
875            .expect("the host accepts it");
876        assert_eq!(
877            next,
878            accepted.next(),
879            "the next commit takes the rev the refusal did not consume",
880        );
881    }
882
883    #[test]
884    fn commits_after_replays_the_tail_in_rev_order() {
885        let mut host = Host::default();
886        for byte in 1..=3 {
887            host.ingest(&commit("Added a block", vec![block_create(byte)]))
888                .expect("the host accepts it");
889        }
890
891        assert_eq!(host.commits_after(Rev::ZERO).len(), 3, "the whole log");
892        assert_eq!(
893            host.commits_after(Rev::new(2)).len(),
894            1,
895            "log[i] is Rev(i+1)"
896        );
897        assert!(host.commits_after(host.rev()).is_empty(), "caught up");
898        assert!(
899            host.commits_after(host.rev().next()).is_empty(),
900            "and a rev beyond the head does not panic",
901        );
902    }
903
904    /// `fold(fold(s, C), inverse(C)) == s` on the visible projection, with
905    /// the edit asserted observable so the property cannot pass vacuously.
906    fn assert_round_trip<R: RevKind>(start: &Document<R>, edit: &Commit) -> Commit {
907        let after = start.try_apply(edit).expect("the edit folds");
908        assert_ne!(
909            projection(&after),
910            projection(start),
911            "the edit must be observable or the round trip proves nothing",
912        );
913
914        let inverse = inverse_of(start, edit);
915        let back = after.try_apply(&inverse).expect("the inverse folds");
916        assert_eq!(projection(&back), projection(start));
917        inverse
918    }
919
920    #[test]
921    fn the_journal_inverts_a_commit_that_writes_one_register_twice() {
922        let start = Document::<Confirmed>::default()
923            .try_apply(&commit("Added a block", vec![block_create(1)]))
924            .expect("the fold succeeds");
925
926        // Reversal is the point: both inverses carry the pre-image value,
927        // so whichever lands last by seq restores it.
928        let inverse = assert_round_trip(
929            &start,
930            &commit(
931                "Dragged it",
932                vec![resize(1, rect(5, 5)), resize(1, rect(9, 9))],
933            ),
934        );
935        assert_eq!(inverse.ops().len(), 2);
936    }
937
938    #[test]
939    fn the_journal_inverts_a_create_and_a_delete() {
940        let start = Document::<Confirmed>::default()
941            .try_apply(&commit("Added a block", vec![block_create(1)]))
942            .expect("the fold succeeds");
943
944        assert_round_trip(&start, &commit("Added another", vec![block_create(2)]));
945        assert_round_trip(
946            &start,
947            &commit(
948                "Deleted it",
949                vec![OpCodes::Block(block_id(1), Crud::Delete)],
950            ),
951        );
952    }
953
954    /// What the reversal is actually for. Two writes to one register are
955    /// order-independent (both inverses carry the same pre-image value),
956    /// but a lifecycle *pair* is not: `[Create, Delete]` inverts to
957    /// `[Delete, Restore]`, which run forwards leaves the entity alive —
958    /// resurrecting something that never existed before the commit.
959    #[test]
960    fn the_inverse_of_a_lifecycle_pair_unwinds_in_reverse() {
961        let start = Document::<Confirmed>::default();
962        let edit = commit(
963            "Added and dropped a block",
964            vec![block_create(1), OpCodes::Block(block_id(1), Crud::Delete)],
965        );
966        let after = start.try_apply(&edit).expect("the edit folds");
967        assert!(
968            projection(&after).is_empty(),
969            "the block ends tombstoned, so it was never visible",
970        );
971
972        let inverse = inverse_of(&start, &edit);
973        assert!(
974            matches!(inverse.ops().last(), Some(OpCodes::Block(_, Crud::Delete))),
975            "the create's inverse must run last",
976        );
977        let back = after.try_apply(&inverse).expect("the inverse folds");
978        assert!(
979            back.block(&block_id(1)).is_none_or(|live| !live.is_alive()),
980            "undo must not resurrect what the commit only ever tombstoned",
981        );
982    }
983
984    #[test]
985    fn the_journal_skips_an_update_to_an_entity_the_commit_created() {
986        let start = Document::<Confirmed>::default();
987        let inverse = assert_round_trip(
988            &start,
989            &commit(
990                "Added and placed a block",
991                vec![block_create(1), resize(1, rect(5, 5))],
992            ),
993        );
994
995        assert_eq!(
996            inverse.ops().len(),
997            1,
998            "the update has no baseline to restore; the create's delete covers it",
999        );
1000        assert!(matches!(
1001            inverse.ops().first(),
1002            Some(OpCodes::Block(_, Crud::Delete)),
1003        ));
1004    }
1005
1006    /// A payload op has no inverse — undoing the placement un-creates the
1007    /// image and leaves the bytes behind, unreferenced. Emitting a
1008    /// "delete the asset" here is what create-only rules out: a redo, or
1009    /// anyone else's placement of the same file, still resolves.
1010    #[test]
1011    fn undoing_a_placement_un_creates_the_image_and_keeps_its_payload() {
1012        let asset = Asset::Png(b"artwork".as_slice().into());
1013        let start = Document::<Confirmed>::default()
1014            .try_apply(&commit("Added a block", vec![block_create(1)]))
1015            .expect("the fold succeeds");
1016        let edit = commit(
1017            "Placed an image",
1018            vec![
1019                OpCodes::Asset(asset.hash(), asset.clone()),
1020                OpCodes::Image(
1021                    image_id(2),
1022                    Crud::Create(ImageInit {
1023                        owner: block_id(1),
1024                        asset: asset.hash(),
1025                        rect: ScreenRect::default(),
1026                    }),
1027                ),
1028            ],
1029        );
1030
1031        let after = start.try_apply(&edit).expect("the edit folds");
1032        assert!(
1033            after.image(&image_id(2)).is_some_and(Live::is_alive),
1034            "precondition: the placement landed",
1035        );
1036        assert_eq!(after.asset(&asset.hash()), Some(&asset));
1037
1038        let inverse = inverse_of(&start, &edit);
1039        assert_eq!(
1040            inverse.ops().len(),
1041            1,
1042            "only the image create inverts; the payload has no inverse to emit",
1043        );
1044        let back = after.try_apply(&inverse).expect("the inverse folds");
1045        assert!(
1046            back.image(&image_id(2)).is_none_or(|live| !live.is_alive()),
1047            "the placement is undone",
1048        );
1049        assert_eq!(
1050            back.asset(&asset.hash()),
1051            Some(&asset),
1052            "and the bytes stay: create-only, with reclamation left to compaction",
1053        );
1054    }
1055
1056    #[test]
1057    fn the_journal_inverts_the_singleton() {
1058        let start = Document::<Confirmed>::default()
1059            .try_apply(&commit(
1060                "Named it",
1061                vec![OpCodes::Document(TitleBlockUpdate::Name("first".into()))],
1062            ))
1063            .expect("the fold succeeds");
1064
1065        let edit = commit(
1066            "Renamed it",
1067            vec![OpCodes::Document(TitleBlockUpdate::Name("second".into()))],
1068        );
1069        let after = start.try_apply(&edit).expect("the edit folds");
1070        let back = after
1071            .try_apply(&inverse_of(&start, &edit))
1072            .expect("the inverse folds");
1073
1074        assert_eq!(after.title_block().name.as_ref().as_str(), "second");
1075        assert_eq!(back.title_block().name.as_ref().as_str(), "first");
1076    }
1077
1078    #[test]
1079    fn undo_and_redo_travel_as_ordinary_commits() {
1080        let (mut host, mut session) = one_block();
1081        let placed = projection(session.confirmed());
1082        edit(
1083            &mut host,
1084            &mut session,
1085            commit("Moved it", vec![resize(1, rect(5, 5))]),
1086        );
1087        let moved = projection(session.confirmed());
1088        assert_ne!(placed, moved);
1089
1090        let top = session.next_undo().expect("something to undo");
1091        session.undo(top).expect("the inverse folds");
1092        ack_last(&mut host, &mut session);
1093        assert_eq!(projection(session.confirmed()), placed, "undo restored it");
1094        assert!(session.can_redo());
1095
1096        let top = session.next_redo().expect("something to redo");
1097        session.redo(top).expect("the inverse folds");
1098        ack_last(&mut host, &mut session);
1099        assert_eq!(projection(session.confirmed()), moved, "redo re-applied it");
1100        assert!(session.can_undo());
1101        assert!(!session.can_redo(), "the redo future is spent");
1102    }
1103
1104    #[test]
1105    fn a_fresh_edit_abandons_the_redo_future() {
1106        let (mut host, mut session) = one_block();
1107        edit(
1108            &mut host,
1109            &mut session,
1110            commit("Moved it", vec![resize(1, rect(5, 5))]),
1111        );
1112        let top = session.next_undo().expect("something to undo");
1113        session.undo(top).expect("the inverse folds");
1114        ack_last(&mut host, &mut session);
1115        assert!(session.can_redo());
1116
1117        edit(
1118            &mut host,
1119            &mut session,
1120            commit("Moved it elsewhere", vec![resize(1, rect(7, 7))]),
1121        );
1122        assert!(!session.can_redo(), "a new edit forks the history");
1123    }
1124
1125    #[test]
1126    fn welcome_folds_a_log_into_a_session() {
1127        let mut host = Host::default();
1128        for byte in 1..=3 {
1129            host.ingest(&commit("Added a block", vec![block_create(byte)]))
1130                .expect("the host accepts it");
1131        }
1132
1133        let session = ClientSession::welcome(host.commits_after(Rev::ZERO)).expect("the log folds");
1134        assert_eq!(session.rev(), host.rev());
1135        assert_eq!(
1136            session.confirmed().content_hash(),
1137            host.state().content_hash(),
1138            "a replayed log reproduces the host's document exactly",
1139        );
1140    }
1141}