Skip to main content

khive_db/
backend.rs

1//! Concrete storage backend providing capability traits.
2//!
3//! `StorageBackend` owns a `ConnectionPool` and provides factory methods for all
4//! capability traits (`GraphStore`, `NoteStore`, `EventStore`, `VectorStore`,
5//! `TextSearch`, `SqlAccess`). File-backed for production; in-memory for tests.
6
7use 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
18/// Concrete storage backend providing capability traits.
19pub struct StorageBackend {
20    pool: Arc<ConnectionPool>,
21    is_file_backed: bool,
22    path: Option<std::path::PathBuf>,
23    /// How many times the lazy `notes_seq` anti-join repair has actually
24    /// executed against this backend's pool. Gates `notes_for_namespace` so
25    /// the repair (a full `notes` scan) runs at most once per backend for
26    /// the process's lifetime instead of on every store acquisition (khive
27    /// #827 round 4 perf finding). Also exposed via
28    /// `notes_seq_repair_run_count` for regression tests.
29    notes_seq_repair_runs: AtomicUsize,
30}
31
32impl StorageBackend {
33    /// File-backed SQLite database.
34    ///
35    /// Opens (or creates) the database at `path`. The underlying pool provides
36    /// 1 writer + N readers in WAL mode for concurrent access.
37    /// No schema is applied — call `apply_schema()` for each service.
38    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    /// File-backed SQLite database opened read-only.
55    ///
56    /// Opens the database at `path` and sets `PRAGMA query_only = ON` on the
57    /// writer connection so that any write attempt (INSERT/UPDATE/DELETE) returns
58    /// an error. Reader connections are opened with `SQLITE_OPEN_READ_ONLY` by the
59    /// pool; this PRAGMA extends that protection to the writer slot.
60    ///
61    /// The database file must already exist — unlike `sqlite()` this constructor
62    /// does not create a new file.
63    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        // `ConnectionPool::new` opens the writer slot with `SQLITE_OPEN_READ_ONLY`
72        // (no `SQLITE_OPEN_CREATE`) and sets `PRAGMA query_only = ON` on it, so a
73        // missing path is rejected instead of created, and any write attempt is
74        // rejected at the SQLite level regardless of which code path reaches the
75        // writer.
76        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    /// In-memory SQLite database (for tests).
86    ///
87    /// All data is lost when the backend is dropped. The pool degrades to
88    /// single-connection mode since in-memory databases cannot be shared
89    /// across multiple connections.
90    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    /// Get the SQL access capability.
106    ///
107    /// Returns an `Arc<dyn SqlAccess>` suitable for passing to services.
108    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    /// Apply a service's schema plan (run migrations).
113    ///
114    /// Each migration in the plan's `sqlite` list is applied idempotently.
115    /// Already-applied migrations are skipped. The `_schema_versions` table
116    /// tracks which migrations have been run.
117    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    /// Apply pack-auxiliary DDL statements.
126    ///
127    /// Executes each DDL statement idempotently via `execute_batch`. Each
128    /// statement MUST be self-contained and use `CREATE TABLE IF NOT EXISTS`
129    /// (or equivalent idempotent DDL) so that calling this method more than
130    /// once does not fail.
131    ///
132    /// Pack auxiliary tables are NOT tracked in `_schema_versions` — they are
133    /// non-versioned. Use `apply_schema` with a `ServiceSchemaPlan` when version
134    /// tracking is needed.
135    ///
136    /// This method is lower-level than `PackRuntime::schema_plan()` — the
137    /// runtime bootstrap calls `pack.schema_plan().statements` and passes the
138    /// slice here. The `SchemaPlan` type lives in `khive-runtime` (above this
139    /// crate in the dep chain); this method accepts a plain `&[&'static str]`
140    /// to avoid a circular dependency.
141    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    /// Get an EntityStore. Applies the entities DDL if not already present.
153    ///
154    /// Idempotent — safe to call multiple times.
155    pub fn entities(&self) -> Result<Arc<dyn khive_storage::EntityStore>, SqliteError> {
156        self.entities_for_namespace("local")
157    }
158
159    /// Get an EntityStore. The namespace parameter is validated (non-empty) and
160    /// the entities schema is applied, but the store itself is unscoped — namespace
161    /// is the caller's responsibility on each query/delete call.
162    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    /// Get a GraphStore for the default namespace.
181    ///
182    /// Creates the `graph_edges` table (with indexes) if it does not already
183    /// exist. Idempotent — safe to call multiple times.
184    pub fn graph(&self) -> Result<Arc<dyn khive_storage::GraphStore>, SqliteError> {
185        self.graph_for_namespace("local")
186    }
187
188    /// Get a GraphStore scoped to a namespace.
189    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    /// Get a NoteStore. Applies the notes DDL if not already present.
209    ///
210    /// Idempotent — safe to call multiple times.
211    pub fn notes(&self) -> Result<Arc<dyn khive_storage::NoteStore>, SqliteError> {
212        self.notes_for_namespace("local")
213    }
214
215    /// Get a NoteStore. The namespace parameter is validated (non-empty) and
216    /// the notes schema is applied, but the store itself is unscoped — namespace
217    /// is the caller's responsibility on each query/delete call.
218    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        // The anti-join repair is a full `notes` scan -- gate it to run at
231        // most once per backend/pool. `try_writer()` blocks for exclusive
232        // access to the single writer connection for this whole function,
233        // so this load-then-run-then-store is race-free: no other caller on
234        // this pool can observe or advance `notes_seq_repair_runs` while we
235        // hold the writer guard (khive #827 round 4 perf finding).
236        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    /// How many times the lazy `notes_seq` anti-join repair has actually
248    /// executed against this backend's pool. Exposed for regression tests
249    /// asserting the repair runs at most once per backend for the process's
250    /// lifetime, not once per `notes_for_namespace` call (khive #827 round 4
251    /// perf finding).
252    pub fn notes_seq_repair_run_count(&self) -> usize {
253        self.notes_seq_repair_runs.load(Ordering::Relaxed)
254    }
255
256    /// Get an EventStore for the default namespace.
257    ///
258    /// Creates the `events` table (with indexes) if it does not already exist.
259    /// Idempotent — safe to call multiple times.
260    pub fn events(&self) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
261        self.events_for_namespace("local")
262    }
263
264    /// Get an EventStore scoped to a namespace.
265    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    /// Get a VectorStore for a specific embedding model, scoped to the default namespace.
285    ///
286    /// Creates the vec0 virtual table if it does not already exist. The `model_key`
287    /// must contain only ASCII alphanumeric/underscore characters. The `embedding_model`
288    /// is the canonical display name stored in each vector row.
289    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    /// Get a VectorStore for a specific embedding model with a default namespace.
299    ///
300    /// Creates the vec0 virtual table if it does not already exist. The `namespace`
301    /// is a default for trait methods that lack a per-call namespace parameter
302    /// (count, delete, info). Access control is enforced at the runtime layer.
303    ///
304    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
305    /// The `embedding_model` is the canonical display name stored in the `embedding_model`
306    /// column of each vector row (e.g. `"all-minilm-l6-v2"`).
307    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        // Ensure sqlite-vec is registered before creating vec0 tables.
332        crate::extension::ensure_extensions_loaded();
333
334        let table = format!("vec_{}", model_key);
335        let writer = self.pool.try_writer()?;
336
337        // Detect old-schema vec0 tables that predate the `field` column.
338        // vec0 virtual tables do not support ALTER TABLE, so we must drop and recreate
339        // the table if it exists without the `field` column. Vector data is a cache —
340        // callers can re-embed from the source record after the table is rebuilt.
341        // Use pragma_table_info to check columns directly; substring matching on the
342        // CREATE DDL is fragile (a model_key containing "field" would false-match).
343        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            // V17 migration (vector_embedding_model_tag_preserving_rebuild) adds
356            // `field` and `embedding_model` to all pre-existing vec0 tables at
357            // migration time.  If this table still lacks either column post-migration
358            // that indicates the database was not migrated — return a hard error
359            // rather than silently dropping data.
360            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        // Ensure the _embedding_models registry table exists.
385        // This is a no-op when the table already exists. Running it here ensures
386        // the registry is present for any caller that opens a vector store without
387        // first calling run_migrations() (e.g., tests that create stores directly).
388        // Production callers are expected to call run_migrations() at startup, which
389        // creates the registry via V14; this is a belt-and-suspenders fallback.
390        // Schema is defined in `migrations::EMBEDDING_MODELS_DDL` (single source of
391        // truth) to prevent the two copies from silently drifting.
392        writer
393            .conn()
394            .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
395
396        // Create the vec0 virtual table. Idempotent on fresh databases and after the
397        // old-schema rebuild above.
398        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    /// Register an embedding model in the `_embedding_models` registry table.
422    ///
423    /// Idempotent: if a row with the same `canonical_key` already exists, updates its
424    /// status back to `'active'` without changing other fields.
425    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    /// Get a SparseStore for a specific model key, scoped to the default namespace.
464    ///
465    /// Creates the sparse table if it does not already exist.
466    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    /// Get a SparseStore for a specific model key with an explicit default namespace.
474    ///
475    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
476    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    /// Get a TextSearch for a specific table key.
509    ///
510    /// Creates the FTS5 virtual table if it does not already exist. Uses the
511    /// `trigram` tokenizer by default (CJK-safe).
512    ///
513    /// The `table_key` must contain only ASCII alphanumeric/underscore characters.
514    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    /// Get a TextSearch with an explicit FTS5 tokenizer.
519    ///
520    /// Use when you need a tokenizer other than the default `trigram` — for
521    /// example `unicode61` for Latin-only corpora.
522    ///
523    /// Both `table_key` and `tokenizer` must contain only ASCII
524    /// alphanumeric/underscore characters.
525    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    /// Is this a file-backed backend?
578    pub fn is_file_backed(&self) -> bool {
579        self.is_file_backed
580    }
581
582    /// Return the directory containing the backend's database file, or `None`
583    /// for an in-memory backend.
584    pub fn data_dir(&self) -> Option<std::path::PathBuf> {
585        self.path.as_ref()?.parent().map(|p| p.to_path_buf())
586    }
587
588    /// Access the underlying pool (escape hatch).
589    pub fn pool(&self) -> &ConnectionPool {
590        &self.pool
591    }
592
593    /// Clone the underlying pool Arc.
594    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        // Create the database and a table while writable.
742        {
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        // Writer acquisition itself must fail for a read-only backend.
756        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        // Create the database and the graph schema while writable.
913        {
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            // Failing to even open the store on a read-only backend is an
922            // acceptable rejection — the write path never becomes reachable.
923            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}