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 Err(e) => Err(RuntimeError::Internal(format!(
103 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
104 e.0
105 ))),
106 }
107 }
108
109 pub async fn restore_entity(
115 &self,
116 token: &NamespaceToken,
117 id: Uuid,
118 ) -> RuntimeResult<Option<(Entity, bool)>> {
119 let Some(entity) = self
120 .entities(token)?
121 .get_entity_including_deleted(id)
122 .await?
123 else {
124 return Ok(None);
125 };
126 if entity.namespace != token.namespace().as_str() {
127 return Ok(None);
128 }
129 if let Some(kept_id) = entity.merged_into {
141 if entity.deleted_at.is_none() {
142 return Err(live_merged_entity_refused(id, kept_id));
143 }
144 return Err(merge_tombstone_restore_refused(id, kept_id));
145 }
146 if entity.deleted_at.is_none() {
147 return Ok(Some((entity, false)));
148 }
149 let updated_at =
150 Utc::now()
151 .timestamp_micros()
152 .max(entity.updated_at.checked_add(1).ok_or_else(|| {
153 RuntimeError::Internal(format!(
154 "entity {id} updated_at is already at i64::MAX and cannot advance"
155 ))
156 })?);
157 let mut restored = entity;
158 restored.deleted_at = None;
159 restored.updated_at = updated_at;
160 restored.version = restored
161 .version
162 .checked_add(1)
163 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
164 let mut statements = vec![PlanStatement {
165 statement: SqlStatement {
166 sql: "UPDATE entities SET deleted_at=NULL, updated_at=?1, version=version+1 \
167 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL AND version=?4"
168 .into(),
169 params: vec![
170 SqlValue::Integer(updated_at),
171 SqlValue::Text(id.to_string()),
172 SqlValue::Text(token.namespace().as_str().to_owned()),
173 SqlValue::Integer(restored.version - 1),
174 ],
175 label: Some("entity-restore".into()),
176 },
177 guard: Some(AffectedRowGuard::exactly(1)),
178 }];
179 for statement in khive_db::stores::text::delete_document_statements(
184 "fts_entities",
185 &restored.namespace,
186 id,
187 )
188 .into_iter()
189 .chain(insert_document_statements(
190 "fts_entities",
191 &entity_fts_document(&restored),
192 )) {
193 statements.push(PlanStatement {
194 statement,
195 guard: None,
196 });
197 }
198 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
199 graph_effects: Vec::new(),
200 target_id: id,
201 statements,
202 post_commit: PostCommitEffect::None,
203 edge_natural_key: None,
204 idempotent_noop: false,
205 entity_guard: None,
206 note_guard: None,
207 note_vector_purge: None,
208 note_embedding_inheritance: None,
209 }));
210 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
211 Ok(AtomicRunOutcome::Committed { .. }) => {
212 #[cfg(any(test, feature = "fault-injection"))]
215 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
216 return Err(restore_reindex_failed(
217 "entity",
218 id,
219 RuntimeError::Internal("injected FTS failure".to_string()),
220 ));
221 }
222 self.reindex_entity(token, &restored)
223 .await
224 .map_err(|e| restore_reindex_failed("entity", id, e))?;
225 Ok(Some((restored, true)))
226 }
227 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
228 format!("entity restore rolled back: {failure:?}"),
229 )),
230 Err(error) => Err(RuntimeError::Storage(error.0)),
231 }
232 }
233
234 pub async fn restore_edge(
236 &self,
237 token: &NamespaceToken,
238 id: Uuid,
239 ) -> RuntimeResult<Option<(Edge, bool)>> {
240 let Some(edge) = self.get_edge_including_deleted(token, id).await? else {
241 return Ok(None);
242 };
243 if edge.namespace != token.namespace().as_str() {
244 return Ok(None);
245 }
246 if edge.deleted_at.is_none() {
247 return Ok(Some((edge, false)));
248 }
249 let updated_at = Utc::now();
250 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
251 graph_effects: Vec::new(),
252 target_id: id,
253 statements: vec![PlanStatement {
254 statement: SqlStatement {
255 sql: "UPDATE graph_edges SET deleted_at=NULL, updated_at=?1 \
256 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL"
257 .into(),
258 params: vec![
259 SqlValue::Integer(updated_at.timestamp_micros()),
260 SqlValue::Text(id.to_string()),
261 SqlValue::Text(edge.namespace.clone()),
262 ],
263 label: Some("edge-restore".into()),
264 },
265 guard: Some(AffectedRowGuard::exactly(1)),
266 }],
267 post_commit: PostCommitEffect::None,
268 edge_natural_key: None,
269 idempotent_noop: false,
270 entity_guard: None,
271 note_guard: None,
272 note_vector_purge: None,
273 note_embedding_inheritance: None,
274 }));
275 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
276 Ok(AtomicRunOutcome::Committed { .. }) => {
277 let mut restored = edge;
278 restored.deleted_at = None;
279 restored.updated_at = updated_at;
280 Ok(Some((restored, true)))
281 }
282 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
283 format!("edge restore rolled back: {failure:?}"),
284 )),
285 Err(error) => Err(RuntimeError::Storage(error.0)),
286 }
287 }
288
289 pub async fn delete_note(
300 &self,
301 token: &NamespaceToken,
302 id: Uuid,
303 hard: bool,
304 ) -> RuntimeResult<bool> {
305 let (deleted, degradations) = self
306 .delete_note_with_post_commit_report(token, id, hard)
307 .await?;
308 legacy_post_commit_result("delete_note", id, deleted, degradations)
309 }
310
311 pub async fn delete_note_with_post_commit_report(
315 &self,
316 token: &NamespaceToken,
317 id: Uuid,
318 hard: bool,
319 ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
320 let note_store = self.notes(token)?;
321 let note = if hard {
322 match note_store.get_note_including_deleted(id).await? {
323 Some(n) => n,
324 None => return Ok((false, Vec::new())),
325 }
326 } else {
327 match note_store.get_note(id).await? {
328 Some(n) => n,
329 None => return Ok((false, Vec::new())),
330 }
331 };
332 if let Some(error) = self.stream_member_error(¬e).await? {
333 return Err(error);
334 }
335 let mode = if hard {
336 DeleteMode::Hard
337 } else {
338 DeleteMode::Soft
339 };
340
341 let record_tok = token.with_namespace(
343 khive_types::Namespace::parse(¬e.namespace)
344 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
345 );
346 let record_ns = note.namespace.clone();
347 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
348
349 let deleted = if hard {
354 self.atomic_hard_delete_with_edge_purge(
355 note_hard_delete_statement(id),
356 id,
357 &record_ns,
358 &actor,
359 SubstrateKind::Note,
360 )
361 .await?
362 } else {
363 note_store.delete_note(id, mode).await?
364 };
365 let mut degradations = Vec::new();
366 if deleted {
367 let fts_result = match self.text_for_notes(&record_tok) {
368 Ok(store) => store
369 .delete_document(&record_ns, id)
370 .await
371 .map_err(RuntimeError::from),
372 Err(error) => Err(error),
373 };
374 if let Err(error) = fts_result {
375 record_post_commit_degradation(
376 &mut degradations,
377 "delete_note",
378 id,
379 "fts_cleanup",
380 error,
381 );
382 }
383 for model_name in self.registered_embedding_model_names() {
385 let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
386 Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
387 Err(error) => Err(error),
388 };
389 if let Err(error) = vector_result {
390 record_post_commit_degradation(
391 &mut degradations,
392 "delete_note",
393 id,
394 "vector_cleanup",
395 format!("{model_name}: {error}"),
396 );
397 }
398 }
399 let event = khive_storage::event::Event::new(
400 record_ns.clone(),
401 "delete",
402 EventKind::NoteDeleted,
403 SubstrateKind::Note,
404 "",
405 )
406 .with_target(id)
407 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
408 let event_result = match self.events(&record_tok) {
409 Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
410 Err(error) => Err(error),
411 };
412 if let Err(error) = event_result {
413 record_post_commit_degradation(
414 &mut degradations,
415 "delete_note",
416 id,
417 "event_append",
418 error,
419 );
420 }
421 self.fire_note_mutation_hook(¬e.kind, id).await;
428 }
429 Ok((deleted, degradations))
430 }
431
432 pub async fn delete_note_row_first_for_compensation(
451 &self,
452 token: &NamespaceToken,
453 id: Uuid,
454 ) -> RuntimeResult<()> {
455 let note_store = self.notes(token)?;
456 let Some(note) = note_store.get_note_including_deleted(id).await? else {
457 return Ok(());
458 };
459 let record_tok = NamespaceToken::for_namespace(
460 khive_types::Namespace::parse(¬e.namespace)
461 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
462 );
463 let record_ns = note.namespace.clone();
464
465 note_store.delete_note(id, DeleteMode::Hard).await?;
467
468 #[cfg(any(test, feature = "fault-injection"))]
469 {
470 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
471 if armed.as_deref() == Some(record_ns.as_str()) {
472 return Err(RuntimeError::Internal(
473 "row removed but compensation cleanup failed: injected=true".to_string(),
474 ));
475 }
476 }
477
478 let mut cleanup_errors = Vec::new();
479 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
480 cleanup_errors.push(format!("graph={e}"));
481 }
482 if let Err(e) = self
483 .text_for_notes(&record_tok)?
484 .delete_document(&record_ns, id)
485 .await
486 {
487 cleanup_errors.push(format!("fts={e}"));
488 }
489 for model_name in self.registered_embedding_model_names() {
490 if let Err(e) = self
491 .vectors_for_model(&record_tok, &model_name)?
492 .delete(id)
493 .await
494 {
495 cleanup_errors.push(format!("vector[{model_name}]={e}"));
496 }
497 }
498 if cleanup_errors.is_empty() {
499 Ok(())
500 } else {
501 Err(RuntimeError::Internal(format!(
502 "row removed but compensation cleanup failed: {}",
503 cleanup_errors.join("; ")
504 )))
505 }
506 }
507
508 pub async fn delete_entity(
517 &self,
518 token: &NamespaceToken,
519 id: Uuid,
520 hard: bool,
521 ) -> RuntimeResult<bool> {
522 let (deleted, degradations) = self
523 .delete_entity_with_post_commit_report(token, id, hard)
524 .await?;
525 legacy_post_commit_result("delete_entity", id, deleted, degradations)
526 }
527
528 pub async fn delete_entity_with_post_commit_report(
530 &self,
531 token: &NamespaceToken,
532 id: Uuid,
533 hard: bool,
534 ) -> RuntimeResult<(bool, Vec<PostCommitDegradation>)> {
535 let entity = if hard {
536 match self
537 .entities(token)?
538 .get_entity_including_deleted(id)
539 .await?
540 {
541 Some(e) => e,
542 None => return Ok((false, Vec::new())),
543 }
544 } else {
545 match self.entities(token)?.get_entity(id).await? {
546 Some(e) => e,
547 None => return Ok((false, Vec::new())),
548 }
549 };
550 let mode = if hard {
551 DeleteMode::Hard
552 } else {
553 DeleteMode::Soft
554 };
555
556 let record_tok = token.with_namespace(
558 khive_types::Namespace::parse(&entity.namespace)
559 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
560 );
561 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
562
563 let deleted = if hard {
568 self.atomic_hard_delete_with_edge_purge(
570 entity_hard_delete_statement(id),
571 id,
572 &entity.namespace,
573 &actor,
574 SubstrateKind::Entity,
575 )
576 .await?
577 } else {
578 self.entities(token)?.delete_entity(id, mode).await?
579 };
580 let mut degradations = Vec::new();
581 if deleted {
582 let ns = entity.namespace.clone();
583 let fts_result = match self.text(&record_tok) {
584 Ok(store) => store
585 .delete_document(&ns, id)
586 .await
587 .map_err(RuntimeError::from),
588 Err(error) => Err(error),
589 };
590 if let Err(error) = fts_result {
591 record_post_commit_degradation(
592 &mut degradations,
593 "delete_entity",
594 id,
595 "fts_cleanup",
596 error,
597 );
598 }
599 for model_name in self.registered_embedding_model_names() {
600 let vector_result = match self.vectors_for_model(&record_tok, &model_name) {
601 Ok(store) => store.delete(id).await.map_err(RuntimeError::from),
602 Err(error) => Err(error),
603 };
604 if let Err(error) = vector_result {
605 record_post_commit_degradation(
606 &mut degradations,
607 "delete_entity",
608 id,
609 "vector_cleanup",
610 format!("{model_name}: {error}"),
611 );
612 }
613 }
614 let event = khive_storage::event::Event::new(
615 ns.clone(),
616 "delete",
617 EventKind::EntityDeleted,
618 SubstrateKind::Entity,
619 "",
620 )
621 .with_target(id)
622 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
623 let event_result = match self.events(&record_tok) {
624 Ok(store) => store.append_event(event).await.map_err(RuntimeError::from),
625 Err(error) => Err(error),
626 };
627 if let Err(error) = event_result {
628 record_post_commit_degradation(
629 &mut degradations,
630 "delete_entity",
631 id,
632 "event_append",
633 error,
634 );
635 }
636 }
637 Ok((deleted, degradations))
638 }
639
640 pub(crate) async fn delete_entity_attachments_on_core(&self, id: Uuid) -> RuntimeResult<bool> {
641 let core = self.core();
642 drop(core.attachments()?);
643 let statement = khive_db::stores::attachment::delete_record_attachments_statement(
644 id,
645 AttachmentSubstrate::Entity,
646 );
647 Ok(core.sql().writer().await?.execute(statement).await? > 0)
648 }
649}