Skip to main content

mdcs_sdk/
document.rs

1//! Document wrappers for collaborative editing.
2
3use mdcs_core::lattice::Lattice;
4use mdcs_db::{
5    json_crdt::{JsonCrdt, JsonPath, JsonValue},
6    rga_text::RGAText,
7    rich_text::{MarkType, RichText},
8};
9use tokio::sync::broadcast;
10
11/// Events emitted when a document changes.
12#[derive(Clone, Debug)]
13pub enum DocEvent {
14    /// Text was inserted.
15    Insert { position: usize, text: String },
16    /// Text was deleted.
17    Delete { position: usize, length: usize },
18    /// Remote changes were applied.
19    RemoteUpdate,
20}
21
22/// Trait for collaborative documents.
23pub trait CollaborativeDoc {
24    /// Return the stable document identifier.
25    fn id(&self) -> &str;
26
27    /// Return the local replica identifier that owns this document handle.
28    fn replica_id(&self) -> &str;
29
30    /// Subscribe to document change events.
31    ///
32    /// Subscribers receive only future events from the time of subscription.
33    fn subscribe(&self) -> broadcast::Receiver<DocEvent>;
34
35    /// Drain pending deltas to send through your sync transport.
36    fn take_pending_deltas(&mut self) -> Vec<Vec<u8>>;
37
38    /// Apply a serialized remote delta payload.
39    fn apply_remote(&mut self, delta: &[u8]);
40}
41
42/// A collaborative plain text document.
43#[derive(Clone)]
44pub struct TextDoc {
45    id: String,
46    replica_id: String,
47    text: RGAText,
48    #[allow(dead_code)]
49    event_tx: broadcast::Sender<DocEvent>,
50    pending_deltas: Vec<Vec<u8>>,
51}
52
53impl TextDoc {
54    /// Create a new plain-text CRDT document.
55    pub fn new(id: impl Into<String>, replica_id: impl Into<String>) -> Self {
56        let replica_id = replica_id.into();
57        let (event_tx, _) = broadcast::channel(100);
58
59        Self {
60            id: id.into(),
61            replica_id: replica_id.clone(),
62            text: RGAText::new(&replica_id),
63            event_tx,
64            pending_deltas: Vec::new(),
65        }
66    }
67
68    /// Insert UTF-8 text at a character position.
69    pub fn insert(&mut self, position: usize, text: &str) {
70        self.text.insert(position, text);
71        let _ = self.event_tx.send(DocEvent::Insert {
72            position,
73            text: text.to_string(),
74        });
75    }
76
77    /// Delete `length` characters starting at `position`.
78    pub fn delete(&mut self, position: usize, length: usize) {
79        self.text.delete(position, length);
80        let _ = self.event_tx.send(DocEvent::Delete { position, length });
81    }
82
83    /// Return the current plain-text content.
84    pub fn get_text(&self) -> String {
85        self.text.to_string()
86    }
87
88    /// Return the current text length.
89    pub fn len(&self) -> usize {
90        self.text.len()
91    }
92
93    /// Return `true` when the document contains no characters.
94    pub fn is_empty(&self) -> bool {
95        self.text.is_empty()
96    }
97
98    /// Merge another replica state into this document.
99    ///
100    /// This is a commutative CRDT join, so order does not affect convergence.
101    pub fn merge(&mut self, other: &TextDoc) {
102        self.text = self.text.join(&other.text);
103        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
104    }
105
106    /// Clone this document's state for synchronization.
107    ///
108    /// The returned state clears local pending delta buffers.
109    pub fn clone_state(&self) -> TextDoc {
110        TextDoc {
111            id: self.id.clone(),
112            replica_id: self.replica_id.clone(),
113            text: self.text.clone(),
114            event_tx: self.event_tx.clone(),
115            pending_deltas: Vec::new(),
116        }
117    }
118}
119
120impl CollaborativeDoc for TextDoc {
121    fn id(&self) -> &str {
122        &self.id
123    }
124
125    fn replica_id(&self) -> &str {
126        &self.replica_id
127    }
128
129    fn subscribe(&self) -> broadcast::Receiver<DocEvent> {
130        self.event_tx.subscribe()
131    }
132
133    fn take_pending_deltas(&mut self) -> Vec<Vec<u8>> {
134        std::mem::take(&mut self.pending_deltas)
135    }
136
137    fn apply_remote(&mut self, _delta: &[u8]) {
138        // In a real implementation, deserialize and apply delta
139        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
140    }
141}
142
143/// A collaborative rich text document with formatting.
144#[derive(Clone)]
145pub struct RichTextDoc {
146    id: String,
147    replica_id: String,
148    text: RichText,
149    #[allow(dead_code)]
150    event_tx: broadcast::Sender<DocEvent>,
151    pending_deltas: Vec<Vec<u8>>,
152}
153
154impl RichTextDoc {
155    /// Create a new rich-text CRDT document.
156    pub fn new(id: impl Into<String>, replica_id: impl Into<String>) -> Self {
157        let replica_id = replica_id.into();
158        let (event_tx, _) = broadcast::channel(100);
159
160        Self {
161            id: id.into(),
162            replica_id: replica_id.clone(),
163            text: RichText::new(&replica_id),
164            event_tx,
165            pending_deltas: Vec::new(),
166        }
167    }
168
169    /// Insert UTF-8 text at a character position.
170    pub fn insert(&mut self, position: usize, text: &str) {
171        self.text.insert(position, text);
172        let _ = self.event_tx.send(DocEvent::Insert {
173            position,
174            text: text.to_string(),
175        });
176    }
177
178    /// Delete `length` characters starting at `position`.
179    pub fn delete(&mut self, position: usize, length: usize) {
180        self.text.delete(position, length);
181        let _ = self.event_tx.send(DocEvent::Delete { position, length });
182    }
183
184    /// Apply a formatting mark to the range `[start, end)`.
185    pub fn format(&mut self, start: usize, end: usize, mark: MarkType) {
186        self.text.add_mark(start, end, mark);
187    }
188
189    /// Remove a formatting mark by its unique mark ID.
190    pub fn unformat_by_id(&mut self, mark_id: &mdcs_db::rich_text::MarkId) {
191        self.text.remove_mark(mark_id);
192    }
193
194    /// Return the plain-text projection of the rich document.
195    pub fn get_text(&self) -> String {
196        self.text.to_string()
197    }
198
199    /// Return a plain-text rendering of the content.
200    ///
201    /// For full mark spans and metadata, use the underlying `RichText` model.
202    pub fn get_content(&self) -> String {
203        self.text.to_string()
204    }
205
206    /// Return the current text length.
207    pub fn len(&self) -> usize {
208        self.text.len()
209    }
210
211    /// Return `true` when the document contains no characters.
212    pub fn is_empty(&self) -> bool {
213        self.text.is_empty()
214    }
215
216    /// Merge another replica state into this document.
217    ///
218    /// This is a commutative CRDT join, so order does not affect convergence.
219    pub fn merge(&mut self, other: &RichTextDoc) {
220        self.text = self.text.join(&other.text);
221        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
222    }
223
224    /// Clone this document's state for synchronization.
225    ///
226    /// The returned state clears local pending delta buffers.
227    pub fn clone_state(&self) -> RichTextDoc {
228        RichTextDoc {
229            id: self.id.clone(),
230            replica_id: self.replica_id.clone(),
231            text: self.text.clone(),
232            event_tx: self.event_tx.clone(),
233            pending_deltas: Vec::new(),
234        }
235    }
236}
237
238impl CollaborativeDoc for RichTextDoc {
239    fn id(&self) -> &str {
240        &self.id
241    }
242
243    fn replica_id(&self) -> &str {
244        &self.replica_id
245    }
246
247    fn subscribe(&self) -> broadcast::Receiver<DocEvent> {
248        self.event_tx.subscribe()
249    }
250
251    fn take_pending_deltas(&mut self) -> Vec<Vec<u8>> {
252        std::mem::take(&mut self.pending_deltas)
253    }
254
255    fn apply_remote(&mut self, _delta: &[u8]) {
256        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
257    }
258}
259
260/// A collaborative JSON document.
261#[derive(Clone)]
262pub struct JsonDoc {
263    id: String,
264    replica_id: String,
265    doc: JsonCrdt,
266    #[allow(dead_code)]
267    event_tx: broadcast::Sender<DocEvent>,
268    pending_deltas: Vec<Vec<u8>>,
269}
270
271impl JsonDoc {
272    /// Create a new JSON CRDT document.
273    pub fn new(id: impl Into<String>, replica_id: impl Into<String>) -> Self {
274        let replica_id = replica_id.into();
275        let (event_tx, _) = broadcast::channel(100);
276
277        Self {
278            id: id.into(),
279            replica_id: replica_id.clone(),
280            doc: JsonCrdt::new(&replica_id),
281            event_tx,
282            pending_deltas: Vec::new(),
283        }
284    }
285
286    /// Set a value at a dot-path (for example, `"profile.name"`).
287    pub fn set(&mut self, path: &str, value: JsonValue) {
288        let json_path = JsonPath::parse(path);
289        let _ = self.doc.set(&json_path, value);
290    }
291
292    /// Get a value at a dot-path.
293    pub fn get(&self, path: &str) -> Option<JsonValue> {
294        let json_path = JsonPath::parse(path);
295        self.doc.get(&json_path).cloned()
296    }
297
298    /// Delete a value at a dot-path.
299    pub fn delete(&mut self, path: &str) {
300        let json_path = JsonPath::parse(path);
301        let _ = self.doc.delete(&json_path);
302    }
303
304    /// Return the entire document as `serde_json::Value`.
305    pub fn root(&self) -> serde_json::Value {
306        self.doc.to_json()
307    }
308
309    /// Return top-level keys in the JSON object.
310    pub fn keys(&self) -> Vec<String> {
311        self.doc.keys()
312    }
313
314    /// Merge another replica state into this document.
315    ///
316    /// This is a commutative CRDT join, so order does not affect convergence.
317    pub fn merge(&mut self, other: &JsonDoc) {
318        self.doc = self.doc.join(&other.doc);
319        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
320    }
321
322    /// Clone this document's state for synchronization.
323    ///
324    /// The returned state clears local pending delta buffers.
325    pub fn clone_state(&self) -> JsonDoc {
326        JsonDoc {
327            id: self.id.clone(),
328            replica_id: self.replica_id.clone(),
329            doc: self.doc.clone(),
330            event_tx: self.event_tx.clone(),
331            pending_deltas: Vec::new(),
332        }
333    }
334}
335
336impl CollaborativeDoc for JsonDoc {
337    fn id(&self) -> &str {
338        &self.id
339    }
340
341    fn replica_id(&self) -> &str {
342        &self.replica_id
343    }
344
345    fn subscribe(&self) -> broadcast::Receiver<DocEvent> {
346        self.event_tx.subscribe()
347    }
348
349    fn take_pending_deltas(&mut self) -> Vec<Vec<u8>> {
350        std::mem::take(&mut self.pending_deltas)
351    }
352
353    fn apply_remote(&mut self, _delta: &[u8]) {
354        let _ = self.event_tx.send(DocEvent::RemoteUpdate);
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_text_doc() {
364        let mut doc = TextDoc::new("doc-1", "replica-1");
365        doc.insert(0, "Hello");
366        doc.insert(5, " World");
367
368        assert_eq!(doc.get_text(), "Hello World");
369        assert_eq!(doc.len(), 11);
370    }
371
372    #[test]
373    fn test_rich_text_doc() {
374        let mut doc = RichTextDoc::new("doc-1", "replica-1");
375        doc.insert(0, "Hello World");
376        doc.format(0, 5, MarkType::Bold);
377
378        assert_eq!(doc.get_text(), "Hello World");
379    }
380
381    #[test]
382    fn test_json_doc() {
383        let mut doc = JsonDoc::new("doc-1", "replica-1");
384        doc.set("name", JsonValue::String("Alice".to_string()));
385        doc.set("age", JsonValue::Float(30.0));
386
387        assert_eq!(
388            doc.get("name"),
389            Some(JsonValue::String("Alice".to_string()))
390        );
391    }
392}