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}