Skip to main content

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}