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, Edge, EdgeFilter, EdgeSortField, GraphPath, NeighborHit,
12    NeighborQuery, Page, PageRequest, PathNode, SortDirection, SortOrder, TraversalRequest,
13};
14use khive_storage::GraphStore;
15use khive_storage::LinkId;
16use khive_storage::StorageCapability;
17use khive_types::EdgeRelation;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21
22/// Map a rusqlite error to `StorageError` with `Graph` capability.
23fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
24    StorageError::driver(StorageCapability::Graph, op, e)
25}
26
27fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
28    StorageError::driver(StorageCapability::Graph, op, e)
29}
30
31/// A GraphStore backed by SQLite tables.
32pub struct SqlGraphStore {
33    pool: Arc<ConnectionPool>,
34    is_file_backed: bool,
35    /// Default namespace for multi-record queries (ADR-007 PARAM-ONLY: used as a
36    /// WHERE filter on `query_edges`/`neighbors`/`traverse`, never as an
37    /// enforcement gate on by-ID operations).
38    namespace: String,
39}
40
41impl SqlGraphStore {
42    /// Create a new store with a default namespace for multi-record query filtering.
43    ///
44    /// The namespace is a PARAM-ONLY hint (ADR-007 rule 4) — it is used as a
45    /// WHERE filter in multi-record queries and as the write namespace stamped on
46    /// upserted edges, but it does NOT enforce isolation: `upsert_edge` accepts
47    /// edges from any namespace, and by-ID ops (`get_edge`, `delete_edge`) ignore
48    /// the namespace entirely.
49    pub fn new_scoped(
50        pool: Arc<ConnectionPool>,
51        is_file_backed: bool,
52        namespace: impl Into<String>,
53    ) -> Self {
54        Self {
55            pool,
56            is_file_backed,
57            namespace: namespace.into(),
58        }
59    }
60
61    fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
62        let config = self.pool.config();
63        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
64            operation: "graph_writer".into(),
65            message: "in-memory databases do not support standalone connections".into(),
66        })?;
67
68        let conn = rusqlite::Connection::open_with_flags(
69            path,
70            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
71                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
72                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
73        )
74        .map_err(|e| map_err(e, "open_graph_writer"))?;
75
76        conn.busy_timeout(config.busy_timeout)
77            .map_err(|e| map_err(e, "open_graph_writer"))?;
78        conn.pragma_update(None, "foreign_keys", "ON")
79            .map_err(|e| map_err(e, "open_graph_writer"))?;
80        conn.pragma_update(None, "synchronous", "NORMAL")
81            .map_err(|e| map_err(e, "open_graph_writer"))?;
82
83        Ok(conn)
84    }
85
86    fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
87        let config = self.pool.config();
88        let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
89            operation: "graph_reader".into(),
90            message: "in-memory databases do not support standalone connections".into(),
91        })?;
92
93        let conn = rusqlite::Connection::open_with_flags(
94            path,
95            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
96                | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
97                | rusqlite::OpenFlags::SQLITE_OPEN_URI,
98        )
99        .map_err(|e| map_err(e, "open_graph_reader"))?;
100
101        conn.busy_timeout(config.busy_timeout)
102            .map_err(|e| map_err(e, "open_graph_reader"))?;
103        conn.pragma_update(None, "foreign_keys", "ON")
104            .map_err(|e| map_err(e, "open_graph_reader"))?;
105        conn.pragma_update(None, "synchronous", "NORMAL")
106            .map_err(|e| map_err(e, "open_graph_reader"))?;
107
108        Ok(conn)
109    }
110
111    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
112    where
113        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
114        R: Send + 'static,
115    {
116        if self.is_file_backed {
117            let conn = self.open_standalone_writer()?;
118            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
119                .await
120                .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
121        } else {
122            let pool = Arc::clone(&self.pool);
123            tokio::task::spawn_blocking(move || {
124                let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
125                f(guard.conn()).map_err(|e| map_err(e, op))
126            })
127            .await
128            .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
129        }
130    }
131
132    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
133    where
134        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
135        R: Send + 'static,
136    {
137        if self.is_file_backed {
138            let conn = self.open_standalone_reader()?;
139            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
140                .await
141                .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
142        } else {
143            let pool = Arc::clone(&self.pool);
144            tokio::task::spawn_blocking(move || {
145                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
146                f(guard.conn()).map_err(|e| map_err(e, op))
147            })
148            .await
149            .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
150        }
151    }
152}
153
154// =============================================================================
155// Helpers
156// =============================================================================
157
158fn read_edge(row: &rusqlite::Row<'_>) -> Result<Edge, rusqlite::Error> {
159    let namespace: String = row.get(0)?;
160    let id_str: String = row.get(1)?;
161    let source_str: String = row.get(2)?;
162    let target_str: String = row.get(3)?;
163    let relation_str: String = row.get(4)?;
164    let weight: f64 = row.get(5)?;
165    let created_micros: i64 = row.get(6)?;
166    let updated_micros: i64 = row.get(7)?;
167    let deleted_micros: Option<i64> = row.get(8)?;
168    let metadata_str: Option<String> = row.get(9)?;
169    let target_backend: Option<String> = row.get(10)?;
170
171    let id = parse_uuid(&id_str)?;
172    let source_id = parse_uuid(&source_str)?;
173    let target_id = parse_uuid(&target_str)?;
174    let created_at = micros_to_datetime(created_micros);
175    let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
176        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
177    })?;
178    let metadata = match metadata_str {
179        Some(s) => {
180            let v = serde_json::from_str(&s).map_err(|e| {
181                rusqlite::Error::FromSqlConversionFailure(
182                    9,
183                    rusqlite::types::Type::Text,
184                    Box::new(e),
185                )
186            })?;
187            Some(v)
188        }
189        None => None,
190    };
191
192    Ok(Edge {
193        id: id.into(),
194        namespace,
195        source_id,
196        target_id,
197        relation,
198        weight,
199        created_at,
200        updated_at: micros_to_datetime(updated_micros),
201        deleted_at: deleted_micros.map(micros_to_datetime),
202        metadata,
203        target_backend,
204    })
205}
206
207fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
208    Uuid::parse_str(s).map_err(|e| {
209        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
210    })
211}
212
213fn micros_to_datetime(micros: i64) -> DateTime<Utc> {
214    Utc.timestamp_micros(micros)
215        .single()
216        .unwrap_or_else(Utc::now)
217}
218
219fn build_edge_filter_sql(
220    namespace: &str,
221    filter: &EdgeFilter,
222) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
223    let mut conditions: Vec<String> = vec![
224        "namespace = ?1".to_string(),
225        "deleted_at IS NULL".to_string(),
226    ];
227    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
228
229    if !filter.ids.is_empty() {
230        let placeholders: Vec<String> = filter
231            .ids
232            .iter()
233            .map(|id| {
234                params.push(Box::new(id.to_string()));
235                format!("?{}", params.len())
236            })
237            .collect();
238        conditions.push(format!("id IN ({})", placeholders.join(",")));
239    }
240
241    if !filter.source_ids.is_empty() {
242        let placeholders: Vec<String> = filter
243            .source_ids
244            .iter()
245            .map(|id| {
246                params.push(Box::new(id.to_string()));
247                format!("?{}", params.len())
248            })
249            .collect();
250        conditions.push(format!("source_id IN ({})", placeholders.join(",")));
251    }
252
253    if !filter.target_ids.is_empty() {
254        let placeholders: Vec<String> = filter
255            .target_ids
256            .iter()
257            .map(|id| {
258                params.push(Box::new(id.to_string()));
259                format!("?{}", params.len())
260            })
261            .collect();
262        conditions.push(format!("target_id IN ({})", placeholders.join(",")));
263    }
264
265    if !filter.relations.is_empty() {
266        let placeholders: Vec<String> = filter
267            .relations
268            .iter()
269            .map(|r| {
270                params.push(Box::new(r.to_string()));
271                format!("?{}", params.len())
272            })
273            .collect();
274        conditions.push(format!("relation IN ({})", placeholders.join(",")));
275    }
276
277    if let Some(min_w) = filter.min_weight {
278        params.push(Box::new(min_w));
279        conditions.push(format!("weight >= ?{}", params.len()));
280    }
281
282    if let Some(max_w) = filter.max_weight {
283        params.push(Box::new(max_w));
284        conditions.push(format!("weight <= ?{}", params.len()));
285    }
286
287    if let Some(ref time_range) = filter.created_at {
288        if let Some(start) = time_range.start {
289            params.push(Box::new(start.timestamp_micros()));
290            conditions.push(format!("created_at >= ?{}", params.len()));
291        }
292        if let Some(end) = time_range.end {
293            params.push(Box::new(end.timestamp_micros()));
294            conditions.push(format!("created_at < ?{}", params.len()));
295        }
296    }
297
298    let clause = format!(" WHERE {}", conditions.join(" AND "));
299    (clause, params)
300}
301
302fn edge_sort_col(field: &EdgeSortField) -> &'static str {
303    match field {
304        EdgeSortField::CreatedAt => "created_at",
305        EdgeSortField::Weight => "weight",
306        EdgeSortField::Relation => "relation",
307    }
308}
309
310// =============================================================================
311// GraphStore implementation
312// =============================================================================
313
314/// Canonical endpoint order for symmetric relations (F012).
315///
316/// For `competes_with` and `composed_with`, ensures `source_uuid < target_uuid`
317/// so A→B and B→A collapse to a single canonical row in storage.
318fn canonical_edge_endpoints(
319    relation: EdgeRelation,
320    source_id: Uuid,
321    target_id: Uuid,
322) -> (Uuid, Uuid) {
323    if relation.is_symmetric() && target_id < source_id {
324        (target_id, source_id)
325    } else {
326        (source_id, target_id)
327    }
328}
329
330#[async_trait]
331impl GraphStore for SqlGraphStore {
332    async fn upsert_edge(&self, edge: Edge) -> Result<(), StorageError> {
333        let namespace = edge.namespace.clone();
334        let id_str = Uuid::from(edge.id).to_string();
335        let (source_id, target_id) =
336            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
337        let src_str = source_id.to_string();
338        let tgt_str = target_id.to_string();
339        let relation_str = edge.relation.to_string();
340        let metadata_str = edge
341            .metadata
342            .as_ref()
343            .map(serde_json::to_string)
344            .transpose()
345            .map_err(|e| StorageError::driver(StorageCapability::Graph, "upsert_edge", e))?;
346        self.with_writer("upsert_edge", move |conn| {
347            conn.execute(
348                "INSERT INTO graph_edges \
349                 (namespace, id, source_id, target_id, relation, weight, \
350                  created_at, updated_at, deleted_at, metadata, target_backend) \
351                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
352                 ON CONFLICT(namespace, id) DO UPDATE SET \
353                     source_id = excluded.source_id, \
354                     target_id = excluded.target_id, \
355                     relation = excluded.relation, \
356                     weight = excluded.weight, \
357                     updated_at = excluded.updated_at, \
358                     deleted_at = NULL, \
359                     metadata = excluded.metadata, \
360                     target_backend = excluded.target_backend \
361                 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
362                     weight = excluded.weight, \
363                     updated_at = excluded.updated_at, \
364                     deleted_at = NULL, \
365                     metadata = excluded.metadata, \
366                     target_backend = excluded.target_backend",
367                rusqlite::params![
368                    namespace,
369                    id_str,
370                    src_str,
371                    tgt_str,
372                    relation_str,
373                    edge.weight,
374                    edge.created_at.timestamp_micros(),
375                    edge.updated_at.timestamp_micros(),
376                    edge.deleted_at.map(|t| t.timestamp_micros()),
377                    metadata_str,
378                    edge.target_backend,
379                ],
380            )?;
381            Ok(())
382        })
383        .await
384    }
385
386    async fn upsert_edges(&self, edges: Vec<Edge>) -> Result<BatchWriteSummary, StorageError> {
387        let attempted = edges.len() as u64;
388
389        self.with_writer("upsert_edges", move |conn| {
390            conn.execute_batch("BEGIN IMMEDIATE")?;
391            let mut affected = 0u64;
392
393            for edge in &edges {
394                let id_str = Uuid::from(edge.id).to_string();
395                let (canon_src, canon_tgt) =
396                    canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
397                let src_str = canon_src.to_string();
398                let tgt_str = canon_tgt.to_string();
399                let relation_str = edge.relation.to_string();
400                let metadata_str = edge
401                    .metadata
402                    .as_ref()
403                    .map(serde_json::to_string)
404                    .transpose()
405                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
406                if let Err(e) = conn.execute(
407                    "INSERT INTO graph_edges \
408                     (namespace, id, source_id, target_id, relation, weight, \
409                      created_at, updated_at, deleted_at, metadata, target_backend) \
410                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
411                     ON CONFLICT(namespace, id) DO UPDATE SET \
412                         source_id = excluded.source_id, \
413                         target_id = excluded.target_id, \
414                         relation = excluded.relation, \
415                         weight = excluded.weight, \
416                         updated_at = excluded.updated_at, \
417                         deleted_at = NULL, \
418                         metadata = excluded.metadata, \
419                         target_backend = excluded.target_backend \
420                     ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
421                         weight = excluded.weight, \
422                         updated_at = excluded.updated_at, \
423                         deleted_at = NULL, \
424                         metadata = excluded.metadata, \
425                         target_backend = excluded.target_backend",
426                    rusqlite::params![
427                        edge.namespace.as_str(),
428                        id_str,
429                        src_str,
430                        tgt_str,
431                        relation_str,
432                        edge.weight,
433                        edge.created_at.timestamp_micros(),
434                        edge.updated_at.timestamp_micros(),
435                        edge.deleted_at.map(|t| t.timestamp_micros()),
436                        metadata_str,
437                        edge.target_backend.as_deref(),
438                    ],
439                ) {
440                    let _ = conn.execute_batch("ROLLBACK");
441                    return Err(e);
442                }
443                affected += 1;
444            }
445
446            if let Err(e) = conn.execute_batch("COMMIT") {
447                let _ = conn.execute_batch("ROLLBACK");
448                return Err(e);
449            }
450            Ok(BatchWriteSummary {
451                attempted,
452                affected,
453                failed: 0,
454                first_error: String::new(),
455            })
456        })
457        .await
458    }
459
460    async fn get_edge(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
461        let id_str = Uuid::from(id).to_string();
462
463        self.with_reader("get_edge", move |conn| {
464            let mut stmt = conn.prepare(
465                "SELECT namespace, id, source_id, target_id, relation, weight, \
466                        created_at, updated_at, deleted_at, metadata, target_backend \
467                 FROM graph_edges WHERE id = ?1 AND deleted_at IS NULL",
468            )?;
469            let mut rows = stmt.query(rusqlite::params![id_str])?;
470            match rows.next()? {
471                Some(row) => Ok(Some(read_edge(row)?)),
472                None => Ok(None),
473            }
474        })
475        .await
476    }
477
478    async fn get_edge_including_deleted(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
479        let id_str = Uuid::from(id).to_string();
480
481        self.with_reader("get_edge_including_deleted", move |conn| {
482            let mut stmt = conn.prepare(
483                "SELECT namespace, id, source_id, target_id, relation, weight, \
484                        created_at, updated_at, deleted_at, metadata, target_backend \
485                 FROM graph_edges WHERE id = ?1",
486            )?;
487            let mut rows = stmt.query(rusqlite::params![id_str])?;
488            match rows.next()? {
489                Some(row) => Ok(Some(read_edge(row)?)),
490                None => Ok(None),
491            }
492        })
493        .await
494    }
495
496    async fn get_edges(&self, ids: &[LinkId]) -> Result<Vec<Edge>, StorageError> {
497        if ids.is_empty() {
498            return Ok(Vec::new());
499        }
500        // SQLite SQLITE_MAX_VARIABLE_NUMBER defaults to 999; chunk at 900 to stay safe.
501        const CHUNK: usize = 900;
502        let id_strs: Vec<String> = ids.iter().map(|id| Uuid::from(*id).to_string()).collect();
503
504        let mut result: Vec<Edge> = Vec::with_capacity(ids.len());
505        for chunk in id_strs.chunks(CHUNK) {
506            let chunk_owned: Vec<String> = chunk.to_vec();
507            let edges = self
508                .with_reader("get_edges", move |conn| {
509                    let placeholders: Vec<String> =
510                        (1..=chunk_owned.len()).map(|i| format!("?{}", i)).collect();
511                    let sql = format!(
512                        "SELECT namespace, id, source_id, target_id, relation, weight, \
513                                created_at, updated_at, deleted_at, metadata, target_backend \
514                         FROM graph_edges WHERE id IN ({}) AND deleted_at IS NULL",
515                        placeholders.join(",")
516                    );
517                    let mut stmt = conn.prepare(&sql)?;
518                    let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
519                        .iter()
520                        .map(|s| s as &dyn rusqlite::types::ToSql)
521                        .collect();
522                    let rows = stmt.query_map(params.as_slice(), read_edge)?;
523                    let mut edges = Vec::new();
524                    for row in rows {
525                        edges.push(row?);
526                    }
527                    Ok(edges)
528                })
529                .await?;
530            result.extend(edges);
531        }
532        Ok(result)
533    }
534
535    async fn batch_neighbors(
536        &self,
537        sources: &[Uuid],
538        query: NeighborQuery,
539    ) -> Result<Vec<(Uuid, NeighborHit)>, StorageError> {
540        use khive_storage::types::Direction;
541
542        if sources.is_empty() {
543            return Ok(Vec::new());
544        }
545        // Compute a per-call chunk size that keeps the total bound parameter count
546        // safely under SQLITE_MAX_VARIABLE_NUMBER (999).
547        //
548        // Variable budget:
549        //   1  = namespace (?1, shared across all halves of a UNION ALL)
550        //   1  = limit (optional, worst-case reserve)
551        //   halves × (src_count + per_half_filter) = the IN-list + filter params
552        //
553        // For Direction::Both the UNION ALL doubles the source IN-list and filter
554        // params (each half is a fully independent positional-parameter block).
555        // For Out/In there is only one half.
556        //
557        // We target 950 total to leave a comfortable margin below 999, then cap at
558        // 880 to preserve the existing ceiling for the single-direction common case.
559        let per_half_filter =
560            query.relations.as_ref().map_or(0, |r| r.len()) + query.min_weight.is_some() as usize;
561        let halves: usize = if query.direction == Direction::Both {
562            2
563        } else {
564            1
565        };
566        let fixed = 1 /*ns*/ + 1 /*limit*/ + halves * per_half_filter;
567        let max_src = (950usize.saturating_sub(fixed) / halves).max(1);
568        let chunk_size = max_src.min(880);
569
570        let namespace = self.namespace.clone();
571        let mut result: Vec<(Uuid, NeighborHit)> = Vec::new();
572
573        for chunk in sources.chunks(chunk_size) {
574            let chunk_owned: Vec<Uuid> = chunk.to_vec();
575            let query_clone = query.clone();
576            let ns = namespace.clone();
577
578            let pairs = self
579                .with_reader("batch_neighbors", move |conn| {
580                    let src_strs: Vec<String> = chunk_owned.iter().map(|u| u.to_string()).collect();
581
582                    // Build the inner SELECT for one direction, using positional
583                    // params starting at `first_src_param` for the source IN-list.
584                    // Returns (sql_fragment, extra_param_values) where extra_param_values
585                    // covers relations and min_weight filters only (NOT the limit).
586                    let build_inner_sql =
587                        |direction_out: bool,
588                         first_src_param: usize,
589                         q: &NeighborQuery|
590                         -> (String, Vec<String>, Option<f64>) {
591                            let placeholders: Vec<String> = (first_src_param
592                                ..first_src_param + src_strs.len())
593                                .map(|i| format!("?{i}"))
594                                .collect();
595                            let in_list = placeholders.join(",");
596
597                            let (origin_col, filter_col, node_col) = if direction_out {
598                                ("source_id", "source_id", "target_id")
599                            } else {
600                                ("target_id", "target_id", "source_id")
601                            };
602
603                            let mut rel_params: Vec<String> = Vec::new();
604                            let mut conditions: Vec<String> = Vec::new();
605                            let mut param_idx = first_src_param + src_strs.len();
606
607                            if let Some(ref rels) = q.relations {
608                                if !rels.is_empty() {
609                                    let ps: Vec<String> = rels
610                                        .iter()
611                                        .map(|r| {
612                                            rel_params.push(r.to_string());
613                                            let p = format!("?{param_idx}");
614                                            param_idx += 1;
615                                            p
616                                        })
617                                        .collect();
618                                    conditions.push(format!("relation IN ({})", ps.join(",")));
619                                }
620                            }
621
622                            // min_weight is returned separately so it can be added to
623                            // all_params AFTER the rel_params block, at the right index.
624                            let min_weight_val = if let Some(min_w) = q.min_weight {
625                                conditions.push(format!("weight >= ?{param_idx}"));
626                                Some(min_w)
627                            } else {
628                                None
629                            };
630
631                            let where_extra = if conditions.is_empty() {
632                                String::new()
633                            } else {
634                                format!(" AND {}", conditions.join(" AND "))
635                            };
636
637                            let sql = format!(
638                                "SELECT {origin_col} AS origin_id, {node_col} AS node_id, \
639                             id AS edge_id, relation, weight \
640                             FROM graph_edges \
641                             WHERE namespace = ?1 AND {filter_col} IN ({in_list}) \
642                               AND deleted_at IS NULL{where_extra}",
643                            );
644                            (sql, rel_params, min_weight_val)
645                        };
646
647                    // For Direction::Both we need to build a UNION ALL of both inner
648                    // selects and then apply the per-source ROW_NUMBER limit ONCE over
649                    // the combined set.  This matches the single-source neighbors()
650                    // behaviour where Both uses a single UNION ALL + one outer LIMIT.
651                    //
652                    // Param layout:
653                    //   Out/In:  ?1=ns  ?2..?N+1=srcs  ?extras...  [?limit]
654                    //   Both:    ?1=ns  ?2..?N+1=out_srcs  out_extras...
655                    //                   ?M..?M+N=in_srcs   in_extras...  [?limit]
656                    //
657                    // `build_inner_sql` receives `first_src_param` so it generates the
658                    // correct placeholder indices for each half.
659
660                    let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
661                    all_params.push(Box::new(ns.to_string())); // ?1
662
663                    let combined_inner: String;
664                    let limit_param_idx: usize;
665
666                    match query_clone.direction {
667                        Direction::Out | Direction::In => {
668                            let direction_out = matches!(query_clone.direction, Direction::Out);
669                            let (sql, rel_params, min_weight_val) =
670                                build_inner_sql(direction_out, 2, &query_clone);
671                            combined_inner = sql;
672
673                            // Bind: ?1=ns (done), ?2..?N+1=srcs, rel_params, [min_weight]
674                            for s in &src_strs {
675                                all_params.push(Box::new(s.clone()));
676                            }
677                            for r in rel_params {
678                                all_params.push(Box::new(r));
679                            }
680                            if let Some(mw) = min_weight_val {
681                                all_params.push(Box::new(mw));
682                            }
683                            limit_param_idx = all_params.len() + 1;
684                        }
685                        Direction::Both => {
686                            // Out half: src params at ?2..?N+1
687                            let (out_sql, out_rels, out_mw) =
688                                build_inner_sql(true, 2, &query_clone);
689                            let after_out_srcs = 2 + src_strs.len();
690                            let after_out_rels = after_out_srcs + out_rels.len();
691                            let after_out_mw =
692                                after_out_rels + if out_mw.is_some() { 1 } else { 0 };
693                            let in_first = after_out_mw;
694
695                            // In half: src params start at `in_first`
696                            let (in_sql, in_rels, in_mw) =
697                                build_inner_sql(false, in_first, &query_clone);
698
699                            combined_inner = format!("{out_sql} UNION ALL {in_sql}");
700
701                            // Bind layout: ns | out_srcs | out_rels | [out_mw] | in_srcs | in_rels | [in_mw]
702                            for s in &src_strs {
703                                all_params.push(Box::new(s.clone())); // out sources
704                            }
705                            for r in out_rels {
706                                all_params.push(Box::new(r));
707                            }
708                            if let Some(mw) = out_mw {
709                                all_params.push(Box::new(mw));
710                            }
711                            for s in &src_strs {
712                                all_params.push(Box::new(s.clone())); // in sources
713                            }
714                            for r in in_rels {
715                                all_params.push(Box::new(r));
716                            }
717                            if let Some(mw) = in_mw {
718                                all_params.push(Box::new(mw));
719                            }
720                            limit_param_idx = all_params.len() + 1;
721                        }
722                    }
723
724                    // Wrap combined inner with per-source ROW_NUMBER limit if needed.
725                    let full_sql = if let Some(lim) = query_clone.limit {
726                        all_params.push(Box::new(lim as i64));
727                        format!(
728                            "SELECT origin_id, node_id, edge_id, relation, weight \
729                             FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY origin_id) AS rn \
730                                   FROM ({combined_inner})) WHERE rn <= ?{limit_param_idx}",
731                        )
732                    } else {
733                        format!(
734                            "SELECT origin_id, node_id, edge_id, relation, weight \
735                             FROM ({combined_inner})",
736                        )
737                    };
738
739                    let param_refs: Vec<&dyn rusqlite::types::ToSql> =
740                        all_params.iter().map(|p| p.as_ref()).collect();
741
742                    let mut stmt = conn.prepare(&full_sql)?;
743                    let rows = stmt.query_map(param_refs.as_slice(), |row| {
744                        let origin_str: String = row.get(0)?;
745                        let nid_str: String = row.get(1)?;
746                        let eid_str: String = row.get(2)?;
747                        let relation_str: String = row.get(3)?;
748                        let weight: f64 = row.get(4)?;
749                        Ok((origin_str, nid_str, eid_str, relation_str, weight))
750                    })?;
751
752                    let mut pairs = Vec::new();
753                    for row in rows {
754                        let (origin_str, nid_str, eid_str, relation_str, weight) = row?;
755                        let origin = parse_uuid(&origin_str)?;
756                        let node_id = parse_uuid(&nid_str)?;
757                        let edge_id = parse_uuid(&eid_str)?;
758                        let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
759                            rusqlite::Error::FromSqlConversionFailure(
760                                3,
761                                rusqlite::types::Type::Text,
762                                Box::new(e),
763                            )
764                        })?;
765                        pairs.push((
766                            origin,
767                            NeighborHit {
768                                node_id,
769                                edge_id,
770                                relation,
771                                weight,
772                                name: None,
773                                kind: None,
774                                entity_type: None,
775                            },
776                        ));
777                    }
778                    Ok(pairs)
779                })
780                .await?;
781            result.extend(pairs);
782        }
783        Ok(result)
784    }
785
786    async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> Result<bool, StorageError> {
787        let id_str = Uuid::from(id).to_string();
788
789        self.with_writer("delete_edge", move |conn| {
790            let affected = match mode {
791                DeleteMode::Soft => conn.execute(
792                    "UPDATE graph_edges SET deleted_at = ?2, updated_at = ?2 \
793                     WHERE id = ?1 AND deleted_at IS NULL",
794                    rusqlite::params![id_str, chrono::Utc::now().timestamp_micros()],
795                )?,
796                DeleteMode::Hard => conn.execute(
797                    "DELETE FROM graph_edges WHERE id = ?1",
798                    rusqlite::params![id_str],
799                )?,
800            };
801            Ok(affected > 0)
802        })
803        .await
804    }
805
806    async fn query_edges(
807        &self,
808        filter: EdgeFilter,
809        sort: Vec<SortOrder<EdgeSortField>>,
810        page: PageRequest,
811    ) -> Result<Page<Edge>, StorageError> {
812        let namespace = self.namespace.clone();
813        self.with_reader("query_edges", move |conn| {
814            let (where_clause, filter_params) = build_edge_filter_sql(&namespace, &filter);
815
816            let count_sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
817            let total: i64 = {
818                let mut stmt = conn.prepare(&count_sql)?;
819                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
820                    filter_params.iter().map(|p| p.as_ref()).collect();
821                stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
822            };
823
824            let order_clause = if sort.is_empty() {
825                " ORDER BY created_at DESC".to_string()
826            } else {
827                let parts: Vec<String> = sort
828                    .iter()
829                    .map(|s| {
830                        let dir = match s.direction {
831                            SortDirection::Asc => "ASC",
832                            SortDirection::Desc => "DESC",
833                        };
834                        format!("{} {}", edge_sort_col(&s.field), dir)
835                    })
836                    .collect();
837                format!(" ORDER BY {}", parts.join(", "))
838            };
839
840            let (_, data_filter_params) = build_edge_filter_sql(&namespace, &filter);
841            let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
842            all_params.push(Box::new(page.limit as i64));
843            all_params.push(Box::new(page.offset as i64));
844
845            let limit_idx = all_params.len() - 1;
846            let offset_idx = all_params.len();
847
848            let data_sql = format!(
849                "SELECT namespace, id, source_id, target_id, relation, weight, \
850                        created_at, updated_at, deleted_at, metadata, target_backend \
851                 FROM graph_edges{}{} LIMIT ?{} OFFSET ?{}",
852                where_clause, order_clause, limit_idx, offset_idx,
853            );
854
855            let mut stmt = conn.prepare(&data_sql)?;
856            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
857                all_params.iter().map(|p| p.as_ref()).collect();
858            let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
859
860            let mut items = Vec::new();
861            for row in rows {
862                items.push(row?);
863            }
864
865            Ok(Page {
866                items,
867                total: Some(total as u64),
868            })
869        })
870        .await
871    }
872
873    async fn count_edges(&self, filter: EdgeFilter) -> Result<u64, StorageError> {
874        let namespace = self.namespace.clone();
875        self.with_reader("count_edges", move |conn| {
876            let (where_clause, params) = build_edge_filter_sql(&namespace, &filter);
877            let sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
878            let mut stmt = conn.prepare(&sql)?;
879            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
880                params.iter().map(|p| p.as_ref()).collect();
881            let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
882            Ok(count as u64)
883        })
884        .await
885    }
886
887    async fn neighbors(
888        &self,
889        node_id: Uuid,
890        query: NeighborQuery,
891    ) -> Result<Vec<NeighborHit>, StorageError> {
892        use khive_storage::types::Direction;
893
894        let namespace = self.namespace.clone();
895        let node_str = node_id.to_string();
896
897        self.with_reader("neighbors", move |conn| {
898            let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight \
899                            FROM graph_edges \
900                            WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
901            let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight \
902                           FROM graph_edges \
903                           WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
904
905            let sql = match query.direction {
906                Direction::Out => base_out.to_string(),
907                Direction::In => base_in.to_string(),
908                Direction::Both => format!("{} UNION ALL {}", base_out, base_in),
909            };
910
911            let mut conditions: Vec<String> = Vec::new();
912            let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
913            let mut param_idx = 3;
914
915            if let Some(ref rels) = query.relations {
916                if !rels.is_empty() {
917                    let placeholders: Vec<String> = rels
918                        .iter()
919                        .map(|r| {
920                            extra_params.push(Box::new(r.to_string()));
921                            let p = format!("?{}", param_idx);
922                            param_idx += 1;
923                            p
924                        })
925                        .collect();
926                    conditions.push(format!("relation IN ({})", placeholders.join(",")));
927                }
928            }
929
930            if let Some(min_w) = query.min_weight {
931                extra_params.push(Box::new(min_w));
932                conditions.push(format!("weight >= ?{}", param_idx));
933                param_idx += 1;
934            }
935
936            let where_extra = if conditions.is_empty() {
937                String::new()
938            } else {
939                format!(" WHERE {}", conditions.join(" AND "))
940            };
941
942            let limit_clause = if let Some(lim) = query.limit {
943                extra_params.push(Box::new(lim as i64));
944                format!(" LIMIT ?{}", param_idx)
945            } else {
946                String::new()
947            };
948
949            let full_sql = format!(
950                "SELECT node_id, edge_id, relation, weight FROM ({}){}{}",
951                sql, where_extra, limit_clause
952            );
953
954            let mut stmt = conn.prepare(&full_sql)?;
955
956            let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
957            all_params.push(Box::new(namespace.clone()));
958            all_params.push(Box::new(node_str.clone()));
959            all_params.extend(extra_params);
960
961            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
962                all_params.iter().map(|p| p.as_ref()).collect();
963
964            let rows = stmt.query_map(param_refs.as_slice(), |row| {
965                let nid_str: String = row.get(0)?;
966                let eid_str: String = row.get(1)?;
967                let relation_str: String = row.get(2)?;
968                let weight: f64 = row.get(3)?;
969                Ok((nid_str, eid_str, relation_str, weight))
970            })?;
971
972            let mut hits = Vec::new();
973            for row in rows {
974                let (nid_str, eid_str, relation_str, weight) = row?;
975                let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
976                    rusqlite::Error::FromSqlConversionFailure(
977                        2,
978                        rusqlite::types::Type::Text,
979                        Box::new(e),
980                    )
981                })?;
982                hits.push(NeighborHit {
983                    node_id: parse_uuid(&nid_str)?,
984                    edge_id: parse_uuid(&eid_str)?,
985                    relation,
986                    weight,
987                    name: None,
988                    kind: None,
989                    entity_type: None,
990                });
991            }
992
993            Ok(hits)
994        })
995        .await
996    }
997
998    async fn traverse(&self, request: TraversalRequest) -> Result<Vec<GraphPath>, StorageError> {
999        use std::collections::{HashMap, HashSet};
1000
1001        use khive_storage::types::Direction;
1002
1003        if request.roots.is_empty() {
1004            return Ok(Vec::new());
1005        }
1006
1007        let roots = request.roots.clone();
1008        let opts = request.options.clone();
1009        let include_roots = request.include_roots;
1010        let namespace = self.namespace.clone();
1011
1012        self.with_reader("traverse", move |conn| {
1013            // Two SQLite limits apply to the seed VALUES clause:
1014            //
1015            //   1. SQLITE_LIMIT_COMPOUND_SELECT (default 500): SQLite counts each row in a
1016            //      VALUES list as one term in a compound SELECT.  Exceeding it gives
1017            //      "too many terms in compound SELECT".
1018            //
1019            //   2. SQLITE_LIMIT_VARIABLE_NUMBER (default 999): each root binds one parameter
1020            //      (referenced 3× in its seed row but counted once).  Fixed overhead —
1021            //      namespace, depth, optional relation/weight params — adds ~20 at most.
1022            //
1023            // 400 rows stays safely below both: 400 < 500 (compound) and
1024            // 400 + fixed << 999 (variables).
1025            const CHUNK_ROOTS: usize = 400;
1026
1027            // Determine join direction (invariant across chunks).
1028            let (join_condition, next_node) = match opts.direction {
1029                Direction::Out => ("e.source_id = t.node_id", "e.target_id"),
1030                Direction::In => ("e.target_id = t.node_id", "e.source_id"),
1031                Direction::Both => (
1032                    "(e.source_id = t.node_id OR e.target_id = t.node_id)",
1033                    "CASE WHEN e.source_id = t.node_id THEN e.target_id ELSE e.source_id END",
1034                ),
1035            };
1036
1037            // Open a deferred read transaction so ALL chunk queries observe the same
1038            // graph snapshot.  Without this, a writer committing between chunks could
1039            // let roots 1..400 see the pre-commit graph and 401..800 see the post-commit
1040            // graph.  One pool checkout, one snapshot for the full traverse.
1041            let tx = conn.unchecked_transaction()?;
1042
1043            // Accumulate per-root state across all chunks: (nodes_with_path_weight, seen_set).
1044            // Each entry carries the PathNode and its cumulative path weight from the SQL row,
1045            // so the Rust-level per-root limit truncation can compute an accurate max_weight
1046            // over the kept nodes.
1047            let mut root_data: HashMap<Uuid, (Vec<(PathNode, f64)>, HashSet<Uuid>)> =
1048                HashMap::with_capacity(roots.len());
1049
1050            // Pre-seed with root nodes when include_roots is set (done once for all roots).
1051            for root_id in &roots {
1052                let (nodes, seen) = root_data.entry(*root_id).or_default();
1053                if include_roots {
1054                    seen.insert(*root_id);
1055                    nodes.push((
1056                        PathNode {
1057                            node_id: *root_id,
1058                            via_edge: None,
1059                            depth: 0,
1060                            name: None,
1061                            kind: None,
1062                            properties: None,
1063                        },
1064                        0.0,
1065                    ));
1066                }
1067            }
1068
1069            for chunk in roots.chunks(CHUNK_ROOTS) {
1070                let n_chunk = chunk.len();
1071
1072                // Param layout (per-chunk, not total):
1073                //   ?1 .. ?{n_chunk}     — root UUID strings (each used 3× in seed row)
1074                //   ?{n_chunk + 1}       — namespace
1075                //   ?{n_chunk + 2}       — max_depth
1076                //   ?{n_chunk + 3} ..    — optional relation / weight params
1077                let ns_param = n_chunk + 1;
1078                let depth_param = n_chunk + 2;
1079                let mut extra_param_idx = n_chunk + 3;
1080
1081                let mut relation_cond = String::new();
1082                let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1083
1084                if let Some(ref rels) = opts.relations {
1085                    if !rels.is_empty() {
1086                        let placeholders: Vec<String> = rels
1087                            .iter()
1088                            .map(|r| {
1089                                extra_params.push(Box::new(r.to_string()));
1090                                let p = format!("?{extra_param_idx}");
1091                                extra_param_idx += 1;
1092                                p
1093                            })
1094                            .collect();
1095                        relation_cond = format!(" AND e.relation IN ({})", placeholders.join(","));
1096                    }
1097                }
1098
1099                let mut weight_cond = String::new();
1100                if let Some(min_w) = opts.min_weight {
1101                    extra_params.push(Box::new(min_w));
1102                    weight_cond = format!(" AND e.weight >= ?{extra_param_idx}");
1103                    // limit is applied in Rust (see below), so no SQL param needed.
1104                }
1105
1106                // Seed rows: one per root in this chunk, each referencing its own
1107                // param 3× (root_id, node_id, and the initial path string).
1108                let seed_rows: Vec<String> = (1..=n_chunk)
1109                    .map(|i| format!("(?{i}, ?{i}, NULL, 0, ?{i}, 0.0)"))
1110                    .collect();
1111                let seeds = seed_rows.join(", ");
1112
1113                // CTE covering the chunk's roots.  CROSS JOIN forces SQLite to put
1114                // the frontier (t) as the outer loop and seek graph_edges by index,
1115                // avoiding the O(edges × frontier) plan (#250, #251).
1116                let cte_sql = format!(
1117                    "WITH RECURSIVE traversal(\
1118                         root_id, node_id, edge_id, depth, path, total_weight\
1119                     ) AS (\
1120                         VALUES {seeds} \
1121                         UNION ALL \
1122                         SELECT t.root_id, {next_node}, e.id, t.depth + 1, \
1123                                t.path || ',' || {next_node}, \
1124                                t.total_weight + e.weight \
1125                         FROM traversal t CROSS JOIN graph_edges e \
1126                             ON {join_condition} \
1127                         WHERE e.namespace = ?{ns} \
1128                           AND e.deleted_at IS NULL \
1129                           AND t.depth < ?{depth} \
1130                           AND (',' || t.path || ',') NOT LIKE '%,' || {next_node} || ',%'\
1131                           {rel_cond}{wt_cond} \
1132                     ) \
1133                     SELECT root_id, node_id, edge_id, depth, total_weight \
1134                     FROM traversal WHERE depth > 0 \
1135                     ORDER BY root_id, depth",
1136                    seeds = seeds,
1137                    next_node = next_node,
1138                    join_condition = join_condition,
1139                    ns = ns_param,
1140                    depth = depth_param,
1141                    rel_cond = relation_cond,
1142                    wt_cond = weight_cond,
1143                );
1144
1145                let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1146                for root_id in chunk {
1147                    all_params.push(Box::new(root_id.to_string()));
1148                }
1149                all_params.push(Box::new(namespace.clone()));
1150                all_params.push(Box::new(opts.max_depth as i64));
1151                all_params.extend(extra_params);
1152
1153                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1154                    all_params.iter().map(|p| p.as_ref()).collect();
1155
1156                // Queries run on `conn`; reads are connection-level and participate
1157                // in the open `tx` deferred snapshot.
1158                let mut stmt = conn.prepare(&cte_sql)?;
1159                let rows_iter = stmt.query_map(param_refs.as_slice(), |row| {
1160                    let root_str: String = row.get(0)?;
1161                    let node_str: String = row.get(1)?;
1162                    let edge_str: Option<String> = row.get(2)?;
1163                    let depth: i64 = row.get(3)?;
1164                    let total_weight: f64 = row.get(4)?;
1165                    Ok((root_str, node_str, edge_str, depth, total_weight))
1166                })?;
1167
1168                // The CTE is ordered by (root_id, depth), so the first occurrence of
1169                // each (root_id, node_id) pair is the shallowest — that is the one we
1170                // keep (BFS first-visit semantics, matching #285).
1171                for row in rows_iter {
1172                    let (root_str, node_str, edge_str, depth, total_weight) = row?;
1173                    let root_id = parse_uuid(&root_str)?;
1174                    let node_id = parse_uuid(&node_str)?;
1175                    let (nodes, seen) = root_data.entry(root_id).or_default();
1176                    if !seen.insert(node_id) {
1177                        continue;
1178                    }
1179                    let via_edge = edge_str.map(|s| parse_uuid(&s)).transpose()?;
1180                    nodes.push((
1181                        PathNode {
1182                            node_id,
1183                            via_edge,
1184                            depth: depth as usize,
1185                            name: None,
1186                            kind: None,
1187                            properties: None,
1188                        },
1189                        total_weight,
1190                    ));
1191                }
1192            }
1193
1194            tx.commit()?;
1195
1196            // Reconstruct Vec<GraphPath> in original root order.
1197            // Per-root limit: counts only non-root nodes against the cap, matching
1198            // the original per-root-CTE semantics where the SQL LIMIT applied only
1199            // to depth > 0 rows.  Truncation is on the post-dedup list (BFS order),
1200            // so the shallowest `limit` reachable nodes are kept per root.
1201            let mut all_paths: Vec<GraphPath> = Vec::with_capacity(roots.len());
1202            for root_id in &roots {
1203                if let Some((mut nw, _)) = root_data.remove(root_id) {
1204                    if nw.is_empty() {
1205                        continue;
1206                    }
1207                    if let Some(lim) = opts.limit {
1208                        let root_count = usize::from(include_roots);
1209                        nw.truncate(root_count + lim as usize);
1210                    }
1211                    // Post-truncation guard: a limit=0 + include_roots=false call
1212                    // truncates to zero nodes; there is nothing to emit.
1213                    if nw.is_empty() {
1214                        continue;
1215                    }
1216                    let max_weight = nw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
1217                    let nodes: Vec<PathNode> = nw.into_iter().map(|(n, _)| n).collect();
1218                    all_paths.push(GraphPath {
1219                        root_id: *root_id,
1220                        nodes,
1221                        total_weight: max_weight,
1222                    });
1223                }
1224            }
1225
1226            Ok(all_paths)
1227        })
1228        .await
1229    }
1230
1231    async fn purge_incident_edges(&self, node_id: Uuid) -> Result<u64, StorageError> {
1232        let id_str = node_id.to_string();
1233        // No namespace filter: UUID v4 is globally unique. Hard-delete cascade must
1234        // remove ALL incident edges regardless of which namespace they were written in
1235        // (ADR-002 no-dangling-references, ADR-007 by-ID contract).
1236        self.with_writer("purge_incident_edges", move |conn| {
1237            let affected = conn.execute(
1238                "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1",
1239                rusqlite::params![id_str],
1240            )?;
1241            Ok(affected as u64)
1242        })
1243        .await
1244    }
1245}
1246
1247// =============================================================================
1248// DDL
1249// =============================================================================
1250
1251const GRAPH_DDL: &str = include_str!("../../sql/graph-ddl.sql");
1252
1253pub(crate) fn ensure_graph_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1254    conn.execute_batch(GRAPH_DDL)
1255}
1256
1257#[cfg(test)]
1258#[path = "graph_tests.rs"]
1259mod tests;