1use super::{
2 canonical_edge_endpoints, delete_record_attachments_statement, edge_hard_delete_statement,
3 edge_replace_if_unchanged_statement, edge_soft_delete_statement,
4 edge_symmetric_absorb_or_update_inplace_statement, edge_symmetric_delete_if_conflict_statement,
5 entity_hard_delete_statement, entity_soft_delete_statement, event_append_statements,
6 hard_delete_lineage_warning_statements, note_hard_delete_statement, note_soft_delete_statement,
7 obj, optional_f64, optional_properties, optional_str, parse_edge_relation,
8 purge_incident_edges_statement, push_index_purge_statements, refuse_pack_registry_tags,
9 reject_inapplicable_update_fields, require_uuid, AffectedRowGuard, AtomicOpPlan,
10 AttachmentSubstrate, DeletePlan, EdgeNaturalKey, EventKind, KhiveRuntime, NamespaceToken,
11 PlanStatement, PostCommitEffect, Resolved, RuntimeError, RuntimeResult, SubstrateKind,
12 UpdatePlan, Uuid, Value,
13};
14
15pub(super) async fn prepare_update_edge(
35 runtime: &KhiveRuntime,
36 token: &NamespaceToken,
37 id: Uuid,
38 mut edge: khive_storage::types::Edge,
39 args: &Value,
40) -> RuntimeResult<AtomicOpPlan> {
41 reject_inapplicable_update_fields(args, "edge")?;
42
43 let expected_updated_at = edge.updated_at;
44 let expected_deleted_at = edge.deleted_at;
45
46 let relation_raw = optional_str(args, "relation");
47 let weight = optional_f64(args, "weight")?;
48 let properties = optional_properties(args, "properties")?;
49
50 if let Some(ref p) = properties {
51 crate::secret_gate::check_json_at(p, "edge", "properties")?;
52 }
53 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
54
55 let namespace = edge.namespace.clone();
56 let record_tok = token.with_namespace(
57 khive_types::Namespace::parse(&namespace)
58 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
59 );
60
61 let mut changed_fields: Vec<&'static str> = Vec::new();
62 if let Some(raw) = relation_raw {
63 let relation = parse_edge_relation(raw)?;
64 runtime
65 .validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, relation)
66 .await?;
67 edge.relation = relation;
68 changed_fields.push("relation");
69 }
70 if let Some(w) = weight {
71 if !khive_types::validate_edge_weight(w) {
72 return Err(RuntimeError::InvalidInput(format!(
73 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
74 )));
75 }
76 edge.weight = w;
77 changed_fields.push("weight");
78 }
79 if let Some(p) = properties {
80 edge.metadata = Some(p);
81 changed_fields.push("properties");
82 }
83
84 prepare_update_edge_plan(
85 token,
86 id,
87 edge,
88 expected_updated_at,
89 expected_deleted_at,
90 changed_fields,
91 chrono::Utc::now().timestamp_micros(),
92 )
93}
94
95pub(crate) fn prepare_update_edge_plan(
96 token: &NamespaceToken,
97 id: Uuid,
98 mut edge: khive_storage::Edge,
99 expected_updated_at: chrono::DateTime<chrono::Utc>,
100 expected_deleted_at: Option<chrono::DateTime<chrono::Utc>>,
101 changed_fields: Vec<&'static str>,
102 now_micros: i64,
103) -> RuntimeResult<AtomicOpPlan> {
104 let namespace = edge.namespace.clone();
105 let (canon_src, canon_tgt) =
106 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
107 let now = chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
108 RuntimeError::InvalidInput("invalid canonical edge write timestamp".into())
109 })?;
110
111 let mut statements: Vec<PlanStatement> = Vec::new();
112 let mut edge_natural_key: Option<EdgeNaturalKey> = None;
113
114 if edge.relation.is_symmetric() {
115 let metadata_str = edge
125 .metadata
126 .as_ref()
127 .map(|v| serde_json::to_string(v).unwrap_or_default());
128
129 let minimum_updated_at_micros = expected_updated_at
134 .timestamp_micros()
135 .checked_add(1)
136 .ok_or_else(|| {
137 RuntimeError::Internal(format!(
138 "edge {id} updated_at is already at i64::MAX and cannot advance"
139 ))
140 })?;
141 let symmetric_updated_at_micros = now.timestamp_micros().max(minimum_updated_at_micros);
142 let expected_deleted_at_micros = expected_deleted_at.map(|v| v.timestamp_micros());
143
144 statements.push(PlanStatement {
145 statement: edge_symmetric_delete_if_conflict_statement(
146 &namespace,
147 id,
148 canon_src,
149 canon_tgt,
150 edge.relation,
151 expected_updated_at.timestamp_micros(),
152 expected_deleted_at_micros,
153 ),
154 guard: Some(AffectedRowGuard {
155 expected_min: 0,
156 expected_max: Some(1),
157 }),
158 });
159 statements.push(PlanStatement {
160 statement: edge_symmetric_absorb_or_update_inplace_statement(
161 &namespace,
162 id,
163 canon_src,
164 canon_tgt,
165 edge.relation,
166 edge.weight,
167 symmetric_updated_at_micros,
168 metadata_str.as_deref(),
169 edge.target_backend.as_deref(),
170 expected_updated_at.timestamp_micros(),
171 expected_deleted_at_micros,
172 ),
173 guard: Some(AffectedRowGuard::exactly(1)),
174 });
175
176 edge_natural_key = Some(EdgeNaturalKey {
181 namespace: namespace.clone(),
182 canon_source_id: canon_src,
183 canon_target_id: canon_tgt,
184 relation: edge.relation,
185 });
186 } else {
187 let minimum_updated_at_micros = expected_updated_at
198 .timestamp_micros()
199 .checked_add(1)
200 .ok_or_else(|| {
201 RuntimeError::Internal(format!(
202 "edge {id} updated_at is already at i64::MAX and cannot advance"
203 ))
204 })?;
205 let now_micros = now.timestamp_micros().max(minimum_updated_at_micros);
206 edge.updated_at = chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
207 RuntimeError::Internal(format!(
208 "edge {id}: computed updated_at {now_micros} is not a valid timestamp"
209 ))
210 })?;
211 statements.push(PlanStatement {
212 statement: edge_replace_if_unchanged_statement(
213 &edge,
214 expected_updated_at,
215 expected_deleted_at,
216 ),
217 guard: Some(AffectedRowGuard::exactly(1)),
218 });
219 }
220
221 statements.extend(event_append_statements(
226 token,
227 &namespace,
228 "update",
229 EventKind::EdgeUpdated,
230 SubstrateKind::Entity,
231 id,
232 serde_json::json!({"id": id, "namespace": namespace, "changed_fields": changed_fields}),
233 )?);
234
235 Ok(AtomicOpPlan::Update(Box::new(UpdatePlan {
236 graph_effects: Vec::new(),
237 note_vector_purge: None,
238 note_embedding_inheritance: None,
239 entity_guard: None,
240 note_guard: None,
241 target_id: id,
242 statements,
243 post_commit: PostCommitEffect::None,
244 edge_natural_key,
245 idempotent_noop: false,
246 })))
247}
248
249pub enum AtomicDeleteKind {
266 Entity {
267 specific: Option<String>,
268 entity_type: Option<String>,
269 },
270 Note {
271 specific: Option<String>,
272 },
273 Edge,
274}
275
276pub async fn prepare_delete(
282 runtime: &KhiveRuntime,
283 token: &NamespaceToken,
284 args: &Value,
285 expected_kind: Option<AtomicDeleteKind>,
286) -> RuntimeResult<AtomicOpPlan> {
287 let id = require_uuid(args, "id")?;
288 let hard = obj(args)?
289 .get("hard")
290 .and_then(|v| v.as_bool())
291 .unwrap_or(false);
292
293 let resolved = if hard {
299 runtime.resolve_by_id_including_deleted(token, id).await?
300 } else {
301 runtime.resolve_by_id(token, id).await?
302 };
303
304 prepare_delete_from_snapshot(runtime, token, args, expected_kind, resolved).await
305}
306
307pub(crate) async fn prepare_delete_from_snapshot(
308 runtime: &KhiveRuntime,
309 token: &NamespaceToken,
310 args: &Value,
311 expected_kind: Option<AtomicDeleteKind>,
312 resolved: Option<Resolved>,
313) -> RuntimeResult<AtomicOpPlan> {
314 let id = require_uuid(args, "id")?;
315 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
316 let hard = obj(args)?
317 .get("hard")
318 .and_then(|v| v.as_bool())
319 .unwrap_or(false);
320
321 match resolved {
322 Some(Resolved::Entity(entity)) => {
323 match &expected_kind {
324 None => {}
325 Some(AtomicDeleteKind::Entity {
326 specific: Some(expected),
327 ..
328 }) if &entity.kind != expected => {
329 return Err(RuntimeError::NotFound(format!("{expected} {id}")));
330 }
331 Some(AtomicDeleteKind::Entity { .. }) => {}
332 Some(AtomicDeleteKind::Note { .. }) => {
333 return Err(RuntimeError::NotFound(format!("note {id}")));
334 }
335 Some(AtomicDeleteKind::Edge) => {
336 return Err(RuntimeError::NotFound(format!("edge {id}")));
337 }
338 }
339 if let Some(AtomicDeleteKind::Entity {
340 entity_type: Some(expected),
341 ..
342 }) = &expected_kind
343 {
344 if entity
345 .entity_type
346 .as_deref()
347 .is_some_and(|actual| actual != expected.as_str())
348 {
349 return Err(RuntimeError::NotFound(format!("entity {id}")));
350 }
351 }
352 refuse_pack_registry_tags(&entity.tags, "delete")?;
353 let namespace = entity.namespace.clone();
354 let mut statements = if hard {
359 vec![
360 PlanStatement {
361 statement: delete_record_attachments_statement(
362 id,
363 AttachmentSubstrate::Entity,
364 ),
365 guard: None,
366 },
367 PlanStatement {
368 statement: entity_hard_delete_statement(id),
369 guard: Some(AffectedRowGuard::exactly(1)),
370 },
371 ]
372 } else {
373 let deleted_at = chrono::Utc::now().timestamp_micros();
374 vec![PlanStatement {
375 statement: entity_soft_delete_statement(id, deleted_at),
376 guard: Some(AffectedRowGuard::exactly(1)),
377 }]
378 };
379 if hard {
380 statements.extend(
381 hard_delete_lineage_warning_statements(
382 &namespace,
383 &actor,
384 id,
385 SubstrateKind::Entity,
386 )
387 .into_iter()
388 .map(|statement| PlanStatement {
389 statement,
390 guard: None,
391 }),
392 );
393 statements.push(PlanStatement {
396 statement: purge_incident_edges_statement(id),
397 guard: None,
398 });
399 }
400 push_index_purge_statements(
405 runtime,
406 &mut statements,
407 "fts_entities",
408 &namespace,
409 id,
410 "atomic-delete-entity",
411 )
412 .await?;
413 statements.extend(event_append_statements(
419 token,
420 &namespace,
421 "delete",
422 EventKind::EntityDeleted,
423 SubstrateKind::Entity,
424 id,
425 serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
426 )?);
427 Ok(AtomicOpPlan::Delete(DeletePlan {
428 target_id: id,
429 statements,
430 post_commit: PostCommitEffect::None,
431 }))
432 }
433 Some(Resolved::Note(note)) => {
434 match &expected_kind {
435 None => {}
436 Some(AtomicDeleteKind::Note {
437 specific: Some(expected),
438 }) if ¬e.kind != expected => {
439 return Err(RuntimeError::NotFound(format!("{expected} {id}")));
440 }
441 Some(AtomicDeleteKind::Note { .. }) => {}
442 Some(AtomicDeleteKind::Entity { .. }) => {
443 return Err(RuntimeError::NotFound(format!("entity {id}")));
444 }
445 Some(AtomicDeleteKind::Edge) => {
446 return Err(RuntimeError::NotFound(format!("edge {id}")));
447 }
448 }
449 if let Some(error) = runtime.stream_member_error(¬e).await? {
450 return Err(error);
451 }
452 let namespace = note.namespace.clone();
453 let mut statements = if hard {
457 vec![
458 PlanStatement {
459 statement: delete_record_attachments_statement(
460 id,
461 AttachmentSubstrate::Note,
462 ),
463 guard: None,
464 },
465 PlanStatement {
466 statement: note_hard_delete_statement(id),
467 guard: Some(AffectedRowGuard::exactly(1)),
468 },
469 ]
470 } else {
471 let deleted_at = chrono::Utc::now().timestamp_micros();
472 vec![PlanStatement {
473 statement: note_soft_delete_statement(id, deleted_at),
474 guard: Some(AffectedRowGuard::exactly(1)),
475 }]
476 };
477 if hard {
478 statements.extend(
479 hard_delete_lineage_warning_statements(
480 &namespace,
481 &actor,
482 id,
483 SubstrateKind::Note,
484 )
485 .into_iter()
486 .map(|statement| PlanStatement {
487 statement,
488 guard: None,
489 }),
490 );
491 statements.push(PlanStatement {
492 statement: purge_incident_edges_statement(id),
493 guard: None,
494 });
495 }
496 push_index_purge_statements(
501 runtime,
502 &mut statements,
503 "fts_notes",
504 &namespace,
505 id,
506 "atomic-delete-note",
507 )
508 .await?;
509 statements.extend(event_append_statements(
513 token,
514 &namespace,
515 "delete",
516 EventKind::NoteDeleted,
517 SubstrateKind::Note,
518 id,
519 serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
520 )?);
521 Ok(AtomicOpPlan::Delete(DeletePlan {
522 target_id: id,
523 statements,
524 post_commit: PostCommitEffect::NoteDeleted {
529 note_id: id,
530 kind: note.kind.clone(),
531 },
532 }))
533 }
534 Some(_) => Err(RuntimeError::InvalidInput(format!(
535 "delete target {id} must be an entity, note, or edge"
536 ))),
537 None => match &expected_kind {
541 Some(AtomicDeleteKind::Entity { .. }) => {
542 Err(RuntimeError::NotFound(format!("entity/note {id}")))
543 }
544 Some(AtomicDeleteKind::Note { .. }) => {
545 Err(RuntimeError::NotFound(format!("entity/note {id}")))
546 }
547 Some(AtomicDeleteKind::Edge) | None => {
548 let edge = if hard {
549 runtime.get_edge_including_deleted(token, id).await?
550 } else {
551 runtime.get_edge(token, id).await?
552 };
553 match edge {
554 Some(edge) => prepare_delete_edge(token, id, edge, hard, &actor).await,
555 None => Err(RuntimeError::NotFound(format!("entity/note/edge {id}"))),
556 }
557 }
558 },
559 }
560}
561
562pub(crate) async fn prepare_delete_edge(
571 token: &NamespaceToken,
572 id: Uuid,
573 edge: khive_storage::types::Edge,
574 hard: bool,
575 actor: &str,
576) -> RuntimeResult<AtomicOpPlan> {
577 let namespace = edge.namespace.clone();
578 let mut statements: Vec<PlanStatement> = Vec::new();
579
580 if hard {
581 statements.extend(
582 hard_delete_lineage_warning_statements(&namespace, actor, id, SubstrateKind::Entity)
583 .into_iter()
584 .map(|statement| PlanStatement {
585 statement,
586 guard: None,
587 }),
588 );
589 statements.push(PlanStatement {
594 statement: purge_incident_edges_statement(id),
595 guard: None,
596 });
597 statements.push(PlanStatement {
598 statement: edge_hard_delete_statement(id),
599 guard: Some(AffectedRowGuard::exactly(1)),
600 });
601 } else {
602 let now = chrono::Utc::now().timestamp_micros();
603 statements.push(PlanStatement {
604 statement: edge_soft_delete_statement(id, now),
605 guard: Some(AffectedRowGuard::exactly(1)),
606 });
607 }
608
609 statements.extend(event_append_statements(
610 token,
611 &namespace,
612 "delete",
613 EventKind::EdgeDeleted,
614 SubstrateKind::Entity,
615 id,
616 serde_json::json!({"id": id, "namespace": namespace, "hard": hard}),
617 )?);
618
619 Ok(AtomicOpPlan::Delete(DeletePlan {
620 target_id: id,
621 statements,
622 post_commit: PostCommitEffect::None,
623 }))
624}