use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum ConflictPolicy {
#[default]
LastWriterWins,
RenameSuffix,
CascadeDefer {
max_retries: u32,
ttl_secs: u64,
},
Custom {
webhook_url: String,
timeout_secs: u64,
},
EscalateToDlq,
}
#[derive(Debug, Clone)]
pub enum PolicyResolution {
AutoResolved(ResolvedAction),
Deferred { retry_after_ms: u64, attempt: u32 },
WebhookRequired {
webhook_url: String,
timeout_secs: u64,
},
Escalate,
}
#[derive(Debug, Clone)]
pub enum ResolvedAction {
OverwriteExisting,
RenamedField { field: String, new_value: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionPolicy {
pub unique: ConflictPolicy,
pub foreign_key: ConflictPolicy,
pub not_null: ConflictPolicy,
pub check: ConflictPolicy,
pub strict_consistency: bool,
}
impl CollectionPolicy {
pub fn ephemeral() -> Self {
Self {
unique: ConflictPolicy::RenameSuffix,
foreign_key: ConflictPolicy::CascadeDefer {
max_retries: 3,
ttl_secs: 300,
},
not_null: ConflictPolicy::LastWriterWins,
check: ConflictPolicy::EscalateToDlq,
strict_consistency: false,
}
}
pub fn strict() -> Self {
Self {
unique: ConflictPolicy::EscalateToDlq,
foreign_key: ConflictPolicy::EscalateToDlq,
not_null: ConflictPolicy::EscalateToDlq,
check: ConflictPolicy::EscalateToDlq,
strict_consistency: true,
}
}
pub fn for_kind(&self, kind: &crate::constraint::ConstraintKind) -> &ConflictPolicy {
match kind {
crate::constraint::ConstraintKind::Unique => &self.unique,
crate::constraint::ConstraintKind::ForeignKey { .. }
| crate::constraint::ConstraintKind::BiTemporalFK { .. } => &self.foreign_key,
crate::constraint::ConstraintKind::NotNull => &self.not_null,
crate::constraint::ConstraintKind::Check { .. } => &self.check,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PolicyRegistry {
policies: HashMap<String, CollectionPolicy>,
}
impl PolicyRegistry {
pub fn new() -> Self {
Self {
policies: HashMap::new(),
}
}
pub fn set(&mut self, collection: &str, policy: CollectionPolicy) {
self.policies.insert(collection.to_string(), policy);
}
pub fn remove(&mut self, collection: &str) -> bool {
self.policies.remove(collection).is_some()
}
pub fn set_for_kind(
&mut self,
collection: &str,
kind: &crate::constraint::ConstraintKind,
policy: ConflictPolicy,
) {
let mut coll_policy = self.get_owned(collection);
match kind {
crate::constraint::ConstraintKind::Unique => coll_policy.unique = policy,
crate::constraint::ConstraintKind::ForeignKey { .. }
| crate::constraint::ConstraintKind::BiTemporalFK { .. } => {
coll_policy.foreign_key = policy
}
crate::constraint::ConstraintKind::NotNull => coll_policy.not_null = policy,
crate::constraint::ConstraintKind::Check { .. } => coll_policy.check = policy,
}
self.set(collection, coll_policy);
}
pub fn get_owned(&self, collection: &str) -> CollectionPolicy {
self.policies
.get(collection)
.cloned()
.unwrap_or_else(CollectionPolicy::ephemeral)
}
pub fn has(&self, collection: &str) -> bool {
self.policies.contains_key(collection)
}
pub fn len(&self) -> usize {
self.policies.len()
}
pub fn is_empty(&self) -> bool {
self.policies.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constraint::ConstraintKind;
#[test]
fn ephemeral_policy_defaults() {
let policy = CollectionPolicy::ephemeral();
assert!(!policy.strict_consistency);
assert!(matches!(policy.unique, ConflictPolicy::RenameSuffix));
assert!(matches!(
policy.foreign_key,
ConflictPolicy::CascadeDefer { .. }
));
}
#[test]
fn strict_policy_defaults() {
let policy = CollectionPolicy::strict();
assert!(policy.strict_consistency);
assert!(matches!(policy.unique, ConflictPolicy::EscalateToDlq));
assert!(matches!(policy.foreign_key, ConflictPolicy::EscalateToDlq));
}
#[test]
fn for_kind_lookup() {
let policy = CollectionPolicy::ephemeral();
let unique_policy = policy.for_kind(&ConstraintKind::Unique);
assert!(matches!(unique_policy, ConflictPolicy::RenameSuffix));
let fk_policy = policy.for_kind(&ConstraintKind::ForeignKey {
ref_collection: "users".into(),
ref_key: "id".into(),
});
assert!(matches!(fk_policy, ConflictPolicy::CascadeDefer { .. }));
}
#[test]
fn registry_set_and_get() {
let mut registry = PolicyRegistry::new();
let policy = CollectionPolicy::strict();
registry.set("agents", policy.clone());
assert!(registry.has("agents"));
assert!(!registry.has("unknown"));
}
#[test]
fn registry_set_for_kind() {
let mut registry = PolicyRegistry::new();
registry.set("posts", CollectionPolicy::ephemeral());
registry.set_for_kind(
"posts",
&ConstraintKind::Unique,
ConflictPolicy::LastWriterWins,
);
assert!(registry.has("posts"));
}
#[test]
fn registry_len() {
let mut registry = PolicyRegistry::new();
assert_eq!(registry.len(), 0);
registry.set("coll1", CollectionPolicy::ephemeral());
assert_eq!(registry.len(), 1);
registry.set("coll2", CollectionPolicy::strict());
assert_eq!(registry.len(), 2);
registry.set("coll1", CollectionPolicy::strict());
assert_eq!(registry.len(), 2);
}
#[test]
fn conflict_policy_default() {
let policy: ConflictPolicy = Default::default();
assert!(matches!(policy, ConflictPolicy::LastWriterWins));
}
#[test]
fn cascade_defer_exponential_backoff() {
let base_ms = 500u64;
for attempt in 0..5 {
let backoff = base_ms.saturating_mul(2_u64.saturating_pow(attempt));
let capped = backoff.min(30_000);
assert!(capped <= 30_000);
}
}
}