mati_core/store/db/mod.rs
1//! SurrealKV storage layer (M-03).
2//!
3//! Two trees per project:
4//! - `knowledge.db` — all user-visible records, indefinite versioning
5//! - `sessions.db` — session analytics and hook events, 90-day retention
6//!
7//! Path: `~/.mati/<slug>/knowledge.db` and `sessions.db`
8//! Slug: first 8 hex chars of SHA-256(git remote URL), falls back to
9//! SHA-256(canonicalized repo root path).
10//!
11//! Write durability follows the split defined in [`crate::store::Durability`]:
12//! - `Immediate` → fsync before commit (knowledge records)
13//! - `Eventual` → OS write buffer (session / analytics records)
14
15mod crud;
16mod history;
17mod slug;
18mod tree;
19
20#[cfg(test)]
21mod tests;
22
23pub use history::HistoryEntry;
24pub use slug::{derive_slug, slug_root, RepoIdent};
25
26use std::path::{Path, PathBuf};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use anyhow::{Context, Result};
30use once_cell::sync::OnceCell;
31use rmp_serde as rmps;
32use sha2::{Digest, Sha256};
33use surrealkv::{
34 Durability as SkvDurability, HistoryOptions, LSMIterator, Mode, Options, Transaction, Tree,
35 TreeBuilder, VLogChecksumLevel,
36};
37
38use serde::{Deserialize, Serialize};
39
40use super::record::Record;
41use super::{Durability, Encoding};
42use crate::search::Search;
43
44#[cfg(test)]
45use crud::prefix_end;
46use tree::{lock_error_hint, open_knowledge_tree, open_sessions_tree};
47
48/// Marker file written by `mati init` when tantivy indexing is deferred.
49/// Detected by [`Store::open_and_rebuild`] (MCP server startup) to trigger
50/// a full rebuild before serving search queries.
51const SEARCH_STALE_MARKER: &str = "search_stale";
52/// Written before every tantivy commit on knowledge keys; removed on success.
53/// Presence on startup means a crash interrupted the KV→tantivy sync window.
54const SEARCH_SYNC_PENDING: &str = "search_sync_pending";
55
56/// Key namespaces stored in the `knowledge` tree that contain [`Record`] structs.
57///
58/// Used by [`Store::rebuild_search_index`] to scan everything that was indexed
59/// during normal `put`/`put_batch` calls. Must stay in sync with
60/// [`Durability::for_key`]'s Immediate set.
61const KNOWLEDGE_NAMESPACES: &[&str] = &[
62 "gotcha:",
63 "decision:",
64 "file:",
65 "stage:",
66 "dev_note:",
67 "dep:",
68];
69
70/// Key namespaces stored in the `sessions` tree that contain [`Record`] structs.
71///
72/// `graph:edge:*` is intentionally excluded — those values are raw 8-byte
73/// timestamps, not `Record` structs, and must not be fed to the search index.
74const SESSION_NAMESPACES: &[&str] = &["session:", "analytics:", "hook_event:", "compliance:"];
75
76/// A write operation for a knowledge-tree transaction.
77///
78/// Supports both Record writes (indexed by tantivy) and raw byte writes
79/// (e.g., audit entries) in the same atomic commit.
80pub enum KnowledgeWriteOp<'a> {
81 /// Write a Record (serialized via MessagePack, indexed by tantivy).
82 PutRecord { key: &'a str, record: &'a Record },
83 /// Write raw bytes (not a Record, not indexed by tantivy).
84 PutRaw { key: &'a str, value: &'a [u8] },
85}
86
87/// Persistent knowledge store for a single mati project.
88///
89/// Wraps two SurrealKV trees:
90/// - `knowledge` — user-visible records (gotchas, files, decisions, …)
91/// - `sessions` — analytics, hook events, compliance logs
92///
93/// All public methods are `async`; callers must be in a `tokio` context.
94pub struct Store {
95 knowledge: Tree,
96 sessions: Tree,
97 /// Tantivy full-text index — lazily initialized on first use.
98 ///
99 /// Hook commands (`get`, `log-hit`, `log-miss`, `reparse`) never touch the
100 /// search index, so we skip the ~30-50ms tantivy init on `Store::open`.
101 /// The index is created on the first call to a method that needs it
102 /// (`put`, `put_batch`, `search`, `rebuild_search_index`).
103 search: OnceCell<Search>,
104 /// Absolute path to `~/.mati/<slug>/`
105 pub root: PathBuf,
106 /// Set by [`Store::open`] when the search index was corrupt or schema-
107 /// incompatible on startup. Callers should use [`Store::open_and_rebuild`]
108 /// rather than inspecting this field directly.
109 index_needs_rebuild: bool,
110}
111
112/// Root directory for all mati on-disk state — every project store, the daemon
113/// socket, logs, and the device id live under it.
114///
115/// `~/.mati` by default. Overridable with the `MATI_HOME` environment variable,
116/// which relocates the entire footprint in one lever — used by CI, sandboxes,
117/// and users with a non-standard layout, and by the test harness to keep test
118/// state out of the developer's real home.
119///
120/// Every site that builds a `~/.mati/...` path MUST go through this (or
121/// [`mati_home_opt`]) so the override is honored uniformly; a stray
122/// `dirs::home_dir().join(".mati")` silently escapes it.
123pub fn mati_home() -> Result<PathBuf> {
124 mati_home_opt().context("cannot determine home directory (set MATI_HOME to override)")
125}
126
127/// Non-failing [`mati_home`] for the call sites that already tolerate a missing
128/// home (logging, best-effort cleanup) with their own fallback.
129pub fn mati_home_opt() -> Option<PathBuf> {
130 if let Some(dir) = std::env::var_os("MATI_HOME").filter(|s| !s.is_empty()) {
131 return Some(PathBuf::from(dir));
132 }
133 // Library unit tests already have their dedicated `cfg(test)` redirect
134 // below. Integration tests link this library without `cfg(test)`, so the
135 // opt-in check is intentionally compiled into that path instead.
136 #[cfg(not(test))]
137 if std::env::var_os("MATI_REQUIRE_EXPLICIT_HOME")
138 .and_then(|value| value.into_string().ok())
139 .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
140 {
141 return None;
142 }
143 // In this crate's own unit tests, never write into the developer's real
144 // `~/.mati`: redirect the whole footprint to a per-process temp dir. This
145 // is compiled only into the lib's `--test` build, so production and the
146 // `mati` binary are unaffected. The bin crate's tests set `MATI_HOME`
147 // explicitly (its `cfg(test)` is separate from the lib's); integration
148 // tests that spawn `mati` isolate via `HOME`/`MATI_HOME` on the child.
149 #[cfg(test)]
150 {
151 Some(test_home())
152 }
153 #[cfg(not(test))]
154 {
155 dirs::home_dir().map(|h| h.join(".mati"))
156 }
157}
158
159/// Per-process temp `MATI_HOME` for unit tests. Computed once and exported to
160/// the environment so any child `mati` process a test spawns inherits the same
161/// root instead of computing a divergent one.
162#[cfg(test)]
163fn test_home() -> PathBuf {
164 use std::sync::OnceLock;
165 static HOME: OnceLock<PathBuf> = OnceLock::new();
166 HOME.get_or_init(|| {
167 let dir = std::env::temp_dir().join(format!("mati-unit-test-{}", std::process::id()));
168 let _ = std::fs::create_dir_all(&dir);
169 std::env::set_var("MATI_HOME", &dir);
170 dir
171 })
172 .clone()
173}
174
175impl Store {
176 /// Open (or create) both trees for the project rooted at `repo_root`.
177 ///
178 /// Creates `<mati_home>/<slug>/` (i.e. `~/.mati/<slug>/`, or under
179 /// `$MATI_HOME`) if it does not exist.
180 ///
181 /// If the search index is corrupt or schema-incompatible, it is wiped and
182 /// replaced with a fresh empty index. [`Store::index_needs_rebuild`] will
183 /// return `true` in that case — call [`Store::rebuild_search_index`] before
184 /// issuing any search queries, or use [`Store::open_and_rebuild`] which
185 /// handles this automatically.
186 pub async fn open(repo_root: &Path) -> Result<Self> {
187 let slug = derive_slug(repo_root);
188 let root = mati_home()?.join(&slug);
189 std::fs::create_dir_all(&root)
190 .with_context(|| format!("cannot create mati dir at {}", root.display()))?;
191
192 let knowledge = open_knowledge_tree(root.join("knowledge.db"))
193 .map_err(|e| lock_error_hint(e, &root.join("knowledge.db")))?;
194 let sessions = open_sessions_tree(root.join("sessions.db"))
195 .map_err(|e| lock_error_hint(e, &root.join("sessions.db")))?;
196
197 // Tantivy is NOT initialized here — it is lazily created on first use
198 // via `ensure_search()`. This saves ~30-50ms for hook commands that
199 // only need KV reads/writes (get, log-hit, log-miss, reparse).
200
201 let store = Self {
202 knowledge,
203 sessions,
204 search: OnceCell::new(),
205 root,
206 index_needs_rebuild: false,
207 };
208
209 // Run forward schema migrations atomically. Single-process flock
210 // (SurrealKV's exclusive lock) means no concurrent migrator can
211 // collide here. If the store is already at the current version
212 // this is a single `Store::get` and returns in microseconds.
213 // Migrations refuse to open the store on detected downgrade,
214 // which propagates the error up to the caller via `?`.
215 super::migrations::migrate(&store).await?;
216
217 Ok(store)
218 }
219
220 /// Open the store and rebuild the search index from SurrealKV if needed.
221 ///
222 /// This is the recommended entry point for the CLI and MCP server. It
223 /// combines [`Store::open`] with an automatic [`Store::rebuild_search_index`]
224 /// call when the index was corrupt or missing (C4). Search queries are safe
225 /// to issue immediately on the returned store.
226 ///
227 /// Unlike [`Store::open`], this eagerly initializes tantivy so corruption
228 /// can be detected and recovered from before any queries are issued.
229 pub async fn open_and_rebuild(repo_root: &Path) -> Result<Self> {
230 let mut store = Self::open(repo_root).await?;
231
232 let search_path = store.root.join("search_index");
233 let stale_marker = store.root.join(SEARCH_STALE_MARKER);
234 let has_sync_pending = store.root.join(SEARCH_SYNC_PENDING).exists();
235
236 // Stale marker is written by `mati init` when tantivy indexing was
237 // deferred. SEARCH_SYNC_PENDING means a crash or sync failure interrupted
238 // the KV → tantivy window. In both cases we must wipe the index before
239 // rebuild so removed keys and old versions cannot survive restart.
240 let has_stale_marker = stale_marker.exists();
241 if (has_stale_marker || has_sync_pending) && search_path.exists() {
242 std::fs::remove_dir_all(&search_path).with_context(|| {
243 format!(
244 "failed to remove stale search index at {}",
245 search_path.display()
246 )
247 })?;
248 }
249
250 // Eagerly initialize tantivy — detect and recover from corruption.
251 match Search::open(&search_path) {
252 Ok(s) => {
253 let _ = store.search.set(s);
254 }
255 Err(e) => {
256 tracing::warn!(
257 error = %e,
258 path = %search_path.display(),
259 "search index corrupt or schema-incompatible — wiping and scheduling rebuild"
260 );
261 if search_path.exists() {
262 std::fs::remove_dir_all(&search_path).with_context(|| {
263 format!(
264 "failed to remove corrupt search index at {}",
265 search_path.display()
266 )
267 })?;
268 }
269 let s = Search::open(&search_path)
270 .context("failed to open fresh search index after clearing corrupt data")?;
271 let _ = store.search.set(s);
272 store.index_needs_rebuild = true;
273 }
274 }
275
276 if has_stale_marker {
277 store.index_needs_rebuild = true;
278 }
279
280 // Detect crash-window desync: KV write committed but the tantivy
281 // commit was interrupted before the fence could be cleared.
282 if has_sync_pending {
283 tracing::warn!("tantivy crash-window desync detected — scheduling rebuild");
284 store.index_needs_rebuild = true;
285 }
286
287 if store.index_needs_rebuild() {
288 store.rebuild_search_index().await?;
289 // Clear the crash-fence if present — a full rebuild is a complete
290 // re-sync from KV, so the index is authoritative again.
291 let _ = std::fs::remove_file(store.root.join(SEARCH_SYNC_PENDING));
292 // Remove stale marker only after a successful rebuild so a
293 // crashed rebuild retries on the next open_and_rebuild call.
294 if has_stale_marker {
295 let _ = std::fs::remove_file(&stale_marker);
296 }
297 }
298 Ok(store)
299 }
300
301 /// True when the search index was corrupt or missing on open.
302 ///
303 /// This flag reflects the state detected at open time and is not reset
304 /// after [`Store::rebuild_search_index`] completes. Use it only to decide
305 /// whether to call `rebuild_search_index` — not as a post-rebuild status.
306 /// [`Store::open_and_rebuild`] handles this automatically.
307 #[must_use]
308 pub fn index_needs_rebuild(&self) -> bool {
309 self.index_needs_rebuild
310 }
311
312 /// Lazily initialize (or return) the tantivy search index.
313 ///
314 /// First call opens the index at `<root>/search_index/`, creating the
315 /// directory and schema if absent. Subsequent calls return the cached
316 /// reference in O(1). If the index is corrupt, the corrupt directory is
317 /// wiped and a fresh index is created.
318 fn ensure_search(&self) -> Result<&Search> {
319 self.search.get_or_try_init(|| {
320 let search_path = self.root.join("search_index");
321 match Search::open(&search_path) {
322 Ok(s) => Ok(s),
323 Err(e) => {
324 tracing::warn!(
325 error = %e,
326 path = %search_path.display(),
327 "search index corrupt on lazy init — wiping and creating fresh"
328 );
329 if search_path.exists() {
330 std::fs::remove_dir_all(&search_path).with_context(|| {
331 format!(
332 "failed to remove corrupt search index at {}",
333 search_path.display()
334 )
335 })?;
336 }
337 Search::open(&search_path)
338 .context("failed to open fresh search index after clearing corrupt data")
339 }
340 }
341 })
342 }
343
344 /// Rebuild the tantivy search index from scratch by scanning all
345 /// [`Record`]-containing namespaces in SurrealKV (C4).
346 ///
347 /// Must be called on a store whose search index is empty — i.e. immediately
348 /// after [`Store::open`] detected a corrupt/missing index, before any writes.
349 /// Calling on a non-empty index will produce duplicate entries; use the
350 /// deduplication in [`Search::query_keys`] to tolerate this if it occurs.
351 ///
352 /// Returns the total number of records committed to the index.
353 pub async fn rebuild_search_index(&self) -> Result<usize> {
354 let search = self.ensure_search()?;
355
356 // Scan and index one namespace at a time — avoids loading all records
357 // into memory simultaneously. Peak RSS is bounded by the largest single
358 // namespace (typically `file:`) rather than the entire corpus.
359 let mut committed = 0usize;
360
361 for ns in KNOWLEDGE_NAMESPACES.iter().chain(SESSION_NAMESPACES) {
362 let records = self.scan_prefix(ns).await?;
363 if records.is_empty() {
364 continue;
365 }
366 let refs: Vec<&Record> = records.iter().collect();
367 committed += search.add_records(&refs)?;
368 }
369
370 tracing::info!(committed, "search index rebuilt from SurrealKV");
371
372 Ok(committed)
373 }
374
375 // -------------------------------------------------------------------------
376 // Lifecycle
377 // -------------------------------------------------------------------------
378
379 /// Flush and close both trees, releasing the LOCK files.
380 ///
381 /// Must be called before dropping `Store` if another process (or test) will
382 /// reopen the same database directory. SurrealKV holds an exclusive lock
383 /// for the lifetime of a `Tree`; reopening without closing first fails with
384 /// "already locked by another process".
385 pub async fn close(self) -> Result<()> {
386 tokio::try_join!(self.knowledge.close(), self.sessions.close())?;
387 // Only close search if it was initialized during this session.
388 if let Some(search) = self.search.into_inner() {
389 search.close()?;
390 }
391 Ok(())
392 }
393
394 /// Best-effort durability flush for shutdown paths.
395 ///
396 /// Calls SurrealKV's `flush_wal(sync=true)` on both trees so every
397 /// previously-committed transaction reaches disk. Non-consuming and
398 /// `&self` — works through a shared `Arc<RwLock<Graph>>` read lock on
399 /// the daemon shutdown path where ownership cannot be reclaimed.
400 ///
401 /// Necessary because SurrealKV's `Tree::Drop` only fire-and-forget-spawns
402 /// `core.close()` onto the current tokio runtime; if the runtime is
403 /// shutting down (signal handler, main return) that spawned task may not
404 /// run before the process exits, losing buffered "Eventual" writes.
405 ///
406 /// Errors are logged via `tracing::warn!` and not propagated — shutdown
407 /// paths must be infallible. Search index pending writes are committed
408 /// per `Search::add_record`/`add_records` call, so no separate flush
409 /// is needed here.
410 pub async fn flush_for_shutdown(&self) {
411 if let Err(e) = self.knowledge.flush_wal(true) {
412 tracing::warn!("flush_for_shutdown: knowledge tree flush failed: {e}");
413 }
414 if let Err(e) = self.sessions.flush_wal(true) {
415 tracing::warn!("flush_for_shutdown: sessions tree flush failed: {e}");
416 }
417 }
418
419 // -------------------------------------------------------------------------
420 // Health / ping
421 // -------------------------------------------------------------------------
422
423 /// Ping the store. Writes a sentinel key and reads it back; returns
424 /// round-trip latency in microseconds.
425 ///
426 /// Used by `mati ping` and by hook fast-path availability checks.
427 pub async fn ping(&self) -> Result<u64> {
428 let start = now_micros();
429
430 let sentinel_key = "analytics:ping_probe";
431 let ts = start.to_string();
432 let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
433 txn.set_durability(SkvDurability::Eventual);
434 txn.set(sentinel_key.as_bytes(), ts.as_bytes())?;
435 txn.commit().await?;
436
437 let txn = self.sessions.begin_with_mode(Mode::ReadOnly)?;
438 let result = txn.get(sentinel_key.as_bytes())?;
439 anyhow::ensure!(
440 result.is_some(),
441 "ping sentinel write was not visible on read-back"
442 );
443
444 Ok(now_micros() - start)
445 }
446
447 // -------------------------------------------------------------------------
448 // Write-seq cache invalidation
449 // -------------------------------------------------------------------------
450
451 /// Path to the monotonic counter file: `~/.mati/<slug>/health_write_seq`.
452 fn write_seq_path(&self) -> PathBuf {
453 self.root.join("health_write_seq")
454 }
455
456 /// Read the current knowledge write-sequence counter.
457 ///
458 /// Returns `0` if the file does not exist or cannot be parsed — callers
459 /// treat `0` as "no valid cached snapshot" and recompute.
460 pub fn read_write_seq(&self) -> u64 {
461 std::fs::read_to_string(self.write_seq_path())
462 .ok()
463 .and_then(|s| s.trim().parse().ok())
464 .unwrap_or(0)
465 }
466
467 /// Increment the write-seq counter. Called after every knowledge-key write.
468 ///
469 /// Best-effort: file write errors are silently discarded — a failed bump
470 /// causes the next stats call to recompute, which is correct behaviour.
471 fn bump_write_seq(&self) {
472 let next = self.read_write_seq().wrapping_add(1);
473 let _ = std::fs::write(self.write_seq_path(), next.to_string());
474 }
475
476 // -------------------------------------------------------------------------
477 // Internals
478 // -------------------------------------------------------------------------
479
480 /// Choose the correct tree based on the key's durability class.
481 fn tree_for(&self, key: &str) -> &Tree {
482 match Durability::for_key(key) {
483 Durability::Eventual => &self.sessions,
484 Durability::Immediate => &self.knowledge,
485 }
486 }
487
488 /// Direct access to the sessions tree for audit reads in tests.
489 ///
490 /// Production code should use the key-routing methods (`get`, `put_raw`,
491 /// `scan_keys`) rather than accessing trees directly.
492 pub fn sessions_tree(&self) -> &Tree {
493 &self.sessions
494 }
495}
496
497/// Current time in microseconds since UNIX epoch.
498fn now_micros() -> u64 {
499 SystemTime::now()
500 .duration_since(UNIX_EPOCH)
501 .map(|d| d.as_micros() as u64)
502 .unwrap_or(0)
503}