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 let mut reader = self.sql().reader().await?;
193 let record_ns = reader
194 .query_scalar(SqlStatement {
195 sql: "SELECT namespace FROM graph_edges \
196 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
197 .into(),
198 params: vec![SqlValue::Text(edge_id.to_string())],
199 label: Some("get_edge_namespace".into()),
200 })
201 .await?;
202
203 let Some(SqlValue::Text(record_ns)) = record_ns else {
204 return Ok(None);
205 };
206 let record_tok = NamespaceToken::for_namespace(
209 khive_types::Namespace::parse(&record_ns)
210 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
211 );
212 Ok(self
213 .graph(&record_tok)?
214 .get_edge(LinkId::from(edge_id))
215 .await?)
216 }
217
218 pub async fn get_edges_by_id(
227 &self,
228 _token: &NamespaceToken,
229 ids: &[Uuid],
230 ) -> RuntimeResult<Vec<Option<Edge>>> {
231 let mut edges = Vec::with_capacity(ids.len());
232 for chunk in ids.chunks(900) {
233 let window = self.prepare_edge_read_window(chunk).await?;
234 edges.extend(
235 Self::hydrate_edge_read_window(chunk, window, |record_token| {
236 self.graph(record_token)
237 })
238 .await?,
239 );
240 }
241 Ok(edges)
242 }
243
244 pub(super) async fn prepare_edge_read_window(
245 &self,
246 ids: &[Uuid],
247 ) -> RuntimeResult<EdgeReadWindow> {
248 let placeholders = (1..=ids.len())
249 .map(|index| format!("?{index}"))
250 .collect::<Vec<_>>()
251 .join(",");
252 let mut reader = self.sql().reader().await?;
253 let rows = reader
254 .query_all(SqlStatement {
255 sql: format!(
256 "SELECT id, namespace FROM graph_edges WHERE id IN ({placeholders}) AND deleted_at IS NULL"
257 ),
258 params: ids.iter().map(|id| SqlValue::Text(id.to_string())).collect(),
259 label: Some("get_edge_namespace".into()),
260 })
261 .await?;
262 let mut namespaces = HashMap::with_capacity(rows.len());
263 for row in rows {
264 let Some(SqlValue::Text(id)) = row.columns.first().map(|column| &column.value) else {
265 return Err(RuntimeError::Internal(
266 "edge namespace lookup returned an invalid id".into(),
267 ));
268 };
269 let id = Uuid::parse_str(id).map_err(|e| {
270 RuntimeError::Internal(format!("edge namespace lookup returned an invalid id: {e}"))
271 })?;
272 let value = row
273 .columns
274 .get(1)
275 .map(|column| column.value.clone())
276 .unwrap_or(SqlValue::Null);
277 namespaces.insert(id, value);
278 }
279 let mut window = EdgeReadWindow {
280 outcomes: (0..ids.len()).map(|_| Some(Ok(None))).collect(),
281 groups: Vec::new(),
282 };
283 let mut group_indices = HashMap::new();
284 for (index, id) in ids.iter().enumerate() {
285 let Some(SqlValue::Text(record_ns)) = namespaces.get(id) else {
286 continue;
287 };
288 match khive_types::Namespace::parse(record_ns) {
289 Ok(namespace) => {
290 let next_group = window.groups.len();
291 let group = *group_indices.entry(record_ns.clone()).or_insert(next_group);
292 if group == next_group {
293 window.groups.push((namespace, Vec::new()));
294 }
295 window.groups[group].1.push(index);
296 window.outcomes[index] = None;
297 }
298 Err(error) => {
299 window.outcomes[index] = Some(Err(RuntimeError::Internal(format!(
300 "edge namespace invalid: {error}"
301 ))));
302 }
303 }
304 }
305 Ok(window)
306 }
307
308 pub(super) async fn hydrate_edge_read_window<F>(
309 ids: &[Uuid],
310 mut window: EdgeReadWindow,
311 mut graph: F,
312 ) -> RuntimeResult<Vec<Option<Edge>>>
313 where
314 F: FnMut(&NamespaceToken) -> RuntimeResult<std::sync::Arc<dyn khive_storage::GraphStore>>,
315 {
316 for (namespace, indices) in window.groups {
317 let record_token = NamespaceToken::for_namespace(namespace);
318 let group_ids: Vec<LinkId> = indices
319 .iter()
320 .map(|&index| LinkId::from(ids[index]))
321 .collect();
322 let outcomes = match graph(&record_token) {
323 Ok(store) => store
324 .get_edge_read_outcomes(&group_ids)
325 .await
326 .map_err(RuntimeError::from),
327 Err(error) => Err(error),
328 };
329 match outcomes {
330 Ok(outcomes) if outcomes.len() == indices.len() => {
331 for (index, outcome) in indices.into_iter().zip(outcomes) {
332 window.outcomes[index] = Some(outcome.map_err(RuntimeError::from));
333 }
334 }
335 Ok(_) => {
336 window.outcomes[indices[0]] = Some(Err(RuntimeError::Internal(
337 "edge batch returned an invalid outcome count".into(),
338 )));
339 }
340 Err(error) => {
341 window.outcomes[indices[0]] = Some(Err(error));
342 }
343 }
344 }
345 window
346 .outcomes
347 .into_iter()
348 .map(|outcome| {
349 outcome.unwrap_or_else(|| {
350 Err(RuntimeError::Internal(
351 "edge batch omitted an input outcome".into(),
352 ))
353 })
354 })
355 .collect()
356 }
357
358 pub async fn get_edge_visible(
363 &self,
364 token: &NamespaceToken,
365 edge_id: Uuid,
366 ) -> RuntimeResult<Option<Edge>> {
367 self.get_edge(token, edge_id).await
368 }
369
370 pub async fn get_edge_including_deleted(
376 &self,
377 _token: &NamespaceToken,
378 edge_id: Uuid,
379 ) -> RuntimeResult<Option<Edge>> {
380 let mut reader = self.sql().reader().await?;
381 let record_ns = reader
382 .query_scalar(SqlStatement {
383 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
384 params: vec![SqlValue::Text(edge_id.to_string())],
385 label: Some("get_edge_including_deleted_namespace".into()),
386 })
387 .await?;
388
389 let Some(SqlValue::Text(record_ns)) = record_ns else {
390 return Ok(None);
391 };
392 let record_tok = NamespaceToken::for_namespace(
394 khive_types::Namespace::parse(&record_ns)
395 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
396 );
397 Ok(self
398 .graph(&record_tok)?
399 .get_edge_including_deleted(LinkId::from(edge_id))
400 .await?)
401 }
402
403 pub async fn get_edge_by_natural_key_including_deleted(
417 &self,
418 token: &NamespaceToken,
419 namespace: &str,
420 source_id: Uuid,
421 target_id: Uuid,
422 relation: EdgeRelation,
423 ) -> RuntimeResult<Option<Edge>> {
424 Ok(self
425 .graph(token)?
426 .get_edge_by_natural_key_including_deleted(namespace, source_id, target_id, relation)
427 .await?)
428 }
429
430 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
435
436 pub async fn list_edges(
441 &self,
442 token: &NamespaceToken,
443 filter: crate::curation::EdgeListFilter,
444 limit: u32,
445 offset: u32,
446 ) -> RuntimeResult<Vec<Edge>> {
447 let limit = limit.min(Self::EDGE_LIST_MAX_LIMIT);
448 let visible = token.visible_namespaces();
449
450 if let [ns] = visible {
453 let temp = NamespaceToken::for_namespace(ns.clone());
454 let page = self
455 .graph(&temp)?
456 .query_edges(
457 filter.into(),
458 vec![SortOrder {
459 field: EdgeSortField::CreatedAt,
460 direction: khive_storage::types::SortDirection::Asc,
461 }],
462 PageRequest {
463 offset: offset.into(),
464 limit,
465 },
466 )
467 .await?;
468 return Ok(page.items);
469 }
470
471 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
478 let sort = vec![SortOrder {
479 field: EdgeSortField::CreatedAt,
480 direction: khive_storage::types::SortDirection::Asc,
481 }];
482 let graph = self.graph(token)?;
483 match graph
484 .query_edges_in_namespaces(
485 &ns_strs,
486 filter.clone().into(),
487 sort.clone(),
488 PageRequest {
489 offset: offset.into(),
490 limit,
491 },
492 )
493 .await
494 {
495 Ok(page) => Ok(page.items),
496 Err(khive_storage::StorageError::Unsupported { operation, .. })
497 if operation == "query_edges_in_namespaces" =>
498 {
499 let fetch_limit = offset.saturating_add(limit);
512 let mut namespace_prefixes = Vec::new();
513 for ns in visible {
514 let temp = NamespaceToken::for_namespace(ns.clone());
515 let page = self
516 .graph(&temp)?
517 .query_edges(
518 filter.clone().into(),
519 sort.clone(),
520 PageRequest {
521 offset: 0,
522 limit: fetch_limit,
523 },
524 )
525 .await?;
526 namespace_prefixes.push(page.items);
527 }
528 Ok(Self::merge_paged_namespace_edges(
529 namespace_prefixes,
530 offset,
531 limit,
532 ))
533 }
534 Err(error) => Err(error.into()),
535 }
536 }
537
538 pub(super) fn merge_paged_namespace_edges(
553 namespace_prefixes: Vec<Vec<Edge>>,
554 offset: u32,
555 limit: u32,
556 ) -> Vec<Edge> {
557 let mut results: Vec<Edge> = namespace_prefixes.into_iter().flatten().collect();
558 results.sort_by_key(|e| (e.created_at, Uuid::from(e.id)));
559 let start = (offset as usize).min(results.len());
560 let end = (start + limit as usize).min(results.len());
561 results[start..end].to_vec()
562 }
563
564 pub async fn list_edges_after(
576 &self,
577 token: &NamespaceToken,
578 filter: crate::curation::EdgeListFilter,
579 after: Option<Uuid>,
580 limit: u32,
581 ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
582 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
583 let visible = token.visible_namespaces();
584 let limit_usize = limit as usize;
585 let cursor_store = self.graph(token)?;
586 let after = match after {
587 Some(id) => {
588 let edge = self
589 .get_edge_including_deleted(token, id)
590 .await?
591 .ok_or_else(|| RuntimeError::NotFound(format!("edge cursor {id}")))?;
592 Self::ensure_namespace_visible(&edge.namespace, token)?;
593 let sequence = cursor_store.edge_sequence(id).await?.ok_or_else(|| {
594 RuntimeError::Internal(format!(
595 "edge cursor {id} has no insertion-sequence ledger row"
596 ))
597 })?;
598 Some(SeekCursor { sequence, id })
599 }
600 None => None,
601 };
602
603 if let [ns] = visible {
604 let temp = NamespaceToken::for_namespace(ns.clone());
605 let page = self
606 .graph(&temp)?
607 .query_edges_sequence_after(filter.into(), after, limit)
608 .await?;
609 return Ok((page.items, page.next_after.map(|cursor| cursor.id)));
610 }
611
612 let probe_limit = limit.saturating_add(1);
616 let mut results = Vec::new();
617 for ns in visible {
618 let temp = NamespaceToken::for_namespace(ns.clone());
619 let page = self
620 .graph(&temp)?
621 .query_edges_sequence_after(filter.clone().into(), after, probe_limit)
622 .await?;
623 results.extend(page.items);
624 }
625 let ids = results
626 .iter()
627 .map(|edge| Uuid::from(edge.id))
628 .collect::<Vec<_>>();
629 let sequences = cursor_store
630 .edge_sequences(&ids)
631 .await?
632 .into_iter()
633 .collect::<HashMap<_, _>>();
634 if let Some(missing) = ids.iter().find(|id| !sequences.contains_key(id)) {
635 return Err(RuntimeError::Internal(format!(
636 "edge {missing} has no insertion-sequence ledger row"
637 )));
638 }
639 results.sort_by_key(|edge| {
640 let id = Uuid::from(edge.id);
641 (sequences[&id], id)
642 });
643 results.dedup_by_key(|e| Uuid::from(e.id));
644 let has_more = results.len() > limit_usize;
645 if has_more {
646 results.truncate(limit_usize);
647 }
648 let next_after = if has_more {
649 results.last().map(|e| Uuid::from(e.id))
650 } else {
651 None
652 };
653 Ok((results, next_after))
654 }
655
656 pub async fn count_edges_by_relation(
660 &self,
661 token: &NamespaceToken,
662 ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
663 let namespaces: Vec<String> = token
664 .visible_namespaces()
665 .iter()
666 .map(|namespace| namespace.as_str().to_owned())
667 .collect();
668 let graph = self.graph(token)?;
669 let counts = match graph
670 .count_edges_by_relation_in_namespaces(&namespaces)
671 .await
672 {
673 Ok(counts) => counts,
674 Err(khive_storage::StorageError::Unsupported { operation, .. })
675 if operation == "count_edges_by_relation_in_namespaces" =>
676 {
677 let mut totals = HashMap::new();
678 for namespace in token.visible_namespaces() {
679 let scoped = NamespaceToken::for_namespace(namespace.clone());
680 for (relation, count) in self.graph(&scoped)?.count_edges_by_relation().await? {
681 *totals.entry(relation).or_insert(0) += count;
682 }
683 }
684 return Ok(totals
685 .into_iter()
686 .map(|(relation, count)| (relation.to_string(), count))
687 .collect());
688 }
689 Err(error) => return Err(error.into()),
690 };
691 Ok(counts
692 .into_iter()
693 .map(|(relation, count)| (relation.to_string(), count))
694 .collect())
695 }
696
697 pub async fn count_edges_by_endpoint_base(
706 &self,
707 token: &NamespaceToken,
708 ) -> RuntimeResult<khive_storage::types::EdgeEndpointBaseCounts> {
709 use khive_storage::types::EdgeEndpointBaseCounts;
710
711 let namespaces: Vec<String> = token
712 .visible_namespaces()
713 .iter()
714 .map(|namespace| namespace.as_str().to_owned())
715 .collect();
716 let graph = self.graph(token)?;
717 match graph
718 .count_edges_by_endpoint_base_in_namespaces(&namespaces)
719 .await
720 {
721 Ok(counts) => Ok(counts),
722 Err(khive_storage::StorageError::Unsupported { operation, .. })
723 if operation == "count_edges_by_endpoint_base_in_namespaces"
724 || operation == "count_edges_by_endpoint_base" =>
725 {
726 let mut totals = EdgeEndpointBaseCounts::default();
727 for namespace in token.visible_namespaces() {
728 let scoped = NamespaceToken::for_namespace(namespace.clone());
729 let counts = self.graph(&scoped)?.count_edges_by_endpoint_base().await?;
730 totals.entity_entity =
731 totals.entity_entity.saturating_add(counts.entity_entity);
732 totals.entity_note = totals.entity_note.saturating_add(counts.entity_note);
733 totals.note_entity = totals.note_entity.saturating_add(counts.note_entity);
734 totals.note_note = totals.note_note.saturating_add(counts.note_note);
735 totals.unresolved = totals.unresolved.saturating_add(counts.unresolved);
736 }
737 Ok(totals)
738 }
739 Err(error) => Err(error.into()),
740 }
741 }
742
743 pub async fn edge_stats_counts(
753 &self,
754 token: &NamespaceToken,
755 ) -> RuntimeResult<super::EdgeStatsCounts> {
756 let namespaces: Vec<String> = token
757 .visible_namespaces()
758 .iter()
759 .map(|namespace| namespace.as_str().to_owned())
760 .collect();
761 let graph = self.graph(token)?;
762 match graph
763 .count_edges_by_relation_and_endpoint_base_in_namespaces(&namespaces)
764 .await
765 {
766 Ok(groups) => {
767 let mut counts = super::EdgeStatsCounts::default();
768 for (relation, by_base) in groups {
769 let relation_total = by_base.total();
770 counts.edges = counts.edges.saturating_add(relation_total);
771 let slot = counts
772 .edges_by_relation
773 .entry(relation.to_string())
774 .or_insert(0);
775 *slot = slot.saturating_add(relation_total);
776 let totals = &mut counts.edges_by_endpoint_base;
777 totals.entity_entity =
778 totals.entity_entity.saturating_add(by_base.entity_entity);
779 totals.entity_note = totals.entity_note.saturating_add(by_base.entity_note);
780 totals.note_entity = totals.note_entity.saturating_add(by_base.note_entity);
781 totals.note_note = totals.note_note.saturating_add(by_base.note_note);
782 totals.unresolved = totals.unresolved.saturating_add(by_base.unresolved);
783 }
784 Ok(counts)
785 }
786 Err(khive_storage::StorageError::Unsupported { operation, .. })
787 if operation == "count_edges_by_relation_and_endpoint_base_in_namespaces" =>
788 {
789 Ok(super::EdgeStatsCounts {
790 edges: self
791 .count_edges(token, crate::curation::EdgeListFilter::default())
792 .await?,
793 edges_by_relation: self.count_edges_by_relation(token).await?,
794 edges_by_endpoint_base: self.count_edges_by_endpoint_base(token).await?,
795 })
796 }
797 Err(error) => Err(error.into()),
798 }
799 }
800
801 pub async fn update_edge(
815 &self,
816 token: &NamespaceToken,
817 edge_id: Uuid,
818 patch: crate::curation::EdgePatch,
819 ) -> RuntimeResult<Edge> {
820 let graph_for_fetch = self.graph(token)?;
823 let mut edge = graph_for_fetch
824 .get_edge(LinkId::from(edge_id))
825 .await?
826 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
827 let expected_updated_at = edge.updated_at;
828 let expected_deleted_at = edge.deleted_at;
829 #[cfg(test)]
830 crate::curation::race_seam::pause_after_read().await;
831
832 let record_ns: String = edge.namespace.clone();
837 let record_tok = token.with_namespace(
838 khive_types::Namespace::parse(&record_ns)
839 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
840 );
841 let graph = self.graph(&record_tok)?;
842
843 let mut changed_fields: Vec<&'static str> = Vec::new();
844 if let Some(r) = patch.relation {
845 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
848 .await?;
849 edge.relation = r;
850 changed_fields.push("relation");
851 }
852 if let Some(w) = patch.weight {
853 if !khive_types::validate_edge_weight(w) {
856 return Err(RuntimeError::InvalidInput(format!(
857 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
858 )));
859 }
860 edge.weight = w;
861 changed_fields.push("weight");
862 }
863 if let Some(props) = patch.properties {
864 crate::secret_gate::reject_reserved_secret_gate_property(Some(&props))?;
865 edge.metadata = Some(props);
866 }
867
868 let (canon_src, canon_tgt) =
878 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
879
880 if edge.relation.is_symmetric() {
881 let outcome = graph
882 .update_symmetric_edge_if_unchanged(SymmetricEdgeUpdateRequest {
883 namespace: record_ns.clone(),
884 id: edge_id.into(),
885 source_id: canon_src,
886 target_id: canon_tgt,
887 relation: edge.relation,
888 weight: edge.weight,
889 metadata: edge.metadata.clone(),
890 expected_updated_at_micros: expected_updated_at.timestamp_micros(),
891 expected_deleted_at_micros: expected_deleted_at.map(|v| v.timestamp_micros()),
892 })
893 .await?;
894
895 match outcome {
896 SymmetricEdgeUpdateOutcome::Absorbed(sid) => {
897 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
904 RuntimeError::Internal(format!(
905 "update_edge: surviving id parse failed: {e}"
906 ))
907 })?;
908 edge = self
909 .get_edge_including_deleted(&record_tok, surviving_uuid)
910 .await?
911 .ok_or_else(|| {
912 RuntimeError::Internal(format!(
913 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
914 ))
915 })?;
916 }
917 SymmetricEdgeUpdateOutcome::Updated => {
918 edge.source_id = canon_src;
920 edge.target_id = canon_tgt;
921 }
922 SymmetricEdgeUpdateOutcome::Stale => {
923 return Err(crate::curation::stale_edge_snapshot_error(edge_id));
924 }
925 }
926 } else {
927 let minimum_updated_at_micros = expected_updated_at
940 .timestamp_micros()
941 .checked_add(1)
942 .ok_or_else(|| {
943 RuntimeError::Internal(format!(
944 "edge {edge_id} updated_at is already at i64::MAX and cannot advance"
945 ))
946 })?;
947 let now_micros = chrono::Utc::now()
948 .timestamp_micros()
949 .max(minimum_updated_at_micros);
950 edge.updated_at =
951 chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
952 RuntimeError::Internal(format!(
953 "edge {edge_id}: computed updated_at {now_micros} is not a valid timestamp"
954 ))
955 })?;
956 let persisted = graph
957 .replace_edge_if_unchanged(edge.clone(), expected_updated_at, expected_deleted_at)
958 .await?;
959 if !persisted {
960 return Err(crate::curation::stale_edge_snapshot_error(edge_id));
961 }
962 }
963
964 let event_store = self.events(&record_tok)?;
966 let event = khive_storage::event::Event::new(
967 record_ns.clone(),
968 "update",
969 EventKind::EdgeUpdated,
970 SubstrateKind::Entity,
971 "",
972 )
973 .with_target(edge_id)
974 .with_payload(
975 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
976 );
977 event_store.append_event(event).await.map_err(|e| {
978 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
979 })?;
980
981 Ok(edge)
982 }
983
984 pub async fn delete_edge(
995 &self,
996 token: &NamespaceToken,
997 edge_id: Uuid,
998 hard: bool,
999 ) -> RuntimeResult<bool> {
1000 let mode = if hard {
1001 DeleteMode::Hard
1002 } else {
1003 DeleteMode::Soft
1004 };
1005
1006 let edge = if hard {
1012 self.get_edge_including_deleted(token, edge_id).await?
1013 } else {
1014 self.get_edge(token, edge_id).await?
1015 };
1016 let Some(edge) = edge else {
1017 return Ok(false);
1018 };
1019
1020 let record_ns: String = edge.namespace.clone();
1022 let record_tok = token.with_namespace(
1023 khive_types::Namespace::parse(&record_ns)
1024 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
1025 );
1026 let graph = self.graph(&record_tok)?;
1027 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
1028
1029 let deleted = if hard {
1036 self.atomic_hard_delete_with_edge_purge(
1037 edge_hard_delete_statement(edge_id),
1038 edge_id,
1039 &record_ns,
1040 &actor,
1041 SubstrateKind::Entity,
1042 )
1043 .await?
1044 } else {
1045 graph.delete_edge(LinkId::from(edge_id), mode).await?
1046 };
1047 if deleted {
1048 let event_store = self.events(&record_tok)?;
1050 let event = khive_storage::event::Event::new(
1051 record_ns.clone(),
1052 "delete",
1053 EventKind::EdgeDeleted,
1054 SubstrateKind::Entity,
1055 "",
1056 )
1057 .with_target(edge_id)
1058 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
1059 event_store.append_event(event).await.map_err(|e| {
1060 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
1061 })?;
1062 }
1063 Ok(deleted)
1064 }
1065
1066 pub async fn count_edges(
1068 &self,
1069 token: &NamespaceToken,
1070 filter: crate::curation::EdgeListFilter,
1071 ) -> RuntimeResult<u64> {
1072 let namespaces: Vec<String> = token
1073 .visible_namespaces()
1074 .iter()
1075 .map(|namespace| namespace.as_str().to_owned())
1076 .collect();
1077 let graph = self.graph(token)?;
1078 match graph
1079 .count_edges_in_namespaces(&namespaces, filter.clone().into())
1080 .await
1081 {
1082 Ok(count) => Ok(count),
1083 Err(khive_storage::StorageError::Unsupported { operation, .. })
1084 if operation == "count_edges_in_namespaces" =>
1085 {
1086 let mut total = 0;
1087 for namespace in token.visible_namespaces() {
1088 let scoped = NamespaceToken::for_namespace(namespace.clone());
1089 total += self
1090 .graph(&scoped)?
1091 .count_edges(filter.clone().into())
1092 .await?;
1093 }
1094 Ok(total)
1095 }
1096 Err(error) => Err(error.into()),
1097 }
1098 }
1099}