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::{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 {
253 self.notes_seq_repair_runs.load(Ordering::Relaxed)
254 }
255
256 pub fn events(&self) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
261 self.events_for_namespace("local")
262 }
263
264 pub fn events_for_namespace(
266 &self,
267 namespace: &str,
268 ) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
269 if namespace.trim().is_empty() {
270 return Err(SqliteError::InvalidData(
271 "events namespace must be non-empty".to_string(),
272 ));
273 }
274 let writer = self.pool.try_writer()?;
275 event::ensure_events_schema(writer.conn())?;
276
277 Ok(Arc::new(event::SqlEventStore::new_scoped(
278 Arc::clone(&self.pool),
279 self.is_file_backed,
280 namespace.trim().to_string(),
281 )))
282 }
283
284 pub fn vectors(
290 &self,
291 model_key: &str,
292 embedding_model: &str,
293 dimensions: usize,
294 ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
295 self.vectors_for_namespace(model_key, embedding_model, dimensions, "local")
296 }
297
298 pub fn vectors_for_namespace(
308 &self,
309 model_key: &str,
310 embedding_model: &str,
311 dimensions: usize,
312 namespace: &str,
313 ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
314 if model_key.is_empty()
315 || !model_key
316 .chars()
317 .all(|c| c.is_ascii_alphanumeric() || c == '_')
318 {
319 return Err(SqliteError::InvalidData(format!(
320 "invalid model_key '{}': must be non-empty and contain only \
321 alphanumeric/underscore characters",
322 model_key
323 )));
324 }
325 if namespace.trim().is_empty() {
326 return Err(SqliteError::InvalidData(
327 "vector store namespace must be non-empty".to_string(),
328 ));
329 }
330
331 crate::extension::ensure_extensions_loaded();
333
334 let table = format!("vec_{}", model_key);
335 let writer = self.pool.try_writer()?;
336
337 let table_exists: bool = writer
344 .conn()
345 .query_row(
346 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
347 rusqlite::params![&table],
348 |row| row.get::<_, i64>(0),
349 )
350 .optional()
351 .map_err(SqliteError::Rusqlite)?
352 .is_some();
353
354 if table_exists {
355 let pragma = format!("PRAGMA table_xinfo({})", table);
361 let mut stmt = writer.conn().prepare(&pragma)?;
362 let mut rows = stmt.query([])?;
363 let mut has_field = false;
364 let mut has_embedding_model = false;
365 while let Some(row) = rows.next()? {
366 let name: String = row.get(1)?;
367 if name == "field" {
368 has_field = true;
369 }
370 if name == "embedding_model" {
371 has_embedding_model = true;
372 }
373 }
374 if !has_field || !has_embedding_model {
375 return Err(SqliteError::InvalidData(format!(
376 "vec0 table '{}' is missing required column(s) (field={}, \
377 embedding_model={}); this is a pre-v0.2.8 vector schema and is \
378 not supported — recreate the database",
379 table, has_field, has_embedding_model,
380 )));
381 }
382 }
383
384 writer
393 .conn()
394 .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
395
396 let ddl = format!(
399 "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
400 subject_id TEXT PRIMARY KEY, \
401 namespace TEXT NOT NULL, \
402 kind TEXT NOT NULL, \
403 field TEXT NOT NULL, \
404 embedding_model TEXT NOT NULL, \
405 embedding float[{}] distance_metric=cosine\
406 )",
407 model_key, dimensions
408 );
409 writer.conn().execute_batch(&ddl)?;
410
411 Ok(Arc::new(vectors::SqliteVecStore::new(
412 Arc::clone(&self.pool),
413 self.is_file_backed,
414 model_key.to_string(),
415 embedding_model.to_string(),
416 dimensions,
417 namespace.trim().to_string(),
418 )?))
419 }
420
421 pub fn register_embedding_model(
426 &self,
427 engine_name: &str,
428 model_id: &str,
429 key_version: &str,
430 dimensions: u32,
431 ) -> Result<(), SqliteError> {
432 let writer = self.pool.try_writer()?;
433 writer
434 .conn()
435 .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
436
437 let now = chrono::Utc::now().timestamp_micros();
438 let canonical_key =
439 format!("{engine_name}:{model_id}:{key_version}:{dimensions}").into_bytes();
440 let id = uuid::Uuid::new_v4();
441 writer.conn().execute(
442 "INSERT INTO _embedding_models \
443 (id, engine_name, model_id, key_version, dim, output_dim, status, \
444 activated_at, superseded_at, superseded_by, canonical_key, created_at) \
445 VALUES (?1, ?2, ?3, ?4, ?5, NULL, 'active', ?6, NULL, NULL, ?7, ?8) \
446 ON CONFLICT(canonical_key) DO UPDATE SET \
447 status = 'active', \
448 activated_at = COALESCE(_embedding_models.activated_at, excluded.activated_at)",
449 rusqlite::params![
450 id.as_bytes().as_slice(),
451 engine_name,
452 model_id,
453 key_version,
454 dimensions as i64,
455 now,
456 canonical_key,
457 now,
458 ],
459 )?;
460 Ok(())
461 }
462
463 pub fn sparse(
467 &self,
468 model_key: &str,
469 ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
470 self.sparse_for_namespace(model_key, "local")
471 }
472
473 pub fn sparse_for_namespace(
477 &self,
478 model_key: &str,
479 namespace: &str,
480 ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
481 if model_key.is_empty()
482 || !model_key
483 .chars()
484 .all(|c| c.is_ascii_alphanumeric() || c == '_')
485 {
486 return Err(SqliteError::InvalidData(format!(
487 "invalid model_key '{}': must be non-empty and contain only alphanumeric/underscore characters",
488 model_key
489 )));
490 }
491 if namespace.trim().is_empty() {
492 return Err(SqliteError::InvalidData(
493 "sparse store namespace must be non-empty".to_string(),
494 ));
495 }
496
497 let writer = self.pool.try_writer()?;
498 sparse::ensure_sparse_schema(writer.conn(), model_key).map_err(SqliteError::Rusqlite)?;
499
500 Ok(Arc::new(sparse::SqliteSparseStore::new(
501 Arc::clone(&self.pool),
502 self.is_file_backed,
503 model_key.to_string(),
504 namespace.trim().to_string(),
505 )?))
506 }
507
508 pub fn text(&self, table_key: &str) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
515 self.text_with_tokenizer(table_key, "trigram")
516 }
517
518 pub fn text_with_tokenizer(
526 &self,
527 table_key: &str,
528 tokenizer: &str,
529 ) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
530 if table_key.is_empty()
531 || !table_key
532 .chars()
533 .all(|c| c.is_ascii_alphanumeric() || c == '_')
534 {
535 return Err(SqliteError::InvalidData(format!(
536 "invalid table_key '{}': must be non-empty and contain only \
537 alphanumeric/underscore characters",
538 table_key
539 )));
540 }
541 if tokenizer.is_empty()
542 || !tokenizer
543 .chars()
544 .all(|c| c.is_ascii_alphanumeric() || c == '_')
545 {
546 return Err(SqliteError::InvalidData(format!(
547 "invalid tokenizer '{}': must be non-empty and contain only \
548 alphanumeric/underscore characters",
549 tokenizer
550 )));
551 }
552
553 let ddl = format!(
554 "CREATE VIRTUAL TABLE IF NOT EXISTS fts_{} USING fts5(\
555 subject_id UNINDEXED, \
556 kind UNINDEXED, \
557 title, \
558 body, \
559 tags UNINDEXED, \
560 namespace UNINDEXED, \
561 metadata UNINDEXED, \
562 updated_at UNINDEXED, \
563 tokenize = '{}'\
564 )",
565 table_key, tokenizer
566 );
567 let writer = self.pool.try_writer()?;
568 writer.conn().execute_batch(&ddl)?;
569
570 Ok(Arc::new(text::Fts5TextSearch::new(
571 Arc::clone(&self.pool),
572 self.is_file_backed,
573 table_key.to_string(),
574 )))
575 }
576
577 pub fn is_file_backed(&self) -> bool {
579 self.is_file_backed
580 }
581
582 pub fn data_dir(&self) -> Option<std::path::PathBuf> {
585 self.path.as_ref()?.parent().map(|p| p.to_path_buf())
586 }
587
588 pub fn pool(&self) -> &ConnectionPool {
590 &self.pool
591 }
592
593 pub fn pool_arc(&self) -> Arc<ConnectionPool> {
595 Arc::clone(&self.pool)
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use khive_storage::types::{SqlStatement, SqlValue};
603
604 #[test]
605 fn memory_backend_creates_successfully() {
606 let backend = StorageBackend::memory().expect("memory backend should create");
607 assert!(!backend.is_file_backed());
608 }
609
610 #[test]
611 fn file_backend_creates_successfully() {
612 let dir = tempfile::tempdir().unwrap();
613 let path = dir.path().join("test.db");
614 let backend = StorageBackend::sqlite(&path).expect("file backend should create");
615 assert!(backend.is_file_backed());
616 assert!(path.exists());
617 }
618
619 #[test]
620 fn data_dir_returns_none_for_memory_backend() {
621 let backend = StorageBackend::memory().expect("memory backend");
622 assert!(backend.data_dir().is_none());
623 }
624
625 #[test]
626 fn data_dir_returns_parent_dir_for_file_backend() {
627 let dir = tempfile::tempdir().unwrap();
628 let path = dir.path().join("data.db");
629 let backend = StorageBackend::sqlite(&path).expect("file backend");
630 let got = backend.data_dir().expect("file backend must return Some");
631 assert_eq!(got, dir.path());
632 }
633
634 #[tokio::test]
635 async fn sql_access_memory_roundtrip() {
636 let backend = StorageBackend::memory().unwrap();
637 let sql = backend.sql();
638
639 let mut writer = sql.writer().await.unwrap();
640 writer
641 .execute_script(
642 "CREATE TABLE test_rt (id TEXT PRIMARY KEY, value INTEGER NOT NULL)".into(),
643 )
644 .await
645 .unwrap();
646
647 let affected = writer
648 .execute(SqlStatement {
649 sql: "INSERT INTO test_rt (id, value) VALUES (?1, ?2)".into(),
650 params: vec![SqlValue::Text("row1".into()), SqlValue::Integer(42)],
651 label: None,
652 })
653 .await
654 .unwrap();
655 assert_eq!(affected, 1);
656
657 let mut reader = sql.reader().await.unwrap();
658 let row = reader
659 .query_row(SqlStatement {
660 sql: "SELECT id, value FROM test_rt WHERE id = ?1".into(),
661 params: vec![SqlValue::Text("row1".into())],
662 label: None,
663 })
664 .await
665 .unwrap();
666
667 let row = row.expect("should find the inserted row");
668 assert_eq!(row.columns.len(), 2);
669 match &row.columns[0].value {
670 SqlValue::Text(s) => assert_eq!(s, "row1"),
671 other => panic!("expected Text, got {other:?}"),
672 }
673 match &row.columns[1].value {
674 SqlValue::Integer(v) => assert_eq!(*v, 42),
675 other => panic!("expected Integer, got {other:?}"),
676 }
677 }
678
679 #[tokio::test]
680 async fn sql_access_file_roundtrip() {
681 let dir = tempfile::tempdir().unwrap();
682 let path = dir.path().join("test_roundtrip.db");
683 let backend = StorageBackend::sqlite(&path).unwrap();
684 let sql = backend.sql();
685
686 let mut writer = sql.writer().await.unwrap();
687 writer
688 .execute_script("CREATE TABLE test_f (k TEXT PRIMARY KEY, v TEXT)".into())
689 .await
690 .unwrap();
691 writer
692 .execute(SqlStatement {
693 sql: "INSERT INTO test_f (k, v) VALUES (?1, ?2)".into(),
694 params: vec![
695 SqlValue::Text("hello".into()),
696 SqlValue::Text("world".into()),
697 ],
698 label: None,
699 })
700 .await
701 .unwrap();
702
703 let mut reader = sql.reader().await.unwrap();
704 let rows = reader
705 .query_all(SqlStatement {
706 sql: "SELECT k, v FROM test_f".into(),
707 params: vec![],
708 label: None,
709 })
710 .await
711 .unwrap();
712 assert_eq!(rows.len(), 1);
713 match &rows[0].columns[1].value {
714 SqlValue::Text(s) => assert_eq!(s, "world"),
715 other => panic!("expected Text, got {other:?}"),
716 }
717 }
718
719 #[test]
720 fn sqlite_read_only_missing_path_does_not_create_file() {
721 let dir = tempfile::tempdir().unwrap();
722 let path = dir.path().join("missing_ro.db");
723 assert!(!path.exists());
724
725 let result = StorageBackend::sqlite_read_only(&path);
726 assert!(
727 result.is_err(),
728 "opening a missing path read-only must fail"
729 );
730 assert!(
731 !path.exists(),
732 "opening a missing path read-only must not create the file"
733 );
734 }
735
736 #[tokio::test]
737 async fn sqlite_read_only_sql_writer_rejects_ddl_and_insert() {
738 let dir = tempfile::tempdir().unwrap();
739 let path = dir.path().join("ro_writer.db");
740
741 {
743 let writable = StorageBackend::sqlite(&path).unwrap();
744 let sql = writable.sql();
745 let mut writer = sql.writer().await.unwrap();
746 writer
747 .execute_script("CREATE TABLE ro_existing (id INTEGER PRIMARY KEY)".into())
748 .await
749 .unwrap();
750 }
751
752 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
753 let sql = ro.sql();
754
755 let writer_result = sql.writer().await;
757 assert!(
758 writer_result.is_err(),
759 "sql().writer() must be rejected on a read-only backend"
760 );
761 }
762
763 #[tokio::test]
764 #[cfg(feature = "vectors")]
765 async fn vectors_roundtrip_via_public_api() {
766 let backend = StorageBackend::memory().unwrap();
767 let store = backend.vectors("test_api", "test_api", 3).unwrap();
768
769 let id = uuid::Uuid::new_v4();
770 store
771 .insert(
772 id,
773 khive_types::SubstrateKind::Entity,
774 "local",
775 "content",
776 vec![vec![1.0, 0.0, 0.0]],
777 )
778 .await
779 .unwrap();
780
781 let hits = store
782 .search(khive_storage::types::VectorSearchRequest {
783 query_vectors: vec![vec![1.0, 0.0, 0.0]],
784 top_k: 1,
785 namespace: None,
786 kind: None,
787 embedding_model: None,
788 filter: None,
789 backend_hints: None,
790 })
791 .await
792 .unwrap();
793
794 assert_eq!(hits.len(), 1);
795 assert_eq!(hits[0].subject_id, id);
796 assert!(hits[0].score.to_f64() > 0.99);
797 }
798
799 #[tokio::test]
800 #[cfg(feature = "vectors")]
801 async fn vectors_creates_table_idempotently() {
802 let backend = StorageBackend::memory().unwrap();
803
804 let store1 = backend.vectors("idempotent", "idempotent", 3).unwrap();
805 let store2 = backend.vectors("idempotent", "idempotent", 3).unwrap();
806
807 let id = uuid::Uuid::new_v4();
808 store1
809 .insert(
810 id,
811 khive_types::SubstrateKind::Entity,
812 "local",
813 "content",
814 vec![vec![1.0, 0.0, 0.0]],
815 )
816 .await
817 .unwrap();
818
819 let count = store2.count().await.unwrap();
820 assert_eq!(count, 1);
821 }
822
823 #[tokio::test]
824 async fn text_roundtrip_via_public_api() {
825 let backend = StorageBackend::memory().unwrap();
826 let store = backend.text("test_api").unwrap();
827
828 let id = uuid::Uuid::new_v4();
829 let doc = khive_storage::types::TextDocument {
830 subject_id: id,
831 kind: khive_types::SubstrateKind::Entity,
832 title: Some("Test Title".to_string()),
833 body: "This is a searchable document about Rust.".to_string(),
834 tags: vec!["rust".to_string()],
835 namespace: "test_ns".to_string(),
836 metadata: None,
837 updated_at: chrono::Utc::now(),
838 };
839 store.upsert_document(doc).await.unwrap();
840
841 let hits = store
842 .search(khive_storage::types::TextSearchRequest {
843 query: "Rust".to_string(),
844 mode: khive_storage::types::TextQueryMode::Plain,
845 filter: Some(khive_storage::types::TextFilter {
846 namespaces: vec!["test_ns".to_string()],
847 ..Default::default()
848 }),
849 top_k: 1,
850 snippet_chars: 64,
851 })
852 .await
853 .unwrap();
854
855 assert_eq!(hits.len(), 1);
856 assert_eq!(hits[0].subject_id, id);
857 assert!(hits[0].score.to_f64() > 0.0);
858 }
859
860 #[tokio::test]
861 async fn text_creates_table_idempotently() {
862 let backend = StorageBackend::memory().unwrap();
863
864 let store1 = backend.text("idempotent_fts").unwrap();
865 let store2 = backend.text("idempotent_fts").unwrap();
866
867 let id = uuid::Uuid::new_v4();
868 let doc = khive_storage::types::TextDocument {
869 subject_id: id,
870 kind: khive_types::SubstrateKind::Note,
871 title: None,
872 body: "Hello world.".to_string(),
873 tags: vec![],
874 namespace: "test_ns".to_string(),
875 metadata: None,
876 updated_at: chrono::Utc::now(),
877 };
878 store1.upsert_document(doc).await.unwrap();
879
880 let count = store2
881 .count(khive_storage::types::TextFilter {
882 namespaces: vec!["test_ns".to_string()],
883 ..Default::default()
884 })
885 .await
886 .unwrap();
887 assert_eq!(count, 1);
888 }
889
890 #[test]
891 fn invalid_model_key_rejected() {
892 let backend = StorageBackend::memory().unwrap();
893 assert!(backend.vectors("bad key!", "bad key!", 3).is_err());
894 assert!(backend.vectors("", "", 3).is_err());
895 }
896
897 #[test]
898 fn invalid_table_key_rejected() {
899 let backend = StorageBackend::memory().unwrap();
900 assert!(backend.text("bad key!").is_err());
901 assert!(backend.text("").is_err());
902 }
903
904 #[tokio::test]
905 async fn sqlite_read_only_graph_store_rejects_upsert_edge() {
906 use khive_storage::types::Edge;
907 use khive_types::EdgeRelation;
908
909 let dir = tempfile::tempdir().unwrap();
910 let path = dir.path().join("ro_graph.db");
911
912 {
914 let writable = StorageBackend::sqlite(&path).unwrap();
915 writable.graph().unwrap();
916 }
917
918 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
919 let store = match ro.graph() {
920 Ok(store) => store,
921 Err(_) => return,
924 };
925
926 let now = chrono::Utc::now();
927 let edge = Edge {
928 id: uuid::Uuid::new_v4().into(),
929 namespace: "local".to_string(),
930 source_id: uuid::Uuid::new_v4(),
931 target_id: uuid::Uuid::new_v4(),
932 relation: EdgeRelation::Extends,
933 weight: 0.8,
934 created_at: now,
935 updated_at: now,
936 deleted_at: None,
937 metadata: None,
938 target_backend: None,
939 };
940
941 let result = store.upsert_edge(edge).await;
942 assert!(
943 result.is_err(),
944 "upsert_edge on a read-only backend must reject, not silently no-op"
945 );
946 }
947
948 #[tokio::test]
949 async fn sqlite_read_only_event_store_rejects_append_event() {
950 use khive_types::{EventKind, EventOutcome, SubstrateKind};
951
952 let dir = tempfile::tempdir().unwrap();
953 let path = dir.path().join("ro_events.db");
954
955 {
956 let writable = StorageBackend::sqlite(&path).unwrap();
957 writable.events().unwrap();
958 }
959
960 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
961 let store = match ro.events() {
962 Ok(store) => store,
963 Err(_) => return,
964 };
965
966 let event = khive_storage::event::Event::new(
967 "local",
968 "test.verb",
969 EventKind::Audit,
970 SubstrateKind::Entity,
971 "test-actor",
972 )
973 .with_outcome(EventOutcome::Success);
974
975 let result = store.append_event(event).await;
976 assert!(
977 result.is_err(),
978 "append_event on a read-only backend must reject, not silently no-op"
979 );
980 }
981
982 #[tokio::test]
983 async fn sqlite_read_only_text_store_rejects_upsert_document() {
984 use khive_storage::types::TextDocument;
985 use khive_types::SubstrateKind;
986
987 let dir = tempfile::tempdir().unwrap();
988 let path = dir.path().join("ro_text.db");
989
990 {
991 let writable = StorageBackend::sqlite(&path).unwrap();
992 writable.text("ro_test").unwrap();
993 }
994
995 let ro = StorageBackend::sqlite_read_only(&path).unwrap();
996 let store = match ro.text("ro_test") {
997 Ok(store) => store,
998 Err(_) => return,
999 };
1000
1001 let doc = TextDocument {
1002 subject_id: uuid::Uuid::new_v4(),
1003 kind: SubstrateKind::Entity,
1004 title: Some("Title".to_string()),
1005 body: "Body text.".to_string(),
1006 tags: vec![],
1007 namespace: "local".to_string(),
1008 metadata: None,
1009 updated_at: chrono::Utc::now(),
1010 };
1011
1012 let result = store.upsert_document(doc).await;
1013 assert!(
1014 result.is_err(),
1015 "upsert_document on a read-only backend must reject, not silently no-op"
1016 );
1017 }
1018
1019 #[test]
1020 fn apply_schema_runs_migrations_idempotently() {
1021 static MIGRATIONS: &[crate::migrations::Migration] = &[crate::migrations::Migration {
1022 id: "001_init",
1023 up_sql: "CREATE TABLE IF NOT EXISTS schema_test (id TEXT PRIMARY KEY);",
1024 down_sql: None,
1025 is_already_applied: None,
1026 }];
1027 let plan = crate::migrations::ServiceSchemaPlan {
1028 service: "schema_test_svc",
1029 sqlite: MIGRATIONS,
1030 postgres: &[],
1031 };
1032
1033 let backend = StorageBackend::memory().unwrap();
1034 backend.apply_schema(&plan).unwrap();
1035 backend.apply_schema(&plan).unwrap();
1036
1037 let reader = backend.pool().reader().unwrap();
1038 let count: i64 = reader
1039 .conn()
1040 .query_row(
1041 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_test'",
1042 [],
1043 |row| row.get(0),
1044 )
1045 .unwrap();
1046 assert_eq!(count, 1);
1047 }
1048}