1use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub enum ConflictPolicy {
31 #[default]
34 LastWriterWins,
35
36 RenameSuffix,
39
40 CascadeDefer {
44 max_retries: u32,
46 ttl_secs: u64,
48 },
49
50 Custom {
53 webhook_url: String,
55 timeout_secs: u64,
57 },
58
59 EscalateToDlq,
62}
63
64#[derive(Debug, Clone)]
66pub enum PolicyResolution {
67 AutoResolved(ResolvedAction),
69
70 Deferred { retry_after_ms: u64, attempt: u32 },
74
75 WebhookRequired {
77 webhook_url: String,
78 timeout_secs: u64,
79 },
80
81 Escalate,
83}
84
85#[derive(Debug, Clone)]
87pub enum ResolvedAction {
88 OverwriteExisting,
90
91 RenamedField { field: String, new_value: String },
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CollectionPolicy {
98 pub unique: ConflictPolicy,
100 pub foreign_key: ConflictPolicy,
102 pub not_null: ConflictPolicy,
104 pub check: ConflictPolicy,
106 pub strict_consistency: bool,
108}
109
110impl CollectionPolicy {
111 pub fn ephemeral() -> Self {
114 Self {
115 unique: ConflictPolicy::RenameSuffix,
116 foreign_key: ConflictPolicy::CascadeDefer {
117 max_retries: 3,
118 ttl_secs: 300,
119 },
120 not_null: ConflictPolicy::LastWriterWins,
121 check: ConflictPolicy::EscalateToDlq,
122 strict_consistency: false,
123 }
124 }
125
126 pub fn strict() -> Self {
129 Self {
130 unique: ConflictPolicy::EscalateToDlq,
131 foreign_key: ConflictPolicy::EscalateToDlq,
132 not_null: ConflictPolicy::EscalateToDlq,
133 check: ConflictPolicy::EscalateToDlq,
134 strict_consistency: true,
135 }
136 }
137
138 pub fn for_kind(&self, kind: &crate::constraint::ConstraintKind) -> &ConflictPolicy {
140 match kind {
141 crate::constraint::ConstraintKind::Unique => &self.unique,
142 crate::constraint::ConstraintKind::ForeignKey { .. }
143 | crate::constraint::ConstraintKind::BiTemporalFK { .. } => &self.foreign_key,
144 crate::constraint::ConstraintKind::NotNull => &self.not_null,
145 crate::constraint::ConstraintKind::Check { .. } => &self.check,
146 }
147 }
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct PolicyRegistry {
153 policies: HashMap<String, CollectionPolicy>,
154}
155
156impl PolicyRegistry {
157 pub fn new() -> Self {
159 Self {
160 policies: HashMap::new(),
161 }
162 }
163
164 pub fn set(&mut self, collection: &str, policy: CollectionPolicy) {
166 self.policies.insert(collection.to_string(), policy);
167 }
168
169 pub fn remove(&mut self, collection: &str) -> bool {
171 self.policies.remove(collection).is_some()
172 }
173
174 pub fn set_for_kind(
176 &mut self,
177 collection: &str,
178 kind: &crate::constraint::ConstraintKind,
179 policy: ConflictPolicy,
180 ) {
181 let mut coll_policy = self.get_owned(collection);
182 match kind {
183 crate::constraint::ConstraintKind::Unique => coll_policy.unique = policy,
184 crate::constraint::ConstraintKind::ForeignKey { .. }
185 | crate::constraint::ConstraintKind::BiTemporalFK { .. } => {
186 coll_policy.foreign_key = policy
187 }
188 crate::constraint::ConstraintKind::NotNull => coll_policy.not_null = policy,
189 crate::constraint::ConstraintKind::Check { .. } => coll_policy.check = policy,
190 }
191 self.set(collection, coll_policy);
192 }
193
194 pub fn get_owned(&self, collection: &str) -> CollectionPolicy {
197 self.policies
198 .get(collection)
199 .cloned()
200 .unwrap_or_else(CollectionPolicy::ephemeral)
201 }
202
203 pub fn has(&self, collection: &str) -> bool {
205 self.policies.contains_key(collection)
206 }
207
208 pub fn len(&self) -> usize {
210 self.policies.len()
211 }
212
213 pub fn is_empty(&self) -> bool {
214 self.policies.is_empty()
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::constraint::ConstraintKind;
222
223 #[test]
224 fn ephemeral_policy_defaults() {
225 let policy = CollectionPolicy::ephemeral();
226 assert!(!policy.strict_consistency);
227 assert!(matches!(policy.unique, ConflictPolicy::RenameSuffix));
228 assert!(matches!(
229 policy.foreign_key,
230 ConflictPolicy::CascadeDefer { .. }
231 ));
232 }
233
234 #[test]
235 fn strict_policy_defaults() {
236 let policy = CollectionPolicy::strict();
237 assert!(policy.strict_consistency);
238 assert!(matches!(policy.unique, ConflictPolicy::EscalateToDlq));
239 assert!(matches!(policy.foreign_key, ConflictPolicy::EscalateToDlq));
240 }
241
242 #[test]
243 fn for_kind_lookup() {
244 let policy = CollectionPolicy::ephemeral();
245
246 let unique_policy = policy.for_kind(&ConstraintKind::Unique);
247 assert!(matches!(unique_policy, ConflictPolicy::RenameSuffix));
248
249 let fk_policy = policy.for_kind(&ConstraintKind::ForeignKey {
250 ref_collection: "users".into(),
251 ref_key: "id".into(),
252 });
253 assert!(matches!(fk_policy, ConflictPolicy::CascadeDefer { .. }));
254 }
255
256 #[test]
257 fn registry_set_and_get() {
258 let mut registry = PolicyRegistry::new();
259 let policy = CollectionPolicy::strict();
260
261 registry.set("agents", policy.clone());
262
263 assert!(registry.has("agents"));
266 assert!(!registry.has("unknown"));
267 }
268
269 #[test]
270 fn registry_set_for_kind() {
271 let mut registry = PolicyRegistry::new();
272
273 registry.set("posts", CollectionPolicy::ephemeral());
275
276 registry.set_for_kind(
278 "posts",
279 &ConstraintKind::Unique,
280 ConflictPolicy::LastWriterWins,
281 );
282
283 assert!(registry.has("posts"));
284 }
285
286 #[test]
287 fn registry_len() {
288 let mut registry = PolicyRegistry::new();
289
290 assert_eq!(registry.len(), 0);
291
292 registry.set("coll1", CollectionPolicy::ephemeral());
293 assert_eq!(registry.len(), 1);
294
295 registry.set("coll2", CollectionPolicy::strict());
296 assert_eq!(registry.len(), 2);
297
298 registry.set("coll1", CollectionPolicy::strict());
300 assert_eq!(registry.len(), 2);
301 }
302
303 #[test]
304 fn conflict_policy_default() {
305 let policy: ConflictPolicy = Default::default();
306 assert!(matches!(policy, ConflictPolicy::LastWriterWins));
307 }
308
309 #[test]
310 fn cascade_defer_exponential_backoff() {
311 let base_ms = 500u64;
318 for attempt in 0..5 {
319 let backoff = base_ms.saturating_mul(2_u64.saturating_pow(attempt));
320 let capped = backoff.min(30_000);
321 assert!(capped <= 30_000);
322 }
323 }
324}