meshlet-core 0.1.1

Core library for meshlet: CRDT bookmark storage, SQLite mirror, and web fetcher
Documentation
pub mod error;
pub mod model;
pub mod doc;
pub mod store;
pub mod search;
pub mod fetch;
pub mod reconcile;

pub use rusqlite;
pub use loro;

use std::path::Path;

use crate::error::MeshletError;
use error::Result;
use model::{Bookmark, BookmarkId, BookmarkPatch};
use store::Store;

#[derive(Debug, Clone, Default)]
pub struct SyncSummary {
    pub merged_duplicates: usize,
}

pub struct MeshletDb {
    inner: doc::LoroStore,
    db: Store,
}

impl MeshletDb {
    pub fn open(path: &Path) -> Result<Self> {
        let db = Store::open(path)?;

        let inner = if let Some(snapshot) = db.load_snapshot()? {
            doc::LoroStore::from_snapshot(&snapshot)?
        } else {
            let peer_id = ulid::Ulid::new().to_string();
            db.set_meta_string("peer_id", &peer_id)?;
            doc::LoroStore::new()
        };

        let this = Self { inner, db };
        this.rebuild_mirror()?;

        Ok(this)
    }

    pub fn open_in_memory() -> Result<Self> {
        let db = Store::open_in_memory()?;
        let inner = doc::LoroStore::new();
        Ok(Self { inner, db })
    }

    pub fn add_bookmark(&self, b: &Bookmark) -> Result<()> {
        self.inner.add_bookmark(b)?;
        self.db.upsert_bookmark(b)?;
        self.save_snapshot()?;
        Ok(())
    }

    pub fn update_bookmark(&self, id: &BookmarkId, patch: &BookmarkPatch) -> Result<()> {
        self.inner.update_bookmark(id, patch)?;
        if let Some(updated) = self.inner.get_bookmark(id) {
            self.db.upsert_bookmark(&updated)?;
        }
        self.save_snapshot()?;
        Ok(())
    }

    pub fn delete_bookmark(&self, id: &BookmarkId) -> Result<()> {
        self.inner.delete_bookmark(id)?;
        self.db.delete_bookmark_mirror(id.as_str())?;
        self.save_snapshot()?;
        Ok(())
    }

