mod crud;
mod history;
mod slug;
mod tree;
#[cfg(test)]
mod tests;
pub use history::HistoryEntry;
pub use slug::{derive_slug, slug_root, RepoIdent};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use once_cell::sync::OnceCell;
use rmp_serde as rmps;
use sha2::{Digest, Sha256};
use surrealkv::{
Durability as SkvDurability, HistoryOptions, LSMIterator, Mode, Options, Transaction, Tree,
TreeBuilder, VLogChecksumLevel,
};
use serde::{Deserialize, Serialize};
use super::record::Record;
use super::{Durability, Encoding};
use crate::search::Search;
#[cfg(test)]
use crud::prefix_end;
use tree::{lock_error_hint, open_knowledge_tree, open_sessions_tree};
const SEARCH_STALE_MARKER: &str = "search_stale";
const SEARCH_SYNC_PENDING: &str = "search_sync_pending";
const KNOWLEDGE_NAMESPACES: &[&str] = &[
"gotcha:",
"decision:",
"file:",
"stage:",
"dev_note:",
"dep:",
];
const SESSION_NAMESPACES: &[&str] = &["session:", "analytics:", "hook_event:", "compliance:"];
pub enum KnowledgeWriteOp<'a> {
PutRecord { key: &'a str, record: &'a Record },
PutRaw { key: &'a str, value: &'a [u8] },
}
pub struct Store {
knowledge: Tree,
sessions: Tree,
search: OnceCell<Search>,
pub root: PathBuf,
index_needs_rebuild: bool,
}
pub fn mati_home() -> Result<PathBuf> {
mati_home_opt().context("cannot determine home directory (set MATI_HOME to override)")
}
pub fn mati_home_opt() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("MATI_HOME").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(dir));
}
#[cfg(not(test))]
if std::env::var_os("MATI_REQUIRE_EXPLICIT_HOME")
.and_then(|value| value.into_string().ok())
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes"))
{
return None;
}
#[cfg(test)]
{
Some(test_home())
}
#[cfg(not(test))]
{
dirs::home_dir().map(|h| h.join(".mati"))
}
}
#[cfg(test)]
fn test_home() -> PathBuf {
use std::sync::OnceLock;
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("mati-unit-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("MATI_HOME", &dir);
dir
})
.clone()
}
impl Store {
pub async fn open(repo_root: &Path) -> Result<Self> {
let slug = derive_slug(repo_root);
let root = mati_home()?.join(&slug);
std::fs::create_dir_all(&root)
.with_context(|| format!("cannot create mati dir at {}", root.display()))?;
let knowledge = open_knowledge_tree(root.join("knowledge.db"))
.map_err(|e| lock_error_hint(e, &root.join("knowledge.db")))?;
let sessions = open_sessions_tree(root.join("sessions.db"))
.map_err(|e| lock_error_hint(e, &root.join("sessions.db")))?;
let store = Self {
knowledge,
sessions,
search: OnceCell::new(),
root,
index_needs_rebuild: false,
};
super::migrations::migrate(&store).await?;
Ok(store)
}
pub async fn open_and_rebuild(repo_root: &Path) -> Result<Self> {
let mut store = Self::open(repo_root).await?;
let search_path = store.root.join("search_index");
let stale_marker = store.root.join(SEARCH_STALE_MARKER);
let has_sync_pending = store.root.join(SEARCH_SYNC_PENDING).exists();
let has_stale_marker = stale_marker.exists();
if (has_stale_marker || has_sync_pending) && search_path.exists() {
std::fs::remove_dir_all(&search_path).with_context(|| {
format!(
"failed to remove stale search index at {}",
search_path.display()
)
})?;
}
match Search::open(&search_path) {
Ok(s) => {
let _ = store.search.set(s);
}
Err(e) => {
tracing::warn!(
error = %e,
path = %search_path.display(),
"search index corrupt or schema-incompatible — wiping and scheduling rebuild"
);
if search_path.exists() {
std::fs::remove_dir_all(&search_path).with_context(|| {
format!(
"failed to remove corrupt search index at {}",
search_path.display()
)
})?;
}
let s = Search::open(&search_path)
.context("failed to open fresh search index after clearing corrupt data")?;
let _ = store.search.set(s);
store.index_needs_rebuild = true;
}
}
if has_stale_marker {
store.index_needs_rebuild = true;
}
if has_sync_pending {
tracing::warn!("tantivy crash-window desync detected — scheduling rebuild");
store.index_needs_rebuild = true;
}
if store.index_needs_rebuild() {
store.rebuild_search_index().await?;
let _ = std::fs::remove_file(store.root.join(SEARCH_SYNC_PENDING));
if has_stale_marker {
let _ = std::fs::remove_file(&stale_marker);
}
}
Ok(store)
}
#[must_use]
pub fn index_needs_rebuild(&self) -> bool {
self.index_needs_rebuild
}
fn ensure_search(&self) -> Result<&Search> {
self.search.get_or_try_init(|| {
let search_path = self.root.join("search_index");
match Search::open(&search_path) {
Ok(s) => Ok(s),
Err(e) => {
tracing::warn!(
error = %e,
path = %search_path.display(),
"search index corrupt on lazy init — wiping and creating fresh"
);
if search_path.exists() {
std::fs::remove_dir_all(&search_path).with_context(|| {
format!(
"failed to remove corrupt search index at {}",
search_path.display()
)
})?;
}
Search::open(&search_path)
.context("failed to open fresh search index after clearing corrupt data")
}
}
})
}
pub async fn rebuild_search_index(&self) -> Result<usize> {
let search = self.ensure_search()?;
let mut committed = 0usize;
for ns in KNOWLEDGE_NAMESPACES.iter().chain(SESSION_NAMESPACES) {
let records = self.scan_prefix(ns).await?;
if records.is_empty() {
continue;
}
let refs: Vec<&Record> = records.iter().collect();
committed += search.add_records(&refs)?;
}
tracing::info!(committed, "search index rebuilt from SurrealKV");
Ok(committed)
}
pub async fn close(self) -> Result<()> {
tokio::try_join!(self.knowledge.close(), self.sessions.close())?;
if let Some(search) = self.search.into_inner() {
search.close()?;
}
Ok(())
}
pub async fn flush_for_shutdown(&self) {
if let Err(e) = self.knowledge.flush_wal(true) {
tracing::warn!("flush_for_shutdown: knowledge tree flush failed: {e}");
}
if let Err(e) = self.sessions.flush_wal(true) {
tracing::warn!("flush_for_shutdown: sessions tree flush failed: {e}");
}
}
pub async fn ping(&self) -> Result<u64> {
let start = now_micros();
let sentinel_key = "analytics:ping_probe";
let ts = start.to_string();
let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
txn.set_durability(SkvDurability::Eventual);
txn.set(sentinel_key.as_bytes(), ts.as_bytes())?;
txn.commit().await?;
let txn = self.sessions.begin_with_mode(Mode::ReadOnly)?;
let result = txn.get(sentinel_key.as_bytes())?;
anyhow::ensure!(
result.is_some(),
"ping sentinel write was not visible on read-back"
);
Ok(now_micros() - start)
}
fn write_seq_path(&self) -> PathBuf {
self.root.join("health_write_seq")
}
pub fn read_write_seq(&self) -> u64 {
std::fs::read_to_string(self.write_seq_path())
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
fn bump_write_seq(&self) {
let next = self.read_write_seq().wrapping_add(1);
let _ = std::fs::write(self.write_seq_path(), next.to_string());
}
fn tree_for(&self, key: &str) -> &Tree {
match Durability::for_key(key) {
Durability::Eventual => &self.sessions,
Durability::Immediate => &self.knowledge,
}
}
pub fn sessions_tree(&self) -> &Tree {
&self.sessions
}
}
fn now_micros() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0)
}