Skip to main content

khive_db/stores/
vectors.rs

1//! sqlite-vec backed `VectorStore`: one vec0 table per embedding model, scoped to namespace.
2
3use std::collections::HashSet;
4use std::sync::{Arc, OnceLock};
5
6use async_trait::async_trait;
7use uuid::Uuid;
8
9use khive_score::DeterministicScore;
10use khive_storage::error::StorageError;
11use khive_storage::types::{
12    BatchWriteSummary, IndexRebuildScope, OrphanSweepConfig, OrphanSweepResult, SqlStatement,
13    SqlValue, VectorIndexKind, VectorRecord, VectorSearchHit, VectorSearchRequest,
14    VectorStoreCapabilities, VectorStoreInfo,
15};
16use khive_storage::StorageCapability;
17use khive_storage::StorageResult;
18use khive_storage::VectorStore;
19use khive_types::SubstrateKind;
20
21use crate::error::SqliteError;
22use crate::pool::ConnectionPool;
23use crate::sql_bridge::bind_params;
24
25/// The exact `DELETE` this store's `delete` issues, for a given vector table
26/// (ADR-099 B3 r6 structural cut — see `stores::entity`'s sibling block).
27/// `table` must already be a trusted, sanitized table name (mirrors
28/// `delete`'s own pre-existing lack of a placeholder for table names).
29pub fn delete_vector_statement(table: &str, subject_id: Uuid, namespace: &str) -> SqlStatement {
30    SqlStatement {
31        sql: format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2"),
32        params: vec![
33            SqlValue::Text(subject_id.to_string()),
34            SqlValue::Text(namespace.to_string()),
35        ],
36        label: Some(format!("vec-delete-{table}")),
37    }
38}
39
40// ---------------------------------------------------------------------------
41// Test-only failpoint: force an error between DELETE and INSERT to exercise
42// the SAVEPOINT ROLLBACK TO path in insert_batch and the transaction rollback
43// in update.  Zero impact on release builds — the entire block is cfg(test).
44//
45// Uses Arc<AtomicBool> rather than thread_local! because the actual DB work
46// runs inside tokio::task::spawn_blocking on a worker thread different from
47// the test thread.  The Arc is cloned into the closure so both sides share
48// the same flag without a thread boundary problem.
49// ---------------------------------------------------------------------------
50#[cfg(test)]
51mod failpoint {
52    use std::sync::atomic::{AtomicBool, Ordering};
53    use std::sync::Arc;
54
55    use std::cell::RefCell;
56
57    thread_local! {
58        /// Per-test handle to the shared AtomicBool.  Each test that needs
59        /// the failpoint calls `arm()` to create a fresh Arc and store it here;
60        /// the `FailpointGuard` clears it on drop.
61        pub(super) static CURRENT: RefCell<Option<Arc<AtomicBool>>> = const { RefCell::new(None) };
62    }
63
64    // The arming mechanism (`arm`/`disarm`/`FailpointGuard`) is used only by the
65    // SAVEPOINT/ROLLBACK sentinel tests, which need the sqlite-vec store and so
66    // live in the `cfg(all(test, feature = "vectors"))` module below.  Gating
67    // these items on `feature = "vectors"` keeps them out of the no-feature test
68    // build, where they would otherwise have no caller and trip
69    // `clippy --all-targets -D warnings` (which runs without `--features vectors`).
70    // `CURRENT`/`take` stay plain `cfg(test)`: they are read by the failpoint hooks
71    // in `insert_batch`/`update`, which are `cfg(test)` and compile in every test build.
72
73    /// Create a fresh `Arc<AtomicBool>` set to `true` and register it in the
74    /// thread-local so the write closure can capture it before spawn_blocking.
75    #[cfg(feature = "vectors")]
76    pub(super) fn arm() {
77        let flag = Arc::new(AtomicBool::new(true));
78        CURRENT.with(|c| *c.borrow_mut() = Some(flag));
79    }
80
81    /// Disarm: clear the thread-local (the Arc may live on in the closure
82    /// a moment longer, but the flag is already spent after one `take()`).
83    #[cfg(feature = "vectors")]
84    pub(super) fn disarm() {
85        CURRENT.with(|c| *c.borrow_mut() = None);
86    }
87
88    /// Called from inside the write closure (worker thread).
89    /// Atomically swaps `true` → `false` and returns whether it fired.
90    pub(super) fn take(flag: &Arc<AtomicBool>) -> bool {
91        flag.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
92            .is_ok()
93    }
94
95    /// RAII guard: arms the failpoint on construction and disarms on drop.
96    /// The Arc is stored in the thread-local and captured by the write closure
97    /// directly; the guard's only job is to ensure `disarm()` runs on drop.
98    #[cfg(feature = "vectors")]
99    pub(super) struct FailpointGuard;
100
101    #[cfg(feature = "vectors")]
102    impl FailpointGuard {
103        pub(super) fn new() -> Self {
104            arm();
105            Self
106        }
107    }
108
109    #[cfg(feature = "vectors")]
110    impl Drop for FailpointGuard {
111        fn drop(&mut self) {
112            disarm();
113        }
114    }
115}
116
117/// Cast a `&[f32]` slice to `&[u8]` for sqlite-vec blob binding.
118///
119/// # Safety
120///
121/// Safe: f32 has no alignment requirements beyond what &[u8] needs, the byte
122/// length is exactly the input slice size, and the lifetime is tied to input.
123fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
124    // SAFETY: `data` is a valid &[f32] so the pointer is non-null, well-aligned, and
125    // live for the call duration. u8 alignment is 1 (satisfied by any allocation).
126    // size_of_val gives the exact byte count. The returned slice borrows `data`
127    // so its lifetime cannot outlive the input reference.
128    unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
129}
130
131/// Snapshot the current thread's failpoint flag (test builds only; always
132/// `None` in a release build). Exists so `insert_batch` can capture the
133/// thread-local's value once, unconditionally, before choosing between the
134/// flag-on (WriterTask) and flag-off (legacy pool-mutex) write paths —
135/// both eventually move the captured `Option` into a `spawn_blocking`
136/// closure on a different thread than the one that read the thread-local.
137#[cfg(test)]
138fn current_failpoint() -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
139    failpoint::CURRENT.with(|c| c.borrow().clone())
140}
141
142#[cfg(not(test))]
143fn current_failpoint() -> Option<std::sync::Arc<std::sync::atomic::AtomicBool>> {
144    None
145}
146
147fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
148    StorageError::driver(StorageCapability::Vectors, op, e)
149}
150
151fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
152    StorageError::driver(StorageCapability::Vectors, op, e)
153}
154
155fn non_finite_index(data: &[f32]) -> Option<usize> {
156    data.iter().position(|v| !v.is_finite())
157}
158
159fn non_finite_vector_error(op: &'static str, idx: usize, value: f32) -> StorageError {
160    StorageError::InvalidInput {
161        capability: StorageCapability::Vectors,
162        operation: op.into(),
163        message: format!(
164            "non-finite value at index {idx}: {value} \
165             (NaN/Inf values corrupt distance computations)"
166        ),
167    }
168}
169
170/// Validate that `model_key` is safe to interpolate into a SQLite table name.
171fn validate_model_key(model_key: &str) -> Result<(), SqliteError> {
172    if model_key.is_empty()
173        || !model_key
174            .chars()
175            .all(|c| c.is_ascii_alphanumeric() || c == '_')
176    {
177        return Err(SqliteError::InvalidData(format!(
178            "invalid model_key '{}': must be non-empty and contain only ASCII alphanumeric/underscore characters",
179            model_key
180        )));
181    }
182    Ok(())
183}
184
185/// A VectorStore backed by sqlite-vec's vec0 virtual tables.
186///
187/// Each instance manages one table `vec_{model_key}`. The `namespace` field
188/// is a default for trait methods that lack a per-call namespace parameter
189/// (count, delete, info). Access control is enforced at the runtime layer.
190pub struct SqliteVecStore {
191    pool: Arc<ConnectionPool>,
192    is_file_backed: bool,
193    model_key: String,
194    embedding_model: String,
195    dimensions: usize,
196    table_name: String,
197    namespace: String,
198    writer_task: Option<crate::writer_task::WriterTaskHandle>,
199}
200
201impl SqliteVecStore {
202    /// Create a new store scoped to the given namespace.
203    ///
204    /// Returns an error if `model_key` contains characters unsafe for table name interpolation.
205    pub fn new(
206        pool: Arc<ConnectionPool>,
207        is_file_backed: bool,
208        model_key: String,
209        embedding_model: String,
210        dimensions: usize,
211        namespace: String,
212    ) -> Result<Self, SqliteError> {
213        validate_model_key(&model_key)?;
214        let table_name = format!("vec_{}", model_key);
215        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
216        // policy): a missing writer task degrades to the legacy pool-mutex
217        // path rather than failing construction.
218        let writer_task = pool.writer_task_handle().ok().flatten();
219        Ok(Self {
220            pool,
221            is_file_backed,
222            model_key,
223            embedding_model,
224            dimensions,
225            table_name,
226            namespace,
227            writer_task,
228        })
229    }
230
231    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
232        let config = self.pool.config();
233        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
234            operation: "vec_reader".into(),
235            message: "in-memory databases do not support standalone connections".into(),
236        })?;
237
238        let conn = rusqlite::Connection::open_with_flags(
239            path,
240            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
241                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
242                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
243        )
244        .map_err(|e| map_err(e, "open_vec_reader"))?;
245
246        conn.busy_timeout(config.busy_timeout)
247            .map_err(|e| map_err(e, "open_vec_reader"))?;
248        conn.pragma_update(None, "foreign_keys", "ON")
249            .map_err(|e| map_err(e, "open_vec_reader"))?;
250        conn.pragma_update(None, "synchronous", "NORMAL")
251            .map_err(|e| map_err(e, "open_vec_reader"))?;
252
253        Ok(conn)
254    }
255
256    /// Route a single-row DML-only write through the pool-wide `WriterTask`
257    /// when available, else fall back to `with_writer_unmanaged`. See
258    /// crates/khive-db/docs/api/vectors.md#with_writer--with_writer_unmanaged--writertask-routing-adr-067-component-a-fork-c-slice-2
259    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
260    where
261        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
262        R: Send + 'static,
263    {
264        if let Some(writer_task) = &self.writer_task {
265            return writer_task
266                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
267                .await;
268        }
269
270        self.with_writer_unmanaged(op, f).await
271    }
272
273    /// Legacy pool-mutex write path; bypasses the WriterTask channel
274    /// unconditionally. Reserved for closures that manage their own
275    /// transaction. See
276    /// crates/khive-db/docs/api/vectors.md#with_writer--with_writer_unmanaged--writertask-routing-adr-067-component-a-fork-c-slice-2
277    async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
278    where
279        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
280        R: Send + 'static,
281    {
282        let pool = Arc::clone(&self.pool);
283        tokio::task::spawn_blocking(move || {
284            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
285            f(guard.conn()).map_err(|e| map_err(e, op))
286        })
287        .await
288        .map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
289    }
290
291    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
292    where
293        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
294        R: Send + 'static,
295    {
296        if self.is_file_backed {
297            let conn = self.open_standalone_reader()?;
298            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
299                .await
300                .map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
301        } else {
302            let pool = Arc::clone(&self.pool);
303            tokio::task::spawn_blocking(move || {
304                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
305                f(guard.conn()).map_err(|e| map_err(e, op))
306            })
307            .await
308            .map_err(|e| StorageError::driver(StorageCapability::Vectors, op, e))?
309        }
310    }
311}
312
313/// One vector row's identity + payload for [`replace_vector_row_dml`] (#546).
314/// `embedding` must already be validated for the target table's dimension
315/// count (or delegated to the helper's own dimension check).
316struct VectorRowRef<'a> {
317    subject_id: Uuid,
318    namespace: &'a str,
319    kind: &'a str,
320    field: &'a str,
321    embedding_model: &'a str,
322    embedding: &'a [f32],
323}
324
325/// Shared DELETE-then-INSERT replacement DML for a single vector row (#546);
326/// caller owns the enclosing transaction/savepoint. See
327/// crates/khive-db/docs/api/vectors.md#replace_vector_row_dml--shared-delete-then-insert-replacement-546
328fn replace_vector_row_dml(
329    conn: &rusqlite::Connection,
330    table: &str,
331    dims: usize,
332    row: VectorRowRef<'_>,
333    failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
334) -> Result<(), rusqlite::Error> {
335    if row.embedding.len() != dims {
336        return Err(rusqlite::Error::InvalidParameterCount(
337            row.embedding.len(),
338            dims,
339        ));
340    }
341
342    let del_sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
343    conn.execute(
344        &del_sql,
345        rusqlite::params![row.subject_id.to_string(), row.namespace],
346    )?;
347
348    // Failpoint: fires only in cfg(test) when the guard is active. DELETE has
349    // already run; if the caller's rollback (transaction or SAVEPOINT) is
350    // missing, the deleted row is lost permanently.
351    #[cfg(test)]
352    if let Some(ref fp) = failpoint_flag {
353        if failpoint::take(fp) {
354            return Err(rusqlite::Error::InvalidParameterName(
355                "__test_failpoint_after_delete__".into(),
356            ));
357        }
358    }
359    #[cfg(not(test))]
360    let _ = failpoint_flag;
361
362    let ins_sql = format!(
363        "INSERT INTO {table} (subject_id, namespace, kind, field, embedding_model, embedding) \
364         VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
365    );
366    let blob = f32_slice_as_bytes(row.embedding);
367    conn.execute(
368        &ins_sql,
369        rusqlite::params![
370            row.subject_id.to_string(),
371            row.namespace,
372            row.kind,
373            row.field,
374            row.embedding_model,
375            blob
376        ],
377    )?;
378
379    // Delta record for the ANN restart classifier; rides the caller's
380    // savepoint/transaction so a rolled-back upsert leaves no log row.
381    conn.execute(
382        "INSERT INTO ann_write_log (namespace, embedding_model, kind, field, subject_id, op) \
383         VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')",
384        rusqlite::params![
385            row.namespace,
386            row.embedding_model,
387            row.kind,
388            row.field,
389            row.subject_id.to_string()
390        ],
391    )?;
392
393    Ok(())
394}
395
396/// Log `'delete'` rows into `ann_write_log` for every vector row in `table`
397/// matching `where_clause` (a predicate over the vec0 table's own columns).
398/// Must run in the same transaction as — and before — the corresponding
399/// `DELETE`, so the logged set is exactly the deleted set.
400fn log_vector_deletes(
401    conn: &rusqlite::Connection,
402    table: &str,
403    where_clause: &str,
404    params: &[&dyn rusqlite::ToSql],
405) -> Result<(), rusqlite::Error> {
406    let sql = format!(
407        "INSERT INTO ann_write_log (namespace, embedding_model, kind, field, subject_id, op) \
408         SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
409         FROM {table} WHERE {where_clause}"
410    );
411    conn.execute(&sql, params)?;
412    Ok(())
413}
414
415/// Delete `subject_id`'s row from every registered-model vector table, in
416/// `namespace` (#546).
417///
418/// Shared by runtime curation's entity/note merge cleanup, which must sweep
419/// the merged-away subject out of every model's `vec_{model_key}` table, not
420/// just the primary embedding model's. Callers own the enclosing transaction;
421/// this issues no `BEGIN`/`COMMIT`/`SAVEPOINT`.
422pub fn delete_subject_from_vector_tables(
423    conn: &rusqlite::Connection,
424    tables: &[String],
425    subject_id: Uuid,
426    namespace: &str,
427) -> Result<(), rusqlite::Error> {
428    for table in tables {
429        log_vector_deletes(
430            conn,
431            table,
432            "subject_id = ?1 AND namespace = ?2",
433            &[&subject_id.to_string(), &namespace],
434        )?;
435        let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
436        conn.execute(&sql, rusqlite::params![subject_id.to_string(), namespace])?;
437    }
438    Ok(())
439}
440
441/// DML-only batch insert loop shared by both the legacy (flag-off) and
442/// WriterTask-routed (flag-on) `insert_batch` paths (ADR-067 Component A).
443///
444/// Issues no OUTER `BEGIN` / `COMMIT` / `ROLLBACK` — the caller owns the
445/// enclosing transaction. The per-record named `SAVEPOINT vec_batch_record`
446/// is preserved unchanged: it gives a failed INSERT a no-worse-than-stale
447/// rollback (only that record's DELETE is undone) independent of which
448/// outer transaction wraps the loop.
449#[allow(clippy::too_many_arguments)]
450fn batch_insert_vectors_dml(
451    conn: &rusqlite::Connection,
452    table: &str,
453    dims: usize,
454    store_embedding_model: &str,
455    records: &[VectorRecord],
456    attempted: u64,
457    failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
458) -> Result<BatchWriteSummary, rusqlite::Error> {
459    let mut affected = 0u64;
460    let mut failed = 0u64;
461    let mut first_error = String::new();
462
463    for record in records {
464        if record.vectors.len() != 1 {
465            if first_error.is_empty() {
466                first_error = format!("expected 1 vector per record, got {}", record.vectors.len());
467            }
468            failed += 1;
469            continue;
470        }
471        let embedding = &record.vectors[0];
472        if embedding.len() != dims {
473            if first_error.is_empty() {
474                first_error = format!(
475                    "wrong vector dimension: expected {dims}, got {}",
476                    embedding.len()
477                );
478            }
479            failed += 1;
480            continue;
481        }
482        if non_finite_index(embedding).is_some() {
483            if first_error.is_empty() {
484                first_error = "embedding contains non-finite values (NaN or Inf)".to_string();
485            }
486            failed += 1;
487            continue;
488        }
489        let kind_str = record.kind.to_string();
490
491        // Wrap each record's DELETE+INSERT in a savepoint so a failed INSERT
492        // rolls back only that record's DELETE, leaving the prior vector intact
493        // (no-worse-than-stale guarantee, same as single-record `insert`).
494        conn.execute_batch("SAVEPOINT vec_batch_record")?;
495        let result = replace_vector_row_dml(
496            conn,
497            table,
498            dims,
499            VectorRowRef {
500                subject_id: record.subject_id,
501                namespace: &record.namespace,
502                kind: &kind_str,
503                field: &record.field,
504                embedding_model: store_embedding_model,
505                embedding,
506            },
507            failpoint_flag.clone(),
508        );
509        match result {
510            Ok(()) => {
511                conn.execute_batch("RELEASE SAVEPOINT vec_batch_record")?;
512                affected += 1;
513            }
514            Err(e) => {
515                let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT vec_batch_record");
516                let _ = conn.execute_batch("RELEASE SAVEPOINT vec_batch_record");
517                if first_error.is_empty() {
518                    first_error = e.to_string();
519                }
520                failed += 1;
521            }
522        }
523    }
524
525    Ok(BatchWriteSummary {
526        attempted,
527        affected,
528        failed,
529        first_error,
530    })
531}
532
533/// Shared DELETE-then-INSERT DML for single-record `insert`/`update`, run
534/// inside a named `SAVEPOINT` (nestable inside the WriterTask's own
535/// transaction) instead of `conn.unchecked_transaction()` (which would
536/// attempt a nested `BEGIN` and fail once this runs inside the WriterTask's
537/// already-open transaction). A failed INSERT rolls back only this
538/// SAVEPOINT, leaving the previous vector intact (no-worse-than-stale
539/// guarantee) — the single-record analog of `batch_insert_vectors_dml`'s
540/// per-record `SAVEPOINT vec_batch_record`.
541#[allow(clippy::too_many_arguments)]
542fn vec_upsert_atomic_dml(
543    conn: &rusqlite::Connection,
544    table: &str,
545    dims: usize,
546    subject_id: Uuid,
547    kind_str: &str,
548    namespace: &str,
549    field: &str,
550    embedding_model: &str,
551    embedding: &[f32],
552    savepoint_name: &'static str,
553    failpoint_flag: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
554) -> Result<(), rusqlite::Error> {
555    conn.execute_batch(&format!("SAVEPOINT {savepoint_name}"))?;
556    let result = replace_vector_row_dml(
557        conn,
558        table,
559        dims,
560        VectorRowRef {
561            subject_id,
562            namespace,
563            kind: kind_str,
564            field,
565            embedding_model,
566            embedding,
567        },
568        failpoint_flag,
569    );
570
571    match result {
572        Ok(()) => {
573            conn.execute_batch(&format!("RELEASE SAVEPOINT {savepoint_name}"))?;
574            Ok(())
575        }
576        Err(e) => {
577            let _ = conn.execute_batch(&format!("ROLLBACK TO SAVEPOINT {savepoint_name}"));
578            let _ = conn.execute_batch(&format!("RELEASE SAVEPOINT {savepoint_name}"));
579            Err(e)
580        }
581    }
582}
583
584/// DML-only orphan-sweep body shared by both the legacy (flag-off) and
585/// WriterTask-routed (flag-on) `orphan_sweep` paths (ADR-067 Amendment 1).
586///
587/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` — the caller owns the enclosing
588/// transaction (either the flag-off path's `Transaction::new_unchecked`, or
589/// the WriterTask drain loop's own `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK`
590/// wrap). `ns_json` / `kind_json` / `allow_json` are the pre-serialized JSON
591/// filter arguments (or `None` for "no filter") computed once by the caller.
592fn orphan_sweep_dml(
593    conn: &rusqlite::Connection,
594    table: &str,
595    ns_json: Option<&str>,
596    kind_json: Option<&str>,
597    allow_json: Option<&str>,
598    max_delete: i64,
599    dry_run: bool,
600) -> Result<OrphanSweepResult, rusqlite::Error> {
601    // Optional-filter clause shared across all three queries.
602    // Each ?N appears twice (IS NULL guard + json_each call); SQLite
603    // reuses the same bound value for every occurrence of the same ?N.
604    //   ?1 = namespace JSON or NULL   ?2 = kind JSON or NULL
605    //   ?3 = allowlist JSON or NULL
606    let filter_pred = "(?1 IS NULL OR namespace IN (SELECT value FROM json_each(?1))) \
607                       AND (?2 IS NULL OR kind IN (SELECT value FROM json_each(?2))) \
608                       AND (?3 IS NULL OR subject_id IN (SELECT value FROM json_each(?3)))";
609
610    // Live-subjects subquery used in the orphan anti-join.
611    //
612    // Policy-critical: `deleted_at IS NULL` means a soft-deleted substrate
613    // row is NOT considered live, so its vector is swept.
614    // To preserve vectors for soft-deleted subjects, remove the
615    // `deleted_at IS NULL` filter from both lines below (one-line change per
616    // table).  The `memories` table referenced in ADR-044 §5 does not exist;
617    // memory notes live in the `notes` table with kind = 'memory'.
618    let live_subq = "SELECT id FROM entities WHERE deleted_at IS NULL \
619                     UNION ALL \
620                     SELECT id FROM notes    WHERE deleted_at IS NULL";
621
622    let orphan_pred = format!(
623        "subject_id NOT IN ({live}) AND {f}",
624        live = live_subq,
625        f = filter_pred,
626    );
627
628    // 1. Scanned: rows matching the caller's filters (before orphan check).
629    let scan_sql = format!(
630        "SELECT COUNT(*) FROM {t} WHERE {f}",
631        t = table,
632        f = filter_pred
633    );
634    let scanned: i64 = conn.query_row(
635        &scan_sql,
636        rusqlite::params![ns_json, kind_json, allow_json],
637        |row| row.get(0),
638    )?;
639
640    // 2. Would-delete: orphaned rows among the scanned set.
641    let count_sql = format!(
642        "SELECT COUNT(*) FROM {t} WHERE {p}",
643        t = table,
644        p = orphan_pred,
645    );
646    let would_delete: i64 = conn.query_row(
647        &count_sql,
648        rusqlite::params![ns_json, kind_json, allow_json],
649        |row| row.get(0),
650    )?;
651
652    let max_delete_hit = would_delete > max_delete;
653
654    // 3. Delete — skipped in dry-run mode.
655    //
656    // `DELETE … LIMIT N` requires SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which
657    // rusqlite's bundled SQLite does not enable.  Portable alternative:
658    // delete subject_ids returned by a capped SELECT subquery.  SQLite
659    // materialises the inner SELECT before running the outer DELETE, so there
660    // is no self-referential conflict.
661    // Materialize the capped victim set first: the same `LIMIT` subquery
662    // evaluated twice (once to log deletes, once to delete) has no ordering
663    // guarantee, so logging and deleting must share one explicit id list.
664    let deleted: i64 = if dry_run {
665        0
666    } else {
667        let select_sql = format!(
668            "SELECT subject_id FROM {t} WHERE {p} LIMIT ?4",
669            t = table,
670            p = orphan_pred,
671        );
672        let mut stmt = conn.prepare(&select_sql)?;
673        let victim_ids: Vec<String> = stmt
674            .query_map(
675                rusqlite::params![ns_json, kind_json, allow_json, max_delete],
676                |row| row.get::<_, String>(0),
677            )?
678            .collect::<Result<_, _>>()?;
679        drop(stmt);
680
681        let mut total: i64 = 0;
682        for chunk in victim_ids.chunks(400) {
683            let placeholders: String = (1..=chunk.len())
684                .map(|i| format!("?{i}"))
685                .collect::<Vec<_>>()
686                .join(", ");
687            let in_clause = format!("subject_id IN ({placeholders})");
688            let params: Vec<&dyn rusqlite::ToSql> =
689                chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
690            log_vector_deletes(conn, table, &in_clause, &params)?;
691            let del_sql = format!("DELETE FROM {t} WHERE {in_clause}", t = table);
692            let mut del_stmt = conn.prepare(&del_sql)?;
693            for (i, id_str) in chunk.iter().enumerate() {
694                del_stmt.raw_bind_parameter(i + 1, id_str.as_str())?;
695            }
696            total += del_stmt.raw_execute()? as i64;
697        }
698        total
699    };
700
701    Ok(OrphanSweepResult {
702        scanned: scanned as u64,
703        would_delete: would_delete as u64,
704        deleted: deleted as u64,
705        max_delete_hit,
706    })
707}
708
709#[async_trait]
710impl VectorStore for SqliteVecStore {
711    async fn insert(
712        &self,
713        subject_id: Uuid,
714        kind: SubstrateKind,
715        namespace: &str,
716        field: &str,
717        vectors: Vec<Vec<f32>>,
718    ) -> Result<(), StorageError> {
719        if vectors.len() != 1 {
720            return Err(StorageError::Unsupported {
721                capability: StorageCapability::Vectors,
722                operation: "vec_insert".into(),
723                message: "sqlite-vec supports exactly one vector per record".into(),
724            });
725        }
726        let embedding = vectors.into_iter().next().expect("len checked");
727
728        let table = self.table_name.clone();
729        let dims = self.dimensions;
730        let namespace = namespace.to_string();
731        let field = field.to_string();
732        let kind_str = kind.to_string();
733        let embedding_model = self.embedding_model.clone();
734
735        if embedding.len() == dims {
736            if let Some(idx) = non_finite_index(&embedding) {
737                return Err(non_finite_vector_error("vec_insert", idx, embedding[idx]));
738            }
739        }
740
741        // Capture the failpoint Arc (if any) from the thread-local on the
742        // calling thread before handing the closure to spawn_blocking.
743        let failpoint_flag = current_failpoint();
744
745        // ADR-067 Component A (Fork C slice 2): when the write queue is
746        // enabled, route through the pool-wide WriterTask. DML-only
747        // closure — atomicity is provided by `vec_upsert_atomic_dml`'s
748        // named SAVEPOINT rather than `conn.unchecked_transaction()`,
749        // which would attempt a nested `BEGIN` and fail under the
750        // WriterTask's already-open transaction.
751        if let Some(writer_task) = &self.writer_task {
752            let table2 = table.clone();
753            let namespace2 = namespace.clone();
754            let field2 = field.clone();
755            let kind_str2 = kind_str.clone();
756            let embedding_model2 = embedding_model.clone();
757            let embedding2 = embedding.clone();
758            return writer_task
759                .send(move |conn| {
760                    vec_upsert_atomic_dml(
761                        conn,
762                        &table2,
763                        dims,
764                        subject_id,
765                        &kind_str2,
766                        &namespace2,
767                        &field2,
768                        &embedding_model2,
769                        &embedding2,
770                        "vec_insert_atomic",
771                        failpoint_flag,
772                    )
773                    .map_err(|e| map_err(e, "vec_insert"))
774                })
775                .await;
776        }
777
778        // Flag-off (default) path: the closure owns its own transaction via
779        // `conn.unchecked_transaction()`; the DELETE+INSERT body is the same
780        // shared helper the WriterTask/batch paths use (#546), so this path
781        // now also exercises the post-delete failpoint in tests.
782        let origin = self.pool.origin();
783        self.with_writer("vec_insert", move |conn| {
784            // ADR-091 Plank 0: register the span before opening the transaction so
785            // the handle (declared first) drops AFTER `tx` (declared second) —
786            // locals drop in reverse declaration order, so `tx`'s own Drop (which
787            // rolls back if uncommitted) runs while the registry entry is still
788            // present.
789            let _tx_handle = khive_storage::tx_registry::register_scoped(
790                Some("vec_insert_tx".to_string()),
791                origin,
792            );
793            let tx = conn.unchecked_transaction()?;
794
795            replace_vector_row_dml(
796                &tx,
797                &table,
798                dims,
799                VectorRowRef {
800                    subject_id,
801                    namespace: &namespace,
802                    kind: &kind_str,
803                    field: &field,
804                    embedding_model: &embedding_model,
805                    embedding: &embedding,
806                },
807                failpoint_flag,
808            )?;
809
810            tx.commit()
811        })
812        .await
813    }
814
815    async fn insert_batch(
816        &self,
817        records: Vec<VectorRecord>,
818    ) -> Result<BatchWriteSummary, StorageError> {
819        let table = self.table_name.clone();
820        let dims = self.dimensions;
821        let attempted = records.len() as u64;
822        let store_embedding_model = self.embedding_model.clone();
823
824        // Capture the failpoint Arc (if any) from the thread-local on the
825        // calling thread before handing the closure to spawn_blocking — both
826        // the WriterTask path and the legacy path eventually run the closure
827        // on a different thread than the one that reads the thread-local.
828        let failpoint_flag = current_failpoint();
829
830        // ADR-067 Component A: when the write queue is enabled, route
831        // through the pool-wide WriterTask. DML-only closure (the per-record
832        // `SAVEPOINT vec_batch_record` is preserved unchanged — only the
833        // OUTER BEGIN IMMEDIATE/COMMIT is removed, since the WriterTask's
834        // run loop owns the enclosing transaction).
835        if let Some(writer_task) = &self.writer_task {
836            let table2 = table.clone();
837            let store_embedding_model2 = store_embedding_model.clone();
838            return writer_task
839                .send(move |conn| {
840                    batch_insert_vectors_dml(
841                        conn,
842                        &table2,
843                        dims,
844                        &store_embedding_model2,
845                        &records,
846                        attempted,
847                        failpoint_flag,
848                    )
849                    .map_err(|e| map_err(e, "vec_insert_batch"))
850                })
851                .await;
852        }
853
854        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
855        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
856        let origin = self.pool.origin();
857        self.with_writer("vec_insert_batch", move |conn| {
858            conn.execute_batch("BEGIN IMMEDIATE")?;
859            let _tx_handle = khive_storage::tx_registry::register_scoped(
860                Some("vector_insert_batch".to_string()),
861                origin,
862            );
863
864            let summary = batch_insert_vectors_dml(
865                conn,
866                &table,
867                dims,
868                &store_embedding_model,
869                &records,
870                attempted,
871                failpoint_flag,
872            )?;
873
874            conn.execute_batch("COMMIT")?;
875
876            Ok(summary)
877        })
878        .await
879    }
880
881    async fn update(
882        &self,
883        subject_id: Uuid,
884        kind: SubstrateKind,
885        namespace: &str,
886        field: &str,
887        vectors: Vec<Vec<f32>>,
888    ) -> Result<(), StorageError> {
889        if vectors.len() != 1 {
890            return Err(StorageError::Unsupported {
891                capability: StorageCapability::Vectors,
892                operation: "vec_update".into(),
893                message: "sqlite-vec supports exactly one vector per record".into(),
894            });
895        }
896        let embedding = vectors.into_iter().next().expect("len checked");
897
898        let table = self.table_name.clone();
899        let dims = self.dimensions;
900        let namespace = namespace.to_string();
901        let field = field.to_string();
902        let kind_str = kind.to_string();
903        let embedding_model = self.embedding_model.clone();
904
905        if embedding.len() == dims {
906            if let Some(idx) = non_finite_index(&embedding) {
907                return Err(non_finite_vector_error("vec_update", idx, embedding[idx]));
908            }
909        }
910
911        // Capture the failpoint Arc (if any) from the thread-local on the
912        // calling thread before handing the closure to spawn_blocking.
913        let failpoint_flag = current_failpoint();
914
915        // ADR-067 Component A (Fork C slice 2): when the write queue is
916        // enabled, route through the pool-wide WriterTask. DML-only
917        // closure — atomicity is provided by `vec_upsert_atomic_dml`'s
918        // named SAVEPOINT rather than `conn.unchecked_transaction()`,
919        // which would attempt a nested `BEGIN` and fail under the
920        // WriterTask's already-open transaction.
921        if let Some(writer_task) = &self.writer_task {
922            let table2 = table.clone();
923            let namespace2 = namespace.clone();
924            let field2 = field.clone();
925            let kind_str2 = kind_str.clone();
926            let embedding_model2 = embedding_model.clone();
927            let embedding2 = embedding.clone();
928            return writer_task
929                .send(move |conn| {
930                    vec_upsert_atomic_dml(
931                        conn,
932                        &table2,
933                        dims,
934                        subject_id,
935                        &kind_str2,
936                        &namespace2,
937                        &field2,
938                        &embedding_model2,
939                        &embedding2,
940                        "vec_update_atomic",
941                        failpoint_flag,
942                    )
943                    .map_err(|e| map_err(e, "vec_update"))
944                })
945                .await;
946        }
947
948        // Flag-off (default) path: the closure owns its own transaction via
949        // `conn.unchecked_transaction()`; the DELETE+INSERT body is the same
950        // shared helper the WriterTask/batch paths use (#546).
951        let origin = self.pool.origin();
952        self.with_writer("vec_update", move |conn| {
953            // ADR-091 Plank 0: registered before the transaction is opened — see
954            // the matching note in `insert()` above for the drop-order rationale.
955            let _tx_handle = khive_storage::tx_registry::register_scoped(
956                Some("vec_update_tx".to_string()),
957                origin,
958            );
959            let tx = conn.unchecked_transaction()?;
960
961            replace_vector_row_dml(
962                &tx,
963                &table,
964                dims,
965                VectorRowRef {
966                    subject_id,
967                    namespace: &namespace,
968                    kind: &kind_str,
969                    field: &field,
970                    embedding_model: &embedding_model,
971                    embedding: &embedding,
972                },
973                failpoint_flag,
974            )?;
975
976            tx.commit()
977        })
978        .await
979    }
980
981    async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
982        let statement = delete_vector_statement(&self.table_name, subject_id, &self.namespace);
983        let table = self.table_name.clone();
984        let namespace = self.namespace.clone();
985
986        self.with_writer("vec_delete", move |conn| {
987            conn.execute_batch("SAVEPOINT vec_delete_log")?;
988            let result = (|| {
989                log_vector_deletes(
990                    conn,
991                    &table,
992                    "subject_id = ?1 AND namespace = ?2",
993                    &[&subject_id.to_string(), &namespace],
994                )?;
995                let mut stmt = conn.prepare(&statement.sql)?;
996                bind_params(&mut stmt, &statement.params)?;
997                Ok(stmt.raw_execute()? > 0)
998            })();
999            match result {
1000                Ok(v) => {
1001                    conn.execute_batch("RELEASE SAVEPOINT vec_delete_log")?;
1002                    Ok(v)
1003                }
1004                Err(e) => {
1005                    let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT vec_delete_log");
1006                    let _ = conn.execute_batch("RELEASE SAVEPOINT vec_delete_log");
1007                    Err(e)
1008                }
1009            }
1010        })
1011        .await
1012    }
1013
1014    async fn count(&self) -> Result<u64, StorageError> {
1015        let table = self.table_name.clone();
1016        let namespace = self.namespace.clone();
1017
1018        self.with_reader("vec_count", move |conn| {
1019            let sql = format!("SELECT COUNT(*) FROM {} WHERE namespace = ?1", table);
1020            let count: i64 =
1021                conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
1022            Ok(count as u64)
1023        })
1024        .await
1025    }
1026
1027    async fn search(
1028        &self,
1029        request: VectorSearchRequest,
1030    ) -> Result<Vec<VectorSearchHit>, StorageError> {
1031        if request.filter.as_ref().is_some_and(|f| !f.is_empty()) {
1032            return Err(StorageError::Unsupported {
1033                capability: StorageCapability::Vectors,
1034                operation: "vec_search".into(),
1035                message: "use search_with_filter for filtered queries".into(),
1036            });
1037        }
1038        if request.query_vectors.len() != 1 {
1039            return Err(StorageError::Unsupported {
1040                capability: StorageCapability::Vectors,
1041                operation: "vec_search".into(),
1042                message: "sqlite-vec supports exactly one query vector per search".into(),
1043            });
1044        }
1045        let query_embedding = request.query_vectors[0].clone();
1046
1047        let table = self.table_name.clone();
1048        let dims = self.dimensions;
1049        // Use request.namespace if present; fall back to self.namespace.
1050        let namespace = request
1051            .namespace
1052            .clone()
1053            .unwrap_or_else(|| self.namespace.clone());
1054        let kind_filter = request.kind.map(|k| k.to_string());
1055        // Use the request's embedding_model filter, or fall back to this store's model.
1056        let effective_model = request
1057            .embedding_model
1058            .clone()
1059            .unwrap_or_else(|| self.embedding_model.clone());
1060
1061        if query_embedding.len() == dims {
1062            if let Some(idx) = non_finite_index(&query_embedding) {
1063                return Err(non_finite_vector_error(
1064                    "vec_search",
1065                    idx,
1066                    query_embedding[idx],
1067                ));
1068            }
1069        }
1070
1071        self.with_reader("vec_search", move |conn| {
1072            if query_embedding.len() != dims {
1073                return Err(rusqlite::Error::InvalidParameterCount(
1074                    query_embedding.len(),
1075                    dims,
1076                ));
1077            }
1078
1079            // Push namespace+embedding_model (and optionally kind) directly into
1080            // the MATCH predicate so sqlite-vec evaluates them before computing
1081            // global top-k, preventing cross-namespace recall starvation.
1082            let kind_clause = if kind_filter.is_some() {
1083                "AND kind = ?5"
1084            } else {
1085                ""
1086            };
1087            let sql = format!(
1088                "SELECT subject_id, distance \
1089                 FROM {t} \
1090                 WHERE embedding MATCH ?1 \
1091                   AND namespace = ?3 \
1092                   AND embedding_model = ?4 \
1093                   {kind_clause} \
1094                 ORDER BY distance \
1095                 LIMIT ?2",
1096                t = table,
1097                kind_clause = kind_clause
1098            );
1099
1100            let query_blob = f32_slice_as_bytes(&query_embedding);
1101            let mut stmt = conn.prepare(&sql)?;
1102
1103            // Collect rows into a Vec to avoid holding MappedRows (which is
1104            // parameterised on its closure type) across both branches.
1105            let raw_rows: Vec<rusqlite::Result<(String, f64)>> =
1106                if let Some(ref kind_str) = kind_filter {
1107                    stmt.query_map(
1108                        rusqlite::params![
1109                            query_blob,
1110                            request.top_k,
1111                            &namespace,
1112                            &effective_model,
1113                            kind_str
1114                        ],
1115                        |row| {
1116                            let id_str: String = row.get(0)?;
1117                            let distance: f64 = row.get(1)?;
1118                            Ok((id_str, distance))
1119                        },
1120                    )?
1121                    .collect()
1122                } else {
1123                    stmt.query_map(
1124                        rusqlite::params![query_blob, request.top_k, &namespace, &effective_model],
1125                        |row| {
1126                            let id_str: String = row.get(0)?;
1127                            let distance: f64 = row.get(1)?;
1128                            Ok((id_str, distance))
1129                        },
1130                    )?
1131                    .collect()
1132                };
1133
1134            let mut hits = Vec::new();
1135            for (rank_idx, row) in raw_rows.into_iter().enumerate() {
1136                let (id_str, distance) = row?;
1137                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1138                    rusqlite::Error::FromSqlConversionFailure(
1139                        0,
1140                        rusqlite::types::Type::Text,
1141                        Box::new(e),
1142                    )
1143                })?;
1144
1145                // sqlite-vec cosine distance: 0.0 = identical, 2.0 = opposite.
1146                // Convert to similarity in [0, 1]: score = 1.0 - (distance / 2.0)
1147                let similarity = 1.0 - (distance / 2.0);
1148
1149                hits.push(VectorSearchHit {
1150                    subject_id,
1151                    score: DeterministicScore::from_f64(similarity),
1152                    rank: (rank_idx + 1) as u32,
1153                });
1154            }
1155
1156            Ok(hits)
1157        })
1158        .await
1159    }
1160
1161    async fn info(&self) -> Result<VectorStoreInfo, StorageError> {
1162        let count = self.count().await?;
1163
1164        Ok(VectorStoreInfo {
1165            model_name: self.model_key.clone(),
1166            dimensions: self.dimensions,
1167            index_kind: VectorIndexKind::SqliteVec,
1168            entry_count: count,
1169            needs_rebuild: false,
1170            last_rebuild_at: None,
1171        })
1172    }
1173
1174    async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<VectorStoreInfo, StorageError> {
1175        // sqlite-vec uses brute-force search — no index to rebuild.
1176        self.info().await
1177    }
1178
1179    async fn delete_subjects(&self, ids: &[Uuid]) -> Result<u64, StorageError> {
1180        if ids.is_empty() {
1181            return Ok(0);
1182        }
1183        let table = self.table_name.clone();
1184        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
1185        let mut total_deleted: u64 = 0;
1186
1187        // Batch in ≤400 IDs per statement to stay within SQLite's variable limit.
1188        for chunk in id_strings.chunks(400) {
1189            let placeholders: String = (1..=chunk.len())
1190                .map(|i| format!("?{i}"))
1191                .collect::<Vec<_>>()
1192                .join(", ");
1193            let sql = format!("DELETE FROM {table} WHERE subject_id IN ({placeholders})");
1194            let in_clause = format!("subject_id IN ({placeholders})");
1195            let chunk_owned = chunk.to_vec();
1196            let table_cl = table.clone();
1197            let table_sp = table.clone();
1198            let deleted = self
1199                .with_writer("vec_delete_subjects", move |conn| {
1200                    conn.execute_batch("SAVEPOINT vec_delete_subjects_log")?;
1201                    let result = (|| {
1202                        let params: Vec<&dyn rusqlite::ToSql> = chunk_owned
1203                            .iter()
1204                            .map(|s| s as &dyn rusqlite::ToSql)
1205                            .collect();
1206                        log_vector_deletes(conn, &table_sp, &in_clause, &params)?;
1207                        let mut stmt = conn.prepare(&sql)?;
1208                        for (i, id_str) in chunk_owned.iter().enumerate() {
1209                            stmt.raw_bind_parameter(i + 1, id_str.as_str())?;
1210                        }
1211                        stmt.raw_execute().map(|n| n as u64)
1212                    })();
1213                    match result {
1214                        Ok(n) => {
1215                            conn.execute_batch("RELEASE SAVEPOINT vec_delete_subjects_log")?;
1216                            Ok(n)
1217                        }
1218                        Err(e) => {
1219                            let _ =
1220                                conn.execute_batch("ROLLBACK TO SAVEPOINT vec_delete_subjects_log");
1221                            let _ = conn.execute_batch("RELEASE SAVEPOINT vec_delete_subjects_log");
1222                            Err(e)
1223                        }
1224                    }
1225                })
1226                .await
1227                .map_err(|e| {
1228                    tracing::warn!(error = %e, table = %table_cl, "delete_subjects chunk failed");
1229                    e
1230                })?;
1231            total_deleted += deleted;
1232        }
1233        Ok(total_deleted)
1234    }
1235
1236    async fn batch_exists(
1237        &self,
1238        ids: &[Uuid],
1239        namespace: &str,
1240    ) -> Result<HashSet<Uuid>, StorageError> {
1241        if ids.is_empty() {
1242            return Ok(HashSet::new());
1243        }
1244
1245        let table = self.table_name.clone();
1246        let namespace = namespace.to_string();
1247        let model = self.embedding_model.clone();
1248        let id_strings: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
1249
1250        self.with_reader("vec_batch_exists", move |conn| {
1251            let mut found = HashSet::new();
1252
1253            for chunk in id_strings.chunks(400) {
1254                // ?1 = namespace, ?2 = embedding_model, ?3.. = subject IDs.
1255                let placeholders: String = (0..chunk.len())
1256                    .map(|i| format!("?{}", i + 3))
1257                    .collect::<Vec<_>>()
1258                    .join(", ");
1259
1260                let sql = format!(
1261                    "SELECT subject_id FROM {} WHERE namespace = ?1 \
1262                     AND embedding_model = ?2 AND subject_id IN ({})",
1263                    table, placeholders
1264                );
1265
1266                let mut stmt = conn.prepare(&sql)?;
1267                stmt.raw_bind_parameter(1, namespace.as_str())?;
1268                stmt.raw_bind_parameter(2, model.as_str())?;
1269                for (i, id_str) in chunk.iter().enumerate() {
1270                    stmt.raw_bind_parameter(i + 3, id_str.as_str())?;
1271                }
1272
1273                let mut rows = stmt.raw_query();
1274                while let Some(row) = rows.next()? {
1275                    let id_str: String = row.get(0)?;
1276                    if let Ok(uuid) = Uuid::parse_str(&id_str) {
1277                        found.insert(uuid);
1278                    }
1279                }
1280            }
1281
1282            Ok(found)
1283        })
1284        .await
1285    }
1286
1287    async fn orphan_sweep(&self, config: &OrphanSweepConfig) -> StorageResult<OrphanSweepResult> {
1288        let table = self.table_name.clone();
1289
1290        // Serialize filter lists as JSON arrays for json_each() usage inside SQL.
1291        // An empty list becomes None, which binds as NULL; the IS NULL guard then
1292        // short-circuits to true, passing all rows through (= no filtering).
1293        let ns_json: Option<String> = if config.namespaces.is_empty() {
1294            None
1295        } else {
1296            serde_json::to_string(&config.namespaces).ok()
1297        };
1298
1299        let kind_json: Option<String> = if config.substrate_kinds.is_empty() {
1300            None
1301        } else {
1302            let strs: Vec<String> = config
1303                .substrate_kinds
1304                .iter()
1305                .map(|k| k.to_string())
1306                .collect();
1307            serde_json::to_string(&strs).ok()
1308        };
1309
1310        // None = all rows eligible; Some(ids) = only those IDs may be swept.
1311        let allow_json: Option<String> = config.subject_id_allowlist.as_ref().map(|ids| {
1312            let strs: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
1313            serde_json::to_string(&strs).unwrap_or_default()
1314        });
1315
1316        let max_delete = config.max_delete as i64;
1317        let dry_run = config.dry_run;
1318
1319        // ADR-067 Amendment 1: when the write queue is enabled, route through
1320        // the pool-wide WriterTask. DML-only closure — `run_writer_task`'s
1321        // drain loop already owns the enclosing `BEGIN IMMEDIATE`/`COMMIT`/
1322        // `ROLLBACK` for this request, so the closure must not open or commit
1323        // its own transaction; issuing `Transaction::new_unchecked`'s `BEGIN
1324        // IMMEDIATE` here would violate SQLite's nested-transaction rule and
1325        // fail with `SQLITE_ERROR: cannot start a transaction within a
1326        // transaction` (ADR-067 lines 271-276).
1327        if let Some(writer_task) = &self.writer_task {
1328            let table2 = table.clone();
1329            let ns_json2 = ns_json.clone();
1330            let kind_json2 = kind_json.clone();
1331            let allow_json2 = allow_json.clone();
1332            return writer_task
1333                .send(move |conn| {
1334                    orphan_sweep_dml(
1335                        conn,
1336                        &table2,
1337                        ns_json2.as_deref(),
1338                        kind_json2.as_deref(),
1339                        allow_json2.as_deref(),
1340                        max_delete,
1341                        dry_run,
1342                    )
1343                    .map_err(|e| map_err(e, "orphan_sweep"))
1344                })
1345                .await;
1346        }
1347
1348        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
1349        // behavior — the closure owns its own transaction via
1350        // `Transaction::new_unchecked`.
1351        let origin = self.pool.origin();
1352        self.with_writer_unmanaged("orphan_sweep", move |conn| {
1353            // `Transaction::new_unchecked` issues `BEGIN IMMEDIATE` and RAII-manages
1354            // rollback via its Drop impl: it checks `conn.is_autocommit()` and issues
1355            // ROLLBACK when the connection still has an open transaction — covering both
1356            // early-`?` errors AND a COMMIT that fails with SQLITE_BUSY (BUSY leaves
1357            // the transaction open, so autocommit is false, and Drop rolls back).
1358            // The hand-rolled guard used previously set `done = true` before COMMIT,
1359            // which would have skipped the Drop-ROLLBACK on a BUSY COMMIT and re-poisoned
1360            // the pool.  Using the native primitive avoids that class of bug entirely.
1361            //
1362            // `with_writer_unmanaged` serialises all callers through the pool mutex — at
1363            // most one writer closure executes on this connection at a time, so no nested
1364            // transactions can exist when this line runs.
1365            //
1366            // ADR-091 Plank 0: registered before the transaction is opened — see the
1367            // matching note in `insert()` for the drop-order rationale (the handle,
1368            // declared first, drops after `tx`'s own Drop/rollback runs).
1369            let _tx_handle = khive_storage::tx_registry::register_scoped(
1370                Some("vec_orphan_sweep".to_string()),
1371                origin,
1372            );
1373            let tx = rusqlite::Transaction::new_unchecked(
1374                conn,
1375                rusqlite::TransactionBehavior::Immediate,
1376            )?;
1377
1378            let result = orphan_sweep_dml(
1379                conn,
1380                &table,
1381                ns_json.as_deref(),
1382                kind_json.as_deref(),
1383                allow_json.as_deref(),
1384                max_delete,
1385                dry_run,
1386            )?;
1387
1388            tx.commit()?;
1389
1390            Ok(result)
1391        })
1392        .await
1393    }
1394
1395    fn capabilities(&self) -> &'static VectorStoreCapabilities {
1396        static SQLITE_VEC_CAPABILITIES: OnceLock<VectorStoreCapabilities> = OnceLock::new();
1397        SQLITE_VEC_CAPABILITIES.get_or_init(|| VectorStoreCapabilities {
1398            supports_filter: false,
1399            supports_batch_search: false,
1400            supports_quantization: false,
1401            supports_update: false,
1402            supports_orphan_sweep: true,
1403            // sqlite-vec uses subject_id as PRIMARY KEY — only one vector per
1404            // subject per namespace is stored. Callers must use a single canonical
1405            // field (e.g. "content") and are not permitted to store both
1406            // "entity.title" and "entity.body" as separate vectors in one table.
1407            supports_multi_field: false,
1408            // sqlite-vec 0.1.9 rejects dimensions > SQLITE_VEC_VEC0_MAX_DIMENSIONS (8192).
1409            // Reporting 8192 lets callers know that 4097–8192 dimensional models are
1410            // supported. The previous value of 4096 was the K_MAX (neighbors per query)
1411            // constant, not the dimension limit.
1412            max_dimensions: Some(8192),
1413            index_kinds: vec![VectorIndexKind::SqliteVec],
1414        })
1415    }
1416}
1417
1418impl SqliteVecStore {
1419    /// Score a fixed set of candidate IDs against a query embedding.
1420    ///
1421    /// Unlike `search`, this does not use the MATCH index — it computes cosine
1422    /// distance directly for the supplied IDs only. Results are returned sorted
1423    /// by descending score.
1424    pub async fn score_candidates(
1425        &self,
1426        query_embedding: &[f32],
1427        candidate_ids: &[Uuid],
1428    ) -> Result<Vec<VectorSearchHit>, StorageError> {
1429        if candidate_ids.is_empty() || query_embedding.is_empty() {
1430            return Ok(Vec::new());
1431        }
1432
1433        let dims = self.dimensions;
1434        if query_embedding.len() != dims {
1435            return Err(StorageError::InvalidInput {
1436                capability: StorageCapability::Vectors,
1437                operation: "score_candidates".into(),
1438                message: format!(
1439                    "query has {} dims, expected {}",
1440                    query_embedding.len(),
1441                    dims
1442                ),
1443            });
1444        }
1445
1446        if let Some(idx) = non_finite_index(query_embedding) {
1447            return Err(non_finite_vector_error(
1448                "score_candidates",
1449                idx,
1450                query_embedding[idx],
1451            ));
1452        }
1453
1454        let table = self.table_name.clone();
1455        let namespace = self.namespace.clone();
1456        let embedding_model = self.embedding_model.clone();
1457        let query_vec = query_embedding.to_vec();
1458        let ids: Vec<String> = candidate_ids.iter().map(|id| id.to_string()).collect();
1459
1460        self.with_reader("score_candidates", move |conn| {
1461            let mut all_hits: Vec<VectorSearchHit> = Vec::new();
1462            let query_blob = f32_slice_as_bytes(&query_vec);
1463
1464            for chunk in ids.chunks(399) {
1465                let placeholders: String = chunk
1466                    .iter()
1467                    .enumerate()
1468                    .map(|(i, _)| format!("?{}", i + 4))
1469                    .collect::<Vec<_>>()
1470                    .join(", ");
1471
1472                let sql = format!(
1473                    "SELECT e.subject_id, vec_distance_cosine(e.embedding, ?1) as distance \
1474                     FROM {} e \
1475                     WHERE e.namespace = ?2 AND e.embedding_model = ?3 \
1476                       AND e.subject_id IN ({})",
1477                    table, placeholders
1478                );
1479
1480                let mut stmt = conn.prepare(&sql)?;
1481                stmt.raw_bind_parameter(1, query_blob)?;
1482                stmt.raw_bind_parameter(2, namespace.as_str())?;
1483                stmt.raw_bind_parameter(3, embedding_model.as_str())?;
1484                for (i, id_str) in chunk.iter().enumerate() {
1485                    stmt.raw_bind_parameter(i + 4, id_str.as_str())?;
1486                }
1487
1488                let mut rows = stmt.raw_query();
1489                while let Some(row) = rows.next()? {
1490                    let id_str: String = row.get(0)?;
1491                    let distance: f64 = row.get(1)?;
1492
1493                    let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1494                        rusqlite::Error::FromSqlConversionFailure(
1495                            0,
1496                            rusqlite::types::Type::Text,
1497                            Box::new(e),
1498                        )
1499                    })?;
1500
1501                    let similarity = 1.0 - (distance / 2.0);
1502                    all_hits.push(VectorSearchHit {
1503                        subject_id,
1504                        score: DeterministicScore::from_f64(similarity),
1505                        rank: 0,
1506                    });
1507                }
1508            }
1509
1510            all_hits.sort_by_key(|hit| std::cmp::Reverse(hit.score));
1511            for (i, hit) in all_hits.iter_mut().enumerate() {
1512                hit.rank = (i + 1) as u32;
1513            }
1514
1515            Ok(all_hits)
1516        })
1517        .await
1518    }
1519}
1520
1521#[cfg(all(test, feature = "vectors"))]
1522mod batch_exists_tests {
1523    use std::collections::HashSet;
1524    use std::sync::Arc;
1525
1526    use khive_types::SubstrateKind;
1527    use uuid::Uuid;
1528
1529    use super::*;
1530
1531    fn make_vec_pool() -> Arc<crate::pool::ConnectionPool> {
1532        use crate::pool::{ConnectionPool, PoolConfig};
1533        crate::extension::ensure_extensions_loaded();
1534        let config = PoolConfig {
1535            path: None,
1536            ..PoolConfig::default()
1537        };
1538        Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
1539    }
1540
1541    fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
1542        let writer = pool.try_writer().expect("pool writer");
1543        let ddl = format!(
1544            "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
1545             subject_id TEXT PRIMARY KEY, \
1546             namespace TEXT NOT NULL, \
1547             kind TEXT NOT NULL, \
1548             field TEXT NOT NULL, \
1549             embedding_model TEXT NOT NULL, \
1550             embedding float[{}] distance_metric=cosine)",
1551            model_key, dims
1552        );
1553        writer.conn().execute_batch(&ddl).expect("create vec table");
1554        writer
1555            .conn()
1556            .execute_batch(crate::migrations::ANN_WRITE_LOG_DDL)
1557            .expect("create ann_write_log");
1558    }
1559
1560    /// Valid (underscored) model key: batch_exists returns the exact set of IDs
1561    /// that have embeddings and excludes IDs that were never inserted.
1562    #[tokio::test]
1563    async fn batch_exists_returns_correct_set_for_underscored_model_key() {
1564        let pool = make_vec_pool();
1565        let model_key = "all_minilm_l6_v2";
1566        let dims = 4;
1567        let ns = "ns:test";
1568
1569        create_vec_table(&pool, model_key, dims);
1570
1571        let store = SqliteVecStore::new(
1572            pool,
1573            false,
1574            model_key.to_string(),
1575            model_key.to_string(),
1576            dims,
1577            ns.to_string(),
1578        )
1579        .expect("SqliteVecStore::new");
1580
1581        let id1 = Uuid::new_v4();
1582        let id2 = Uuid::new_v4();
1583        let id_absent = Uuid::new_v4();
1584
1585        store
1586            .insert(
1587                id1,
1588                SubstrateKind::Entity,
1589                ns,
1590                "body",
1591                vec![vec![0.1, 0.2, 0.3, 0.4]],
1592            )
1593            .await
1594            .expect("insert id1");
1595        store
1596            .insert(
1597                id2,
1598                SubstrateKind::Entity,
1599                ns,
1600                "body",
1601                vec![vec![0.5, 0.6, 0.7, 0.8]],
1602            )
1603            .await
1604            .expect("insert id2");
1605
1606        let exists = store
1607            .batch_exists(&[id1, id2, id_absent], ns)
1608            .await
1609            .expect("batch_exists");
1610
1611        assert!(exists.contains(&id1), "id1 must be found");
1612        assert!(exists.contains(&id2), "id2 must be found");
1613        assert!(
1614            !exists.contains(&id_absent),
1615            "absent id must not be returned"
1616        );
1617        assert_eq!(exists.len(), 2);
1618    }
1619
1620    /// Empty input must return an empty set without hitting the DB.
1621    #[tokio::test]
1622    async fn batch_exists_empty_ids_returns_empty_set() {
1623        let pool = make_vec_pool();
1624        let model_key = "empty_test_model";
1625        create_vec_table(&pool, model_key, 4);
1626
1627        let store = SqliteVecStore::new(
1628            pool,
1629            false,
1630            model_key.to_string(),
1631            model_key.to_string(),
1632            4,
1633            "ns:test".to_string(),
1634        )
1635        .expect("SqliteVecStore::new");
1636
1637        let exists: HashSet<Uuid> = store
1638            .batch_exists(&[], "ns:test")
1639            .await
1640            .expect("batch_exists");
1641        assert!(exists.is_empty());
1642    }
1643
1644    /// A nearer vector in namespace A must not starve the top-k result in namespace B.
1645    ///
1646    /// Regression for the cross-namespace recall starvation path: sqlite-vec must
1647    /// evaluate the namespace predicate before computing global top-k, not after.
1648    #[tokio::test]
1649    async fn vector_search_namespace_predicate_prevents_recall_starvation() {
1650        let pool = make_vec_pool();
1651        let model_key = "knn_namespace_scope";
1652        let dims = 4;
1653        create_vec_table(&pool, model_key, dims);
1654
1655        let store = SqliteVecStore::new(
1656            pool,
1657            false,
1658            model_key.to_string(),
1659            model_key.to_string(),
1660            dims,
1661            "ns:b".to_string(),
1662        )
1663        .expect("SqliteVecStore::new");
1664
1665        let distractor_a = Uuid::new_v4();
1666        let victim_b = Uuid::new_v4();
1667
1668        // Insert a nearer vector in namespace A (distractor).
1669        store
1670            .insert(
1671                distractor_a,
1672                SubstrateKind::Entity,
1673                "ns:a",
1674                "body",
1675                vec![vec![1.0, 0.0, 0.0, 0.0]],
1676            )
1677            .await
1678            .expect("insert nearer cross-namespace vector");
1679
1680        // Insert a slightly farther vector in namespace B (victim).
1681        store
1682            .insert(
1683                victim_b,
1684                SubstrateKind::Entity,
1685                "ns:b",
1686                "body",
1687                vec![vec![0.8, 0.2, 0.0, 0.0]],
1688            )
1689            .await
1690            .expect("insert in-namespace vector");
1691
1692        // top_k=1 search in ns:b must return victim_b, not the nearer distractor_a.
1693        let hits = store
1694            .search(VectorSearchRequest {
1695                query_vectors: vec![vec![1.0, 0.0, 0.0, 0.0]],
1696                top_k: 1,
1697                namespace: Some("ns:b".to_string()),
1698                kind: Some(SubstrateKind::Entity),
1699                embedding_model: None,
1700                filter: None,
1701                backend_hints: None,
1702            })
1703            .await
1704            .expect("search");
1705
1706        assert_eq!(
1707            hits.len(),
1708            1,
1709            "namespace B must not be starved by namespace A"
1710        );
1711        assert_eq!(
1712            hits[0].subject_id, victim_b,
1713            "top-1 in ns:b must be victim_b, not cross-namespace distractor_a"
1714        );
1715    }
1716
1717    /// Hyphenated model_key must be rejected at SqliteVecStore::new(), preventing
1718    /// any table-name divergence between the store and a hand-rolled sanitizer.
1719    #[test]
1720    fn hyphenated_model_key_is_rejected_at_construction() {
1721        use crate::pool::{ConnectionPool, PoolConfig};
1722        let pool = Arc::new(
1723            ConnectionPool::new(PoolConfig {
1724                path: None,
1725                ..PoolConfig::default()
1726            })
1727            .expect("pool"),
1728        );
1729
1730        let result = SqliteVecStore::new(
1731            pool,
1732            false,
1733            "all-minilm-l6-v2".to_string(),
1734            "all-minilm-l6-v2".to_string(),
1735            4,
1736            "ns:test".to_string(),
1737        );
1738
1739        assert!(
1740            result.is_err(),
1741            "hyphenated model_key 'all-minilm-l6-v2' must be rejected; \
1742             the store's table_name would differ from what a hand-rolled sanitizer produces"
1743        );
1744    }
1745}
1746
1747/// Tests for `first_error` surfacing in `insert_batch`.
1748///
1749/// These tests use only the pre-SAVEPOINT validation path (wrong vector count
1750/// or wrong dimensions) so they do not need the `vectors` feature; no vec0
1751/// virtual table is accessed.
1752#[cfg(test)]
1753mod first_error_tests {
1754    use super::*;
1755    use khive_storage::types::VectorRecord;
1756    use khive_storage::VectorStore;
1757    use khive_types::SubstrateKind;
1758    use uuid::Uuid;
1759
1760    fn make_pool() -> Arc<crate::pool::ConnectionPool> {
1761        use crate::pool::{ConnectionPool, PoolConfig};
1762        let config = PoolConfig {
1763            path: None,
1764            ..PoolConfig::default()
1765        };
1766        Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
1767    }
1768
1769    /// insert_batch must populate `first_error` when records fail the dimension
1770    /// validation check.
1771    ///
1772    /// Both records have the wrong number of dimensions, so both hit the
1773    /// `embedding.len() != dims` guard before any SAVEPOINT or vec0 operation.
1774    /// The outer transaction still commits (best-effort batch semantics).
1775    ///
1776    /// Regression: before the fix, `first_error` was always `String::new()` even
1777    /// when `failed > 0`.  This test is RED against the unfixed code and GREEN
1778    /// after the fix.
1779    #[tokio::test]
1780    async fn insert_batch_first_error_populated_on_dimension_mismatch() {
1781        let dims = 4usize;
1782        let store = SqliteVecStore::new(
1783            make_pool(),
1784            false,
1785            "first_err_vec".into(),
1786            "first_err_vec".into(),
1787            dims,
1788            "ns:test".into(),
1789        )
1790        .expect("SqliteVecStore::new");
1791
1792        // Both records have wrong dimensions, so they fail the pre-SAVEPOINT
1793        // validation and never touch the vec0 virtual table.
1794        let summary = store
1795            .insert_batch(vec![
1796                VectorRecord {
1797                    subject_id: Uuid::new_v4(),
1798                    kind: SubstrateKind::Entity,
1799                    namespace: "ns:test".to_string(),
1800                    field: "body".to_string(),
1801                    embedding_model: None,
1802                    vectors: vec![vec![0.0f32; dims + 1]],
1803                    updated_at: chrono::Utc::now(),
1804                },
1805                VectorRecord {
1806                    subject_id: Uuid::new_v4(),
1807                    kind: SubstrateKind::Entity,
1808                    namespace: "ns:test".to_string(),
1809                    field: "body".to_string(),
1810                    embedding_model: None,
1811                    vectors: vec![vec![0.0f32; dims + 2]],
1812                    updated_at: chrono::Utc::now(),
1813                },
1814            ])
1815            .await
1816            .expect("insert_batch must return Ok (best-effort semantics)");
1817
1818        assert_eq!(summary.attempted, 2);
1819        assert_eq!(
1820            summary.failed, 2,
1821            "both wrong-dims records must be counted as failed"
1822        );
1823        assert_eq!(summary.affected, 0);
1824        assert!(
1825            !summary.first_error.is_empty(),
1826            "first_error must be populated when failed > 0; \
1827             got empty string; the validation error is silently swallowed"
1828        );
1829    }
1830}
1831
1832#[cfg(test)]
1833mod capabilities_tests {
1834    use super::*;
1835
1836    fn make_pool() -> Arc<crate::pool::ConnectionPool> {
1837        use crate::pool::{ConnectionPool, PoolConfig};
1838        let config = PoolConfig {
1839            path: None,
1840            ..PoolConfig::default()
1841        };
1842        Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
1843    }
1844
1845    #[test]
1846    fn sqlite_vec_store_capabilities_are_correct() {
1847        let store = SqliteVecStore::new(
1848            make_pool(),
1849            /*is_file_backed=*/ false,
1850            "test_model".into(),
1851            "test_model".into(),
1852            /*dimensions=*/ 4,
1853            "ns:test".into(),
1854        )
1855        .expect("SqliteVecStore::new");
1856
1857        let caps = store.capabilities();
1858
1859        assert!(
1860            !caps.supports_filter,
1861            "sqlite-vec does not support filter pushdown"
1862        );
1863        assert!(
1864            !caps.supports_batch_search,
1865            "sqlite-vec does not support native batch search"
1866        );
1867        assert!(
1868            !caps.supports_quantization,
1869            "sqlite-vec does not support quantization"
1870        );
1871        assert!(
1872            !caps.supports_update,
1873            "sqlite-vec does not support in-place update"
1874        );
1875        assert!(
1876            caps.supports_orphan_sweep,
1877            "SqliteVecStore must advertise supports_orphan_sweep = true"
1878        );
1879        // sqlite-vec 0.1.9: SQLITE_VEC_VEC0_MAX_DIMENSIONS = 8192.
1880        assert_eq!(caps.max_dimensions, Some(8192));
1881        assert_eq!(
1882            caps.index_kinds,
1883            vec![VectorIndexKind::SqliteVec],
1884            "index_kinds should be [SqliteVec]"
1885        );
1886    }
1887
1888    /// Regression: max_dimensions must equal the sqlite-vec hard limit (8192),
1889    /// not the K_MAX constant (4096). A caller with 5000-dim embeddings must not
1890    /// be falsely told the backend is incapable.
1891    #[test]
1892    fn max_dimensions_reflects_sqlite_vec_hard_limit_not_k_max() {
1893        let store = SqliteVecStore::new(
1894            make_pool(),
1895            false,
1896            "test_dim_limit".into(),
1897            "test_dim_limit".into(),
1898            /*dimensions=*/ 4,
1899            "ns:test".into(),
1900        )
1901        .expect("SqliteVecStore::new");
1902
1903        let caps = store.capabilities();
1904
1905        // SQLITE_VEC_VEC0_MAX_DIMENSIONS = 8192 (sqlite-vec.c:3488).
1906        // The previous incorrect value 4096 was SQLITE_VEC_VEC0_K_MAX (max neighbours),
1907        // which would falsely reject 4097–8192 dimensional models.
1908        let max = caps
1909            .max_dimensions
1910            .expect("SqliteVecStore must declare a finite dimension limit");
1911        assert!(
1912            max >= 8192,
1913            "max_dimensions ({max}) must be at least 8192 — the sqlite-vec hard limit"
1914        );
1915    }
1916
1917    /// Capabilities struct is returned by &'static reference; calling twice must
1918    /// return the same value (OnceLock semantics, no allocation on repeat calls).
1919    #[test]
1920    fn capabilities_is_idempotent() {
1921        let store = SqliteVecStore::new(
1922            make_pool(),
1923            false,
1924            "test_idempotent".into(),
1925            "test_idempotent".into(),
1926            4,
1927            "ns:test".into(),
1928        )
1929        .expect("SqliteVecStore::new");
1930
1931        let caps1 = store.capabilities();
1932        let caps2 = store.capabilities();
1933        assert_eq!(
1934            caps1 as *const _, caps2 as *const _,
1935            "capabilities() must return the same static reference each call"
1936        );
1937    }
1938}
1939
1940#[cfg(all(test, feature = "vectors"))]
1941mod atomic_replace_tests {
1942    use std::sync::Arc;
1943
1944    use khive_storage::types::VectorRecord;
1945    use khive_storage::VectorStore;
1946    use khive_types::SubstrateKind;
1947    use uuid::Uuid;
1948
1949    use super::*;
1950
1951    fn make_vec_pool() -> Arc<crate::pool::ConnectionPool> {
1952        use crate::pool::{ConnectionPool, PoolConfig};
1953        crate::extension::ensure_extensions_loaded();
1954        let config = PoolConfig {
1955            path: None,
1956            ..PoolConfig::default()
1957        };
1958        Arc::new(ConnectionPool::new(config).expect("in-memory pool"))
1959    }
1960
1961    fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
1962        let writer = pool.try_writer().expect("pool writer");
1963        let ddl = format!(
1964            "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
1965             subject_id TEXT PRIMARY KEY, \
1966             namespace TEXT NOT NULL, \
1967             kind TEXT NOT NULL, \
1968             field TEXT NOT NULL, \
1969             embedding_model TEXT NOT NULL, \
1970             embedding float[{}] distance_metric=cosine)",
1971            model_key, dims
1972        );
1973        writer.conn().execute_batch(&ddl).expect("create vec table");
1974        writer
1975            .conn()
1976            .execute_batch(crate::migrations::ANN_WRITE_LOG_DDL)
1977            .expect("create ann_write_log");
1978    }
1979
1980    /// insert_batch: a record with wrong dimensions fails its INSERT but must not
1981    /// lose the previously stored vector (no-worse-than-stale guarantee for batch).
1982    ///
1983    /// Setup: insert a good vector for `id_existing` via the single-record path.
1984    /// Then call insert_batch with two records: `id_existing` with wrong dimensions
1985    /// (forced failure), and `id_new` with correct dimensions.
1986    /// Expected: `id_existing`'s old vector survives; `id_new` is inserted;
1987    /// BatchWriteSummary reflects 1 affected / 1 failed.
1988    #[tokio::test]
1989    async fn insert_batch_failed_record_preserves_prior_vector() {
1990        let pool = make_vec_pool();
1991        let model_key = "atomic_batch_test";
1992        let dims = 4;
1993        let ns = "ns:atomic";
1994
1995        create_vec_table(&pool, model_key, dims);
1996
1997        let store = SqliteVecStore::new(
1998            Arc::clone(&pool),
1999            false,
2000            model_key.to_string(),
2001            model_key.to_string(),
2002            dims,
2003            ns.to_string(),
2004        )
2005        .expect("SqliteVecStore::new");
2006
2007        let id_existing = Uuid::new_v4();
2008        let id_new = Uuid::new_v4();
2009        let original_vec = vec![0.1f32, 0.2, 0.3, 0.4];
2010
2011        store
2012            .insert(
2013                id_existing,
2014                SubstrateKind::Entity,
2015                ns,
2016                "body",
2017                vec![original_vec.clone()],
2018            )
2019            .await
2020            .expect("initial insert");
2021
2022        let summary = store
2023            .insert_batch(vec![
2024                VectorRecord {
2025                    subject_id: id_existing,
2026                    kind: SubstrateKind::Entity,
2027                    namespace: ns.to_string(),
2028                    field: "body".to_string(),
2029                    embedding_model: None,
2030                    vectors: vec![vec![9.9f32; dims + 1]],
2031                    updated_at: chrono::Utc::now(),
2032                },
2033                VectorRecord {
2034                    subject_id: id_new,
2035                    kind: SubstrateKind::Entity,
2036                    namespace: ns.to_string(),
2037                    field: "body".to_string(),
2038                    embedding_model: None,
2039                    vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
2040                    updated_at: chrono::Utc::now(),
2041                },
2042            ])
2043            .await
2044            .expect("insert_batch");
2045
2046        assert_eq!(summary.attempted, 2);
2047        assert_eq!(summary.affected, 1, "only id_new should succeed");
2048        assert_eq!(summary.failed, 1, "id_existing with wrong dims must fail");
2049
2050        let existing_still_present = store
2051            .batch_exists(&[id_existing], ns)
2052            .await
2053            .expect("batch_exists");
2054        assert!(
2055            existing_still_present.contains(&id_existing),
2056            "prior vector for id_existing must survive a failed batch replace"
2057        );
2058
2059        let new_present = store
2060            .batch_exists(&[id_new], ns)
2061            .await
2062            .expect("batch_exists for id_new");
2063        assert!(
2064            new_present.contains(&id_new),
2065            "id_new with valid dims must be inserted"
2066        );
2067    }
2068
2069    /// update: a vector with wrong dimensions must fail without deleting the prior
2070    /// vector (no-worse-than-stale guarantee for the update override).
2071    #[tokio::test]
2072    async fn update_failed_preserves_prior_vector() {
2073        let pool = make_vec_pool();
2074        let model_key = "atomic_update_test";
2075        let dims = 4;
2076        let ns = "ns:atomic_upd";
2077
2078        create_vec_table(&pool, model_key, dims);
2079
2080        let store = SqliteVecStore::new(
2081            Arc::clone(&pool),
2082            false,
2083            model_key.to_string(),
2084            model_key.to_string(),
2085            dims,
2086            ns.to_string(),
2087        )
2088        .expect("SqliteVecStore::new");
2089
2090        let id = Uuid::new_v4();
2091
2092        store
2093            .insert(
2094                id,
2095                SubstrateKind::Entity,
2096                ns,
2097                "body",
2098                vec![vec![0.1f32, 0.2, 0.3, 0.4]],
2099            )
2100            .await
2101            .expect("initial insert");
2102
2103        let result = store
2104            .update(
2105                id,
2106                SubstrateKind::Entity,
2107                ns,
2108                "body",
2109                vec![vec![9.9f32; dims + 1]],
2110            )
2111            .await;
2112
2113        assert!(result.is_err(), "update with wrong dims must fail");
2114
2115        let still_present = store
2116            .batch_exists(&[id], ns)
2117            .await
2118            .expect("batch_exists after failed update");
2119        assert!(
2120            still_present.contains(&id),
2121            "prior vector must survive a failed update"
2122        );
2123    }
2124
2125    /// insert_batch: SAVEPOINT/ROLLBACK path — INSERT failure inside the
2126    /// savepoint via a PK conflict. See
2127    /// crates/khive-db/docs/api/vectors.md#insert_batch_savepoint_rollback_on_pk_conflict_preserves_stale
2128    #[tokio::test]
2129    async fn insert_batch_savepoint_rollback_on_pk_conflict_preserves_stale() {
2130        let pool = make_vec_pool();
2131        let model_key = "atomic_pk_batch";
2132        let dims = 4;
2133        let ns_a = "ns:pk_a";
2134        let ns_b = "ns:pk_b";
2135
2136        create_vec_table(&pool, model_key, dims);
2137
2138        let store = SqliteVecStore::new(
2139            Arc::clone(&pool),
2140            false,
2141            model_key.to_string(),
2142            model_key.to_string(),
2143            dims,
2144            ns_a.to_string(),
2145        )
2146        .expect("SqliteVecStore::new");
2147
2148        let id_x = Uuid::new_v4();
2149        let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
2150
2151        // Store stale row in ns:a — this occupies id_X in the vec0 PK.
2152        store
2153            .insert(
2154                id_x,
2155                SubstrateKind::Entity,
2156                ns_a,
2157                "body",
2158                vec![stale_vec.clone()],
2159            )
2160            .await
2161            .expect("stale insert");
2162
2163        // Batch: one record for (id_X, ns:b) — correct dims, all finite.
2164        // DELETE WHERE ns=ns:b finds nothing.  INSERT hits PK constraint.
2165        // Code path: SAVEPOINT → DELETE(noop) → INSERT(PK fail) →
2166        //            ROLLBACK TO SAVEPOINT → RELEASE → outer COMMIT.
2167        let summary = store
2168            .insert_batch(vec![VectorRecord {
2169                subject_id: id_x,
2170                kind: SubstrateKind::Entity,
2171                namespace: ns_b.to_string(),
2172                field: "body".to_string(),
2173                embedding_model: None,
2174                vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
2175                updated_at: chrono::Utc::now(),
2176            }])
2177            .await
2178            .expect("insert_batch must complete (outer tx must commit)");
2179
2180        assert_eq!(summary.attempted, 1);
2181        assert_eq!(summary.affected, 0, "PK conflict must count as failed");
2182        assert_eq!(
2183            summary.failed, 1,
2184            "failed counter must increment after ROLLBACK TO SAVEPOINT"
2185        );
2186
2187        // Stale row must survive — no partial state must have leaked.
2188        let post = store
2189            .batch_exists(&[id_x], ns_a)
2190            .await
2191            .expect("batch_exists ns:a");
2192        assert!(
2193            post.contains(&id_x),
2194            "stale row in ns:a must survive after SAVEPOINT + INSERT failure"
2195        );
2196
2197        // Verify embedding bytes via self-similarity — any shadow-table corruption
2198        // would produce a score below 1.0.
2199        let hits = store
2200            .search(VectorSearchRequest {
2201                query_vectors: vec![stale_vec.clone()],
2202                top_k: 1,
2203                namespace: Some(ns_a.to_string()),
2204                kind: Some(SubstrateKind::Entity),
2205                embedding_model: None,
2206                filter: None,
2207                backend_hints: None,
2208            })
2209            .await
2210            .expect("search ns:a after batch");
2211
2212        assert_eq!(hits.len(), 1, "stale vector must be searchable");
2213        assert_eq!(hits[0].subject_id, id_x);
2214        let sim = hits[0].score.to_f64();
2215        assert!(
2216            sim > 0.999,
2217            "cosine similarity of stale_vec to itself must be ~1.0 (got {sim:.6}); \
2218             a lower value means the SAVEPOINT/ROLLBACK left partial writes visible"
2219        );
2220    }
2221
2222    /// insert_batch: a rolled-back first record must not corrupt a
2223    /// successful second record. See
2224    /// crates/khive-db/docs/api/vectors.md#insert_batch_rollback_does_not_corrupt_subsequent_record
2225    #[tokio::test]
2226    async fn insert_batch_rollback_does_not_corrupt_subsequent_record() {
2227        let pool = make_vec_pool();
2228        let model_key = "atomic_sib_batch";
2229        let dims = 4;
2230        let ns_a = "ns:sib_a";
2231        let ns_b = "ns:sib_b";
2232
2233        create_vec_table(&pool, model_key, dims);
2234
2235        let store = SqliteVecStore::new(
2236            Arc::clone(&pool),
2237            false,
2238            model_key.to_string(),
2239            model_key.to_string(),
2240            dims,
2241            ns_a.to_string(),
2242        )
2243        .expect("SqliteVecStore::new");
2244
2245        let id_x = Uuid::new_v4();
2246        let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
2247        let new_vec = vec![0.9f32, 0.1, 0.1, 0.1];
2248
2249        // Stale row occupies id_X in ns:a.
2250        store
2251            .insert(
2252                id_x,
2253                SubstrateKind::Entity,
2254                ns_a,
2255                "body",
2256                vec![stale_vec.clone()],
2257            )
2258            .await
2259            .expect("stale insert");
2260
2261        // Record A (ns:b) fails — PK conflict; Record B (ns:a) succeeds — replaces stale.
2262        let summary = store
2263            .insert_batch(vec![
2264                VectorRecord {
2265                    subject_id: id_x,
2266                    kind: SubstrateKind::Entity,
2267                    namespace: ns_b.to_string(),
2268                    field: "body".to_string(),
2269                    embedding_model: None,
2270                    vectors: vec![vec![0.5f32, 0.6, 0.7, 0.8]],
2271                    updated_at: chrono::Utc::now(),
2272                },
2273                VectorRecord {
2274                    subject_id: id_x,
2275                    kind: SubstrateKind::Entity,
2276                    namespace: ns_a.to_string(),
2277                    field: "body".to_string(),
2278                    embedding_model: None,
2279                    vectors: vec![new_vec.clone()],
2280                    updated_at: chrono::Utc::now(),
2281                },
2282            ])
2283            .await
2284            .expect("insert_batch");
2285
2286        assert_eq!(summary.attempted, 2);
2287        // Record A (ns:b) hits the PK constraint → failed.
2288        // Record B (ns:a) DELETEs the stale (freeing PK) then INSERTs → affected.
2289        assert_eq!(summary.affected, 1, "Record B must succeed");
2290        assert_eq!(summary.failed, 1, "Record A must fail (PK conflict)");
2291
2292        // Record B's new_vec must be in the DB with correct embedding bytes.
2293        let hits = store
2294            .search(VectorSearchRequest {
2295                query_vectors: vec![new_vec.clone()],
2296                top_k: 1,
2297                namespace: Some(ns_a.to_string()),
2298                kind: Some(SubstrateKind::Entity),
2299                embedding_model: None,
2300                filter: None,
2301                backend_hints: None,
2302            })
2303            .await
2304            .expect("search after batch");
2305
2306        assert_eq!(hits.len(), 1);
2307        assert_eq!(hits[0].subject_id, id_x);
2308        let sim = hits[0].score.to_f64();
2309        assert!(
2310            sim > 0.999,
2311            "new_vec similarity to itself must be ~1.0 (got {sim:.6}); \
2312             Record A's ROLLBACK must not corrupt Record B's write"
2313        );
2314    }
2315
2316    /// update: PK-conflict INSERT inside `unchecked_transaction` rolls back
2317    /// and preserves the stale row. See
2318    /// crates/khive-db/docs/api/vectors.md#update_pk_conflict_rolls_back_transaction_preserves_stale
2319    #[tokio::test]
2320    async fn update_pk_conflict_rolls_back_transaction_preserves_stale() {
2321        let pool = make_vec_pool();
2322        let model_key = "atomic_upd_pk";
2323        let dims = 4;
2324        let ns_a = "ns:upk_a";
2325        let ns_b = "ns:upk_b";
2326
2327        create_vec_table(&pool, model_key, dims);
2328
2329        let store = SqliteVecStore::new(
2330            Arc::clone(&pool),
2331            false,
2332            model_key.to_string(),
2333            model_key.to_string(),
2334            dims,
2335            ns_a.to_string(),
2336        )
2337        .expect("store");
2338
2339        let id_x = Uuid::new_v4();
2340        let stale_vec = vec![0.1f32, 0.2, 0.3, 0.4];
2341
2342        // Store stale row in ns:a.
2343        store
2344            .insert(
2345                id_x,
2346                SubstrateKind::Entity,
2347                ns_a,
2348                "body",
2349                vec![stale_vec.clone()],
2350            )
2351            .await
2352            .expect("stale insert");
2353
2354        // update() with ns:b — correct dims, finite values, but different namespace.
2355        // DELETE WHERE ns=ns:b finds nothing; INSERT id_X hits PK → transaction rolls back.
2356        let result = store
2357            .update(
2358                id_x,
2359                SubstrateKind::Entity,
2360                ns_b,
2361                "body",
2362                vec![vec![0.5f32, 0.6, 0.7, 0.8]],
2363            )
2364            .await;
2365
2366        assert!(
2367            result.is_err(),
2368            "update must fail when INSERT hits the vec0 PK constraint"
2369        );
2370
2371        // Stale row in ns:a must be intact.
2372        let post = store
2373            .batch_exists(&[id_x], ns_a)
2374            .await
2375            .expect("batch_exists after failed update");
2376        assert!(
2377            post.contains(&id_x),
2378            "stale row in ns:a must survive after update transaction rollback"
2379        );
2380
2381        // Self-similarity check proves the embedding bytes are unchanged.
2382        let hits = store
2383            .search(VectorSearchRequest {
2384                query_vectors: vec![stale_vec.clone()],
2385                top_k: 1,
2386                namespace: Some(ns_a.to_string()),
2387                kind: Some(SubstrateKind::Entity),
2388                embedding_model: None,
2389                filter: None,
2390                backend_hints: None,
2391            })
2392            .await
2393            .expect("search after failed update");
2394
2395        assert_eq!(hits.len(), 1, "stale vector must be searchable");
2396        assert_eq!(hits[0].subject_id, id_x);
2397        let sim = hits[0].score.to_f64();
2398        assert!(
2399            sim > 0.999,
2400            "cosine similarity of stale_vec to itself must be ~1.0 (got {sim:.6}); \
2401             transaction rollback must leave embedding bytes unchanged"
2402        );
2403    }
2404
2405    // True ROLLBACK TO SAVEPOINT sentinels (failpoint-driven) — see
2406    // crates/khive-db/docs/api/vectors.md#true-rollback-to-savepoint-sentinels-failpoint-driven
2407
2408    /// SENTINEL — insert_batch: stale row is restored when DELETE succeeds
2409    /// but INSERT is forced to fail via the cfg(test) failpoint. See
2410    /// crates/khive-db/docs/api/vectors.md#insert_batch_rollback_restores_deleted_stale_after_post_delete_insert_failure
2411    #[tokio::test]
2412    async fn insert_batch_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
2413        let pool = make_vec_pool();
2414        let model_key = "sentinel_batch_rb";
2415        let dims = 4;
2416        let ns = "ns:sentinel_batch";
2417
2418        create_vec_table(&pool, model_key, dims);
2419
2420        let store = SqliteVecStore::new(
2421            Arc::clone(&pool),
2422            false,
2423            model_key.to_string(),
2424            model_key.to_string(),
2425            dims,
2426            ns.to_string(),
2427        )
2428        .expect("SqliteVecStore::new");
2429
2430        let id_x = Uuid::new_v4();
2431        let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
2432        let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
2433
2434        // Insert the stale row that must survive.
2435        store
2436            .insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
2437            .await
2438            .expect("stale insert");
2439
2440        // Arm the failpoint under an RAII guard so it always clears on exit.
2441        // The guard is dropped AFTER the batch call returns, but `take()` is
2442        // one-shot — it clears the flag the moment the failpoint fires.
2443        let _guard = failpoint::FailpointGuard::new();
2444
2445        // Same namespace, correct dims, finite — DELETE will run, then failpoint fires.
2446        let summary = store
2447            .insert_batch(vec![VectorRecord {
2448                subject_id: id_x,
2449                kind: SubstrateKind::Entity,
2450                namespace: ns.to_string(),
2451                field: "body".to_string(),
2452                embedding_model: None,
2453                vectors: vec![vec2.clone()],
2454                updated_at: chrono::Utc::now(),
2455            }])
2456            .await
2457            .expect("insert_batch must complete (outer tx must commit regardless)");
2458
2459        drop(_guard); // explicit drop for clarity; flag already cleared by take()
2460
2461        assert_eq!(summary.attempted, 1);
2462        assert_eq!(
2463            summary.affected, 0,
2464            "failpoint must prevent INSERT from succeeding"
2465        );
2466        assert_eq!(
2467            summary.failed, 1,
2468            "failed counter must increment after injected failure"
2469        );
2470
2471        // ROLLBACK TO SAVEPOINT must have restored the deleted stale row.
2472        let present = store
2473            .batch_exists(&[id_x], ns)
2474            .await
2475            .expect("batch_exists after failpoint");
2476        assert!(
2477            present.contains(&id_x),
2478            "ROLLBACK TO SAVEPOINT must restore the stale row after DELETE + injected failure"
2479        );
2480
2481        // Self-similarity with vec1 (not vec2) confirms the original bytes are restored.
2482        let hits = store
2483            .search(VectorSearchRequest {
2484                query_vectors: vec![vec1.clone()],
2485                top_k: 1,
2486                namespace: Some(ns.to_string()),
2487                kind: Some(SubstrateKind::Entity),
2488                embedding_model: None,
2489                filter: None,
2490                backend_hints: None,
2491            })
2492            .await
2493            .expect("search after failpoint");
2494
2495        assert_eq!(
2496            hits.len(),
2497            1,
2498            "stale vector must be searchable after rollback"
2499        );
2500        assert_eq!(hits[0].subject_id, id_x);
2501        let sim = hits[0].score.to_f64();
2502        assert!(
2503            sim > 0.999,
2504            "similarity to vec1 must be ~1.0 (got {sim:.6}); \
2505             a lower value means the stale embedding was not restored — ROLLBACK TO SAVEPOINT failed"
2506        );
2507
2508        // Cross-check: vec2 must NOT be the stored embedding.
2509        let hits2 = store
2510            .search(VectorSearchRequest {
2511                query_vectors: vec![vec2.clone()],
2512                top_k: 1,
2513                namespace: Some(ns.to_string()),
2514                kind: Some(SubstrateKind::Entity),
2515                embedding_model: None,
2516                filter: None,
2517                backend_hints: None,
2518            })
2519            .await
2520            .expect("search vec2 after failpoint");
2521        let sim2 = hits2.first().map(|h| h.score.to_f64()).unwrap_or(0.0);
2522        assert!(
2523            sim2 < 0.99,
2524            "similarity to vec2 must be < 0.99 (got {sim2:.6}); \
2525             vec2 must not be the stored embedding after a rolled-back INSERT"
2526        );
2527    }
2528
2529    /// SENTINEL — update: stale row is restored when DELETE succeeds but
2530    /// INSERT is forced to fail via the cfg(test) failpoint. See
2531    /// crates/khive-db/docs/api/vectors.md#update_rollback_restores_deleted_stale_after_post_delete_insert_failure
2532    #[tokio::test]
2533    async fn update_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
2534        let pool = make_vec_pool();
2535        let model_key = "sentinel_upd_rb";
2536        let dims = 4;
2537        let ns = "ns:sentinel_upd";
2538
2539        create_vec_table(&pool, model_key, dims);
2540
2541        let store = SqliteVecStore::new(
2542            Arc::clone(&pool),
2543            false,
2544            model_key.to_string(),
2545            model_key.to_string(),
2546            dims,
2547            ns.to_string(),
2548        )
2549        .expect("SqliteVecStore::new");
2550
2551        let id_x = Uuid::new_v4();
2552        let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
2553        let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
2554
2555        // Insert the stale row that must survive.
2556        store
2557            .insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
2558            .await
2559            .expect("stale insert");
2560
2561        // Arm the failpoint under a RAII guard.
2562        let _guard = failpoint::FailpointGuard::new();
2563
2564        // Same namespace, correct dims, finite — DELETE will run, then failpoint fires.
2565        let result = store
2566            .update(id_x, SubstrateKind::Entity, ns, "body", vec![vec2.clone()])
2567            .await;
2568
2569        drop(_guard);
2570
2571        assert!(
2572            result.is_err(),
2573            "update must propagate the injected error back to the caller"
2574        );
2575
2576        // Transaction rollback must have restored the deleted stale row.
2577        let present = store
2578            .batch_exists(&[id_x], ns)
2579            .await
2580            .expect("batch_exists after failpoint");
2581        assert!(
2582            present.contains(&id_x),
2583            "transaction rollback must restore the stale row after DELETE + injected failure"
2584        );
2585
2586        // Self-similarity with vec1 confirms the original bytes are intact.
2587        let hits = store
2588            .search(VectorSearchRequest {
2589                query_vectors: vec![vec1.clone()],
2590                top_k: 1,
2591                namespace: Some(ns.to_string()),
2592                kind: Some(SubstrateKind::Entity),
2593                embedding_model: None,
2594                filter: None,
2595                backend_hints: None,
2596            })
2597            .await
2598            .expect("search after failpoint");
2599
2600        assert_eq!(
2601            hits.len(),
2602            1,
2603            "stale vector must be searchable after rollback"
2604        );
2605        assert_eq!(hits[0].subject_id, id_x);
2606        let sim = hits[0].score.to_f64();
2607        assert!(
2608            sim > 0.999,
2609            "similarity to vec1 must be ~1.0 (got {sim:.6}); \
2610             a lower value means the stale embedding was not restored — transaction rollback failed"
2611        );
2612    }
2613
2614    /// #546: `insert` now routes through the shared `replace_vector_row_dml`
2615    /// helper, so the same post-delete-failpoint rollback guarantee that
2616    /// covers `update` must also cover `insert`. See
2617    /// crates/khive-db/docs/api/vectors.md#insert_rollback_restores_deleted_stale_after_post_delete_insert_failure
2618    #[tokio::test]
2619    async fn insert_rollback_restores_deleted_stale_after_post_delete_insert_failure() {
2620        let pool = make_vec_pool();
2621        let model_key = "sentinel_ins_rb";
2622        let dims = 4;
2623        let ns = "ns:sentinel_ins";
2624
2625        create_vec_table(&pool, model_key, dims);
2626
2627        let store = SqliteVecStore::new(
2628            Arc::clone(&pool),
2629            false,
2630            model_key.to_string(),
2631            model_key.to_string(),
2632            dims,
2633            ns.to_string(),
2634        )
2635        .expect("SqliteVecStore::new");
2636
2637        let id_x = Uuid::new_v4();
2638        let vec1 = vec![0.1f32, 0.2, 0.3, 0.4];
2639        let vec2 = vec![0.9f32, 0.0, 0.0, 0.0];
2640
2641        // Insert the stale row that must survive a second, failing `insert`
2642        // call for the same (subject_id, namespace) — `vec0` has no
2643        // INSERT OR REPLACE, so a second `insert` is itself a replace.
2644        store
2645            .insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec1.clone()])
2646            .await
2647            .expect("stale insert");
2648
2649        // Arm the failpoint under a RAII guard.
2650        let _guard = failpoint::FailpointGuard::new();
2651
2652        // Same namespace, correct dims, finite — DELETE will run, then failpoint fires.
2653        let result = store
2654            .insert(id_x, SubstrateKind::Entity, ns, "body", vec![vec2.clone()])
2655            .await;
2656
2657        drop(_guard);
2658
2659        assert!(
2660            result.is_err(),
2661            "insert must propagate the injected error back to the caller"
2662        );
2663
2664        // Transaction rollback must have restored the deleted stale row.
2665        let present = store
2666            .batch_exists(&[id_x], ns)
2667            .await
2668            .expect("batch_exists after failpoint");
2669        assert!(
2670            present.contains(&id_x),
2671            "transaction rollback must restore the stale row after DELETE + injected failure"
2672        );
2673
2674        // Self-similarity with vec1 confirms the original bytes are intact.
2675        let hits = store
2676            .search(VectorSearchRequest {
2677                query_vectors: vec![vec1.clone()],
2678                top_k: 1,
2679                namespace: Some(ns.to_string()),
2680                kind: Some(SubstrateKind::Entity),
2681                embedding_model: None,
2682                filter: None,
2683                backend_hints: None,
2684            })
2685            .await
2686            .expect("search after failpoint");
2687
2688        assert_eq!(
2689            hits.len(),
2690            1,
2691            "stale vector must be searchable after rollback"
2692        );
2693        assert_eq!(hits[0].subject_id, id_x);
2694        let sim = hits[0].score.to_f64();
2695        assert!(
2696            sim > 0.999,
2697            "similarity to vec1 must be ~1.0 (got {sim:.6}); \
2698             a lower value means the stale embedding was not restored — transaction rollback failed"
2699        );
2700    }
2701}
2702
2703// ---------------------------------------------------------------------------
2704// Orphan sweep tests
2705// ---------------------------------------------------------------------------
2706// Require the `vectors` feature because the sweep queries the vec0 virtual
2707// table, which only exists when the sqlite-vec extension is loaded.
2708// ---------------------------------------------------------------------------
2709#[cfg(all(test, feature = "vectors"))]
2710mod orphan_sweep_tests {
2711    use std::sync::Arc;
2712
2713    use khive_storage::types::{OrphanSweepConfig, OrphanSweepResult};
2714    use khive_storage::VectorStore;
2715    use khive_types::SubstrateKind;
2716    use uuid::Uuid;
2717
2718    use super::*;
2719
2720    // ── helpers ──────────────────────────────────────────────────────────────
2721
2722    fn make_pool() -> Arc<crate::pool::ConnectionPool> {
2723        use crate::pool::{ConnectionPool, PoolConfig};
2724        crate::extension::ensure_extensions_loaded();
2725        Arc::new(
2726            ConnectionPool::new(PoolConfig {
2727                path: None,
2728                ..PoolConfig::default()
2729            })
2730            .expect("in-memory pool"),
2731        )
2732    }
2733
2734    /// Create minimal substrate tables (id + deleted_at only — enough for the anti-join).
2735    fn create_substrate_tables(pool: &Arc<crate::pool::ConnectionPool>) {
2736        pool.try_writer()
2737            .expect("writer")
2738            .conn()
2739            .execute_batch(
2740                "CREATE TABLE IF NOT EXISTS entities \
2741                     (id TEXT PRIMARY KEY, deleted_at INTEGER); \
2742                 CREATE TABLE IF NOT EXISTS notes \
2743                     (id TEXT PRIMARY KEY, deleted_at INTEGER);",
2744            )
2745            .expect("create substrate tables");
2746    }
2747
2748    fn create_vec_table(pool: &Arc<crate::pool::ConnectionPool>, model_key: &str, dims: usize) {
2749        let ddl = format!(
2750            "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
2751             subject_id TEXT PRIMARY KEY, \
2752             namespace TEXT NOT NULL, \
2753             kind TEXT NOT NULL, \
2754             field TEXT NOT NULL, \
2755             embedding_model TEXT NOT NULL, \
2756             embedding float[{}] distance_metric=cosine)",
2757            model_key, dims
2758        );
2759        let writer = pool.try_writer().expect("writer");
2760        writer.conn().execute_batch(&ddl).expect("create vec table");
2761        writer
2762            .conn()
2763            .execute_batch(crate::migrations::ANN_WRITE_LOG_DDL)
2764            .expect("create ann_write_log");
2765    }
2766
2767    fn make_store(
2768        pool: Arc<crate::pool::ConnectionPool>,
2769        model_key: &str,
2770        dims: usize,
2771        ns: &str,
2772    ) -> SqliteVecStore {
2773        SqliteVecStore::new(
2774            pool,
2775            false,
2776            model_key.to_string(),
2777            model_key.to_string(),
2778            dims,
2779            ns.to_string(),
2780        )
2781        .expect("SqliteVecStore::new")
2782    }
2783
2784    /// Insert a substrate row into `entities`.  `deleted_at = None` → live; `Some(ts)` → soft-deleted.
2785    fn insert_entity(pool: &Arc<crate::pool::ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
2786        let id_str = id.to_string();
2787        pool.try_writer()
2788            .expect("writer")
2789            .conn()
2790            .execute(
2791                "INSERT INTO entities (id, deleted_at) VALUES (?1, ?2)",
2792                rusqlite::params![id_str, deleted_at],
2793            )
2794            .expect("insert entity");
2795    }
2796
2797    fn vec4(a: f32, b: f32, c: f32, d: f32) -> Vec<f32> {
2798        vec![a, b, c, d]
2799    }
2800
2801    fn sweep_all(max_delete: u32, dry_run: bool) -> OrphanSweepConfig {
2802        OrphanSweepConfig {
2803            subject_id_allowlist: None,
2804            namespaces: vec![],
2805            substrate_kinds: vec![],
2806            max_delete,
2807            dry_run,
2808        }
2809    }
2810
2811    // ── test 1: live subject → vector kept ───────────────────────────────────
2812
2813    #[tokio::test]
2814    async fn orphan_sweep_keeps_live_subject() {
2815        let pool = make_pool();
2816        create_substrate_tables(&pool);
2817        create_vec_table(&pool, "sw_live", 4);
2818        let store = make_store(Arc::clone(&pool), "sw_live", 4, "ns:sw");
2819        let ns = "ns:sw";
2820
2821        let id = Uuid::new_v4();
2822        insert_entity(&pool, id, None); // live
2823
2824        store
2825            .insert(
2826                id,
2827                SubstrateKind::Entity,
2828                ns,
2829                "body",
2830                vec![vec4(0.1, 0.2, 0.3, 0.4)],
2831            )
2832            .await
2833            .expect("insert vec");
2834
2835        let r: OrphanSweepResult = store
2836            .orphan_sweep(&sweep_all(100, false))
2837            .await
2838            .expect("sweep");
2839
2840        assert_eq!(r.scanned, 1, "one vec row exists");
2841        assert_eq!(r.would_delete, 0, "live subject is not an orphan");
2842        assert_eq!(r.deleted, 0);
2843        assert!(!r.max_delete_hit);
2844
2845        let present = store.batch_exists(&[id], ns).await.expect("exists");
2846        assert!(present.contains(&id), "live subject's vec must survive");
2847    }
2848
2849    // ── test 2: soft-deleted subject → vector swept ──────────────────────────
2850
2851    #[tokio::test]
2852    async fn orphan_sweep_sweeps_soft_deleted_subject() {
2853        let pool = make_pool();
2854        create_substrate_tables(&pool);
2855        create_vec_table(&pool, "sw_soft", 4);
2856        let store = make_store(Arc::clone(&pool), "sw_soft", 4, "ns:soft");
2857        let ns = "ns:soft";
2858
2859        let id = Uuid::new_v4();
2860        insert_entity(&pool, id, Some(1_000_000)); // soft-deleted
2861
2862        store
2863            .insert(
2864                id,
2865                SubstrateKind::Entity,
2866                ns,
2867                "body",
2868                vec![vec4(0.5, 0.5, 0.5, 0.5)],
2869            )
2870            .await
2871            .expect("insert vec");
2872
2873        let r = store
2874            .orphan_sweep(&sweep_all(100, false))
2875            .await
2876            .expect("sweep");
2877
2878        assert_eq!(r.scanned, 1);
2879        assert_eq!(r.would_delete, 1, "soft-deleted subject counts as orphan");
2880        assert_eq!(r.deleted, 1);
2881        assert!(!r.max_delete_hit);
2882
2883        let present = store.batch_exists(&[id], ns).await.expect("exists");
2884        assert!(
2885            !present.contains(&id),
2886            "soft-deleted subject's vec must be swept"
2887        );
2888    }
2889
2890    // ── test 3: absent subject → vector swept ────────────────────────────────
2891
2892    #[tokio::test]
2893    async fn orphan_sweep_sweeps_absent_subject() {
2894        let pool = make_pool();
2895        create_substrate_tables(&pool);
2896        create_vec_table(&pool, "sw_absent", 4);
2897        let store = make_store(Arc::clone(&pool), "sw_absent", 4, "ns:absent");
2898        let ns = "ns:absent";
2899
2900        let id = Uuid::new_v4(); // no substrate row at all
2901
2902        store
2903            .insert(
2904                id,
2905                SubstrateKind::Entity,
2906                ns,
2907                "body",
2908                vec![vec4(0.1, 0.2, 0.3, 0.4)],
2909            )
2910            .await
2911            .expect("insert vec");
2912
2913        let r = store
2914            .orphan_sweep(&sweep_all(100, false))
2915            .await
2916            .expect("sweep");
2917
2918        assert_eq!(r.scanned, 1);
2919        assert_eq!(r.would_delete, 1, "absent subject counts as orphan");
2920        assert_eq!(r.deleted, 1);
2921
2922        let present = store.batch_exists(&[id], ns).await.expect("exists");
2923        assert!(!present.contains(&id), "absent subject's vec must be swept");
2924    }
2925
2926    // ── test 4: dry_run → nothing deleted, would_delete populated ────────────
2927
2928    #[tokio::test]
2929    async fn orphan_sweep_dry_run_does_not_delete() {
2930        let pool = make_pool();
2931        create_substrate_tables(&pool);
2932        create_vec_table(&pool, "sw_dry", 4);
2933        let store = make_store(Arc::clone(&pool), "sw_dry", 4, "ns:dry");
2934        let ns = "ns:dry";
2935
2936        let id = Uuid::new_v4(); // absent subject → orphan
2937        store
2938            .insert(
2939                id,
2940                SubstrateKind::Entity,
2941                ns,
2942                "body",
2943                vec![vec4(0.1, 0.2, 0.3, 0.4)],
2944            )
2945            .await
2946            .expect("insert vec");
2947
2948        let r = store
2949            .orphan_sweep(&sweep_all(100, true))
2950            .await
2951            .expect("sweep");
2952
2953        assert_eq!(r.would_delete, 1, "dry-run must still count the orphan");
2954        assert_eq!(r.deleted, 0, "dry-run must not delete anything");
2955
2956        let present = store.batch_exists(&[id], ns).await.expect("exists");
2957        assert!(present.contains(&id), "dry-run must not remove the vec");
2958    }
2959
2960    // ── test 5: max_delete cap ────────────────────────────────────────────────
2961
2962    #[tokio::test]
2963    async fn orphan_sweep_max_delete_caps_deletion() {
2964        let pool = make_pool();
2965        create_substrate_tables(&pool);
2966        create_vec_table(&pool, "sw_cap", 4);
2967        let store = make_store(Arc::clone(&pool), "sw_cap", 4, "ns:cap");
2968        let ns = "ns:cap";
2969
2970        // Insert 5 orphaned vecs (no substrate rows).
2971        let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
2972        for (i, &id) in ids.iter().enumerate() {
2973            let v = i as f32 / 10.0;
2974            store
2975                .insert(
2976                    id,
2977                    SubstrateKind::Entity,
2978                    ns,
2979                    "body",
2980                    vec![vec![v, v + 0.1, v + 0.2, v + 0.3]],
2981                )
2982                .await
2983                .expect("insert vec");
2984        }
2985
2986        let r = store
2987            .orphan_sweep(&OrphanSweepConfig {
2988                subject_id_allowlist: None,
2989                namespaces: vec![],
2990                substrate_kinds: vec![],
2991                max_delete: 2,
2992                dry_run: false,
2993            })
2994            .await
2995            .expect("sweep");
2996
2997        assert_eq!(r.scanned, 5);
2998        assert_eq!(r.would_delete, 5);
2999        assert_eq!(r.deleted, 2, "cap must stop at max_delete");
3000        assert!(
3001            r.max_delete_hit,
3002            "max_delete_hit must be true when cap triggered"
3003        );
3004
3005        // Verify exactly 3 vecs survive.
3006        let mut surviving = 0usize;
3007        for &id in &ids {
3008            if store
3009                .batch_exists(&[id], ns)
3010                .await
3011                .expect("exists")
3012                .contains(&id)
3013            {
3014                surviving += 1;
3015            }
3016        }
3017        assert_eq!(surviving, 3, "3 orphans must survive after cap");
3018    }
3019
3020    // ── test 6: namespace filter ──────────────────────────────────────────────
3021
3022    #[tokio::test]
3023    async fn orphan_sweep_namespace_filter_scopes_sweep() {
3024        let pool = make_pool();
3025        create_substrate_tables(&pool);
3026        create_vec_table(&pool, "sw_ns", 4);
3027        let store = make_store(Arc::clone(&pool), "sw_ns", 4, "ns:a");
3028
3029        let id_a = Uuid::new_v4();
3030        let id_b = Uuid::new_v4();
3031
3032        store
3033            .insert(
3034                id_a,
3035                SubstrateKind::Entity,
3036                "ns:a",
3037                "body",
3038                vec![vec4(0.1, 0.2, 0.3, 0.4)],
3039            )
3040            .await
3041            .expect("insert ns:a");
3042        store
3043            .insert(
3044                id_b,
3045                SubstrateKind::Entity,
3046                "ns:b",
3047                "body",
3048                vec![vec4(0.5, 0.6, 0.7, 0.8)],
3049            )
3050            .await
3051            .expect("insert ns:b");
3052
3053        // Both are orphans (no substrate rows); sweep scoped to ns:a only.
3054        let r = store
3055            .orphan_sweep(&OrphanSweepConfig {
3056                subject_id_allowlist: None,
3057                namespaces: vec!["ns:a".to_string()],
3058                substrate_kinds: vec![],
3059                max_delete: 100,
3060                dry_run: false,
3061            })
3062            .await
3063            .expect("sweep");
3064
3065        assert_eq!(r.scanned, 1, "only ns:a row visible to scoped sweep");
3066        assert_eq!(r.deleted, 1);
3067
3068        let exists_a = store.batch_exists(&[id_a], "ns:a").await.expect("exists a");
3069        let exists_b = store.batch_exists(&[id_b], "ns:b").await.expect("exists b");
3070        assert!(!exists_a.contains(&id_a), "ns:a orphan must be swept");
3071        assert!(exists_b.contains(&id_b), "ns:b vec must be untouched");
3072    }
3073
3074    // ── test 7: substrate_kinds filter ───────────────────────────────────────
3075
3076    #[tokio::test]
3077    async fn orphan_sweep_substrate_kinds_filter_scopes_sweep() {
3078        let pool = make_pool();
3079        create_substrate_tables(&pool);
3080        create_vec_table(&pool, "sw_kind", 4);
3081        let store = make_store(Arc::clone(&pool), "sw_kind", 4, "ns:kind");
3082        let ns = "ns:kind";
3083
3084        let id_ent = Uuid::new_v4();
3085        let id_note = Uuid::new_v4();
3086
3087        // Both orphaned; one entity-kind vec, one note-kind vec.
3088        store
3089            .insert(
3090                id_ent,
3091                SubstrateKind::Entity,
3092                ns,
3093                "body",
3094                vec![vec4(0.1, 0.2, 0.3, 0.4)],
3095            )
3096            .await
3097            .expect("insert entity vec");
3098        store
3099            .insert(
3100                id_note,
3101                SubstrateKind::Note,
3102                ns,
3103                "body",
3104                vec![vec4(0.5, 0.6, 0.7, 0.8)],
3105            )
3106            .await
3107            .expect("insert note vec");
3108
3109        // Sweep only entity-kind vecs.
3110        let r = store
3111            .orphan_sweep(&OrphanSweepConfig {
3112                subject_id_allowlist: None,
3113                namespaces: vec![],
3114                substrate_kinds: vec![SubstrateKind::Entity],
3115                max_delete: 100,
3116                dry_run: false,
3117            })
3118            .await
3119            .expect("sweep");
3120
3121        assert_eq!(r.scanned, 1, "kind filter restricts scanned count");
3122        assert_eq!(r.deleted, 1, "only entity-kind orphan is swept");
3123
3124        let ent_exists = store.batch_exists(&[id_ent], ns).await.expect("ent exists");
3125        let note_exists = store
3126            .batch_exists(&[id_note], ns)
3127            .await
3128            .expect("note exists");
3129        assert!(
3130            !ent_exists.contains(&id_ent),
3131            "entity-kind orphan must be swept"
3132        );
3133        assert!(
3134            note_exists.contains(&id_note),
3135            "note-kind vec must be untouched"
3136        );
3137    }
3138
3139    // ── test 8: subject_id_allowlist filter ──────────────────────────────────
3140
3141    #[tokio::test]
3142    async fn orphan_sweep_allowlist_restricts_eligible_rows() {
3143        let pool = make_pool();
3144        create_substrate_tables(&pool);
3145        create_vec_table(&pool, "sw_allow", 4);
3146        let store = make_store(Arc::clone(&pool), "sw_allow", 4, "ns:allow");
3147        let ns = "ns:allow";
3148
3149        let id1 = Uuid::new_v4();
3150        let id2 = Uuid::new_v4();
3151        let id3 = Uuid::new_v4(); // not in allowlist
3152
3153        for (i, &id) in [id1, id2, id3].iter().enumerate() {
3154            let v = i as f32 * 0.1 + 0.1;
3155            store
3156                .insert(
3157                    id,
3158                    SubstrateKind::Entity,
3159                    ns,
3160                    "body",
3161                    vec![vec![v, v, v, v]],
3162                )
3163                .await
3164                .expect("insert vec");
3165        }
3166
3167        // All are orphans; allowlist only allows id1 and id2 to be swept.
3168        let r = store
3169            .orphan_sweep(&OrphanSweepConfig {
3170                subject_id_allowlist: Some(vec![id1, id2]),
3171                namespaces: vec![],
3172                substrate_kinds: vec![],
3173                max_delete: 100,
3174                dry_run: false,
3175            })
3176            .await
3177            .expect("sweep");
3178
3179        assert_eq!(r.scanned, 2, "allowlist restricts scanned to 2");
3180        assert_eq!(r.would_delete, 2);
3181        assert_eq!(r.deleted, 2, "both allowlisted orphans deleted");
3182
3183        let e1 = store.batch_exists(&[id1], ns).await.expect("e1");
3184        let e2 = store.batch_exists(&[id2], ns).await.expect("e2");
3185        let e3 = store.batch_exists(&[id3], ns).await.expect("e3");
3186        assert!(!e1.contains(&id1), "id1 must be swept");
3187        assert!(!e2.contains(&id2), "id2 must be swept");
3188        assert!(e3.contains(&id3), "id3 not in allowlist must survive");
3189    }
3190
3191    // ── helpers for note substrate rows ─────────────────────────────────────
3192
3193    fn insert_note(pool: &Arc<crate::pool::ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
3194        let id_str = id.to_string();
3195        pool.try_writer()
3196            .expect("writer")
3197            .conn()
3198            .execute(
3199                "INSERT INTO notes (id, deleted_at) VALUES (?1, ?2)",
3200                rusqlite::params![id_str, deleted_at],
3201            )
3202            .expect("insert note");
3203    }
3204
3205    // ── test 9: live note → vector kept ──────────────────────────────────────
3206
3207    #[tokio::test]
3208    async fn orphan_sweep_keeps_live_note() {
3209        let pool = make_pool();
3210        create_substrate_tables(&pool);
3211        create_vec_table(&pool, "sw_note_live", 4);
3212        let store = make_store(Arc::clone(&pool), "sw_note_live", 4, "ns:nlive");
3213        let ns = "ns:nlive";
3214
3215        let id = Uuid::new_v4();
3216        insert_note(&pool, id, None); // live note row
3217
3218        store
3219            .insert(
3220                id,
3221                SubstrateKind::Note,
3222                ns,
3223                "body",
3224                vec![vec4(0.1, 0.2, 0.3, 0.4)],
3225            )
3226            .await
3227            .expect("insert vec");
3228
3229        let r = store
3230            .orphan_sweep(&sweep_all(100, false))
3231            .await
3232            .expect("sweep");
3233
3234        assert_eq!(r.scanned, 1);
3235        assert_eq!(r.would_delete, 0, "live note is not an orphan");
3236        assert_eq!(r.deleted, 0);
3237
3238        let present = store.batch_exists(&[id], ns).await.expect("exists");
3239        assert!(present.contains(&id), "live note's vec must survive");
3240    }
3241
3242    // ── test 10: soft-deleted note → vector swept ─────────────────────────────
3243
3244    #[tokio::test]
3245    async fn orphan_sweep_sweeps_soft_deleted_note() {
3246        let pool = make_pool();
3247        create_substrate_tables(&pool);
3248        create_vec_table(&pool, "sw_note_soft", 4);
3249        let store = make_store(Arc::clone(&pool), "sw_note_soft", 4, "ns:nsoft");
3250        let ns = "ns:nsoft";
3251
3252        let id = Uuid::new_v4();
3253        insert_note(&pool, id, Some(1_000_000)); // soft-deleted note row
3254
3255        store
3256            .insert(
3257                id,
3258                SubstrateKind::Note,
3259                ns,
3260                "body",
3261                vec![vec4(0.5, 0.5, 0.5, 0.5)],
3262            )
3263            .await
3264            .expect("insert vec");
3265
3266        let r = store
3267            .orphan_sweep(&sweep_all(100, false))
3268            .await
3269            .expect("sweep");
3270
3271        assert_eq!(r.scanned, 1);
3272        assert_eq!(r.would_delete, 1, "soft-deleted note counts as orphan");
3273        assert_eq!(r.deleted, 1);
3274
3275        let present = store.batch_exists(&[id], ns).await.expect("exists");
3276        assert!(
3277            !present.contains(&id),
3278            "soft-deleted note's vec must be swept"
3279        );
3280    }
3281
3282    // ── test 11: mid-transaction error must NOT poison the pooled connection ──
3283    //
3284    // Regression for the transaction-leak bug: if orphan_sweep errors after
3285    // BEGIN IMMEDIATE but before COMMIT, the pooled writer must NOT be left
3286    // with an open transaction.  Without the RAII guard, the next writer
3287    // call fails with "cannot start a transaction within a transaction".
3288    //
3289    // Deterministic injection: we create the vec table but deliberately omit
3290    // the substrate tables.  The anti-join queries reference `entities` and
3291    // `notes`, so the first scan COUNT fails with "no such table: entities".
3292    // After the error, we immediately perform a normal vector insert on the
3293    // same store and assert it succeeds — proving the connection is clean.
3294
3295    #[tokio::test]
3296    async fn orphan_sweep_error_does_not_poison_connection() {
3297        let pool = make_pool();
3298        // Note: create_substrate_tables is intentionally NOT called here.
3299        create_vec_table(&pool, "sw_poison", 4);
3300        let store = make_store(Arc::clone(&pool), "sw_poison", 4, "ns:poison");
3301        let ns = "ns:poison";
3302
3303        // orphan_sweep must fail because `entities` / `notes` do not exist.
3304        let sweep_result = store.orphan_sweep(&sweep_all(100, false)).await;
3305        assert!(
3306            sweep_result.is_err(),
3307            "sweep must fail when substrate tables are absent"
3308        );
3309
3310        // The connection must not be poisoned: a normal vector insert must succeed.
3311        let id = Uuid::new_v4();
3312        store
3313            .insert(
3314                id,
3315                SubstrateKind::Entity,
3316                ns,
3317                "body",
3318                vec![vec4(0.1, 0.2, 0.3, 0.4)],
3319            )
3320            .await
3321            .expect("insert after failed sweep must succeed (connection not poisoned)");
3322
3323        let present = store.batch_exists(&[id], ns).await.expect("exists");
3324        assert!(
3325            present.contains(&id),
3326            "vector inserted after failed sweep must be present"
3327        );
3328    }
3329}
3330
3331/// ADR-067 Component A entry 7 / Amendment 1: `insert_batch` and
3332/// `orphan_sweep` are the `BEGIN IMMEDIATE`-issuing sites in this store that
3333/// route through the pool-wide `WriterTask` when the write queue is enabled
3334/// (`insert`/`update` route through `vec_upsert_atomic_dml`'s SAVEPOINT
3335/// instead — see the flag-on branches in the `VectorStore` impl above).
3336/// Needs the real `vec0` extension loaded, so it lives behind the same
3337/// `feature = "vectors"` gate as its sibling
3338/// `atomic_replace_tests`/`orphan_sweep_tests` modules — `cargo test
3339/// --workspace` (no `--all-features`) does not compile or run it, matching
3340/// the existing convention in this file.
3341#[cfg(all(test, feature = "vectors"))]
3342mod write_queue_tests {
3343    use std::sync::Arc;
3344    use std::time::Duration;
3345
3346    use khive_storage::types::VectorRecord;
3347    use khive_storage::VectorStore;
3348    use khive_types::SubstrateKind;
3349    use uuid::Uuid;
3350
3351    use super::*;
3352    use crate::pool::{ConnectionPool, PoolConfig};
3353
3354    fn create_vec_table(pool: &Arc<ConnectionPool>, model_key: &str, dims: usize) {
3355        let ddl = format!(
3356            "CREATE VIRTUAL TABLE IF NOT EXISTS vec_{} USING vec0(\
3357             subject_id TEXT PRIMARY KEY, \
3358             namespace TEXT NOT NULL, \
3359             kind TEXT NOT NULL, \
3360             field TEXT NOT NULL, \
3361             embedding_model TEXT NOT NULL, \
3362             embedding float[{}] distance_metric=cosine)",
3363            model_key, dims
3364        );
3365        let writer = pool.writer().expect("writer");
3366        writer.conn().execute_batch(&ddl).expect("create vec table");
3367        writer
3368            .conn()
3369            .execute_batch(crate::migrations::ANN_WRITE_LOG_DDL)
3370            .expect("create ann_write_log");
3371    }
3372
3373    /// Constructed via a `PoolConfig` literal (`write_queue_enabled: true`),
3374    /// not the `KHIVE_WRITE_QUEUE` env var — that env var is process-global
3375    /// and this crate's other tests are NOT `#[serial]` against it, so a
3376    /// window where it is set here could leak into a
3377    /// concurrently-scheduled test's own pool construction (ADR-067
3378    /// Component A). Builds the pool inline (rather than
3379    /// via `make_file_backed_pool`, which hardcodes `PoolConfig::default()`)
3380    /// so `write_queue_enabled` can be set directly in the literal.
3381    #[tokio::test]
3382    async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
3383        crate::extension::ensure_extensions_loaded();
3384
3385        let model_key = "write_queue_flag_test";
3386        let dims = 4usize;
3387        let dir = tempfile::tempdir().unwrap();
3388        let path = dir.path().join("write_queue_vectors.db");
3389        let pool = Arc::new(
3390            ConnectionPool::new(PoolConfig {
3391                path: Some(path),
3392                write_queue_enabled: true,
3393                ..PoolConfig::default()
3394            })
3395            .expect("file-backed pool"),
3396        );
3397        create_vec_table(&pool, model_key, dims);
3398
3399        let store = SqliteVecStore::new(
3400            Arc::clone(&pool),
3401            true,
3402            model_key.to_string(),
3403            model_key.to_string(),
3404            dims,
3405            "ns:test".to_string(),
3406        )
3407        .expect("SqliteVecStore::new");
3408
3409        let id1 = Uuid::new_v4();
3410        let id2 = Uuid::new_v4();
3411        let records = vec![
3412            VectorRecord {
3413                subject_id: id1,
3414                kind: SubstrateKind::Entity,
3415                namespace: "ns:test".to_string(),
3416                field: "body".to_string(),
3417                embedding_model: None,
3418                vectors: vec![vec![0.1, 0.2, 0.3, 0.4]],
3419                updated_at: chrono::Utc::now(),
3420            },
3421            VectorRecord {
3422                subject_id: id2,
3423                kind: SubstrateKind::Entity,
3424                namespace: "ns:test".to_string(),
3425                field: "body".to_string(),
3426                embedding_model: None,
3427                vectors: vec![vec![0.5, 0.6, 0.7, 0.8]],
3428                updated_at: chrono::Utc::now(),
3429            },
3430        ];
3431
3432        let summary = store.insert_batch(records).await.unwrap();
3433        assert_eq!(summary.attempted, 2);
3434        assert_eq!(summary.affected, 2);
3435        assert_eq!(summary.failed, 0);
3436
3437        let present = store
3438            .batch_exists(&[id1, id2], "ns:test")
3439            .await
3440            .expect("batch_exists");
3441        assert!(present.contains(&id1));
3442        assert!(present.contains(&id2));
3443        assert_eq!(
3444            pool.writer_task_spawn_count(),
3445            1,
3446            "the flag-ON path must actually spawn and use the writer task"
3447        );
3448    }
3449
3450    /// Create minimal substrate tables (id + deleted_at only — enough for the
3451    /// anti-join). Mirrors `orphan_sweep_tests::create_substrate_tables`;
3452    /// duplicated here (rather than shared) because that helper is private to
3453    /// its own sibling module — same convention as this module's own
3454    /// `create_vec_table` duplicate.
3455    fn create_substrate_tables(pool: &Arc<ConnectionPool>) {
3456        pool.try_writer()
3457            .expect("writer")
3458            .conn()
3459            .execute_batch(
3460                "CREATE TABLE IF NOT EXISTS entities \
3461                     (id TEXT PRIMARY KEY, deleted_at INTEGER); \
3462                 CREATE TABLE IF NOT EXISTS notes \
3463                     (id TEXT PRIMARY KEY, deleted_at INTEGER);",
3464            )
3465            .expect("create substrate tables");
3466    }
3467
3468    /// Insert a substrate row into `entities`. `deleted_at = None` → live.
3469    fn insert_entity(pool: &Arc<ConnectionPool>, id: Uuid, deleted_at: Option<i64>) {
3470        let id_str = id.to_string();
3471        pool.try_writer()
3472            .expect("writer")
3473            .conn()
3474            .execute(
3475                "INSERT INTO entities (id, deleted_at) VALUES (?1, ?2)",
3476                rusqlite::params![id_str, deleted_at],
3477            )
3478            .expect("insert entity");
3479    }
3480
3481    /// ADR-067 Amendment 1: `orphan_sweep`'s flag-on path must route through
3482    /// the pool-wide `WriterTask` (not `with_writer_unmanaged`'s pool-mutex
3483    /// path) when the write queue is enabled — mirrors
3484    /// `insert_batch_routes_through_writer_task_when_flag_enabled` above.
3485    #[tokio::test]
3486    async fn orphan_sweep_routes_through_writer_task_when_flag_enabled() {
3487        crate::extension::ensure_extensions_loaded();
3488
3489        let model_key = "write_queue_orphan_sweep";
3490        let dims = 4usize;
3491        let dir = tempfile::tempdir().unwrap();
3492        let path = dir.path().join("write_queue_orphan_sweep.db");
3493        let pool = Arc::new(
3494            ConnectionPool::new(PoolConfig {
3495                path: Some(path),
3496                write_queue_enabled: true,
3497                ..PoolConfig::default()
3498            })
3499            .expect("file-backed pool"),
3500        );
3501        create_substrate_tables(&pool);
3502        create_vec_table(&pool, model_key, dims);
3503
3504        let store = SqliteVecStore::new(
3505            Arc::clone(&pool),
3506            true,
3507            model_key.to_string(),
3508            model_key.to_string(),
3509            dims,
3510            "ns:test".to_string(),
3511        )
3512        .expect("SqliteVecStore::new");
3513
3514        let live_id = Uuid::new_v4();
3515        insert_entity(&pool, live_id, None); // live subject
3516        let orphan_id = Uuid::new_v4(); // no substrate row -> orphaned vector
3517
3518        store
3519            .insert(
3520                live_id,
3521                SubstrateKind::Entity,
3522                "ns:test",
3523                "body",
3524                vec![vec![0.1, 0.2, 0.3, 0.4]],
3525            )
3526            .await
3527            .expect("insert live vector");
3528        store
3529            .insert(
3530                orphan_id,
3531                SubstrateKind::Entity,
3532                "ns:test",
3533                "body",
3534                vec![vec![0.5, 0.6, 0.7, 0.8]],
3535            )
3536            .await
3537            .expect("insert orphan vector");
3538
3539        // Dry run: reports the orphan without deleting it.
3540        let dry = store
3541            .orphan_sweep(&OrphanSweepConfig {
3542                subject_id_allowlist: None,
3543                namespaces: vec![],
3544                substrate_kinds: vec![],
3545                max_delete: 100,
3546                dry_run: true,
3547            })
3548            .await
3549            .expect("dry-run sweep");
3550        assert_eq!(dry.scanned, 2);
3551        assert_eq!(dry.would_delete, 1);
3552        assert_eq!(dry.deleted, 0);
3553        assert!(!dry.max_delete_hit);
3554
3555        // Real sweep: deletes the orphan, keeps the live vector.
3556        let real = store
3557            .orphan_sweep(&OrphanSweepConfig {
3558                subject_id_allowlist: None,
3559                namespaces: vec![],
3560                substrate_kinds: vec![],
3561                max_delete: 100,
3562                dry_run: false,
3563            })
3564            .await
3565            .expect("real sweep");
3566        assert_eq!(real.scanned, 2);
3567        assert_eq!(real.would_delete, 1);
3568        assert_eq!(real.deleted, 1);
3569        assert!(!real.max_delete_hit);
3570
3571        let present = store
3572            .batch_exists(&[live_id, orphan_id], "ns:test")
3573            .await
3574            .expect("batch_exists");
3575        assert!(
3576            present.contains(&live_id),
3577            "live vector must survive the sweep"
3578        );
3579        assert!(
3580            !present.contains(&orphan_id),
3581            "orphaned vector must be swept"
3582        );
3583
3584        // `writer_task_spawn_count() == 1` alone does not discriminate the
3585        // fix from a regression: `SqliteVecStore::new` and the two setup
3586        // `store.insert(..)` calls above already spawn and use the writer
3587        // task, so that counter would read 1 even if `orphan_sweep` itself
3588        // had reverted to the legacy `with_writer_unmanaged` path. Prove
3589        // routing directly instead, mirroring
3590        // `upsert_entity_routes_through_writer_task_when_flag_enabled`
3591        // (entity_tests.rs): hold the writer task's single drain slot open
3592        // with an occupier parked on a oneshot (`blocking_recv`, valid
3593        // inside the writer task's `spawn_blocking`), then call
3594        // `orphan_sweep` on a separate task and poll
3595        // `WriterTaskHandle::queue_depth()`. A version that genuinely
3596        // routes through `writer_task.send(..)` must show the request
3597        // sitting in the channel (`queue_depth() >= 1`) while the occupier
3598        // holds the slot; a version that fell back to
3599        // `with_writer_unmanaged`'s pool-mutex path never touches this
3600        // channel, so `queue_depth()` would stay `0` for the whole poll
3601        // window — the failure mode this test exists to catch.
3602        let writer_task = pool
3603            .writer_task_handle()
3604            .expect("writer task handle")
3605            .expect("writer task must be spawned for a file-backed pool with the flag on");
3606
3607        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
3608        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
3609        let occupier = {
3610            let writer_task = writer_task.clone();
3611            tokio::spawn(async move {
3612                writer_task
3613                    .send(move |_conn| {
3614                        let _ = started_tx.send(());
3615                        let _ = release_rx.blocking_recv();
3616                        Ok::<(), StorageError>(())
3617                    })
3618                    .await
3619            })
3620        };
3621
3622        started_rx
3623            .await
3624            .expect("occupier must signal it has started running inside the writer task");
3625        assert_eq!(
3626            writer_task.queue_depth(),
3627            0,
3628            "channel must start empty once the occupier has been dequeued and is running"
3629        );
3630
3631        let sweep_task = tokio::spawn(async move {
3632            store
3633                .orphan_sweep(&OrphanSweepConfig {
3634                    subject_id_allowlist: None,
3635                    namespaces: vec![],
3636                    substrate_kinds: vec![],
3637                    max_delete: 100,
3638                    dry_run: true,
3639                })
3640                .await
3641        });
3642
3643        let mut saw_enqueued = false;
3644        for _ in 0..100 {
3645            if writer_task.queue_depth() >= 1 {
3646                saw_enqueued = true;
3647                break;
3648            }
3649            tokio::time::sleep(Duration::from_millis(5)).await;
3650        }
3651        assert!(
3652            saw_enqueued,
3653            "orphan_sweep's write request never appeared in the writer task's channel \
3654             while the occupier held the single drain slot — orphan_sweep is not routing \
3655             through the shared writer task"
3656        );
3657
3658        release_tx
3659            .send(())
3660            .expect("occupier must still be waiting on the release signal");
3661        occupier
3662            .await
3663            .expect("occupier task must not panic")
3664            .expect("occupier write must succeed");
3665        let post_sweep = sweep_task
3666            .await
3667            .expect("sweep task must not panic")
3668            .expect("orphan_sweep must succeed once unblocked");
3669        assert_eq!(
3670            post_sweep.scanned, 1,
3671            "only the surviving live vector remains after the earlier real sweep"
3672        );
3673    }
3674
3675    /// Revert-and-confirm-fails companion (mirrors the pattern in
3676    /// `crates/khive-vcs/src/sync.rs::checkpoint_wal_write_queue_tests`): the
3677    /// OLD `orphan_sweep` shape — a closure that opens its own
3678    /// `Transaction::new_unchecked`/`BEGIN IMMEDIATE` — must fail if routed
3679    /// through the WriterTask channel. `run_writer_task`'s drain loop already
3680    /// wraps every request in its own `BEGIN IMMEDIATE` before invoking the
3681    /// closure, so a second `BEGIN IMMEDIATE` issued from inside the closure
3682    /// violates SQLite's nested-transaction rule. This proves the fix's
3683    /// DML-only extraction (`orphan_sweep_dml`, no inner `BEGIN`) is
3684    /// required — naively forwarding the old closure to `writer_task.send()`
3685    /// would not have worked.
3686    #[tokio::test]
3687    async fn orphan_sweep_old_unmanaged_shape_nests_transaction_under_write_queue() {
3688        crate::extension::ensure_extensions_loaded();
3689
3690        let dir = tempfile::tempdir().unwrap();
3691        let path = dir.path().join("write_queue_orphan_sweep_regression.db");
3692        let pool = Arc::new(
3693            ConnectionPool::new(PoolConfig {
3694                path: Some(path),
3695                write_queue_enabled: true,
3696                ..PoolConfig::default()
3697            })
3698            .expect("file-backed pool"),
3699        );
3700        create_substrate_tables(&pool);
3701        create_vec_table(&pool, "write_queue_orphan_sweep_regression", 4);
3702
3703        let writer_task = pool
3704            .writer_task_handle()
3705            .expect("writer task handle")
3706            .expect("writer task must spawn for a file-backed pool with the flag on");
3707
3708        let result: Result<(), StorageError> = writer_task
3709            .send(move |conn| {
3710                // The OLD orphan_sweep shape: opens its own BEGIN IMMEDIATE via
3711                // `Transaction::new_unchecked`. Under the write queue this
3712                // closure already runs inside the drain loop's own open
3713                // transaction, so this must fail with SQLite's
3714                // nested-transaction error.
3715                let tx = rusqlite::Transaction::new_unchecked(
3716                    conn,
3717                    rusqlite::TransactionBehavior::Immediate,
3718                )
3719                .map_err(|e| map_err(e, "orphan_sweep_old_shape"))?;
3720                tx.commit()
3721                    .map_err(|e| map_err(e, "orphan_sweep_old_shape"))?;
3722                Ok(())
3723            })
3724            .await;
3725
3726        let err = result.expect_err(
3727            "routing the OLD orphan_sweep closure (its own BEGIN IMMEDIATE) through the \
3728             WriterTask must fail under KHIVE_WRITE_QUEUE — if this now succeeds, re-audit \
3729             whether the WriterTask still owns the sole BEGIN IMMEDIATE for this connection",
3730        );
3731        let msg = err.to_string();
3732        assert!(
3733            msg.contains("cannot start a transaction within a transaction"),
3734            "expected the deterministic nested-transaction failure (SQLite's own message \
3735             for a second BEGIN issued inside an already-open transaction), got: {msg}"
3736        );
3737    }
3738}