    pub fn add_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
        self.inner.add_tags(id, tags)?;
        if let Some(updated) = self.inner.get_bookmark(id) {
            self.db.upsert_bookmark(&updated)?;
        }
        self.save_snapshot()?;
        Ok(())
    }

    pub fn remove_tags(&self, id: &BookmarkId, tags: &[String]) -> Result<()> {
        self.inner.remove_tags(id, tags)?;
        if let Some(updated) = self.inner.get_bookmark(id) {
            self.db.upsert_bookmark(&updated)?;
        }
        self.save_snapshot()?;
        Ok(())
    }

    pub fn get_bookmark(&self, id: &BookmarkId) -> Option<Bookmark> {
        self.inner.get_bookmark(id)
    }

    pub fn list_bookmarks(&self) -> Vec<Bookmark> {
        self.inner.list_bookmarks()
    }

    pub fn import(&self, data: &[u8]) -> Result<()> {
        self.inner.import(data)?;
        reconcile::reconcile(&self.inner)?;
        self.rebuild_mirror()?;
        self.save_snapshot()?;
        Ok(())
    }

    pub fn export_snapshot(&self) -> Result<Vec<u8>> {
        self.inner.export_snapshot()
    }

    pub fn search_keywords(
        &self,
        keywords: &[String],
        deep: bool,
        all_match: bool,
    ) -> Result<Vec<Bookmark>> {
        search::search_keywords(self.db.connection(), keywords, deep, all_match)
    }

    pub fn search_by_tags(&self, tags: &[String]) -> Result<Vec<Bookmark>> {
        search::search_by_tags(self.db.connection(), tags)
    }

    pub fn list_from_mirror(&self) -> Result<Vec<Bookmark>> {
        search::list_all(self.db.connection())
    }

    pub fn inner_connection(&self) -> &rusqlite::Connection {
        self.db.connection()
    }

    pub fn oplog_vv(&self) -> loro::VersionVector {
        self.inner.oplog_vv()
    }

    pub fn export_updates_since(&self, vv: &loro::VersionVector) -> Result<Vec<u8>> {
        self.inner.export_updates_since(vv)
    }

    pub fn sync_import(&self, data: &[u8]) -> Result<SyncSummary> {
        self.inner.import(data)?;
        let merged = reconcile::reconcile(&self.inner)?;
        self.rebuild_mirror()?;
        self.save_snapshot()?;
        Ok(SyncSummary {
            merged_duplicates: merged,
        })
    }

    pub fn compact_change_store(&self) {
        self.inner.compact_change_store();
    }

    pub fn save_last_server_vv(&self, vv: &loro::VersionVector) -> Result<()> {
        let data = serde_json::to_vec(vv)
            .map_err(|e| MeshletError::SerializationError(e.to_string()))?;
        self.db.set_meta("last_server_vv", &data)
    }

    pub fn load_last_server_vv(&self) -> Result<Option<loro::VersionVector>> {
        match self.db.get_meta("last_server_vv")? {
            Some(data) => Ok(Some(serde_json::from_slice(&data)
                .map_err(|e| MeshletError::SerializationError(e.to_string()))?)),
            None => Ok(None),
        }
    }

    fn rebuild_mirror(&self) -> Result<()> {
        self.db.connection().execute("DELETE FROM bookmark_tags", [])?;
        self.db.connection().execute("DELETE FROM bookmarks", [])?;
        let bookmarks = self.inner.list_bookmarks();
        for b in &bookmarks {
            self.db.upsert_bookmark(b)?;
        }
        Ok(())
    }
    fn save_snapshot(&self) -> Result<()> {
        let snapshot = self.inner.export_snapshot()?;
        let vv = serde_json::to_vec(&self.inner.oplog_vv())
            .map_err(|e| error::MeshletError::SerializationError(e.to_string()))?;
        self.db.save_snapshot(&snapshot, &vv)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mirror_rebuilt_on_restart() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.db");

        let b = Bookmark {
            id: BookmarkId::new(),
            url: "https://example.com".into(),
            title: "Example".into(),
            desc: "A test bookmark".into(),
            tags: {
                let mut t = std::collections::BTreeSet::new();
                t.insert("test".into());
                t.insert("example".into());
                t
            },
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };

        {
            let db = MeshletDb::open(&path).unwrap();
            db.add_bookmark(&b).unwrap();
        }

        {
            let db = MeshletDb::open(&path).unwrap();
            let loaded = db.get_bookmark(&b.id).unwrap();
            assert_eq!(loaded.url, "https://example.com");
            assert_eq!(loaded.title, "Example");
            assert_eq!(loaded.desc, "A test bookmark");
            assert!(loaded.tags.contains("test"));
            assert!(loaded.tags.contains("example"));
        }
    }

    #[test]
    fn test_search_from_mirror() {
        let db = MeshletDb::open_in_memory().unwrap();

        let b1 = Bookmark {
            id: BookmarkId::new(),
            url: "https://rust-lang.org".into(),
            title: "Rust Programming Language".into(),
            desc: "A systems programming language".into(),
            tags: {
                let mut t = std::collections::BTreeSet::new();
                t.insert("rust".into());
                t
            },
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };

        let b2 = Bookmark {
            id: BookmarkId::new(),
            url: "https://loro.dev".into(),
            title: "Loro CRDT".into(),
            desc: "CRDT framework".into(),
            tags: {
                let mut t = std::collections::BTreeSet::new();
                t.insert("crdt".into());
                t.insert("rust".into());
                t
            },
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };

        db.add_bookmark(&b1).unwrap();
        db.add_bookmark(&b2).unwrap();

        let results = db.search_keywords(&["crdt".into()], false, false).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].url, "https://loro.dev");

        let results = db.search_keywords(&["rust".into()], false, false).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].url, "https://rust-lang.org");

        let results = db.search_by_tags(&["crdt".into()]).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].url, "https://loro.dev");
    }

    #[test]
    fn test_delete_persists_across_restart() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.db");

        let b = Bookmark {
            id: BookmarkId::new(),
            url: "https://example.com".into(),
            title: "Example".into(),
            desc: "".into(),
            tags: std::collections::BTreeSet::new(),
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };

        {
            let db = MeshletDb::open(&path).unwrap();
            db.add_bookmark(&b).unwrap();
            assert!(db.get_bookmark(&b.id).is_some());
            db.delete_bookmark(&b.id).unwrap();
            assert!(db.get_bookmark(&b.id).is_none());
        }

        {
            let db = MeshletDb::open(&path).unwrap();
            assert!(db.get_bookmark(&b.id).is_none());
            assert_eq!(db.list_bookmarks().len(), 0);
        }
    }

    #[test]
    fn test_update_reflected_in_mirror() {
        let db = MeshletDb::open_in_memory().unwrap();

        let b = Bookmark {
            id: BookmarkId::new(),
            url: "https://old.example.com".into(),
            title: "Old Title".into(),
            desc: "Old desc".into(),
            tags: {
                let mut t = std::collections::BTreeSet::new();
                t.insert("initial".into());
                t
            },
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };
        db.add_bookmark(&b).unwrap();

        let patch = BookmarkPatch {
            url: Some("https://new.example.com".into()),
            title: Some("New Title".into()),
            desc: None,
            flags: None,
        };
        db.update_bookmark(&b.id, &patch).unwrap();

        let mirror_results = db
            .search_keywords(&["New Title".into()], false, false)
            .unwrap();
        assert_eq!(mirror_results.len(), 1);
        assert_eq!(mirror_results[0].url, "https://new.example.com");
        assert_eq!(mirror_results[0].title, "New Title");
        assert_eq!(mirror_results[0].desc, "Old desc");
    }

    #[test]
    fn test_tag_sync_in_mirror() {
        let db = MeshletDb::open_in_memory().unwrap();

        let b = Bookmark {
            id: BookmarkId::new(),
            url: "https://example.com".into(),
            title: "Example".into(),
            desc: "".into(),
            tags: std::collections::BTreeSet::new(),
            flags: 0,
            created_at: 0,
            updated_at: 0,
        };
        db.add_bookmark(&b).unwrap();

        db.add_tags(&b.id, &["rust".into(), "crdt".into()])
            .unwrap();
        db.remove_tags(&b.id, &["crdt".into()]).unwrap();

        let results = db.search_by_tags(&["rust".into()]).unwrap();
        assert_eq!(results.len(), 1);
        assert!(results[0].tags.contains("rust"));
        assert!(!results[0].tags.contains("crdt"));
    }
}