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//! ten capability traits (`SqlAccess`, `NoteStore`, `EntityStore`, `GraphStore`,
5//! `EventStore`, `VectorStore`, `SparseStore`, `TextSearch`, `BlobStore`, and
6//! `AttachmentStore`). File-backed for production; in-memory for tests.
7
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10use std::sync::Arc;
11
12use rusqlite::OptionalExtension;
13
14use crate::database_owner_identity::{DatabaseOwnerIdentity, DatabaseOwnerIdentityError};
15use crate::error::SqliteError;
16use crate::pool::{ConnectionPool, PoolConfig, WalCeilingPolicy};
17use crate::stores::{agents, attachment, blob, entity, event, graph, note, sparse, text, vectors};
18
19mod capability_factories;
20mod code_map;
21mod core_stores;
22#[path = "backend/schema_readiness.rs"]
23mod memory_visibility;
24mod pack_schema;
25mod policy_open;
26
27#[cfg(test)]
28#[path = "backend/filter_builder_tests.rs"]
29mod filter_builder_tests;
30
31mod namespace_listing;
32pub use namespace_listing::NamespaceLiveness;
33
34#[cfg(test)]
35#[path = "backend/memory_visibility_tests.rs"]
36mod memory_visibility_tests;
37
38#[cfg(any(unix, windows))]
39mod claimed_file_identity;
40
41fn sqlite_table_exists(conn: &rusqlite::Connection, table: &str) -> Result<bool, SqliteError> {
42    conn.query_row(
43        "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
44        rusqlite::params![table],
45        |row| row.get::<_, i64>(0),
46    )
47    .optional()
48    .map(|row| row.is_some())
49    .map_err(SqliteError::Rusqlite)
50}
51
52/// Populate `table`'s [`text::rowid_map_table`] from `table` itself, and
53/// record completion in [`text::rowid_map_state_table`], the first time this
54/// backend opens a database that predates the map.
55///
56/// `StorageBackend::text()`/`text_with_tokenizer()` is called uncached on
57/// essentially every text-store access (`khive-runtime` builds a fresh
58/// `Fts5TextSearch` per call, never caching the `Arc`). The backend skips this
59/// check once a table's marker is durable, but a table with no rows yet is
60/// re-checked on every fetch, so the check stays on the hot path — it must
61/// stay O(1), not scale with either
62/// table's row count, or it reintroduces the exact class of cost this
63/// migration exists to remove. `SELECT EXISTS(... LIMIT 1)` is an index probe
64/// that stops at the first row, unlike `COUNT(*)` which SQLite satisfies by
65/// walking every row of the smallest available index.
66///
67/// Completion is read from a durable marker row rather than inferred from
68/// the map's own row count: a map can legitimately be empty for a table with
69/// no rows yet, which is indistinguishable from "never backfilled" by row
70/// count alone, and every runtime write path (`text.rs`'s
71/// `delete_document_dml`, `upsert_document_dml`, `batch_upsert_documents_dml`,
72/// and the raw SQL in `khive-runtime`'s
73/// `atomic_prepare`/`atomic_message`/`curation`) maintains the FTS row and
74/// its map row atomically, inside one transaction, so a legitimately
75/// half-empty map from a live write path never happens either. Until the
76/// table actually holds a row, there is nothing to reconcile and the marker
77/// is deliberately left unwritten — both probes below stay O(1) index-only
78/// lookups on an empty table, so repeating them costs nothing, and a table
79/// that later gains rows through anything other than the maintained write
80/// paths (a raw-SQL legacy seed, or a restored pre-map snapshot) is still
81/// picked up and reconciled the next time this runs. Once the table holds at
82/// least one row, the marker asserts a bijection — every live row has
83/// exactly one map row pointing at it, and every map row points at a live
84/// row with the same key — so reconciliation runs in three steps before the
85/// marker is written, all inside one transaction:
86///
87/// 1. Any existing map row that no longer has a matching live FTS row at the
88///    same rowid AND the same `(namespace, subject_id)` is removed first. A
89///    map row can otherwise survive with the wrong key after FTS5 reuses its
90///    rowid for a different document (the crash window
91///    `delete_document_dml` guards against at the single-delete level; this
92///    is the same class of staleness surviving into a legacy/reconciliation
93///    pass instead).
94/// 2. The map is (re)built from every current FTS row (`INSERT OR REPLACE`),
95///    which reconciles a partially populated map rather than only filling a
96///    wholly empty one. `updated_at ASC, rowid ASC` matches migration 024's
97///    own backfill ordering: for any legacy duplicate `(namespace,
98///    subject_id)` pair, the row with the newest `updated_at` survives
99///    `INSERT OR REPLACE`, breaking a tie toward the higher rowid.
100/// 3. Any non-NULL-key FTS row that lost step 2's survivor race — a
101///    duplicate whose rowid the map no longer points at — is deleted,
102///    mirroring migration 024's own first sweep, so no live row is left
103///    without a map entry.
104///
105/// The marker is then written in the same transaction. Returns whether the
106/// marker is durable when this returns: `false` only for a table with no rows
107/// yet. This function never
108/// checks `entities`/`notes` for orphaned subjects — that sweep is specific
109/// to those two backing tables and stays in migration 024's SQL; a generic
110/// `table_key` here has no fixed backing table to check against.
111fn ensure_fts_rowid_map_backfilled(
112    conn: &rusqlite::Connection,
113    table: &str,
114    admission: &crate::pool::WriteAdmission,
115) -> Result<bool, SqliteError> {
116    let map = text::rowid_map_table(table);
117    let state = text::rowid_map_state_table(table);
118    let already_backfilled: bool = conn.query_row(
119        &format!("SELECT EXISTS(SELECT 1 FROM {state} WHERE key = 'backfill' AND value = ?1)"),
120        rusqlite::params![text::ROWID_MAP_BACKFILL_COMPLETE],
121        |row| row.get(0),
122    )?;
123    if already_backfilled {
124        return Ok(true);
125    }
126    let fts_has_a_row: bool = conn.query_row(
127        &format!("SELECT EXISTS(SELECT 1 FROM {table} LIMIT 1)"),
128        [],
129        |row| row.get(0),
130    )?;
131    if !fts_has_a_row {
132        return Ok(false);
133    }
134
135    conn.execute_batch("BEGIN IMMEDIATE")?;
136    if let Err(error) = admission.check() {
137        return Err(crate::migrations::capacity_refusal_after_rollback(
138            conn,
139            conn.execute_batch("ROLLBACK"),
140            error,
141            "FTS rowid-map backfill",
142        ));
143    }
144    let result: Result<(), SqliteError> = (|| {
145        conn.execute_batch(&format!(
146            "DELETE FROM {map} WHERE NOT EXISTS ( \
147                 SELECT 1 FROM {table} \
148                 WHERE {table}.rowid = {map}.rowid \
149                   AND {table}.namespace = {map}.namespace \
150                   AND {table}.subject_id = {map}.subject_id \
151             )"
152        ))?;
153        conn.execute_batch(&format!(
154            "INSERT OR REPLACE INTO {map} (namespace, subject_id, rowid) \
155             SELECT namespace, subject_id, rowid FROM {table} \
156             WHERE namespace IS NOT NULL AND subject_id IS NOT NULL \
157             ORDER BY updated_at ASC, rowid ASC"
158        ))?;
159        conn.execute_batch(&format!(
160            "DELETE FROM {table} \
161             WHERE namespace IS NOT NULL AND subject_id IS NOT NULL \
162               AND rowid NOT IN (SELECT rowid FROM {map})"
163        ))?;
164        conn.execute(
165            &format!("INSERT OR REPLACE INTO {state} (key, value) VALUES ('backfill', ?1)"),
166            rusqlite::params![text::ROWID_MAP_BACKFILL_COMPLETE],
167        )?;
168        Ok(())
169    })();
170
171    match result {
172        Ok(()) => {
173            conn.execute_batch("COMMIT")?;
174            Ok(true)
175        }
176        Err(e) => {
177            let _ = conn.execute_batch("ROLLBACK");
178            Err(e)
179        }
180    }
181}
182
183/// Emit exactly one `tracing::warn!` for the whole process the first time
184/// any table falls back to scan-fallback mode, rather than once per `text()`
185/// call — `StorageBackend::text()` is called fresh on essentially every
186/// access (see `ensure_fts_rowid_map_backfilled`'s doc comment), so an
187/// unconditional warning here would spam the log on a hot path.
188fn warn_scan_fallback_once(table: &str) {
189    static WARNED: std::sync::Once = std::sync::Once::new();
190    WARNED.call_once(|| {
191        tracing::warn!(
192            table,
193            "opened a read-only text-search table with no rowid-map sidecar, or with a sidecar \
194             that has never proven a completed backfill (no durable completion marker); a \
195             read-only connection cannot create, backfill, or reconcile the map itself, so this \
196             falls back to pre-map namespace/subject_id scan predicates for get/delete on this \
197             table rather than trusting a map that might be partial"
198        );
199    });
200}
201
202fn validate_vector_model_key(model_key: &str) -> Result<(), SqliteError> {
203    if model_key.is_empty()
204        || !model_key
205            .chars()
206            .all(|c| c.is_ascii_alphanumeric() || c == '_')
207    {
208        return Err(SqliteError::InvalidData(format!(
209            "invalid model_key '{}': must be non-empty and contain only \
210             alphanumeric/underscore characters",
211            model_key
212        )));
213    }
214    Ok(())
215}
216
217fn validate_vector_table_columns(
218    conn: &rusqlite::Connection,
219    table: &str,
220) -> Result<(), SqliteError> {
221    let pragma = format!("PRAGMA table_xinfo({table})");
222    let mut stmt = conn.prepare(&pragma)?;
223    let mut rows = stmt.query([])?;
224    let mut has_field = false;
225    let mut has_embedding_model = false;
226    while let Some(row) = rows.next()? {
227        let name: String = row.get(1)?;
228        if name == "field" {
229            has_field = true;
230        }
231        if name == "embedding_model" {
232            has_embedding_model = true;
233        }
234    }
235    if !has_field || !has_embedding_model {
236        return Err(SqliteError::InvalidData(format!(
237            "vec0 table '{table}' is missing required column(s) (field={has_field}, \
238             embedding_model={has_embedding_model}); this is a pre-v0.2.8 vector schema and is \
239             not supported — recreate the database"
240        )));
241    }
242    Ok(())
243}
244
245#[derive(Clone, Copy, Debug)]
246pub(crate) enum StoreSchemaKind {
247    Entities,
248    Graph,
249    Notes,
250    Events,
251    Agents,
252}
253
254impl StoreSchemaKind {
255    fn initializer(self) -> fn(&rusqlite::Connection) -> Result<(), rusqlite::Error> {
256        match self {
257            Self::Entities => entity::ensure_entities_schema,
258            Self::Graph => graph::ensure_graph_schema,
259            Self::Notes => note::ensure_notes_schema,
260            Self::Events => event::ensure_events_schema,
261            Self::Agents => agents::ensure_agents_schema,
262        }
263    }
264}
265
266#[derive(Default)]
267pub(crate) struct StoreSchemaGate {
268    pub(crate) ready: AtomicBool,
269    #[cfg(test)]
270    pub(crate) attempts: AtomicUsize,
271}
272
273impl StoreSchemaGate {
274    pub(crate) fn ensure(
275        &self,
276        conn: &rusqlite::Connection,
277        ensure: fn(&rusqlite::Connection) -> Result<(), rusqlite::Error>,
278    ) -> Result<(), rusqlite::Error> {
279        // A concurrent initializer or repair may have completed while this
280        // caller waited for the writer. Publish readiness only after success.
281        if self.ready.load(Ordering::Acquire) {
282            return Ok(());
283        }
284        #[cfg(test)]
285        self.attempts.fetch_add(1, Ordering::Relaxed);
286        ensure(conn)?;
287        self.ready.store(true, Ordering::Release);
288        Ok(())
289    }
290}
291
292/// Concrete storage backend providing capability traits.
293///
294/// Capability schemas are initialized once per backend. An index removed by
295/// another process is repaired on the narrowly supported forced-index read paths. Other
296/// external schema changes may still require reopening the backend.
297pub struct StorageBackend {
298    pool: Arc<ConnectionPool>,
299    is_file_backed: bool,
300    path: Option<std::path::PathBuf>,
301    /// Vector model keys whose `vec_<model_key>` table this backend has already
302    /// validated or created. A key is recorded only after the whole check
303    /// succeeded under the writer, so a failed check is retried by the next
304    /// fetch and a warm fetch never takes the writer. A table another process
305    /// creates for a model this set does not hold is still validated before
306    /// use; one that is replaced after its model was recorded is not
307    /// re-checked until the backend reopens.
308    vector_tables_ready: parking_lot::Mutex<std::collections::HashSet<String>>,
309    /// FTS tables whose schema, rowid-map sidecar and durable backfill marker
310    /// this backend has already proven under the writer. A table is recorded
311    /// only once its marker exists, so a table with no rows yet is re-checked
312    /// by the next fetch, and a warm fetch never takes the writer or the
313    /// volume lease: a search opens its text store on every call. As with
314    /// `vector_tables_ready`, a table dropped behind a live backend is not
315    /// recreated until the backend is reopened.
316    text_tables_ready: parking_lot::Mutex<std::collections::HashSet<String>>,
317    /// How many times the lazy `notes_seq` anti-join repair has actually
318    /// executed against this backend's pool. Gates `notes_for_namespace` so
319    /// the repair (a full `notes` scan) runs at most once per backend for
320    /// the process's lifetime instead of on every store acquisition (khive
321    /// #827). Also exposed via
322    /// `notes_seq_repair_run_count` for regression tests.
323    notes_seq_repair_runs: AtomicUsize,
324    store_schemas: [Arc<StoreSchemaGate>; 5],
325}
326
327impl StorageBackend {
328    /// File-backed SQLite database.
329    ///
330    /// Opens (or creates) the database at `path`. An existing filesystem path
331    /// whose mode is read-only is opened with the same locked-down pool
332    /// configuration as [`Self::sqlite_read_only`]. The writable pool provides
333    /// 1 writer + N readers in WAL mode for concurrent access.
334    /// No service schema is applied — call `apply_schema()` for each service.
335    /// The pool may create its internal `_khive_database_identity` singleton
336    /// table on a writable open before service migrations run.
337    ///
338    /// The volume-lock directory is the per-user default of
339    /// [`crate::default_volume_lock_dir`], shared by every process of the user;
340    /// [`Self::sqlite_with_volume_lock_dir`] names one explicitly.
341    pub fn sqlite(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
342        Self::sqlite_with_pool_config(path, PoolConfig::default(), None)
343    }
344
345    /// A private test database with a small, explicitly sized reader pool.
346    #[cfg(any(test, feature = "test-support"))]
347    pub fn sqlite_for_test(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
348        Self::sqlite_with_pool_config(path, PoolConfig::for_test(), None)
349    }
350
351    /// Open a private test database with an explicit journal mode and busy wait.
352    #[cfg(any(test, feature = "test-support"))]
353    pub fn sqlite_for_test_with_journal_mode(
354        path: impl AsRef<Path>,
355        wal_mode: bool,
356        busy_timeout: std::time::Duration,
357    ) -> Result<Self, SqliteError> {
358        Self::sqlite_with_pool_config(
359            path,
360            PoolConfig {
361                wal_mode,
362                busy_timeout,
363                write_queue_enabled: Some(true),
364                ..PoolConfig::for_test()
365            },
366            None,
367        )
368    }
369
370    /// [`Self::sqlite_for_test_with_journal_mode`] with its volume-lock files in
371    /// `volume_lock_dir`. Under the test harness each directory takes its own
372    /// in-process lease slot, so a fixture that holds the lease across a pause
373    /// does not stall fixtures on other databases in the same test binary.
374    #[cfg(any(test, feature = "test-support"))]
375    pub fn sqlite_for_test_with_journal_mode_in(
376        path: impl AsRef<Path>,
377        wal_mode: bool,
378        busy_timeout: std::time::Duration,
379        volume_lock_dir: std::path::PathBuf,
380    ) -> Result<Self, SqliteError> {
381        Self::sqlite_with_pool_config(
382            path,
383            PoolConfig {
384                wal_mode,
385                busy_timeout,
386                write_queue_enabled: Some(true),
387                volume_lock_dir: Some(volume_lock_dir),
388                ..PoolConfig::for_test()
389            },
390            None,
391        )
392    }
393
394    /// Open SQLite with a reader count selected before any connections are opened.
395    /// `None` preserves the default pool size and filesystem read-only detection.
396    /// The volume-lock directory defaults as in [`Self::sqlite`].
397    pub fn sqlite_with_max_readers(
398        path: impl AsRef<Path>,
399        max_readers: Option<usize>,
400    ) -> Result<Self, SqliteError> {
401        Self::sqlite_with_pool_config(path, PoolConfig::default(), max_readers)
402    }
403
404    /// Open a file-backed SQLite backend with its already-resolved WAL ceiling.
405    /// A nonzero policy fails closed until the WAL I/O limiter is installed.
406    /// The volume-lock directory defaults as in [`Self::sqlite`].
407    pub fn sqlite_with_max_readers_and_wal_ceiling(
408        path: impl AsRef<Path>,
409        max_readers: Option<usize>,
410        wal_ceiling: WalCeilingPolicy,
411    ) -> Result<Self, SqliteError> {
412        Self::sqlite_with_pool_config(
413            path,
414            PoolConfig {
415                wal_ceiling,
416                ..PoolConfig::default()
417            },
418            max_readers,
419        )
420    }
421
422    pub(crate) fn sqlite_with_pool_config(
423        path: impl AsRef<Path>,
424        pool_config: PoolConfig,
425        max_readers: Option<usize>,
426    ) -> Result<Self, SqliteError> {
427        crate::extension::ensure_extensions_loaded();
428        let resolved = path.as_ref().to_path_buf();
429        let read_only =
430            std::fs::metadata(&resolved).is_ok_and(|metadata| metadata.permissions().readonly());
431        let mut config = PoolConfig {
432            path: Some(resolved.clone()),
433            read_only,
434            ..pool_config
435        };
436        if let Some(max_readers) = max_readers {
437            config.max_readers = max_readers;
438        }
439        if read_only {
440            config.write_queue_enabled = Some(false);
441        }
442        let pool = ConnectionPool::new(config)?;
443        Ok(Self {
444            pool: Arc::new(pool),
445            is_file_backed: true,
446            path: Some(resolved),
447            vector_tables_ready: Default::default(),
448            text_tables_ready: Default::default(),
449            notes_seq_repair_runs: AtomicUsize::new(0),
450            store_schemas: std::array::from_fn(|_| Arc::new(StoreSchemaGate::default())),
451        })
452    }
453
454    /// File-backed SQLite database opened read-only.
455    ///
456    /// Opens the database at `path` and sets `PRAGMA query_only = ON` on the
457    /// writer connection so that any write attempt (INSERT/UPDATE/DELETE) returns
458    /// an error. Reader connections are opened with `SQLITE_OPEN_READ_ONLY` by the
459    /// pool; at least one remains dedicated even for a rollback-journal snapshot,
460    /// while this PRAGMA extends the protection to the otherwise-unused writer slot.
461    ///
462    /// The database file must already exist — unlike `sqlite()` this constructor
463    /// does not create a new file.
464    pub fn sqlite_read_only(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
465        Self::sqlite_read_only_with_pool_config(path, PoolConfig::default(), None)
466    }
467
468    /// A private read-only test database with a small reader pool.
469    #[cfg(any(test, feature = "test-support"))]
470    pub fn sqlite_read_only_for_test(path: impl AsRef<Path>) -> Result<Self, SqliteError> {
471        Self::sqlite_read_only_with_pool_config(path, PoolConfig::for_test(), None)
472    }
473
474    /// Open a read-only SQLite store with a construction-time reader count.
475    pub fn sqlite_read_only_with_max_readers(
476        path: impl AsRef<Path>,
477        max_readers: Option<usize>,
478    ) -> Result<Self, SqliteError> {
479        Self::sqlite_read_only_with_pool_config(path, PoolConfig::default(), max_readers)
480    }
481
482    /// Open a read-only SQLite backend while retaining WAL ceiling
483    /// configuration for diagnostics. No writer policy is enforced.
484    pub fn sqlite_read_only_with_max_readers_and_wal_ceiling(
485        path: impl AsRef<Path>,
486        max_readers: Option<usize>,
487        wal_ceiling: WalCeilingPolicy,
488    ) -> Result<Self, SqliteError> {
489        Self::sqlite_read_only_with_pool_config(
490            path,
491            PoolConfig {
492                wal_ceiling,
493                ..PoolConfig::default()
494            },
495            max_readers,
496        )
497    }
498
499    fn sqlite_read_only_with_pool_config(
500        path: impl AsRef<Path>,
501        pool_config: PoolConfig,
502        max_readers: Option<usize>,
503    ) -> Result<Self, SqliteError> {
504        crate::extension::ensure_extensions_loaded();
505        let resolved = path.as_ref().to_path_buf();
506        let mut config = PoolConfig {
507            path: Some(resolved.clone()),
508            read_only: true,
509            write_queue_enabled: Some(false),
510            ..pool_config
511        };
512        if let Some(max_readers) = max_readers {
513            config.max_readers = max_readers;
514        }
515        // `ConnectionPool::new` opens the writer slot with `SQLITE_OPEN_READ_ONLY`
516        // (no `SQLITE_OPEN_CREATE`) and sets `PRAGMA query_only = ON` on it, so a
517        // missing path is rejected instead of created, and any write attempt is
518        // rejected at the SQLite level regardless of which code path reaches the
519        // writer.
520        let pool = ConnectionPool::new(config)?;
521        Ok(Self {
522            pool: Arc::new(pool),
523            is_file_backed: true,
524            path: Some(resolved),
525            vector_tables_ready: Default::default(),
526            text_tables_ready: Default::default(),
527            notes_seq_repair_runs: AtomicUsize::new(0),
528            store_schemas: std::array::from_fn(|_| Arc::new(StoreSchemaGate::default())),
529        })
530    }
531
532    /// In-memory SQLite database (for tests).
533    ///
534    /// All data is lost when the backend is dropped. The pool degrades to
535    /// single-connection mode since in-memory databases cannot be shared
536    /// across multiple connections.
537    pub fn memory() -> Result<Self, SqliteError> {
538        crate::extension::ensure_extensions_loaded();
539        let config = PoolConfig {
540            path: None,
541            ..PoolConfig::default()
542        };
543        let pool = ConnectionPool::new(config)?;
544        Ok(Self {
545            pool: Arc::new(pool),
546            is_file_backed: false,
547            path: None,
548            vector_tables_ready: Default::default(),
549            text_tables_ready: Default::default(),
550            notes_seq_repair_runs: AtomicUsize::new(0),
551            store_schemas: std::array::from_fn(|_| Arc::new(StoreSchemaGate::default())),
552        })
553    }
554
555    /// Apply a service's schema plan (run migrations).
556    ///
557    /// Each migration in the plan's `sqlite` list is applied idempotently,
558    /// including when another opener commits it first. Already-applied
559    /// migrations are skipped after taking the SQLite write lock. The
560    /// `_schema_versions` table tracks which migrations have been run.
561    pub fn apply_schema(
562        &self,
563        plan: &crate::migrations::ServiceSchemaPlan,
564    ) -> Result<(), SqliteError> {
565        let admission = self.pool.write_admission();
566        crate::migrations::apply_schema_plan_with_admission(
567            &mut self.pool.migration_transactions(),
568            plan,
569            &admission,
570        )
571    }
572
573    /// Apply pack-auxiliary DDL statements.
574    ///
575    /// Executes the full plan in one transaction, applying each DDL statement
576    /// idempotently via `execute_batch`. Each statement MUST be self-contained
577    /// and use `CREATE TABLE IF NOT EXISTS` (or equivalent idempotent DDL) so
578    /// that calling this method more than once does not fail.
579    ///
580    /// Pack auxiliary tables are NOT tracked in `_schema_versions` — they are
581    /// non-versioned. Use `apply_schema` with a `ServiceSchemaPlan` when version
582    /// tracking is needed.
583    ///
584    /// Plans declaring nullable-column upgrades must use
585    /// [`Self::apply_pack_ddl_statements_with_columns`]. The runtime supplies
586    /// the SQL slice and column metadata separately because its `SchemaPlan`
587    /// type lives above this crate in the dependency chain.
588    pub fn apply_pack_ddl_statements(
589        &self,
590        statements: &[&'static str],
591    ) -> Result<(), SqliteError> {
592        self.apply_pack_ddl_statements_with_columns(statements, &[])
593    }
594
595    /// Apply a pack's nullable-column upgrades and idempotent SQL atomically.
596    ///
597    /// Missing columns are added only to existing tables; the full SQL plan
598    /// creates fresh tables. Existing columns and the final schema must match
599    /// the declarations. Schema inspection, additions, and SQL all run under
600    /// one writer transaction, including rollback if any later step fails.
601    pub fn apply_pack_ddl_statements_with_columns(
602        &self,
603        statements: &[&'static str],
604        additions: &[khive_types::PackColumnAddition],
605    ) -> Result<(), SqliteError> {
606        let writer = self.pool.writer_for_admitted_operation()?;
607        writer.transaction(|conn| {
608            pack_schema::add_missing_columns(conn, additions)?;
609            for &stmt in statements {
610                conn.execute_batch(stmt)?;
611            }
612            pack_schema::validate_columns(conn, additions)?;
613            Ok(())
614        })
615    }
616
617    /// Validate a pack's declared columns without applying SQL or acquiring a writer.
618    /// Read-only hosts use this before exposing verbs that require these columns.
619    pub fn validate_pack_schema_columns(
620        &self,
621        additions: &[khive_types::PackColumnAddition],
622    ) -> Result<(), SqliteError> {
623        if additions.is_empty() {
624            return Ok(());
625        }
626        let reader = self.pool.reader()?;
627        pack_schema::validate_columns(reader.conn(), additions)
628    }
629
630    /// Prepare the core schema for runtime boot.
631    ///
632    /// Writable backends acquire the canonical database-GC owner before the
633    /// writer, apply the ordinary versioned prefix, and may finish V21 only
634    /// through its zero-legacy-reference fast path. A legacy V20 database
635    /// remains at V20 for the async host's application-assisted attachment
636    /// cutover; this method alone is not a serving boot gate.
637    /// Read-only backends perform a query-only compatibility check and require
638    /// the snapshot to be at this build's exact latest schema version.
639    pub fn prepare_core_schema(&self) -> Result<u32, SqliteError> {
640        if self.is_read_only() {
641            let reader = self.pool.reader()?;
642            crate::migrations::validate_schema_is_current(reader.conn())
643        } else {
644            let latest = crate::migrations::MIGRATIONS
645                .last()
646                .map(|migration| migration.version)
647                .unwrap_or(0);
648            {
649                let reader = self.pool.reader()?;
650                let current = crate::migrations::read_schema_version(reader.conn())?;
651                if current >= latest {
652                    return crate::migrations::validate_schema_is_current(reader.conn());
653                }
654            }
655            let owner = crate::stores::blob::acquire_database_gc_owner_for_path_blocking(
656                self.pool.canonical_path().map(Path::to_path_buf),
657            )
658            .map_err(|error| {
659                SqliteError::InvalidData(format!(
660                    "failed to acquire database GC owner before schema preparation: {error}"
661                ))
662            })?;
663            self.run_core_migrations(&owner)
664        }
665    }
666
667    /// Inspect the coordinated V21 attachment cutover state.
668    pub fn attachment_cutover_status(
669        &self,
670    ) -> Result<crate::migrations::AttachmentCutoverStatus, SqliteError> {
671        let reader = self.pool.reader()?;
672        crate::migrations::attachment_cutover_status(reader.conn())
673    }
674
675    fn require_attachment_cutover_owner(
676        &self,
677        owner: &crate::stores::blob::DatabaseGcOwnerGuard,
678    ) -> Result<(), SqliteError> {
679        let sql = self.sql();
680        let backend_path = sql.database_path();
681        if owner.database_path() != backend_path.as_deref() {
682            return Err(SqliteError::InvalidData(format!(
683                "attachment cutover GC owner targets {:?}, but this backend is {:?}",
684                owner.database_path(),
685                backend_path.as_deref()
686            )));
687        }
688        Ok(())
689    }
690
691    /// Commit resumable V21 stage 1 while the caller owns this database's GC
692    /// protocol. The owner must remain live through verified application
693    /// backfill and finalization.
694    pub fn stage_attachment_cutover(
695        &self,
696        owner: &crate::stores::blob::DatabaseGcOwnerGuard,
697    ) -> Result<(), SqliteError> {
698        self.require_attachment_cutover_owner(owner)?;
699        if self.is_read_only() {
700            return Err(SqliteError::InvalidData(
701                "cannot stage attachment cutover on a read-only backend".into(),
702            ));
703        }
704        let mut writer = self.pool.writer_for_admitted_operation()?;
705        let admission = self.pool.write_admission();
706        crate::migrations::stage_attachment_cutover_with_admission(writer.conn_mut(), &admission)
707    }
708
709    /// Atomically publish a verified batch of pack-owned attachment roles.
710    pub fn apply_verified_attachments(
711        &self,
712        owner: &crate::stores::blob::DatabaseGcOwnerGuard,
713        attachments: &[khive_storage::Attachment],
714    ) -> Result<(), SqliteError> {
715        self.require_attachment_cutover_owner(owner)?;
716        if self.is_read_only() {
717            return Err(SqliteError::InvalidData(
718                "cannot apply verified attachments on a read-only backend".into(),
719            ));
720        }
721        let mut writer = self.pool.writer_for_admitted_operation()?;
722        let tx = writer
723            .conn_mut()
724            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
725        let admission = self.pool.write_admission();
726        if let Err(error) = admission.check() {
727            let rollback = tx.rollback();
728            return Err(crate::migrations::capacity_refusal_after_rollback(
729                writer.conn(),
730                rollback,
731                error,
732                "verified attachment publication",
733            ));
734        }
735        for attachment in attachments {
736            attachment
737                .validate()
738                .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
739            crate::migrations::apply_generic_verified_attachment(
740                &tx,
741                &attachment.record_uuid.to_string(),
742                attachment.substrate.as_str(),
743                &attachment.role,
744                &attachment.content_ref,
745                attachment.media_type.as_deref(),
746                attachment.size_bytes,
747                attachment.created_at,
748            )?;
749        }
750        tx.commit()?;
751        Ok(())
752    }
753
754    /// Atomically swap GC liveness/fences to attachments, remove the legacy
755    /// entity column, and record V21 while the canonical owner is held.
756    pub fn finalize_attachment_cutover(
757        &self,
758        owner: &crate::stores::blob::DatabaseGcOwnerGuard,
759    ) -> Result<(), SqliteError> {
760        self.require_attachment_cutover_owner(owner)?;
761        if self.is_read_only() {
762            return Err(SqliteError::InvalidData(
763                "cannot finalize attachment cutover on a read-only backend".into(),
764            ));
765        }
766        let mut writer = self.pool.writer_for_admitted_operation()?;
767        let admission = self.pool.write_admission();
768        crate::migrations::finalize_attachment_cutover_with_admission(writer.conn_mut(), &admission)
769    }
770
771    /// Get an EntityStore. Applies the entities DDL if not already present.
772    ///
773    /// Idempotent — safe to call multiple times.
774    pub fn entities(&self) -> Result<Arc<dyn khive_storage::EntityStore>, SqliteError> {
775        self.entities_for_namespace("local")
776    }
777
778    /// Get an EntityStore. The namespace parameter is validated (non-empty) and
779    /// the entities schema is applied, but the store itself is unscoped — namespace
780    /// is the caller's responsibility on each query/delete call.
781    pub fn entities_for_namespace(
782        &self,
783        namespace: &str,
784    ) -> Result<Arc<dyn khive_storage::EntityStore>, SqliteError> {
785        if namespace.trim().is_empty() {
786            return Err(SqliteError::InvalidData(
787                "entities namespace must be non-empty".to_string(),
788            ));
789        }
790        self.ensure_store_schema(StoreSchemaKind::Entities)?;
791
792        Ok(Arc::new(entity::SqlEntityStore::new(
793            Arc::clone(&self.pool),
794            self.is_file_backed,
795        )))
796    }
797
798    /// Get the role-keyed attachment store.
799    ///
800    /// Unlike the legacy capability accessors, this does not install DDL on
801    /// demand. The coordinated V21 core cutover owns creation of the table,
802    /// reference fences, GC liveness swap, and removal of the legacy entity
803    /// column as one boot-gated operation.
804    pub fn attachments(&self) -> Result<Arc<dyn khive_storage::AttachmentStore>, SqliteError> {
805        Ok(Arc::new(attachment::SqlAttachmentStore::new(
806            Arc::clone(&self.pool),
807            self.is_file_backed,
808        )))
809    }
810
811    /// Get a GraphStore for the default namespace.
812    ///
813    /// Creates the `graph_edges` table (with indexes) if it does not already
814    /// exist. Idempotent — safe to call multiple times.
815    pub fn graph(&self) -> Result<Arc<dyn khive_storage::GraphStore>, SqliteError> {
816        self.graph_for_namespace("local")
817    }
818
819    /// Get a GraphStore scoped to a namespace.
820    pub fn graph_for_namespace(
821        &self,
822        namespace: &str,
823    ) -> Result<Arc<dyn khive_storage::GraphStore>, SqliteError> {
824        if namespace.trim().is_empty() {
825            return Err(SqliteError::InvalidData(
826                "graph namespace must be non-empty".to_string(),
827            ));
828        }
829        self.ensure_store_schema(StoreSchemaKind::Graph)?;
830
831        Ok(Arc::new(
832            graph::SqlGraphStore::new_scoped(
833                Arc::clone(&self.pool),
834                self.is_file_backed,
835                namespace.trim().to_string(),
836            )
837            .with_index_repair(crate::stores::index_repair::IndexRepairContext::new(
838                Arc::clone(&self.pool),
839                self.store_schemas.clone(),
840                crate::stores::index_repair::IndexReadKind::Graph,
841            )),
842        ))
843    }
844
845    fn ensure_store_schema(&self, kind: StoreSchemaKind) -> Result<(), SqliteError> {
846        if self.is_read_only()
847            || self.store_schemas[kind as usize]
848                .ready
849                .load(Ordering::Acquire)
850        {
851            return Ok(());
852        }
853        let writer = self.constructor_writer()?;
854        self.ensure_store_schema_with_writer(kind, writer.conn())
855    }
856
857    fn ensure_store_schema_with_writer(
858        &self,
859        kind: StoreSchemaKind,
860        conn: &rusqlite::Connection,
861    ) -> Result<(), SqliteError> {
862        self.store_schemas[kind as usize].ensure(conn, kind.initializer())?;
863        Ok(())
864    }
865
866    fn constructor_writer(
867        &self,
868    ) -> Result<crate::pool::PooledAutocommitWriteUnit<'_>, SqliteError> {
869        let context = khive_storage::capture_request_read_context();
870        let Some(operation) = context.store_acquisition_operation() else {
871            return self.pool.autocommit_write_unit();
872        };
873        let writer = self
874            .pool
875            .writer_until_for_admitted_operation(|| context.blocking_stop_reason().is_some())?
876            .ok_or_else(|| {
877                SqliteError::RequestReadStopped(khive_storage::StorageError::Timeout {
878                    operation: operation.into(),
879                })
880            })?;
881        writer.admit_autocommit()
882    }
883
884    /// Get a NoteStore. Applies the notes DDL if not already present.
885    ///
886    /// Idempotent — safe to call multiple times.
887    pub fn notes(&self) -> Result<Arc<dyn khive_storage::NoteStore>, SqliteError> {
888        self.notes_for_namespace("local")
889    }
890
891    /// Get a NoteStore. The namespace parameter is validated (non-empty) and
892    /// the notes schema is applied, but the store itself is unscoped — namespace
893    /// is the caller's responsibility on each query/delete call.
894    pub fn notes_for_namespace(
895        &self,
896        namespace: &str,
897    ) -> Result<Arc<dyn khive_storage::NoteStore>, SqliteError> {
898        if namespace.trim().is_empty() {
899            return Err(SqliteError::InvalidData(
900                "notes namespace must be non-empty".to_string(),
901            ));
902        }
903        if !self.is_read_only()
904            && (!self.store_schemas[StoreSchemaKind::Notes as usize]
905                .ready
906                .load(Ordering::Acquire)
907                || self.notes_seq_repair_runs.load(Ordering::Relaxed) == 0)
908        {
909            let writer = self.constructor_writer()?;
910            self.ensure_store_schema_with_writer(StoreSchemaKind::Notes, writer.conn())?;
911
912            // The anti-join repair is a full `notes` scan -- gate it to run at
913            // most once per backend/pool. `try_writer()` blocks for exclusive
914            // access to the single writer connection for this whole function,
915            // so this load-then-run-then-store is race-free: no other caller on
916            // this pool can observe or advance `notes_seq_repair_runs` while we
917            // hold the writer guard (khive #827).
918            if self.notes_seq_repair_runs.load(Ordering::Relaxed) == 0 {
919                note::repair_notes_seq(writer.conn())?;
920                self.notes_seq_repair_runs.fetch_add(1, Ordering::Relaxed);
921            }
922        }
923
924        Ok(Arc::new(
925            note::SqlNoteStore::new(Arc::clone(&self.pool), self.is_file_backed).with_index_repair(
926                crate::stores::index_repair::IndexRepairContext::new(
927                    Arc::clone(&self.pool),
928                    self.store_schemas.clone(),
929                    crate::stores::index_repair::IndexReadKind::Notes,
930                ),
931            ),
932        ))
933    }
934
935    /// How many times the lazy `notes_seq` anti-join repair has actually
936    /// executed against this backend's pool. Exposed for regression tests
937    /// asserting the repair runs at most once per backend for the process's
938    /// lifetime, not once per `notes_for_namespace` call (khive #827).
939    pub fn notes_seq_repair_run_count(&self) -> usize {
940        self.notes_seq_repair_runs.load(Ordering::Relaxed)
941    }
942
943    /// Get an EventStore for the default namespace.
944    ///
945    /// Creates the `events` table (with indexes) if it does not already exist.
946    /// Idempotent — safe to call multiple times.
947    pub fn events(&self) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
948        self.events_for_namespace("local")
949    }
950
951    /// Get an EventStore scoped to a namespace.
952    pub fn events_for_namespace(
953        &self,
954        namespace: &str,
955    ) -> Result<Arc<dyn khive_storage::EventStore>, SqliteError> {
956        if namespace.trim().is_empty() {
957            return Err(SqliteError::InvalidData(
958                "events namespace must be non-empty".to_string(),
959            ));
960        }
961        self.ensure_store_schema(StoreSchemaKind::Events)?;
962
963        Ok(Arc::new(event::SqlEventStore::new_scoped(
964            Arc::clone(&self.pool),
965            self.is_file_backed,
966            namespace.trim().to_string(),
967        )))
968    }
969
970    /// Get the agent-process store (ADR-142 §1). Applies the agents DDL if not
971    /// already present. Idempotent — safe to call multiple times. Unlike the
972    /// other stores here, agent-process records are not namespace-scoped, so
973    /// there is no `_for_namespace` variant.
974    pub fn agents(&self) -> Result<Arc<dyn khive_storage::AgentStore>, SqliteError> {
975        self.ensure_store_schema(StoreSchemaKind::Agents)?;
976
977        Ok(Arc::new(agents::SqlAgentStore::new(
978            Arc::clone(&self.pool),
979            self.is_file_backed,
980        )))
981    }
982
983    /// Get a VectorStore for a specific embedding model, scoped to the default namespace.
984    ///
985    /// Creates the vec0 virtual table if it does not already exist. The `model_key`
986    /// must contain only ASCII alphanumeric/underscore characters. The `embedding_model`
987    /// is the canonical display name stored in each vector row.
988    pub fn vectors(
989        &self,
990        model_key: &str,
991        embedding_model: &str,
992        dimensions: usize,
993    ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
994        self.vectors_for_namespace(model_key, embedding_model, dimensions, "local")
995    }
996
997    /// Get a VectorStore for a specific embedding model with a default namespace.
998    ///
999    /// Creates the vec0 virtual table if it does not already exist. The `namespace`
1000    /// is a default for trait methods that lack a per-call namespace parameter
1001    /// (count, delete, info). Access control is enforced at the runtime layer.
1002    ///
1003    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
1004    /// The `embedding_model` is the canonical display name stored in the `embedding_model`
1005    /// column of each vector row (e.g. `"all-minilm-l6-v2"`).
1006    pub fn vectors_for_namespace(
1007        &self,
1008        model_key: &str,
1009        embedding_model: &str,
1010        dimensions: usize,
1011        namespace: &str,
1012    ) -> Result<Arc<dyn khive_storage::VectorStore>, SqliteError> {
1013        validate_vector_model_key(model_key)?;
1014        if namespace.trim().is_empty() {
1015            return Err(SqliteError::InvalidData(
1016                "vector store namespace must be non-empty".to_string(),
1017            ));
1018        }
1019        self.ensure_vector_tables(&[(model_key, dimensions)])?;
1020        Ok(Arc::new(vectors::SqliteVecStore::new(
1021            Arc::clone(&self.pool),
1022            self.is_file_backed,
1023            model_key.to_string(),
1024            embedding_model.to_string(),
1025            dimensions,
1026            namespace.trim().to_string(),
1027        )?))
1028    }
1029
1030    /// Ensure all requested vector tables with one schema-writer acquisition.
1031    /// Read-only backends inspect the same tables using one reader instead.
1032    ///
1033    /// A writable backend prepares each model's table once: the first call that
1034    /// names it validates or creates the table under the writer and records the
1035    /// model only after every step succeeded. Later calls for a recorded model
1036    /// return without taking the writer, and a failed call records nothing, so
1037    /// the next call repeats the check.
1038    pub fn ensure_vector_tables(&self, models: &[(&str, usize)]) -> Result<(), SqliteError> {
1039        for (model_key, _) in models {
1040            validate_vector_model_key(model_key)?;
1041        }
1042        if models.is_empty() {
1043            return Ok(());
1044        }
1045
1046        // Ensure sqlite-vec is registered before creating vec0 tables.
1047        crate::extension::ensure_extensions_loaded();
1048
1049        // Direct store construction may precede migrations. Leave the
1050        // provenance sidecar to its versioned migration; vector operations
1051        // tolerate its absence until the ledger advances.
1052
1053        if self.is_read_only() {
1054            // Snapshot inspection must not check schema through the pool's
1055            // query-only writer slot: even a SELECT there is a writer-class
1056            // acquisition and violates ADR-028 A2's write-free lifecycle.
1057            let reader = self.pool.reader()?;
1058            for (model_key, _) in models {
1059                let table = format!("vec_{model_key}");
1060                if !sqlite_table_exists(reader.conn(), &table)? {
1061                    return Err(SqliteError::InvalidData(format!(
1062                        "read-only database has no vector table '{table}'; create and populate it in \
1063                         a writable copy before opening the snapshot"
1064                    )));
1065                }
1066                validate_vector_table_columns(reader.conn(), &table)?;
1067            }
1068            return Ok(());
1069        }
1070
1071        let pending = self.unprepared_vector_tables(models);
1072        if pending.is_empty() {
1073            return Ok(());
1074        }
1075        let writer = self.constructor_writer()?;
1076        // A concurrent caller may have prepared these tables while this one
1077        // waited for the writer.
1078        let pending = self.unprepared_vector_tables(&pending);
1079        if pending.is_empty() {
1080            return Ok(());
1081        }
1082
1083        // Detect old-schema vec0 tables that predate the `field` column.
1084        // Use pragma_table_info to check columns directly; substring matching on the
1085        // CREATE DDL is fragile (a model_key containing "field" would false-match).
1086        for (model_key, _) in &pending {
1087            let table = format!("vec_{model_key}");
1088            // V17 migration (vector_embedding_model_tag_preserving_rebuild) adds
1089            // `field` and `embedding_model` to all pre-existing vec0 tables at
1090            // migration time.  If this table still lacks either column post-migration
1091            // that indicates the database was not migrated — return a hard error
1092            // rather than silently dropping data.
1093            if sqlite_table_exists(writer.conn(), &table)? {
1094                validate_vector_table_columns(writer.conn(), &table)?;
1095            }
1096        }
1097
1098        // Ensure the _embedding_models registry table exists.
1099        // This is a no-op when the table already exists. Running it here ensures
1100        // the registry is present for any caller that opens a vector store without
1101        // first calling run_migrations() (e.g., tests that create stores directly).
1102        // Production callers are expected to call run_migrations() at startup, which
1103        // creates the registry via V14; this is a belt-and-suspenders fallback.
1104        // Schema is defined in `migrations::EMBEDDING_MODELS_DDL` (single source of
1105        // truth) to prevent the two copies from silently drifting.
1106        writer
1107            .conn()
1108            .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)?;
1109
1110        // Same guarantee for the ANN write log: vector write paths append to it
1111        // in the same transaction as the vec0 mutation, so it must exist in any
1112        // database that hosts vec_* tables.
1113        writer
1114            .conn()
1115            .execute_batch(crate::migrations::ANN_WRITE_LOG_DDL)?;
1116        writer
1117            .conn()
1118            .execute_batch(crate::migrations::ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL)?;
1119        writer
1120            .conn()
1121            .execute_batch(crate::migrations::ANN_CONSUMER_PENDING_DDL)?;
1122        // Create missing vec0 tables without changing existing vector data.
1123        for (model_key, dimensions) in &pending {
1124            let ddl = format!(
1125                "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
1126                 subject_id TEXT PRIMARY KEY, \
1127                 namespace TEXT NOT NULL, \
1128                 kind TEXT NOT NULL, \
1129                 field TEXT NOT NULL, \
1130                 embedding_model TEXT NOT NULL, \
1131                 embedding float[{}] distance_metric=cosine\
1132                 )",
1133                model_key, dimensions
1134            );
1135            writer.conn().execute_batch(&ddl)?;
1136        }
1137        // Record the models only now that every step above succeeded, while the
1138        // writer is still held so a waiting caller re-checks against this set.
1139        self.vector_tables_ready.lock().extend(
1140            pending
1141                .iter()
1142                .map(|(model_key, _)| (*model_key).to_string()),
1143        );
1144        Ok(())
1145    }
1146
1147    /// The requested vector models this backend has not yet validated or created.
1148    fn unprepared_vector_tables<'a>(&self, models: &[(&'a str, usize)]) -> Vec<(&'a str, usize)> {
1149        let ready = self.vector_tables_ready.lock();
1150        models
1151            .iter()
1152            .filter(|(model_key, _)| !ready.contains(*model_key))
1153            .copied()
1154            .collect()
1155    }
1156
1157    /// Register an embedding model in the `_embedding_models` registry table.
1158    ///
1159    /// Idempotent: if a row with the same `canonical_key` already exists, updates its
1160    /// status back to `'active'` without changing other fields.
1161    pub fn register_embedding_model(
1162        &self,
1163        engine_name: &str,
1164        model_id: &str,
1165        key_version: &str,
1166        dimensions: u32,
1167    ) -> Result<(), SqliteError> {
1168        let writer = self.pool.autocommit_write_unit()?;
1169        writer
1170            .conn()
1171            .execute_batch(crate::migrations::EMBEDDING_MODELS_DDL)
1172            .map_err(|error| {
1173                SqliteError::write(error, khive_storage::error::SqliteWriteStage::Statement)
1174            })?;
1175
1176        let now = chrono::Utc::now().timestamp_micros();
1177        let canonical_key =
1178            format!("{engine_name}:{model_id}:{key_version}:{dimensions}").into_bytes();
1179        let id = uuid::Uuid::new_v4();
1180        writer
1181            .conn()
1182            .execute(
1183                "INSERT INTO _embedding_models \
1184             (id, engine_name, model_id, key_version, dim, output_dim, status, \
1185              activated_at, superseded_at, superseded_by, canonical_key, created_at) \
1186             VALUES (?1, ?2, ?3, ?4, ?5, NULL, 'active', ?6, NULL, NULL, ?7, ?8) \
1187             ON CONFLICT(canonical_key) DO UPDATE SET \
1188                status = 'active', \
1189                activated_at = COALESCE(_embedding_models.activated_at, excluded.activated_at)",
1190                rusqlite::params![
1191                    id.as_bytes().as_slice(),
1192                    engine_name,
1193                    model_id,
1194                    key_version,
1195                    dimensions as i64,
1196                    now,
1197                    canonical_key,
1198                    now,
1199                ],
1200            )
1201            .map_err(|error| {
1202                SqliteError::write(error, khive_storage::error::SqliteWriteStage::Statement)
1203            })?;
1204        Ok(())
1205    }
1206
1207    /// Get a SparseStore for a specific model key, scoped to the default namespace.
1208    ///
1209    /// Creates the sparse table if it does not already exist.
1210    pub fn sparse(
1211        &self,
1212        model_key: &str,
1213    ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
1214        self.sparse_for_namespace(model_key, "local")
1215    }
1216
1217    /// Get a SparseStore for a specific model key with an explicit default namespace.
1218    ///
1219    /// The `model_key` must contain only ASCII alphanumeric/underscore characters.
1220    pub fn sparse_for_namespace(
1221        &self,
1222        model_key: &str,
1223        namespace: &str,
1224    ) -> Result<Arc<dyn khive_storage::SparseStore>, SqliteError> {
1225        if model_key.is_empty()
1226            || !model_key
1227                .chars()
1228                .all(|c| c.is_ascii_alphanumeric() || c == '_')
1229        {
1230            return Err(SqliteError::InvalidData(format!(
1231                "invalid model_key '{}': must be non-empty and contain only alphanumeric/underscore characters",
1232                model_key
1233            )));
1234        }
1235        if namespace.trim().is_empty() {
1236            return Err(SqliteError::InvalidData(
1237                "sparse store namespace must be non-empty".to_string(),
1238            ));
1239        }
1240
1241        if self.is_read_only() {
1242            let table = format!("sparse_{model_key}");
1243            let reader = self.pool.reader()?;
1244            if !sqlite_table_exists(reader.conn(), &table)? {
1245                return Err(SqliteError::InvalidData(format!(
1246                    "read-only database has no sparse table '{table}'; create and populate it in \
1247                     a writable copy before opening the snapshot"
1248                )));
1249            }
1250        } else {
1251            let writer = self.pool.autocommit_write_unit()?;
1252            sparse::ensure_sparse_schema(writer.conn(), model_key)
1253                .map_err(SqliteError::Rusqlite)?;
1254        }
1255
1256        Ok(Arc::new(sparse::SqliteSparseStore::new(
1257            Arc::clone(&self.pool),
1258            self.is_file_backed,
1259            model_key.to_string(),
1260            namespace.trim().to_string(),
1261        )?))
1262    }
1263
1264    /// Get a TextSearch for a specific table key.
1265    ///
1266    /// Creates the FTS5 virtual table if it does not already exist. Uses the
1267    /// `trigram` tokenizer by default (CJK-safe).
1268    ///
1269    /// The `table_key` must contain only ASCII alphanumeric/underscore characters.
1270    pub fn text(&self, table_key: &str) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
1271        self.text_with_tokenizer(table_key, "trigram")
1272    }
1273
1274    /// Get a TextSearch with an explicit FTS5 tokenizer.
1275    ///
1276    /// Use when you need a tokenizer other than the default `trigram` — for
1277    /// example `unicode61` for Latin-only corpora.
1278    ///
1279    /// Both `table_key` and `tokenizer` must contain only ASCII
1280    /// alphanumeric/underscore characters.
1281    pub fn text_with_tokenizer(
1282        &self,
1283        table_key: &str,
1284        tokenizer: &str,
1285    ) -> Result<Arc<dyn khive_storage::TextSearch>, SqliteError> {
1286        if table_key.is_empty()
1287            || !table_key
1288                .chars()
1289                .all(|c| c.is_ascii_alphanumeric() || c == '_')
1290        {
1291            return Err(SqliteError::InvalidData(format!(
1292                "invalid table_key '{}': must be non-empty and contain only \
1293                 alphanumeric/underscore characters",
1294                table_key
1295            )));
1296        }
1297        // `text::rowid_map_table`/`text::rowid_map_state_table` name a
1298        // table's sidecar map `{table}_rowids` and that map's own completion
1299        // marker `{table}_rowids_state` — a `table_key` ending in either
1300        // reserved suffix (e.g. "entities_rowids" or "entities_rowids_state")
1301        // would resolve to the exact sidecar table name another key's own
1302        // map or marker already reserves. `CREATE VIRTUAL TABLE IF NOT
1303        // EXISTS` would then silently accept that ordinary (non-FTS5) table
1304        // as if it were this key's FTS table, and every later point
1305        // read/write against it would fail against the wrong schema.
1306        if table_key.ends_with("_rowids") || table_key.ends_with("_rowids_state") {
1307            return Err(SqliteError::InvalidData(format!(
1308                "invalid table_key '{}': must not end in '_rowids' or '_rowids_state' — those \
1309                 suffixes are reserved for a text table's own rowid-map sidecar and its \
1310                 completion-marker state table (see text::rowid_map_table, \
1311                 text::rowid_map_state_table)",
1312                table_key
1313            )));
1314        }
1315        if tokenizer.is_empty()
1316            || !tokenizer
1317                .chars()
1318                .all(|c| c.is_ascii_alphanumeric() || c == '_')
1319        {
1320            return Err(SqliteError::InvalidData(format!(
1321                "invalid tokenizer '{}': must be non-empty and contain only \
1322                 alphanumeric/underscore characters",
1323                tokenizer
1324            )));
1325        }
1326
1327        let ddl = format!(
1328            "CREATE VIRTUAL TABLE IF NOT EXISTS fts_{} USING fts5(\
1329             subject_id UNINDEXED, \
1330             kind UNINDEXED, \
1331             title, \
1332             body, \
1333             tags UNINDEXED, \
1334             namespace UNINDEXED, \
1335             metadata UNINDEXED, \
1336             updated_at UNINDEXED, \
1337             record_kind, \
1338             tokenize = '{}'\
1339             )",
1340            table_key, tokenizer
1341        );
1342        let table = format!("fts_{table_key}");
1343        if self.is_read_only() {
1344            let reader = self.pool.reader()?;
1345            if !sqlite_table_exists(reader.conn(), &table)? {
1346                return Err(SqliteError::InvalidData(format!(
1347                    "read-only database has no text-search table '{table}'; create and populate \
1348                     it in a writable copy before opening the snapshot"
1349                )));
1350            }
1351            // A read-only connection cannot create, backfill, or reconcile
1352            // the rowid-map sidecar: a snapshot taken before this migration
1353            // shipped can have the FTS table without its map at all, and a
1354            // snapshot taken mid-backfill (a crash, or a copy made between
1355            // the map's creation and its completion marker being written) can
1356            // have a map table that is only partially populated. Trusting an
1357            // unproven map here would silently hide live rows the map
1358            // doesn't yet know about — only a map with a durable completion
1359            // marker (`ROWID_MAP_BACKFILL_COMPLETE`) is safe to join against
1360            // read-only; anything else falls back to the pre-map scan
1361            // predicates rather than constructing a store whose get/delete
1362            // would either fail against a nonexistent sidecar or silently
1363            // miss rows an unreconciled map doesn't cover.
1364            let map = text::rowid_map_table(&table);
1365            let map_exists = sqlite_table_exists(reader.conn(), &map)?;
1366            let marker_present = if map_exists {
1367                let state = text::rowid_map_state_table(&table);
1368                sqlite_table_exists(reader.conn(), &state)?
1369                    && reader.conn().query_row(
1370                        &format!(
1371                            "SELECT EXISTS(SELECT 1 FROM {state} WHERE key = 'backfill' AND value = ?1)"
1372                        ),
1373                        rusqlite::params![text::ROWID_MAP_BACKFILL_COMPLETE],
1374                        |row| row.get(0),
1375                    )?
1376            } else {
1377                false
1378            };
1379            if !map_exists || !marker_present {
1380                warn_scan_fallback_once(&table);
1381                return Ok(Arc::new(text::Fts5TextSearch::new_scan_fallback(
1382                    Arc::clone(&self.pool),
1383                    self.is_file_backed,
1384                    table_key.to_string(),
1385                )));
1386            }
1387        } else if !self.text_tables_ready.lock().contains(&table) {
1388            let writer = self.pool.autocommit_write_unit()?;
1389            // A concurrent caller may have proven this table while this one
1390            // waited for the writer.
1391            if !self.text_tables_ready.lock().contains(&table) {
1392                writer.conn().execute_batch(&ddl)?;
1393                writer.conn().execute_batch(&text::rowid_map_ddl(&table))?;
1394                let marker_durable = ensure_fts_rowid_map_backfilled(
1395                    writer.conn(),
1396                    &table,
1397                    &self.pool.write_admission(),
1398                )?;
1399                // Record the table only now that every step succeeded, while
1400                // the writer is still held.
1401                if marker_durable {
1402                    self.text_tables_ready.lock().insert(table.clone());
1403                }
1404            }
1405        }
1406
1407        Ok(Arc::new(text::Fts5TextSearch::new(
1408            Arc::clone(&self.pool),
1409            self.is_file_backed,
1410            table_key.to_string(),
1411        )))
1412    }
1413
1414    /// Get a `BlobStore` rooted per khive#292's precedence chain:
1415    /// `KHIVE_BLOB_ROOT` env var > `config_root` (a caller-resolved
1416    /// `khive.toml` override — `khive-db` has no TOML parser of its own) >
1417    /// beside this backend's database directory. `floor_bytes` overrides the
1418    /// default 100 GB fail-closed free-space floor (`None` keeps the
1419    /// default). Errors if none of the three roots apply — e.g. an in-memory
1420    /// backend with no override and no env var has nowhere to default to.
1421    pub fn blob_store(
1422        &self,
1423        config_root: Option<&Path>,
1424        floor_bytes: Option<u64>,
1425    ) -> Result<Arc<dyn khive_storage::BlobStore>, SqliteError> {
1426        let root = blob::resolve_blob_root(self.data_dir().as_deref(), config_root)?;
1427        let floor = floor_bytes.unwrap_or(blob::FsBlobStore::DEFAULT_FLOOR_BYTES);
1428        Ok(Arc::new(blob::FsBlobStore::new(root, floor)?))
1429    }
1430
1431    /// Resolve the filesystem blob root exactly like [`Self::blob_store`] but
1432    /// require it to exist instead of creating it. Snapshot runtimes wrap the
1433    /// returned capability so its read methods remain available while every
1434    /// mutator is refused.
1435    pub fn blob_store_read_only(
1436        &self,
1437        config_root: Option<&Path>,
1438        floor_bytes: Option<u64>,
1439    ) -> Result<Arc<dyn khive_storage::BlobStore>, SqliteError> {
1440        let root = blob::resolve_blob_root(self.data_dir().as_deref(), config_root)?;
1441        let floor = floor_bytes.unwrap_or(blob::FsBlobStore::DEFAULT_FLOOR_BYTES);
1442        Ok(Arc::new(blob::FsBlobStore::open_existing(root, floor)?))
1443    }
1444
1445    /// Is this a file-backed backend?
1446    pub fn is_file_backed(&self) -> bool {
1447        self.is_file_backed
1448    }
1449
1450    /// Whether this backend was opened with SQLite's read-only/query-only
1451    /// contract, explicitly or after filesystem-mode detection.
1452    pub fn is_read_only(&self) -> bool {
1453        self.pool.config().read_only
1454    }
1455
1456    /// Return the directory containing the backend's database file, or `None`
1457    /// for an in-memory backend.
1458    pub fn data_dir(&self) -> Option<std::path::PathBuf> {
1459        self.path.as_ref()?.parent().map(|p| p.to_path_buf())
1460    }
1461
1462    /// Root directory for this database's ANN segment tree, or `None` for an
1463    /// in-memory backend. Derived from the database file name itself
1464    /// (`<db-file>.ann/` beside the file), so two databases sharing a parent
1465    /// directory can never adopt each other's segments or UUID maps. The
1466    /// suffix is appended at the `OsString` byte level — a lossy UTF-8
1467    /// conversion would collapse distinct non-UTF-8 filenames into one
1468    /// replacement-character root, breaking exactly that isolation.
1469    pub fn ann_root(&self) -> Option<std::path::PathBuf> {
1470        ann_root_for(self.path.as_ref()?)
1471    }
1472
1473    /// Access the underlying pool (escape hatch).
1474    pub fn pool(&self) -> &ConnectionPool {
1475        &self.pool
1476    }
1477
1478    /// Durable owner evidence for this backend, captured at pool construction.
1479    /// The caller must choose the effective main backend before comparing it.
1480    pub fn database_owner_identity(
1481        &self,
1482    ) -> Result<DatabaseOwnerIdentity, DatabaseOwnerIdentityError> {
1483        self.pool.database_owner_identity()
1484    }
1485
1486    /// Refuse a secondary or copied database as the supplied database owner.
1487    pub fn verify_database_owner(
1488        &self,
1489        expected: &DatabaseOwnerIdentity,
1490    ) -> Result<(), DatabaseOwnerIdentityError> {
1491        self.pool.verify_database_owner(expected)
1492    }
1493
1494    /// Clone the underlying pool Arc.
1495    pub fn pool_arc(&self) -> Arc<ConnectionPool> {
1496        Arc::clone(&self.pool)
1497    }
1498}
1499
1500/// `<db-file>.ann` sibling of a database file, appended at the `OsString`
1501/// byte level: a lossy UTF-8 conversion would collapse distinct non-UTF-8
1502/// filenames into one replacement-character root, breaking the per-database
1503/// segment isolation that `ann_root` exists to guarantee.
1504fn ann_root_for(path: &std::path::Path) -> Option<std::path::PathBuf> {
1505    let mut file = path.file_name()?.to_os_string();
1506    file.push(".ann");
1507    path.parent().map(|p| p.join(file))
1508}
1509
1510#[cfg(test)]
1511#[path = "backend/store_accessor_tests.rs"]
1512mod store_accessor_tests;
1513
1514#[cfg(test)]
1515#[path = "backend/store_accessor_index_tests.rs"]
1516mod store_accessor_index_tests;
1517
1518#[cfg(test)]
1519#[path = "backend_tests.rs"]
1520mod tests;
1521
1522#[cfg(test)]
1523#[path = "backend_admission_tests.rs"]
1524mod admission_tests;