1use serde::{Deserialize, Serialize};
10
11#[derive(
17 Debug,
18 Clone,
19 PartialEq,
20 Eq,
21 Serialize,
22 Deserialize,
23 rkyv::Archive,
24 rkyv::Serialize,
25 rkyv::Deserialize,
26 zerompk::ToMessagePack,
27 zerompk::FromMessagePack,
28)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum ViolationType {
32 #[serde(rename = "rls_policy_violation")]
34 RlsPolicyViolation { policy_name: String },
35 #[serde(rename = "unique_violation")]
37 UniqueViolation { field: String, value: String },
38 #[serde(rename = "foreign_key_missing")]
40 ForeignKeyMissing { referenced_id: String },
41 #[serde(rename = "not_null_violation")]
43 NotNullViolation { field: String },
44 #[serde(rename = "permission_denied")]
46 PermissionDenied,
47 #[serde(rename = "rate_limited")]
49 RateLimited,
50 #[serde(rename = "token_expired")]
52 TokenExpired,
53 #[serde(rename = "schema_violation")]
55 SchemaViolation { field: String, reason: String },
56 #[serde(rename = "constraint_violation")]
58 ConstraintViolation { detail: String },
59 #[serde(rename = "malformed_delta")]
62 MalformedDelta { detail: String },
63}
64
65impl std::fmt::Display for ViolationType {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::RlsPolicyViolation { policy_name } => {
69 write!(f, "rls_policy:{policy_name}")
70 }
71 Self::UniqueViolation { field, value } => {
72 write!(f, "unique:{field}={value}")
73 }
74 Self::ForeignKeyMissing { referenced_id } => {
75 write!(f, "fk_missing:{referenced_id}")
76 }
77 Self::NotNullViolation { field } => write!(f, "not_null:{field}"),
78 Self::PermissionDenied => write!(f, "permission_denied"),
79 Self::RateLimited => write!(f, "rate_limited"),
80 Self::TokenExpired => write!(f, "token_expired"),
81 Self::SchemaViolation { field, reason } => {
82 write!(f, "schema:{field}={reason}")
83 }
84 Self::ConstraintViolation { detail } => write!(f, "constraint:{detail}"),
85 Self::MalformedDelta { detail } => write!(f, "malformed_delta:{detail}"),
86 }
87 }
88}
89
90impl ViolationType {
91 pub fn to_compensation_hint(&self) -> super::compensation::CompensationHint {
96 use super::compensation::CompensationHint;
97 match self {
98 Self::UniqueViolation { field, value } => CompensationHint::UniqueViolation {
99 field: field.clone(),
100 conflicting_value: value.clone(),
101 },
102 Self::ForeignKeyMissing { referenced_id } => CompensationHint::ForeignKeyMissing {
103 referenced_id: referenced_id.clone(),
104 },
105 Self::RateLimited => CompensationHint::RateLimited {
106 retry_after_ms: 5000,
107 },
108 Self::RlsPolicyViolation { .. } | Self::PermissionDenied | Self::TokenExpired => {
110 CompensationHint::PermissionDenied
111 }
112 Self::SchemaViolation { field, reason } => CompensationHint::SchemaViolation {
113 field: field.clone(),
114 reason: reason.clone(),
115 },
116 Self::NotNullViolation { field } => CompensationHint::SchemaViolation {
117 field: field.clone(),
118 reason: "required field missing".into(),
119 },
120 Self::ConstraintViolation { detail } => CompensationHint::Custom {
121 constraint: "constraint".into(),
122 detail: detail.clone(),
123 },
124 Self::MalformedDelta { detail } => CompensationHint::Custom {
125 constraint: "malformed_delta".into(),
126 detail: detail.clone(),
127 },
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn violation_display() {
138 assert_eq!(
139 ViolationType::PermissionDenied.to_string(),
140 "permission_denied"
141 );
142 assert_eq!(ViolationType::RateLimited.to_string(), "rate_limited");
143 assert_eq!(
144 ViolationType::UniqueViolation {
145 field: "email".into(),
146 value: "x@y.com".into()
147 }
148 .to_string(),
149 "unique:email=x@y.com"
150 );
151 assert_eq!(
152 ViolationType::NotNullViolation {
153 field: "name".into()
154 }
155 .to_string(),
156 "not_null:name"
157 );
158 }
159
160 #[test]
161 fn rls_violation_maps_to_permission_denied() {
162 let v = ViolationType::RlsPolicyViolation {
163 policy_name: "user_write_own".into(),
164 };
165 let hint = v.to_compensation_hint();
166 assert!(matches!(
168 hint,
169 super::super::compensation::CompensationHint::PermissionDenied
170 ));
171 }
172
173 #[test]
174 fn unique_violation_preserves_details() {
175 let v = ViolationType::UniqueViolation {
176 field: "username".into(),
177 value: "alice".into(),
178 };
179 let hint = v.to_compensation_hint();
180 match hint {
181 super::super::compensation::CompensationHint::UniqueViolation {
182 field,
183 conflicting_value,
184 } => {
185 assert_eq!(field, "username");
186 assert_eq!(conflicting_value, "alice");
187 }
188 _ => panic!("expected UniqueViolation hint"),
189 }
190 }
191
192 #[test]
193 fn token_expired_maps_to_permission_denied() {
194 let hint = ViolationType::TokenExpired.to_compensation_hint();
195 assert!(matches!(
196 hint,
197 super::super::compensation::CompensationHint::PermissionDenied
198 ));
199 }
200
201 #[test]
202 fn malformed_delta_names_itself_rather_than_posing_as_a_constraint() {
203 let v = ViolationType::MalformedDelta {
204 detail: "orders/o1 could not be decoded".into(),
205 };
206 match v.to_compensation_hint() {
207 super::super::compensation::CompensationHint::Custom { constraint, detail } => {
208 assert_eq!(constraint, "malformed_delta");
209 assert!(detail.contains("orders/o1"));
210 }
211 other => panic!("expected a Custom hint naming the malformed delta, got {other:?}"),
212 }
213 }
214
215 #[test]
216 fn every_violation_is_terminal() {
217 let hints = [
224 ViolationType::PermissionDenied,
225 ViolationType::RateLimited,
226 ViolationType::TokenExpired,
227 ViolationType::ConstraintViolation { detail: "d".into() },
228 ViolationType::MalformedDelta { detail: "d".into() },
229 ViolationType::SchemaViolation {
230 field: "f".into(),
231 reason: "r".into(),
232 },
233 ViolationType::UniqueViolation {
234 field: "f".into(),
235 value: "v".into(),
236 },
237 ViolationType::ForeignKeyMissing {
238 referenced_id: "r".into(),
239 },
240 ViolationType::NotNullViolation { field: "f".into() },
241 ViolationType::RlsPolicyViolation {
242 policy_name: "p".into(),
243 },
244 ]
245 .map(|v| v.to_compensation_hint());
246 for hint in hints {
247 assert!(
248 !matches!(
249 hint,
250 super::super::compensation::CompensationHint::Retry { .. }
251 ),
252 "a violation mapped to a retry hint: {hint:?}"
253 );
254 }
255 }
256}