Skip to main content

khive_db/stores/
graph.rs

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