Skip to main content

blockworx_doc/
document.rs

1//! The document root and the index derived from it.
2//! Rationale: `docs/doc-ng-design-notes.md`.
3
4use std::sync::Arc;
5
6use crate::{
7    block_model::{Area, Asset, Block, Image, Pin, Route, RouteLabel, Text},
8    commit::Commit,
9    entity::{Entity, entity},
10    hash::{AssetHash, AssetKind, HashedMap},
11    id::{
12        Allocator, AreaId, BlockId, EntityRef, Id, IdKind, ImageId, PinId, RouteId, RouteLabelId,
13        TextId,
14    },
15    opcode::{Crud, OpCodes},
16    rev::{DocStamp, Rev},
17};
18use ahash::{HashMap, HashSet};
19use serde::{Deserialize, Serialize, Serializer};
20use std::collections::{BTreeMap, BTreeSet};
21
22entity! {
23    /// The document's own registers — a mechanical drawing's title
24    /// block, expected to grow fields (author, revision notes, …). The
25    /// singleton exception: never created by an op, no id, no
26    /// lifecycle — `OpCodes::Document` is update-only.
27    pub struct TitleBlock(update TitleBlockUpdate, id ()) {
28        registers {
29            Name => name: String,
30            /// The designated root of the single-canvas view; `NULL` = no
31            /// top yet (an empty document). A register, not a convention,
32            /// so two concurrent Wrap Tops converge on the later one's
33            /// root instead of leaving two.
34            Top => top: BlockId,
35        }
36        namespaces {}
37        constants {}
38    }
39}
40
41/// The format version this build writes, and the guard against reading a
42/// file from a newer one. Bumped whenever a change would make an older
43/// reader *misinterpret* a file rather than fail on it.
44///
45/// 1. The original format: a nested tree, blocks in a list, `loc: "w1"`.
46/// 2. Asset ids became content-derived.
47/// 3. Flat and id-keyed: the [`Document`] is the file (`docs/json-format.md`).
48pub const CURRENT_VERSION: u32 = 3;
49
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51#[error("document format version {0} is newer than this build reads (up to {CURRENT_VERSION})")]
52pub struct UnsupportedVersion(u32);
53
54/// The version a file declares. A version from the future is refused at
55/// the decode boundary rather than guessed at — the field exists precisely
56/// because a newer writer may have used syntax this build would otherwise
57/// misread as something else.
58#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
59#[serde(try_from = "u32")]
60pub struct FormatVersion(u32);
61
62impl Default for FormatVersion {
63    fn default() -> Self {
64        FormatVersion(CURRENT_VERSION)
65    }
66}
67
68impl TryFrom<u32> for FormatVersion {
69    type Error = UnsupportedVersion;
70    fn try_from(version: u32) -> Result<Self, Self::Error> {
71        if version > CURRENT_VERSION {
72            return Err(UnsupportedVersion(version));
73        }
74        Ok(FormatVersion(version))
75    }
76}
77
78/// Everything the document *is*, and exactly what a file holds: the entity
79/// tables keyed by id, the title block flattened over them, and the artwork
80/// payloads. `BTreeMap` rather than a hash map so the file is written in id
81/// order without a sorting pass to forget.
82///
83/// Split from the session state beside it in [`Document`] — the log
84/// position, the value stamp, the allocator's marks — because those are
85/// facts about this process's copy rather than about the drawing.
86#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
87pub struct Content {
88    version: FormatVersion,
89    #[serde(flatten)]
90    title_block: TitleBlock,
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    blocks: BTreeMap<BlockId, Arc<Block>>,
93    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94    pins: BTreeMap<PinId, Arc<Pin>>,
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    routes: BTreeMap<RouteId, Arc<Route>>,
97    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
98    route_labels: BTreeMap<RouteLabelId, Arc<RouteLabel>>,
99    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
100    texts: BTreeMap<TextId, Arc<Text>>,
101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102    areas: BTreeMap<AreaId, Arc<Area>>,
103    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104    images: BTreeMap<ImageId, Arc<Image>>,
105    /// Artwork bytes, addressed by content. Not entities: no liveness and
106    /// no lifecycle — a hash names one byte string forever, so the only
107    /// question the table can answer is whether it is held.
108    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
109    assets: HashedMap<AssetKind, Asset>,
110}
111
112/// Authored state only — the derived index is built beside it
113/// ([`DocIndex`]). The document is the implicit root of the
114/// containment tree: not an element, cannot be deleted, moved, or raced.
115/// Blocks whose parent is `Id::NULL` live at document level.
116///
117/// Serializing a document writes its [`Content`] and nothing else, and
118/// parsing one goes through [`From<Content>`](Document::from), which is
119/// what keeps a document read off a file stamped and marked like one that
120/// was folded.
121#[derive(Clone, Debug, Deserialize)]
122#[serde(from = "Content")]
123pub struct Document {
124    /// The head position in the log: the rev of the last commit folded in.
125    /// Minted only by a successful [`Document::try_apply`], so a refused
126    /// commit consumes no rev and the log cannot gap.
127    rev: Rev,
128    /// Names this value, not this position: two documents at one rev in
129    /// two processes are unrelated. Minted wherever a distinct value is
130    /// born — a successful fold, an empty document, a parsed file — and
131    /// carried by `Clone`.
132    stamp: DocStamp,
133    /// Where the next id of each kind comes from. Derived, never stored:
134    /// [`Self::try_apply`] raises a mark for every entity a commit names,
135    /// deleted ones included, so folding a log leaves the marks past
136    /// everything the document has ever held — which is why undoing a
137    /// delete can re-create the entity under its own id and why a deleted
138    /// id cannot come back on a different one. A document read off a file
139    /// has no such history, so its marks come from what it holds.
140    ids: Allocator,
141    content: Content,
142}
143
144impl Serialize for Document {
145    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
146        self.content.serialize(serializer)
147    }
148}
149
150/// Value equality is over the content alone: `rev` is a position, and a
151/// `DocStamp` is minted fresh for every distinct value, so comparing
152/// either would make two readings of one file unequal.
153impl PartialEq for Document {
154    fn eq(&self, other: &Self) -> bool {
155        self.content == other.content
156    }
157}
158
159impl From<Content> for Document {
160    fn from(content: Content) -> Self {
161        let mut document = Document {
162            rev: Rev::default(),
163            stamp: DocStamp::next(),
164            ids: Allocator::default(),
165            content,
166        };
167        document.observe_own_ids();
168        document
169    }
170}
171
172impl Default for Document {
173    fn default() -> Self {
174        Content::default().into()
175    }
176}
177
178impl Document {
179    /// The log position this document holds: the rev of the last commit
180    /// the [`Repo`](crate::repo::Repo) folded into it.
181    pub fn rev(&self) -> Rev {
182        self.rev
183    }
184
185    /// Names this value: the gate every derived cache beside the document
186    /// re-checks before rebuilding, [`DocIndex`] included.
187    pub fn stamp(&self) -> DocStamp {
188        self.stamp
189    }
190    /// The document's own marks, as a value a client mints from.
191    pub fn ids(&self) -> Allocator {
192        self.ids
193    }
194    /// Take this value as the document at `rev`, in a session that has
195    /// already got as far as `reached`: the position it is being adopted
196    /// at, a stamp of its own since it is a distinct value in this
197    /// process, and the one thing a session accumulates rather than holds
198    /// — the allocator's marks — carried over from where it had got to.
199    ///
200    /// A mark never falls, here or in the fold: an id a taken-back edit
201    /// minted is never minted again, which is what stops a restored
202    /// entity's id being handed out a second time.
203    ///
204    /// Payloads are *not* carried. They live in the store, addressed by
205    /// content and never deleted, so a rev that references one gets it
206    /// back when it is read — and a rev that references none holds none,
207    /// live or reopened.
208    ///
209    /// The door a history step comes through (`docs/log-vs-snapshot.md`),
210    /// and the only way a rev moves without a fold.
211    pub(crate) fn restored_at(&mut self, rev: Rev, reached: &Document) {
212        self.rev = rev;
213        self.stamp = DocStamp::next();
214        self.ids.raise_to(&reached.ids);
215    }
216
217    /// Take this value as the document at `rev` — a rev file names its
218    /// position in its own name, never inside itself, so the reader is what
219    /// puts the two together.
220    pub fn positioned_at(&mut self, rev: Rev) {
221        self.rev = rev;
222    }
223
224    /// A fresh id of this document's own, for a caller with no gesture to
225    /// mint through — the fold's own high-water mark advanced by one.
226    pub fn mint<K: IdKind>(&mut self) -> Id<K> {
227        self.ids.mint()
228    }
229    pub fn title_block(&self) -> &TitleBlock {
230        &self.content.title_block
231    }
232    pub fn block(&self, id: &BlockId) -> Option<&Block> {
233        self.content.blocks.get(id).map(Arc::as_ref)
234    }
235    pub fn blocks(&self) -> impl Iterator<Item = (BlockId, &Block)> {
236        self.content
237            .blocks
238            .iter()
239            .map(|(id, block)| (*id, block.as_ref()))
240    }
241    pub fn pin(&self, id: &PinId) -> Option<&Pin> {
242        self.content.pins.get(id).map(Arc::as_ref)
243    }
244    pub fn pins(&self) -> impl Iterator<Item = (PinId, &Pin)> {
245        self.content
246            .pins
247            .iter()
248            .map(|(id, pin)| (*id, pin.as_ref()))
249    }
250    pub fn route(&self, id: &RouteId) -> Option<&Route> {
251        self.content.routes.get(id).map(Arc::as_ref)
252    }
253    pub fn routes(&self) -> impl Iterator<Item = (RouteId, &Route)> {
254        self.content
255            .routes
256            .iter()
257            .map(|(id, route)| (*id, route.as_ref()))
258    }
259    pub fn route_label(&self, id: &RouteLabelId) -> Option<&RouteLabel> {
260        self.content.route_labels.get(id).map(Arc::as_ref)
261    }
262    pub fn route_labels(&self) -> impl Iterator<Item = (RouteLabelId, &RouteLabel)> {
263        self.content
264            .route_labels
265            .iter()
266            .map(|(id, label)| (*id, label.as_ref()))
267    }
268    pub fn text(&self, id: &TextId) -> Option<&Text> {
269        self.content.texts.get(id).map(Arc::as_ref)
270    }
271    pub fn texts(&self) -> impl Iterator<Item = (TextId, &Text)> {
272        self.content
273            .texts
274            .iter()
275            .map(|(id, text)| (*id, text.as_ref()))
276    }
277    pub fn area(&self, id: &AreaId) -> Option<&Area> {
278        self.content.areas.get(id).map(Arc::as_ref)
279    }
280    pub fn areas(&self) -> impl Iterator<Item = (AreaId, &Area)> {
281        self.content
282            .areas
283            .iter()
284            .map(|(id, area)| (*id, area.as_ref()))
285    }
286    pub fn image(&self, id: &ImageId) -> Option<&Image> {
287        self.content.images.get(id).map(Arc::as_ref)
288    }
289    pub fn images(&self) -> impl Iterator<Item = (ImageId, &Image)> {
290        self.content
291            .images
292            .iter()
293            .map(|(id, image)| (*id, image.as_ref()))
294    }
295    /// The payload behind an [`Image`]'s or [`Icon`](crate::block_model::Icon)'s
296    /// hash. `None` while the reference is held but its payload op has not
297    /// arrived — a legal, transient state that renders as nothing.
298    pub fn asset(&self, hash: &AssetHash) -> Option<&Asset> {
299        self.content.assets.get(hash)
300    }
301    /// Every payload the document holds, in hash order. Assets are
302    /// create-only, so this is simply what is there.
303    pub fn assets(&self) -> impl Iterator<Item = (AssetHash, &Asset)> {
304        self.content
305            .assets
306            .iter()
307            .map(|(hash, asset)| (*hash, asset))
308    }
309    pub fn try_apply(&self, commit: &Commit) -> Result<Document, FoldError> {
310        let mut new_doc = self.clone();
311        new_doc.rev = self.rev.next();
312        new_doc.stamp = DocStamp::next();
313        apply(&mut new_doc, commit)?;
314        validate(&new_doc, commit)?;
315        Ok(new_doc)
316    }
317    fn no_block_cycles(&self, id: BlockId) -> bool {
318        let mut current = id;
319        let mut seen = HashSet::default();
320        while let Some(block) = self.block(&current) {
321            if !seen.insert(current) {
322                return false; // cycle detected
323            }
324            if block.parent == BlockId::NULL {
325                break;
326            }
327            current = block.parent;
328        }
329        true
330    }
331    /// A commit that removed its target leaves nothing to check here:
332    /// [`apply`] refuses an op whose target was absent to begin with, so
333    /// a missing entity at this point is one this very commit deleted.
334    fn validate_block_owner(&self, id: BlockId) -> Result<(), FoldError> {
335        let Some(block) = self.block(&id) else {
336            return Ok(());
337        };
338        if block.parent != BlockId::NULL && !self.content.blocks.contains_key(&block.parent) {
339            return Err(FoldError::InvalidBlockParent(id, block.parent));
340        }
341        if !self.no_block_cycles(id) {
342            return Err(FoldError::BlockCycle(id));
343        }
344        Ok(())
345    }
346    /// The document root is a scope like any other: `Id::NULL` names it,
347    /// holds scoped entities, and is not a block — so it is never in the
348    /// map and never needs to be.
349    fn holds_scope(&self, scope: BlockId) -> bool {
350        scope == BlockId::NULL || self.content.blocks.contains_key(&scope)
351    }
352    fn validate_pin_owner(&self, id: PinId) -> Result<(), FoldError> {
353        let Some(pin) = self.pin(&id) else {
354            return Ok(());
355        };
356        if !self.holds_scope(pin.owner) {
357            return Err(FoldError::InvalidPinOwner(id, pin.owner));
358        }
359        Ok(())
360    }
361    fn validate_route_owner_and_endpoints(&self, id: RouteId) -> Result<(), FoldError> {
362        let Some(route) = self.route(&id) else {
363            return Ok(());
364        };
365        if !self.holds_scope(route.owner) {
366            return Err(FoldError::InvalidRouteOwner(id, route.owner));
367        }
368        if !self.content.pins.contains_key(&route.from) {
369            return Err(FoldError::InvalidRouteFrom(id, route.from));
370        }
371        if !self.content.pins.contains_key(&route.to) {
372            return Err(FoldError::InvalidRouteTo(id, route.to));
373        }
374        Ok(())
375    }
376    fn validate_route_label_owner(&self, id: RouteLabelId) -> Result<(), FoldError> {
377        let Some(label) = self.route_label(&id) else {
378            return Ok(());
379        };
380        if !self.content.routes.contains_key(&label.owner) {
381            return Err(FoldError::InvalidRouteLabelOwner(id, label.owner));
382        }
383        Ok(())
384    }
385    fn validate_text_owner(&self, id: TextId) -> Result<(), FoldError> {
386        let Some(text) = self.text(&id) else {
387            return Ok(());
388        };
389        if !self.holds_scope(text.owner) {
390            return Err(FoldError::InvalidTextOwner(id, text.owner));
391        }
392        Ok(())
393    }
394    fn validate_area_owner(&self, id: AreaId) -> Result<(), FoldError> {
395        let Some(area) = self.area(&id) else {
396            return Ok(());
397        };
398        if !self.holds_scope(area.owner) {
399            return Err(FoldError::InvalidAreaOwner(id, area.owner));
400        }
401        Ok(())
402    }
403    fn validate_image_owner(&self, id: ImageId) -> Result<(), FoldError> {
404        let Some(image) = self.image(&id) else {
405            return Ok(());
406        };
407        if !self.holds_scope(image.owner) {
408            return Err(FoldError::InvalidImageOwner(id, image.owner));
409        }
410        Ok(())
411    }
412
413    /// Every mark this document's own content implies. A fold raises the
414    /// marks as it goes; a document read off a file has only what it
415    /// holds, which is why a parse goes through here.
416    fn observe_own_ids(&mut self) {
417        for id in self.content.blocks.keys() {
418            self.ids.observe(EntityRef::Block(*id));
419        }
420        for id in self.content.pins.keys() {
421            self.ids.observe(EntityRef::Pin(*id));
422        }
423        for id in self.content.routes.keys() {
424            self.ids.observe(EntityRef::Route(*id));
425        }
426        for id in self.content.route_labels.keys() {
427            self.ids.observe(EntityRef::RouteLabel(*id));
428        }
429        for id in self.content.texts.keys() {
430            self.ids.observe(EntityRef::Text(*id));
431        }
432        for id in self.content.areas.keys() {
433            self.ids.observe(EntityRef::Area(*id));
434        }
435        for id in self.content.images.keys() {
436            self.ids.observe(EntityRef::Image(*id));
437        }
438    }
439
440    /// The whole document as one labeled commit of creates, in dependency
441    /// order — the one bridge from a parsed file to the log that builds
442    /// it. Id-preserving: the ids in the file are the ids, and re-minting
443    /// on paste into a populated document is
444    /// [`edit::clipboard`](../../blockworx/edit/clipboard/index.html)'s
445    /// job, not a loader's. `None` for a document with nothing in it.
446    pub fn creating_commit(&self, label: &str) -> Option<Commit> {
447        fn creates<'a, K, E>(
448            table: &'a BTreeMap<Id<K>, Arc<E>>,
449            op: impl Fn(Id<K>, Crud<E, E::Update>) -> OpCodes + 'a,
450        ) -> impl Iterator<Item = OpCodes> + 'a
451        where
452            K: IdKind,
453            E: Entity + Clone + 'a,
454        {
455            table
456                .iter()
457                .map(move |(id, entity)| op(*id, Crud::Create(E::clone(entity))))
458        }
459        let mut builder = crate::commit::CommitBuilder::new(label);
460        builder.extend(
461            self.content
462                .assets
463                .iter()
464                .map(|(hash, asset)| OpCodes::Asset(*hash, asset.clone())),
465        );
466        builder.extend(creates(&self.content.blocks, OpCodes::Block));
467        builder.extend(creates(&self.content.pins, OpCodes::Pin));
468        builder.extend(creates(&self.content.routes, OpCodes::Route));
469        builder.extend(creates(&self.content.route_labels, OpCodes::RouteLabel));
470        builder.extend(creates(&self.content.texts, OpCodes::Text));
471        builder.extend(creates(&self.content.areas, OpCodes::Area));
472        builder.extend(creates(&self.content.images, OpCodes::Image));
473        let title = &self.content.title_block;
474        if !title.name.is_empty() {
475            builder.push(OpCodes::Document(TitleBlockUpdate::Name(
476                title.name.clone(),
477            )));
478        }
479        if title.top != BlockId::NULL {
480            builder.push(OpCodes::Document(TitleBlockUpdate::Top(title.top)));
481        }
482        builder.seal()
483    }
484
485    /// Every payload this document *references*: the artwork behind its
486    /// icons and its images. Never the table's own keys — a payload
487    /// outlives the reference that brought it in, so the table can hold
488    /// bytes nothing points at.
489    pub fn referenced_assets(&self) -> BTreeSet<AssetHash> {
490        let icons = self
491            .content
492            .blocks
493            .values()
494            .map(|block| block.icon.asset)
495            .filter(|hash| *hash != AssetHash::default());
496        let images = self.content.images.values().map(|image| image.asset);
497        icons.chain(images).collect()
498    }
499
500    /// This value with its payloads left behind — what a rev file is
501    /// written from (`docs/log-vs-snapshot.md` §10). `assets/` is the one
502    /// home for bytes, so a rev carries the references and the
503    /// content-addressed store carries what they name; the entity tables
504    /// are `Arc`s, so the clone is pointers.
505    #[must_use]
506    pub fn without_assets(&self) -> Document {
507        let mut stripped = self.clone();
508        stripped.content.assets.clear();
509        stripped
510    }
511
512    /// Put back every payload this document references, asking `payload`
513    /// for the bytes behind each hash — [`Self::without_assets`]'s other
514    /// half.
515    ///
516    /// # Errors
517    /// Whatever the source says about the first payload it will not hand
518    /// back.
519    pub fn attach_assets<E>(
520        &mut self,
521        mut payload: impl FnMut(AssetHash) -> Result<Asset, E>,
522    ) -> Result<(), E> {
523        for hash in self.referenced_assets() {
524            if self.content.assets.contains_key(&hash) {
525                continue;
526            }
527            self.content.assets.insert(hash, payload(hash)?);
528        }
529        Ok(())
530    }
531}
532
533/// A document and the index over it, bundled so they cannot be mismatched.
534///
535/// The pairing is the guarantee. [`DocIndex::view`] is the only
536/// constructor: it brings the index up to date against the very document
537/// it then bundles, so consulting an index built from a *different*
538/// document — the one hazard a derived index has, and the same mistake
539/// class as reading the wrong document directly — is unrepresentable
540/// rather than merely discouraged. Coherence beyond that is structural:
541/// documents are immutable values, so a matched index cannot drift.
542///
543/// The refresh is gated on the document's stamp, so re-viewing a document
544/// nothing has folded into costs one comparison; a rebuild happens only
545/// where a new document value does.
546///
547/// A pair of shared references, so the view copies freely — the pairing
548/// constructor is still the only way to *make* one.
549#[non_exhaustive]
550#[derive(Clone, Copy)]
551pub struct IndexedDocument<'a> {
552    pub doc: &'a Document,
553    pub index: &'a DocIndex,
554}
555
556/// The derived index over the whole document — never authoritative. A
557/// child whose owner has been deleted is indexed nowhere (its owner has
558/// no entry).
559///
560/// Owned, and stamp-gated through [`Self::view`]: the third instance of
561/// the pattern the app already runs for its spatial tree (`CachedIndex`)
562/// and its route-accent propagation (`PinAccents`) — kept across frames,
563/// rebuilt from the document value alone, so no mutation site has to
564/// remember to invalidate it.
565#[derive(Default)]
566pub struct DocIndex {
567    /// `None` until the first build; the empty index describes no
568    /// document, not the empty one.
569    built_from: Option<DocStamp>,
570    /// Every block's scope, plus the document root under `Id::NULL` —
571    /// whose `children` are the top-level blocks and whose other sets hold
572    /// the entities owned by the root directly. A row here is a *scope*,
573    /// which the root is and a block also is; [`Self::holds_block`] is the
574    /// other question.
575    pub blocks: HashMap<BlockId, BlockIndex>,
576    pub routes: HashMap<RouteId, RouteIndex>,
577    /// Every route, keyed by both endpoint pins — the adjacency the
578    /// delete cascade and pin drags ask for.
579    pub routes_by_endpoint: HashMap<PinId, HashSet<RouteId>>,
580}
581
582impl DocIndex {
583    /// One linear pass over every kind. The ad-hoc path, for a caller
584    /// with nowhere to keep an index between document values; a caller
585    /// that has somewhere keeps one and calls [`Self::view`].
586    pub fn of(doc: &Document) -> Self {
587        let mut index = Self {
588            built_from: Some(doc.stamp),
589            blocks: doc
590                .blocks()
591                .map(|(id, _)| (id, BlockIndex::default()))
592                .chain(std::iter::once((BlockId::NULL, BlockIndex::default())))
593                .collect(),
594            routes: doc
595                .routes()
596                .map(|(id, _)| (id, RouteIndex::default()))
597                .collect(),
598            routes_by_endpoint: HashMap::default(),
599        };
600        for (id, block) in doc.blocks() {
601            if let Some(entry) = index.blocks.get_mut(&block.parent) {
602                entry.children.insert(id);
603            }
604        }
605        for (id, pin) in doc.pins() {
606            if let Some(entry) = index.blocks.get_mut(&pin.owner) {
607                entry.pins.insert(id);
608            }
609        }
610        for (id, route) in doc.routes() {
611            if let Some(entry) = index.blocks.get_mut(&route.owner) {
612                entry.routes.insert(id);
613            }
614            for endpoint in [route.from, route.to] {
615                index
616                    .routes_by_endpoint
617                    .entry(endpoint)
618                    .or_default()
619                    .insert(id);
620            }
621        }
622        for (id, label) in doc.route_labels() {
623            if let Some(entry) = index.routes.get_mut(&label.owner) {
624                entry.labels.insert(id);
625            }
626        }
627        for (id, text) in doc.texts() {
628            if let Some(entry) = index.blocks.get_mut(&text.owner) {
629                entry.texts.insert(id);
630            }
631        }
632        for (id, area) in doc.areas() {
633            if let Some(entry) = index.blocks.get_mut(&area.owner) {
634                entry.areas.insert(id);
635            }
636        }
637        for (id, image) in doc.images() {
638            if let Some(entry) = index.blocks.get_mut(&image.owner) {
639                entry.images.insert(id);
640            }
641        }
642        index
643    }
644
645    /// The contents of `scope`: a block's interior, or the document root
646    /// (`Id::NULL`). Every block has a row and so does the root, so a
647    /// `None` here means the scope is gone, never that it is the root.
648    pub fn scope(&self, id: BlockId) -> Option<&BlockIndex> {
649        self.blocks.get(&id)
650    }
651
652    /// Whether `id` names a block the document holds. The root's row is a
653    /// scope without an entity behind it — nothing can delete, move, or
654    /// rename it — so it answers `false` here while still answering
655    /// [`Self::scope`].
656    pub fn holds_block(&self, id: BlockId) -> bool {
657        id != BlockId::NULL && self.blocks.contains_key(&id)
658    }
659
660    /// `doc` bundled with this index, rebuilt first unless it was already
661    /// built from that very document value.
662    pub fn view<'a>(&'a mut self, doc: &'a Document) -> IndexedDocument<'a> {
663        if self.built_from != Some(doc.stamp) {
664            *self = Self::of(doc);
665        }
666        IndexedDocument { doc, index: self }
667    }
668
669    /// The view over the document value this index was built from, and
670    /// `None` for any other — the read-only door, for a caller holding a
671    /// document and an index it built together and with no `&mut` to
672    /// rebuild through. Refusing the mismatched pair is the whole point:
673    /// it is the same check [`Self::view`] makes, minus the repair.
674    pub fn view_of<'a>(&'a self, doc: &'a Document) -> Option<IndexedDocument<'a>> {
675        (self.built_from == Some(doc.stamp)).then_some(IndexedDocument { doc, index: self })
676    }
677}
678
679#[derive(Debug, Default, PartialEq, Eq)]
680pub struct BlockIndex {
681    pub pins: HashSet<PinId>,
682    pub routes: HashSet<RouteId>,
683    pub texts: HashSet<TextId>,
684    pub areas: HashSet<AreaId>,
685    pub images: HashSet<ImageId>,
686    pub children: HashSet<BlockId>,
687}
688
689#[derive(Debug, Default, PartialEq, Eq)]
690pub struct RouteIndex {
691    pub labels: HashSet<RouteLabelId>,
692}
693
694/// One order, every consumer (`docs/doc-ng-design-notes.md`, "Draw order
695/// is a derived policy"): ascending id — which is creation order, ids
696/// being monotonic counters — so painting in this order puts the newest
697/// entity on top. Hit-testing walks it back-to-front and the router
698/// places first-come-first-served, so the three cannot disagree.
699pub fn chronological<'a, K, T>(entries: impl Iterator<Item = (Id<K>, &'a T)>) -> Vec<Id<K>>
700where
701    K: crate::id::IdKind,
702    T: 'a,
703{
704    let mut ids: Vec<Id<K>> = entries.map(|(id, _)| id).collect();
705    ids.sort();
706    ids
707}
708
709#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
710pub enum FoldError {
711    #[error("invalid block id {0}")]
712    InvalidBlockId(BlockId),
713    #[error("invalid pin id {0}")]
714    InvalidPinId(PinId),
715    #[error("invalid route id {0}")]
716    InvalidRouteId(RouteId),
717    #[error("invalid route label id {0}")]
718    InvalidRouteLabelId(RouteLabelId),
719    #[error("invalid text id {0}")]
720    InvalidTextId(TextId),
721    #[error("invalid area id {0}")]
722    InvalidAreaId(AreaId),
723    #[error("invalid image id {0}")]
724    InvalidImageId(ImageId),
725    #[error("Block {0} parent {1} is not a block in the document")]
726    InvalidBlockParent(BlockId, BlockId),
727    #[error("Pin {0} owner {1} is not a block in the document")]
728    InvalidPinOwner(PinId, BlockId),
729    #[error("Route {0} owner {1} is not a block in the document")]
730    InvalidRouteOwner(RouteId, BlockId),
731    #[error("Route {0} from pin {1} is not a pin in the document")]
732    InvalidRouteFrom(RouteId, PinId),
733    #[error("Route {0} to pin {1} is not a pin in the document")]
734    InvalidRouteTo(RouteId, PinId),
735    #[error("RouteLabel {0} owner {1} is not a route in the document")]
736    InvalidRouteLabelOwner(RouteLabelId, RouteId),
737    #[error("Text {0} owner {1} is not a block in the document")]
738    InvalidTextOwner(TextId, BlockId),
739    #[error("Area {0} owner {1} is not a block in the document")]
740    InvalidAreaOwner(AreaId, BlockId),
741    #[error("top {0} is not a block in the document")]
742    InvalidTop(BlockId),
743    #[error("Image {0} owner {1} is not a block in the document")]
744    InvalidImageOwner(ImageId, BlockId),
745    #[error("Block {0} is in a cycle of parent links")]
746    BlockCycle(BlockId),
747    #[error("the payload filed under asset {0} does not hash to it")]
748    AssetHashMismatch(AssetHash),
749    #[error("asset {0} is {1} bytes, over the {limit}-byte limit", limit = crate::block_model::ASSET_LIMIT)]
750    AssetTooLarge(AssetHash, usize),
751}
752
753fn apply(doc: &mut Document, commit: &Commit) -> Result<(), FoldError> {
754    for op in commit.ops() {
755        doc.ids.observe(op.target());
756        match op {
757            OpCodes::Document(update) => {
758                doc.content.title_block.apply(update);
759            }
760            OpCodes::Block(id, crud) => {
761                apply_crud_to_entity(&mut doc.content.blocks, *id, crud)
762                    .ok_or(FoldError::InvalidBlockId(*id))?;
763            }
764            OpCodes::Pin(id, crud) => {
765                apply_crud_to_entity(&mut doc.content.pins, *id, crud)
766                    .ok_or(FoldError::InvalidPinId(*id))?;
767            }
768            OpCodes::Route(id, crud) => {
769                apply_crud_to_entity(&mut doc.content.routes, *id, crud)
770                    .ok_or(FoldError::InvalidRouteId(*id))?;
771            }
772            OpCodes::RouteLabel(id, crud) => {
773                apply_crud_to_entity(&mut doc.content.route_labels, *id, crud)
774                    .ok_or(FoldError::InvalidRouteLabelId(*id))?;
775            }
776            OpCodes::Text(id, crud) => {
777                apply_crud_to_entity(&mut doc.content.texts, *id, crud)
778                    .ok_or(FoldError::InvalidTextId(*id))?;
779            }
780            OpCodes::Area(id, crud) => {
781                apply_crud_to_entity(&mut doc.content.areas, *id, crud)
782                    .ok_or(FoldError::InvalidAreaId(*id))?;
783            }
784            OpCodes::Image(id, crud) => {
785                apply_crud_to_entity(&mut doc.content.images, *id, crud)
786                    .ok_or(FoldError::InvalidImageId(*id))?;
787            }
788            OpCodes::Asset(hash, asset) => {
789                doc.content
790                    .assets
791                    .entry(*hash)
792                    .or_insert_with(|| asset.clone());
793            }
794        }
795    }
796    Ok(())
797}
798
799/// Run against the *folded* document, so an op's position within the
800/// commit never matters: a cascade may delete a pin before or after the
801/// route hanging off it and be judged the same either way.
802fn validate(doc: &Document, commit: &Commit) -> Result<(), FoldError> {
803    dangling_endpoints(doc, commit)?;
804    for op in commit.ops() {
805        match op {
806            OpCodes::Block(id, _) => {
807                doc.validate_block_owner(*id)?;
808            }
809            OpCodes::Pin(id, _) => {
810                doc.validate_pin_owner(*id)?;
811            }
812            OpCodes::Route(id, _) => {
813                doc.validate_route_owner_and_endpoints(*id)?;
814            }
815            OpCodes::RouteLabel(id, _) => {
816                doc.validate_route_label_owner(*id)?;
817            }
818            OpCodes::Text(id, _) => {
819                doc.validate_text_owner(*id)?;
820            }
821            OpCodes::Area(id, _) => {
822                doc.validate_area_owner(*id)?;
823            }
824            OpCodes::Image(id, _) => {
825                doc.validate_image_owner(*id)?;
826            }
827            OpCodes::Asset(hash, asset) => {
828                // Content addressing is only an invariant if someone checks
829                // it: a payload filed under a hash it does not have would
830                // make one key name two byte strings, and every replica's
831                // "same hash ⇒ same bytes" reasoning with it.
832                if asset.hash() != *hash {
833                    return Err(FoldError::AssetHashMismatch(*hash));
834                }
835                // Unbounded here is unbounded forever: the log is replayed
836                // in full on every open, so a payload nobody bounded is one
837                // the document carries for its whole life.
838                if !asset.within_limit() {
839                    return Err(FoldError::AssetTooLarge(*hash, asset.bytes().len()));
840                }
841            }
842            OpCodes::Document(_) => {
843                // Existence-only, like route endpoints: anything stricter
844                // ("top's parent must be NULL") is breakable by a
845                // concurrent reparent, and a well-behaved client is never
846                // rejected by a race it could not see.
847                let top = doc.title_block().top;
848                if top != BlockId::NULL && !doc.content.blocks.contains_key(&top) {
849                    return Err(FoldError::InvalidTop(top));
850                }
851            }
852        }
853    }
854    Ok(())
855}
856
857/// A route's endpoints are the one reference the index cannot quietly
858/// drop: an orphaned route is still indexed and still drawn, whereas a
859/// child whose owner is gone leaves the index with its scope. So a pin
860/// may not leave under a route — the delete cascade emits the dependent
861/// route deletes itself, which is what keeps this refusal unreachable
862/// from the editor.
863fn dangling_endpoints(doc: &Document, commit: &Commit) -> Result<(), FoldError> {
864    let removed: HashSet<PinId> = commit
865        .ops()
866        .iter()
867        .filter_map(|op| match op {
868            OpCodes::Pin(id, Crud::Delete) => Some(*id),
869            _ => None,
870        })
871        .collect();
872    if removed.is_empty() {
873        return Ok(());
874    }
875    let mut dangling: Vec<(RouteId, &Route)> = doc
876        .routes()
877        .filter(|(_, route)| removed.contains(&route.from) || removed.contains(&route.to))
878        .collect();
879    dangling.sort_by_key(|(id, _)| *id);
880    match dangling.first() {
881        None => Ok(()),
882        Some((id, route)) if removed.contains(&route.from) => {
883            Err(FoldError::InvalidRouteFrom(*id, route.from))
884        }
885        Some((id, route)) => Err(FoldError::InvalidRouteTo(*id, route.to)),
886    }
887}
888
889/// `None` means the id is invalid for the op: unknown for
890/// `Update`/`Delete`, already taken for `Create` — the allocator never
891/// re-mints a number the document has held, so a duplicate create is a
892/// replay or a bug, never a merge.
893fn apply_crud_to_entity<E: Entity + Clone>(
894    map: &mut BTreeMap<E::Id, Arc<E>>,
895    id: E::Id,
896    crud: &Crud<E, E::Update>,
897) -> Option<()> {
898    match crud {
899        Crud::Create(entity) => {
900            if map.contains_key(&id) {
901                return None;
902            }
903            map.insert(id, Arc::new(entity.clone()));
904            Some(())
905        }
906        Crud::Update(update) => {
907            Arc::make_mut(map.get_mut(&id)?).apply(update);
908            Some(())
909        }
910        Crud::Delete => map.remove(&id).map(|_| ()),
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use crate::fixtures::{area_id, block_id, image_id, pin_id, route_id, route_label_id, text_id};
918    use crate::{
919        block_model::{
920            Area, Block, BlockUpdate, Icon, Image, Label, Pin, PinUpdate, Route, RouteLabel, Text,
921        },
922        geometry::{FracVal, GridPoint, GridRect, GridSize, PinSlot, ScreenRect},
923        values::{LabelSide, PinDir, PinSide, Role},
924    };
925
926    fn rename(name: &str) -> Commit {
927        Commit::new(
928            "Renamed the document".into(),
929            vec![OpCodes::Document(TitleBlockUpdate::Name(name.into()))],
930        )
931    }
932
933    fn label_init(name: String) -> Label {
934        Label {
935            name,
936            side: LabelSide::default(),
937            offset: FracVal::default(),
938            hidden: false,
939        }
940    }
941
942    fn block_create(n: u32) -> OpCodes {
943        OpCodes::Block(
944            block_id(n),
945            Crud::Create(Block {
946                parent: BlockId::NULL,
947                rect: GridRect::default(),
948                locked: false,
949                role: Role::default(),
950                title: label_init(format!("b{n}")),
951                type_label: label_init(String::new()),
952                icon: Icon::default(),
953            }),
954        )
955    }
956
957    fn route_create(n: u32, owner: u32, from: u32, to: u32) -> OpCodes {
958        OpCodes::Route(
959            route_id(n),
960            Crud::Create(Route {
961                owner: block_id(owner),
962                name: format!("r{n}"),
963                from: pin_id(from),
964                to: pin_id(to),
965                role: Role::default(),
966                waypoints: Vec::new(),
967            }),
968        )
969    }
970
971    /// Block 1 top-level with block 2 nested under it; pins 3 and 4 on
972    /// block 1; route 5 between them owned by block 1; route label 6 on
973    /// the route; text 7, area 8, image 9 on block 1.
974    fn wired_document() -> Document {
975        Document::default()
976            .try_apply(&Commit::new(
977                "Wired a document".into(),
978                vec![
979                    block_create(1),
980                    block_create(2),
981                    reparent(2, 1),
982                    pin_create(3, block_id(1)),
983                    pin_create(4, block_id(1)),
984                    route_create(5, 1, 3, 4),
985                    OpCodes::RouteLabel(
986                        route_label_id(6),
987                        Crud::Create(RouteLabel {
988                            owner: route_id(5),
989                            pos: FracVal::default(),
990                        }),
991                    ),
992                    OpCodes::Text(
993                        text_id(7),
994                        Crud::Create(Text {
995                            owner: block_id(1),
996                            text: "note".into(),
997                            pos: GridPoint::default(),
998                            role: Role::default(),
999                            width: None,
1000                        }),
1001                    ),
1002                    OpCodes::Area(
1003                        area_id(8),
1004                        Crud::Create(Area {
1005                            owner: block_id(1),
1006                            rect: GridRect::default(),
1007                            role: Role::default(),
1008                            title: label_init("c8".into()),
1009                        }),
1010                    ),
1011                    OpCodes::Image(
1012                        image_id(9),
1013                        Crud::Create(Image {
1014                            owner: block_id(1),
1015                            asset: AssetHash::default(),
1016                            rect: ScreenRect::default(),
1017                        }),
1018                    ),
1019                ],
1020            ))
1021            .expect("the fold succeeds")
1022    }
1023
1024    fn reparent(child: u32, parent: u32) -> OpCodes {
1025        OpCodes::Block(
1026            block_id(child),
1027            Crud::Update(BlockUpdate::Parent(block_id(parent))),
1028        )
1029    }
1030
1031    fn rect(x: i32, y: i32) -> GridRect {
1032        GridRect {
1033            top_left: GridPoint { x, y },
1034            size: GridSize { w: 4, h: 4 },
1035        }
1036    }
1037
1038    fn resize(block: u32, to: GridRect) -> OpCodes {
1039        OpCodes::Block(block_id(block), Crud::Update(BlockUpdate::Rect(to)))
1040    }
1041
1042    fn rename_pin(n: u32, name: &str) -> OpCodes {
1043        OpCodes::Pin(pin_id(n), Crud::Update(PinUpdate::Name(name.into())))
1044    }
1045
1046    fn pin_delete(n: u32) -> OpCodes {
1047        OpCodes::Pin(pin_id(n), Crud::Delete)
1048    }
1049
1050    fn block_with_pin() -> Document {
1051        Document::default()
1052            .try_apply(&Commit::new(
1053                "Added a block with a pin".into(),
1054                vec![block_create(1), pin_create(2, block_id(1))],
1055            ))
1056            .expect("the fold succeeds")
1057    }
1058
1059    fn pin_create(n: u32, owner: BlockId) -> OpCodes {
1060        OpCodes::Pin(
1061            pin_id(n),
1062            Crud::Create(Pin {
1063                owner,
1064                name: format!("p{n}"),
1065                type_name: String::new(),
1066                tag: String::new(),
1067                tag_hidden: false,
1068                rect: GridRect::default(),
1069                slot: PinSlot::default(),
1070                dir: PinDir::default(),
1071                port_accent: Role::default(),
1072                flip_lr: false,
1073            }),
1074        )
1075    }
1076
1077    #[test]
1078    fn a_name_update_folds_into_a_new_document_and_the_source_stays_frozen() {
1079        let doc = Document::default();
1080
1081        let next = doc
1082            .try_apply(&rename("drawing"))
1083            .expect("the fold succeeds");
1084        assert_eq!(next.rev(), Rev::new(1), "the fold mints the successor rev");
1085        assert_eq!(next.title_block().name.as_str(), "drawing");
1086        assert_eq!(doc.rev(), Rev::ZERO, "the source document is frozen");
1087        assert_eq!(
1088            doc.title_block().name.as_str(),
1089            "",
1090            "the source document is frozen"
1091        );
1092    }
1093
1094    /// A block's accent is authored state, so two writes to it resolve in
1095    /// fold order.
1096    #[test]
1097    fn a_block_role_folds_and_the_later_write_wins() {
1098        let doc = Document::default()
1099            .try_apply(&Commit::new("Added b1".into(), vec![block_create(1)]))
1100            .expect("the create folds");
1101
1102        let recolor = |role| {
1103            Commit::new(
1104                "Recolored".into(),
1105                vec![OpCodes::Block(
1106                    block_id(1),
1107                    Crud::Update(BlockUpdate::Role(role)),
1108                )],
1109            )
1110        };
1111        let next = doc
1112            .try_apply(&recolor(Role::Accent3))
1113            .expect("the first recolor folds")
1114            .try_apply(&recolor(Role::Accent5))
1115            .expect("the second recolor folds");
1116
1117        let block = next.block(&block_id(1)).expect("the block is held");
1118        assert_eq!(block.role, Role::Accent5);
1119    }
1120
1121    /// Wrap Top's exact commit shape — create the new root, reparent the
1122    /// old, point `top` at it — folds as one commit, and a later `top`
1123    /// write displaces an earlier one whole (LWW, like any register).
1124    #[test]
1125    fn wrap_top_folds_and_the_later_top_wins() {
1126        let set_top = |n| OpCodes::Document(TitleBlockUpdate::Top(block_id(n)));
1127        let doc = Document::default()
1128            .try_apply(&Commit::new(
1129                "Added b1".into(),
1130                vec![block_create(1), set_top(1)],
1131            ))
1132            .expect("the create folds")
1133            .try_apply(&Commit::new(
1134                "Wrapped the top".into(),
1135                vec![block_create(2), reparent(1, 2), set_top(2)],
1136            ))
1137            .expect("the wrap folds");
1138
1139        assert_eq!(doc.title_block().top, block_id(2));
1140        assert_eq!(
1141            doc.block(&block_id(1)).expect("the old top is held").parent,
1142            block_id(2),
1143        );
1144    }
1145
1146    /// Existence-only, but existence is checked: a top naming a block the
1147    /// document does not hold is refused, and `NULL` stays legal ("no top
1148    /// yet").
1149    #[test]
1150    fn a_top_naming_an_unknown_block_is_refused() {
1151        let doc = Document::default();
1152        let unknown = block_id(7);
1153
1154        let refused = doc.try_apply(&Commit::new(
1155            "Pointed top nowhere".into(),
1156            vec![OpCodes::Document(TitleBlockUpdate::Top(unknown))],
1157        ));
1158        assert_eq!(refused.err(), Some(FoldError::InvalidTop(unknown)));
1159
1160        doc.try_apply(&Commit::new(
1161            "Cleared the top".into(),
1162            vec![OpCodes::Document(TitleBlockUpdate::Top(BlockId::NULL))],
1163        ))
1164        .expect("NULL is the meaningful zero, not a reference");
1165    }
1166
1167    /// The slot is one register by design: concurrent writers race whole
1168    /// `(side, offset)` pairs, so a merge can never land one write's edge
1169    /// under another's offset.
1170    #[test]
1171    fn concurrent_slot_writes_race_as_whole_pairs() {
1172        let doc = block_with_pin();
1173
1174        let move_to = |slot| {
1175            Commit::new(
1176                "Moved a pin".into(),
1177                vec![OpCodes::Pin(pin_id(2), Crud::Update(PinUpdate::Slot(slot)))],
1178            )
1179        };
1180        let east_3 = PinSlot {
1181            side: PinSide::East,
1182            offset: 3,
1183        };
1184        let west_9 = PinSlot {
1185            side: PinSide::West,
1186            offset: 9,
1187        };
1188        let next = doc
1189            .try_apply(&move_to(east_3))
1190            .expect("the first move folds")
1191            .try_apply(&move_to(west_9))
1192            .expect("the second move folds");
1193
1194        let pin = next.pin(&pin_id(2)).expect("the pin is held");
1195        assert_eq!(pin.slot, west_9, "the later pair wins whole");
1196    }
1197
1198    /// The atomicity contract: an op that cannot resolve refuses the whole
1199    /// commit — no successor document exists, so a partial application is
1200    /// unrepresentable.
1201    #[test]
1202    fn a_bad_op_refuses_the_whole_commit() {
1203        let doc = Document::default();
1204        let unknown = block_id(7);
1205        let commit = Commit::new(
1206            "Deleted a block".into(),
1207            vec![
1208                OpCodes::Document(TitleBlockUpdate::Name("drawing".into())),
1209                OpCodes::Block(unknown, Crud::Delete),
1210            ],
1211        );
1212
1213        let Err(error) = doc.try_apply(&commit) else {
1214            panic!("a delete of an unknown id must refuse the commit");
1215        };
1216        assert_eq!(error, FoldError::InvalidBlockId(unknown));
1217    }
1218
1219    /// Refusal is rev-neutral: the next accepted commit lands on the rev
1220    /// the refused one would have taken, so the log cannot gap.
1221    #[test]
1222    fn a_refused_commit_mints_no_rev() {
1223        let doc = Document::default()
1224            .try_apply(&rename("drawing"))
1225            .expect("the fold succeeds");
1226        assert_eq!(doc.rev(), Rev::new(1));
1227
1228        let unknown = block_id(7);
1229        let bad = Commit::new(
1230            "Deleted a block".into(),
1231            vec![OpCodes::Block(unknown, Crud::Delete)],
1232        );
1233        assert!(doc.try_apply(&bad).is_err());
1234
1235        let next = doc
1236            .try_apply(&rename("schematic"))
1237            .expect("the fold succeeds");
1238        assert_eq!(next.rev(), Rev::new(2));
1239    }
1240
1241    /// The tables iterate in id order whatever order they were written in —
1242    /// the one property the file's diffs and the content hash both rest on,
1243    /// and now the table type's own rather than a sorting pass someone has to
1244    /// remember.
1245    #[test]
1246    fn the_tables_iterate_in_id_order_whatever_order_they_were_written_in() {
1247        let written: Vec<u32> = vec![12, 3, 7, 1, 9];
1248        let doc = Document::default()
1249            .try_apply(&Commit::new(
1250                "Created five blocks".into(),
1251                written.iter().copied().map(block_create).collect(),
1252            ))
1253            .expect("the fold succeeds");
1254        assert_ne!(
1255            written,
1256            {
1257                let mut sorted = written.clone();
1258                sorted.sort_unstable();
1259                sorted
1260            },
1261            "the fixture must write out of order or the ordering is untested"
1262        );
1263        assert_eq!(
1264            doc.blocks().map(|(id, _)| id).collect::<Vec<_>>(),
1265            [1, 3, 7, 9, 12].map(block_id),
1266        );
1267    }
1268
1269    /// A rev file is written from a document with its payloads left
1270    /// behind, and read back with the ones it references put in — so the
1271    /// pair must be a round trip, and must reach into `assets/` for
1272    /// exactly what the document points at.
1273    #[test]
1274    fn stripping_and_re_attaching_payloads_is_a_round_trip() {
1275        let held = png(b"artwork");
1276        let orphan = png(b"nobody points at this");
1277        let document = Document::default()
1278            .try_apply(&Commit::new(
1279                "Placed it".into(),
1280                vec![payload(&held), payload(&orphan), image_create(1, 0, &held)],
1281            ))
1282            .expect("the fold succeeds");
1283        assert_eq!(
1284            document.assets().count(),
1285            2,
1286            "precondition: the table holds a payload nothing references",
1287        );
1288        assert_eq!(
1289            document.referenced_assets(),
1290            std::collections::BTreeSet::from([held.hash()]),
1291        );
1292
1293        let stripped = document.without_assets();
1294        assert_eq!(stripped.assets().count(), 0, "the rev file carries bytes");
1295        assert_ne!(stripped, document);
1296
1297        let mut attached = stripped;
1298        let store = [(held.hash(), held.clone()), (orphan.hash(), orphan)];
1299        attached
1300            .attach_assets(|hash| {
1301                store
1302                    .iter()
1303                    .find(|(named, _)| *named == hash)
1304                    .map(|(_, asset)| asset.clone())
1305                    .ok_or(hash)
1306            })
1307            .expect("the store holds what the document references");
1308        assert_eq!(attached.asset(&held.hash()), Some(&held));
1309        assert_eq!(
1310            attached.assets().count(),
1311            1,
1312            "the unreferenced payload is not read back: nothing names it",
1313        );
1314    }
1315
1316    /// A reference the store cannot honour is the reader's fault to
1317    /// report, not something to leave silently missing.
1318    #[test]
1319    fn attaching_reports_the_payload_the_store_will_not_hand_back() {
1320        let held = png(b"artwork");
1321        let mut document = Document::default()
1322            .try_apply(&Commit::new(
1323                "Placed it".into(),
1324                vec![payload(&held), image_create(1, 0, &held)],
1325            ))
1326            .expect("the fold succeeds")
1327            .without_assets();
1328        assert_eq!(document.attach_assets(Err::<Asset, _>), Err(held.hash()),);
1329    }
1330
1331    /// Payload references validate against the commit's *final* view, so
1332    /// op order inside a commit is free: a pin may precede its owner's
1333    /// create. An owner the document never sees refuses the whole commit.
1334    #[test]
1335    fn a_pin_needs_its_owner_block() {
1336        let owner = block_id(1);
1337        let good = Commit::new(
1338            "Added a block with a pin".into(),
1339            vec![pin_create(2, owner), block_create(1)],
1340        );
1341        let doc = Document::default()
1342            .try_apply(&good)
1343            .expect("an owner created anywhere in the commit resolves");
1344        assert!(doc.pin(&pin_id(2)).is_some());
1345
1346        let stranger = block_id(9);
1347        let bad = Commit::new("Added an orphan pin".into(), vec![pin_create(3, stranger)]);
1348        let Err(error) = doc.try_apply(&bad) else {
1349            panic!("a pin with an unknown owner must refuse the commit");
1350        };
1351        assert_eq!(error, FoldError::InvalidPinOwner(pin_id(3), stranger));
1352        assert!(
1353            doc.pin(&pin_id(3)).is_none(),
1354            "the refused create must leave no trace"
1355        );
1356    }
1357
1358    /// The update path revalidates too: re-owning a pin to an unknown
1359    /// block is refused even though the write itself applied cleanly —
1360    /// the trial fold discards the whole successor.
1361    #[test]
1362    fn an_owner_update_to_an_unknown_block_is_refused() {
1363        let owner = block_id(1);
1364        let doc = Document::default()
1365            .try_apply(&Commit::new(
1366                "Added a block with a pin".into(),
1367                vec![block_create(1), pin_create(2, owner)],
1368            ))
1369            .expect("the fold succeeds");
1370
1371        let stranger = block_id(9);
1372        let reown = Commit::new(
1373            "Re-owned the pin".into(),
1374            vec![OpCodes::Pin(
1375                pin_id(2),
1376                Crud::Update(PinUpdate::Owner(stranger)),
1377            )],
1378        );
1379        let Err(error) = doc.try_apply(&reown) else {
1380            panic!("re-owning to an unknown block must refuse the commit");
1381        };
1382        assert_eq!(error, FoldError::InvalidPinOwner(pin_id(2), stranger));
1383        assert_eq!(
1384            doc.pin(&pin_id(2)).map(|pin| pin.owner),
1385            Some(owner),
1386            "the source document still holds the valid owner"
1387        );
1388    }
1389
1390    /// The one global check: parent writes that close a loop are refused —
1391    /// across commits, within one commit, and the self-parent degenerate.
1392    #[test]
1393    fn a_parent_cycle_is_refused() {
1394        let doc = Document::default()
1395            .try_apply(&Commit::new(
1396                "Added two blocks".into(),
1397                vec![block_create(1), block_create(2)],
1398            ))
1399            .expect("the fold succeeds");
1400
1401        let nested = doc
1402            .try_apply(&Commit::new(
1403                "Nested 1 under 2".into(),
1404                vec![reparent(1, 2)],
1405            ))
1406            .expect("a loop-free reparent lands");
1407        assert_eq!(
1408            nested.block(&block_id(1)).map(|block| block.parent),
1409            Some(block_id(2)),
1410            "the fixture must nest or the loop below tests nothing"
1411        );
1412
1413        let Err(error) =
1414            nested.try_apply(&Commit::new("Closed the loop".into(), vec![reparent(2, 1)]))
1415        else {
1416            panic!("closing a parent loop must refuse the commit");
1417        };
1418        assert_eq!(error, FoldError::BlockCycle(block_id(2)));
1419
1420        let Err(error) = doc.try_apply(&Commit::new(
1421            "Swapped parents".into(),
1422            vec![reparent(1, 2), reparent(2, 1)],
1423        )) else {
1424            panic!("a loop closed within one commit must refuse it");
1425        };
1426        assert_eq!(error, FoldError::BlockCycle(block_id(1)));
1427
1428        let Err(error) = doc.try_apply(&Commit::new("Self parent".into(), vec![reparent(1, 1)]))
1429        else {
1430            panic!("a self-parent must refuse the commit");
1431        };
1432        assert_eq!(error, FoldError::BlockCycle(block_id(1)));
1433    }
1434
1435    /// Donor: `a_batch_writing_one_register_twice_ends_on_its_last_write`.
1436    /// One commit's writes land in push order, end to end: the last one
1437    /// stands.
1438    #[test]
1439    fn a_commit_writing_one_register_twice_ends_on_its_last_write() {
1440        let commit = Commit::new(
1441            "Created and nudged a block".into(),
1442            vec![
1443                block_create(1),
1444                resize(1, rect(1, 1)),
1445                resize(1, rect(2, 2)),
1446            ],
1447        );
1448        let doc = Document::default()
1449            .try_apply(&commit)
1450            .expect("the fold succeeds");
1451
1452        let block = doc.block(&block_id(1)).expect("created");
1453        assert_eq!(block.rect, rect(2, 2));
1454    }
1455
1456    /// Donor: `concurrent_writes_converge_whichever_order_they_arrive`,
1457    /// re-based for minted revs — apply order *is* rev order now, so what
1458    /// remains provable end to end is that a later commit's write
1459    /// displaces an earlier one's while untouched fields stand.
1460    #[test]
1461    fn a_later_commits_write_displaces_an_earlier_ones() {
1462        let doc = Document::default()
1463            .try_apply(&Commit::new("Added a block".into(), vec![block_create(1)]))
1464            .expect("the fold succeeds")
1465            .try_apply(&Commit::new("Moved it".into(), vec![resize(1, rect(1, 1))]))
1466            .expect("the fold succeeds")
1467            .try_apply(&Commit::new(
1468                "Moved it again".into(),
1469                vec![resize(1, rect(2, 2))],
1470            ))
1471            .expect("the fold succeeds");
1472
1473        let block = doc.block(&block_id(1)).expect("created");
1474        assert_eq!(block.rect, rect(2, 2));
1475        assert!(!block.locked, "untouched fields keep their created value");
1476    }
1477
1478    /// Donor: `deleting_hides_an_element_but_keeps_it_restorable`. A
1479    /// delete is a removal, so the identity leaves the document with the
1480    /// value — and a second delete has nothing to remove.
1481    #[test]
1482    fn deleting_removes_an_entity_and_a_second_delete_refuses() {
1483        let deleted = block_with_pin()
1484            .try_apply(&Commit::new("Deleted the pin".into(), vec![pin_delete(2)]))
1485            .expect("the fold succeeds");
1486        assert!(deleted.pin(&pin_id(2)).is_none());
1487
1488        let Err(error) = deleted.try_apply(&Commit::new(
1489            "Deleted the pin again".into(),
1490            vec![pin_delete(2)],
1491        )) else {
1492            panic!("a delete at an absent target must refuse");
1493        };
1494        assert_eq!(error, FoldError::InvalidPinId(pin_id(2)));
1495    }
1496
1497    /// Nothing retains an inner to absorb an edit at a removed target, so
1498    /// the update refuses the whole commit — which is what makes the
1499    /// emitters' "push nothing at an absent target" a checked rule
1500    /// rather than a convention.
1501    #[test]
1502    fn an_edit_after_a_delete_refuses_the_commit() {
1503        let deleted = block_with_pin()
1504            .try_apply(&Commit::new("Deleted the pin".into(), vec![pin_delete(2)]))
1505            .expect("the fold succeeds");
1506        let Err(error) = deleted.try_apply(&Commit::new(
1507            "Renamed the pin".into(),
1508            vec![rename_pin(2, "clk")],
1509        )) else {
1510            panic!("an update at an absent target must refuse");
1511        };
1512        assert_eq!(error, FoldError::InvalidPinId(pin_id(2)));
1513    }
1514
1515    /// A delete frees nothing: the fold observes every id the log names,
1516    /// so the marks stay past a departed entity and a create can put the
1517    /// id back — which is how the journal undoes a delete.
1518    #[test]
1519    fn a_deleted_id_is_never_reissued_but_can_be_re_created() {
1520        let mut deleted = block_with_pin()
1521            .try_apply(&Commit::new("Deleted the pin".into(), vec![pin_delete(2)]))
1522            .expect("the fold succeeds");
1523        assert_ne!(deleted.mint::<crate::id::PinKind>(), pin_id(2));
1524
1525        let back = deleted
1526            .try_apply(&Commit::new(
1527                "Put the pin back".into(),
1528                vec![pin_create(2, block_id(1))],
1529            ))
1530            .expect("a create under a departed id folds");
1531        assert!(back.pin(&pin_id(2)).is_some());
1532    }
1533
1534    /// Donor: `replay_is_a_function_of_the_log_alone` +
1535    /// `replicas_agree_byte_for_byte_not_just_structurally` — the hash is
1536    /// the pivot's `state_to_bytes`. Covers the lifecycle ops too.
1537    #[test]
1538    fn replay_is_a_function_of_the_log_alone() {
1539        let log = [
1540            Commit::new(
1541                "Added a block with a pin".into(),
1542                vec![block_create(1), pin_create(2, block_id(1))],
1543            ),
1544            Commit::new("Renamed the pin".into(), vec![rename_pin(2, "clk")]),
1545            Commit::new("Deleted the pin".into(), vec![pin_delete(2)]),
1546        ];
1547        let replay = || {
1548            log.iter()
1549                .try_fold(Document::default(), |doc, commit| doc.try_apply(commit))
1550        };
1551
1552        let one = replay().expect("the fold succeeds");
1553        let other = replay().expect("the fold succeeds");
1554        assert_eq!(
1555            one.rev(),
1556            Rev::new(3),
1557            "the fixture must fold the whole log"
1558        );
1559        assert_eq!(one, other);
1560    }
1561
1562    #[test]
1563    fn the_index_covers_every_kind_and_the_containment_tree() {
1564        let doc = wired_document();
1565        let index = DocIndex::of(&doc);
1566
1567        assert_eq!(
1568            index.blocks[&BlockId::NULL].children,
1569            [block_id(1)].into_iter().collect()
1570        );
1571        let entry = &index.blocks[&block_id(1)];
1572        assert_eq!(entry.children, [block_id(2)].into_iter().collect());
1573        assert_eq!(entry.pins, [pin_id(3), pin_id(4)].into_iter().collect());
1574        assert_eq!(entry.routes, [route_id(5)].into_iter().collect());
1575        assert_eq!(entry.texts, [text_id(7)].into_iter().collect());
1576        assert_eq!(entry.areas, [area_id(8)].into_iter().collect());
1577        assert_eq!(entry.images, [image_id(9)].into_iter().collect());
1578        assert_eq!(
1579            index.blocks[&block_id(2)],
1580            BlockIndex::default(),
1581            "the nested block owns nothing"
1582        );
1583        assert_eq!(
1584            index.routes[&route_id(5)].labels,
1585            [route_label_id(6)].into_iter().collect()
1586        );
1587    }
1588
1589    /// Every way a distinct document value is born mints, and only
1590    /// `Clone` — which produces no new value — carries.
1591    #[test]
1592    fn a_born_document_value_mints_a_stamp_and_a_clone_carries_it() {
1593        let empty = Document::default();
1594        assert_ne!(
1595            empty.stamp,
1596            Document::default().stamp,
1597            "two empty documents are two values"
1598        );
1599        assert_eq!(empty.stamp, empty.clone().stamp);
1600
1601        let folded = empty
1602            .try_apply(&rename("drawing"))
1603            .expect("the fold succeeds");
1604        assert_ne!(empty.stamp, folded.stamp, "a fold mints");
1605    }
1606
1607    /// The stamp exists to stop `view` from walking a document it has
1608    /// already indexed. Doctoring the index is the probe: a rebuild is
1609    /// precisely what erases the sentinel.
1610    #[test]
1611    fn view_rebuilds_for_a_new_document_value_and_not_otherwise() {
1612        let doc = wired_document();
1613        let mut index = DocIndex::of(&doc);
1614        let sentinel = block_id(200);
1615        let root = |index: &DocIndex| index.blocks[&BlockId::NULL].children.clone();
1616        assert!(
1617            !root(&index).contains(&sentinel),
1618            "the sentinel must not be a real entry or the probe proves nothing"
1619        );
1620        let plant = |index: &mut DocIndex| {
1621            index
1622                .blocks
1623                .entry(BlockId::NULL)
1624                .or_default()
1625                .children
1626                .insert(sentinel);
1627        };
1628        plant(&mut index);
1629
1630        assert!(
1631            root(index.view(&doc).index).contains(&sentinel),
1632            "one document value is indexed once"
1633        );
1634        assert!(
1635            root(index.view(&doc.clone()).index).contains(&sentinel),
1636            "a clone is the same value"
1637        );
1638
1639        plant(&mut index);
1640        let folded = doc
1641            .try_apply(&Commit::new("Added b20".into(), vec![block_create(20)]))
1642            .expect("the fold succeeds");
1643        let view = index.view(&folded);
1644        assert!(
1645            !root(view.index).contains(&sentinel),
1646            "a folded document is a new value"
1647        );
1648        assert!(root(view.index).contains(&block_id(20)));
1649    }
1650
1651    /// The root is a scope like any other. Its row carries the top-level
1652    /// blocks as children and every entity owned by the root directly, so a
1653    /// scope read never needs a root special case — and it is still not a
1654    /// *block*, which is the question deletes and moves ask.
1655    #[test]
1656    fn the_root_is_a_scope_row_but_not_a_block() {
1657        let doc = Document::default()
1658            .try_apply(&Commit::new(
1659                "Authored at the root".into(),
1660                vec![
1661                    block_create(1),
1662                    pin_create(3, BlockId::NULL),
1663                    pin_create(4, BlockId::NULL),
1664                    OpCodes::Route(
1665                        route_id(5),
1666                        Crud::Create(Route {
1667                            owner: BlockId::NULL,
1668                            name: "r5".into(),
1669                            from: pin_id(3),
1670                            to: pin_id(4),
1671                            role: Role::default(),
1672                            waypoints: Vec::new(),
1673                        }),
1674                    ),
1675                    OpCodes::Text(
1676                        text_id(7),
1677                        Crud::Create(Text {
1678                            owner: BlockId::NULL,
1679                            text: "note".into(),
1680                            pos: GridPoint::default(),
1681                            role: Role::default(),
1682                            width: None,
1683                        }),
1684                    ),
1685                    OpCodes::Area(
1686                        area_id(8),
1687                        Crud::Create(Area {
1688                            owner: BlockId::NULL,
1689                            rect: GridRect::default(),
1690                            role: Role::default(),
1691                            title: label_init("c8".into()),
1692                        }),
1693                    ),
1694                    OpCodes::Image(
1695                        image_id(9),
1696                        Crud::Create(Image {
1697                            owner: BlockId::NULL,
1698                            asset: AssetHash::default(),
1699                            rect: ScreenRect::default(),
1700                        }),
1701                    ),
1702                ],
1703            ))
1704            .expect("the root accepts scoped entities");
1705        let index = DocIndex::of(&doc);
1706
1707        let root = index.scope(BlockId::NULL).expect("the root is a scope");
1708        assert_eq!(root.children, [block_id(1)].into_iter().collect());
1709        assert_eq!(root.pins, [pin_id(3), pin_id(4)].into_iter().collect());
1710        assert_eq!(root.routes, [route_id(5)].into_iter().collect());
1711        assert_eq!(root.texts, [text_id(7)].into_iter().collect());
1712        assert_eq!(root.areas, [area_id(8)].into_iter().collect());
1713        assert_eq!(root.images, [image_id(9)].into_iter().collect());
1714
1715        assert!(index.holds_block(block_id(1)));
1716        assert!(
1717            !index.holds_block(BlockId::NULL),
1718            "the root has no entity to hold"
1719        );
1720        assert_eq!(
1721            index.scope(block_id(1)),
1722            Some(&BlockIndex::default()),
1723            "the block owns nothing — the root does"
1724        );
1725    }
1726
1727    /// The other half of the root being a scope: the fold accepts `NULL` as
1728    /// an owner wherever it accepts a block, and still refuses an owner that
1729    /// is neither.
1730    #[test]
1731    fn the_fold_accepts_the_root_as_an_owner_and_refuses_a_stranger() {
1732        let stranger = block_id(9);
1733        let doc = Document::default();
1734
1735        doc.try_apply(&Commit::new(
1736            "Pinned the root".into(),
1737            vec![pin_create(3, BlockId::NULL)],
1738        ))
1739        .expect("the root owns pins");
1740
1741        let Err(error) = doc.try_apply(&Commit::new(
1742            "Pinned a stranger".into(),
1743            vec![pin_create(3, stranger)],
1744        )) else {
1745            panic!("an owner that is neither a block nor the root must refuse");
1746        };
1747        assert_eq!(error, FoldError::InvalidPinOwner(pin_id(3), stranger));
1748    }
1749
1750    /// Donor: `state.rs` live-children-only — a deleted child leaves its
1751    /// parent's sets, and a deleted element has no index entry.
1752    #[test]
1753    fn a_deleted_child_leaves_its_parents_sets() {
1754        let doc = wired_document()
1755            .try_apply(&Commit::new(
1756                "Deleted a pin and the nested block".into(),
1757                vec![
1758                    OpCodes::Route(route_id(5), Crud::Delete),
1759                    OpCodes::RouteLabel(route_label_id(6), Crud::Delete),
1760                    pin_delete(4),
1761                    OpCodes::Block(block_id(2), Crud::Delete),
1762                ],
1763            ))
1764            .expect("the fold succeeds");
1765        let index = DocIndex::of(&doc);
1766
1767        let entry = &index.blocks[&block_id(1)];
1768        assert_eq!(
1769            entry.pins,
1770            [pin_id(3)].into_iter().collect(),
1771            "a deleted pin leaves the owner's set"
1772        );
1773        assert!(
1774            entry.children.is_empty(),
1775            "a deleted child leaves the parent's list"
1776        );
1777        assert!(
1778            !index.blocks.contains_key(&block_id(2)),
1779            "no entries for deleted elements"
1780        );
1781    }
1782
1783    /// Endpoint integrity is structural now that a delete removes: a
1784    /// commit that takes a pin out from under a route leaves a dangling
1785    /// endpoint, and the fold refuses the whole commit rather than
1786    /// suppressing the route. The delete cascade emits every dependent
1787    /// delete itself, which is what keeps this refusal unreachable in
1788    /// practice.
1789    #[test]
1790    fn deleting_an_endpoint_without_its_route_refuses_the_commit() {
1791        let doc = wired_document();
1792        assert!(
1793            doc.route(&route_id(5)).is_some_and(|r| r.from == pin_id(3)),
1794            "precondition: the fixture's route hangs off the pin being deleted"
1795        );
1796
1797        let Err(error) = doc.try_apply(&Commit::new(
1798            "Deleted an endpoint".into(),
1799            vec![pin_delete(3)],
1800        )) else {
1801            panic!("a dangling endpoint must refuse the commit");
1802        };
1803        assert_eq!(error, FoldError::InvalidRouteFrom(route_id(5), pin_id(3)));
1804
1805        doc.try_apply(&Commit::new(
1806            "Deleted an endpoint and its route".into(),
1807            vec![
1808                OpCodes::RouteLabel(route_label_id(6), Crud::Delete),
1809                OpCodes::Route(route_id(5), Crud::Delete),
1810                pin_delete(3),
1811            ],
1812        ))
1813        .expect("the cascade folds");
1814    }
1815
1816    /// The oracle is a brute-force scan, so the index cannot inherit a
1817    /// bug from the code it checks. Covers the subtle row: a deleted
1818    /// route leaves the index entirely.
1819    #[test]
1820    fn routes_by_endpoint_agrees_with_a_brute_force_scan() {
1821        let doc = wired_document()
1822            .try_apply(&Commit::new(
1823                "Added a second route and killed the first".into(),
1824                vec![
1825                    route_create(10, 1, 3, 4),
1826                    OpCodes::RouteLabel(route_label_id(6), Crud::Delete),
1827                    OpCodes::Route(route_id(5), Crud::Delete),
1828                ],
1829            ))
1830            .expect("the fold succeeds");
1831        let index = DocIndex::of(&doc);
1832
1833        let mut oracle: HashMap<PinId, HashSet<RouteId>> = HashMap::default();
1834        for (id, route) in doc.routes() {
1835            for endpoint in [route.from, route.to] {
1836                oracle.entry(endpoint).or_default().insert(id);
1837            }
1838        }
1839        assert_eq!(index.routes_by_endpoint, oracle);
1840
1841        assert!(
1842            index.routes_by_endpoint[&pin_id(3)].contains(&route_id(10)),
1843            "a route is indexed under both its endpoints"
1844        );
1845        assert!(
1846            !index
1847                .routes_by_endpoint
1848                .values()
1849                .any(|routes| routes.contains(&route_id(5))),
1850            "a deleted route leaves the index"
1851        );
1852    }
1853
1854    /// The one order every consumer shares: ascending id, which is
1855    /// creation order because ids are monotonic. Touching a block does not
1856    /// move it, so the fixture edits the oldest and it stays beneath its
1857    /// neighbours.
1858    #[test]
1859    fn chronological_orders_by_id_and_a_touch_does_not_move_it() {
1860        let mut doc = Document::default();
1861        for n in [1, 2, 3] {
1862            doc = doc
1863                .try_apply(&Commit::new(format!("Added b{n}"), vec![block_create(n)]))
1864                .expect("the create folds");
1865        }
1866        doc = doc
1867            .try_apply(&Commit::new("Moved b1".into(), vec![resize(1, rect(9, 9))]))
1868            .expect("the move folds");
1869        assert_eq!(
1870            chronological(doc.blocks()),
1871            vec![block_id(1), block_id(2), block_id(3)],
1872            "the touched block stays where its id puts it"
1873        );
1874    }
1875
1876    fn png(bytes: &[u8]) -> Asset {
1877        Asset::Png(bytes.into())
1878    }
1879
1880    fn payload(asset: &Asset) -> OpCodes {
1881        OpCodes::Asset(asset.hash(), asset.clone())
1882    }
1883
1884    fn image_create(n: u32, owner: u32, asset: &Asset) -> OpCodes {
1885        OpCodes::Image(
1886            image_id(n),
1887            Crud::Create(Image {
1888                owner: block_id(owner),
1889                asset: asset.hash(),
1890                rect: ScreenRect::default(),
1891            }),
1892        )
1893    }
1894
1895    /// The payload arrives keyed by its own hash, and the bytes come back
1896    /// out under it. No liveness, no order — just held or not held.
1897    #[test]
1898    fn an_asset_payload_folds_into_the_table_under_its_hash() {
1899        let asset = png(b"artwork");
1900        let doc = Document::default()
1901            .try_apply(&Commit::new(
1902                "Placed an image".into(),
1903                vec![payload(&asset), block_create(1), image_create(9, 1, &asset)],
1904            ))
1905            .expect("the fold succeeds");
1906
1907        assert_eq!(doc.asset(&asset.hash()), Some(&asset));
1908        assert_eq!(
1909            doc.asset(&png(b"other artwork").hash()),
1910            None,
1911            "an unheld hash reads as absent, not as some other payload"
1912        );
1913        assert_eq!(
1914            doc.image(&image_id(9)).expect("the image is held").asset,
1915            asset.hash(),
1916            "the entity carries the reference, the table carries the bytes"
1917        );
1918    }
1919
1920    /// Idempotence is structural rather than checked: one hash can only
1921    /// name one byte string, so a duplicate delivery — a replay, a second
1922    /// placement of the same file — has nothing to conflict with.
1923    #[test]
1924    fn a_duplicate_payload_is_a_no_op_not_a_refusal() {
1925        let asset = png(b"artwork");
1926        let once = Document::default()
1927            .try_apply(&Commit::new("Placed it".into(), vec![payload(&asset)]))
1928            .expect("the fold succeeds");
1929        let twice = once
1930            .try_apply(&Commit::new(
1931                "Placed it again".into(),
1932                vec![payload(&asset)],
1933            ))
1934            .expect("a duplicate payload folds rather than refusing");
1935
1936        assert_eq!(twice.asset(&asset.hash()), Some(&asset));
1937        assert_eq!(
1938            twice.assets().count(),
1939            1,
1940            "the table holds one entry per hash, however often it arrives"
1941        );
1942    }
1943
1944    /// The trust boundary: the payload and its key must agree, or one hash
1945    /// names two byte strings and every replica's content addressing is a
1946    /// lie. Refused, never re-keyed to the hash the bytes actually have.
1947    #[test]
1948    fn a_payload_that_does_not_hash_to_its_key_is_refused() {
1949        let asset = png(b"artwork");
1950        let forged = png(b"different artwork").hash();
1951        assert_ne!(
1952            forged,
1953            asset.hash(),
1954            "precondition: the key must name other bytes or nothing is tested"
1955        );
1956
1957        let doc = Document::default();
1958        let Err(error) = doc.try_apply(&Commit::new(
1959            "Forged a payload".into(),
1960            vec![OpCodes::Asset(forged, asset)],
1961        )) else {
1962            panic!("a payload that does not hash to its key must refuse the commit");
1963        };
1964        assert_eq!(error, FoldError::AssetHashMismatch(forged));
1965    }
1966
1967    /// The log is replayed in full on every open, so an unbounded payload
1968    /// is one the document carries forever.
1969    /// The format is the model: a document survives the trip through its own
1970    /// JSON, and a parsed one comes back stamped and marked as a folded one
1971    /// is — the file carries neither.
1972    #[test]
1973    fn a_document_round_trips_through_json_and_the_marks_come_back() {
1974        let doc = wired_document();
1975        let text = serde_json::to_string(&doc).expect("a document serializes");
1976        let read: Document = serde_json::from_str(&text).expect("and parses back");
1977
1978        assert_eq!(read, doc);
1979        assert_ne!(read.stamp(), doc.stamp(), "a parsed value is its own");
1980        assert_eq!(read.rev(), Rev::ZERO, "a file names no log position");
1981        assert_eq!(
1982            read.ids().mint::<crate::id::BlockKind>(),
1983            doc.clone().ids().mint::<crate::id::BlockKind>(),
1984            "the marks are derived from what the file holds",
1985        );
1986    }
1987
1988    /// The newer-build guard survives the schema's deletion: a file from the
1989    /// future is refused rather than half-read.
1990    #[test]
1991    fn a_version_from_the_future_is_refused() {
1992        let text = serde_json::to_string(&Document::default()).expect("it serializes");
1993        assert!(text.contains(&format!(r#""version":{CURRENT_VERSION}"#)));
1994        let ahead = text.replace(
1995            &format!(r#""version":{CURRENT_VERSION}"#),
1996            &format!(r#""version":{}"#, CURRENT_VERSION + 1),
1997        );
1998        let refusal = serde_json::from_str::<Document>(&ahead).expect_err("it is refused");
1999        assert!(
2000            refusal.to_string().contains("newer than this build reads"),
2001            "{refusal}"
2002        );
2003    }
2004
2005    #[test]
2006    fn a_payload_over_the_limit_is_refused() {
2007        let oversized = png(&vec![0u8; crate::block_model::ASSET_LIMIT + 1]);
2008        let hash = oversized.hash();
2009        let doc = Document::default();
2010
2011        let Err(error) = doc.try_apply(&Commit::new(
2012            "Committed a photograph".into(),
2013            vec![OpCodes::Asset(hash, oversized)],
2014        )) else {
2015            panic!("a payload over the limit must refuse the commit");
2016        };
2017        assert_eq!(
2018            error,
2019            FoldError::AssetTooLarge(hash, crate::block_model::ASSET_LIMIT + 1)
2020        );
2021
2022        // The bound is inclusive: a payload exactly at the limit is fine, so
2023        // the check cannot be an off-by-one that refuses what it advertises.
2024        let exact = png(&vec![0u8; crate::block_model::ASSET_LIMIT]);
2025        assert!(
2026            doc.try_apply(&Commit::new(
2027                "Committed the largest allowed artwork".into(),
2028                vec![OpCodes::Asset(exact.hash(), exact)],
2029            ))
2030            .is_ok(),
2031            "a payload exactly at the limit must fold",
2032        );
2033    }
2034
2035    /// The stamp a folded payload canonically encodes to. It is a ciborium
2036    /// encoding and `Asset`'s serde impl is format-aware, so a change to the
2037    /// *text* spelling of a payload must leave this untouched — which is
2038    /// Loading is folding, so the marks a load leaves are the marks the
2039    /// fold raised: the next id of a kind is one past the highest the
2040    /// document has ever held.
2041    #[test]
2042    fn the_next_id_is_one_past_the_highest_the_fold_saw() {
2043        let mut document = wired_document();
2044        assert!(
2045            document.block(&block_id(2)).is_some() && document.block(&block_id(3)).is_none(),
2046            "precondition: the fixture's blocks run out at b2",
2047        );
2048        assert_eq!(document.mint::<crate::id::BlockKind>(), block_id(3));
2049        assert_eq!(document.mint::<crate::id::PinKind>(), pin_id(5));
2050        assert_eq!(
2051            document.mint::<crate::id::TextKind>(),
2052            text_id(8),
2053            "each kind counts in its own space",
2054        );
2055    }
2056
2057    /// The fold observes every id a commit names, so a delete cannot hand
2058    /// the departed id to something else.
2059    #[test]
2060    fn a_deleted_entity_does_not_free_its_id() {
2061        let document = wired_document()
2062            .try_apply(&Commit::new(
2063                "Deleted a block".into(),
2064                vec![OpCodes::Block(block_id(2), Crud::Delete)],
2065            ))
2066            .expect("the delete folds");
2067        assert!(
2068            document.block(&block_id(2)).is_none(),
2069            "precondition: b2 is gone from the document",
2070        );
2071        let mut ids = document.ids();
2072        assert_eq!(ids.mint::<crate::id::BlockKind>(), block_id(3));
2073    }
2074
2075    /// A replica holding every reference but not the bytes has not folded
2076    /// the same log, and the hash that proves fold agreement must say so.
2077    #[test]
2078    fn a_document_is_its_asset_table_too() {
2079        let fold_payload = |asset: &Asset| {
2080            Document::default()
2081                .try_apply(&Commit::new("Placed it".into(), vec![payload(asset)]))
2082                .expect("the fold succeeds")
2083        };
2084        let one = fold_payload(&png(b"artwork"));
2085        let other = fold_payload(&png(b"other artwork"));
2086
2087        assert_eq!(one.rev(), other.rev(), "precondition: same log position");
2088        assert_ne!(one, other);
2089    }
2090
2091    /// Z-order is id order: two entities whose values are identical still
2092    /// order by the ids they were minted under, and nothing about how
2093    /// recently either was touched enters into it.
2094    #[test]
2095    fn chronological_orders_by_id_alone() {
2096        let block = Block {
2097            title: label_init("tied".into()),
2098            ..Block::default()
2099        };
2100        let touched = Block {
2101            rect: rect(9, 9),
2102            ..block.clone()
2103        };
2104        assert_eq!(
2105            chronological([(block_id(2), &block), (block_id(1), &touched)].into_iter()),
2106            vec![block_id(1), block_id(2)],
2107        );
2108    }
2109}