Skip to main content

blockworx/doc_ng/
document.rs

1//! The document root and its derived cache.
2//! Rationale: `docs/doc-ng-design-notes.md`.
3
4use std::sync::Arc;
5
6use crate::doc_ng::{
7    block_model::{Block, Comment, Image, Live, Pin, Route, RouteLabel, Text},
8    commit::Commit,
9    entity::{Entity, entity},
10    hash::{DocHash, DocKind, Hasher},
11    id::{BlockId, CommentId, ImageId, PinId, RouteId, RouteLabelId, TextId},
12    opcode::{Crud, OpCodes},
13    register::Applied,
14    rev::{Confirmed, Provisional, Rev, RevKind},
15    write_order::{Seq, WriteOrder},
16};
17use ahash::{HashMap, HashSet};
18
19entity! {
20    /// The document's own registers — a mechanical drawing's title
21    /// block, expected to grow fields (author, revision notes, …). The
22    /// singleton exception: never created by an op, no id, no
23    /// lifecycle, no `Live` wrapper — `OpCodes::Document` is
24    /// update-only.
25    #[derive(Default)]
26    pub struct TitleBlock(init TitleBlockInit, update TitleBlockUpdate, id ()) {
27        registers {
28            Name => name: String,
29        }
30        namespaces {}
31        constants {}
32    }
33}
34
35/// Authored state only — the derived view is built on demand
36/// ([`Document::cache`]). The document is the implicit root of the
37/// containment tree: not an element, cannot be deleted, moved, or raced.
38/// Blocks whose parent is `Id::NULL` live at document level.
39///
40/// `R` is the kind of position the head names: a
41/// [`Document<Confirmed>`](crate::doc_ng::rev::Confirmed) is the
42/// authority's copy (or a faithful fold of its log), a
43/// [`Document<Provisional>`](Provisional) is a prediction whose rev is
44/// scratch. Only the head field is typed — the body is identical either
45/// way, which is why [`Document::predict`] can retype it wholesale.
46#[derive(Default, Clone)]
47pub struct Document<R: RevKind> {
48    /// The head position in the log: the rev of the last commit folded in.
49    /// Minted only by a successful [`Document::try_apply`], so a refused
50    /// commit consumes no rev and the log cannot gap.
51    rev: R,
52    title_block: TitleBlock,
53    blocks: HashMap<BlockId, Arc<Live<Block>>>,
54    pins: HashMap<PinId, Arc<Live<Pin>>>,
55    routes: HashMap<RouteId, Arc<Live<Route>>>,
56    route_labels: HashMap<RouteLabelId, Arc<Live<RouteLabel>>>,
57    texts: HashMap<TextId, Arc<Live<Text>>>,
58    comments: HashMap<CommentId, Arc<Live<Comment>>>,
59    images: HashMap<ImageId, Arc<Live<Image>>>,
60}
61
62impl Document<Confirmed> {
63    /// The log position this document holds. Only a confirmed document
64    /// has one to report — a prediction's is scratch.
65    pub fn rev(&self) -> Rev {
66        self.rev.get()
67    }
68
69    /// The starting point for `confirmed ⊕ pending`: the same body under
70    /// a head that no longer claims a log position. Cheap — the interior
71    /// is `Arc`'d, so this is a pointer bump per entry.
72    pub fn predict(&self) -> Document<Provisional> {
73        Document {
74            rev: Provisional::new(self.rev.get()),
75            title_block: self.title_block.clone(),
76            blocks: self.blocks.clone(),
77            pins: self.pins.clone(),
78            routes: self.routes.clone(),
79            route_labels: self.route_labels.clone(),
80            texts: self.texts.clone(),
81            comments: self.comments.clone(),
82            images: self.images.clone(),
83        }
84    }
85}
86
87impl<R: RevKind> Document<R> {
88    pub fn title_block(&self) -> &TitleBlock {
89        &self.title_block
90    }
91    pub fn block(&self, id: &BlockId) -> Option<&Live<Block>> {
92        self.blocks.get(id).map(Arc::as_ref)
93    }
94    pub fn blocks(&self) -> impl Iterator<Item = (BlockId, &Live<Block>)> {
95        self.blocks.iter().map(|(id, block)| (*id, block.as_ref()))
96    }
97    pub fn pin(&self, id: &PinId) -> Option<&Live<Pin>> {
98        self.pins.get(id).map(Arc::as_ref)
99    }
100    pub fn pins(&self) -> impl Iterator<Item = (PinId, &Live<Pin>)> {
101        self.pins.iter().map(|(id, pin)| (*id, pin.as_ref()))
102    }
103    pub fn route(&self, id: &RouteId) -> Option<&Live<Route>> {
104        self.routes.get(id).map(Arc::as_ref)
105    }
106    pub fn routes(&self) -> impl Iterator<Item = (RouteId, &Live<Route>)> {
107        self.routes.iter().map(|(id, route)| (*id, route.as_ref()))
108    }
109    pub fn route_label(&self, id: &RouteLabelId) -> Option<&Live<RouteLabel>> {
110        self.route_labels.get(id).map(Arc::as_ref)
111    }
112    pub fn route_labels(&self) -> impl Iterator<Item = (RouteLabelId, &Live<RouteLabel>)> {
113        self.route_labels
114            .iter()
115            .map(|(id, label)| (*id, label.as_ref()))
116    }
117    pub fn text(&self, id: &TextId) -> Option<&Live<Text>> {
118        self.texts.get(id).map(Arc::as_ref)
119    }
120    pub fn texts(&self) -> impl Iterator<Item = (TextId, &Live<Text>)> {
121        self.texts.iter().map(|(id, text)| (*id, text.as_ref()))
122    }
123    pub fn comment(&self, id: &CommentId) -> Option<&Live<Comment>> {
124        self.comments.get(id).map(Arc::as_ref)
125    }
126    pub fn comments(&self) -> impl Iterator<Item = (CommentId, &Live<Comment>)> {
127        self.comments
128            .iter()
129            .map(|(id, comment)| (*id, comment.as_ref()))
130    }
131    pub fn image(&self, id: &ImageId) -> Option<&Live<Image>> {
132        self.images.get(id).map(Arc::as_ref)
133    }
134    pub fn images(&self) -> impl Iterator<Item = (ImageId, &Live<Image>)> {
135        self.images.iter().map(|(id, image)| (*id, image.as_ref()))
136    }
137    pub fn try_apply(&self, commit: &Commit) -> Result<Document<R>, FoldError> {
138        let mut new_doc = self.clone();
139        new_doc.rev = self.rev.next();
140        apply(&mut new_doc, commit)?;
141        validate(&new_doc, commit)?;
142        Ok(new_doc)
143    }
144    fn no_block_cycles(&self, id: BlockId) -> bool {
145        let mut current = id;
146        let mut seen = HashSet::default();
147        while let Some(block) = self.block(&current) {
148            if !seen.insert(current) {
149                return false; // cycle detected
150            }
151            let parent = block.as_ref().parent.as_ref();
152            if parent == &BlockId::NULL {
153                break;
154            }
155            current = *parent;
156        }
157        true
158    }
159    fn validate_block_owner(&self, id: BlockId) -> Result<(), FoldError> {
160        let block = self
161            .block(&id)
162            .ok_or(FoldError::InvalidBlockId(id))?
163            .as_ref();
164        let block_parent = block.parent.as_ref();
165        if block_parent != &BlockId::NULL && !self.blocks.contains_key(block_parent) {
166            return Err(FoldError::InvalidBlockParent(id, *block_parent));
167        }
168        if !self.no_block_cycles(id) {
169            return Err(FoldError::BlockCycle(id));
170        }
171        Ok(())
172    }
173    fn validate_pin_owner(&self, id: PinId) -> Result<(), FoldError> {
174        let pin: &Pin = self.pin(&id).ok_or(FoldError::InvalidPinId(id))?.as_ref();
175        let owner = pin.owner.as_ref();
176        if !self.blocks.contains_key(owner) {
177            return Err(FoldError::InvalidPinOwner(id, *owner));
178        }
179        Ok(())
180    }
181    fn validate_route_owner_and_endpoints(&self, id: RouteId) -> Result<(), FoldError> {
182        let route: &Route = self
183            .route(&id)
184            .ok_or(FoldError::InvalidRouteId(id))?
185            .as_ref();
186        let owner = route.owner.as_ref();
187        if !self.blocks.contains_key(owner) {
188            return Err(FoldError::InvalidRouteOwner(id, *owner));
189        }
190        if !self.pins.contains_key(&route.from) {
191            return Err(FoldError::InvalidRouteFrom(id, route.from));
192        }
193        if !self.pins.contains_key(&route.to) {
194            return Err(FoldError::InvalidRouteTo(id, route.to));
195        }
196        Ok(())
197    }
198    fn validate_route_label_owner(&self, id: RouteLabelId) -> Result<(), FoldError> {
199        let label: &RouteLabel = self
200            .route_label(&id)
201            .ok_or(FoldError::InvalidRouteLabelId(id))?
202            .as_ref();
203        let owner = label.owner.as_ref();
204        if !self.routes.contains_key(owner) {
205            return Err(FoldError::InvalidRouteLabelOwner(id, *owner));
206        }
207        Ok(())
208    }
209    fn validate_text_owner(&self, id: TextId) -> Result<(), FoldError> {
210        let text: &Text = self.text(&id).ok_or(FoldError::InvalidTextId(id))?.as_ref();
211        let owner = text.owner.as_ref();
212        if !self.blocks.contains_key(owner) {
213            return Err(FoldError::InvalidTextOwner(id, *owner));
214        }
215        Ok(())
216    }
217    fn validate_comment_owner(&self, id: CommentId) -> Result<(), FoldError> {
218        let comment: &Comment = self
219            .comment(&id)
220            .ok_or(FoldError::InvalidCommentId(id))?
221            .as_ref();
222        let owner = comment.owner.as_ref();
223        if !self.blocks.contains_key(owner) {
224            return Err(FoldError::InvalidCommentOwner(id, *owner));
225        }
226        Ok(())
227    }
228    fn validate_image_owner(&self, id: ImageId) -> Result<(), FoldError> {
229        let image: &Image = self
230            .image(&id)
231            .ok_or(FoldError::InvalidImageId(id))?
232            .as_ref();
233        let owner = image.owner.as_ref();
234        if !self.blocks.contains_key(owner) {
235            return Err(FoldError::InvalidImageOwner(id, *owner));
236        }
237        Ok(())
238    }
239
240    /// Canonical over map iteration order (entries hashed sorted by id) and
241    /// covering everything the fold produces: rev, values, and write
242    /// orders. `rev` is the cheap version compare; this hash is the proof
243    /// that a version's contents match the server's fold bit-for-bit.
244    // Serialization is infallible: derived impls over plain data, and the
245    // hasher's `Write` never errors.
246    #[expect(clippy::expect_used, clippy::missing_panics_doc)]
247    pub fn content_hash(&self) -> DocHash {
248        fn sorted<I: Ord + Copy, T>(map: &HashMap<I, Arc<Live<T>>>) -> Vec<(I, &Live<T>)> {
249            let mut entries: Vec<_> = map.iter().map(|(id, live)| (*id, live.as_ref())).collect();
250            entries.sort_by_key(|(id, _)| *id);
251            entries
252        }
253        let mut hasher = Hasher::<DocKind>::new();
254        ciborium::into_writer(
255            &(
256                // The plain rev, not the kind: a drained prediction and
257                // the document it drained to must hash alike.
258                self.rev.minting(),
259                &self.title_block,
260                sorted(&self.blocks),
261                sorted(&self.pins),
262                sorted(&self.routes),
263                sorted(&self.route_labels),
264                sorted(&self.texts),
265                sorted(&self.comments),
266                sorted(&self.images),
267            ),
268            &mut hasher,
269        )
270        .expect("the document serializes infallibly");
271        hasher.finalize()
272    }
273    /// One linear pass over every kind; built once per frame by the
274    /// client and queried at any scope. The server never needs one.
275    pub fn cache(&self) -> DocumentCache<'_, R> {
276        let mut cache = DocumentCache {
277            doc: self,
278            top_level: HashSet::default(),
279            blocks: self
280                .blocks()
281                .filter(|(_, block)| block.is_alive())
282                .map(|(id, _)| (id, BlockIndex::default()))
283                .collect(),
284            routes: self
285                .routes()
286                .filter(|(_, route)| route.is_alive())
287                .map(|(id, _)| (id, RouteIndex::default()))
288                .collect(),
289            suppressed: HashSet::default(),
290        };
291        for (id, block) in self.blocks().filter(|(_, block)| block.is_alive()) {
292            let parent = block.as_ref().parent.as_ref();
293            if parent == &BlockId::NULL {
294                cache.top_level.insert(id);
295            } else if let Some(index) = cache.blocks.get_mut(parent) {
296                index.children.insert(id);
297            }
298        }
299        for (id, pin) in self.pins().filter(|(_, pin)| pin.is_alive()) {
300            if let Some(index) = cache.blocks.get_mut(pin.as_ref().owner.as_ref()) {
301                index.pins.insert(id);
302            }
303        }
304        for (id, route) in self.routes().filter(|(_, route)| route.is_alive()) {
305            let inner = route.as_ref();
306            if let Some(index) = cache.blocks.get_mut(inner.owner.as_ref()) {
307                index.routes.insert(id);
308            }
309            let endpoints_alive = self.pin(&inner.from).is_some_and(Live::is_alive)
310                && self.pin(&inner.to).is_some_and(Live::is_alive);
311            if !endpoints_alive {
312                cache.suppressed.insert(id);
313            }
314        }
315        for (id, label) in self.route_labels().filter(|(_, label)| label.is_alive()) {
316            if let Some(index) = cache.routes.get_mut(label.as_ref().owner.as_ref()) {
317                index.labels.insert(id);
318            }
319        }
320        for (id, text) in self.texts().filter(|(_, text)| text.is_alive()) {
321            if let Some(index) = cache.blocks.get_mut(text.as_ref().owner.as_ref()) {
322                index.texts.insert(id);
323            }
324        }
325        for (id, comment) in self.comments().filter(|(_, comment)| comment.is_alive()) {
326            if let Some(index) = cache.blocks.get_mut(comment.as_ref().owner.as_ref()) {
327                index.comments.insert(id);
328            }
329        }
330        for (id, image) in self.images().filter(|(_, image)| image.is_alive()) {
331            if let Some(index) = cache.blocks.get_mut(image.as_ref().owner.as_ref()) {
332                index.images.insert(id);
333            }
334        }
335        cache
336    }
337}
338
339/// The derived view over the whole document — built on demand, never
340/// authoritative, live entities only. A live child of a tombstoned owner
341/// is indexed nowhere (its owner has no entry). Coherence is structural:
342/// documents are immutable values, so a cache can never drift from the
343/// document it borrows; the only hazard is consulting a cache built from
344/// a *different* document, the same mistake class as reading the wrong
345/// document directly.
346pub struct DocumentCache<'a, R: RevKind> {
347    pub doc: &'a Document<R>,
348    /// Live blocks whose parent is `Id::NULL` — the document-root scope.
349    pub top_level: HashSet<BlockId>,
350    pub blocks: HashMap<BlockId, BlockIndex>,
351    pub routes: HashMap<RouteId, RouteIndex>,
352    /// Routes whose authored presence is alive but whose effective
353    /// liveness is dead (an endpoint pin is tombstoned): rendered as
354    /// deleted, revived when the endpoint is restored.
355    pub suppressed: HashSet<RouteId>,
356}
357
358#[derive(Debug, Default, PartialEq, Eq)]
359pub struct BlockIndex {
360    pub pins: HashSet<PinId>,
361    pub routes: HashSet<RouteId>,
362    pub texts: HashSet<TextId>,
363    pub comments: HashSet<CommentId>,
364    pub images: HashSet<ImageId>,
365    pub children: HashSet<BlockId>,
366}
367
368#[derive(Debug, Default, PartialEq, Eq)]
369pub struct RouteIndex {
370    pub labels: HashSet<RouteLabelId>,
371}
372
373#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
374pub enum FoldError {
375    #[error("invalid block id {0}")]
376    InvalidBlockId(BlockId),
377    #[error("invalid pin id {0}")]
378    InvalidPinId(PinId),
379    #[error("invalid route id {0}")]
380    InvalidRouteId(RouteId),
381    #[error("invalid route label id {0}")]
382    InvalidRouteLabelId(RouteLabelId),
383    #[error("invalid text id {0}")]
384    InvalidTextId(TextId),
385    #[error("invalid comment id {0}")]
386    InvalidCommentId(CommentId),
387    #[error("invalid image id {0}")]
388    InvalidImageId(ImageId),
389    #[error("Block {0} parent {1} is not a block in the document")]
390    InvalidBlockParent(BlockId, BlockId),
391    #[error("Pin {0} owner {1} is not a block in the document")]
392    InvalidPinOwner(PinId, BlockId),
393    #[error("Route {0} owner {1} is not a block in the document")]
394    InvalidRouteOwner(RouteId, BlockId),
395    #[error("Route {0} from pin {1} is not a pin in the document")]
396    InvalidRouteFrom(RouteId, PinId),
397    #[error("Route {0} to pin {1} is not a pin in the document")]
398    InvalidRouteTo(RouteId, PinId),
399    #[error("RouteLabel {0} owner {1} is not a route in the document")]
400    InvalidRouteLabelOwner(RouteLabelId, RouteId),
401    #[error("Text {0} owner {1} is not a block in the document")]
402    InvalidTextOwner(TextId, BlockId),
403    #[error("Comment {0} owner {1} is not a block in the document")]
404    InvalidCommentOwner(CommentId, BlockId),
405    #[error("Image {0} owner {1} is not a block in the document")]
406    InvalidImageOwner(ImageId, BlockId),
407    #[error("Block {0} is in a cycle of parent links")]
408    BlockCycle(BlockId),
409}
410
411fn apply<R: RevKind>(doc: &mut Document<R>, commit: &Commit) -> Result<(), FoldError> {
412    for (ndx, op) in commit.ops().iter().enumerate() {
413        let order = WriteOrder::new(doc.rev.minting(), Seq::new(ndx));
414        match op {
415            OpCodes::Document(update) => {
416                doc.title_block.apply(update, order);
417            }
418            OpCodes::Block(id, crud) => {
419                apply_crud_to_entity(&mut doc.blocks, *id, crud, order)
420                    .ok_or(FoldError::InvalidBlockId(*id))?;
421            }
422            OpCodes::Pin(id, crud) => {
423                apply_crud_to_entity(&mut doc.pins, *id, crud, order)
424                    .ok_or(FoldError::InvalidPinId(*id))?;
425            }
426            OpCodes::Route(id, crud) => {
427                apply_crud_to_entity(&mut doc.routes, *id, crud, order)
428                    .ok_or(FoldError::InvalidRouteId(*id))?;
429            }
430            OpCodes::RouteLabel(id, crud) => {
431                apply_crud_to_entity(&mut doc.route_labels, *id, crud, order)
432                    .ok_or(FoldError::InvalidRouteLabelId(*id))?;
433            }
434            OpCodes::Text(id, crud) => {
435                apply_crud_to_entity(&mut doc.texts, *id, crud, order)
436                    .ok_or(FoldError::InvalidTextId(*id))?;
437            }
438            OpCodes::Comment(id, crud) => {
439                apply_crud_to_entity(&mut doc.comments, *id, crud, order)
440                    .ok_or(FoldError::InvalidCommentId(*id))?;
441            }
442            OpCodes::Image(id, crud) => {
443                apply_crud_to_entity(&mut doc.images, *id, crud, order)
444                    .ok_or(FoldError::InvalidImageId(*id))?;
445            }
446        }
447    }
448    Ok(())
449}
450
451fn validate<R: RevKind>(doc: &Document<R>, commit: &Commit) -> Result<(), FoldError> {
452    for op in commit.ops() {
453        match op {
454            OpCodes::Block(id, _) => {
455                doc.validate_block_owner(*id)?;
456            }
457            OpCodes::Pin(id, _) => {
458                doc.validate_pin_owner(*id)?;
459            }
460            OpCodes::Route(id, _) => {
461                doc.validate_route_owner_and_endpoints(*id)?;
462            }
463            OpCodes::RouteLabel(id, _) => {
464                doc.validate_route_label_owner(*id)?;
465            }
466            OpCodes::Text(id, _) => {
467                doc.validate_text_owner(*id)?;
468            }
469            OpCodes::Comment(id, _) => {
470                doc.validate_comment_owner(*id)?;
471            }
472            OpCodes::Image(id, _) => {
473                doc.validate_image_owner(*id)?;
474            }
475            OpCodes::Document(_) => {}
476        }
477    }
478    Ok(())
479}
480
481/// `None` means the id is invalid for the op: unknown for
482/// `Update`/`Delete`/`Restore`, already taken for `Create` (unforgeable
483/// uuids make a duplicate create a replay or a bug, never a merge).
484fn apply_crud_to_entity<E: Entity + Clone>(
485    map: &mut HashMap<E::Id, Arc<Live<E>>>,
486    id: E::Id,
487    crud: &Crud<E::Init, E::Update>,
488    order: WriteOrder,
489) -> Option<Applied> {
490    match crud {
491        Crud::Create(init) => {
492            if map.contains_key(&id) {
493                return None;
494            }
495            let live = Live::new(E::from_init(init, order), order);
496            map.insert(id, Arc::new(live));
497            Some(Applied::Won)
498        }
499        Crud::Restore => Some(Arc::make_mut(map.get_mut(&id)?).restore(order)),
500        Crud::Update(update) => Some(Arc::make_mut(map.get_mut(&id)?).apply_update(update, order)),
501        Crud::Delete => Some(Arc::make_mut(map.get_mut(&id)?).delete(order)),
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::doc_ng::fixtures::{
509        block_id, comment_id, image_id, pin_id, route_id, route_label_id, text_id,
510    };
511    use crate::doc_ng::{
512        block_model::{
513            BlockInit, BlockUpdate, CommentInit, Icon, ImageInit, LabelInit, PinInit, PinUpdate,
514            RouteInit, RouteLabelInit, TextInit,
515        },
516        geometry::{FracVal, GridPoint, GridRect, GridSize, ScreenRect},
517        hash::AssetHash,
518        values::{LabelSide, PinDir, Role},
519    };
520
521    fn at(rev: u64) -> WriteOrder {
522        WriteOrder::new(Rev::new(rev), Seq::new(0))
523    }
524
525    fn rename(name: &str) -> Commit {
526        Commit::new(
527            "Renamed the document".into(),
528            vec![OpCodes::Document(TitleBlockUpdate::Name(name.into()))],
529        )
530    }
531
532    fn label_init(name: String) -> LabelInit {
533        LabelInit {
534            name,
535            side: LabelSide::default(),
536            offset: FracVal::default(),
537            hidden: false,
538        }
539    }
540
541    fn block_create(byte: u8) -> OpCodes {
542        OpCodes::Block(
543            block_id(byte),
544            Crud::Create(BlockInit {
545                parent: BlockId::NULL,
546                rect: GridRect::default(),
547                locked: false,
548                title: label_init(format!("b{byte}")),
549                type_label: label_init(String::new()),
550                icon: Icon::default(),
551            }),
552        )
553    }
554
555    fn route_create(byte: u8, owner: u8, from: u8, to: u8) -> OpCodes {
556        OpCodes::Route(
557            route_id(byte),
558            Crud::Create(RouteInit {
559                owner: block_id(owner),
560                name: format!("r{byte}"),
561                from: pin_id(from),
562                to: pin_id(to),
563                role: Role::default(),
564                waypoints: Vec::new(),
565            }),
566        )
567    }
568
569    /// Block 1 top-level with block 2 nested under it; pins 3 and 4 on
570    /// block 1; route 5 between them owned by block 1; route label 6 on
571    /// the route; text 7, comment 8, image 9 on block 1.
572    fn wired_document() -> Document<Confirmed> {
573        Document::<Confirmed>::default()
574            .try_apply(&Commit::new(
575                "Wired a document".into(),
576                vec![
577                    block_create(1),
578                    block_create(2),
579                    reparent(2, 1),
580                    pin_create(3, block_id(1)),
581                    pin_create(4, block_id(1)),
582                    route_create(5, 1, 3, 4),
583                    OpCodes::RouteLabel(
584                        route_label_id(6),
585                        Crud::Create(RouteLabelInit {
586                            owner: route_id(5),
587                            pos: FracVal::default(),
588                        }),
589                    ),
590                    OpCodes::Text(
591                        text_id(7),
592                        Crud::Create(TextInit {
593                            owner: block_id(1),
594                            text: "note".into(),
595                            pos: GridPoint::default(),
596                            role: Role::default(),
597                        }),
598                    ),
599                    OpCodes::Comment(
600                        comment_id(8),
601                        Crud::Create(CommentInit {
602                            owner: block_id(1),
603                            rect: GridRect::default(),
604                            role: Role::default(),
605                            title: label_init("c8".into()),
606                        }),
607                    ),
608                    OpCodes::Image(
609                        image_id(9),
610                        Crud::Create(ImageInit {
611                            owner: block_id(1),
612                            asset: AssetHash::default(),
613                            rect: ScreenRect::default(),
614                        }),
615                    ),
616                ],
617            ))
618            .expect("the fold succeeds")
619    }
620
621    fn reparent(child: u8, parent: u8) -> OpCodes {
622        OpCodes::Block(
623            block_id(child),
624            Crud::Update(BlockUpdate::Parent(block_id(parent))),
625        )
626    }
627
628    fn rect(x: i32, y: i32) -> GridRect {
629        GridRect {
630            top_left: GridPoint { x, y },
631            size: GridSize { w: 4, h: 4 },
632        }
633    }
634
635    fn resize(block: u8, to: GridRect) -> OpCodes {
636        OpCodes::Block(block_id(block), Crud::Update(BlockUpdate::Rect(to)))
637    }
638
639    fn rename_pin(byte: u8, name: &str) -> OpCodes {
640        OpCodes::Pin(pin_id(byte), Crud::Update(PinUpdate::Name(name.into())))
641    }
642
643    fn pin_delete(byte: u8) -> OpCodes {
644        OpCodes::Pin(pin_id(byte), Crud::Delete)
645    }
646
647    fn pin_restore(byte: u8) -> OpCodes {
648        OpCodes::Pin(pin_id(byte), Crud::Restore)
649    }
650
651    fn block_with_pin() -> Document<Confirmed> {
652        Document::<Confirmed>::default()
653            .try_apply(&Commit::new(
654                "Added a block with a pin".into(),
655                vec![block_create(1), pin_create(2, block_id(1))],
656            ))
657            .expect("the fold succeeds")
658    }
659
660    fn pin_create(byte: u8, owner: BlockId) -> OpCodes {
661        OpCodes::Pin(
662            pin_id(byte),
663            Crud::Create(PinInit {
664                owner,
665                name: format!("p{byte}"),
666                type_name: String::new(),
667                tag: String::new(),
668                tag_hidden: false,
669                rect: GridRect::default(),
670                dir: PinDir::default(),
671                pin_accent: Role::default(),
672                port_accent: Role::default(),
673                port_pin_accent: Role::default(),
674                flip_lr: false,
675            }),
676        )
677    }
678
679    #[test]
680    fn a_name_update_folds_into_a_new_document_and_the_source_stays_frozen() {
681        let doc = Document::<Confirmed>::default();
682
683        let next = doc
684            .try_apply(&rename("drawing"))
685            .expect("the fold succeeds");
686        assert_eq!(next.rev(), Rev::new(1), "the fold mints the successor rev");
687        assert_eq!(next.title_block().name.as_ref().as_str(), "drawing");
688        assert_eq!(next.title_block().name.order(), at(1));
689        assert_eq!(doc.rev(), Rev::ZERO, "the source document is frozen");
690        assert_eq!(
691            doc.title_block().name.as_ref().as_str(),
692            "",
693            "the source document is frozen"
694        );
695    }
696
697    /// The atomicity contract: an op that cannot resolve refuses the whole
698    /// commit — no successor document exists, so a partial application is
699    /// unrepresentable.
700    #[test]
701    fn a_bad_op_refuses_the_whole_commit() {
702        let doc = Document::<Confirmed>::default();
703        let unknown = block_id(7);
704        let commit = Commit::new(
705            "Deleted a block".into(),
706            vec![
707                OpCodes::Document(TitleBlockUpdate::Name("drawing".into())),
708                OpCodes::Block(unknown, Crud::Delete),
709            ],
710        );
711
712        let Err(error) = doc.try_apply(&commit) else {
713            panic!("a delete of an unknown id must refuse the commit");
714        };
715        assert_eq!(error, FoldError::InvalidBlockId(unknown));
716    }
717
718    /// Refusal is rev-neutral: the next accepted commit lands on the rev
719    /// the refused one would have taken, so the log cannot gap.
720    #[test]
721    fn a_refused_commit_mints_no_rev() {
722        let doc = Document::<Confirmed>::default()
723            .try_apply(&rename("drawing"))
724            .expect("the fold succeeds");
725        assert_eq!(doc.rev(), Rev::new(1));
726
727        let unknown = block_id(7);
728        let bad = Commit::new(
729            "Deleted a block".into(),
730            vec![OpCodes::Block(unknown, Crud::Delete)],
731        );
732        assert!(doc.try_apply(&bad).is_err());
733
734        let next = doc
735            .try_apply(&rename("schematic"))
736            .expect("the fold succeeds");
737        assert_eq!(next.rev(), Rev::new(2));
738    }
739
740    /// Two folds of one commit produce maps with independent hash seeds,
741    /// so equal hashes prove the id-sorted canonicalization, not luck.
742    #[test]
743    fn the_content_hash_is_canonical_over_map_iteration_order() {
744        let commit = Commit::new(
745            "Created twelve blocks".into(),
746            (1..=12u8).map(block_create).collect(),
747        );
748        let a = Document::<Confirmed>::default()
749            .try_apply(&commit)
750            .expect("the fold succeeds");
751        let b = Document::<Confirmed>::default()
752            .try_apply(&commit)
753            .expect("the fold succeeds");
754
755        assert_ne!(
756            a.blocks.keys().copied().collect::<Vec<_>>(),
757            b.blocks.keys().copied().collect::<Vec<_>>(),
758            "the fixture must iterate differently or canonicalization is untested"
759        );
760        assert_eq!(a.content_hash(), b.content_hash());
761    }
762
763    #[test]
764    fn the_content_hash_is_deterministic_and_detects_divergence() {
765        let doc = Document::<Confirmed>::default()
766            .try_apply(&rename("drawing"))
767            .expect("the fold succeeds");
768        assert_eq!(doc.content_hash(), doc.content_hash());
769
770        let diverged = doc
771            .try_apply(&rename("schematic"))
772            .expect("the fold succeeds");
773        assert_ne!(doc.content_hash(), diverged.content_hash());
774    }
775
776    /// Payload references validate against the commit's *final* view, so
777    /// op order inside a commit is free: a pin may precede its owner's
778    /// create. An owner the document never sees refuses the whole commit.
779    #[test]
780    fn a_pin_needs_its_owner_block() {
781        let owner = block_id(1);
782        let good = Commit::new(
783            "Added a block with a pin".into(),
784            vec![pin_create(2, owner), block_create(1)],
785        );
786        let doc = Document::<Confirmed>::default()
787            .try_apply(&good)
788            .expect("an owner created anywhere in the commit resolves");
789        assert!(doc.pin(&pin_id(2)).is_some());
790
791        let stranger = block_id(9);
792        let bad = Commit::new("Added an orphan pin".into(), vec![pin_create(3, stranger)]);
793        let Err(error) = doc.try_apply(&bad) else {
794            panic!("a pin with an unknown owner must refuse the commit");
795        };
796        assert_eq!(error, FoldError::InvalidPinOwner(pin_id(3), stranger));
797        assert!(
798            doc.pin(&pin_id(3)).is_none(),
799            "the refused create must leave no trace"
800        );
801    }
802
803    /// The update path revalidates too: re-owning a pin to an unknown
804    /// block is refused even though the write itself applied cleanly —
805    /// the trial fold discards the whole successor.
806    #[test]
807    fn an_owner_update_to_an_unknown_block_is_refused() {
808        let owner = block_id(1);
809        let doc = Document::<Confirmed>::default()
810            .try_apply(&Commit::new(
811                "Added a block with a pin".into(),
812                vec![block_create(1), pin_create(2, owner)],
813            ))
814            .expect("the fold succeeds");
815
816        let stranger = block_id(9);
817        let reown = Commit::new(
818            "Re-owned the pin".into(),
819            vec![OpCodes::Pin(
820                pin_id(2),
821                Crud::Update(PinUpdate::Owner(stranger)),
822            )],
823        );
824        let Err(error) = doc.try_apply(&reown) else {
825            panic!("re-owning to an unknown block must refuse the commit");
826        };
827        assert_eq!(error, FoldError::InvalidPinOwner(pin_id(2), stranger));
828        assert_eq!(
829            doc.pin(&pin_id(2)).map(|pin| *pin.as_ref().owner.as_ref()),
830            Some(owner),
831            "the source document still holds the valid owner"
832        );
833    }
834
835    /// The one global check: parent writes that close a loop are refused —
836    /// across commits, within one commit, and the self-parent degenerate.
837    #[test]
838    fn a_parent_cycle_is_refused() {
839        let doc = Document::<Confirmed>::default()
840            .try_apply(&Commit::new(
841                "Added two blocks".into(),
842                vec![block_create(1), block_create(2)],
843            ))
844            .expect("the fold succeeds");
845
846        let nested = doc
847            .try_apply(&Commit::new(
848                "Nested 1 under 2".into(),
849                vec![reparent(1, 2)],
850            ))
851            .expect("a loop-free reparent lands");
852        assert_eq!(
853            nested
854                .block(&block_id(1))
855                .map(|block| *block.as_ref().parent.as_ref()),
856            Some(block_id(2)),
857            "the fixture must nest or the loop below tests nothing"
858        );
859
860        let Err(error) =
861            nested.try_apply(&Commit::new("Closed the loop".into(), vec![reparent(2, 1)]))
862        else {
863            panic!("closing a parent loop must refuse the commit");
864        };
865        assert_eq!(error, FoldError::BlockCycle(block_id(2)));
866
867        let Err(error) = doc.try_apply(&Commit::new(
868            "Swapped parents".into(),
869            vec![reparent(1, 2), reparent(2, 1)],
870        )) else {
871            panic!("a loop closed within one commit must refuse it");
872        };
873        assert_eq!(error, FoldError::BlockCycle(block_id(1)));
874
875        let Err(error) = doc.try_apply(&Commit::new("Self parent".into(), vec![reparent(1, 1)]))
876        else {
877            panic!("a self-parent must refuse the commit");
878        };
879        assert_eq!(error, FoldError::BlockCycle(block_id(1)));
880    }
881
882    /// Donor: `a_batch_writing_one_register_twice_ends_on_its_last_write`.
883    /// One commit's writes are ordered by `Seq` alone, end to end: the
884    /// last write lands, at the last op's order.
885    #[test]
886    fn a_commit_writing_one_register_twice_ends_on_its_last_write() {
887        let commit = Commit::new(
888            "Created and nudged a block".into(),
889            vec![
890                block_create(1),
891                resize(1, rect(1, 1)),
892                resize(1, rect(2, 2)),
893            ],
894        );
895        let doc = Document::<Confirmed>::default()
896            .try_apply(&commit)
897            .expect("the fold succeeds");
898
899        let block = doc.block(&block_id(1)).expect("created").as_ref();
900        assert_eq!(block.rect.as_ref(), &rect(2, 2));
901        assert_eq!(
902            block.rect.order(),
903            WriteOrder::new(Rev::new(1), Seq::new(2))
904        );
905    }
906
907    /// Donor: `concurrent_writes_converge_whichever_order_they_arrive`,
908    /// re-based for minted revs — apply order *is* rev order now, so what
909    /// remains provable end to end is that a later commit's write
910    /// displaces an earlier one's while untouched registers keep their
911    /// orders.
912    #[test]
913    fn a_later_commits_write_displaces_an_earlier_ones() {
914        let doc = Document::<Confirmed>::default()
915            .try_apply(&Commit::new("Added a block".into(), vec![block_create(1)]))
916            .expect("the fold succeeds")
917            .try_apply(&Commit::new("Moved it".into(), vec![resize(1, rect(1, 1))]))
918            .expect("the fold succeeds")
919            .try_apply(&Commit::new(
920                "Moved it again".into(),
921                vec![resize(1, rect(2, 2))],
922            ))
923            .expect("the fold succeeds");
924
925        let block = doc.block(&block_id(1)).expect("created").as_ref();
926        assert_eq!(block.rect.as_ref(), &rect(2, 2));
927        assert_eq!(
928            block.rect.order(),
929            WriteOrder::new(Rev::new(3), Seq::new(0))
930        );
931        assert_eq!(
932            block.locked.order(),
933            WriteOrder::new(Rev::new(1), Seq::new(0)),
934            "untouched registers keep their create order"
935        );
936    }
937
938    /// Donor: `deleting_hides_an_element_but_keeps_it_restorable`. The
939    /// tombstone keeps the identity and the inner; delete and restore
940    /// touch only `presence`.
941    #[test]
942    fn deleting_tombstones_an_entity_but_keeps_it_restorable() {
943        let deleted = block_with_pin()
944            .try_apply(&Commit::new("Deleted the pin".into(), vec![pin_delete(2)]))
945            .expect("the fold succeeds");
946        let pin = deleted
947            .pin(&pin_id(2))
948            .expect("the tombstone keeps the identity");
949        assert!(!pin.is_alive());
950        assert_eq!(pin.as_ref().name.as_ref(), "p2", "the inner is retained");
951
952        let restored = deleted
953            .try_apply(&Commit::new(
954                "Restored the pin".into(),
955                vec![pin_restore(2)],
956            ))
957            .expect("restoring a tombstone");
958        let pin = restored.pin(&pin_id(2)).expect("restored");
959        assert!(pin.is_alive());
960        assert_eq!(
961            pin.as_ref().name.order(),
962            WriteOrder::new(Rev::new(1), Seq::new(1)),
963            "delete and restore never touch the inner registers"
964        );
965    }
966
967    /// Donor: `an_edit_concurrent_with_a_delete_converges`, linearized by
968    /// the server: the delete is sequenced first and the racing edit still
969    /// folds — it lands in the retained inner without resurrecting the
970    /// tombstone, and surfaces on restore.
971    #[test]
972    fn an_edit_after_a_delete_lands_in_the_inner_and_surfaces_on_restore() {
973        let edited = block_with_pin()
974            .try_apply(&Commit::new("Deleted the pin".into(), vec![pin_delete(2)]))
975            .expect("the fold succeeds")
976            .try_apply(&Commit::new(
977                "Renamed the pin".into(),
978                vec![rename_pin(2, "clk")],
979            ))
980            .expect("an edit to a tombstone folds — absorb, not reject");
981
982        let pin = edited.pin(&pin_id(2)).expect("tombstone");
983        assert!(
984            !pin.is_alive(),
985            "no edit at any order resurrects a tombstone"
986        );
987        assert_eq!(
988            pin.as_ref().name.as_ref(),
989            "clk",
990            "the edit landed in the retained inner"
991        );
992
993        let restored = edited
994            .try_apply(&Commit::new(
995                "Restored the pin".into(),
996                vec![pin_restore(2)],
997            ))
998            .expect("the fold succeeds");
999        let pin = restored.pin(&pin_id(2)).expect("restored");
1000        assert!(pin.is_alive());
1001        assert_eq!(
1002            pin.as_ref().name.as_ref(),
1003            "clk",
1004            "the absorbed edit surfaces on restore"
1005        );
1006    }
1007
1008    /// Donor: `replay_is_a_function_of_the_log_alone` +
1009    /// `replicas_agree_byte_for_byte_not_just_structurally` — the hash is
1010    /// the pivot's `state_to_bytes`. Covers the lifecycle ops too.
1011    #[test]
1012    fn replay_is_a_function_of_the_log_alone() {
1013        let log = [
1014            Commit::new(
1015                "Added a block with a pin".into(),
1016                vec![block_create(1), pin_create(2, block_id(1))],
1017            ),
1018            Commit::new("Renamed the pin".into(), vec![rename_pin(2, "clk")]),
1019            Commit::new("Deleted the pin".into(), vec![pin_delete(2)]),
1020            Commit::new("Restored the pin".into(), vec![pin_restore(2)]),
1021        ];
1022        let replay = || {
1023            log.iter()
1024                .try_fold(Document::<Confirmed>::default(), |doc, commit| {
1025                    doc.try_apply(commit)
1026                })
1027        };
1028
1029        let one = replay().expect("the fold succeeds");
1030        let other = replay().expect("the fold succeeds");
1031        assert_eq!(
1032            one.rev(),
1033            Rev::new(4),
1034            "the fixture must fold the whole log"
1035        );
1036        assert_eq!(one.content_hash(), other.content_hash());
1037    }
1038
1039    #[test]
1040    fn the_cache_indexes_every_kind_and_the_containment_tree() {
1041        let doc = wired_document();
1042        let cache = doc.cache();
1043
1044        assert_eq!(cache.top_level, [block_id(1)].into_iter().collect());
1045        let index = &cache.blocks[&block_id(1)];
1046        assert_eq!(index.children, [block_id(2)].into_iter().collect());
1047        assert_eq!(index.pins, [pin_id(3), pin_id(4)].into_iter().collect());
1048        assert_eq!(index.routes, [route_id(5)].into_iter().collect());
1049        assert_eq!(index.texts, [text_id(7)].into_iter().collect());
1050        assert_eq!(index.comments, [comment_id(8)].into_iter().collect());
1051        assert_eq!(index.images, [image_id(9)].into_iter().collect());
1052        assert_eq!(
1053            cache.blocks[&block_id(2)],
1054            BlockIndex::default(),
1055            "the nested block owns nothing"
1056        );
1057        assert_eq!(
1058            cache.routes[&route_id(5)].labels,
1059            [route_label_id(6)].into_iter().collect()
1060        );
1061        assert!(cache.suppressed.is_empty());
1062    }
1063
1064    /// Donor: `state.rs` live-children-only — a tombstoned child leaves
1065    /// its parent's sets, and a tombstoned element has no index entry.
1066    #[test]
1067    fn a_tombstoned_child_leaves_its_parents_sets() {
1068        let doc = wired_document()
1069            .try_apply(&Commit::new(
1070                "Deleted a pin and the nested block".into(),
1071                vec![pin_delete(4), OpCodes::Block(block_id(2), Crud::Delete)],
1072            ))
1073            .expect("the fold succeeds");
1074        let cache = doc.cache();
1075
1076        let index = &cache.blocks[&block_id(1)];
1077        assert_eq!(
1078            index.pins,
1079            [pin_id(3)].into_iter().collect(),
1080            "a tombstoned pin leaves the owner's set"
1081        );
1082        assert!(
1083            index.children.is_empty(),
1084            "a tombstoned child leaves the parent's list"
1085        );
1086        assert!(
1087            !cache.blocks.contains_key(&block_id(2)),
1088            "no entries for tombstoned elements"
1089        );
1090    }
1091
1092    /// Suppression is effective liveness, not presence: the endpoint pin
1093    /// is still *in the map* (tombstones retain entries), so a presence
1094    /// check would never fire — the first draft had exactly that bug.
1095    #[test]
1096    fn a_route_with_a_tombstoned_endpoint_is_suppressed_and_revived() {
1097        let deleted = wired_document()
1098            .try_apply(&Commit::new(
1099                "Deleted an endpoint".into(),
1100                vec![pin_delete(3)],
1101            ))
1102            .expect("the fold succeeds");
1103        assert!(
1104            deleted.route(&route_id(5)).expect("route").is_alive(),
1105            "the fixture's route must stay authored-alive"
1106        );
1107        assert!(
1108            deleted.pin(&pin_id(3)).is_some(),
1109            "the tombstoned endpoint must remain present or suppression tests nothing"
1110        );
1111
1112        let cache = deleted.cache();
1113        assert_eq!(cache.suppressed, [route_id(5)].into_iter().collect());
1114        assert!(
1115            cache.blocks[&block_id(1)].routes.contains(&route_id(5)),
1116            "a suppressed route stays indexed; suppression is a flag, not removal"
1117        );
1118
1119        let restored = deleted
1120            .try_apply(&Commit::new(
1121                "Restored the endpoint".into(),
1122                vec![pin_restore(3)],
1123            ))
1124            .expect("the fold succeeds");
1125        assert!(
1126            restored.cache().suppressed.is_empty(),
1127            "restoring the endpoint revives the route"
1128        );
1129    }
1130}