platform_core/
idempotency.rs1use 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}