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}