Skip to main content

khive_runtime/
entity_write.rs

1//! Entity revision preconditions, evaluated by the shared writer transaction.
2
3use khive_storage::{SqlStatement, SqlValue, SqlWriter, StorageError};
4use khive_types::{Details, KhiveError};
5use uuid::Uuid;
6
7use crate::{RuntimeError, RuntimeResult};
8
9/// A caller's entity revision did not match the live writer-transaction row.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct EntityVersionConflict {
12    pub expected: i64,
13    pub current: i64,
14}
15
16impl EntityVersionConflict {
17    pub fn into_error(self) -> KhiveError {
18        KhiveError::conflict("entity version precondition failed").with_details(Details::new_owned(
19            vec![
20                ("reason", "version_conflict".to_owned()),
21                ("expected_version", self.expected.to_string()),
22                ("current_version", self.current.to_string()),
23            ],
24        ))
25    }
26}
27
28#[derive(Debug, Clone)]
29pub(crate) struct EntityWriteGuard {
30    pub(crate) id: Uuid,
31    pub(crate) expected_version: i64,
32}
33
34pub(crate) fn validate_expected_version(expected: Option<i64>) -> RuntimeResult<()> {
35    if expected.is_some_and(|value| value < 1) {
36        return Err(RuntimeError::InvalidInput(
37            "expected_version must be a positive integer".into(),
38        ));
39    }
40    Ok(())
41}
42
43impl EntityWriteGuard {
44    pub(crate) async fn check(
45        &self,
46        writer: &mut dyn SqlWriter,
47    ) -> Result<Option<EntityVersionConflict>, StorageError> {
48        let current = writer
49            .query_scalar(SqlStatement {
50                sql: "SELECT version FROM entities WHERE id=?1 AND deleted_at IS NULL".into(),
51                params: vec![SqlValue::Text(self.id.to_string())],
52                label: Some("entity-version-precondition".into()),
53            })
54            .await?;
55        // A missing/deleted row is refused by the following snapshot CAS,
56        // matching the note guard's missing-row behavior.
57        Ok(match current {
58            Some(SqlValue::Integer(current)) if current != self.expected_version => {
59                Some(EntityVersionConflict {
60                    expected: self.expected_version,
61                    current,
62                })
63            }
64            _ => None,
65        })
66    }
67}
68
69#[cfg(test)]
70#[path = "entity_write_tests.rs"]
71mod tests;