Skip to main content

khive_db/stores/
graph.rs

1//! SQL-backed `GraphStore`: edge CRUD, neighbor queries, and bounded BFS traversal.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::{DateTime, TimeZone, Utc};
8use rusqlite::OptionalExtension;
9use uuid::Uuid;
10
11use khive_storage::error::StorageError;
12use khive_storage::types::{
13    BatchWriteSummary, DeleteMode, DirectedNeighborHit, Direction, Edge, EdgeFilter, EdgeSeekPage,
14    EdgeSortField, GraphPath, GuardedBatchOutcome, GuardedBatchRefusal, GuardedWriteOutcome,
15    MissingEndpoints, NeighborHit, NeighborQuery, Page, PageRequest, PathNode, SeekCursor,
16    SeekPage, SortDirection, SortOrder, SqlStatement, SqlValue, TraversalExecutionBudget,
17    TraversalOptions, TraversalRequest,
18};
19use khive_storage::GraphStore;
20use khive_storage::LinkId;
21use khive_storage::StorageCapability;
22use khive_types::EdgeRelation;
23
24use crate::error::SqliteError;
25use crate::pool::ConnectionPool;
26use crate::sql_bridge::bind_params;
27use crate::writer_task::WriterTaskHandle;
28
29/// Map a rusqlite error to `StorageError` with `Graph` capability.
30fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
31    StorageError::driver(StorageCapability::Graph, op, e)
32}
33
34fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
35    StorageError::driver(StorageCapability::Graph, op, e)
36}
37
38const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
39
40// ---------------------------------------------------------------------------
41// Pure statement builders (ADR-099 B3 r6 structural cut) — see entity.rs's
42// sibling block for the full rationale. `upsert_edge`/`delete_edge` below and
43// `purge_incident_edges` (plus ADR-099's atomic prepare path in
44// `khive-runtime`, and `khive-runtime::operations::update_edge`'s
45// non-symmetric branch) all call these.
46// ---------------------------------------------------------------------------
47
48/// The natural-key conflict arm's `SET` list — shared, textually, between
49/// [`edge_upsert_statement`] and [`edge_insert_guarded_by_endpoints_statement`]
50/// (ADR-099 §B3) so the two can never silently
51/// diverge again: the atomic link path previously hand-duplicated this SET
52/// list without `target_backend = excluded.target_backend`, so a re-link of
53/// an edge that had a cross-backend `target_backend` stamp behaved
54/// differently under `--atomic` than under canonical `link`.
55const EDGE_NATURAL_KEY_CONFLICT_SET: &str = "weight = excluded.weight, \
56     updated_at = excluded.updated_at, \
57     deleted_at = NULL, \
58     metadata = excluded.metadata, \
59     target_backend = excluded.target_backend";
60
61/// A `WHERE`-clause fragment asserting the id bound to `id_param` (an SQL
62/// placeholder like `?3`) resolves to a live edge endpoint — an
63/// undeleted entity or note, an event (append-only, no `deleted_at`), or an
64/// undeleted edge (the `annotates` relation's target may be any substrate,
65/// including another edge; ADR-002/ADR-055). Shared by
66/// [`edge_insert_guarded_by_endpoints_statement`] and
67/// [`edge_endpoints_exist`] (#769) so the two "does this endpoint still
68/// exist" probes — the guarded single-row insert and the guarded batch
69/// pre-check — can never drift on which substrates count as valid
70/// endpoints.
71fn endpoint_exists_clause(id_param: &str) -> String {
72    format!(
73        "EXISTS (SELECT 1 FROM entities WHERE id = {id_param} AND deleted_at IS NULL) \
74         OR EXISTS (SELECT 1 FROM notes WHERE id = {id_param} AND deleted_at IS NULL) \
75         OR EXISTS (SELECT 1 FROM events WHERE id = {id_param}) \
76         OR EXISTS (SELECT 1 FROM graph_edges WHERE id = {id_param} AND deleted_at IS NULL)"
77    )
78}
79
80/// The exact natural-key-upserting `INSERT ... ON CONFLICT` this store's
81/// `upsert_edge` issues. Canonicalizes symmetric-relation endpoints first,
82/// matching `upsert_edge`'s own call to `canonical_edge_endpoints`.
83pub fn edge_upsert_statement(edge: &Edge) -> SqlStatement {
84    let (source_id, target_id) =
85        canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
86    let metadata_str = edge
87        .metadata
88        .as_ref()
89        .map(|v| serde_json::to_string(v).unwrap_or_default());
90    SqlStatement {
91        sql: format!(
92            "INSERT INTO graph_edges \
93              (namespace, id, source_id, target_id, relation, weight, \
94               created_at, updated_at, deleted_at, metadata, target_backend) \
95              VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
96              ON CONFLICT(namespace, id) DO UPDATE SET \
97                  source_id = excluded.source_id, \
98                  target_id = excluded.target_id, \
99                  relation = excluded.relation, \
100                  {EDGE_NATURAL_KEY_CONFLICT_SET} \
101              ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
102                  {EDGE_NATURAL_KEY_CONFLICT_SET}"
103        ),
104        params: vec![
105            SqlValue::Text(edge.namespace.clone()),
106            SqlValue::Text(Uuid::from(edge.id).to_string()),
107            SqlValue::Text(source_id.to_string()),
108            SqlValue::Text(target_id.to_string()),
109            SqlValue::Text(edge.relation.to_string()),
110            SqlValue::Float(edge.weight),
111            SqlValue::Integer(edge.created_at.timestamp_micros()),
112            SqlValue::Integer(edge.updated_at.timestamp_micros()),
113            match edge.deleted_at {
114                Some(t) => SqlValue::Integer(t.timestamp_micros()),
115                None => SqlValue::Null,
116            },
117            match metadata_str {
118                Some(m) => SqlValue::Text(m),
119                None => SqlValue::Null,
120            },
121            match &edge.target_backend {
122                Some(b) => SqlValue::Text(b.clone()),
123                None => SqlValue::Null,
124            },
125        ],
126        label: Some("edge-upsert".to_string()),
127    }
128}
129
130/// The atomic `link` op's variant of [`edge_upsert_statement`] (ADR-099
131/// §B3). Shares the SAME
132/// `EDGE_NATURAL_KEY_CONFLICT_SET` conflict-arm text — the two builders
133/// cannot diverge on write behavior — but wraps the `INSERT` in a guarded
134/// `SELECT ... WHERE EXISTS(...)` that re-probes both endpoints for
135/// existence INSIDE the transaction, at commit time, rather than trusting
136/// prepare-time validation alone.
137///
138/// This guard is atomic-`link`-specific, not a `edge_upsert_statement`
139/// concern: `LinkPlan`'s own doc comment (`khive-runtime::atomic_plan`)
140/// records why it must be commit-time, not prepare-time — a `link` op's
141/// async prepare pass (`validate_edge_relation_endpoints`) can run and pass
142/// BEFORE an earlier op in the SAME atomic unit (e.g. `delete(X, hard)`)
143/// removes that very endpoint; only a commit-time, in-transaction guard
144/// closes that intra-batch ordering hazard (ADR-099 acceptance criteria:
145/// `[delete(X, hard), link(A, X)]` must fail, not silently create a
146/// dangling edge). Canonical `link` has no equivalent need — it executes
147/// and commits standalone, with no other op's write interleaved between its
148/// own validation and its own write.
149#[allow(clippy::too_many_arguments)]
150pub fn edge_insert_guarded_by_endpoints_statement(
151    namespace: &str,
152    edge_id: Uuid,
153    source_id: Uuid,
154    target_id: Uuid,
155    relation: EdgeRelation,
156    weight: f64,
157    now: i64,
158    metadata: Option<&str>,
159) -> SqlStatement {
160    let src_exists = endpoint_exists_clause("?3");
161    let tgt_exists = endpoint_exists_clause("?4");
162    SqlStatement {
163        sql: format!(
164            "INSERT INTO graph_edges \
165              (namespace, id, source_id, target_id, relation, weight, \
166               created_at, updated_at, metadata) \
167              SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8 \
168              WHERE ({src_exists}) AND ({tgt_exists}) \
169              ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
170                  {EDGE_NATURAL_KEY_CONFLICT_SET}"
171        ),
172        params: vec![
173            SqlValue::Text(namespace.to_string()),
174            SqlValue::Text(edge_id.to_string()),
175            SqlValue::Text(source_id.to_string()),
176            SqlValue::Text(target_id.to_string()),
177            SqlValue::Text(relation.as_str().to_string()),
178            SqlValue::Float(weight),
179            SqlValue::Integer(now),
180            match metadata {
181                Some(m) => SqlValue::Text(m.to_string()),
182                None => SqlValue::Null,
183            },
184        ],
185        label: Some("atomic-link-insert-edge-where-exists".to_string()),
186    }
187}
188
189/// The exact soft-delete `UPDATE` this store's `delete_edge(Soft)` issues.
190pub fn edge_soft_delete_statement(id: Uuid, now: i64) -> SqlStatement {
191    SqlStatement {
192        sql: "UPDATE graph_edges SET deleted_at = ?2, updated_at = ?2 \
193              WHERE id = ?1 AND deleted_at IS NULL"
194            .to_string(),
195        params: vec![SqlValue::Text(id.to_string()), SqlValue::Integer(now)],
196        label: Some("edge-delete-soft".to_string()),
197    }
198}
199
200/// The exact hard-delete `DELETE` this store's `delete_edge(Hard)` issues.
201pub fn edge_hard_delete_statement(id: Uuid) -> SqlStatement {
202    SqlStatement {
203        sql: "DELETE FROM graph_edges WHERE id = ?1".to_string(),
204        params: vec![SqlValue::Text(id.to_string())],
205        label: Some("edge-delete-hard".to_string()),
206    }
207}
208
209/// The exact cascade `DELETE` this store's `purge_incident_edges` issues.
210pub fn purge_incident_edges_statement(node_id: Uuid) -> SqlStatement {
211    SqlStatement {
212        sql: "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1".to_string(),
213        params: vec![SqlValue::Text(node_id.to_string())],
214        label: Some("edge-purge-incident".to_string()),
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Symmetric-relation update DML (ADR-099 B3 r6 second pass) — the SQL text
220// `khive-runtime::operations::KhiveRuntime::update_edge_symmetric_dml` (the
221// synchronous raw-connection commit-time path, run inside the writer-task/
222// pool-mutex transaction) and ADR-099's atomic `prepare_update_edge` symmetric
223// branch (the async plan-time path) both bind. `upsert_edge` cannot be used
224// here: it resolves `ON CONFLICT(namespace, id)` first and cannot detect a
225// natural-key collision at (namespace, source_id, target_id, relation) with a
226// *different* id, which is exactly the case a symmetric-relation endpoint
227// canonicalization can produce.
228//
229// The two call sites bind these against different parameter-passing
230// mechanisms — `conn.execute`/`conn.query_row` with `rusqlite::params!` in the
231// synchronous path (it must run inside an existing transaction on a borrowed
232// `&rusqlite::Connection`, so it cannot go through the `SqlStatement`/
233// `SqlValue` plan-shape khive-storage abstracts elsewhere) vs. `SqlValue`
234// plan params for the async `PlanStatement` path — but the SQL TEXT itself
235// (the `EDGE_SYMMETRIC_*_SQL` constants below) is the single source of truth
236// for both, closing the class of drift that produced a hand-copied SQL
237// literal silently diverging from canonical (ADR-099 §B3).
238pub const EDGE_SYMMETRIC_CONFLICT_PROBE_SQL: &str = "SELECT id FROM graph_edges \
239     WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
240     AND relation = ?4 AND id != ?5";
241
242pub const EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL: &str =
243    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2";
244
245pub const EDGE_SYMMETRIC_UPDATE_INPLACE_SQL: &str = "UPDATE graph_edges SET \
246     source_id = ?1, target_id = ?2, relation = ?3, \
247     weight = ?4, updated_at = ?5, metadata = ?6 \
248     WHERE namespace = ?7 AND id = ?8";
249
250/// Plan-shape builder for [`EDGE_SYMMETRIC_CONFLICT_PROBE_SQL`] — the
251/// async prepare-time conflict probe.
252pub fn edge_symmetric_conflict_probe_statement(
253    namespace: &str,
254    canon_src: Uuid,
255    canon_tgt: Uuid,
256    relation: EdgeRelation,
257    exclude_id: Uuid,
258) -> SqlStatement {
259    SqlStatement {
260        sql: EDGE_SYMMETRIC_CONFLICT_PROBE_SQL.to_string(),
261        params: vec![
262            SqlValue::Text(namespace.to_string()),
263            SqlValue::Text(canon_src.to_string()),
264            SqlValue::Text(canon_tgt.to_string()),
265            SqlValue::Text(relation.to_string()),
266            SqlValue::Text(exclude_id.to_string()),
267        ],
268        label: Some("edge-symmetric-conflict-probe".to_string()),
269    }
270}
271
272/// Plan-shape builder for [`EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL`] —
273/// case (b): a canonical row already exists, delete the requested row.
274pub fn edge_symmetric_delete_noncanonical_statement(namespace: &str, id: Uuid) -> SqlStatement {
275    SqlStatement {
276        sql: EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL.to_string(),
277        params: vec![
278            SqlValue::Text(namespace.to_string()),
279            SqlValue::Text(id.to_string()),
280        ],
281        label: Some("edge-symmetric-delete-noncanonical".to_string()),
282    }
283}
284
285/// Plan-shape builder for [`EDGE_SYMMETRIC_UPDATE_INPLACE_SQL`] —
286/// case (a): no conflict, update the requested row in place.
287#[allow(clippy::too_many_arguments)]
288pub fn edge_symmetric_update_inplace_statement(
289    namespace: &str,
290    id: Uuid,
291    canon_src: Uuid,
292    canon_tgt: Uuid,
293    relation: EdgeRelation,
294    weight: f64,
295    updated_at_micros: i64,
296    metadata: Option<&str>,
297) -> SqlStatement {
298    SqlStatement {
299        sql: EDGE_SYMMETRIC_UPDATE_INPLACE_SQL.to_string(),
300        params: vec![
301            SqlValue::Text(canon_src.to_string()),
302            SqlValue::Text(canon_tgt.to_string()),
303            SqlValue::Text(relation.to_string()),
304            SqlValue::Float(weight),
305            SqlValue::Integer(updated_at_micros),
306            match metadata {
307                Some(m) => SqlValue::Text(m.to_string()),
308                None => SqlValue::Null,
309            },
310            SqlValue::Text(namespace.to_string()),
311            SqlValue::Text(id.to_string()),
312        ],
313        label: Some("edge-symmetric-update-inplace".to_string()),
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Symmetric-relation update DML — atomic-only, commit-time self-guarding
319// variant (ADR-099 §B3).
320//
321// The four builders above are still what canonical `update_edge_symmetric_dml`
322// binds: it probes and branches synchronously INSIDE its own writer-task
323// transaction, with no other op interleaved between its probe and its write,
324// so its branch has no staleness exposure and is left untouched (control
325// group — canonical's tests must stay green).
326//
327// The atomic path is structurally different: its conflict probe runs in the
328// async PREPARE phase, which for a multi-op `--atomic` unit completes for
329// EVERY op before the synchronous COMMIT phase begins for ANY of them. An
330// earlier op in the SAME atomic unit (e.g. a `delete` or another symmetric
331// `update` touching the same natural key) can change the conflict landscape
332// between this op's prepare-time probe and its own statements finally
333// executing at commit time — this is a real staleness
334// window, not just an SQL-text duplication concern.
335//
336// The two builders below close it: instead of a Rust-level `if let
337// Some(conflict) { ... } else { ... }` that hand-picks ONE of three
338// statements at prepare time (the second hand-assembled branch this
339// closes), the atomic plan ALWAYS carries both statements, in order, and
340// each is a self-guarding, commit-time predicate that re-evaluates conflict
341// state fresh against whatever the transaction's state actually is when it
342// runs — not what prepare's probe said:
343//
344// 1. [`edge_symmetric_delete_if_conflict_statement`]: deletes the requested
345//    (non-canonical) row IF AND ONLY IF a differently-id'd canonical row
346//    exists at the target natural key at THIS moment (guard: 0 or 1 rows).
347// 2. [`edge_symmetric_absorb_or_update_inplace_statement`]: a single
348//    `UPDATE` that no longer trusts an `id = ?2 OR natural-key` predicate
349//    (ADR-099 §B3 — that predicate could
350//    match the WRONG row: if a different op earlier in the SAME atomic unit
351//    had already deleted the requested edge, statement 1 above no-ops (its
352//    "0 rows" result is indistinguishable at the Rust level from "no
353//    conflict existed"), yet the natural-key arm could still hit a
354//    pre-existing canonical row that this update never causally touched).
355//    The fix ties the natural-key arm to `changes()` — SQLite's per-
356//    connection scalar reporting the row count of the most recently
357//    COMPLETED statement, i.e. statement 1's own result, evaluated fresh at
358//    THIS statement's execution, not at prepare time:
359//    - `id = ?2 AND changes() = 0`: statement 1 deleted nothing, so the
360//      requested row is still live under its own id — update it in place.
361//    - `source_id = ?3 AND target_id = ?4 AND relation = ?5 AND id != ?2
362//      AND changes() = 1`: statement 1 just deleted the requested row
363//      BECAUSE a conflict existed — match the surviving canonical row and
364//      leave its attributes unchanged (ADR-039 DO NOTHING; see below).
365//    These two arms are mutually exclusive and, together with statement 1's
366//    own guard, jointly exhaustive: if the requested row no longer existed
367//    when statement 1 ran (the same-unit race above), statement 1 affects 0
368//    rows for a reason unrelated to conflict absorption, `id = ?2` no
369//    longer matches anything (the row is gone), and the natural-key arm's
370//    `changes() = 1` guard is false — so this statement affects ZERO rows
371//    and the plan's `AffectedRowGuard::exactly(1)` on it fails the op,
372//    aborting the whole atomic unit rather than silently mutating an
373//    unrelated row.
374//
375//    ADR-039's edge-conflict contract is ON CONFLICT DO NOTHING: the
376//    natural-key (absorbed-conflict) arm must leave the surviving canonical
377//    row's attributes exactly as they were — refreshing them from the
378//    discarded edge (and forcing `deleted_at = NULL`, resurrecting a
379//    tombstone) is the same defect already fixed on the merge-rewire path
380//    (khive#1213). Every SET expression is therefore keyed on `id = ?2`:
381//    that condition is true only for the WHERE clause's first (in-place)
382//    arm — the second (absorbed) arm only ever matches a row whose id is
383//    NOT ?2 — so each column either takes its new value (in-place arm) or
384//    self-assigns its current value (absorbed arm, a true no-op). This
385//    still affects exactly one row either way (SQLite's `changes()` counts
386//    matched rows, not changed bytes), so `AffectedRowGuard::exactly(1)`
387//    and the race-abort behavior above are unaffected by this being a
388//    no-op write in the absorbed case.
389//
390// No probe, no branch, no read at all is needed to APPLY this pair. Which
391// row this plan actually touched is derived post-commit by the caller via a
392// fresh natural-key lookup (`khive-runtime::KhiveRuntime::get_edge_by_natural_key_including_deleted`,
393// filtered on the canonicalized endpoints/relation and including soft-deleted rows — unlike
394// `list_edges`, which unconditionally filters `deleted_at IS NULL` and would report "not
395// found" for a surviving row this absorption arm left tombstoned) — ADR-099 §B3
396// removed the prior prepare-time advisory `target_id` probe entirely:
397// a value computed before the
398// SAME atomic unit's other ops have run is not a fact this plan can stand
399// behind, so result rendering no longer trusts it.
400pub fn edge_symmetric_delete_if_conflict_statement(
401    namespace: &str,
402    id: Uuid,
403    canon_src: Uuid,
404    canon_tgt: Uuid,
405    relation: EdgeRelation,
406) -> SqlStatement {
407    SqlStatement {
408        sql: "DELETE FROM graph_edges \
409              WHERE namespace = ?1 AND id = ?2 \
410                AND EXISTS ( \
411                  SELECT 1 FROM graph_edges \
412                  WHERE namespace = ?1 AND source_id = ?3 AND target_id = ?4 \
413                    AND relation = ?5 AND id != ?2 \
414                )"
415        .to_string(),
416        params: vec![
417            SqlValue::Text(namespace.to_string()),
418            SqlValue::Text(id.to_string()),
419            SqlValue::Text(canon_src.to_string()),
420            SqlValue::Text(canon_tgt.to_string()),
421            SqlValue::Text(relation.to_string()),
422        ],
423        label: Some("edge-symmetric-delete-if-conflict".to_string()),
424    }
425}
426
427#[allow(clippy::too_many_arguments)]
428pub fn edge_symmetric_absorb_or_update_inplace_statement(
429    namespace: &str,
430    id: Uuid,
431    canon_src: Uuid,
432    canon_tgt: Uuid,
433    relation: EdgeRelation,
434    weight: f64,
435    updated_at_micros: i64,
436    metadata: Option<&str>,
437    target_backend: Option<&str>,
438) -> SqlStatement {
439    SqlStatement {
440        sql: "UPDATE graph_edges SET \
441              source_id = CASE WHEN id = ?2 THEN ?3 ELSE source_id END, \
442              target_id = CASE WHEN id = ?2 THEN ?4 ELSE target_id END, \
443              relation = CASE WHEN id = ?2 THEN ?5 ELSE relation END, \
444              weight = CASE WHEN id = ?2 THEN ?6 ELSE weight END, \
445              updated_at = CASE WHEN id = ?2 THEN ?7 ELSE updated_at END, \
446              deleted_at = CASE WHEN id = ?2 THEN NULL ELSE deleted_at END, \
447              metadata = CASE WHEN id = ?2 THEN ?8 ELSE metadata END, \
448              target_backend = CASE WHEN id = ?2 THEN ?9 ELSE target_backend END \
449              WHERE namespace = ?1 \
450                AND ( \
451                  (id = ?2 AND changes() = 0) \
452                  OR (source_id = ?3 AND target_id = ?4 AND relation = ?5 \
453                      AND id != ?2 AND changes() = 1) \
454                )"
455        .to_string(),
456        params: vec![
457            SqlValue::Text(namespace.to_string()),
458            SqlValue::Text(id.to_string()),
459            SqlValue::Text(canon_src.to_string()),
460            SqlValue::Text(canon_tgt.to_string()),
461            SqlValue::Text(relation.to_string()),
462            SqlValue::Float(weight),
463            SqlValue::Integer(updated_at_micros),
464            match metadata {
465                Some(m) => SqlValue::Text(m.to_string()),
466                None => SqlValue::Null,
467            },
468            match target_backend {
469                Some(b) => SqlValue::Text(b.to_string()),
470                None => SqlValue::Null,
471            },
472        ],
473        label: Some("edge-symmetric-absorb-or-update-inplace".to_string()),
474    }
475}
476
477/// A GraphStore backed by SQLite tables.
478pub struct SqlGraphStore {
479    pool: Arc<ConnectionPool>,
480    is_file_backed: bool,
481    /// Default namespace for multi-record queries (ADR-007 PARAM-ONLY: used as a
482    /// WHERE filter on `query_edges`/`neighbors`/`traverse`, never as an
483    /// enforcement gate on by-ID operations).
484    namespace: String,
485    writer_task: Option<WriterTaskHandle>,
486}
487
488impl SqlGraphStore {
489    /// Create a new store with a default namespace for multi-record query filtering.
490    ///
491    /// The namespace is a PARAM-ONLY hint (ADR-007 rule 4) — it is used as a
492    /// WHERE filter in multi-record queries and as the write namespace stamped on
493    /// upserted edges, but it does NOT enforce isolation: `upsert_edge` accepts
494    /// edges from any namespace, and by-ID ops (`get_edge`, `delete_edge`) ignore
495    /// the namespace entirely.
496    pub fn new_scoped(
497        pool: Arc<ConnectionPool>,
498        is_file_backed: bool,
499        namespace: impl Into<String>,
500    ) -> Self {
501        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
502        // policy): a missing writer task degrades to the legacy pool-mutex /
503        // standalone-connection path rather than failing construction.
504        let writer_task = pool.writer_task_handle().ok().flatten();
505
506        Self {
507            pool,
508            is_file_backed,
509            namespace: namespace.into(),
510            writer_task,
511        }
512    }
513
514    fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
515        self.pool
516            .open_standalone_writer()
517            .map_err(|e| map_sqlite_err(e, "open_graph_writer"))
518    }
519
520    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
521        self.pool
522            .open_standalone_reader()
523            .map_err(|e| map_sqlite_err(e, "open_graph_reader"))
524    }
525
526    /// Route a single-row write through the pool-wide `WriterTask` when
527    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
528    /// to the legacy standalone-connection / pool-mutex path (ADR-067
529    /// Component A, Fork C slice 2). `f` must be DML-only. See
530    /// `crates/khive-db/docs/api/graph.md` for the per-caller routing rules.
531    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
532    where
533        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
534        R: Send + 'static,
535    {
536        if let Some(writer_task) = &self.writer_task {
537            return writer_task
538                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
539                .await;
540        }
541
542        if self.is_file_backed {
543            let conn = self.open_standalone_writer()?;
544            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
545                .await
546                .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
547        } else {
548            let pool = Arc::clone(&self.pool);
549            tokio::task::spawn_blocking(move || {
550                let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
551                f(guard.conn()).map_err(|e| map_err(e, op))
552            })
553            .await
554            .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
555        }
556    }
557
558    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
559    where
560        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
561        R: Send + 'static,
562    {
563        if self.is_file_backed {
564            let conn = self.open_standalone_reader()?;
565            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
566                .await
567                .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
568        } else {
569            let pool = Arc::clone(&self.pool);
570            tokio::task::spawn_blocking(move || {
571                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
572                f(guard.conn()).map_err(|e| map_err(e, op))
573            })
574            .await
575            .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
576        }
577    }
578}
579
580// =============================================================================
581// Helpers
582// =============================================================================
583
584/// Publish a graph read's accounting after the read resolves, on either
585/// outcome.
586///
587/// Both counters are collected inside the storage closure because that closure
588/// runs on a blocking thread where the task-local usage context is invisible,
589/// and because a value returned only on success reports nothing at all when
590/// the read fails partway. `queries` is incremented immediately before a
591/// statement executes, so a read that never got that far — a connection the
592/// pool could not hand out, a blocking task that died — counts no round trip.
593/// `rows` is incremented as each adjacency entry materialises, so entries
594/// storage already returned still count when a later row fails to convert.
595fn report_graph_usage(queries: &std::sync::atomic::AtomicU64, rows: &std::sync::atomic::AtomicU64) {
596    khive_storage::usage::count(
597        khive_storage::usage::UsageUnit::DbRoundTrips,
598        queries.load(std::sync::atomic::Ordering::Relaxed),
599    );
600    khive_storage::usage::count(
601        khive_storage::usage::UsageUnit::GraphHops,
602        rows.load(std::sync::atomic::Ordering::Relaxed),
603    );
604}
605
606fn read_edge(row: &rusqlite::Row<'_>) -> Result<Edge, rusqlite::Error> {
607    let namespace: String = row.get(0)?;
608    let id_str: String = row.get(1)?;
609    let source_str: String = row.get(2)?;
610    let target_str: String = row.get(3)?;
611    let relation_str: String = row.get(4)?;
612    let weight: f64 = row.get(5)?;
613    let created_micros: i64 = row.get(6)?;
614    let updated_micros: i64 = row.get(7)?;
615    let deleted_micros: Option<i64> = row.get(8)?;
616    let metadata_str: Option<String> = row.get(9)?;
617    let target_backend: Option<String> = row.get(10)?;
618
619    let id = parse_uuid(&id_str)?;
620    let source_id = parse_uuid(&source_str)?;
621    let target_id = parse_uuid(&target_str)?;
622    let created_at = micros_to_datetime(created_micros);
623    let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
624        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
625    })?;
626    let metadata = match metadata_str {
627        Some(s) => {
628            let v = serde_json::from_str(&s).map_err(|e| {
629                rusqlite::Error::FromSqlConversionFailure(
630                    9,
631                    rusqlite::types::Type::Text,
632                    Box::new(e),
633                )
634            })?;
635            Some(v)
636        }
637        None => None,
638    };
639
640    Ok(Edge {
641        id: id.into(),
642        namespace,
643        source_id,
644        target_id,
645        relation,
646        weight,
647        created_at,
648        updated_at: micros_to_datetime(updated_micros),
649        deleted_at: deleted_micros.map(micros_to_datetime),
650        metadata,
651        target_backend,
652    })
653}
654
655fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
656    Uuid::parse_str(s).map_err(|e| {
657        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
658    })
659}
660
661/// Build the `relation IN (...)` / `weight >= ?` `WHERE`-extra clause and the
662/// `LIMIT` clause shared by `neighbors` and `neighbors_both_directions` —
663/// both filter and cap identically, differing only in which direction(s) the
664/// base `SELECT`s cover. `start_param_idx` is the next free `?N` placeholder
665/// (both callers bind `namespace` and `node_id` as `?1`/`?2` first).
666fn neighbor_extra_clause(
667    query: &NeighborQuery,
668    start_param_idx: usize,
669) -> (String, String, Vec<Box<dyn rusqlite::types::ToSql>>) {
670    let mut conditions: Vec<String> = Vec::new();
671    let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
672    let mut param_idx = start_param_idx;
673
674    if let Some(ref rels) = query.relations {
675        if !rels.is_empty() {
676            let placeholders: Vec<String> = rels
677                .iter()
678                .map(|r| {
679                    extra_params.push(Box::new(r.to_string()));
680                    let p = format!("?{}", param_idx);
681                    param_idx += 1;
682                    p
683                })
684                .collect();
685            conditions.push(format!("relation IN ({})", placeholders.join(",")));
686        }
687    }
688
689    if let Some(min_w) = query.min_weight {
690        extra_params.push(Box::new(min_w));
691        conditions.push(format!("weight >= ?{}", param_idx));
692        param_idx += 1;
693    }
694
695    let where_extra = if conditions.is_empty() {
696        String::new()
697    } else {
698        format!(" WHERE {}", conditions.join(" AND "))
699    };
700
701    let limit_clause = if let Some(lim) = query.limit {
702        extra_params.push(Box::new(lim as i64));
703        format!(" LIMIT ?{}", param_idx)
704    } else {
705        String::new()
706    };
707
708    (where_extra, limit_clause, extra_params)
709}
710
711// Test-only counter of storage-level neighbor SELECT executions (`neighbors`
712// and `neighbors_both_directions` each issue exactly one `graph_edges`
713// query per call). Lets tests assert the query-count halving a
714// `Direction::Both` caller gets from `neighbors_both_directions` vs the old
715// pattern of two separate `neighbors` calls (ADR-089 context-verb
716// optimization). Gated out of release builds — no counter overhead on the
717// hot path in production.
718#[cfg(test)]
719static NEIGHBOR_SELECT_COUNT: std::sync::atomic::AtomicUsize =
720    std::sync::atomic::AtomicUsize::new(0);
721
722#[cfg(test)]
723fn count_neighbor_select() {
724    NEIGHBOR_SELECT_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
725}
726
727#[cfg(not(test))]
728fn count_neighbor_select() {}
729
730#[cfg(test)]
731pub(crate) fn reset_neighbor_select_count() {
732    NEIGHBOR_SELECT_COUNT.store(0, std::sync::atomic::Ordering::Relaxed);
733}
734
735#[cfg(test)]
736pub(crate) fn neighbor_select_count() -> usize {
737    NEIGHBOR_SELECT_COUNT.load(std::sync::atomic::Ordering::Relaxed)
738}
739
740fn micros_to_datetime(micros: i64) -> DateTime<Utc> {
741    Utc.timestamp_micros(micros)
742        .single()
743        .unwrap_or_else(Utc::now)
744}
745
746fn build_edge_filter_sql(
747    namespace: &str,
748    filter: &EdgeFilter,
749) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
750    build_edge_filter_sql_for_namespaces(&[namespace.to_string()], filter)
751}
752
753fn build_edge_filter_sql_for_namespaces(
754    namespaces: &[String],
755    filter: &EdgeFilter,
756) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
757    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
758        .iter()
759        .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
760        .collect();
761    let namespace_condition = match namespaces.len() {
762        0 => "0".to_string(),
763        1 => "namespace = ?1".to_string(),
764        _ => {
765            let placeholders: Vec<String> =
766                (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
767            format!("namespace IN ({})", placeholders.join(", "))
768        }
769    };
770    let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
771
772    if !filter.ids.is_empty() {
773        let placeholders: Vec<String> = filter
774            .ids
775            .iter()
776            .map(|id| {
777                params.push(Box::new(id.to_string()));
778                format!("?{}", params.len())
779            })
780            .collect();
781        conditions.push(format!("id IN ({})", placeholders.join(",")));
782    }
783
784    if !filter.source_ids.is_empty() {
785        let placeholders: Vec<String> = filter
786            .source_ids
787            .iter()
788            .map(|id| {
789                params.push(Box::new(id.to_string()));
790                format!("?{}", params.len())
791            })
792            .collect();
793        conditions.push(format!("source_id IN ({})", placeholders.join(",")));
794    }
795
796    if !filter.target_ids.is_empty() {
797        let placeholders: Vec<String> = filter
798            .target_ids
799            .iter()
800            .map(|id| {
801                params.push(Box::new(id.to_string()));
802                format!("?{}", params.len())
803            })
804            .collect();
805        conditions.push(format!("target_id IN ({})", placeholders.join(",")));
806    }
807
808    if !filter.relations.is_empty() {
809        let placeholders: Vec<String> = filter
810            .relations
811            .iter()
812            .map(|r| {
813                params.push(Box::new(r.to_string()));
814                format!("?{}", params.len())
815            })
816            .collect();
817        conditions.push(format!("relation IN ({})", placeholders.join(",")));
818    }
819
820    if let Some(min_w) = filter.min_weight {
821        params.push(Box::new(min_w));
822        conditions.push(format!("weight >= ?{}", params.len()));
823    }
824
825    if let Some(max_w) = filter.max_weight {
826        params.push(Box::new(max_w));
827        conditions.push(format!("weight <= ?{}", params.len()));
828    }
829
830    if let Some(ref time_range) = filter.created_at {
831        if let Some(start) = time_range.start {
832            params.push(Box::new(start.timestamp_micros()));
833            conditions.push(format!("created_at >= ?{}", params.len()));
834        }
835        if let Some(end) = time_range.end {
836            params.push(Box::new(end.timestamp_micros()));
837            conditions.push(format!("created_at < ?{}", params.len()));
838        }
839    }
840
841    let clause = format!(" WHERE {}", conditions.join(" AND "));
842    (clause, params)
843}
844
845fn edge_sort_col(field: &EdgeSortField) -> &'static str {
846    match field {
847        EdgeSortField::CreatedAt => "created_at",
848        EdgeSortField::Weight => "weight",
849        EdgeSortField::Relation => "relation",
850    }
851}
852
853// =============================================================================
854// GraphStore implementation
855// =============================================================================
856
857/// Canonical endpoint order for symmetric relations (F012).
858///
859/// For `competes_with` and `composed_with`, ensures `source_uuid < target_uuid`
860/// so A→B and B→A collapse to a single canonical row in storage.
861fn canonical_edge_endpoints(
862    relation: EdgeRelation,
863    source_id: Uuid,
864    target_id: Uuid,
865) -> (Uuid, Uuid) {
866    if relation.is_symmetric() && target_id < source_id {
867        (target_id, source_id)
868    } else {
869        (source_id, target_id)
870    }
871}
872
873/// DML-only batch upsert loop shared by both the legacy (flag-off) and
874/// WriterTask-routed (flag-on) `upsert_edges` paths. Issues no
875/// `BEGIN`/`COMMIT`/`ROLLBACK` itself — the caller owns the transaction.
876/// All-or-nothing: the first row failure returns `Err` immediately. See
877/// `crates/khive-db/docs/api/graph.md` for why this shares
878/// [`edge_upsert_statement`]'s conflict-arm builder rather than a
879/// second copy.
880fn batch_upsert_edges(
881    conn: &rusqlite::Connection,
882    edges: &[Edge],
883    attempted: u64,
884) -> Result<BatchWriteSummary, rusqlite::Error> {
885    let mut affected = 0u64;
886
887    for edge in edges {
888        let statement = edge_upsert_statement(edge);
889        let mut stmt = conn.prepare(&statement.sql)?;
890        bind_params(&mut stmt, &statement.params)?;
891        stmt.raw_execute()?;
892        affected += 1;
893    }
894
895    Ok(BatchWriteSummary {
896        attempted,
897        affected,
898        failed: 0,
899        first_error: String::new(),
900    })
901}
902
903/// Standalone existence probe for both endpoints of a would-be edge (#769),
904/// matching the `WHERE EXISTS(...)` shape
905/// [`edge_insert_guarded_by_endpoints_statement`] embeds in its own guarded
906/// `INSERT`. Returns per-endpoint existence (not a single AND'd bool) so
907/// callers can report exactly which side was missing. See
908/// `crates/khive-db/docs/api/graph.md` for its two call sites and why both
909/// need in-transaction, not reconstructed-after-the-fact, results.
910fn edge_endpoints_exist(
911    conn: &rusqlite::Connection,
912    source_id: Uuid,
913    target_id: Uuid,
914) -> Result<MissingEndpoints, rusqlite::Error> {
915    let src_exists = endpoint_exists_clause("?1");
916    let tgt_exists = endpoint_exists_clause("?2");
917    let sql = format!("SELECT ({src_exists}), ({tgt_exists})");
918    conn.query_row(
919        &sql,
920        rusqlite::params![source_id.to_string(), target_id.to_string()],
921        |row| {
922            let src_exists: bool = row.get(0)?;
923            let tgt_exists: bool = row.get(1)?;
924            Ok(MissingEndpoints {
925                source: !src_exists,
926                target: !tgt_exists,
927            })
928        },
929    )
930}
931
932/// DML-only guarded single-row insert shared by both the legacy (flag-off)
933/// and WriterTask-routed (flag-on) `upsert_edge_guarded` paths.
934///
935/// Runs the guarded `INSERT` and, if it was refused, the missing-endpoint
936/// probe on the SAME connection with no gap for another writer to intervene
937/// between them, PROVIDED the caller holds the connection under a single
938/// write-locked transaction (either the WriterTask's own `BEGIN IMMEDIATE`,
939/// or an explicit one the flag-off caller opens around this call: the
940/// singleton fallback previously ran the insert and the
941/// probe as two separate autocommit statements).
942fn edge_insert_guarded(
943    conn: &rusqlite::Connection,
944    statement: &SqlStatement,
945    source_id: Uuid,
946    target_id: Uuid,
947) -> Result<GuardedWriteOutcome, rusqlite::Error> {
948    let mut stmt = conn.prepare(&statement.sql)?;
949    bind_params(&mut stmt, &statement.params)?;
950    if stmt.raw_execute()? > 0 {
951        return Ok(GuardedWriteOutcome::Written);
952    }
953    // Test-only observation point for the exact insert-to-probe seam this
954    // function's doc comment describes: a no-op in every non-test build,
955    // and a no-op in test builds unless a test has installed a barrier for
956    // this precise (source_id, target_id) pair (see
957    // `tests::insert_probe_seam` in graph_tests.rs). Lets the
958    // atomicity regression test force a racer to run at this seam instead
959    // of guessing at it with sleeps.
960    #[cfg(test)]
961    tests::insert_probe_seam::hook((source_id, target_id));
962    let missing = edge_endpoints_exist(conn, source_id, target_id)?;
963    Ok(GuardedWriteOutcome::Refused(missing))
964}
965
966/// DML-only guarded batch upsert loop shared by both the legacy (flag-off)
967/// and WriterTask-routed (flag-on) `upsert_edges_guarded` paths, mirroring
968/// [`batch_upsert_edges`]'s split. Pre-checks every edge's endpoints with
969/// [`edge_endpoints_exist`] BEFORE issuing any `INSERT` — if any endpoint is
970/// missing, returns immediately with `affected: 0` and no writes at all
971/// (#769), so the caller's transaction has nothing to roll back. See
972/// `crates/khive-db/docs/api/graph.md`.
973fn batch_upsert_edges_guarded(
974    conn: &rusqlite::Connection,
975    edges: &[Edge],
976    attempted: u64,
977) -> Result<GuardedBatchOutcome, rusqlite::Error> {
978    for (index, edge) in edges.iter().enumerate() {
979        let (source_id, target_id) =
980            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
981        let missing = edge_endpoints_exist(conn, source_id, target_id)?;
982        if missing.any() {
983            return Ok(GuardedBatchOutcome {
984                summary: BatchWriteSummary {
985                    attempted,
986                    affected: 0,
987                    failed: attempted,
988                    first_error: format!(
989                        "batch entry {index}: edge endpoint no longer exists at write time: source {source_id} or target {target_id}"
990                    ),
991                },
992                refused: Some(GuardedBatchRefusal {
993                    entry_index: index,
994                    missing,
995                }),
996            });
997        }
998    }
999
1000    let mut affected = 0u64;
1001    for edge in edges {
1002        let statement = edge_upsert_statement(edge);
1003        let mut stmt = conn.prepare(&statement.sql)?;
1004        bind_params(&mut stmt, &statement.params)?;
1005        stmt.raw_execute()?;
1006        affected += 1;
1007    }
1008
1009    Ok(GuardedBatchOutcome {
1010        summary: BatchWriteSummary {
1011            attempted,
1012            affected,
1013            failed: 0,
1014            first_error: String::new(),
1015        },
1016        refused: None,
1017    })
1018}
1019
1020/// One index-seek adjacency statement used by bounded level-synchronous BFS.
1021/// There is deliberately no `ORDER BY`: the public contract promises minimum
1022/// depth, not a same-depth tie order, and sorting a high-degree node before a
1023/// low `LIMIT` would reintroduce the unbounded SQL work fixed by #1444.
1024fn traversal_neighbor_sql(
1025    direction: Direction,
1026    relation_count: usize,
1027    has_min_weight: bool,
1028) -> String {
1029    let (node_column, endpoint_column, index) = match direction {
1030        Direction::Out => ("target_id", "source_id", "idx_graph_edges_ns_src_rel"),
1031        Direction::In => ("source_id", "target_id", "idx_graph_edges_ns_tgt_rel"),
1032        Direction::Both => unreachable!("Direction::Both is split into indexed Out/In seeks"),
1033    };
1034    let mut sql = format!(
1035        "SELECT {node_column}, id, weight \
1036         FROM graph_edges INDEXED BY {index} \
1037         WHERE namespace = ?1 AND {endpoint_column} = ?2 AND deleted_at IS NULL"
1038    );
1039    if relation_count > 0 {
1040        let placeholders = (0..relation_count)
1041            .map(|offset| format!("?{}", 4 + offset))
1042            .collect::<Vec<_>>()
1043            .join(",");
1044        sql.push_str(&format!(" AND relation IN ({placeholders})"));
1045    }
1046    if has_min_weight {
1047        sql.push_str(&format!(" AND weight >= ?{}", 4 + relation_count));
1048    }
1049    sql.push_str(" LIMIT ?3");
1050    sql
1051}
1052
1053fn traversal_timeout_error(budget: &TraversalExecutionBudget) -> StorageError {
1054    StorageError::Timeout {
1055        operation: format!(
1056            "traverse ({}ms execution budget)",
1057            budget.max_duration().as_millis()
1058        )
1059        .into(),
1060    }
1061}
1062
1063fn traversal_work_error(budget: &TraversalExecutionBudget) -> StorageError {
1064    StorageError::InvalidInput {
1065        capability: StorageCapability::Graph,
1066        operation: "traverse".into(),
1067        message: format!(
1068            "traversal work budget exceeded after {} adjacency rows; \
1069             narrow roots, depth, relations, or result limit",
1070            budget.work_limit()
1071        ),
1072    }
1073}
1074
1075#[derive(Clone, Copy)]
1076struct TraversalFrontierNode {
1077    node_id: Uuid,
1078    depth: usize,
1079    total_weight: f64,
1080}
1081
1082#[allow(clippy::too_many_arguments)]
1083fn run_bounded_traversal(
1084    conn: &rusqlite::Connection,
1085    roots: Vec<Uuid>,
1086    opts: TraversalOptions,
1087    include_roots: bool,
1088    namespace: String,
1089    origin: khive_storage::tx_registry::TxOrigin,
1090    budget: TraversalExecutionBudget,
1091    counted_rows: &std::sync::atomic::AtomicU64,
1092    counted_queries: &std::sync::atomic::AtomicU64,
1093) -> Result<Vec<GraphPath>, StorageError> {
1094    let progress_timed_out = Arc::new(std::sync::atomic::AtomicBool::new(false));
1095    let callback_timed_out = Arc::clone(&progress_timed_out);
1096    let callback_budget = budget.clone();
1097    #[cfg(test)]
1098    let progress_seam_root = roots.first().copied();
1099    conn.progress_handler(
1100        1_000,
1101        Some(move || {
1102            #[cfg(test)]
1103            if tests::traverse_progress_seam::hook(progress_seam_root) {
1104                callback_timed_out.store(true, std::sync::atomic::Ordering::Relaxed);
1105                return true;
1106            }
1107            let expired = callback_budget.is_expired();
1108            if expired {
1109                callback_timed_out.store(true, std::sync::atomic::Ordering::Relaxed);
1110            }
1111            expired
1112        }),
1113    )
1114    .map_err(|e| map_err(e, "traverse_progress_handler"))?;
1115
1116    let result = (|| {
1117        let result_limit = opts.effective_limit() as usize;
1118        let relation_count = opts.relations.as_ref().map_or(0, Vec::len);
1119        let directions = match opts.direction {
1120            Direction::Out => vec![Direction::Out],
1121            Direction::In => vec![Direction::In],
1122            Direction::Both => vec![Direction::Out, Direction::In],
1123        };
1124        let statements = directions
1125            .into_iter()
1126            .map(|direction| {
1127                traversal_neighbor_sql(direction, relation_count, opts.min_weight.is_some())
1128            })
1129            .collect::<Vec<_>>();
1130        let map_sql_error = |error| {
1131            if progress_timed_out.load(std::sync::atomic::Ordering::Relaxed) {
1132                traversal_timeout_error(&budget)
1133            } else {
1134                map_err(error, "traverse")
1135            }
1136        };
1137
1138        let mut all_paths = Vec::with_capacity(roots.len());
1139        for root_id in roots {
1140            let mut seen = HashSet::new();
1141            seen.insert(root_id);
1142            let mut frontier = VecDeque::from([TraversalFrontierNode {
1143                node_id: root_id,
1144                depth: 0,
1145                total_weight: 0.0,
1146            }]);
1147            let mut nodes = Vec::with_capacity(result_limit + usize::from(include_roots));
1148            if include_roots {
1149                nodes.push(PathNode {
1150                    node_id: root_id,
1151                    via_edge: None,
1152                    depth: 0,
1153                    name: None,
1154                    kind: None,
1155                    properties: None,
1156                    weight: 0.0,
1157                });
1158            }
1159            let mut non_root_count = 0usize;
1160
1161            'root_walk: while non_root_count < result_limit {
1162                let Some(current) = frontier.pop_front() else {
1163                    break;
1164                };
1165                if current.depth >= opts.max_depth {
1166                    continue;
1167                }
1168                if budget.is_expired() {
1169                    return Err(traversal_timeout_error(&budget));
1170                }
1171
1172                for sql in &statements {
1173                    let row_cap = budget.remaining_work().saturating_add(1);
1174                    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1175                        Box::new(namespace.clone()),
1176                        Box::new(current.node_id.to_string()),
1177                        Box::new(row_cap as i64),
1178                    ];
1179                    if let Some(relations) = &opts.relations {
1180                        params.extend(relations.iter().map(|relation| {
1181                            Box::new(relation.to_string()) as Box<dyn rusqlite::types::ToSql>
1182                        }));
1183                    }
1184                    if let Some(min_weight) = opts.min_weight {
1185                        params.push(Box::new(min_weight));
1186                    }
1187                    let param_refs = params
1188                        .iter()
1189                        .map(|param| param.as_ref())
1190                        .collect::<Vec<&dyn rusqlite::types::ToSql>>();
1191
1192                    let _snapshot = khive_storage::tx_registry::register_scoped(
1193                        Some("graph_traverse_read".to_string()),
1194                        origin.clone(),
1195                    );
1196                    let mut stmt = conn.prepare(sql).map_err(&map_sql_error)?;
1197                    counted_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1198                    let mut rows = stmt.query(param_refs.as_slice()).map_err(&map_sql_error)?;
1199                    while let Some(row) = rows.next().map_err(&map_sql_error)? {
1200                        // Test seam after `sqlite3_step` has produced a row:
1201                        // the cursor's read snapshot is demonstrably live.
1202                        #[cfg(test)]
1203                        tests::traverse_snapshot_seam::hook(current.node_id);
1204                        counted_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1205                        if budget.is_expired() {
1206                            return Err(traversal_timeout_error(&budget));
1207                        }
1208                        if !budget.try_consume_row() {
1209                            return Err(traversal_work_error(&budget));
1210                        }
1211                        let node_str: String = row.get(0).map_err(&map_sql_error)?;
1212                        let edge_str: String = row.get(1).map_err(&map_sql_error)?;
1213                        let edge_weight: f64 = row.get(2).map_err(&map_sql_error)?;
1214                        let node_id = parse_uuid(&node_str).map_err(&map_sql_error)?;
1215                        if !seen.insert(node_id) {
1216                            continue;
1217                        }
1218                        let via_edge = parse_uuid(&edge_str).map_err(&map_sql_error)?;
1219                        let depth = current.depth + 1;
1220                        let total_weight = current.total_weight + edge_weight;
1221                        nodes.push(PathNode {
1222                            node_id,
1223                            via_edge: Some(via_edge),
1224                            depth,
1225                            name: None,
1226                            kind: None,
1227                            properties: None,
1228                            weight: total_weight,
1229                        });
1230                        non_root_count += 1;
1231                        if depth < opts.max_depth {
1232                            frontier.push_back(TraversalFrontierNode {
1233                                node_id,
1234                                depth,
1235                                total_weight,
1236                            });
1237                        }
1238                        if non_root_count == result_limit {
1239                            break 'root_walk;
1240                        }
1241                    }
1242                }
1243            }
1244
1245            if !nodes.is_empty() {
1246                let total_weight = nodes.iter().map(|node| node.weight).fold(0.0_f64, f64::max);
1247                all_paths.push(GraphPath {
1248                    root_id,
1249                    nodes,
1250                    total_weight,
1251                });
1252            }
1253        }
1254        Ok(all_paths)
1255    })();
1256
1257    conn.progress_handler(0, None::<fn() -> bool>)
1258        .map_err(|e| map_err(e, "traverse_progress_handler_clear"))?;
1259    result
1260}
1261
1262#[async_trait]
1263impl GraphStore for SqlGraphStore {
1264    async fn upsert_edge(&self, edge: Edge) -> Result<(), StorageError> {
1265        let statement = edge_upsert_statement(&edge);
1266        self.with_writer("upsert_edge", move |conn| {
1267            let mut stmt = conn.prepare(&statement.sql)?;
1268            bind_params(&mut stmt, &statement.params)?;
1269            stmt.raw_execute()?;
1270            Ok(())
1271        })
1272        .await
1273    }
1274
1275    async fn upsert_edges(&self, edges: Vec<Edge>) -> Result<BatchWriteSummary, StorageError> {
1276        let attempted = edges.len() as u64;
1277
1278        // ADR-067 Component A: when the write queue is enabled, route
1279        // through the pool-wide WriterTask. DML-only closure — no BEGIN
1280        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
1281        // owns the transaction (a bare BEGIN IMMEDIATE here would violate
1282        // SQLite's nested-transaction rule).
1283        if let Some(writer_task) = &self.writer_task {
1284            return writer_task
1285                .send(move |conn| {
1286                    batch_upsert_edges(conn, &edges, attempted)
1287                        .map_err(|e| map_err(e, "upsert_edges"))
1288                })
1289                .await;
1290        }
1291
1292        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
1293        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT/ROLLBACK
1294        // via the pool-mutex/standalone writer.
1295        let origin = self.pool.origin();
1296        self.with_writer("upsert_edges", move |conn| {
1297            conn.execute_batch("BEGIN IMMEDIATE")?;
1298            let _tx_handle = khive_storage::tx_registry::register_scoped(
1299                Some("graph_upsert_edges".to_string()),
1300                origin,
1301            );
1302
1303            let summary = match batch_upsert_edges(conn, &edges, attempted) {
1304                Ok(summary) => summary,
1305                Err(e) => {
1306                    let _ = conn.execute_batch("ROLLBACK");
1307                    return Err(e);
1308                }
1309            };
1310
1311            if let Err(e) = conn.execute_batch("COMMIT") {
1312                let _ = conn.execute_batch("ROLLBACK");
1313                return Err(e);
1314            }
1315            Ok(summary)
1316        })
1317        .await
1318    }
1319
1320    async fn upsert_edge_guarded(&self, edge: Edge) -> Result<GuardedWriteOutcome, StorageError> {
1321        let (source_id, target_id) =
1322            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
1323        let metadata_str = edge
1324            .metadata
1325            .as_ref()
1326            .map(|v| serde_json::to_string(v).unwrap_or_default());
1327        let statement = edge_insert_guarded_by_endpoints_statement(
1328            &edge.namespace,
1329            Uuid::from(edge.id),
1330            source_id,
1331            target_id,
1332            edge.relation,
1333            edge.weight,
1334            edge.created_at.timestamp_micros(),
1335            metadata_str.as_deref(),
1336        );
1337
1338        // Same WriterTask routing as `upsert_edges_guarded` — the
1339        // WriterTask's run loop owns its own `BEGIN IMMEDIATE`, so the
1340        // insert and the missing-endpoint probe below already run inside
1341        // one write-locked transaction; a bare `BEGIN IMMEDIATE` here would
1342        // violate SQLite's nested-transaction rule.
1343        if let Some(writer_task) = &self.writer_task {
1344            return writer_task
1345                .send(move |conn| {
1346                    edge_insert_guarded(conn, &statement, source_id, target_id)
1347                        .map_err(|e| map_err(e, "upsert_edge_guarded"))
1348                })
1349                .await;
1350        }
1351
1352        // Flag-off (singleton) path: wrap the insert and the refused-probe
1353        // in one explicit transaction so nothing can change an endpoint
1354        // between them (this fallback previously
1355        // ran the two as separate autocommit statements on the standalone
1356        // writer connection).
1357        let origin = self.pool.origin();
1358        self.with_writer("upsert_edge_guarded", move |conn| {
1359            conn.execute_batch("BEGIN IMMEDIATE")?;
1360            let _tx_handle = khive_storage::tx_registry::register_scoped(
1361                Some("graph_upsert_edge_guarded".to_string()),
1362                origin,
1363            );
1364
1365            let outcome = match edge_insert_guarded(conn, &statement, source_id, target_id) {
1366                Ok(outcome) => outcome,
1367                Err(e) => {
1368                    let _ = conn.execute_batch("ROLLBACK");
1369                    return Err(e);
1370                }
1371            };
1372
1373            if let Err(e) = conn.execute_batch("COMMIT") {
1374                let _ = conn.execute_batch("ROLLBACK");
1375                return Err(e);
1376            }
1377            Ok(outcome)
1378        })
1379        .await
1380    }
1381
1382    async fn upsert_edges_guarded(
1383        &self,
1384        edges: Vec<Edge>,
1385    ) -> Result<GuardedBatchOutcome, StorageError> {
1386        let attempted = edges.len() as u64;
1387
1388        // Same WriterTask routing as `upsert_edges` — the guard's pre-check
1389        // runs inside the WriterTask's own `BEGIN IMMEDIATE`, so a missing
1390        // endpoint is caught before any `INSERT` in this batch runs at all.
1391        if let Some(writer_task) = &self.writer_task {
1392            return writer_task
1393                .send(move |conn| {
1394                    batch_upsert_edges_guarded(conn, &edges, attempted)
1395                        .map_err(|e| map_err(e, "upsert_edges_guarded"))
1396                })
1397                .await;
1398        }
1399
1400        let origin = self.pool.origin();
1401        self.with_writer("upsert_edges_guarded", move |conn| {
1402            conn.execute_batch("BEGIN IMMEDIATE")?;
1403            let _tx_handle = khive_storage::tx_registry::register_scoped(
1404                Some("graph_upsert_edges_guarded".to_string()),
1405                origin,
1406            );
1407
1408            let summary = match batch_upsert_edges_guarded(conn, &edges, attempted) {
1409                Ok(summary) => summary,
1410                Err(e) => {
1411                    let _ = conn.execute_batch("ROLLBACK");
1412                    return Err(e);
1413                }
1414            };
1415
1416            if let Err(e) = conn.execute_batch("COMMIT") {
1417                let _ = conn.execute_batch("ROLLBACK");
1418                return Err(e);
1419            }
1420            Ok(summary)
1421        })
1422        .await
1423    }
1424
1425    async fn get_edge(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1426        let id_str = Uuid::from(id).to_string();
1427
1428        self.with_reader("get_edge", move |conn| {
1429            let mut stmt = conn.prepare(
1430                "SELECT namespace, id, source_id, target_id, relation, weight, \
1431                        created_at, updated_at, deleted_at, metadata, target_backend \
1432                 FROM graph_edges WHERE id = ?1 AND deleted_at IS NULL",
1433            )?;
1434            let mut rows = stmt.query(rusqlite::params![id_str])?;
1435            match rows.next()? {
1436                Some(row) => Ok(Some(read_edge(row)?)),
1437                None => Ok(None),
1438            }
1439        })
1440        .await
1441    }
1442
1443    async fn get_edge_including_deleted(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1444        let id_str = Uuid::from(id).to_string();
1445
1446        self.with_reader("get_edge_including_deleted", move |conn| {
1447            let mut stmt = conn.prepare(
1448                "SELECT namespace, id, source_id, target_id, relation, weight, \
1449                        created_at, updated_at, deleted_at, metadata, target_backend \
1450                 FROM graph_edges WHERE id = ?1",
1451            )?;
1452            let mut rows = stmt.query(rusqlite::params![id_str])?;
1453            match rows.next()? {
1454                Some(row) => Ok(Some(read_edge(row)?)),
1455                None => Ok(None),
1456            }
1457        })
1458        .await
1459    }
1460
1461    async fn edge_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1462        let id = id.to_string();
1463        self.with_reader("edge_sequence", move |conn| {
1464            conn.query_row(
1465                "SELECT seq FROM graph_edges_seq WHERE edge_id = ?1",
1466                rusqlite::params![id],
1467                |row| row.get(0),
1468            )
1469            .optional()
1470        })
1471        .await
1472    }
1473
1474    async fn edge_sequences(&self, ids: &[Uuid]) -> Result<Vec<(Uuid, i64)>, StorageError> {
1475        if ids.is_empty() {
1476            return Ok(Vec::new());
1477        }
1478        let ids = ids.to_vec();
1479        self.with_reader("edge_sequences", move |conn| {
1480            const CHUNK: usize = 900;
1481            let mut resolved = Vec::with_capacity(ids.len());
1482            for chunk in ids.chunks(CHUNK) {
1483                let placeholders = (1..=chunk.len())
1484                    .map(|index| format!("?{index}"))
1485                    .collect::<Vec<_>>()
1486                    .join(", ");
1487                let sql = format!(
1488                    "SELECT edge_id, seq FROM graph_edges_seq WHERE edge_id IN ({placeholders})"
1489                );
1490                let strings = chunk.iter().map(Uuid::to_string).collect::<Vec<_>>();
1491                let params = strings
1492                    .iter()
1493                    .map(|id| id as &dyn rusqlite::types::ToSql)
1494                    .collect::<Vec<_>>();
1495                let mut stmt = conn.prepare(&sql)?;
1496                let rows = stmt.query_map(params.as_slice(), |row| {
1497                    let id: String = row.get(0)?;
1498                    Ok((parse_uuid(&id)?, row.get::<_, i64>(1)?))
1499                })?;
1500                resolved.extend(rows.collect::<Result<Vec<_>, _>>()?);
1501            }
1502            Ok(resolved)
1503        })
1504        .await
1505    }
1506
1507    async fn get_edge_by_natural_key_including_deleted(
1508        &self,
1509        namespace: &str,
1510        source_id: Uuid,
1511        target_id: Uuid,
1512        relation: EdgeRelation,
1513    ) -> Result<Option<Edge>, StorageError> {
1514        let namespace = namespace.to_string();
1515        let source_str = source_id.to_string();
1516        let target_str = target_id.to_string();
1517        let relation_str = relation.to_string();
1518
1519        self.with_reader("get_edge_by_natural_key_including_deleted", move |conn| {
1520            let mut stmt = conn.prepare(
1521                "SELECT namespace, id, source_id, target_id, relation, weight, \
1522                        created_at, updated_at, deleted_at, metadata, target_backend \
1523                 FROM graph_edges \
1524                 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 AND relation = ?4",
1525            )?;
1526            let mut rows = stmt.query(rusqlite::params![
1527                namespace,
1528                source_str,
1529                target_str,
1530                relation_str
1531            ])?;
1532            match rows.next()? {
1533                Some(row) => Ok(Some(read_edge(row)?)),
1534                None => Ok(None),
1535            }
1536        })
1537        .await
1538    }
1539
1540    async fn get_edges(&self, ids: &[LinkId]) -> Result<Vec<Edge>, StorageError> {
1541        if ids.is_empty() {
1542            return Ok(Vec::new());
1543        }
1544        // SQLite SQLITE_MAX_VARIABLE_NUMBER defaults to 999; chunk at 900 to stay safe.
1545        const CHUNK: usize = 900;
1546        let id_strs: Vec<String> = ids.iter().map(|id| Uuid::from(*id).to_string()).collect();
1547
1548        let mut result: Vec<Edge> = Vec::with_capacity(ids.len());
1549        for chunk in id_strs.chunks(CHUNK) {
1550            let chunk_owned: Vec<String> = chunk.to_vec();
1551            let edges = self
1552                .with_reader("get_edges", move |conn| {
1553                    let placeholders: Vec<String> =
1554                        (1..=chunk_owned.len()).map(|i| format!("?{}", i)).collect();
1555                    let sql = format!(
1556                        "SELECT namespace, id, source_id, target_id, relation, weight, \
1557                                created_at, updated_at, deleted_at, metadata, target_backend \
1558                         FROM graph_edges WHERE id IN ({}) AND deleted_at IS NULL",
1559                        placeholders.join(",")
1560                    );
1561                    let mut stmt = conn.prepare(&sql)?;
1562                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
1563                        .iter()
1564                        .map(|s| s as &dyn rusqlite::types::ToSql)
1565                        .collect();
1566                    let rows = stmt.query_map(params.as_slice(), read_edge)?;
1567                    let mut edges = Vec::new();
1568                    for row in rows {
1569                        edges.push(row?);
1570                    }
1571                    Ok(edges)
1572                })
1573                .await?;
1574            result.extend(edges);
1575        }
1576        Ok(result)
1577    }
1578
1579    async fn batch_neighbors(
1580        &self,
1581        sources: &[Uuid],
1582        query: NeighborQuery,
1583    ) -> Result<Vec<(Uuid, NeighborHit)>, StorageError> {
1584        use khive_storage::types::Direction;
1585
1586        if sources.is_empty() {
1587            return Ok(Vec::new());
1588        }
1589        let mut seen_sources = HashSet::with_capacity(sources.len());
1590        let unique_sources: Vec<Uuid> = sources
1591            .iter()
1592            .copied()
1593            .filter(|source| seen_sources.insert(*source))
1594            .collect();
1595        const CHUNK_SIZE: usize = 880;
1596
1597        let namespace = self.namespace.clone();
1598        let mut result: Vec<(Uuid, NeighborHit)> = Vec::new();
1599        // Declared across the whole chunk loop, not per chunk: a failure in a
1600        // later chunk must not erase what the earlier chunks already did.
1601        let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
1602        let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
1603
1604        for chunk in unique_sources.chunks(CHUNK_SIZE) {
1605            let chunk_owned: Vec<Uuid> = chunk.to_vec();
1606            let query_clone = query.clone();
1607            let ns = namespace.clone();
1608            let closure_queries = Arc::clone(&counted_queries);
1609            let closure_rows = Arc::clone(&counted_rows);
1610
1611            let chunk_result = self
1612                .with_reader("batch_neighbors", move |conn| {
1613                    let src_strs: Vec<String> = chunk_owned.iter().map(|u| u.to_string()).collect();
1614
1615                    let sources_json = serde_json::to_string(&src_strs).map_err(|error| {
1616                        rusqlite::Error::ToSqlConversionFailure(Box::new(error))
1617                    })?;
1618
1619                    let build_inner_sql =
1620                        |direction_out: bool,
1621                         q: &NeighborQuery|
1622                         -> (String, Vec<String>, Option<f64>) {
1623                            let (filter_col, node_col) = if direction_out {
1624                                ("source_id", "target_id")
1625                            } else {
1626                                ("target_id", "source_id")
1627                            };
1628
1629                            let mut rel_params: Vec<String> = Vec::new();
1630                            let mut conditions: Vec<String> = Vec::new();
1631                            let mut param_idx = 3;
1632
1633                            if let Some(ref rels) = q.relations {
1634                                if !rels.is_empty() {
1635                                    let ps: Vec<String> = rels
1636                                        .iter()
1637                                        .map(|r| {
1638                                            rel_params.push(r.to_string());
1639                                            let p = format!("?{param_idx}");
1640                                            param_idx += 1;
1641                                            p
1642                                        })
1643                                        .collect();
1644                                    conditions
1645                                        .push(format!("edges.relation IN ({})", ps.join(",")));
1646                                }
1647                            }
1648
1649                            // min_weight is returned separately so it can be added to
1650                            // all_params AFTER the rel_params block, at the right index.
1651                            let min_weight_val = if let Some(min_w) = q.min_weight {
1652                                conditions.push(format!("edges.weight >= ?{param_idx}"));
1653                                Some(min_w)
1654                            } else {
1655                                None
1656                            };
1657
1658                            let where_extra = if conditions.is_empty() {
1659                                String::new()
1660                            } else {
1661                                format!(" AND {}", conditions.join(" AND "))
1662                            };
1663
1664                            let sql = format!(
1665                                "SELECT requested.origin_id, edges.{node_col} AS node_id, \
1666                                 edges.id AS edge_id, edges.relation, edges.weight \
1667                                 FROM requested CROSS JOIN graph_edges AS edges \
1668                                   ON edges.{filter_col} = requested.origin_id \
1669                                 WHERE edges.namespace = ?1 \
1670                                   AND edges.deleted_at IS NULL{where_extra}",
1671                            );
1672                            (sql, rel_params, min_weight_val)
1673                        };
1674
1675                    let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1676                    all_params.push(Box::new(ns.to_string()));
1677                    all_params.push(Box::new(sources_json));
1678
1679                    let (combined_inner, rel_params, min_weight_val) = match query_clone.direction {
1680                        Direction::Out => build_inner_sql(true, &query_clone),
1681                        Direction::In => build_inner_sql(false, &query_clone),
1682                        Direction::Both => {
1683                            let (out_sql, rel_params, min_weight_val) =
1684                                build_inner_sql(true, &query_clone);
1685                            let (in_sql, _, _) = build_inner_sql(false, &query_clone);
1686                            (
1687                                format!("{out_sql} UNION ALL {in_sql}"),
1688                                rel_params,
1689                                min_weight_val,
1690                            )
1691                        }
1692                    };
1693
1694                    for relation in rel_params {
1695                        all_params.push(Box::new(relation));
1696                    }
1697                    if let Some(min_weight) = min_weight_val {
1698                        all_params.push(Box::new(min_weight));
1699                    }
1700                    let limit_param_idx = all_params.len() + 1;
1701
1702                    // Wrap combined inner with per-source ROW_NUMBER limit if needed.
1703                    //
1704                    // Deterministic weight-descending order, tie-broken by node_id
1705                    // ascending, applied INSIDE the window's ORDER BY — otherwise a
1706                    // per-origin cap can silently drop high-weight neighbors in favor
1707                    // of arbitrary SQLite row order (mirrors neighbors(), ADR-089
1708                    // context-verb review; issue #589).
1709                    let full_sql = if let Some(lim) = query_clone.limit {
1710                        all_params.push(Box::new(lim as i64));
1711                        format!(
1712                            "WITH requested(origin_id) AS (\
1713                               SELECT value FROM json_each(?2)\
1714                             ) SELECT origin_id, node_id, edge_id, relation, weight \
1715                             FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY origin_id \
1716                                   ORDER BY weight DESC, node_id ASC) AS rn \
1717                                   FROM ({combined_inner})) WHERE rn <= ?{limit_param_idx}",
1718                        )
1719                    } else {
1720                        format!(
1721                            "WITH requested(origin_id) AS (\
1722                               SELECT value FROM json_each(?2)\
1723                             ) SELECT origin_id, node_id, edge_id, relation, weight \
1724                             FROM ({combined_inner})",
1725                        )
1726                    };
1727
1728                    let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1729                        all_params.iter().map(|p| p.as_ref()).collect();
1730
1731                    let mut stmt = conn.prepare(&full_sql)?;
1732                    closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1733                    let rows = stmt.query_map(param_refs.as_slice(), |row| {
1734                        let origin_str: String = row.get(0)?;
1735                        let nid_str: String = row.get(1)?;
1736                        let eid_str: String = row.get(2)?;
1737                        let relation_str: String = row.get(3)?;
1738                        let weight: f64 = row.get(4)?;
1739                        Ok((origin_str, nid_str, eid_str, relation_str, weight))
1740                    })?;
1741
1742                    let mut pairs = Vec::new();
1743                    for row in rows {
1744                        let (origin_str, nid_str, eid_str, relation_str, weight) = row?;
1745                        closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1746                        let origin = parse_uuid(&origin_str)?;
1747                        let node_id = parse_uuid(&nid_str)?;
1748                        let edge_id = parse_uuid(&eid_str)?;
1749                        let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1750                            rusqlite::Error::FromSqlConversionFailure(
1751                                3,
1752                                rusqlite::types::Type::Text,
1753                                Box::new(e),
1754                            )
1755                        })?;
1756                        pairs.push((
1757                            origin,
1758                            NeighborHit {
1759                                node_id,
1760                                edge_id,
1761                                relation,
1762                                weight,
1763                                name: None,
1764                                kind: None,
1765                                entity_type: None,
1766                            },
1767                        ));
1768                    }
1769                    Ok(pairs)
1770                })
1771                .await;
1772            let pairs = match chunk_result {
1773                Ok(pairs) => pairs,
1774                Err(e) => {
1775                    report_graph_usage(&counted_queries, &counted_rows);
1776                    return Err(e);
1777                }
1778            };
1779            result.extend(pairs);
1780        }
1781        report_graph_usage(&counted_queries, &counted_rows);
1782
1783        let requested: HashSet<Uuid> = unique_sources.iter().copied().collect();
1784        let mut grouped: HashMap<Uuid, Vec<NeighborHit>> =
1785            HashMap::with_capacity(unique_sources.len());
1786        for (origin, hit) in result {
1787            if !requested.contains(&origin) {
1788                return Err(StorageError::Internal(format!(
1789                    "batch_neighbors returned unrequested origin {origin}"
1790                )));
1791            }
1792            grouped.entry(origin).or_default().push(hit);
1793        }
1794
1795        for hits in grouped.values_mut() {
1796            hits.sort_by(|a, b| {
1797                b.weight
1798                    .partial_cmp(&a.weight)
1799                    .unwrap_or(std::cmp::Ordering::Equal)
1800                    .then(a.node_id.cmp(&b.node_id))
1801                    .then(a.edge_id.cmp(&b.edge_id))
1802            });
1803        }
1804
1805        let mut ordered = Vec::new();
1806        for &source in sources {
1807            if let Some(hits) = grouped.get(&source) {
1808                ordered.extend(hits.iter().cloned().map(|hit| (source, hit)));
1809            }
1810        }
1811        Ok(ordered)
1812    }
1813
1814    async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> Result<bool, StorageError> {
1815        let id = Uuid::from(id);
1816        let statement = match mode {
1817            DeleteMode::Soft => {
1818                edge_soft_delete_statement(id, chrono::Utc::now().timestamp_micros())
1819            }
1820            DeleteMode::Hard => edge_hard_delete_statement(id),
1821        };
1822        self.with_writer("delete_edge", move |conn| {
1823            let mut stmt = conn.prepare(&statement.sql)?;
1824            bind_params(&mut stmt, &statement.params)?;
1825            Ok(stmt.raw_execute()? > 0)
1826        })
1827        .await
1828    }
1829
1830    async fn query_edges(
1831        &self,
1832        filter: EdgeFilter,
1833        sort: Vec<SortOrder<EdgeSortField>>,
1834        page: PageRequest,
1835    ) -> Result<Page<Edge>, StorageError> {
1836        let namespace = self.namespace.clone();
1837        let limit_i64 = i64::from(page.limit);
1838        let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1839            capability: StorageCapability::Graph,
1840            operation: "query_edges".into(),
1841            message: format!(
1842                "PageRequest: offset must be <= i64::MAX, got {}",
1843                page.offset
1844            ),
1845        })?;
1846        self.with_reader("query_edges", move |conn| {
1847            let (where_clause, filter_params) = build_edge_filter_sql(&namespace, &filter);
1848
1849            let count_sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1850            let total: i64 = {
1851                let mut stmt = conn.prepare(&count_sql)?;
1852                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1853                    filter_params.iter().map(|p| p.as_ref()).collect();
1854                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
1855            };
1856
1857            let order_clause = if sort.is_empty() {
1858                " ORDER BY created_at DESC".to_string()
1859            } else {
1860                let parts: Vec<String> = sort
1861                    .iter()
1862                    .map(|s| {
1863                        let dir = match s.direction {
1864                            SortDirection::Asc => "ASC",
1865                            SortDirection::Desc => "DESC",
1866                        };
1867                        format!("{} {}", edge_sort_col(&s.field), dir)
1868                    })
1869                    .collect();
1870                format!(" ORDER BY {}", parts.join(", "))
1871            };
1872
1873            let (_, data_filter_params) = build_edge_filter_sql(&namespace, &filter);
1874            let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
1875            all_params.push(Box::new(limit_i64));
1876            all_params.push(Box::new(offset_i64));
1877
1878            let limit_idx = all_params.len() - 1;
1879            let offset_idx = all_params.len();
1880
1881            let data_sql = format!(
1882                "SELECT namespace, id, source_id, target_id, relation, weight, \
1883                        created_at, updated_at, deleted_at, metadata, target_backend \
1884                 FROM graph_edges{}{} LIMIT ?{} OFFSET ?{}",
1885                where_clause, order_clause, limit_idx, offset_idx,
1886            );
1887
1888            let mut stmt = conn.prepare(&data_sql)?;
1889            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1890                all_params.iter().map(|p| p.as_ref()).collect();
1891            let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
1892
1893            let mut items = Vec::new();
1894            for row in rows {
1895                items.push(row?);
1896            }
1897
1898            Ok(Page {
1899                items,
1900                total: Some(total as u64),
1901            })
1902        })
1903        .await
1904    }
1905
1906    async fn count_edges(&self, filter: EdgeFilter) -> Result<u64, StorageError> {
1907        let namespace = self.namespace.clone();
1908        self.with_reader("count_edges", move |conn| {
1909            let (where_clause, params) = build_edge_filter_sql(&namespace, &filter);
1910            let sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1911            let mut stmt = conn.prepare(&sql)?;
1912            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1913                params.iter().map(|p| p.as_ref()).collect();
1914            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1915            Ok(count as u64)
1916        })
1917        .await
1918    }
1919
1920    async fn count_edges_in_namespaces(
1921        &self,
1922        namespaces: &[String],
1923        filter: EdgeFilter,
1924    ) -> Result<u64, StorageError> {
1925        let namespaces: Vec<String> = namespaces
1926            .iter()
1927            .cloned()
1928            .collect::<HashSet<_>>()
1929            .into_iter()
1930            .collect();
1931        self.with_reader("count_edges_in_namespaces", move |conn| {
1932            let mut total = 0;
1933            for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1934                let (where_clause, params) = build_edge_filter_sql_for_namespaces(chunk, &filter);
1935                let sql = format!("SELECT COUNT(*) FROM graph_edges{where_clause}");
1936                let mut stmt = conn.prepare(&sql)?;
1937                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1938                    params.iter().map(|p| p.as_ref()).collect();
1939                let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1940                total += count as u64;
1941            }
1942            Ok(total)
1943        })
1944        .await
1945    }
1946
1947    async fn count_edges_by_relation(&self) -> Result<Vec<(EdgeRelation, u64)>, StorageError> {
1948        let namespace = self.namespace.clone();
1949        self.with_reader("count_edges_by_relation", move |conn| {
1950            let sql = "SELECT relation, COUNT(*) FROM graph_edges \
1951                       WHERE namespace = ?1 AND deleted_at IS NULL \
1952                       GROUP BY relation";
1953            let mut stmt = conn.prepare(sql)?;
1954            let rows = stmt.query_map([&namespace], |row| {
1955                let relation_str: String = row.get(0)?;
1956                let count: i64 = row.get(1)?;
1957                Ok((relation_str, count))
1958            })?;
1959            let mut out = Vec::new();
1960            for row in rows {
1961                let (relation_str, count) = row?;
1962                let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1963                    rusqlite::Error::FromSqlConversionFailure(
1964                        0,
1965                        rusqlite::types::Type::Text,
1966                        Box::new(e),
1967                    )
1968                })?;
1969                out.push((relation, count as u64));
1970            }
1971            Ok(out)
1972        })
1973        .await
1974    }
1975
1976    async fn count_edges_by_relation_in_namespaces(
1977        &self,
1978        namespaces: &[String],
1979    ) -> Result<Vec<(EdgeRelation, u64)>, StorageError> {
1980        let namespaces: Vec<String> = namespaces
1981            .iter()
1982            .cloned()
1983            .collect::<HashSet<_>>()
1984            .into_iter()
1985            .collect();
1986        self.with_reader("count_edges_by_relation_in_namespaces", move |conn| {
1987            let mut totals = HashMap::new();
1988            for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1989                let (where_clause, params) =
1990                    build_edge_filter_sql_for_namespaces(chunk, &EdgeFilter::default());
1991                let sql = format!(
1992                    "SELECT relation, COUNT(*) FROM graph_edges{where_clause} GROUP BY relation"
1993                );
1994                let mut stmt = conn.prepare(&sql)?;
1995                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1996                    params.iter().map(|p| p.as_ref()).collect();
1997                let rows = stmt.query_map(param_refs.as_slice(), |row| {
1998                    let relation_str: String = row.get(0)?;
1999                    let count: i64 = row.get(1)?;
2000                    Ok((relation_str, count))
2001                })?;
2002                for row in rows {
2003                    let (relation_str, count) = row?;
2004                    let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2005                        rusqlite::Error::FromSqlConversionFailure(
2006                            0,
2007                            rusqlite::types::Type::Text,
2008                            Box::new(e),
2009                        )
2010                    })?;
2011                    *totals.entry(relation).or_insert(0) += count as u64;
2012                }
2013            }
2014            Ok(totals.into_iter().collect())
2015        })
2016        .await
2017    }
2018
2019    async fn query_edges_after(
2020        &self,
2021        filter: EdgeFilter,
2022        after: Option<Uuid>,
2023        limit: u32,
2024    ) -> Result<EdgeSeekPage, StorageError> {
2025        let namespace = self.namespace.clone();
2026        let limit_usize = limit as usize;
2027        let probe_limit_i64 = i64::from(limit) + 1;
2028        self.with_reader("query_edges_after", move |conn| {
2029            let (mut where_clause, mut params) = build_edge_filter_sql(&namespace, &filter);
2030            if let Some(cursor) = after {
2031                params.push(Box::new(cursor.to_string()));
2032                where_clause.push_str(&format!(" AND id > ?{}", params.len()));
2033            }
2034            params.push(Box::new(probe_limit_i64));
2035            let limit_idx = params.len();
2036
2037            // `where_clause` always pins `namespace = ?1`; adding `id > ?N` here
2038            // keeps the predicate a range scan against the implicit unique index
2039            // backing `PRIMARY KEY (namespace, id)` — equality on the leading
2040            // column plus a range on the trailing one, with `ORDER BY id ASC`
2041            // matching the index order, so SQLite seeks instead of scanning.
2042            let data_sql = format!(
2043                "SELECT namespace, id, source_id, target_id, relation, weight, \
2044                        created_at, updated_at, deleted_at, metadata, target_backend \
2045                 FROM graph_edges{} ORDER BY id ASC LIMIT ?{}",
2046                where_clause, limit_idx,
2047            );
2048
2049            let mut stmt = conn.prepare(&data_sql)?;
2050            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2051                params.iter().map(|p| p.as_ref()).collect();
2052            let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
2053
2054            let mut items = Vec::new();
2055            for row in rows {
2056                items.push(row?);
2057            }
2058            let has_more = items.len() > limit_usize;
2059            if has_more {
2060                items.truncate(limit_usize);
2061            }
2062            let next_after = if has_more {
2063                items.last().map(|e| Uuid::from(e.id))
2064            } else {
2065                None
2066            };
2067
2068            Ok(EdgeSeekPage { items, next_after })
2069        })
2070        .await
2071    }
2072
2073    async fn query_edges_sequence_after(
2074        &self,
2075        filter: EdgeFilter,
2076        after: Option<SeekCursor>,
2077        limit: u32,
2078    ) -> Result<SeekPage<Edge>, StorageError> {
2079        if limit == 0 {
2080            return Ok(SeekPage::default());
2081        }
2082        let namespace = self.namespace.clone();
2083        let limit_usize = limit as usize;
2084        let probe_limit_i64 = i64::from(limit) + 1;
2085        self.with_reader("query_edges_sequence_after", move |conn| {
2086            let (mut where_clause, mut params) = build_edge_filter_sql(&namespace, &filter);
2087            if let Some(cursor) = after {
2088                params.push(Box::new(cursor.sequence));
2089                where_clause.push_str(&format!(" AND graph_edges_seq.seq > ?{}", params.len()));
2090            }
2091            params.push(Box::new(probe_limit_i64));
2092            let limit_idx = params.len();
2093            // CROSS JOIN fixes the ledger as the outer loop, preserving an
2094            // indexed `seq > boundary` scan with no full-match sort.
2095            let sql = format!(
2096                "SELECT namespace, id, source_id, target_id, relation, weight, \
2097                        created_at, updated_at, deleted_at, metadata, target_backend, \
2098                        graph_edges_seq.seq \
2099                 FROM graph_edges_seq CROSS JOIN graph_edges \
2100                    ON graph_edges.id = graph_edges_seq.edge_id{where_clause} \
2101                 ORDER BY graph_edges_seq.seq ASC LIMIT ?{limit_idx}"
2102            );
2103            let mut stmt = conn.prepare(&sql)?;
2104            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2105                params.iter().map(|param| param.as_ref()).collect();
2106            let rows = stmt.query_map(param_refs.as_slice(), |row| {
2107                Ok((read_edge(row)?, row.get::<_, i64>(11)?))
2108            })?;
2109            let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
2110            let has_more = entries.len() > limit_usize;
2111            if has_more {
2112                entries.truncate(limit_usize);
2113            }
2114            let next_after = if has_more {
2115                entries.last().map(|(edge, sequence)| SeekCursor {
2116                    sequence: *sequence,
2117                    id: Uuid::from(edge.id),
2118                })
2119            } else {
2120                None
2121            };
2122            let items = entries.into_iter().map(|(edge, _)| edge).collect();
2123            Ok(SeekPage { items, next_after })
2124        })
2125        .await
2126    }
2127
2128    async fn neighbors(
2129        &self,
2130        node_id: Uuid,
2131        query: NeighborQuery,
2132    ) -> Result<Vec<NeighborHit>, StorageError> {
2133        count_neighbor_select();
2134
2135        let namespace = self.namespace.clone();
2136        let node_str = node_id.to_string();
2137
2138        let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2139        let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2140        let closure_queries = Arc::clone(&counted_queries);
2141        let closure_rows = Arc::clone(&counted_rows);
2142        let result = self
2143            .with_reader("neighbors", move |conn| {
2144                let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight \
2145                            FROM graph_edges \
2146                            WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
2147                let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight \
2148                           FROM graph_edges \
2149                           WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
2150
2151                let sql = match query.direction {
2152                    Direction::Out => base_out.to_string(),
2153                    Direction::In => base_in.to_string(),
2154                    Direction::Both => format!("{} UNION ALL {}", base_out, base_in),
2155                };
2156
2157                let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
2158
2159                // Deterministic weight-descending order, tie-broken by node_id ascending,
2160                // applied BEFORE `LIMIT` — otherwise a `limit`/`fanout` cap can silently
2161                // drop high-weight neighbors in favor of arbitrary SQLite row order
2162                // (ADR-089 context-verb review).
2163                let full_sql = format!(
2164                    "SELECT node_id, edge_id, relation, weight FROM ({}){} \
2165                 ORDER BY weight DESC, node_id ASC{}",
2166                    sql, where_extra, limit_clause
2167                );
2168
2169                let mut stmt = conn.prepare(&full_sql)?;
2170
2171                let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2172                all_params.push(Box::new(namespace.clone()));
2173                all_params.push(Box::new(node_str.clone()));
2174                all_params.extend(extra_params);
2175
2176                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2177                    all_params.iter().map(|p| p.as_ref()).collect();
2178
2179                closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2180                let rows = stmt.query_map(param_refs.as_slice(), |row| {
2181                    let nid_str: String = row.get(0)?;
2182                    let eid_str: String = row.get(1)?;
2183                    let relation_str: String = row.get(2)?;
2184                    let weight: f64 = row.get(3)?;
2185                    Ok((nid_str, eid_str, relation_str, weight))
2186                })?;
2187
2188                let mut hits = Vec::new();
2189                for row in rows {
2190                    let (nid_str, eid_str, relation_str, weight) = row?;
2191                    closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2192                    let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2193                        rusqlite::Error::FromSqlConversionFailure(
2194                            2,
2195                            rusqlite::types::Type::Text,
2196                            Box::new(e),
2197                        )
2198                    })?;
2199                    hits.push(NeighborHit {
2200                        node_id: parse_uuid(&nid_str)?,
2201                        edge_id: parse_uuid(&eid_str)?,
2202                        relation,
2203                        weight,
2204                        name: None,
2205                        kind: None,
2206                        entity_type: None,
2207                    });
2208                }
2209
2210                Ok(hits)
2211            })
2212            .await;
2213
2214        report_graph_usage(&counted_queries, &counted_rows);
2215        result
2216    }
2217
2218    /// Single-query both-direction neighbor fetch (ADR-089 context-verb
2219    /// optimization): projects a `'out'`/`'in'` literal from each `UNION ALL`
2220    /// arm so the caller gets direction labels without a second direction-
2221    /// scoped round trip. `query.direction` is ignored — always both.
2222    async fn neighbors_both_directions(
2223        &self,
2224        node_id: Uuid,
2225        query: NeighborQuery,
2226    ) -> Result<Vec<DirectedNeighborHit>, StorageError> {
2227        count_neighbor_select();
2228
2229        let namespace = self.namespace.clone();
2230        let node_str = node_id.to_string();
2231
2232        let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2233        let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2234        let closure_queries = Arc::clone(&counted_queries);
2235        let closure_rows = Arc::clone(&counted_rows);
2236        let result = self
2237            .with_reader("neighbors_both_directions", move |conn| {
2238                let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight, \
2239                            'out' AS dir \
2240                            FROM graph_edges \
2241                            WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
2242                let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight, \
2243                           'in' AS dir \
2244                           FROM graph_edges \
2245                           WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
2246                let sql = format!("{} UNION ALL {}", base_out, base_in);
2247
2248                let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
2249
2250                // Same global weight-descending/node_id-ascending order as `neighbors`
2251                // (ADR-089 context-verb review),
2252                // applied across BOTH directions before `LIMIT` truncates. A
2253                // reciprocal pair (an Out edge and an In edge to/from the same
2254                // neighbor at the same weight) ties on `(weight, node_id)`, so the
2255                // order is extended with a direction rank (`out` before `in`) and
2256                // finally `edge_id` to make the pre-`LIMIT` order fully
2257                // deterministic).
2258                let full_sql = format!(
2259                    "SELECT node_id, edge_id, relation, weight, dir FROM ({}){} \
2260                 ORDER BY weight DESC, node_id ASC, \
2261                 CASE dir WHEN 'out' THEN 0 ELSE 1 END ASC, edge_id ASC{}",
2262                    sql, where_extra, limit_clause
2263                );
2264
2265                let mut stmt = conn.prepare(&full_sql)?;
2266
2267                let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2268                all_params.push(Box::new(namespace.clone()));
2269                all_params.push(Box::new(node_str.clone()));
2270                all_params.extend(extra_params);
2271
2272                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2273                    all_params.iter().map(|p| p.as_ref()).collect();
2274
2275                closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2276                let rows = stmt.query_map(param_refs.as_slice(), |row| {
2277                    let nid_str: String = row.get(0)?;
2278                    let eid_str: String = row.get(1)?;
2279                    let relation_str: String = row.get(2)?;
2280                    let weight: f64 = row.get(3)?;
2281                    let dir_str: String = row.get(4)?;
2282                    Ok((nid_str, eid_str, relation_str, weight, dir_str))
2283                })?;
2284
2285                let mut hits = Vec::new();
2286                for row in rows {
2287                    let (nid_str, eid_str, relation_str, weight, dir_str) = row?;
2288                    closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2289                    let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2290                        rusqlite::Error::FromSqlConversionFailure(
2291                            2,
2292                            rusqlite::types::Type::Text,
2293                            Box::new(e),
2294                        )
2295                    })?;
2296                    let direction = if dir_str == "out" {
2297                        Direction::Out
2298                    } else {
2299                        Direction::In
2300                    };
2301                    hits.push(DirectedNeighborHit {
2302                        hit: NeighborHit {
2303                            node_id: parse_uuid(&nid_str)?,
2304                            edge_id: parse_uuid(&eid_str)?,
2305                            relation,
2306                            weight,
2307                            name: None,
2308                            kind: None,
2309                            entity_type: None,
2310                        },
2311                        direction,
2312                    });
2313                }
2314
2315                Ok(hits)
2316            })
2317            .await;
2318
2319        report_graph_usage(&counted_queries, &counted_rows);
2320        result
2321    }
2322
2323    async fn traverse(&self, request: TraversalRequest) -> Result<Vec<GraphPath>, StorageError> {
2324        request
2325            .validate()
2326            .map_err(|message| StorageError::InvalidInput {
2327                capability: StorageCapability::Graph,
2328                operation: "traverse".into(),
2329                message,
2330            })?;
2331        if request.roots.is_empty() {
2332            return Ok(Vec::new());
2333        }
2334
2335        let mut distinct_roots = HashSet::with_capacity(request.roots.len());
2336        let roots = request
2337            .roots
2338            .iter()
2339            .copied()
2340            .filter(|root| distinct_roots.insert(*root))
2341            .collect::<Vec<_>>();
2342        let opts = request.options;
2343        let include_roots = request.include_roots;
2344        let namespace = self.namespace.clone();
2345        let origin = self.pool.origin();
2346        let budget = request.execution_budget;
2347        // Shared with the blocking closure so the counts survive an error.
2348        // `with_reader` runs the closure on a blocking thread where the
2349        // task-local usage context is invisible, so it cannot call
2350        // `usage::count` itself; returning the totals in the Ok value would
2351        // lose every round trip already issued when a later statement fails.
2352        let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2353        let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2354        let closure_rows = Arc::clone(&counted_rows);
2355        let closure_queries = Arc::clone(&counted_queries);
2356        let result = self
2357            .with_reader("traverse", move |conn| {
2358                Ok(run_bounded_traversal(
2359                    conn,
2360                    roots,
2361                    opts,
2362                    include_roots,
2363                    namespace,
2364                    origin,
2365                    budget,
2366                    closure_rows.as_ref(),
2367                    closure_queries.as_ref(),
2368                ))
2369            })
2370            .await
2371            .and_then(|inner| inner);
2372
2373        // Accounted on BOTH outcomes. `db_round_trips` counts round trips
2374        // *issued* and `graph_hops` counts adjacency rows storage *returned*,
2375        // so work already done before a later statement errors is real work and
2376        // must appear. Reading the shared counters here rather than off the
2377        // Ok value is what makes that possible: the closure runs on a blocking
2378        // thread where the task-local usage context is not visible, so it
2379        // cannot count for itself, and a value returned only on success
2380        // reports nothing at all when the traversal fails partway.
2381        khive_storage::usage::count(
2382            khive_storage::usage::UsageUnit::DbRoundTrips,
2383            counted_queries.load(std::sync::atomic::Ordering::Relaxed),
2384        );
2385        khive_storage::usage::count(
2386            khive_storage::usage::UsageUnit::GraphHops,
2387            counted_rows.load(std::sync::atomic::Ordering::Relaxed),
2388        );
2389
2390        result
2391    }
2392
2393    async fn purge_incident_edges(&self, node_id: Uuid) -> Result<u64, StorageError> {
2394        // No namespace filter: UUID v4 is globally unique. Hard-delete cascade must
2395        // remove ALL incident edges regardless of which namespace they were written in
2396        // (ADR-002 no-dangling-references, ADR-007 by-ID contract).
2397        let statement = purge_incident_edges_statement(node_id);
2398        self.with_writer("purge_incident_edges", move |conn| {
2399            let mut stmt = conn.prepare(&statement.sql)?;
2400            bind_params(&mut stmt, &statement.params)?;
2401            Ok(stmt.raw_execute()? as u64)
2402        })
2403        .await
2404    }
2405}
2406
2407// =============================================================================
2408// DDL
2409// =============================================================================
2410
2411const GRAPH_DDL: &str = include_str!("../../sql/graph-ddl.sql");
2412
2413pub(crate) fn ensure_graph_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2414    conn.execute_batch(GRAPH_DDL)
2415}
2416
2417#[cfg(test)]
2418#[path = "graph_tests.rs"]
2419mod tests;