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}