Skip to main content

autumn_web/
repository.rs

1//! Repository error types for framework-generated CRUD operations.
2//!
3//! Framework-generated repositories use the [`Db`](crate::Db) extractor, which
4//! is bound to the primary/write database role. If an application wants a
5//! primary/replica read split, use an explicit repository seam and route reads
6//! through [`crate::AppState::read_pool`] while keeping writes on
7//! [`crate::AppState::pool`].
8//!
9//! [`RepositoryError`] surfaces typed errors that arise during repository
10//! operations — most notably optimistic-lock conflicts when two replicas
11//! write the same row concurrently.
12
13use thiserror::Error;
14
15/// Typed errors returned by generated repository methods.
16///
17/// Distinct from [`crate::AutumnError`] so callers can match on the
18/// variant without parsing an HTTP status code.
19#[derive(Debug, Clone, Error)]
20pub enum RepositoryError {
21    /// Two writers raced on the same row.
22    ///
23    /// Returned by generated `update`/`save` methods when the
24    /// `#[lock_version]` field no longer matches the value the client
25    /// sent — meaning another replica committed a write in the meantime.
26    ///
27    /// Map this to `409 Conflict` via [`crate::AutumnError::conflict`].
28    #[error(
29        "optimistic lock conflict on record {id}: \
30         client expected version {expected_version}, \
31         row was already modified (actual: {actual_version:?})"
32    )]
33    Conflict {
34        /// Primary key of the contested record.
35        id: i64,
36        /// The version the client read and expected to still be current.
37        expected_version: i64,
38        /// The version actually stored when the conflict was detected,
39        /// or `None` if the row was deleted between the read and the write.
40        actual_version: Option<i64>,
41    },
42}
43
44/// Extension trait that provides a fallback `None` for model structs that do
45/// not have a `#[lock_version]` field — or that are defined manually without
46/// going through `#[model]`.
47///
48/// `#[model]` generates an *inherent* method with the same name on the model
49/// and on `UpdateModel`; inherent methods take priority over trait methods in
50/// Rust's method-resolution order.  For types without `#[lock_version]` (or
51/// without `#[model]` altogether), the trait provides the `None` fallback so
52/// the generated repository code can call these methods unconditionally.
53#[doc(hidden)]
54pub trait AutumnLockVersionModelExt {
55    fn __autumn_lock_version_actual(&self) -> Option<i64> {
56        None
57    }
58}
59
60#[doc(hidden)]
61pub trait AutumnLockVersionUpdateExt {
62    fn __autumn_lock_version_expected(&self) -> Option<i64> {
63        None
64    }
65}
66
67// Blanket impls — any type that doesn't have an inherent implementation
68// (generated by `#[model]`) falls through to these, returning `None`.
69impl<T: ?Sized> AutumnLockVersionModelExt for T {}
70impl<T: ?Sized> AutumnLockVersionUpdateExt for T {}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn conflict_variant_stores_all_fields() {
78        let err = RepositoryError::Conflict {
79            id: 42,
80            expected_version: 3,
81            actual_version: Some(4),
82        };
83        match err {
84            RepositoryError::Conflict {
85                id,
86                expected_version,
87                actual_version,
88            } => {
89                assert_eq!(id, 42);
90                assert_eq!(expected_version, 3);
91                assert_eq!(actual_version, Some(4));
92            }
93        }
94    }
95
96    #[test]
97    fn conflict_with_no_actual_version() {
98        let err = RepositoryError::Conflict {
99            id: 1,
100            expected_version: 0,
101            actual_version: None,
102        };
103        assert!(matches!(
104            err,
105            RepositoryError::Conflict {
106                actual_version: None,
107                ..
108            }
109        ));
110    }
111
112    #[test]
113    fn conflict_display_includes_id_and_expected_version() {
114        let err = RepositoryError::Conflict {
115            id: 99,
116            expected_version: 7,
117            actual_version: Some(8),
118        };
119        let s = err.to_string();
120        assert!(s.contains("99"), "display should include id");
121        assert!(s.contains('7'), "display should include expected_version");
122    }
123
124    #[test]
125    fn conflict_is_clone() {
126        let err = RepositoryError::Conflict {
127            id: 1,
128            expected_version: 0,
129            actual_version: Some(1),
130        };
131        let cloned = err.clone();
132        assert!(matches!(err, RepositoryError::Conflict { id: 1, .. }));
133        assert!(matches!(cloned, RepositoryError::Conflict { id: 1, .. }));
134    }
135
136    #[test]
137    fn conflict_implements_std_error() {
138        let err = RepositoryError::Conflict {
139            id: 1,
140            expected_version: 0,
141            actual_version: None,
142        };
143        let _: &dyn std::error::Error = &err;
144    }
145}