1use std::path::Path;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10
11use rusqlite::OptionalExtension;
12
13use crate::error::SqliteError;
14use crate::pool::{ConnectionPool, PoolConfig};
15use crate::sql_bridge::SqlBridge;
16use crate::stores::{blob, entity, event, graph, note, sparse, text, vectors};
17
18pub struct StorageBackend {
20 pool: Arc<ConnectionPool>,
21 is_file_backed: bool,
22 path: Option<std::path::PathBuf>,
23 notes_seq_repair_runs: AtomicUsize,
30}
31
32impl StorageBackend {
33 pub fn sqlite(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
39 crate::extension::ensure_extensions_loaded();
40 let resolved = path.as_ref().to_path_buf();
41 let config = PoolConfig {
42 path: Some(resolved.clone()),
43 ..PoolConfig::default()
44 };
45 let pool = ConnectionPool::new(config)?;
46 Ok(Self {
47 pool: Arc::new(pool),
48 is_file_backed: true,
49 path: Some(resolved),
50 notes_seq_repair_runs: AtomicUsize::new(0),
51 })
52 }
53
54 pub fn sqlite_read_only(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
64 crate::extension::ensure_extensions_loaded();
65 let resolved = path.as_ref().to_path_buf();
66 let config = PoolConfig {
67 path: Some(resolved.clone()),
68 read_only: true,
69 ..PoolConfig::default()
70 };
71 let pool = ConnectionPool::new(config)?;
77 Ok(Self {
78 pool: Arc::new(pool),
79 is_file_backed: true,
80 path: Some(resolved),
81 notes_seq_repair_runs: AtomicUsize::new(0),
82 })
83 }
84
85 pub fn memory() -> Result<Self, SqliteError> {
91 crate::extension::ensure_extensions_loaded();
92 let config = PoolConfig {
93 path: None,
94 ..PoolConfig::default()
95 };
96 let pool = ConnectionPool::new(config)?;
97 Ok(Self {
98 pool: Arc::new(pool),
99 is_file_backed: false,
100 path: None,
101 notes_seq_repair_runs: AtomicUsize::new(0),
102 })
103 }
104
105 pub fn sql(&self) -> Arc<dyn khive_storage::SqlAccess> {
109 Arc::new(SqlBridge::new(Arc::clone(&self.pool), self.is_file_backed))
110 }
111
112 pub fn apply_schema(
118 &self,
119 plan: &crate::migrations::ServiceSchemaPlan,
120 ) -> Result<(), SqliteError> {
121 let writer = self.pool.try_writer()?;
122 crate::migrations::apply_schema_plan(writer.conn(), plan)
123 }
124
125 pub fn apply_pack_ddl_statements(
142 &self,
143 statements: &[&'static str],
144 ) -> Result<(), SqliteError> {
145 let writer = self.pool.try_writer()?;
146 for &stmt in statements {
147 writer.conn().execute_batch(stmt)?;
148 }
149 Ok(())
150 }
151
152 pub fn entities(&self) -> Result<Arc<dyn khive_storage::EntityStore>, SqliteError> {
156 self.entities_for_namespace("local")
157 }
158
159 pub fn entities_for_namespace(
163 &self,
164 namespace: &str,
165 ) -> Result<Arc<dyn khive_storage::EntityStore>, SqliteError> {
166 if namespace.trim().is_empty() {
167 return Err(SqliteError::InvalidData(
168 "entities namespace must be non-empty".to_string(),
169 ));
170 }
171 let writer = self.pool.try_writer()?;
172 entity::ensure_entities_schema(writer.conn())?;
173
174 Ok(Arc::new(entity::SqlEntityStore::new(
175 Arc::clone(&self.pool),
176 self.is_file_backed,
177 )))
178 }
179
180 pub fn graph(&self) -> Result<Arc<dyn khive_storage::GraphStore>, SqliteError> {
185 self.graph_for_namespace("local")
186 }
187
188 pub fn graph_for_namespace(
190 &self,
191 namespace: &str,
192 ) -> Result<Arc<dyn khive_storage::GraphStore>, SqliteError> {
193 if namespace.trim().is_empty() {
194 return Err(SqliteError::InvalidData(
195 "graph namespace must be non-empty".to_string(),
196 ));
197 }
198 let writer = self.pool.try_writer()?;
199 graph::ensure_graph_schema(writer.conn())?;
200
201 Ok(Arc::new(graph::SqlGraphStore::new_scoped(
202 Arc::clone(&self.pool),
203 self.is_file_backed,
204 namespace.trim().to_string(),
205 )))
206 }
207
208 pub fn notes(&self) -> Result<Arc<dyn khive_storage::NoteStore>, SqliteError> {
212 self.notes_for_namespace("local")
213 }
214
215 pub fn notes_for_namespace(
219 &self,
220 namespace: &str,
221 ) -> Result<Arc<dyn khive_storage::NoteStore>, SqliteError> {
222 if namespace.trim().is_empty() {
223 return Err(SqliteError::InvalidData(
224 "notes namespace must be non-empty".to_string(),
225 ));
226 }
227 let writer = self.pool.try_writer()?;
228 note::ensure_notes_schema(writer.conn())?;
229
230 if self.notes_seq_repair_runs.load(Ordering::Relaxed) == 0 {
237 note::repair_notes_seq(writer.conn())?;
238 self.notes_seq_repair_runs.fetch_add(1, Ordering::Relaxed);
239 }
240
241 Ok(Arc::new(note::SqlNoteStore::new(
242 Arc::clone(&self.pool),
243 self.is_file_backed,
244 )))
245 }
246
247 pub fn notes_seq_repair_run_count(&self) -> usize {
252 self.notes_seq_repair_runs.load(Ordering::Relaxed)
253 }
254
255 pub fn events(&self) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
260 self.events_for_namespace("local")
261 }
262
263 pub fn events_for_namespace(
265 &self,
266 namespace: &str,
267 ) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
268 if namespace.trim().is_empty() {
269 return Err(SqliteError::InvalidData(
270 "events namespace must be non-empty".to_string(),
271 ));
272 }
273 let writer = self.pool.try_writer()?;
274 event::ensure_events_schema(writer.conn())?;
275
276 Ok(Arc::new(event::SqlEventStore::new_scoped(
277 Arc::clone(&self.pool),
278 self.is_file_backed,
279 namespace.trim().to_string(),
280 )))
281 }
282
283 pub fn vectors(
289 &self,
290 model_key: &str,
291 embedding_model: &str,
292 dimensions: usize,
293 ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
294 self.vectors_for_namespace(model_key, embedding_model, dimensions, "local")
295 }
296
297 pub fn vectors_for_namespace(
307 &self,
308 model_key: &str,
309 embedding_model: &str,
310 dimensions: usize,
311 namespace: &str,
312 ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
313 if model_key.is_empty()
314 || !model_key
315 .chars()
316 .all(|c| c.is_ascii_alphanumeric() || c == '_')
317 {
318 return Err(SqliteError::InvalidData(format!(
319 "invalid model_key '{}': must be non-empty and contain only \
320 alphanumeric/underscore characters",
321 model_key
322 )));
323 }
324 if namespace.trim().is_empty() {
325 return Err(SqliteError::InvalidData(
326 "vector store namespace must be non-empty".to_string(),
327 ));
328 }
329
330 crate::extension::ensure_extensions_loaded();
332
333 let table = format!("vec_{}", model_key);
334 let writer = self.pool.try_writer()?;
335
336 let table_exists: bool = writer
343 .conn()
344 .query_row(
345 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
346 rusqlite::params![&table],
347 |row| row.get::<_, i64>(0),
348 )
349 .optional()
350 .map_err(SqliteError::Rusqlite)?
351 .is_some();
352
353 if table_exists {
354 let pragma = format!("PRAGMA table_xinfo({})", table);
360 let mut stmt = writer.conn().prepare(&pragma)?;
361 let mut rows = stmt.query([])?;
362 let mut has_field = false;
363 let mut has_embedding_model = false;
364 while let Some(row) = rows.next()? {
365 let name: String = row.get(1)?;
366 if name == "field" {
367 has_field = true;
368 }
369 if name == "embedding_model" {
370 has_embedding_model = true;
371 }
372 }
373 if !has_field || !has_embedding_model {
374 return Err(SqliteError::InvalidData(format!(
375 "vec0 table '{}' is missing required column(s) (field={}, \
376 embedding_model={}); this is a pre-v0.2.8 vector schema and is \
377 not supported — recreate the database",
378 table, has_field, has_embedding_model,
379 )));
380 }
381 }
382
383 writer
392 .conn()
393 .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
394
395 let ddl = format!(
398 "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
399 subject_id TEXT PRIMARY KEY, \
400 namespace TEXT NOT NULL, \
401 kind TEXT NOT NULL, \
402 field TEXT NOT NULL, \
403 embedding_model TEXT NOT NULL, \
404 embedding float[{}] distance_metric=cosine\
405 )",
406 model_key, dimensions
407 );
408 writer.conn().execute_batch(&ddl)?;
409
410 Ok(Arc::new(vectors::SqliteVecStore::new(
411 Arc::clone(&self.pool),
412 self.is_file_backed,
413 model_key.to_string(),
414 embedding_model.to_string(),
415 dimensions,
416 namespace.trim().to_string(),
417 )?))
418 }
419
420 pub fn register_embedding_model(
425 &self,
426 engine_name: &str,
427 model_id: &str,
428 key_version: &str,
429 dimensions: u32,
430 ) -> Result<(), SqliteError> {
431 let writer = self.pool.try_writer()?;
432 writer
433 .conn()
434 .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
435
436 let now = chrono::Utc::now().timestamp_micros();
437 let canonical_key =
438 format!("{engine_name}:{model_id}:{key_version}:{dimensions}").into_bytes();
439 let id = uuid::Uuid::new_v4();
440 writer.conn().execute(
441 "INSERT INTO _embedding_models \
442 (id, engine_name, model_id, key_version, dim, output_dim, status, \
443 activated_at, superseded_at, superseded_by, canonical_key, created_at) \
444 VALUES (?1, ?2, ?3, ?4, ?5, NULL, 'active', ?6, NULL, NULL, ?7, ?8) \
445 ON CONFLICT(canonical_key) DO UPDATE SET \
446 status = 'active', \
447 activated_at = COALESCE(_embedding_models.activated_at, excluded.activated_at)",
448 rusqlite::params![
449 id.as_bytes().as_slice(),
450 engine_name,
451 model_id,
452 key_version,
453 dimensions as i64,
454 now,
455 canonical_key,
456 now,
457 ],
458 )?;
459 Ok(())
460 }
461
462 pub fn sparse(
466 &self,
467 model_key: &str,
468 ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
469 self.sparse_for_namespace(model_key, "local")
470 }
471
472 pub fn sparse_for_namespace(
476 &self,
477 model_key: &str,
478 namespace: &str,
479 ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
480 if model_key.is_empty()
481 || !model_key
482 .chars()
483 .all(|c| c.is_ascii_alphanumeric() || c == '_')
484 {
485 return Err(SqliteError::InvalidData(format!(
486 "invalid model_key '{}': must be non-empty and contain only alphanumeric/underscore characters",
487 model_key
488 )));
489 }
490 if namespace.trim().is_empty() {
491 return Err(SqliteError::InvalidData(
492 "sparse store namespace must be non-empty".to_string(),
493 ));
494 }
495
496 let writer = self.pool.try_writer()?;
497 sparse::ensure_sparse_schema(writer.conn(), model_key).map_err(SqliteError::Rusqlite)?;
498
499 Ok(Arc::new(sparse::SqliteSparseStore::new(
500 Arc::clone(&self.pool),
501 self.is_file_backed,
502 model_key.to_string(),
503 namespace.trim().to_string(),
504 )?))
505 }
506
507 pub fn text(&self, table_key: &str) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
514 self.text_with_tokenizer(table_key, "trigram")
515 }
516
517 pub fn text_with_tokenizer(
525 &self,
526 table_key: &str,
527 tokenizer: &str,
528 ) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
529 if table_key.is_empty()
530 || !table_key
531 .chars()
532 .all(|c| c.is_ascii_alphanumeric() || c == '_')
533 {
534 return Err(SqliteError::InvalidData(format!(
535 "invalid table_key '{}': must be non-empty and contain only \
536 alphanumeric/underscore characters",
537 table_key
538 )));
539 }
540 if tokenizer.is_empty()
541 || !tokenizer
542 .chars()
543 .all(|c| c.is_ascii_alphanumeric() || c == '_')
544 {
545 return Err(SqliteError::InvalidData(format!(
546 "invalid tokenizer '{}': must be non-empty and contain only \
547 alphanumeric/underscore characters",
548 tokenizer
549 )));
550 }
551
552 let ddl = format!(
553 "CREATE VIRTUAL TABLE IF NOT EXISTS fts_{} USING fts5(\
554 subject_id UNINDEXED, \
555 kind UNINDEXED, \
556 title, \
557 body, \
558 tags UNINDEXED, \
559 namespace UNINDEXED, \
560 metadata UNINDEXED, \
561 updated_at UNINDEXED, \
562 tokenize = '{}'\
563 )",
564 table_key, tokenizer
565 );
566 let writer = self.pool.try_writer()?;
567 writer.conn().execute_batch(&ddl)?;
568
569 Ok(Arc::new(text::Fts5TextSearch::new(
570 Arc::clone(&self.pool),
571 self.is_file_backed,
572 table_key.to_string(),
573 )))
574 }
575
576 pub fn blob_store(
584 &self,
585 config_root: Option<&Path>,
586 floor_bytes: Option<u64>,
587 ) -> Result<Arc<dyn khive_storage::BlobStore>, SqliteError> {
588 let root = blob::resolve_blob_root(self.data_dir().as_deref(), config_root)?;
589 let floor = floor_bytes.unwrap_or(blob::FsBlobStore::DEFAULT_FLOOR_BYTES);
590 Ok(Arc::new(blob::FsBlobStore::new(root, floor)?))
591 }
592
593 pub fn is_file_backed(&self) -> bool {
595 self.is_file_backed
596 }
597
598 pub fn data_dir(&self) -> Option<std::path::PathBuf> {
601 self.path.as_ref()?.parent().map(|p| p.to_path_buf())
602 }
603
604 pub fn pool(&self) -> &ConnectionPool {
606 &self.pool
607 }
608
609 pub fn pool_arc(&self) -> Arc<ConnectionPool> {
611 Arc::clone(&self.pool)
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use khive_storage::types::{SqlStatement, SqlValue};
619
620 #[test]
621 fn memory_backend_creates_successfully() {
622 let backend = StorageBackend::memory().expect("memory backend should create");
623 assert!(!backend.is_file_backed());
624 }
625
626 #[test]
627 fn file_backend_creates_successfully() {
628 let dir = tempfile::tempdir().unwrap();
629 let path = dir.path().join("test.db");
630 let backend = StorageBackend::sqlite(&path).expect("file backend should create");
631 assert!(backend.is_file_backed());
632 assert!(path.exists());
633 }
634
635 #[test]
636 fn data_dir_returns_none_for_memory_backend() {
637 let backend = StorageBackend::memory().expect("memory backend");
638 assert!(backend.data_dir().is_none());
639 }
640
641 #[test]
642 fn data_dir_returns_parent_dir_for_file_backend() {
643 let dir = tempfile::tempdir().unwrap();
644 let path = dir.path().join("data.db");
645 let backend = StorageBackend::sqlite(&path).expect("file backend");
646 let got = backend.data_dir().expect("file backend must return Some");
647 assert_eq!(got, dir.path());
648 }
649
650 #[tokio::test]
651 async fn sql_access_memory_roundtrip() {
652 let backend = StorageBackend::memory().unwrap();
653 let sql = backend.sql();
654
655 let mut writer = sql.writer().await.unwrap();
656 writer
657 .execute_script(
658 "CREATE TABLE test_rt (id TEXT PRIMARY KEY, value INTEGER NOT NULL)".into(),
659 )
660 .await
661 .unwrap();
662
663 let affected = writer
664 .execute(SqlStatement {
665 sql: "INSERT INTO test_rt (id, value) VALUES (?1, ?2)".into(),
666 params: vec![SqlValue::Text("row1".into()), SqlValue::Integer(42)],
667 label: None,
668 })
669 .await
670 .unwrap();
671 assert_eq!(affected, 1);
672
673 let mut reader = sql.reader().await.unwrap();
674 let row = reader
675 .query_row(SqlStatement {
676 sql: "SELECT id, value FROM test_rt WHERE id = ?1".into(),
677 params: vec![SqlValue::Text("row1".into())],
678 label: None,
679 })
680 .await
681 .unwrap();
682
683 let row = row.expect("should find the inserted row");
684 assert_eq!(row.columns.len(), 2);
685 match &row.columns[0].value {
686 SqlValue::Text(s) => assert_eq!(s, "row1"),
687 other => panic!("expected Text, got {other:?}"),
688 }
689 match &row.columns[1].value {
690 SqlValue::Integer(v) => assert_eq!(*v, 42),
691 other => panic!("expected Integer, got {other:?}"),
692 }
693 }
694
695 #[tokio::test]
696 async fn sql_access_file_roundtrip() {
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("test_roundtrip.db");
699 let backend = StorageBackend::sqlite(&path).unwrap();
700 let sql = backend.sql();
701
702 let mut writer = sql.writer().await.unwrap();
703 writer
704 .execute_script("CREATE TABLE test_f (k TEXT PRIMARY KEY, v TEXT)".into())
705 .await
706 .unwrap();
707 writer
708 .execute(SqlStatement {
709 sql: "INSERT INTO test_f (k, v) VALUES (?1, ?2)".into(),
710 params: vec![
711 SqlValue::Text("hello".into()),
712 SqlValue::Text("world".into()),
713 ],
714 label: None,
715 })
716 .await
717 .unwrap();
718
719 let mut reader = sql.reader().await.unwrap();
720 let rows = reader
721 .query_all(SqlStatement {
722 sql: "SELECT k, v FROM test_f".into(),
723 params: vec![],
724 label: None,
725 })
726 .await
727 .unwrap();
728 assert_eq!(rows.len(), 1);
729 match &rows[0].columns[1].value {
730 SqlValue::Text(s) => assert_eq!(s, "world"),
731 other => panic!("expected Text, got {other:?}"),
732 }
733 }
734
735 #[test]
736 fn sqlite_read_only_missing_path_does_not_create_file() {
737 let dir = tempfile::tempdir().unwrap();
738 let path = dir.path().join("missing_ro.db");
739 assert!(!path.exists());
740
741 let result = StorageBackend::sqlite_read_only(&path);
742 assert!(
743 result.is_err(),
744 "opening a missing path read-only must fail"
745 );
746 assert!(
747 !path.exists(),
748 "opening a missing path read-only must not create the file"
749 );
750 }
751
752 #[tokio::test]
753 async fn sqlite_read_only_sql_writer_rejects_ddl_and_insert() {
754 let dir = tempfile::tempdir().unwrap();
755 let path = dir.path().join("ro_writer.db");
756
757 {
759 let writable = StorageBackend::sqlite(&path).unwrap();
760 let sql = writable.sql();
761 let mut writer = sql.writer().await.unwrap();
762 writer
763 .execute_script("CREATE TABLE ro_existing (id INTEGER PRIMARY KEY)".into())
764 .await
765 .unwrap();
766 }
767
768 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
769 let sql = ro.sql();
770
771 let writer_result = sql.writer().await;
773 assert!(
774 writer_result.is_err(),
775 "sql().writer() must be rejected on a read-only backend"
776 );
777 }
778
779 #[tokio::test]
780 #[cfg(feature = "vectors")]
781 async fn vectors_roundtrip_via_public_api() {
782 let backend = StorageBackend::memory().unwrap();
783 let store = backend.vectors("test_api", "test_api", 3).unwrap();
784
785 let id = uuid::Uuid::new_v4();
786 store
787 .insert(
788 id,
789 khive_types::SubstrateKind::Entity,
790 "local",
791 "content",
792 vec![vec![1.0, 0.0, 0.0]],
793 )
794 .await
795 .unwrap();
796
797 let hits = store
798 .search(khive_storage::types::VectorSearchRequest {
799 query_vectors: vec![vec![1.0, 0.0, 0.0]],
800 top_k: 1,
801 namespace: None,
802 kind: None,
803 embedding_model: None,
804 filter: None,
805 backend_hints: None,
806 })
807 .await
808 .unwrap();
809
810 assert_eq!(hits.len(), 1);
811 assert_eq!(hits[0].subject_id, id);
812 assert!(hits[0].score.to_f64() > 0.99);
813 }
814
815 #[tokio::test]
816 #[cfg(feature = "vectors")]
817 async fn vectors_creates_table_idempotently() {
818 let backend = StorageBackend::memory().unwrap();
819
820 let store1 = backend.vectors("idempotent", "idempotent", 3).unwrap();
821 let store2 = backend.vectors("idempotent", "idempotent", 3).unwrap();
822
823 let id = uuid::Uuid::new_v4();
824 store1
825 .insert(
826 id,
827 khive_types::SubstrateKind::Entity,
828 "local",
829 "content",
830 vec![vec![1.0, 0.0, 0.0]],
831 )
832 .await
833 .unwrap();
834
835 let count = store2.count().await.unwrap();
836 assert_eq!(count, 1);
837 }
838
839 #[tokio::test]
840 async fn text_roundtrip_via_public_api() {
841 let backend = StorageBackend::memory().unwrap();
842 let store = backend.text("test_api").unwrap();
843
844 let id = uuid::Uuid::new_v4();
845 let doc = khive_storage::types::TextDocument {
846 subject_id: id,
847 kind: khive_types::SubstrateKind::Entity,
848 title: Some("Test Title".to_string()),
849 body: "This is a searchable document about Rust.".to_string(),
850 tags: vec!["rust".to_string()],
851 namespace: "test_ns".to_string(),
852 metadata: None,
853 updated_at: chrono::Utc::now(),
854 };
855 store.upsert_document(doc).await.unwrap();
856
857 let hits = store
858 .search(khive_storage::types::TextSearchRequest {
859 query: "Rust".to_string(),
860 mode: khive_storage::types::TextQueryMode::Plain,
861 filter: Some(khive_storage::types::TextFilter {
862 namespaces: vec!["test_ns".to_string()],
863 ..Default::default()
864 }),
865 top_k: 1,
866 snippet_chars: 64,
867 })
868 .await
869 .unwrap();
870
871 assert_eq!(hits.len(), 1);
872 assert_eq!(hits[0].subject_id, id);
873 assert!(hits[0].score.to_f64() > 0.0);
874 }
875
876 #[tokio::test]
877 async fn text_creates_table_idempotently() {
878 let backend = StorageBackend::memory().unwrap();
879
880 let store1 = backend.text("idempotent_fts").unwrap();
881 let store2 = backend.text("idempotent_fts").unwrap();
882
883 let id = uuid::Uuid::new_v4();
884 let doc = khive_storage::types::TextDocument {
885 subject_id: id,
886 kind: khive_types::SubstrateKind::Note,
887 title: None,
888 body: "Hello world.".to_string(),
889 tags: vec![],
890 namespace: "test_ns".to_string(),
891 metadata: None,
892 updated_at: chrono::Utc::now(),
893 };
894 store1.upsert_document(doc).await.unwrap();
895
896 let count = store2
897 .count(khive_storage::types::TextFilter {
898 namespaces: vec!["test_ns".to_string()],
899 ..Default::default()
900 })
901 .await
902 .unwrap();
903 assert_eq!(count, 1);
904 }
905
906 #[test]
907 fn invalid_model_key_rejected() {
908 let backend = StorageBackend::memory().unwrap();
909 assert!(backend.vectors("bad key!", "bad key!", 3).is_err());
910 assert!(backend.vectors("", "", 3).is_err());
911 }
912
913 #[test]
914 fn invalid_table_key_rejected() {
915 let backend = StorageBackend::memory().unwrap();
916 assert!(backend.text("bad key!").is_err());
917 assert!(backend.text("").is_err());
918 }
919
920 #[tokio::test]
921 async fn sqlite_read_only_graph_store_rejects_upsert_edge() {
922 use khive_storage::types::Edge;
923 use khive_types::EdgeRelation;
924
925 let dir = tempfile::tempdir().unwrap();
926 let path = dir.path().join("ro_graph.db");
927
928 {
930 let writable = StorageBackend::sqlite(&path).unwrap();
931 writable.graph().unwrap();
932 }
933
934 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
935 let store = match ro.graph() {
936 Ok(store) => store,
937 Err(_) => return,
940 };
941
942 let now = chrono::Utc::now();
943 let edge = Edge {
944 id: uuid::Uuid::new_v4().into(),
945 namespace: "local".to_string(),
946 source_id: uuid::Uuid::new_v4(),
947 target_id: uuid::Uuid::new_v4(),
948 relation: EdgeRelation::Extends,
949 weight: 0.8,
950 created_at: now,
951 updated_at: now,
952 deleted_at: None,
953 metadata: None,
954 target_backend: None,
955 };
956
957 let result = store.upsert_edge(edge).await;
958 assert!(
959 result.is_err(),
960 "upsert_edge on a read-only backend must reject, not silently no-op"
961 );
962 }
963
964 #[tokio::test]
965 async fn sqlite_read_only_event_store_rejects_append_event() {
966 use khive_types::{EventKind, EventOutcome, SubstrateKind};
967
968 let dir = tempfile::tempdir().unwrap();
969 let path = dir.path().join("ro_events.db");
970
971 {
972 let writable = StorageBackend::sqlite(&path).unwrap();
973 writable.events().unwrap();
974 }
975
976 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
977 let store = match ro.events() {
978 Ok(store) => store,
979 Err(_) => return,
980 };
981
982 let event = khive_storage::event::Event::new(
983 "local",
984 "test.verb",
985 EventKind::Audit,
986 SubstrateKind::Entity,
987 "test-actor",
988 )
989 .with_outcome(EventOutcome::Success);
990
991 let result = store.append_event(event).await;
992 assert!(
993 result.is_err(),
994 "append_event on a read-only backend must reject, not silently no-op"
995 );
996 }
997
998 #[tokio::test]
999 async fn sqlite_read_only_text_store_rejects_upsert_document() {
1000 use khive_storage::types::TextDocument;
1001 use khive_types::SubstrateKind;
1002
1003 let dir = tempfile::tempdir().unwrap();
1004 let path = dir.path().join("ro_text.db");
1005
1006 {
1007 let writable = StorageBackend::sqlite(&path).unwrap();
1008 writable.text("ro_test").unwrap();
1009 }
1010
1011 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
1012 let store = match ro.text("ro_test") {
1013 Ok(store) => store,
1014 Err(_) => return,
1015 };
1016
1017 let doc = TextDocument {
1018 subject_id: uuid::Uuid::new_v4(),
1019 kind: SubstrateKind::Entity,
1020 title: Some("Title".to_string()),
1021 body: "Body text.".to_string(),
1022 tags: vec![],
1023 namespace: "local".to_string(),
1024 metadata: None,
1025 updated_at: chrono::Utc::now(),
1026 };
1027
1028 let result = store.upsert_document(doc).await;
1029 assert!(
1030 result.is_err(),
1031 "upsert_document on a read-only backend must reject, not silently no-op"
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn blob_store_roundtrip_via_public_api() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let path = dir.path().join("blob_backend.db");
1039 let backend = StorageBackend::sqlite(&path).unwrap();
1040
1041 let store = backend.blob_store(None, Some(0)).unwrap();
1045 let bytes = b"backend-level blob roundtrip".to_vec();
1046 let content_ref = store.put(bytes.clone()).await.unwrap();
1047 assert_eq!(store.get(&content_ref).await.unwrap(), bytes);
1048 }
1049
1050 #[test]
1051 fn blob_store_defaults_root_beside_db_file() {
1052 let dir = tempfile::tempdir().unwrap();
1053 let path = dir.path().join("blob_default.db");
1054 let backend = StorageBackend::sqlite(&path).unwrap();
1055
1056 let _store = backend.blob_store(None, None).unwrap();
1060 assert!(
1061 dir.path().join("blobs").is_dir(),
1062 "default root must be created beside the database file"
1063 );
1064 }
1065
1066 #[test]
1067 fn blob_store_errors_for_in_memory_backend_with_no_override() {
1068 let backend = StorageBackend::memory().unwrap();
1069 assert!(backend.blob_store(None, None).is_err());
1070 }
1071
1072 #[test]
1073 fn blob_store_accepts_explicit_root_for_in_memory_backend() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let backend = StorageBackend::memory().unwrap();
1076 let store = backend.blob_store(Some(dir.path()), None);
1077 assert!(store.is_ok());
1078 }
1079
1080 #[test]
1081 fn apply_schema_runs_migrations_idempotently() {
1082 static MIGRATIONS: &[crate::migrations::Migration] = &[crate::migrations::Migration {
1083 id: "001_init",
1084 up_sql: "CREATE TABLE IF NOT EXISTS schema_test (id TEXT PRIMARY KEY);",
1085 down_sql: None,
1086 is_already_applied: None,
1087 }];
1088 let plan = crate::migrations::ServiceSchemaPlan {
1089 service: "schema_test_svc",
1090 sqlite: MIGRATIONS,
1091 postgres: &[],
1092 };
1093
1094 let backend = StorageBackend::memory().unwrap();
1095 backend.apply_schema(&plan).unwrap();
1096 backend.apply_schema(&plan).unwrap();
1097
1098 let reader = backend.pool().reader().unwrap();
1099 let count: i64 = reader
1100 .conn()
1101 .query_row(
1102 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_test'",
1103 [],
1104 |row| row.get(0),
1105 )
1106 .unwrap();
1107 assert_eq!(count, 1);
1108 }
1109
1110 fn issue_1029_pool(write_queue_enabled: bool) -> (tempfile::TempDir, StorageBackend) {
1119 let dir = tempfile::tempdir().unwrap();
1120 let path = dir.path().join("issue_1029.db");
1121 let config = crate::pool::PoolConfig {
1122 path: Some(path.clone()),
1123 busy_timeout: std::time::Duration::from_millis(200),
1124 write_queue_enabled,
1125 ..crate::pool::PoolConfig::default()
1126 };
1127 let pool = ConnectionPool::new(config).expect("fresh tenant-shaped pool should open");
1128 let backend = StorageBackend {
1129 pool: Arc::new(pool),
1130 is_file_backed: true,
1131 path: Some(path),
1132 notes_seq_repair_runs: AtomicUsize::new(0),
1133 };
1134 (dir, backend)
1135 }
1136
1137 async fn issue_1029_create_entity_shaped_sequence(
1138 backend: &StorageBackend,
1139 ) -> Result<(), String> {
1140 let entities = backend
1141 .entities_for_namespace("tenant_ns")
1142 .map_err(|e| format!("entities_for_namespace: {e}"))?;
1143 let entity = khive_storage::entity::Entity::new("tenant_ns", "concept", "Issue1029Repro");
1144 let entity_id = entity.id;
1145 entities
1146 .upsert_entity(entity)
1147 .await
1148 .map_err(|e| format!("upsert_entity: {e}"))?;
1149
1150 let text = backend.text("entities").map_err(|e| format!("text: {e}"))?;
1151 let doc = khive_storage::types::TextDocument {
1152 subject_id: entity_id,
1153 kind: khive_types::SubstrateKind::Entity,
1154 title: Some("Issue1029Repro".to_string()),
1155 body: "issue 1029 repro body".to_string(),
1156 tags: vec![],
1157 namespace: "tenant_ns".to_string(),
1158 metadata: None,
1159 updated_at: chrono::Utc::now(),
1160 };
1161 text.upsert_document(doc)
1162 .await
1163 .map_err(|e| format!("fts_upsert: {e}"))
1164 }
1165
1166 #[tokio::test]
1172 async fn issue_1029_create_entity_shaped_sequence_write_queue_off() {
1173 let (_dir, backend) = issue_1029_pool(false);
1174 let result = issue_1029_create_entity_shaped_sequence(&backend).await;
1175 assert!(
1176 result.is_ok(),
1177 "khive#1029 repro (KHIVE_WRITE_QUEUE off): fts_upsert step failed: {:?}",
1178 result.err()
1179 );
1180 }
1181
1182 #[tokio::test]
1188 async fn issue_1029_create_entity_shaped_sequence_write_queue_on() {
1189 let (_dir, backend) = issue_1029_pool(true);
1190 let result = issue_1029_create_entity_shaped_sequence(&backend).await;
1191 assert!(
1192 result.is_ok(),
1193 "khive#1029 repro (KHIVE_WRITE_QUEUE=1): fts_upsert step failed: {:?}",
1194 result.err()
1195 );
1196 }
1197
1198 #[tokio::test]
1206 async fn issue_1029_two_pools_same_file_write_queue_on() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let path = dir.path().join("issue_1029_two_pools.db");
1209
1210 let cfg = |p: std::path::PathBuf| crate::pool::PoolConfig {
1211 path: Some(p),
1212 busy_timeout: std::time::Duration::from_millis(200),
1213 write_queue_enabled: true,
1214 ..crate::pool::PoolConfig::default()
1215 };
1216
1217 let pool_a = ConnectionPool::new(cfg(path.clone())).expect("pool A should open");
1218 let backend_a = StorageBackend {
1219 pool: Arc::new(pool_a),
1220 is_file_backed: true,
1221 path: Some(path.clone()),
1222 notes_seq_repair_runs: AtomicUsize::new(0),
1223 };
1224 let pool_b = ConnectionPool::new(cfg(path.clone())).expect("pool B should open");
1225 let backend_b = StorageBackend {
1226 pool: Arc::new(pool_b),
1227 is_file_backed: true,
1228 path: Some(path),
1229 notes_seq_repair_runs: AtomicUsize::new(0),
1230 };
1231
1232 let entities = backend_a
1233 .entities_for_namespace("tenant_ns")
1234 .expect("entities_for_namespace on pool A");
1235 let entity =
1236 khive_storage::entity::Entity::new("tenant_ns", "concept", "Issue1029TwoPools");
1237 let entity_id = entity.id;
1238 entities
1239 .upsert_entity(entity)
1240 .await
1241 .expect("pool A entity upsert should succeed");
1242
1243 let text = backend_b.text("entities").expect("text on pool B");
1244 let doc = khive_storage::types::TextDocument {
1245 subject_id: entity_id,
1246 kind: khive_types::SubstrateKind::Entity,
1247 title: Some("Issue1029TwoPools".to_string()),
1248 body: "issue 1029 two-pool repro body".to_string(),
1249 tags: vec![],
1250 namespace: "tenant_ns".to_string(),
1251 metadata: None,
1252 updated_at: chrono::Utc::now(),
1253 };
1254 let result = text.upsert_document(doc).await;
1255 assert!(
1256 result.is_ok(),
1257 "khive#1029 two-pool repro: fts_upsert on an independent pool for the \
1258 same tenant DB file failed: {:?}",
1259 result.err()
1260 );
1261 }
1262
1263 struct StarvationCaptureSubscriber {
1266 events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
1267 }
1268
1269 impl tracing::Subscriber for StarvationCaptureSubscriber {
1270 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1271 true
1272 }
1273 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1274 tracing::span::Id::from_u64(1)
1275 }
1276 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1277 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1278 fn event(&self, event: &tracing::Event<'_>) {
1279 #[derive(Default)]
1280 struct FieldVisitor(std::collections::BTreeMap<String, String>);
1281 impl tracing::field::Visit for FieldVisitor {
1282 fn record_debug(
1283 &mut self,
1284 field: &tracing::field::Field,
1285 value: &dyn std::fmt::Debug,
1286 ) {
1287 self.0
1288 .insert(field.name().to_string(), format!("{value:?}"));
1289 }
1290 }
1291 let mut visitor = FieldVisitor::default();
1292 event.record(&mut visitor);
1293 self.events.lock().unwrap().push(visitor.0);
1294 }
1295 fn enter(&self, _: &tracing::span::Id) {}
1296 fn exit(&self, _: &tracing::span::Id) {}
1297 }
1298
1299 #[tokio::test]
1312 #[serial_test::serial(tx_registry)]
1313 async fn issue_1029_starvation_warn_reports_registered_transactions() {
1314 let (_dir, backend) = issue_1029_pool(false);
1315 let text = backend.text("entities").expect("text store");
1318
1319 let holder = backend
1323 .pool
1324 .open_standalone_writer()
1325 .expect("holder connection");
1326 holder
1327 .execute_batch("BEGIN IMMEDIATE")
1328 .expect("holder BEGIN IMMEDIATE");
1329 let fixture =
1330 khive_storage::tx_registry::register(Some("issue_1029_fixture_tx".to_string()));
1331
1332 let events = Arc::new(std::sync::Mutex::new(Vec::new()));
1333 let subscriber = StarvationCaptureSubscriber {
1334 events: Arc::clone(&events),
1335 };
1336 let guard = tracing::subscriber::set_default(subscriber);
1337
1338 let doc = khive_storage::types::TextDocument {
1339 subject_id: uuid::Uuid::new_v4(),
1340 kind: khive_types::SubstrateKind::Entity,
1341 title: Some("Issue1029Starved".to_string()),
1342 body: "issue 1029 starvation diagnostic body".to_string(),
1343 tags: vec![],
1344 namespace: "tenant_ns".to_string(),
1345 metadata: None,
1346 updated_at: chrono::Utc::now(),
1347 };
1348 let result = text.upsert_document(doc).await;
1349
1350 drop(guard);
1351 drop(fixture);
1352 holder
1353 .execute_batch("ROLLBACK")
1354 .expect("holder ROLLBACK releases the lock");
1355
1356 assert!(
1357 result.is_err(),
1358 "upsert_document must starve while another connection holds the write lock"
1359 );
1360
1361 let events = events.lock().unwrap();
1362 let warn = events
1363 .iter()
1364 .find(|fields| {
1365 fields
1366 .get("message")
1367 .is_some_and(|m| m.contains("text write starved"))
1368 })
1369 .unwrap_or_else(|| panic!("expected a starvation WARN, captured events: {events:?}"));
1370 assert!(
1371 warn.get("op").is_some_and(|op| op.contains("fts_upsert")),
1372 "WARN must name the starved operation, got: {warn:?}"
1373 );
1374 assert!(
1375 warn.get("open_txs")
1376 .is_some_and(|txs| txs.contains("issue_1029_fixture_tx")),
1377 "WARN must list the registered holder label, got: {warn:?}"
1378 );
1379 let count: usize = warn
1380 .get("open_tx_count")
1381 .expect("WARN must carry open_tx_count")
1382 .parse()
1383 .expect("open_tx_count must be numeric");
1384 assert!(
1385 count >= 1,
1386 "open_tx_count must count the fixture, got {count}"
1387 );
1388 }
1389}