1pub mod error;
2pub mod model;
3pub mod doc;
4pub mod store;
5pub mod search;
6pub mod fetch;
7pub mod reconcile;
8
9pub use rusqlite;
10pub use loro;
11
12use std::path::Path;
13
14use crate::error::MeshletError;
15use error::Result;
16use model::{Bookmark, BookmarkId, BookmarkPatch};
17use store::Store;
18
19#[derive(Debug, Clone, Default)]
20pub struct SyncSummary {
21 pub merged_duplicates: usize,
22}
23
24pub struct MeshletDb {
25 inner: doc::LoroStore,
26 db: Store,
27}
28
29impl MeshletDb {
30 pub fn open(path: &Path) -> Result<Self> {
31 let db = Store::open(path)?;
32
33 let inner = if let Some(snapshot) = db.load_snapshot()? {
34 doc::LoroStore::from_snapshot(&snapshot)?
35 } else {
36 let peer_id = ulid::Ulid::new().to_string();
37 db.set_meta_string("peer_id", &peer_id)?;
38 doc::LoroStore::new()
39 };
40
41 let this = Self { inner, db };
42 this.rebuild_mirror()?;
43
44 Ok(this)
45 }
46
47 pub fn open_in_memory() -> Result<Self> {
48 let db = Store::open_in_memory()?;
49 let inner = doc::LoroStore::new();
50 Ok(Self { inner, db })
51 }
52
53 pub fn add_bookmark(&self, b: &Bookmark) -> Result<()> {
54 self.inner.add_bookmark(b)?;
55 self.db.upsert_bookmark(b)?;
56 self.save_snapshot()?;
57 Ok(())
58 }
59
60 pub fn update_bookmark(&self, id: &BookmarkId, patch: &BookmarkPatch) -> Result<()> {
61 self.inner.update_bookmark(id, patch)?;
62 if let Some(updated) = self.inner.get_bookmark(id) {
63 self.db.upsert_bookmark(&updated)?;
64 }
65 self.save_snapshot()?;
66 Ok(())
67 }
68
69 pub fn delete_bookmark(&self, id: &BookmarkId) -> Result<()> {
70 self.inner.delete_bookmark(id)?;
71 self.db.delete_bookmark_mirror(id.as_str())?;
72 self.save_snapshot()?;
73 Ok(())
74 }
75
76 pub fn add_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
77 self.inner.add_tags(id, tags)?;
78 if let Some(updated) = self.inner.get_bookmark(id) {
79 self.db.upsert_bookmark(&updated)?;
80 }
81 self.save_snapshot()?;
82 Ok(())
83 }
84
85 pub fn remove_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
86 self.inner.remove_tags(id, tags)?;
87 if let Some(updated) = self.inner.get_bookmark(id) {
88 self.db.upsert_bookmark(&updated)?;
89 }
90 self.save_snapshot()?;
91 Ok(())
92 }
93
94 pub fn get_bookmark(&self, id: &BookmarkId) -> Option<Bookmark> {
95 self.inner.get_bookmark(id)
96 }
97
98 pub fn list_bookmarks(&self) -> Vec<Bookmark> {
99 self.inner.list_bookmarks()
100 }
101
102 pub fn import(&self, data: &[u8]) -> Result<()> {
103 self.inner.import(data)?;
104 reconcile::reconcile(&self.inner)?;
105 self.rebuild_mirror()?;
106 self.save_snapshot()?;
107 Ok(())
108 }
109
110 pub fn export_snapshot(&self) -> Result<Vec<u8>> {
111 self.inner.export_snapshot()
112 }
113
114 pub fn search_keywords(
115 &self,
116 keywords: &[String],
117 deep: bool,
118 all_match: bool,
119 ) -> Result<Vec<Bookmark>> {
120 search::search_keywords(self.db.connection(), keywords, deep, all_match)
121 }
122
123 pub fn search_by_tags(&self, tags: &[String]) -> Result<Vec<Bookmark>> {
124 search::search_by_tags(self.db.connection(), tags)
125 }
126
127 pub fn list_from_mirror(&self) -> Result<Vec<Bookmark>> {
128 search::list_all(self.db.connection())
129 }
130
131 pub fn inner_connection(&self) -> &rusqlite::Connection {
132 self.db.connection()
133 }
134
135 pub fn oplog_vv(&self) -> loro::VersionVector {
136 self.inner.oplog_vv()
137 }
138
139 pub fn export_updates_since(&self, vv: &loro::VersionVector) -> Result<Vec<u8>> {
140 self.inner.export_updates_since(vv)
141 }
142
143 pub fn sync_import(&self, data: &[u8]) -> Result<SyncSummary> {
144 self.inner.import(data)?;
145 let merged = reconcile::reconcile(&self.inner)?;
146 self.rebuild_mirror()?;
147 self.save_snapshot()?;
148 Ok(SyncSummary {
149 merged_duplicates: merged,
150 })
151 }
152
153 pub fn compact_change_store(&self) {
154 self.inner.compact_change_store();
155 }
156
157 pub fn save_last_server_vv(&self, vv: &loro::VersionVector) -> Result<()> {
158 let data = serde_json::to_vec(vv)
159 .map_err(|e| MeshletError::SerializationError(e.to_string()))?;
160 self.db.set_meta("last_server_vv", &data)
161 }
162
163 pub fn load_last_server_vv(&self) -> Result<Option<loro::VersionVector>> {
164 match self.db.get_meta("last_server_vv")? {
165 Some(data) => Ok(Some(serde_json::from_slice(&data)
166 .map_err(|e| MeshletError::SerializationError(e.to_string()))?)),
167 None => Ok(None),
168 }
169 }
170
171 fn rebuild_mirror(&self) -> Result<()> {
172 self.db.connection().execute("DELETE FROM bookmark_tags", [])?;
173 self.db.connection().execute("DELETE FROM bookmarks", [])?;
174 let bookmarks = self.inner.list_bookmarks();
175 for b in &bookmarks {
176 self.db.upsert_bookmark(b)?;
177 }
178 Ok(())
179 }
180 fn save_snapshot(&self) -> Result<()> {
181 let snapshot = self.inner.export_snapshot()?;
182 let vv = serde_json::to_vec(&self.inner.oplog_vv())
183 .map_err(|e| error::MeshletError::SerializationError(e.to_string()))?;
184 self.db.save_snapshot(&snapshot, &vv)
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_mirror_rebuilt_on_restart() {
194 let dir = tempfile::tempdir().unwrap();
195 let path = dir.path().join("test.db");
196
197 let b = Bookmark {
198 id: BookmarkId::new(),
199 url: "https://example.com".into(),
200 title: "Example".into(),
201 desc: "A test bookmark".into(),
202 tags: {
203 let mut t = std::collections::BTreeSet::new();
204 t.insert("test".into());
205 t.insert("example".into());
206 t
207 },
208 flags: 0,
209 created_at: 0,
210 updated_at: 0,
211 };
212
213 {
214 let db = MeshletDb::open(&path).unwrap();
215 db.add_bookmark(&b).unwrap();
216 }
217
218 {
219 let db = MeshletDb::open(&path).unwrap();
220 let loaded = db.get_bookmark(&b.id).unwrap();
221 assert_eq!(loaded.url, "https://example.com");
222 assert_eq!(loaded.title, "Example");
223 assert_eq!(loaded.desc, "A test bookmark");
224 assert!(loaded.tags.contains("test"));
225 assert!(loaded.tags.contains("example"));
226 }
227 }
228
229 #[test]
230 fn test_search_from_mirror() {
231 let db = MeshletDb::open_in_memory().unwrap();
232
233 let b1 = Bookmark {
234 id: BookmarkId::new(),
235 url: "https://rust-lang.org".into(),
236 title: "Rust Programming Language".into(),
237 desc: "A systems programming language".into(),
238 tags: {
239 let mut t = std::collections::BTreeSet::new();
240 t.insert("rust".into());
241 t
242 },
243 flags: 0,
244 created_at: 0,
245 updated_at: 0,
246 };
247
248 let b2 = Bookmark {
249 id: BookmarkId::new(),
250 url: "https://loro.dev".into(),
251 title: "Loro CRDT".into(),
252 desc: "CRDT framework".into(),
253 tags: {
254 let mut t = std::collections::BTreeSet::new();
255 t.insert("crdt".into());
256 t.insert("rust".into());
257 t
258 },
259 flags: 0,
260 created_at: 0,
261 updated_at: 0,
262 };
263
264 db.add_bookmark(&b1).unwrap();
265 db.add_bookmark(&b2).unwrap();
266
267 let results = db.search_keywords(&["crdt".into()], false, false).unwrap();
268 assert_eq!(results.len(), 1);
269 assert_eq!(results[0].url, "https://loro.dev");
270
271 let results = db.search_keywords(&["rust".into()], false, false).unwrap();
272 assert_eq!(results.len(), 1);
273 assert_eq!(results[0].url, "https://rust-lang.org");
274
275 let results = db.search_by_tags(&["crdt".into()]).unwrap();
276 assert_eq!(results.len(), 1);
277 assert_eq!(results[0].url, "https://loro.dev");
278 }
279
280 #[test]
281 fn test_delete_persists_across_restart() {
282 let dir = tempfile::tempdir().unwrap();
283 let path = dir.path().join("test.db");
284
285 let b = Bookmark {
286 id: BookmarkId::new(),
287 url: "https://example.com".into(),
288 title: "Example".into(),
289 desc: "".into(),
290 tags: std::collections::BTreeSet::new(),
291 flags: 0,
292 created_at: 0,
293 updated_at: 0,
294 };
295
296 {
297 let db = MeshletDb::open(&path).unwrap();
298 db.add_bookmark(&b).unwrap();
299 assert!(db.get_bookmark(&b.id).is_some());
300 db.delete_bookmark(&b.id).unwrap();
301 assert!(db.get_bookmark(&b.id).is_none());
302 }
303
304 {
305 let db = MeshletDb::open(&path).unwrap();
306 assert!(db.get_bookmark(&b.id).is_none());
307 assert_eq!(db.list_bookmarks().len(), 0);
308 }
309 }
310
311 #[test]
312 fn test_update_reflected_in_mirror() {
313 let db = MeshletDb::open_in_memory().unwrap();
314
315 let b = Bookmark {
316 id: BookmarkId::new(),
317 url: "https://old.example.com".into(),
318 title: "Old Title".into(),
319 desc: "Old desc".into(),
320 tags: {
321 let mut t = std::collections::BTreeSet::new();
322 t.insert("initial".into());
323 t
324 },
325 flags: 0,
326 created_at: 0,
327 updated_at: 0,
328 };
329 db.add_bookmark(&b).unwrap();
330
331 let patch = BookmarkPatch {
332 url: Some("https://new.example.com".into()),
333 title: Some("New Title".into()),
334 desc: None,
335 flags: None,
336 };
337 db.update_bookmark(&b.id, &patch).unwrap();
338
339 let mirror_results = db
340 .search_keywords(&["New Title".into()], false, false)
341 .unwrap();
342 assert_eq!(mirror_results.len(), 1);
343 assert_eq!(mirror_results[0].url, "https://new.example.com");
344 assert_eq!(mirror_results[0].title, "New Title");
345 assert_eq!(mirror_results[0].desc, "Old desc");
346 }
347
348 #[test]
349 fn test_tag_sync_in_mirror() {
350 let db = MeshletDb::open_in_memory().unwrap();
351
352 let b = Bookmark {
353 id: BookmarkId::new(),
354 url: "https://example.com".into(),
355 title: "Example".into(),
356 desc: "".into(),
357 tags: std::collections::BTreeSet::new(),
358 flags: 0,
359 created_at: 0,
360 updated_at: 0,
361 };
362 db.add_bookmark(&b).unwrap();
363
364 db.add_tags(&b.id, &["rust".into(), "crdt".into()])
365 .unwrap();
366 db.remove_tags(&b.id, &["crdt".into()]).unwrap();
367
368 let results = db.search_by_tags(&["rust".into()]).unwrap();
369 assert_eq!(results.len(), 1);
370 assert!(results[0].tags.contains("rust"));
371 assert!(!results[0].tags.contains("crdt"));
372 }
373}