Skip to main content

crdt_doc/
lib.rs

1//! crdt-doc — the typed CRDT-document border for Saber (SABER.md §5).
2//!
3//! A verbete body is ALWAYS a `CrdtDoc` by construction (solo = a one-replica
4//! CRDT). M0a is deliberately degenerate: the ONLY realized engine is
5//! [`SoloDoc`] (single writer, no concurrent merge). [`CrdtKind::Loro`] and
6//! [`CrdtKind::YCrdt`] are typed-and-named but UNBUILT — constructing one
7//! returns [`SpecError::Unimplemented`], never a silent `Ok`, never a
8//! `todo!()`/`panic!()` (★★ UNREPRESENTABILITY / no-stub-Ok; ★★ TYPED-SPEC
9//! triplet "every unimplemented surface returns a typed error so consumers see
10//! the gap mechanically").
11//!
12//! The seam-clean rule (SABER §5): `Edit` is the local-author border,
13//! `CrdtUpdate` is the wire border, `materialize()→Rope` is the render border —
14//! never crossed raw. At M0a only `materialize()` is realized; `local_edit` /
15//! `apply_update` are the M2 wire borders and return `SpecError` for non-Solo
16//! kinds.
17#![forbid(unsafe_code)]
18
19use ropey::Rope;
20use serde::{Deserialize, Serialize};
21
22/// The typed error surface for the border. Every unimplemented engine arm
23/// returns one of these (no `format!()` — `thiserror`'s `#[error]` is the typed
24/// emission surface, allowed surface #2).
25#[derive(Debug, thiserror::Error)]
26pub enum SpecError {
27    /// A `CrdtKind` arm has a type but no realized interpreter yet (M2 work).
28    /// Carries the kind so a consumer sees exactly which engine is missing.
29    #[error("crdt engine not implemented at M0a: {kind:?} (lands at M2 behind the convergence gate)")]
30    Unimplemented { kind: CrdtKind },
31    /// A wire operation (`local_edit`/`apply_update`) was attempted on the M0a
32    /// degenerate doc, which has no wire form.
33    #[error("operation '{op}' has no wire form on the M0a single-writer SoloDoc")]
34    NoWireForm { op: &'static str },
35}
36
37/// The CRDT engine selector (SABER §2 `CrdtKind`). `Solo` is the M0a
38/// degenerate; `Loro` the M2 primary; `YCrdt` the M2 interop arm.
39#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum CrdtKind {
42    /// The M0a one-replica degenerate (single writer; no concurrent merge).
43    Solo,
44    /// loro — the M2 primary co-editing engine (UNBUILT at M0a).
45    Loro,
46    /// y-crdt (yrs) — the M2 interop arm (UNBUILT at M0a).
47    YCrdt,
48}
49
50/// The wire delta exchanged between replicas (SABER §2 `CrdtUpdate`). At M0a
51/// this is an opaque byte vector with no producer — the type exists so the M2
52/// `local_edit`/`apply_update` signatures are stable now.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct CrdtUpdate(pub Vec<u8>);
55
56/// The CRDT-document border (SABER §5). A verbete body is ALWAYS one.
57///
58/// At M0a only [`CrdtDoc::materialize`] is realized. `local_edit`/`apply_update`
59/// are the M2 wire borders; the default impls return a typed [`SpecError`] so a
60/// caller that reaches for them before M2 sees the gap mechanically.
61pub trait CrdtDoc: Send + Sync {
62    /// The current materialized text the renderer consumes (SABER §5 render
63    /// border). Realized at M0a.
64    fn materialize(&self) -> Rope;
65
66    /// The engine kind backing this doc.
67    fn kind(&self) -> CrdtKind;
68
69    /// Apply a wire delta from another replica (SABER §2 `CrdtUpdate`).
70    /// M2 work — degenerate docs have no wire form.
71    ///
72    /// # Errors
73    /// Always [`SpecError::NoWireForm`] at M0a for the [`SoloDoc`].
74    fn apply_update(&mut self, _update: &CrdtUpdate) -> Result<(), SpecError> {
75        Err(SpecError::NoWireForm { op: "apply_update" })
76    }
77}
78
79/// The single-writer degenerate document (M0a). Holds the body text directly —
80/// no merge, no concurrent edits. A loro `CrdtDoc` replaces it at M2 behind the
81/// same trait.
82pub struct SoloDoc {
83    text: Rope,
84}
85
86impl SoloDoc {
87    /// Build a solo doc from materialized body bytes.
88    #[must_use]
89    pub fn from_str(body: &str) -> Self {
90        Self {
91            text: Rope::from_str(body),
92        }
93    }
94}
95
96impl CrdtDoc for SoloDoc {
97    fn materialize(&self) -> Rope {
98        self.text.clone()
99    }
100
101    fn kind(&self) -> CrdtKind {
102        CrdtKind::Solo
103    }
104}
105
106/// The typed dispatch over `CrdtKind` (SABER §2 "the dispatch tag"). M0a builds
107/// only `Solo`; `Loro`/`YCrdt` return [`SpecError::Unimplemented`] — the
108/// typed-but-degenerate border the M0a spec mandates.
109///
110/// # Errors
111/// [`SpecError::Unimplemented`] for every non-`Solo` kind until M2.
112pub fn open_doc(kind: CrdtKind, body: &str) -> Result<Box<dyn CrdtDoc>, SpecError> {
113    match kind {
114        CrdtKind::Solo => Ok(Box::new(SoloDoc::from_str(body))),
115        // Typed, named, UNBUILT — a typed error, never a silent Ok.
116        CrdtKind::Loro | CrdtKind::YCrdt => Err(SpecError::Unimplemented { kind }),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn solo_materializes_its_body() {
126        let doc = open_doc(CrdtKind::Solo, "# hello\nworld").expect("solo opens");
127        assert_eq!(doc.materialize().to_string(), "# hello\nworld");
128        assert_eq!(doc.kind(), CrdtKind::Solo);
129    }
130
131    #[test]
132    fn loro_is_typed_unimplemented_not_silent_ok() {
133        // `Box<dyn CrdtDoc>` is not `Debug`, so match the Result directly
134        // rather than `expect_err` (which would require Debug on the Ok type).
135        assert!(matches!(
136            open_doc(CrdtKind::Loro, "x"),
137            Err(SpecError::Unimplemented { kind: CrdtKind::Loro })
138        ));
139    }
140
141    #[test]
142    fn ycrdt_is_typed_unimplemented() {
143        assert!(matches!(
144            open_doc(CrdtKind::YCrdt, "x"),
145            Err(SpecError::Unimplemented { kind: CrdtKind::YCrdt })
146        ));
147    }
148
149    #[test]
150    fn solo_has_no_wire_form() {
151        let mut doc = SoloDoc::from_str("x");
152        assert!(matches!(
153            doc.apply_update(&CrdtUpdate(vec![])),
154            Err(SpecError::NoWireForm { op: "apply_update" })
155        ));
156    }
157
158    // The degenerate (one-replica) case of the M2 convergence forcing-function
159    // (SABER §9 risk #1): for a solo doc, materialize() round-trips its body
160    // losslessly for arbitrary input. At M2 this generalizes to "two replicas,
161    // interleaved edits → identical materialize()", the no-clobber merge gate.
162    proptest::proptest! {
163        #[test]
164        fn solo_materialize_round_trips_arbitrary_body(body in ".*") {
165            let doc = open_doc(CrdtKind::Solo, &body).expect("solo opens");
166            proptest::prop_assert_eq!(doc.materialize().to_string(), body);
167        }
168    }
169}