Skip to main content

archelon_core/
journal_state.rs

1//! In-memory session state: an open journal paired with its SQLite cache connection.
2//!
3//! [`JournalState`] is the single object that frontends (CLI, MCP, GUI) hold while
4//! a workspace is active. Passing it to `ops` functions avoids reopening the
5//! journal directory and database on every call.
6
7use tokio::sync::OnceCell;
8
9use crate::{
10    cache,
11    embed::Embedder,
12    error::Result,
13    journal::Journal,
14    user_config::UserConfig,
15    vector_store::VectorStore,
16};
17
18/// An open journal paired with its SQLite cache connection.
19///
20/// Create with [`JournalState::open`] or [`JournalState::rebuild`], then pass
21/// references to [`crate::ops`] functions.
22///
23/// The vector store (if configured) is loaded lazily on the first call to
24/// [`JournalState::load_vector_store`] and cached for the lifetime of the state.
25pub struct JournalState {
26    pub journal: Journal,
27    pub conn: rusqlite::Connection,
28    vector_store: OnceCell<Option<Box<dyn VectorStore + Send>>>,
29    embedder: OnceCell<Option<Box<dyn Embedder + Send>>>,
30}
31
32impl JournalState {
33    /// Open the cache for `journal`, creating it if it does not yet exist.
34    pub fn open(journal: Journal) -> Result<Self> {
35        let conn = cache::open_cache(&journal)?;
36        Ok(Self { journal, conn, vector_store: OnceCell::new(), embedder: OnceCell::new() })
37    }
38
39    /// Drop and recreate the cache from scratch, then return the new state.
40    pub fn rebuild(journal: Journal) -> Result<Self> {
41        let conn = cache::rebuild_cache(&journal)?;
42        Ok(Self { journal, conn, vector_store: OnceCell::new(), embedder: OnceCell::new() })
43    }
44
45    /// Incrementally sync the cache with the current on-disk journal state.
46    pub fn sync(&self) -> Result<()> {
47        cache::sync_cache(&self.journal, &self.conn)
48    }
49
50    /// Return cache statistics (path, schema version, entry count, etc.).
51    pub fn cache_info(&self) -> Result<cache::CacheInfo> {
52        cache::cache_info(&self.journal, &self.conn)
53    }
54
55    /// Initialize the vector store from the user config if not already done (sync).
56    ///
57    /// Idempotent — if the vector store is already loaded this is a no-op.
58    /// Returns an error if the configured backend fails to open (e.g. LanceDB
59    /// directory is inaccessible).
60    ///
61    /// See also [`load_vector_store_async`](Self::load_vector_store_async) for
62    /// async callers (e.g. Dioxus GUI) where contention-free init is preferred.
63    pub fn load_vector_store(&self, config: &UserConfig) -> Result<()> {
64        if self.vector_store.initialized() {
65            return Ok(());
66        }
67        let store = build_vector_store(&self.journal, config)?;
68        // `set` returns Err if another caller raced and set first; that's fine.
69        let _ = self.vector_store.set(store);
70        Ok(())
71    }
72
73    /// Initialize the vector store from the user config if not already done (async).
74    ///
75    /// Preferred over [`load_vector_store`](Self::load_vector_store) in async
76    /// contexts; at most one initialization runs even under concurrent callers.
77    pub async fn load_vector_store_async(&self, config: &UserConfig) -> Result<()> {
78        self.vector_store
79            .get_or_try_init(|| async { build_vector_store(&self.journal, config) })
80            .await?;
81        Ok(())
82    }
83
84    /// Borrow the vector store if it has been loaded and is configured.
85    ///
86    /// Returns `None` if neither [`load_vector_store`] nor
87    /// [`load_vector_store_async`] has been called yet, or if
88    /// `vector_db = "none"` in the user config.
89    pub fn vector_store(&self) -> Option<&dyn VectorStore> {
90        let boxed: &Box<dyn VectorStore + Send> = self.vector_store.get()?.as_ref()?;
91        let r: &dyn VectorStore = boxed.as_ref();
92        Some(r)
93    }
94
95    /// Initialize the embedder from the user config if not already done (sync).
96    ///
97    /// For `"fastembed"` this loads the ONNX model from disk (slow on first
98    /// call, instant on subsequent calls).  REST providers are lightweight.
99    /// Idempotent — if the embedder is already loaded this is a no-op.
100    ///
101    /// See also [`load_embedder_async`](Self::load_embedder_async) for async callers.
102    pub fn load_embedder(&self, config: &UserConfig) -> Result<()> {
103        if self.embedder.initialized() {
104            return Ok(());
105        }
106        let embedder = config.cache.embedding.as_ref()
107            .map(|c| crate::embed::build_embedder(c))
108            .transpose()?;
109        let _ = self.embedder.set(embedder);
110        Ok(())
111    }
112
113    /// Initialize the embedder from the user config if not already done (async).
114    ///
115    /// Preferred over [`load_embedder`](Self::load_embedder) in async contexts;
116    /// at most one initialization runs even under concurrent callers.
117    pub async fn load_embedder_async(&self, config: &UserConfig) -> Result<()> {
118        self.embedder
119            .get_or_try_init(|| async {
120                config.cache.embedding.as_ref()
121                    .map(|c| crate::embed::build_embedder(c))
122                    .transpose()
123            })
124            .await?;
125        Ok(())
126    }
127
128    /// Borrow the embedder if it has been loaded and an embedding provider is configured.
129    ///
130    /// Returns `None` if [`load_embedder`](Self::load_embedder) has not been
131    /// called yet, or if no `[cache.embedding]` section exists in the user config.
132    pub fn embedder(&self) -> Option<&dyn Embedder> {
133        let boxed: &Box<dyn Embedder + Send> = self.embedder.get()?.as_ref()?;
134        let r: &dyn Embedder = boxed.as_ref();
135        Some(r)
136    }
137}
138
139fn build_vector_store(
140    journal: &Journal,
141    config: &UserConfig,
142) -> Result<Option<Box<dyn VectorStore + Send>>> {
143    use crate::{user_config::VectorDb, vector_store::SqliteVecStore};
144
145    let Some(embed_cfg) = &config.cache.embedding else {
146        return Ok(None);
147    };
148    let Some(dim) = embed_cfg.dimension else {
149        return Ok(None);
150    };
151
152    match config.cache.vector_db {
153        VectorDb::None => Ok(None),
154        VectorDb::SqliteVec => {
155            let store = SqliteVecStore::open(journal, dim)?;
156            Ok(Some(Box::new(store)))
157        }
158        #[cfg(feature = "lancedb-store")]
159        VectorDb::LanceDb => {
160            use crate::lancedb_store::{self, LanceDbVectorStore};
161            let root = journal.cache_dir()?;
162            let store = LanceDbVectorStore::new(&lancedb_store::versioned_dir(&root), dim)?;
163            Ok(Some(Box::new(store)))
164        }
165        #[cfg(not(feature = "lancedb-store"))]
166        VectorDb::LanceDb => Err(crate::error::Error::InvalidConfig(
167            "lancedb support is not compiled in (enable the `lancedb-store` feature)".into(),
168        )),
169    }
170}