Skip to main content

khive_runtime/operations/
edge_query.rs

1use std::collections::HashMap;
2
3use serde::Serialize;
4use uuid::Uuid;
5
6use khive_db::stores::graph::edge_hard_delete_statement;
7use khive_storage::graph::{SymmetricEdgeUpdateOutcome, SymmetricEdgeUpdateRequest};
8use khive_storage::types::{
9    DeleteMode, EdgeSortField, LinkId, PageRequest, SeekCursor, SortOrder, SqlRow, SqlStatement,
10    SqlValue,
11};
12use khive_storage::{Edge, EdgeRelation};
13use khive_types::{EventKind, SubstrateKind};
14
15use crate::error::{RuntimeError, RuntimeResult};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18use super::{canonical_edge_endpoints, static_impossible_edge_pattern_warnings, EdgeReadWindow};
19
20/// Result of a GQL/SPARQL query with optional validation warnings.
21#[derive(Clone, Debug, Serialize)]
22pub struct QueryResult {
23    pub rows: Vec<SqlRow>,
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub warnings: Vec<String>,
26    /// Zero-based offset of this deterministic result page.
27    pub offset: usize,
28    /// Effective payload bound after composing query `LIMIT` and server page size.
29    pub page_size: usize,
30    /// `true` when at least one additional match exists after this page.
31    pub has_more: bool,
32    /// GQL continuation offset. Present exactly when GQL has another page.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub next_offset: Option<usize>,
35    /// Backward-compatible alias for `has_more` (#1168, #1247, #1601).
36    pub truncated: bool,
37}
38
39impl KhiveRuntime {
40    // ---- Query operations ----
41
42    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
43    ///
44    /// The query is compiled to SQL with the namespace scope applied.
45    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
46    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
47    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
48        Ok(self
49            .query_with_metadata(token, query, khive_query::CompileOptions::default())
50            .await?
51            .rows)
52    }
53
54    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
55    pub async fn query_with_metadata(
56        &self,
57        token: &NamespaceToken,
58        query: &str,
59        mut opts: khive_query::CompileOptions,
60    ) -> RuntimeResult<QueryResult> {
61        use khive_query::QueryValue;
62        use khive_storage::types::SqlValue;
63
64        let (language, ast) = khive_query::language::parse_auto_with_language(query)?;
65        if opts.max_limit == 0 {
66            return Err(RuntimeError::InvalidInput(
67                "query page size must be at least 1".into(),
68            ));
69        }
70        let offset = ast.offset;
71        let page_size = ast.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
72        opts.scopes = token
73            .visible_namespaces()
74            .iter()
75            .map(|ns| ns.as_str().to_string())
76            .collect();
77        let compiled = khive_query::compile(&ast, &opts)?;
78        let mut warnings = compiled.warnings;
79        let truncation_check = compiled.truncation_check;
80
81        warnings.extend(self.with_pack_edge_rules(|pack_rules| {
82            static_impossible_edge_pattern_warnings(language, &ast.pattern, pack_rules)
83        }));
84
85        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
86        // at the query–storage boundary.
87        let params: Vec<SqlValue> = compiled
88            .params
89            .into_iter()
90            .map(|qv| match qv {
91                QueryValue::Null => SqlValue::Null,
92                QueryValue::Integer(n) => SqlValue::Integer(n),
93                QueryValue::Float(f) => SqlValue::Float(f),
94                QueryValue::Text(s) => SqlValue::Text(s),
95                QueryValue::Blob(b) => SqlValue::Blob(b),
96            })
97            .collect();
98
99        let mut reader = self.sql().reader().await?;
100        let stmt = SqlStatement {
101            sql: compiled.sql,
102            params,
103            label: None,
104        };
105        let mut rows = reader.query_all(stmt).await?;
106
107        // When the effective page size was the binding constraint, the compiled
108        // SQL asked for one extra (sentinel) row. Its presence in the actual
109        // result set — not the requested LIMIT — is the continuation signal.
110        let mut truncated = false;
111        if let Some(check) = truncation_check {
112            if rows.len() > check.max_limit {
113                rows.truncate(check.max_limit);
114                truncated = true;
115            }
116        }
117
118        let next_offset = if truncated && language == khive_query::QueryLanguage::Gql {
119            let next = offset.checked_add(rows.len()).ok_or_else(|| {
120                RuntimeError::InvalidInput("GQL next_offset exceeds usize::MAX".into())
121            })?;
122            if next == offset {
123                return Err(RuntimeError::InvalidInput(
124                    "query page did not advance; page size must be at least 1".into(),
125                ));
126            }
127            i64::try_from(next).map_err(|_| {
128                RuntimeError::InvalidInput("GQL next_offset exceeds i64::MAX".into())
129            })?;
130            Some(next)
131        } else {
132            None
133        };
134
135        if truncated {
136            let Some(check) = truncation_check else {
137                return Err(RuntimeError::Internal(
138                    "truncated query result is missing sentinel metadata".into(),
139                ));
140            };
141            let bound = match check.requested_limit {
142                Some(requested) => {
143                    format!("requested query LIMIT {requested} exceeds the effective page size")
144                }
145                None => "the query has no explicit LIMIT".to_string(),
146            };
147            let warning = match language {
148                khive_query::QueryLanguage::Gql => {
149                    let Some(next) = next_offset else {
150                        return Err(RuntimeError::Internal(
151                            "truncated GQL result is missing its continuation offset".into(),
152                        ));
153                    };
154                    format!(
155                        "result page capped at {} rows because {bound}; more matches exist. \
156                         Continue the same GQL query with `SKIP {next}` (the machine-readable \
157                         `next_offset`) and keep the same page size.",
158                        check.max_limit
159                    )
160                }
161                khive_query::QueryLanguage::Sparql => format!(
162                    "result page capped at {} rows because {bound}; more matches exist. \
163                     SPARQL OFFSET paging is not part of the supported dialect.",
164                    check.max_limit
165                ),
166            };
167            warnings.push(warning);
168        }
169
170        Ok(QueryResult {
171            rows,
172            warnings,
173            offset,
174            page_size,
175            has_more: truncated,
176            next_offset,
177            truncated,
178        })
179    }
180
181    // ---- Edge CRUD operations ----
182
183    /// Fetch a single edge by id.
184    ///
185    /// UUID v4 is globally unique: returns the edge regardless of which
186    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
187    pub async fn get_edge(
188        &self,
189        _token: &NamespaceToken,
190        edge_id: Uuid,
191    ) -> RuntimeResult<Option<Edge>> {
192        self.get_edge_by_id_inner(edge_id, false).await
193    }
194
195    /// Read live edges by ID in input order, without a visibility predicate.
196    ///
197    /// Stored namespaces are validated before the corresponding edge decode.
198    /// Each namespace group uses its own graph capability; missing rows remain
199    /// `None`. Group failures belong to their first input, and the earliest
200    /// input error wins after all groups in that bounded window are observed.
201    /// Metadata statement failures are fatal batch errors. Windows are separate
202    /// read observations, not a snapshot of the whole request.
203    pub async fn get_edges_by_id(
204        &self,
205        _token: &NamespaceToken,
206        ids: &[Uuid],
207    ) -> RuntimeResult<Vec<Option<Edge>>> {
208        let mut edges = Vec::with_capacity(ids.len());
209        for chunk in ids.chunks(900) {
210            let window = self.prepare_edge_read_window(chunk).await?;
211            edges.extend(
212                Self::hydrate_edge_read_window(chunk, window, |record_token| {
213                    self.graph(record_token)
214                })
215                .await?,
216            );
217        }
218        Ok(edges)
219    }
220
221    pub(super) async fn prepare_edge_read_window(
222        &self,
223        ids: &[Uuid],
224    ) -> RuntimeResult<EdgeReadWindow> {
225        let placeholders = (1..=ids.len())
226            .map(|index| format!("?{index}"))
227            .collect::<Vec<_>>()
228            .join(",");
229        let mut reader = self.sql().reader().await?;
230        let rows = reader
231            .query_all(SqlStatement {
232                sql: format!(
233                    "SELECT id, namespace FROM graph_edges WHERE id IN ({placeholders}) AND deleted_at IS NULL"
234                ),
235                params: ids.iter().map(|id| SqlValue::Text(id.to_string())).collect(),
236                label: Some("get_edge_namespace".into()),
237            })
238            .await?;
239        let mut namespaces = HashMap::with_capacity(rows.len());
240        for row in rows {
241            let Some(SqlValue::Text(id)) = row.columns.first().map(|column| &column.value) else {
242                return Err(RuntimeError::Internal(
243                    "edge namespace lookup returned an invalid id".into(),
244                ));
245            };
246            let id = Uuid::parse_str(id).map_err(|e| {
247                RuntimeError::Internal(format!("edge namespace lookup returned an invalid id: {e}"))
248            })?;
249            let value = row
250                .columns
251                .get(1)
252                .map(|column| column.value.clone())
253                .unwrap_or(SqlValue::Null);
254            namespaces.insert(id, value);
255        }
256        let mut window = EdgeReadWindow {
257            outcomes: (0..ids.len()).map(|_| Some(Ok(None))).collect(),
258            groups: Vec::new(),
259        };
260        let mut group_indices = HashMap::new();
261        for (index, id) in ids.iter().enumerate() {
262            let Some(SqlValue::Text(record_ns)) = namespaces.get(id) else {
263                continue;
264            };
265            match khive_types::Namespace::parse(record_ns) {
266                Ok(namespace) => {
267                    let next_group = window.groups.len();
268                    let group = *group_indices.entry(record_ns.clone()).or_insert(next_group);
269                    if group == next_group {
270                        window.groups.push((namespace, Vec::new()));
271                    }
272                    window.groups[group].1.push(index);
273                    window.outcomes[index] = None;
274                }
275                Err(error) => {
276                    window.outcomes[index] = Some(Err(RuntimeError::Internal(format!(
277                        "edge namespace invalid: {error}"
278                    ))));
279                }
280            }
281        }
282        Ok(window)
283    }
284
285    pub(super) async fn hydrate_edge_read_window<F>(
286        ids: &[Uuid],
287        mut window: EdgeReadWindow,
288        mut graph: F,
289    ) -> RuntimeResult<Vec<Option<Edge>>>
290    where
291        F: FnMut(&NamespaceToken) -> RuntimeResult<std::sync::Arc<dyn khive_storage::GraphStore>>,
292    {
293        for (namespace, indices) in window.groups {
294            let record_token = NamespaceToken::for_namespace(namespace);
295            let group_ids: Vec<LinkId> = indices
296                .iter()
297                .map(|&index| LinkId::from(ids[index]))
298                .collect();
299            let outcomes = match graph(&record_token) {
300                Ok(store) => store
301                    .get_edge_read_outcomes(&group_ids)
302                    .await
303                    .map_err(RuntimeError::from),
304                Err(error) => Err(error),
305            };
306            match outcomes {
307                Ok(outcomes) if outcomes.len() == indices.len() => {
308                    for (index, outcome) in indices.into_iter().zip(outcomes) {
309                        window.outcomes[index] = Some(outcome.map_err(RuntimeError::from));
310                    }
311                }
312                Ok(_) => {
313                    window.outcomes[indices[0]] = Some(Err(RuntimeError::Internal(
314                        "edge batch returned an invalid outcome count".into(),
315                    )));
316                }
317                Err(error) => {
318                    window.outcomes[indices[0]] = Some(Err(error));
319                }
320            }
321        }
322        window
323            .outcomes
324            .into_iter()
325            .map(|outcome| {
326                outcome.unwrap_or_else(|| {
327                    Err(RuntimeError::Internal(
328                        "edge batch omitted an input outcome".into(),
329                    ))
330                })
331            })
332            .collect()
333    }
334
335    /// Fetch a single edge by id.
336    ///
337    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
338    /// namespace-agnostic; UUID v4 is globally unique.
339    pub async fn get_edge_visible(
340        &self,
341        token: &NamespaceToken,
342        edge_id: Uuid,
343    ) -> RuntimeResult<Option<Edge>> {
344        self.get_edge(token, edge_id).await
345    }
346
347    /// Fetch an edge by UUID including soft-deleted rows.
348    ///
349    /// Returns the edge regardless of which namespace the token carries:
350    /// UUID v4 is globally unique. Used by the hard-delete path so that a
351    /// soft-deleted edge can still be purged via its edge ID.
352    pub async fn get_edge_including_deleted(
353        &self,
354        _token: &NamespaceToken,
355        edge_id: Uuid,
356    ) -> RuntimeResult<Option<Edge>> {
357        self.get_edge_by_id_inner(edge_id, true).await
358    }
359
360    async fn get_edge_by_id_inner(
361        &self,
362        edge_id: Uuid,
363        include_deleted: bool,
364    ) -> RuntimeResult<Option<Edge>> {
365        let (sql, label) = if include_deleted {
366            (
367                "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1",
368                "get_edge_including_deleted_namespace",
369            )
370        } else {
371            (
372                "SELECT namespace FROM graph_edges \
373                 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1",
374                "get_edge_namespace",
375            )
376        };
377        let mut reader = self.sql().reader().await?;
378        let record_ns = reader
379            .query_scalar(SqlStatement {
380                sql: sql.into(),
381                params: vec![SqlValue::Text(edge_id.to_string())],
382                label: Some(label.into()),
383            })
384            .await?;
385
386        let Some(SqlValue::Text(record_ns)) = record_ns else {
387            return Ok(None);
388        };
389        // Route through the record's own namespace store (no namespace equality check).
390        let record_tok = NamespaceToken::for_namespace(
391            khive_types::Namespace::parse(&record_ns)
392                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
393        );
394        let graph = self.graph(&record_tok)?;
395        Ok(if include_deleted {
396            graph
397                .get_edge_including_deleted(LinkId::from(edge_id))
398                .await?
399        } else {
400            graph.get_edge(LinkId::from(edge_id)).await?
401        })
402    }
403
404    /// Fetch an edge by natural key (namespace, canonical source/target, relation)
405    /// including soft-deleted rows. Unlike [`Self::list_edges`]/[`Self::list_edges_after`],
406    /// which always filter `deleted_at IS NULL`, this can render a tombstoned symmetric-edge
407    /// survivor (ADR-039 DO NOTHING conflict absorption) — used by the atomic-apply
408    /// post-commit result renderer, which otherwise reports "not found" for a committed
409    /// update whose surviving row happens to be soft-deleted.
410    ///
411    /// `token` selects the store instance; `namespace` is the natural key's own
412    /// namespace and is bound into the query explicitly. These can legitimately differ:
413    /// the record namespace is fixed at prepare time (`EdgeNaturalKey::namespace`) and by-ID
414    /// edge updates are namespace-agnostic (ADR-007 Rev 6), so the caller's ambient `token`
415    /// namespace is never a safe substitute for the record's own — the prior implicit
416    /// `self.namespace` scoping is exactly the bug this parameter closes (khive#1213/#1214).
417    pub async fn get_edge_by_natural_key_including_deleted(
418        &self,
419        token: &NamespaceToken,
420        namespace: &str,
421        source_id: Uuid,
422        target_id: Uuid,
423        relation: EdgeRelation,
424    ) -> RuntimeResult<Option<Edge>> {
425        Ok(self
426            .graph(token)?
427            .get_edge_by_natural_key_including_deleted(namespace, source_id, target_id, relation)
428            .await?)
429    }
430
431    /// Maximum rows returned by a single [`Self::list_edges`] /
432    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
433    /// can rely on; kept as a named constant so tests can exercise pagination
434    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
435    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
436
437    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
438    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100. For O(1)-at-depth walks
439    /// over large edge populations, prefer [`Self::list_edges_after`] instead
440    /// of paging offset deep.
441    pub async fn list_edges(
442        &self,
443        token: &NamespaceToken,
444        filter: crate::curation::EdgeListFilter,
445        limit: u32,
446        offset: u32,
447    ) -> RuntimeResult<Vec<Edge>> {
448        let limit = limit.min(Self::EDGE_LIST_MAX_LIMIT);
449        let visible = token.visible_namespaces();
450
451        // Common case: a single visible namespace — page directly against the
452        // store so `offset`/`limit` reach SQL unmodified.
453        if let [ns] = visible {
454            let temp = NamespaceToken::for_namespace(ns.clone());
455            let page = self
456                .graph(&temp)?
457                .query_edges(
458                    filter.into(),
459                    vec![SortOrder {
460                        field: EdgeSortField::CreatedAt,
461                        direction: khive_storage::types::SortDirection::Asc,
462                    }],
463                    PageRequest {
464                        offset: offset.into(),
465                        limit,
466                    },
467                )
468                .await?;
469            return Ok(page.items);
470        }
471
472        // Multi-namespace visibility: one deterministic query with
473        // `namespace IN (...)` and real SQL paging, mirroring
474        // `list_entities`. Fetching per-namespace prefixes and slicing a
475        // client-side merge re-sorted by UUID floats the offset window
476        // between calls — pages silently duplicate and skip rows, so
477        // enumeration never covers the set (#2088).
478        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
479        let sort = vec![SortOrder {
480            field: EdgeSortField::CreatedAt,
481            direction: khive_storage::types::SortDirection::Asc,
482        }];
483        let graph = self.graph(token)?;
484        match graph
485            .query_edges_in_namespaces(
486                &ns_strs,
487                filter.clone().into(),
488                sort.clone(),
489                PageRequest {
490                    offset: offset.into(),
491                    limit,
492                },
493            )
494            .await
495        {
496            Ok(page) => Ok(page.items),
497            Err(khive_storage::StorageError::Unsupported { operation, .. })
498                if operation == "query_edges_in_namespaces" =>
499            {
500                // Backend exercises the trait default (no batched
501                // namespace query support): fall back to one `query_edges`
502                // call per namespace. Unlike the pre-image fix for #2088,
503                // this fetches an `offset + limit` prefix from every
504                // namespace and merges by the *same* `(created_at, id)` key
505                // each per-namespace fetch already orders by — the
506                // pre-image bug sorted the merged set by UUID alone, a key
507                // unrelated to the order each per-namespace prefix was cut
508                // at, which floated the offset window and silently
509                // duplicated/skipped rows across pages. Sorting by the
510                // fetch's own order key keeps the top `offset + limit` of
511                // the merge exactly equal to the true global prefix.
512                let fetch_limit = offset.saturating_add(limit);
513                let mut namespace_prefixes = Vec::new();
514                for ns in visible {
515                    let temp = NamespaceToken::for_namespace(ns.clone());
516                    let page = self
517                        .graph(&temp)?
518                        .query_edges(
519                            filter.clone().into(),
520                            sort.clone(),
521                            PageRequest {
522                                offset: 0,
523                                limit: fetch_limit,
524                            },
525                        )
526                        .await?;
527                    namespace_prefixes.push(page.items);
528                }
529                Ok(Self::merge_paged_namespace_edges(
530                    namespace_prefixes,
531                    offset,
532                    limit,
533                ))
534            }
535            Err(error) => Err(error.into()),
536        }
537    }
538
539    /// Merge per-namespace `(created_at, id)`-ordered edge prefixes (each
540    /// already fetched up to `offset + limit` from its own namespace, as
541    /// [`Self::list_edges`]'s trait-default fallback does) into one global
542    /// `[offset, offset + limit)` page.
543    ///
544    /// Sorting by the *same key each prefix was already cut at* is what
545    /// keeps this exact: the top `offset + limit` of the merged set is then
546    /// provably equal to the true global prefix (a standard k-way merge
547    /// argument — no element beyond position `offset + limit` in any single
548    /// namespace can appear before that position in the global order). The
549    /// pre-image #2088 bug instead re-sorted the merged set by UUID alone —
550    /// a key unrelated to the order each namespace's prefix was fetched in —
551    /// which floated the offset window and silently duplicated/skipped rows
552    /// across pages.
553    pub(super) fn merge_paged_namespace_edges(
554        namespace_prefixes: Vec<Vec<Edge>>,
555        offset: u32,
556        limit: u32,
557    ) -> Vec<Edge> {
558        let mut results: Vec<Edge> = namespace_prefixes.into_iter().flatten().collect();
559        results.sort_by_key(|e| (e.created_at, Uuid::from(e.id)));
560        let start = (offset as usize).min(results.len());
561        let end = (start + limit as usize).min(results.len());
562        results[start..end].to_vec()
563    }
564
565    /// Keyset (seek) page of edges matching `filter`, ordered by immutable
566    /// database-assigned insertion sequence. `after` is the last edge id from the
567    /// previous page (exclusive); omit to start from the beginning. Returns
568    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
569    /// past this page.
570    ///
571    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth and
572    /// genuinely new inserts are appended after already-issued boundaries. The
573    /// cursor row is resolved including tombstones; a hard-deleted or
574    /// out-of-scope cursor fails explicitly rather than hiding an incomplete
575    /// traversal.
576    pub async fn list_edges_after(
577        &self,
578        token: &NamespaceToken,
579        filter: crate::curation::EdgeListFilter,
580        after: Option<Uuid>,
581        limit: u32,
582    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
583        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
584        let visible = token.visible_namespaces();
585        let limit_usize = limit as usize;
586        let cursor_store = self.graph(token)?;
587        let after = match after {
588            Some(id) => {
589                let edge = self
590                    .get_edge_including_deleted(token, id)
591                    .await?
592                    .ok_or_else(|| RuntimeError::NotFound(format!("edge cursor {id}")))?;
593                Self::ensure_namespace_visible(&edge.namespace, token)?;
594                let sequence = cursor_store.edge_sequence(id).await?.ok_or_else(|| {
595                    RuntimeError::Internal(format!(
596                        "edge cursor {id} has no insertion-sequence ledger row"
597                    ))
598                })?;
599                Some(SeekCursor { sequence, id })
600            }
601            None => None,
602        };
603
604        if let [ns] = visible {
605            let temp = NamespaceToken::for_namespace(ns.clone());
606            let page = self
607                .graph(&temp)?
608                .query_edges_sequence_after(filter.into(), after, limit)
609                .await?;
610            return Ok((page.items, page.next_after.map(|cursor| cursor.id)));
611        }
612
613        // Multi-namespace visibility: seek each namespace from the same
614        // immutable boundary, merge in global insertion order, then take the head of
615        // the merged set as this page.
616        let probe_limit = limit.saturating_add(1);
617        let mut results = Vec::new();
618        for ns in visible {
619            let temp = NamespaceToken::for_namespace(ns.clone());
620            let page = self
621                .graph(&temp)?
622                .query_edges_sequence_after(filter.clone().into(), after, probe_limit)
623                .await?;
624            results.extend(page.items);
625        }
626        let ids = results
627            .iter()
628            .map(|edge| Uuid::from(edge.id))
629            .collect::<Vec<_>>();
630        let sequences = cursor_store
631            .edge_sequences(&ids)
632            .await?
633            .into_iter()
634            .collect::<HashMap<_, _>>();
635        if let Some(missing) = ids.iter().find(|id| !sequences.contains_key(id)) {
636            return Err(RuntimeError::Internal(format!(
637                "edge {missing} has no insertion-sequence ledger row"
638            )));
639        }
640        results.sort_by_key(|edge| {
641            let id = Uuid::from(edge.id);
642            (sequences[&id], id)
643        });
644        results.dedup_by_key(|e| Uuid::from(e.id));
645        let has_more = results.len() > limit_usize;
646        if has_more {
647            results.truncate(limit_usize);
648        }
649        let next_after = if has_more {
650            results.last().map(|e| Uuid::from(e.id))
651        } else {
652            None
653        };
654        Ok((results, next_after))
655    }
656
657    /// Count edges by relation, ignoring soft-deleted rows. Used by
658    /// `stats()` to report the true per-relation population so full-graph
659    /// audits know what they're sampling from before they walk it.
660    pub async fn count_edges_by_relation(
661        &self,
662        token: &NamespaceToken,
663    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
664        let namespaces: Vec<String> = token
665            .visible_namespaces()
666            .iter()
667            .map(|namespace| namespace.as_str().to_owned())
668            .collect();
669        let graph = self.graph(token)?;
670        let counts = match graph
671            .count_edges_by_relation_in_namespaces(&namespaces)
672            .await
673        {
674            Ok(counts) => counts,
675            Err(khive_storage::StorageError::Unsupported { operation, .. })
676                if operation == "count_edges_by_relation_in_namespaces" =>
677            {
678                let mut totals = HashMap::new();
679                for namespace in token.visible_namespaces() {
680                    let scoped = NamespaceToken::for_namespace(namespace.clone());
681                    for (relation, count) in self.graph(&scoped)?.count_edges_by_relation().await? {
682                        *totals.entry(relation).or_insert(0) += count;
683                    }
684                }
685                return Ok(totals
686                    .into_iter()
687                    .map(|(relation, count)| (relation.to_string(), count))
688                    .collect());
689            }
690            Err(error) => return Err(error.into()),
691        };
692        Ok(counts
693            .into_iter()
694            .map(|(relation, count)| (relation.to_string(), count))
695            .collect())
696    }
697
698    /// Count edges by the base each endpoint resolves against. Used by
699    /// `stats()` so a caller can name the denominator of a density figure
700    /// instead of inheriting the flat edge total, which on a real store is
701    /// mostly provenance.
702    ///
703    /// The per-namespace fallback sums the same buckets, so the aggregate and
704    /// the fallback are checkable against each other and against
705    /// `count_edges` by the invariant that the buckets sum to the total.
706    pub async fn count_edges_by_endpoint_base(
707        &self,
708        token: &NamespaceToken,
709    ) -> RuntimeResult<khive_storage::types::EdgeEndpointBaseCounts> {
710        use khive_storage::types::EdgeEndpointBaseCounts;
711
712        let namespaces: Vec<String> = token
713            .visible_namespaces()
714            .iter()
715            .map(|namespace| namespace.as_str().to_owned())
716            .collect();
717        let graph = self.graph(token)?;
718        match graph
719            .count_edges_by_endpoint_base_in_namespaces(&namespaces)
720            .await
721        {
722            Ok(counts) => Ok(counts),
723            Err(khive_storage::StorageError::Unsupported { operation, .. })
724                if operation == "count_edges_by_endpoint_base_in_namespaces"
725                    || operation == "count_edges_by_endpoint_base" =>
726            {
727                let mut totals = EdgeEndpointBaseCounts::default();
728                for namespace in token.visible_namespaces() {
729                    let scoped = NamespaceToken::for_namespace(namespace.clone());
730                    let counts = self.graph(&scoped)?.count_edges_by_endpoint_base().await?;
731                    totals.entity_entity =
732                        totals.entity_entity.saturating_add(counts.entity_entity);
733                    totals.entity_note = totals.entity_note.saturating_add(counts.entity_note);
734                    totals.note_entity = totals.note_entity.saturating_add(counts.note_entity);
735                    totals.note_note = totals.note_note.saturating_add(counts.note_note);
736                    totals.unresolved = totals.unresolved.saturating_add(counts.unresolved);
737                }
738                Ok(totals)
739            }
740            Err(error) => Err(error.into()),
741        }
742    }
743
744    /// Return the live edge total, its relation counts and its endpoint-base
745    /// counts for `stats`.
746    ///
747    /// The three describe one population: live edges with live endpoints in
748    /// the caller's visible namespaces. A backend with the combined aggregate
749    /// answers all of them from one grouped read, and the total is the sum of
750    /// its groups, so the invariants `stats` reports on (relation counts and
751    /// endpoint-base buckets each summing to the total) hold by construction.
752    /// A backend without it gets the three single-purpose counts.
753    pub async fn edge_stats_counts(
754        &self,
755        token: &NamespaceToken,
756    ) -> RuntimeResult<super::EdgeStatsCounts> {
757        let namespaces: Vec<String> = token
758            .visible_namespaces()
759            .iter()
760            .map(|namespace| namespace.as_str().to_owned())
761            .collect();
762        let graph = self.graph(token)?;
763        match graph
764            .count_edges_by_relation_and_endpoint_base_in_namespaces(&namespaces)
765            .await
766        {
767            Ok(groups) => {
768                let mut counts = super::EdgeStatsCounts::default();
769                for (relation, by_base) in groups {
770                    let relation_total = by_base.total();
771                    counts.edges = counts.edges.saturating_add(relation_total);
772                    let slot = counts
773                        .edges_by_relation
774                        .entry(relation.to_string())
775                        .or_insert(0);
776                    *slot = slot.saturating_add(relation_total);
777                    let totals = &mut counts.edges_by_endpoint_base;
778                    totals.entity_entity =
779                        totals.entity_entity.saturating_add(by_base.entity_entity);
780                    totals.entity_note = totals.entity_note.saturating_add(by_base.entity_note);
781                    totals.note_entity = totals.note_entity.saturating_add(by_base.note_entity);
782                    totals.note_note = totals.note_note.saturating_add(by_base.note_note);
783                    totals.unresolved = totals.unresolved.saturating_add(by_base.unresolved);
784                }
785                Ok(counts)
786            }
787            Err(khive_storage::StorageError::Unsupported { operation, .. })
788                if operation == "count_edges_by_relation_and_endpoint_base_in_namespaces" =>
789            {
790                Ok(super::EdgeStatsCounts {
791                    edges: self
792                        .count_edges(token, crate::curation::EdgeListFilter::default())
793                        .await?,
794                    edges_by_relation: self.count_edges_by_relation(token).await?,
795                    edges_by_endpoint_base: self.count_edges_by_endpoint_base(token).await?,
796                })
797            }
798            Err(error) => Err(error.into()),
799        }
800    }
801
802    /// Patch-style edge update. Only `Some(_)` fields are applied.
803    ///
804    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
805    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
806    /// skip validation. Returns `InvalidInput` if the new relation would violate the
807    /// three-case endpoint contract; the edge is NOT mutated on error.
808    ///
809    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
810    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
811    /// row already exists at the target triple, the non-canonical edge is deleted and
812    /// the existing canonical row is preserved unchanged (ADR-039 ON CONFLICT DO
813    /// NOTHING, mirroring `merge_entity_sql`) — its attributes, including a soft-deleted
814    /// `deleted_at`, are never overwritten by the discarded edge's patch.
815    pub async fn update_edge(
816        &self,
817        token: &NamespaceToken,
818        edge_id: Uuid,
819        patch: crate::curation::EdgePatch,
820    ) -> RuntimeResult<Edge> {
821        // Fetch the edge by UUID: ID-only, no namespace check.
822        // get_edge already uses the record's stored namespace internally.
823        let graph_for_fetch = self.graph(token)?;
824        let mut edge = graph_for_fetch
825            .get_edge(LinkId::from(edge_id))
826            .await?
827            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
828        let expected_updated_at = edge.updated_at;
829        let expected_deleted_at = edge.deleted_at;
830        #[cfg(test)]
831        crate::curation::race_seam::pause_after_read().await;
832
833        // After fetching, all mutations and validation must use the
834        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
835        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
836        // all address the correct backend partition.
837        let record_ns: String = edge.namespace.clone();
838        let record_tok = token.with_namespace(
839            khive_types::Namespace::parse(&record_ns)
840                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
841        );
842        let graph = self.graph(&record_tok)?;
843
844        let mut changed_fields: Vec<&'static str> = Vec::new();
845        if let Some(r) = patch.relation {
846            // Validate before mutating — use the existing endpoints with the new relation.
847            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
848            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
849                .await?;
850            edge.relation = r;
851            changed_fields.push("relation");
852        }
853        if let Some(w) = patch.weight {
854            // Reject non-finite or out-of-range weight explicitly; do not silently
855            // clamp invalid caller input (coding-standards §608-622).
856            if !khive_types::validate_edge_weight(w) {
857                return Err(RuntimeError::InvalidInput(format!(
858                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
859                )));
860            }
861            edge.weight = w;
862            changed_fields.push("weight");
863        }
864        if let Some(props) = patch.properties {
865            crate::secret_gate::reject_reserved_secret_gate_property(Some(&props))?;
866            edge.metadata = Some(props);
867        }
868
869        // For symmetric relations, canonicalise endpoint order and check
870        // for natural-key conflicts regardless of whether endpoints were flipped.
871        //
872        // The guarded graph operation is used for ALL symmetric relations because `upsert_edge`
873        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
874        // the natural key (namespace, source_id, target_id, relation) with a different
875        // id. Bug-fix: this path must also run when endpoints are already canonical
876        // (endpoints_flipped=false) to catch conflicts arising from a relation change
877        // that collides with an existing canonical row.
878        let (canon_src, canon_tgt) =
879            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
880
881        if edge.relation.is_symmetric() {
882            let outcome = graph
883                .update_symmetric_edge_if_unchanged(SymmetricEdgeUpdateRequest {
884                    namespace: record_ns.clone(),
885                    id: edge_id.into(),
886                    source_id: canon_src,
887                    target_id: canon_tgt,
888                    relation: edge.relation,
889                    weight: edge.weight,
890                    metadata: edge.metadata.clone(),
891                    expected_updated_at_micros: expected_updated_at.timestamp_micros(),
892                    expected_deleted_at_micros: expected_deleted_at.map(|v| v.timestamp_micros()),
893                })
894                .await?;
895
896            match outcome {
897                SymmetricEdgeUpdateOutcome::Absorbed(sid) => {
898                    // A conflict was absorbed (ADR-039 DO NOTHING): re-fetch the surviving
899                    // canonical row so the caller receives its real, UNMODIFIED attributes —
900                    // including soft-deleted rows, since the survivor's tombstone state (if
901                    // any) must not be resurrected by the absorbed update either. Use
902                    // record_tok — the surviving row lives in the same namespace as the
903                    // original.
904                    let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
905                        RuntimeError::Internal(format!(
906                            "update_edge: surviving id parse failed: {e}"
907                        ))
908                    })?;
909                    edge = self
910                        .get_edge_including_deleted(&record_tok, surviving_uuid)
911                        .await?
912                        .ok_or_else(|| {
913                            RuntimeError::Internal(format!(
914                                "update_edge: surviving canonical row {surviving_uuid} vanished after update"
915                            ))
916                        })?;
917                }
918                SymmetricEdgeUpdateOutcome::Updated => {
919                    // Reflect canonical endpoints in the returned edge (no conflict absorbed).
920                    edge.source_id = canon_src;
921                    edge.target_id = canon_tgt;
922                }
923                SymmetricEdgeUpdateOutcome::Stale => {
924                    return Err(crate::curation::stale_edge_snapshot_error(edge_id));
925                }
926            }
927        } else {
928            // Non-symmetric: replace_edge_if_unchanged takes namespace from edge.namespace
929            // (not from the graph store's routing namespace), so this is already
930            // record-namespace correct. `graph` is already self.graph(&record_tok)?.
931            // Guarded on the fetched snapshot's revision — a concurrent writer that moved
932            // this edge between the fetch above and this write must be refused, not
933            // silently overwritten by a full-row replacement derived from stale state.
934            // `updated_at` must advance past the snapshot: the guard requires the
935            // replacement revision to be strictly greater than the persisted one.
936            // Make it strictly advance even when two operations land inside one
937            // clock microsecond; saturating to i64::MAX would let the CAS accept
938            // a write without advancing its revision, so that is not a valid
939            // fallback (mirrors the note path).
940            let minimum_updated_at_micros = expected_updated_at
941                .timestamp_micros()
942                .checked_add(1)
943                .ok_or_else(|| {
944                RuntimeError::Internal(format!(
945                    "edge {edge_id} updated_at is already at i64::MAX and cannot advance"
946                ))
947            })?;
948            let now_micros = chrono::Utc::now()
949                .timestamp_micros()
950                .max(minimum_updated_at_micros);
951            edge.updated_at =
952                chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
953                    RuntimeError::Internal(format!(
954                        "edge {edge_id}: computed updated_at {now_micros} is not a valid timestamp"
955                    ))
956                })?;
957            let persisted = graph
958                .replace_edge_if_unchanged(edge.clone(), expected_updated_at, expected_deleted_at)
959                .await?;
960            if !persisted {
961                return Err(crate::curation::stale_edge_snapshot_error(edge_id));
962            }
963        }
964
965        // Audit event: use the record's namespace (record_ns) for the event payload.
966        let event_store = self.events(&record_tok)?;
967        let event = khive_storage::event::Event::new(
968            record_ns.clone(),
969            "update",
970            EventKind::EdgeUpdated,
971            SubstrateKind::Entity,
972            "",
973        )
974        .with_target(edge_id)
975        .with_payload(
976            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
977        );
978        event_store.append_event(event).await.map_err(|e| {
979            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
980        })?;
981
982        Ok(edge)
983    }
984
985    /// Hard-delete an edge by id.
986    ///
987    /// Cascades to remove any `annotates` edges whose target is the deleted edge
988    /// (`annotates` is note → anything; deleting an edge target leaves annotation
989    /// edges dangling if not cleaned up). Returns `true` if the primary
990    /// edge was removed.
991    ///
992    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
993    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
994    /// side effects — it does **not** cascade inbound edges of the non-edge record.
995    pub async fn delete_edge(
996        &self,
997        token: &NamespaceToken,
998        edge_id: Uuid,
999        hard: bool,
1000    ) -> RuntimeResult<bool> {
1001        let mode = if hard {
1002            DeleteMode::Hard
1003        } else {
1004            DeleteMode::Soft
1005        };
1006
1007        // Fetch the edge first to obtain the record's own namespace.
1008        // By-ID ops cross namespace boundaries; all graph routing and audit
1009        // events must use the record namespace, not the caller's (mirrors update_edge).
1010        // For hard delete we also check soft-deleted rows so a soft-deleted edge
1011        // can still be purged via its edge ID.
1012        let edge = if hard {
1013            self.get_edge_including_deleted(token, edge_id).await?
1014        } else {
1015            self.get_edge(token, edge_id).await?
1016        };
1017        let Some(edge) = edge else {
1018            return Ok(false);
1019        };
1020
1021        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
1022        let record_ns: String = edge.namespace.clone();
1023        let record_tok = token.with_namespace(
1024            khive_types::Namespace::parse(&record_ns)
1025                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
1026        );
1027        let graph = self.graph(&record_tok)?;
1028        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
1029
1030        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
1031        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
1032        // delete and the cascade purge run as ONE write transaction: see
1033        // `atomic_hard_delete_with_edge_purge`.
1034        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
1035        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
1036        let deleted = if hard {
1037            self.atomic_hard_delete_with_edge_purge(
1038                edge_hard_delete_statement(edge_id),
1039                edge_id,
1040                &record_ns,
1041                &actor,
1042                SubstrateKind::Entity,
1043            )
1044            .await?
1045        } else {
1046            graph.delete_edge(LinkId::from(edge_id), mode).await?
1047        };
1048        if deleted {
1049            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
1050            let event_store = self.events(&record_tok)?;
1051            let event = khive_storage::event::Event::new(
1052                record_ns.clone(),
1053                "delete",
1054                EventKind::EdgeDeleted,
1055                SubstrateKind::Entity,
1056                "",
1057            )
1058            .with_target(edge_id)
1059            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
1060            event_store.append_event(event).await.map_err(|e| {
1061                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
1062            })?;
1063        }
1064        Ok(deleted)
1065    }
1066
1067    /// Count edges matching `filter` across the caller's visible namespaces.
1068    pub async fn count_edges(
1069        &self,
1070        token: &NamespaceToken,
1071        filter: crate::curation::EdgeListFilter,
1072    ) -> RuntimeResult<u64> {
1073        let namespaces: Vec<String> = token
1074            .visible_namespaces()
1075            .iter()
1076            .map(|namespace| namespace.as_str().to_owned())
1077            .collect();
1078        let graph = self.graph(token)?;
1079        match graph
1080            .count_edges_in_namespaces(&namespaces, filter.clone().into())
1081            .await
1082        {
1083            Ok(count) => Ok(count),
1084            Err(khive_storage::StorageError::Unsupported { operation, .. })
1085                if operation == "count_edges_in_namespaces" =>
1086            {
1087                let mut total = 0;
1088                for namespace in token.visible_namespaces() {
1089                    let scoped = NamespaceToken::for_namespace(namespace.clone());
1090                    total += self
1091                        .graph(&scoped)?
1092                        .count_edges(filter.clone().into())
1093                        .await?;
1094                }
1095                Ok(total)
1096            }
1097            Err(error) => Err(error.into()),
1098        }
1099    }
1100}