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