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::{blob, 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). 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).
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).
251    pub fn notes_seq_repair_run_count(&self) -> usize {
252        self.notes_seq_repair_runs.load(Ordering::Relaxed)
253    }
254
255    /// Get an EventStore for the default namespace.
256    ///
257    /// Creates the `events` table (with indexes) if it does not already exist.
258    /// Idempotent — safe to call multiple times.
259    pub fn events(&self) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
260        self.events_for_namespace("local")
261    }
262
263    /// Get an EventStore scoped to a namespace.
264    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    /// Get a VectorStore for a specific embedding model, scoped to the default namespace.
284    ///
285    /// Creates the vec0 virtual table if it does not already exist. The `model_key`
286    /// must contain only ASCII alphanumeric/underscore characters. The `embedding_model`
287    /// is the canonical display name stored in each vector row.
288    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    /// Get a VectorStore for a specific embedding model with a default namespace.
298    ///
299    /// Creates the vec0 virtual table if it does not already exist. The `namespace`
300    /// is a default for trait methods that lack a per-call namespace parameter
301    /// (count, delete, info). Access control is enforced at the runtime layer.
302    ///
303    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
304    /// The `embedding_model` is the canonical display name stored in the `embedding_model`
305    /// column of each vector row (e.g. `"all-minilm-l6-v2"`).
306    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        // Ensure sqlite-vec is registered before creating vec0 tables.
331        crate::extension::ensure_extensions_loaded();
332
333        let table = format!("vec_{}", model_key);
334        let writer = self.pool.try_writer()?;
335
336        // Detect old-schema vec0 tables that predate the `field` column.
337        // vec0 virtual tables do not support ALTER TABLE, so we must drop and recreate
338        // the table if it exists without the `field` column. Vector data is a cache —
339        // callers can re-embed from the source record after the table is rebuilt.
340        // Use pragma_table_info to check columns directly; substring matching on the
341        // CREATE DDL is fragile (a model_key containing "field" would false-match).
342        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            // V17 migration (vector_embedding_model_tag_preserving_rebuild) adds
355            // `field` and `embedding_model` to all pre-existing vec0 tables at
356            // migration time.  If this table still lacks either column post-migration
357            // that indicates the database was not migrated — return a hard error
358            // rather than silently dropping data.
359            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        // Ensure the _embedding_models registry table exists.
384        // This is a no-op when the table already exists. Running it here ensures
385        // the registry is present for any caller that opens a vector store without
386        // first calling run_migrations() (e.g., tests that create stores directly).
387        // Production callers are expected to call run_migrations() at startup, which
388        // creates the registry via V14; this is a belt-and-suspenders fallback.
389        // Schema is defined in `migrations::EMBEDDING_MODELS_DDL` (single source of
390        // truth) to prevent the two copies from silently drifting.
391        writer
392            .conn()
393            .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
394
395        // Create the vec0 virtual table. Idempotent on fresh databases and after the
396        // old-schema rebuild above.
397        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    /// Register an embedding model in the `_embedding_models` registry table.
421    ///
422    /// Idempotent: if a row with the same `canonical_key` already exists, updates its
423    /// status back to `'active'` without changing other fields.
424    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    /// Get a SparseStore for a specific model key, scoped to the default namespace.
463    ///
464    /// Creates the sparse table if it does not already exist.
465    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    /// Get a SparseStore for a specific model key with an explicit default namespace.
473    ///
474    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
475    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    /// Get a TextSearch for a specific table key.
508    ///
509    /// Creates the FTS5 virtual table if it does not already exist. Uses the
510    /// `trigram` tokenizer by default (CJK-safe).
511    ///
512    /// The `table_key` must contain only ASCII alphanumeric/underscore characters.
513    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    /// Get a TextSearch with an explicit FTS5 tokenizer.
518    ///
519    /// Use when you need a tokenizer other than the default `trigram` — for
520    /// example `unicode61` for Latin-only corpora.
521    ///
522    /// Both `table_key` and `tokenizer` must contain only ASCII
523    /// alphanumeric/underscore characters.
524    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    /// Get a `BlobStore` rooted per khive#292's precedence chain:
577    /// `KHIVE_BLOB_ROOT` env var > `config_root` (a caller-resolved
578    /// `khive.toml` override — `khive-db` has no TOML parser of its own) >
579    /// beside this backend's database directory. `floor_bytes` overrides the
580    /// default 100 GB fail-closed free-space floor (`None` keeps the
581    /// default). Errors if none of the three roots apply — e.g. an in-memory
582    /// backend with no override and no env var has nowhere to default to.
583    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    /// Is this a file-backed backend?
594    pub fn is_file_backed(&self) -> bool {
595        self.is_file_backed
596    }
597
598    /// Return the directory containing the backend's database file, or `None`
599    /// for an in-memory backend.
600    pub fn data_dir(&self) -> Option<std::path::PathBuf> {
601        self.path.as_ref()?.parent().map(|p| p.to_path_buf())
602    }
603
604    /// Access the underlying pool (escape hatch).
605    pub fn pool(&self) -> &ConnectionPool {
606        &self.pool
607    }
608
609    /// Clone the underlying pool Arc.
610    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        // Create the database and a table while writable.
758        {
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        // Writer acquisition itself must fail for a read-only backend.
772        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        // Create the database and the graph schema while writable.
929        {
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            // Failing to even open the store on a read-only backend is an
938            // acceptable rejection — the write path never becomes reachable.
939            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        // Explicit floor_bytes=0, not the default 100GB — the free space on
1042        // whatever volume runs this test is not this test's concern (and a
1043        // dev machine or CI runner legitimately may not clear 100GB free).
1044        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        // `blob_store` creates the root directory eagerly (`FsBlobStore::new`),
1057        // so its existence at the expected default path is directly
1058        // observable without reaching into the trait object.
1059        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    /// khive#1029 repro: a `create_entity`-shaped write sequence (entity
1111    /// upsert, then FTS `upsert_document` on the SAME file-backed DB, SAME
1112    /// `StorageBackend`/pool) against a fresh tenant DB file, with a short
1113    /// `busy_timeout` so a genuine lock hang fails fast instead of burning
1114    /// 30s. Runs with `write_queue_enabled: false` — the legacy pool-mutex /
1115    /// standalone-connection path (`KHIVE_WRITE_QUEUE` unset/0 in the
1116    /// hosted symptom report is one of the two configs to check; see the
1117    /// `_write_queue_enabled` sibling below for the flag-on config).
1118    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    /// khive#1029 H1/H2 control: `KHIVE_WRITE_QUEUE` unset (legacy pool-mutex
1167    /// / standalone-connection path for both stores, sharing ONE
1168    /// `ConnectionPool` via ONE `StorageBackend` — the topology this test
1169    /// exists to confirm or kill as the lock source, isolated from any
1170    /// multi-pool or multi-backend wiring question).
1171    #[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    /// khive#1029 H1 direct test: `KHIVE_WRITE_QUEUE=1`, single shared
1183    /// `ConnectionPool`/`StorageBackend` (so the pool-wide `WriterTask` is
1184    /// shared by construction) — isolates whether the WriterTask's
1185    /// transaction lifecycle itself (not a multi-pool topology) is the lock
1186    /// source.
1187    #[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    /// khive#1029 H2 direct test: TWO independent `ConnectionPool`s (hence
1199    /// two independent writer connections / two independent `WriterTask`
1200    /// `OnceLock`s) opened against the SAME tenant DB file — the shape a
1201    /// per-store (rather than per-backend) pool construction would produce.
1202    /// Entity writes go through pool A, the FTS write through pool B, each
1203    /// with `write_queue_enabled: true` so each independently spawns its own
1204    /// WriterTask on first access.
1205    #[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    /// Minimal thread-local capture subscriber for asserting emitted events —
1264    /// mirrors the capture subscriber in `checkpoint.rs`'s tick tests.
1265    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    /// Regression coverage for the lock-starvation diagnostic itself: when a
1300    /// text write starves on the SQLite write lock, `with_writer_unmanaged`
1301    /// must emit the WARN carrying the `tx_registry` snapshot — operation
1302    /// name, open-transaction count, and the registered labels.
1303    ///
1304    /// `#[serial(tx_registry)]`: the registry is a process-wide singleton
1305    /// shared across this test binary; this group serializes every test that
1306    /// registers fixture entries or asserts snapshot contents (see
1307    /// `checkpoint.rs`, `pool.rs`, `sql_bridge.rs`). The assertion checks the
1308    /// fixture label is PRESENT rather than the snapshot being exactly one
1309    /// entry, so unrelated short-lived production registrations elsewhere in
1310    /// the binary cannot flake it.
1311    #[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        // Create the store (and its FTS DDL) BEFORE the lock is held, so the
1316        // starvation happens inside `upsert_document` itself.
1317        let text = backend.text("entities").expect("text store");
1318
1319        // Hold a genuine SQLite write lock on a separate standalone writer
1320        // connection, with a registered fixture transaction the diagnostic
1321        // must surface.
1322        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}