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#[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 pub offset: usize,
28 pub page_size: usize,
30 pub has_more: bool,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub next_offset: Option<usize>,
35 pub truncated: bool,
37}
38
39impl KhiveRuntime {
40 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 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 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 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 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 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 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 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 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 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 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
436
437 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 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 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 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 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 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 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 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 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 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 pub async fn update_edge(
816 &self,
817 token: &NamespaceToken,
818 edge_id: Uuid,
819 patch: crate::curation::EdgePatch,
820 ) -> RuntimeResult<Edge> {
821 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}