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}