1use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub enum ConflictPolicy {
29 #[default]
32 LastWriterWins,
33
34 RenameSuffix,
37
38 CascadeDefer {
42 max_retries: u32,
44 ttl_secs: u64,
46 },
47
48 Custom {
51 webhook_url: String,
53 timeout_secs: u64,
55 },
56
57 EscalateToDlq,
60}
61
62#[derive(Debug, Clone)]
64pub enum PolicyResolution {
65 AutoResolved(ResolvedAction),
67
68 Deferred { retry_after_ms: u64, attempt: u32 },
72
73 WebhookRequired {
75 webhook_url: String,
76 timeout_secs: u64,
77 },
78
79 Escalate,
81}
82
83#[derive(Debug, Clone)]
85pub enum ResolvedAction {
86 OverwriteExisting,
88
89 RenamedField { field: String, new_value: String },
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct CollectionPolicy {
96 pub unique: ConflictPolicy,
98 pub foreign_key: ConflictPolicy,
100 pub not_null: ConflictPolicy,
102 pub check: ConflictPolicy,
104 pub strict_consistency: bool,
106}
107
108impl CollectionPolicy {
109 pub fn ephemeral() -> Self {
112 Self {
113 unique: ConflictPolicy::RenameSuffix,
114 foreign_key: ConflictPolicy::CascadeDefer {
115 max_retries: 3,
116 ttl_secs: 300,
117 },
118 not_null: ConflictPolicy::LastWriterWins,
119 check: ConflictPolicy::EscalateToDlq,
120 strict_consistency: false,
121 }
122 }
123
124 pub fn strict() -> Self {
127 Self {
128 unique: ConflictPolicy::EscalateToDlq,
129 foreign_key: ConflictPolicy::EscalateToDlq,
130 not_null: ConflictPolicy::EscalateToDlq,
131 check: ConflictPolicy::EscalateToDlq,
132 strict_consistency: true,
133 }
134 }
135
136 pub fn for_kind(&self, kind: &crate::constraint::ConstraintKind) -> &ConflictPolicy {
138 match kind {
139 crate::constraint::ConstraintKind::Unique => &self.unique,
140 crate::constraint::ConstraintKind::ForeignKey { .. } => &self.foreign_key,
141 crate::constraint::ConstraintKind::NotNull => &self.not_null,
142 crate::constraint::ConstraintKind::Check { .. } => &self.check,
143 }
144 }
145}
146
147#[derive(Debug, Clone, Default)]
149pub struct PolicyRegistry {
150 policies: HashMap<String, CollectionPolicy>,
151}
152
153impl PolicyRegistry {
154 pub fn new() -> Self {
156 Self {
157 policies: HashMap::new(),
158 }
159 }
160
161 pub fn set(&mut self, collection: &str, policy: CollectionPolicy) {
163 self.policies.insert(collection.to_string(), policy);
164 }
165
166 pub fn set_for_kind(
168 &mut self,
169 collection: &str,
170 kind: &crate::constraint::ConstraintKind,
171 policy: ConflictPolicy,
172 ) {
173 let mut coll_policy = self.get_owned(collection);
174 match kind {
175 crate::constraint::ConstraintKind::Unique => coll_policy.unique = policy,
176 crate::constraint::ConstraintKind::ForeignKey { .. } => {
177 coll_policy.foreign_key = policy
178 }
179 crate::constraint::ConstraintKind::NotNull => coll_policy.not_null = policy,
180 crate::constraint::ConstraintKind::Check { .. } => coll_policy.check = policy,
181 }
182 self.set(collection, coll_policy);
183 }
184
185 pub fn get_owned(&self, collection: &str) -> CollectionPolicy {
188 self.policies
189 .get(collection)
190 .cloned()
191 .unwrap_or_else(CollectionPolicy::ephemeral)
192 }
193
194 pub fn has(&self, collection: &str) -> bool {
196 self.policies.contains_key(collection)
197 }
198
199 pub fn len(&self) -> usize {
201 self.policies.len()
202 }
203
204 pub fn is_empty(&self) -> bool {
205 self.policies.is_empty()
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::constraint::ConstraintKind;
213
214 #[test]
215 fn ephemeral_policy_defaults() {
216 let policy = CollectionPolicy::ephemeral();
217 assert!(!policy.strict_consistency);
218 assert!(matches!(policy.unique, ConflictPolicy::RenameSuffix));
219 assert!(matches!(
220 policy.foreign_key,
221 ConflictPolicy::CascadeDefer { .. }
222 ));
223 }
224
225 #[test]
226 fn strict_policy_defaults() {
227 let policy = CollectionPolicy::strict();
228 assert!(policy.strict_consistency);
229 assert!(matches!(policy.unique, ConflictPolicy::EscalateToDlq));
230 assert!(matches!(policy.foreign_key, ConflictPolicy::EscalateToDlq));
231 }
232
233 #[test]
234 fn for_kind_lookup() {
235 let policy = CollectionPolicy::ephemeral();
236
237 let unique_policy = policy.for_kind(&ConstraintKind::Unique);
238 assert!(matches!(unique_policy, ConflictPolicy::RenameSuffix));
239
240 let fk_policy = policy.for_kind(&ConstraintKind::ForeignKey {
241 ref_collection: "users".into(),
242 ref_key: "id".into(),
243 });
244 assert!(matches!(fk_policy, ConflictPolicy::CascadeDefer { .. }));
245 }
246
247 #[test]
248 fn registry_set_and_get() {
249 let mut registry = PolicyRegistry::new();
250 let policy = CollectionPolicy::strict();
251
252 registry.set("agents", policy.clone());
253
254 assert!(registry.has("agents"));
257 assert!(!registry.has("unknown"));
258 }
259
260 #[test]
261 fn registry_set_for_kind() {
262 let mut registry = PolicyRegistry::new();
263
264 registry.set("posts", CollectionPolicy::ephemeral());
266
267 registry.set_for_kind(
269 "posts",
270 &ConstraintKind::Unique,
271 ConflictPolicy::LastWriterWins,
272 );
273
274 assert!(registry.has("posts"));
275 }
276
277 #[test]
278 fn registry_len() {
279 let mut registry = PolicyRegistry::new();
280
281 assert_eq!(registry.len(), 0);
282
283 registry.set("coll1", CollectionPolicy::ephemeral());
284 assert_eq!(registry.len(), 1);
285
286 registry.set("coll2", CollectionPolicy::strict());
287 assert_eq!(registry.len(), 2);
288
289 registry.set("coll1", CollectionPolicy::strict());
291 assert_eq!(registry.len(), 2);
292 }
293
294 #[test]
295 fn conflict_policy_default() {
296 let policy: ConflictPolicy = Default::default();
297 assert!(matches!(policy, ConflictPolicy::LastWriterWins));
298 }
299
300 #[test]
301 fn cascade_defer_exponential_backoff() {
302 let base_ms = 500u64;
309 for attempt in 0..5 {
310 let backoff = base_ms.saturating_mul(2_u64.saturating_pow(attempt));
311 let capped = backoff.min(30_000);
312 assert!(capped <= 30_000);
313 }
314 }
315}