pub mod cache;
mod chat_steps;
pub mod db;
pub mod json;
pub mod schema;
use anyhow::Result;
use uuid::Uuid;
use crate::shared::paths::Paths;
pub use cache::CacheDb;
pub use db::Db;
pub use json::JsonStore;
pub fn db_missing_beside_chats(paths: &Paths) -> bool {
if paths.data_db().exists() {
return false;
}
JsonStore::new(paths.clone())
.chat_files()
.is_ok_and(|files| !files.is_empty())
}
pub struct Storage {
json: JsonStore,
db: Db,
cache: CacheDb,
chats_without_db: bool,
}
impl Storage {
pub fn open(paths: Paths) -> Result<Self> {
let chats_without_db = db_missing_beside_chats(&paths);
if chats_without_db {
tracing::warn!(
root = %paths.root().display(),
"data.db is missing next to existing chats — starting an empty database: \
notes, the self-model, the knowledge base and the attachment index begin empty"
);
}
let db = Db::open(&paths.data_db())?;
let cache = CacheDb::open(&paths.cache_db())?;
let json = JsonStore::new(paths);
Ok(Self {
json,
db,
cache,
chats_without_db,
})
}
#[cfg(test)]
pub fn open_in_memory(paths: Paths) -> Result<Self> {
Ok(Self {
json: JsonStore::new(paths),
db: Db::open_in_memory()?,
cache: CacheDb::open_in_memory()?,
chats_without_db: false,
})
}
pub fn json(&self) -> &JsonStore {
&self.json
}
pub fn db(&self) -> &Db {
&self.db
}
pub fn cache(&self) -> &CacheDb {
&self.cache
}
pub fn chats_without_db(&self) -> bool {
self.chats_without_db
}
pub fn hide_profile_cascade(&self, profile_id: Uuid) -> Result<bool> {
let found = self.json.hide_profile(profile_id)?;
if found {
self.json.hide_chats_of_profile(profile_id)?;
}
Ok(found)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::chat::Chat;
use crate::entities::profile::Profile;
#[test]
fn in_memory_storage_works_but_writes_no_database_files() {
use crate::entities::note::Note;
use crate::entities::rag::RagDocument;
let dir = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dir.path());
let storage = Storage::open_in_memory(paths.clone()).unwrap();
let profile = Uuid::new_v4();
storage
.db()
.note_insert(&Note::new(profile, "заметка", vec![]))
.unwrap();
storage
.db()
.rag_insert(&RagDocument::new(profile, "s", "документ", vec![1.0, 0.0]))
.unwrap();
assert_eq!(
storage
.db()
.note_list(profile, None, &[], None)
.unwrap()
.len(),
1
);
assert_eq!(
storage
.db()
.rag_search(profile, &[1.0, 0.0], 5)
.unwrap()
.len(),
1
);
assert!(!paths.data_db().exists(), "data.db must not be created");
assert!(!paths.cache_db().exists(), "cache.db must not be created");
}
#[test]
fn notes_and_rag_isolated_by_profile_via_facade() {
use crate::entities::note::Note;
use crate::entities::rag::RagDocument;
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(Paths::with_root(dir.path())).unwrap();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
storage
.db()
.note_insert(&Note::new(a, "секрет A", vec![]))
.unwrap();
storage
.db()
.note_insert(&Note::new(b, "секрет B", vec![]))
.unwrap();
storage
.db()
.rag_insert(&RagDocument::new(b, "b", "док B", vec![1.0, 0.0]))
.unwrap();
storage
.db()
.rag_insert(&RagDocument::new(a, "a", "док A", vec![1.0, 0.0]))
.unwrap();
let a_notes = storage.db().note_list(a, None, &[], None).unwrap();
assert_eq!(a_notes.len(), 1);
assert!(a_notes.iter().all(|n| n.profile_id == a));
let a_hits = storage.db().rag_search(a, &[1.0, 0.0], 5).unwrap();
assert_eq!(a_hits.len(), 1);
assert_eq!(a_hits[0].chunk_text, "док A");
}
#[test]
fn db_missing_beside_chats_only_fires_when_chats_outlived_the_database() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::with_root(dir.path());
let json = JsonStore::new(paths.clone());
assert!(!db_missing_beside_chats(&paths));
let stray = paths.chats_dir().join("readme.json");
std::fs::create_dir_all(paths.chats_dir()).unwrap();
std::fs::write(&stray, b"{}").unwrap();
assert!(!db_missing_beside_chats(&paths));
std::fs::remove_file(&stray).unwrap();
let profile = Profile::new("A", "s");
json.save_chat(&Chat::from_profile(&profile, "c1")).unwrap();
assert!(db_missing_beside_chats(&paths));
let _storage = Storage::open(paths.clone()).unwrap();
assert!(!db_missing_beside_chats(&paths));
}
#[test]
fn hide_profile_cascades_to_chats() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(Paths::with_root(dir.path())).unwrap();
let p = Profile::new("A", "s");
storage.json().upsert_profile(&p).unwrap();
storage
.json()
.save_chat(&Chat::from_profile(&p, "c1"))
.unwrap();
storage
.json()
.save_chat(&Chat::from_profile(&p, "c2"))
.unwrap();
assert!(storage.hide_profile_cascade(p.id).unwrap());
assert!(storage.json().load_profiles().unwrap()[0].is_hidden);
assert!(
storage
.json()
.load_chats()
.unwrap()
.iter()
.all(|c| c.is_hidden)
);
}
}