Skip to main content

blockworx/doc_ng/
session.rs

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