Skip to main content

platform_core/
idempotency.rs

1use crate::db::DbTransaction;
2use crate::error::{AppError, AppResult, ErrorCode};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct IdempotencyKey {
6    scope: String,
7    value: String,
8}
9
10impl IdempotencyKey {
11    pub fn parse(scope: impl Into<String>, value: impl Into<String>) -> AppResult<Self> {
12        let scope = scope.into();
13        let value = value.into();
14        if scope.trim().is_empty() || value.trim().is_empty() {
15            return Err(AppError::new(
16                ErrorCode::Validation,
17                "Idempotency scope and key must not be empty",
18            ));
19        }
20        Ok(Self { scope, value })
21    }
22
23    pub fn scope(&self) -> &str {
24        &self.scope
25    }
26
27    pub fn value(&self) -> &str {
28        &self.value
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum IdempotencyClaim {
34    Acquired,
35    Existing,
36}
37
38pub async fn claim_idempotency_key_in_tx(
39    transaction: &mut DbTransaction<'_>,
40    key: &IdempotencyKey,
41) -> AppResult<IdempotencyClaim> {
42    let inserted = sqlx::query_scalar::<_, i32>(
43        r#"
44        insert into platform.idempotency_claims (scope, key)
45        values ($1, $2)
46        on conflict (scope, key) do nothing
47        returning 1
48        "#,
49    )
50    .bind(key.scope())
51    .bind(key.value())
52    .fetch_optional(&mut **transaction)
53    .await
54    .map_err(map_idempotency_error)?;
55    Ok(if inserted.is_some() {
56        IdempotencyClaim::Acquired
57    } else {
58        IdempotencyClaim::Existing
59    })
60}
61
62fn map_idempotency_error(source: sqlx::Error) -> AppError {
63    AppError::new(ErrorCode::Internal, "Idempotency claim failed").with_source(source)
64}