1use chrono::Utc;
2use uuid::Uuid;
3
4use khive_db::stores::entity::entity_hard_delete_statement;
5use khive_db::stores::event::hard_delete_lineage_warning_statements;
6use khive_db::stores::graph::purge_incident_edges_statement;
7use khive_db::stores::note::note_hard_delete_statement;
8use khive_db::stores::text::insert_document_statements;
9use khive_storage::types::{DeleteMode, SqlStatement, SqlValue};
10use khive_storage::{AttachmentSubstrate, Edge, Entity};
11use khive_types::{EventKind, SubstrateKind};
12
13use crate::atomic_plan::{
14 AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect, UpdatePlan,
15};
16use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
17use crate::curation::entity_fts_document;
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::{KhiveRuntime, NamespaceToken};
20
21#[cfg(any(test, feature = "fault-injection"))]
22use super::{consume_fault, FTS_FAIL_NS, ROLLBACK_CLEANUP_FAIL_NS};
23use super::{
24 legacy_post_commit_result, live_merged_entity_refused, merge_tombstone_restore_refused,
25 record_post_commit_degradation, restore_reindex_failed, PostCommitDegradation,
26};
27
28impl KhiveRuntime {
29 pub(super) async fn atomic_hard_delete_with_edge_purge(
41 &self,
42 row_statement: SqlStatement,
43 node_id: Uuid,
44 namespace: &str,
45 actor: &str,
46 substrate: SubstrateKind,
47 ) -> RuntimeResult<bool> {
48 let mut statements = vec![PlanStatement {
49 statement: row_statement,
50 guard: Some(AffectedRowGuard::exactly(1)),
51 }];
52 if matches!(substrate, SubstrateKind::Entity | SubstrateKind::Note) {
53 statements.push(PlanStatement {
54 statement: khive_db::stores::attachment::delete_record_attachments_statement(
55 node_id,
56 if substrate == SubstrateKind::Entity {
57 AttachmentSubstrate::Entity
58 } else {
59 AttachmentSubstrate::Note
60 },
61 ),
62 guard: None,
63 });
64 }
65 statements.extend(
66 hard_delete_lineage_warning_statements(namespace, actor, node_id, substrate)
67 .into_iter()
68 .map(|statement| PlanStatement {
69 statement,
70 guard: None,
71 }),
72 );
73 statements.push(PlanStatement {
74 statement: purge_incident_edges_statement(node_id),
75 guard: None,
76 });
77 let plan = AtomicOpPlan::Delete(DeletePlan {
78 target_id: node_id,
79 statements,
80 post_commit: PostCommitEffect::None,
81 });
82 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
83 Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
84 Ok(AtomicRunOutcome::RolledBack {
85 failure: AtomicOpFailure::NoteConflict(conflict),
86 ..
87 }) => Err(conflict.into_error().into()),
88 Ok(AtomicRunOutcome::RolledBack {
89 failure: AtomicOpFailure::EntityConflict(conflict),
90 ..
91 }) => Err(conflict.into_error().into()),
92 Ok(AtomicRunOutcome::RolledBack {
93 failure: AtomicOpFailure::GuardFailed { .. },
94 ..
95 }) => Ok(false),
96 Ok(AtomicRunOutcome::RolledBack {
97 failure: AtomicOpFailure::SqlError { message, .. },
98 ..
99 }) => Err(RuntimeError::Internal(format!(
100 "hard delete + edge purge for {node_id} failed: {message}"
101 ))),
102 Ok(AtomicRunOutcome::RolledBack {
104 failure:
105 failure @ (AtomicOpFailure::PreimageMismatch { .. }
106 | AtomicOpFailure::OwnerSnapshotConflict { .. }),
107 ..
108 }) => Err(RuntimeError::Internal(format!(
109 "hard delete + edge purge for {node_id}: unexpected canonical failure {failure:?}"
110 ))),
111 Err(e) => Err(RuntimeError::Internal(format!(
112 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
113 e.0
114 ))),
115 }
116 }
117
118 pub async fn restore_entity(
124 &self,
125 token: &NamespaceToken,
126 id: Uuid,
127 ) -> RuntimeResult<Option<(Entity, bool)>> {
128 let Some(entity) = self
129 .entities(token)?
130 .get_entity_including_deleted(id)
131 .await?
132 else {
133 return Ok(None);
134 };
135 if entity.namespace != token.namespace().as_str() {
136 return Ok(None);
137 }
138 if let Some(kept_id) = entity.merged_into {
150 if entity.deleted_at.is_none() {
151 return Err(live_merged_entity_refused(id, kept_id));
152 }
153 return Err(merge_tombstone_restore_refused(id, kept_id));
154 }
155 if entity.deleted_at.is_none() {
156 return Ok(Some((entity, false)));
157 }
158 let updated_at =
159 Utc::now()
160 .timestamp_micros()
161 .max(entity.updated_at.checked_add(1).ok_or_else(|| {
162 RuntimeError::Internal(format!(
163 "entity {id} updated_at is already at i64::MAX and cannot advance"
164 ))
165 })?);
166 let mut restored = entity;
167 restored.deleted_at = None;
168 restored.updated_at = updated_at;
169 restored.version = restored
170 .version
171 .checked_add(1)
172 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
173 let mut statements = vec![PlanStatement {
174 statement: SqlStatement {
175 sql: "UPDATE entities SET deleted_at=NULL, updated_at=?1, version=version+1 \
176 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL AND version=?4"
177 .into(),
178 params: vec![
179 SqlValue::Integer(updated_at),
180 SqlValue::Text(id.to_string()),
181 SqlValue::Text(token.namespace().as_str().to_owned()),
182 SqlValue::Integer(restored.version - 1),
183 ],
184 label: Some("entity-restore".into()),
185 },
186 guard: Some(AffectedRowGuard::exactly(1)),
187 }];
188 for statement in khive_db::stores::text::delete_document_statements(
193 "fts_entities",
194 &restored.namespace,
195 id,
196 )
197 .into_iter()
198 .chain(insert_document_statements(
199 "fts_entities",
200 &entity_fts_document(&restored),
201 )) {
202 statements.push(PlanStatement {
203 statement,
204 guard: None,
205 });
206 }
207 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
208 graph_effects: Vec::new(),
209 target_id: id,
210 statements,
211 post_commit: PostCommitEffect::None,
212 edge_natural_key: None,
213 idempotent_noop: false,
214 entity_guard: None,
215 note_guard: None,
216 note_vector_purge: None,
217 note_embedding_inheritance: None,
218 }));
219 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
220 Ok(AtomicRunOutcome::Committed { .. }) => {
221 #[cfg(any(test, feature = "fault-injection"))]
224 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
225 return Err(restore_reindex_failed(
226 "entity",
227 id,
228 RuntimeError::Internal("injected FTS failure".to_string()),
229 ));
230 }
231 self.reindex_entity(token, &restored)
232 .await
233 .map_err(|e| restore_reindex_failed("entity", id, e))?;
234 Ok(Some((restored, true)))
235 }
236 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
237 format!("entity restore rolled back: {failure:?}"),
238 )),
239 Err(error) => Err(RuntimeError::Storage(error.0)),
240 }
241 }
242
243 pub async fn restore_edge(
245 &self,
246 token: &NamespaceToken,
247 id: Uuid,
248 ) -> RuntimeResult<Option<(Edge, bool)>> {
249 let Some(edge) = self.get_edge_including_deleted(token, id).await? else {
250 return Ok(None);
251 };
252 if edge.namespace != token.namespace().as_str() {
253 return Ok(None);
254 }
255 if edge.deleted_at.is_none() {
256 return Ok(Some((edge, false)));
257 }
258 let updated_at = Utc::now();
259 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
260 graph_effects: Vec::new(),
261 target_id: id,
262 statements: vec![PlanStatement {
263 statement: SqlStatement {
264 sql: "UPDATE graph_edges SET deleted_at=NULL, updated_at=?1 \
265 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL"
266 .into(),
267 params: vec![
268 SqlValue::Integer(updated_at.timestamp_micros()),
269 SqlValue::Text(id.to_string()),
270 SqlValue::Text(edge.namespace.clone()),
271 ],
272 label: Some("edge-restore".into()),
273 },
274 guard: Some(AffectedRowGuard::exactly(1)),
275 }],
276 post_commit: PostCommitEffect::None,
277 edge_natural_key: None,
278 idempotent_noop: false,
279 entity_guard: None,
280 note_guard: None,
281 note_vector_purge: None,
282 note_embedding_inheritance: None,
283 }));
284 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
285 Ok(AtomicRunOutcome::Committed { .. }) => {
286 let mut restored = edge;
287 restored.deleted_at = None;
288 restored.updated_at = updated_at;
289 Ok(Some((restored, true)))
290 }
291 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
292 format!("edge restore rolled back: {failure:?}"),
293 )),
294 Err(error) => Err(RuntimeError::Storage(error.0)),
295 }
296 }
297
298 pub async fn delete_note(
309 &self,
310 token: &NamespaceToken,
311 id: Uuid,
312 hard: bool,
313 ) -> RuntimeResult<bool> {
314 let (deleted, degradations) = self
315 .delete_note_with_post_commit_report(token, id, hard)
316 .await?;
317 legacy_post_commit_result("delete_note", id, deleted, degradations)
318 }
319
320 pub async fn delete_note_with_post_commit_report(
324 &self,
325 token: &NamespaceToken,
326 id: Uuid,
327 hard: bool,
328 ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
329 let note_store = self.notes(token)?;
330 let note = if hard {
331 match note_store.get_note_including_deleted(id).await? {
332 Some(n) => n,
333 None => return Ok((false, Vec::new())),
334 }
335 } else {
336 match note_store.get_note(id).await? {
337 Some(n) => n,
338 None => return Ok((false, Vec::new())),
339 }
340 };
341 if let Some(error) = self.stream_member_error(¬e).await? {
342 return Err(error);
343 }
344 let mode = if hard {
345 DeleteMode::Hard
346 } else {
347 DeleteMode::Soft
348 };
349
350 let record_tok = token.with_namespace(
352 khive_types::Namespace::parse(¬e.namespace)
353 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
354 );
355 let record_ns = note.namespace.clone();
356 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
357
358 let deleted = if hard {
363 self.atomic_hard_delete_with_edge_purge(
364 note_hard_delete_statement(id),
365 id,
366 &record_ns,
367 &actor,
368 SubstrateKind::Note,
369 )
370 .await?
371 } else {
372 note_store.delete_note(id, mode).await?
373 };
374 let mut degradations = Vec::new();
375 if deleted {
376 let fts_result = match self.text_for_notes(&record_tok) {
377 Ok(store) => store
378 .delete_document(&record_ns, id)
379 .await
380 .map_err(RuntimeError::from),
381 Err(error) => Err(error),
382 };
383 if let Err(error) = fts_result {
384 record_post_commit_degradation(
385 &mut degradations,
386 "delete_note",
387 id,
388 "fts_cleanup",
389 error,
390 );
391 }
392 for model_name in self.registered_embedding_model_names() {
394 let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
395 Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
396 Err(error) => Err(error),
397 };
398 if let Err(error) = vector_result {
399 record_post_commit_degradation(
400 &mut degradations,
401 "delete_note",
402 id,
403 "vector_cleanup",
404 format!("{model_name}: {error}"),
405 );
406 }
407 }
408 let event = khive_storage::event::Event::new(
409 record_ns.clone(),
410 "delete",
411 EventKind::NoteDeleted,
412 SubstrateKind::Note,
413 "",
414 )
415 .with_target(id)
416 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
417 let event_result = match self.events(&record_tok) {
418 Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
419 Err(error) => Err(error),
420 };
421 if let Err(error) = event_result {
422 record_post_commit_degradation(
423 &mut degradations,
424 "delete_note",
425 id,
426 "event_append",
427 error,
428 );
429 }
430 self.fire_note_mutation_hook(¬e.kind, id).await;
437 }
438 Ok((deleted, degradations))
439 }
440
441 pub async fn delete_note_row_first_for_compensation(
460 &self,
461 token: &NamespaceToken,
462 id: Uuid,
463 ) -> RuntimeResult<()> {
464 let note_store = self.notes(token)?;
465 let Some(note) = note_store.get_note_including_deleted(id).await? else {
466 return Ok(());
467 };
468 let record_tok = NamespaceToken::for_namespace(
469 khive_types::Namespace::parse(¬e.namespace)
470 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
471 );
472 let record_ns = note.namespace.clone();
473
474 note_store.delete_note(id, DeleteMode::Hard).await?;
476
477 #[cfg(any(test, feature = "fault-injection"))]
478 {
479 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
480 if armed.as_deref() == Some(record_ns.as_str()) {
481 return Err(RuntimeError::Internal(
482 "row removed but compensation cleanup failed: injected=true".to_string(),
483 ));
484 }
485 }
486
487 let mut cleanup_errors = Vec::new();
488 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
489 cleanup_errors.push(format!("graph={e}"));
490 }
491 if let Err(e) = self
492 .text_for_notes(&record_tok)?
493 .delete_document(&record_ns, id)
494 .await
495 {
496 cleanup_errors.push(format!("fts={e}"));
497 }
498 for model_name in self.registered_embedding_model_names() {
499 if let Err(e) = self
500 .vectors_for_model(&record_tok, &model_name)?
501 .delete(id)
502 .await
503 {
504 cleanup_errors.push(format!("vector[{model_name}]={e}"));
505 }
506 }
507 if cleanup_errors.is_empty() {
508 Ok(())
509 } else {
510 Err(RuntimeError::Internal(format!(
511 "row removed but compensation cleanup failed: {}",
512 cleanup_errors.join("; ")
513 )))
514 }
515 }
516
517 pub async fn delete_entity(
526 &self,
527 token: &NamespaceToken,
528 id: Uuid,
529 hard: bool,
530 ) -> RuntimeResult<bool> {
531 let (deleted, degradations) = self
532 .delete_entity_with_post_commit_report(token, id, hard)
533 .await?;
534 legacy_post_commit_result("delete_entity", id, deleted, degradations)
535 }
536
537 pub async fn delete_entity_with_post_commit_report(
539 &self,
540 token: &NamespaceToken,
541 id: Uuid,
542 hard: bool,
543 ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
544 let entity = if hard {
545 match self
546 .entities(token)?
547 .get_entity_including_deleted(id)
548 .await?
549 {
550 Some(e) => e,
551 None => return Ok((false, Vec::new())),
552 }
553 } else {
554 match self.entities(token)?.get_entity(id).await? {
555 Some(e) => e,
556 None => return Ok((false, Vec::new())),
557 }
558 };
559 let mode = if hard {
560 DeleteMode::Hard
561 } else {
562 DeleteMode::Soft
563 };
564
565 let record_tok = token.with_namespace(
567 khive_types::Namespace::parse(&entity.namespace)
568 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
569 );
570 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
571
572 let deleted = if hard {
577 self.atomic_hard_delete_with_edge_purge(
579 entity_hard_delete_statement(id),
580 id,
581 &entity.namespace,
582 &actor,
583 SubstrateKind::Entity,
584 )
585 .await?
586 } else {
587 self.entities(token)?.delete_entity(id, mode).await?
588 };
589 let mut degradations = Vec::new();
590 if deleted {
591 let ns = entity.namespace.clone();
592 let fts_result = match self.text(&record_tok) {
593 Ok(store) => store
594 .delete_document(&ns, id)
595 .await
596 .map_err(RuntimeError::from),
597 Err(error) => Err(error),
598 };
599 if let Err(error) = fts_result {
600 record_post_commit_degradation(
601 &mut degradations,
602 "delete_entity",
603 id,
604 "fts_cleanup",
605 error,
606 );
607 }
608 for model_name in self.registered_embedding_model_names() {
609 let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
610 Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
611 Err(error) => Err(error),
612 };
613 if let Err(error) = vector_result {
614 record_post_commit_degradation(
615 &mut degradations,
616 "delete_entity",
617 id,
618 "vector_cleanup",
619 format!("{model_name}: {error}"),
620 );
621 }
622 }
623 let event = khive_storage::event::Event::new(
624 ns.clone(),
625 "delete",
626 EventKind::EntityDeleted,
627 SubstrateKind::Entity,
628 "",
629 )
630 .with_target(id)
631 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
632 let event_result = match self.events(&record_tok) {
633 Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
634 Err(error) => Err(error),
635 };
636 if let Err(error) = event_result {
637 record_post_commit_degradation(
638 &mut degradations,
639 "delete_entity",
640 id,
641 "event_append",
642 error,
643 );
644 }
645 }
646 Ok((deleted, degradations))
647 }
648
649 pub(crate) async fn delete_entity_attachments_on_core(&self, id: Uuid) -> RuntimeResult<bool> {
650 let core = self.core();
651 drop(core.attachments()?);
652 let statement = khive_db::stores::attachment::delete_record_attachments_statement(
653 id,
654 AttachmentSubstrate::Entity,
655 );
656 Ok(core.sql().writer().await?.execute(statement).await? > 0)
657 }
658}