Skip to main content

meshlet_core/
doc.rs

1use std::borrow::Cow;
2use std::collections::BTreeSet;
3
4use loro::{Container, ExportMode, LoroDoc, LoroMap, LoroValue, ValueOrContainer};
5
6use crate::error::{MeshletError, Result};
7use crate::model::{Bookmark, BookmarkId, BookmarkPatch};
8
9const BOOKMARKS: &str = "bookmarks";
10const TAGS: &str = "tags";
11const FIELD_URL: &str = "url";
12const FIELD_TITLE: &str = "title";
13const FIELD_DESC: &str = "desc";
14const FIELD_IMMUTABLE: &str = "immutable_title";
15const FIELD_CREATED_AT: &str = "created_at";
16const FIELD_UPDATED_AT: &str = "updated_at";
17
18pub struct LoroStore {
19    doc: LoroDoc,
20}
21
22impl LoroStore {
23    pub fn new() -> Self {
24        let doc = LoroDoc::new();
25        doc.set_record_timestamp(true);
26        doc.get_map(BOOKMARKS);
27        doc.commit();
28        Self { doc }
29    }
30}
31
32impl Default for LoroStore {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl LoroStore {
39    pub fn from_snapshot(data: &[u8]) -> Result<Self> {
40        let doc =
41            LoroDoc::from_snapshot(data).map_err(MeshletError::LoroError)?;
42        doc.set_record_timestamp(true);
43        Ok(Self { doc })
44    }
45
46    pub fn export_snapshot(&self) -> Result<Vec<u8>> {
47        self.doc
48            .export(ExportMode::Snapshot)
49            .map_err(|e| MeshletError::LoroError(e.into()))
50    }
51
52    pub fn export_updates_since(
53        &self,
54        vv: &loro::VersionVector,
55    ) -> Result<Vec<u8>> {
56        self.doc
57            .export(ExportMode::Updates {
58                from: Cow::Borrowed(vv),
59            })
60            .map_err(|e| MeshletError::LoroError(e.into()))
61    }
62
63    pub fn import(&self, data: &[u8]) -> Result<ImportStatus> {
64        self.doc
65            .import(data)
66            .map_err(MeshletError::LoroError)?;
67        Ok(ImportStatus { success: true })
68    }
69
70    pub fn oplog_vv(&self) -> loro::VersionVector {
71        self.doc.oplog_vv().clone()
72    }
73
74    pub fn state_vv(&self) -> loro::VersionVector {
75        self.doc.state_vv().clone()
76    }
77
78    pub fn add_bookmark(&self, b: &Bookmark) -> Result<()> {
79        let bookmarks = self.bookmarks_map()?;
80        let child = bookmarks.ensure_mergeable_map(b.id.as_str())?;
81
82        let now = crate::model::now_ts();
83        let created = if b.created_at > 0 { b.created_at } else { now };
84
85        child.insert(FIELD_URL, b.url.as_str())?;
86        child.insert(FIELD_TITLE, b.title.as_str())?;
87        child.insert(FIELD_DESC, b.desc.as_str())?;
88        child.insert(FIELD_IMMUTABLE, b.flags & 0x01 != 0)?;
89        child.insert(FIELD_CREATED_AT, created)?;
90        child.insert(FIELD_UPDATED_AT, now)?;
91
92        let tags_map = child.ensure_mergeable_map(TAGS)?;
93        for tag in &b.tags {
94            tags_map.insert(tag.as_str(), true)?;
95        }
96
97        self.doc.commit();
98        Ok(())
99    }
100
101    pub fn update_bookmark(&self, id: &BookmarkId, patch: &BookmarkPatch) -> Result<()> {
102        let bookmarks = self.bookmarks_map()?;
103        let child = self
104            .get_child_map(&bookmarks, id.as_str())?
105            .ok_or_else(|| MeshletError::BookmarkNotFound(id.to_string()))?;
106
107        if let Some(ref url) = patch.url {
108            child.insert(FIELD_URL, url.as_str())?;
109        }
110        if let Some(ref title) = patch.title {
111            child.insert(FIELD_TITLE, title.as_str())?;
112        }
113        if let Some(ref desc) = patch.desc {
114            child.insert(FIELD_DESC, desc.as_str())?;
115        }
116        if let Some(flags) = patch.flags {
117            child.insert(FIELD_IMMUTABLE, flags & 0x01 != 0)?;
118        }
119
120        child.insert(FIELD_UPDATED_AT, crate::model::now_ts())?;
121
122        self.doc.commit();
123        Ok(())
124    }
125
126    pub fn delete_bookmark(&self, id: &BookmarkId) -> Result<()> {
127        let bookmarks = self.bookmarks_map()?;
128        bookmarks.delete(id.as_str())?;
129        self.doc.commit();
130        Ok(())
131    }
132
133    pub fn add_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
134        let bookmarks = self.bookmarks_map()?;
135        let child = self
136            .get_child_map(&bookmarks, id.as_str())?
137            .ok_or_else(|| MeshletError::BookmarkNotFound(id.to_string()))?;
138
139        let tags_map = child.ensure_mergeable_map(TAGS)?;
140
141        for tag in tags {
142            tags_map.insert(tag.as_str(), true)?;
143        }
144
145        child.insert(FIELD_UPDATED_AT, crate::model::now_ts())?;
146        self.doc.commit();
147        Ok(())
148    }
149
150    pub fn remove_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
151        let bookmarks = self.bookmarks_map()?;
152        let child = self
153            .get_child_map(&bookmarks, id.as_str())?
154            .ok_or_else(|| MeshletError::BookmarkNotFound(id.to_string()))?;
155
156        let tags_map = child.ensure_mergeable_map(TAGS)?;
157
158        for tag in tags {
159            tags_map.delete(tag.as_str())?;
160        }
161
162        child.insert(FIELD_UPDATED_AT, crate::model::now_ts())?;
163        self.doc.commit();
164        Ok(())
165    }
166
167    pub fn get_bookmark(&self, id: &BookmarkId) -> Option<Bookmark> {
168        let bookmarks = self.bookmarks_map().ok()?;
169        let child = self.get_child_map(&bookmarks, id.as_str()).ok()??;
170        Some(self.read_bookmark(id, &child))
171    }
172
173    pub fn list_bookmarks(&self) -> Vec<Bookmark> {
174        let bookmarks = self.bookmarks_map().unwrap();
175        let mut results = Vec::new();
176        bookmarks.for_each(|key, value| {
177            if let ValueOrContainer::Container(Container::Map(child)) = value {
178                let id = BookmarkId(key.to_string());
179                results.push(self.read_bookmark(&id, &child));
180            }
181        });
182        results
183    }
184
185    pub fn compact_change_store(&self) {
186        self.doc.compact_change_store();
187    }
188
189    fn bookmarks_map(&self) -> Result<LoroMap> {
190        Ok(self.doc.get_map(BOOKMARKS))
191    }
192
193    fn get_child_map(&self, parent: &LoroMap, key: &str) -> Result<Option<LoroMap>> {
194        match parent.get(key) {
195            Some(ValueOrContainer::Container(Container::Map(m))) => Ok(Some(m)),
196            None => Ok(None),
197            Some(_) => Err(MeshletError::LoroError(loro::LoroError::internal(
198                format!("expected map at key '{}', found different type", key),
199            ))),
200        }
201    }
202
203    fn read_bookmark(&self, id: &BookmarkId, child: &LoroMap) -> Bookmark {
204        let url = read_string_field(child, FIELD_URL).unwrap_or_default();
205        let title = read_string_field(child, FIELD_TITLE).unwrap_or_default();
206        let desc = read_string_field(child, FIELD_DESC).unwrap_or_default();
207        let immutable = read_bool_field(child, FIELD_IMMUTABLE).unwrap_or(false);
208        let flags: i64 = if immutable { 0x01 } else { 0 };
209        let created_at = read_i64_field(child, FIELD_CREATED_AT).unwrap_or(0);
210        let updated_at = read_i64_field(child, FIELD_UPDATED_AT).unwrap_or(0);
211
212        let tags: BTreeSet<String> = {
213            let mut set = BTreeSet::new();
214            child.for_each(|key, value| {
215                if key == TAGS
216                    && let ValueOrContainer::Container(Container::Map(tags_map)) = value
217                {
218                    tags_map.for_each(|tag_key, _| {
219                        set.insert(tag_key.to_string());
220                    });
221                }
222            });
223            set
224        };
225
226        Bookmark {
227            id: id.clone(),
228            url,
229            title,
230            desc,
231            tags,
232            flags,
233            created_at,
234            updated_at,
235        }
236    }
237}
238
239fn read_string_field(map: &LoroMap, key: &str) -> Option<String> {
240    match map.get(key)? {
241        ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()),
242        _ => None,
243    }
244}
245
246fn read_bool_field(map: &LoroMap, key: &str) -> Option<bool> {
247    match map.get(key)? {
248        ValueOrContainer::Value(LoroValue::Bool(b)) => Some(b),
249        _ => None,
250    }
251}
252
253fn read_i64_field(map: &LoroMap, key: &str) -> Option<i64> {
254    match map.get(key)? {
255        ValueOrContainer::Value(LoroValue::I64(n)) => Some(n),
256        _ => None,
257    }
258}
259
260#[derive(Debug, Clone)]
261pub struct ImportStatus {
262    pub success: bool,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_round_trip_single_bookmark() {
271        let store = LoroStore::new();
272        let mut tags = BTreeSet::new();
273        tags.insert("rust".into());
274        tags.insert("crdt".into());
275
276        let b = Bookmark {
277            id: BookmarkId::new(),
278            url: "https://loro.dev".into(),
279            title: "Loro CRDT".into(),
280            desc: "High-performance CRDT framework".into(),
281            tags: tags.clone(),
282            flags: 0,
283            created_at: 0,
284            updated_at: 0,
285        };
286
287        store.add_bookmark(&b).unwrap();
288
289        let snapshot = store.export_snapshot().unwrap();
290        let store2 = LoroStore::from_snapshot(&snapshot).unwrap();
291
292        let loaded = store2.get_bookmark(&b.id).unwrap();
293        assert_eq!(loaded.url, "https://loro.dev");
294        assert_eq!(loaded.title, "Loro CRDT");
295        assert_eq!(loaded.desc, "High-performance CRDT framework");
296        assert_eq!(loaded.tags, tags);
297    }
298
299    #[test]
300    fn test_round_trip_multiple_bookmarks_with_tags() {
301        let store = LoroStore::new();
302
303        for i in 0..5 {
304            let mut tags = BTreeSet::new();
305            if i % 2 == 0 {
306                tags.insert("even".into());
307            }
308            if i % 3 == 0 {
309                tags.insert("three".into());
310            }
311            tags.insert(format!("index-{}", i));
312
313            let b = Bookmark {
314                id: BookmarkId::new(),
315                url: format!("https://example.com/{}", i),
316                title: format!("Page {}", i),
317                desc: format!("Description {}", i),
318                tags,
319                flags: 0,
320                created_at: 0,
321                updated_at: 0,
322            };
323
324            store.add_bookmark(&b).unwrap();
325        }
326
327        let snapshot = store.export_snapshot().unwrap();
328        let store2 = LoroStore::from_snapshot(&snapshot).unwrap();
329
330        let list = store2.list_bookmarks();
331        assert_eq!(list.len(), 5);
332    }
333
334    #[test]
335    fn test_tag_operations() {
336        let store = LoroStore::new();
337        let b = Bookmark {
338            id: BookmarkId::new(),
339            url: "https://example.com".into(),
340            title: "Example".into(),
341            desc: "".into(),
342            tags: BTreeSet::new(),
343            flags: 0,
344            created_at: 0,
345            updated_at: 0,
346        };
347        store.add_bookmark(&b).unwrap();
348
349        store
350            .add_tags(&b.id, &["rust".into(), "crdt".into()])
351            .unwrap();
352        let loaded = store.get_bookmark(&b.id).unwrap();
353        assert!(loaded.tags.contains("rust"));
354        assert!(loaded.tags.contains("crdt"));
355
356        store.remove_tags(&b.id, &["crdt".into()]).unwrap();
357        let loaded = store.get_bookmark(&b.id).unwrap();
358        assert!(loaded.tags.contains("rust"));
359        assert!(!loaded.tags.contains("crdt"));
360    }
361
362    #[test]
363    fn test_update_bookmark() {
364        let store = LoroStore::new();
365        let b = Bookmark {
366            id: BookmarkId::new(),
367            url: "https://example.com".into(),
368            title: "Old Title".into(),
369            desc: "Old Desc".into(),
370            tags: BTreeSet::new(),
371            flags: 0,
372            created_at: 0,
373            updated_at: 0,
374        };
375        store.add_bookmark(&b).unwrap();
376
377        let patch = BookmarkPatch {
378            url: Some("https://new-url.com".into()),
379            title: Some("New Title".into()),
380            desc: None,
381            flags: Some(0x01),
382        };
383        store.update_bookmark(&b.id, &patch).unwrap();
384
385        let loaded = store.get_bookmark(&b.id).unwrap();
386        assert_eq!(loaded.url, "https://new-url.com");
387        assert_eq!(loaded.title, "New Title");
388        assert_eq!(loaded.desc, "Old Desc");
389        assert_eq!(loaded.flags, 0x01);
390    }
391
392    #[test]
393    fn test_delete_bookmark() {
394        let store = LoroStore::new();
395        let b = Bookmark {
396            id: BookmarkId::new(),
397            url: "https://example.com".into(),
398            title: "Example".into(),
399            desc: "".into(),
400            tags: BTreeSet::new(),
401            flags: 0,
402            created_at: 0,
403            updated_at: 0,
404        };
405        store.add_bookmark(&b).unwrap();
406        assert!(store.get_bookmark(&b.id).is_some());
407
408        store.delete_bookmark(&b.id).unwrap();
409        assert!(store.get_bookmark(&b.id).is_none());
410    }
411
412    #[test]
413    fn test_duplicate_tag_concurrent_add() {
414        let store = LoroStore::new();
415        let b = Bookmark {
416            id: BookmarkId::new(),
417            url: "https://example.com".into(),
418            title: "Example".into(),
419            desc: "".into(),
420            tags: BTreeSet::new(),
421            flags: 0,
422            created_at: 0,
423            updated_at: 0,
424        };
425        store.add_bookmark(&b).unwrap();
426
427        store.add_tags(&b.id, &["rust".into()]).unwrap();
428        store.add_tags(&b.id, &["rust".into()]).unwrap();
429
430        let loaded = store.get_bookmark(&b.id).unwrap();
431        assert_eq!(loaded.tags.iter().filter(|t| *t == "rust").count(), 1);
432    }
433
434    #[test]
435    fn test_two_peers_concurrent_adds_converge() {
436        let store_a = LoroStore::new();
437        let store_b = LoroStore::new();
438
439        let id_a = BookmarkId::new();
440        let id_b = BookmarkId::new();
441
442        let mut tags_a = BTreeSet::new();
443        tags_a.insert("rust".into());
444        let bm_a = Bookmark {
445            id: id_a.clone(),
446            url: "https://rust-lang.org".into(),
447            title: "Rust".into(),
448            desc: "".into(),
449            tags: tags_a,
450            flags: 0,
451            created_at: 0,
452            updated_at: 0,
453        };
454
455        let mut tags_b = BTreeSet::new();
456        tags_b.insert("crdt".into());
457        let bm_b = Bookmark {
458            id: id_b.clone(),
459            url: "https://loro.dev".into(),
460            title: "Loro".into(),
461            desc: "".into(),
462            tags: tags_b,
463            flags: 0,
464            created_at: 0,
465            updated_at: 0,
466        };
467
468        store_a.add_bookmark(&bm_a).unwrap();
469        store_b.add_bookmark(&bm_b).unwrap();
470
471        let snapshot_a = store_a.export_snapshot().unwrap();
472        let snapshot_b = store_b.export_snapshot().unwrap();
473
474        store_a.import(&snapshot_b).unwrap();
475        store_b.import(&snapshot_a).unwrap();
476
477        let list_a = store_a.list_bookmarks();
478        let list_b = store_b.list_bookmarks();
479        assert_eq!(list_a.len(), 2);
480        assert_eq!(list_b.len(), 2);
481
482        assert!(store_a.get_bookmark(&id_a).is_some());
483        assert!(store_a.get_bookmark(&id_b).is_some());
484        assert!(store_b.get_bookmark(&id_a).is_some());
485        assert!(store_b.get_bookmark(&id_b).is_some());
486    }
487
488    #[test]
489    fn test_two_peers_concurrent_same_bookmark_converges() {
490        let store_a = LoroStore::new();
491        let store_b = LoroStore::new();
492
493        let shared_id = BookmarkId::new();
494
495        let bm_a = Bookmark {
496            id: shared_id.clone(),
497            url: "https://example.com".into(),
498            title: "From A".into(),
499            desc: "Desc A".into(),
500            tags: {
501                let mut t = BTreeSet::new();
502                t.insert("a-tag".into());
503                t
504            },
505            flags: 0,
506            created_at: 0,
507            updated_at: 0,
508        };
509
510        let bm_b = Bookmark {
511            id: shared_id.clone(),
512            url: "https://example.com".into(),
513            title: "From B".into(),
514            desc: "Desc B".into(),
515            tags: {
516                let mut t = BTreeSet::new();
517                t.insert("b-tag".into());
518                t
519            },
520            flags: 0,
521            created_at: 0,
522            updated_at: 0,
523        };
524
525        store_a.add_bookmark(&bm_a).unwrap();
526        store_b.add_bookmark(&bm_b).unwrap();
527
528        let snapshot_a = store_a.export_snapshot().unwrap();
529        store_b.import(&snapshot_a).unwrap();
530
531        let snapshot_b = store_b.export_snapshot().unwrap();
532        store_a.import(&snapshot_b).unwrap();
533
534        let a_bookmark = store_a.get_bookmark(&shared_id).unwrap();
535        let b_bookmark = store_b.get_bookmark(&shared_id).unwrap();
536
537        assert_eq!(a_bookmark.url, b_bookmark.url);
538        assert_eq!(a_bookmark.tags.len(), b_bookmark.tags.len());
539        assert_eq!(a_bookmark.tags, b_bookmark.tags);
540    }
541}