use nodedb_crdt::validator::Violation;
use nodedb_crdt::{ConflictPolicy, PolicyResolution, ResolvedAction};
use nodedb_types::sync::compensation::CompensationHint;
use super::engine::CrdtEngine;
fn to_crdt_hint(hint: &CompensationHint) -> nodedb_crdt::CompensationHint {
match hint {
CompensationHint::UniqueViolation {
field,
conflicting_value,
} => nodedb_crdt::CompensationHint::RetryWithDifferentValue {
field: field.clone(),
conflicting_value: conflicting_value.clone(),
suggestion: format!("{conflicting_value}_1"),
},
CompensationHint::ForeignKeyMissing { referenced_id } => {
nodedb_crdt::CompensationHint::CreateReferencedRow {
ref_collection: String::new(),
ref_key: referenced_id.clone(),
missing_value: referenced_id.clone(),
}
}
CompensationHint::PermissionDenied => nodedb_crdt::CompensationHint::ManualIntervention {
reason: "permission denied by Origin".to_string(),
},
CompensationHint::RateLimited { retry_after_ms } => {
nodedb_crdt::CompensationHint::ManualIntervention {
reason: format!("rate limited; retry after {retry_after_ms}ms"),
}
}
CompensationHint::SchemaViolation { field, reason } => {
nodedb_crdt::CompensationHint::ManualIntervention {
reason: format!("schema violation on field `{field}`: {reason}"),
}
}
CompensationHint::Custom { constraint, detail } => {
nodedb_crdt::CompensationHint::ManualIntervention {
reason: format!("{constraint}: {detail}"),
}
}
CompensationHint::IntegrityViolation => nodedb_crdt::CompensationHint::ManualIntervention {
reason: "delta integrity check failed (CRC32C mismatch)".to_string(),
},
CompensationHint::Retry { retry_after_ms } => {
nodedb_crdt::CompensationHint::ManualIntervention {
reason: format!("transient rejection; retry after {retry_after_ms}ms"),
}
}
_ => nodedb_crdt::CompensationHint::ManualIntervention {
reason: hint.to_string(),
},
}
}
fn violation_from_hint(hint: &CompensationHint) -> Vec<Violation> {
vec![Violation {
constraint_name: format!("{hint:?}"),
reason: hint.to_string(),
hint: to_crdt_hint(hint),
}]
}
impl CrdtEngine {
pub fn reject_delta_with_policy(
&mut self,
mutation_id: u64,
hint: &CompensationHint,
) -> Option<PolicyResolution> {
let pos = self
.pending_deltas
.iter()
.position(|d| d.mutation_id == mutation_id)?;
let delta = &self.pending_deltas[pos];
let collection = delta.collection.clone();
let doc_id = delta.document_id.clone();
let policy = self.policies.get_owned(&collection);
let resolution = match hint {
CompensationHint::UniqueViolation { field, .. } => match &policy.unique {
ConflictPolicy::LastWriterWins => {
PolicyResolution::AutoResolved(ResolvedAction::OverwriteExisting)
}
ConflictPolicy::RenameSuffix => {
let resolved = (|| {
let state = self.states.get(&collection)?;
let loro_val = state.read_row(&collection, &doc_id)?;
let loro::LoroValue::Map(map) = &loro_val else {
return None;
};
let current_val = map.get(field.as_str())?;
let val_str = match current_val {
loro::LoroValue::String(s) => s.to_string(),
loro::LoroValue::I64(n) => n.to_string(),
loro::LoroValue::Double(n) => n.to_string(),
other => format!("{other:?}"),
};
let new_val = format!("{val_str}_1");
state
.upsert(
&collection,
&doc_id,
&[(
field.as_str(),
loro::LoroValue::String(new_val.clone().into()),
)],
)
.ok()?;
Some(PolicyResolution::AutoResolved(
ResolvedAction::RenamedField {
field: field.clone(),
new_value: new_val,
},
))
})();
resolved.unwrap_or(PolicyResolution::Escalate {
violations: violation_from_hint(hint),
})
}
ConflictPolicy::CascadeDefer { max_retries, .. } => PolicyResolution::Deferred {
retry_after_ms: 1000,
attempt: 1.min(*max_retries),
violations: violation_from_hint(hint),
},
ConflictPolicy::EscalateToDlq => PolicyResolution::Escalate {
violations: violation_from_hint(hint),
},
ConflictPolicy::Custom { .. } => PolicyResolution::Escalate {
violations: violation_from_hint(hint),
},
},
CompensationHint::ForeignKeyMissing { .. } => match &policy.foreign_key {
ConflictPolicy::CascadeDefer {
max_retries,
ttl_secs,
} => PolicyResolution::Deferred {
retry_after_ms: (*ttl_secs * 1000 / (*max_retries).max(1) as u64).max(1000),
attempt: 1,
violations: violation_from_hint(hint),
},
ConflictPolicy::LastWriterWins => {
PolicyResolution::AutoResolved(ResolvedAction::OverwriteExisting)
}
ConflictPolicy::RenameSuffix
| ConflictPolicy::Custom { .. }
| ConflictPolicy::EscalateToDlq => PolicyResolution::Escalate {
violations: violation_from_hint(hint),
},
},
CompensationHint::IntegrityViolation => {
self.delete_local_row(&collection, &doc_id);
self.sync_indexes(&collection, [doc_id.as_str()]);
self.pending_deltas.remove(pos);
return Some(PolicyResolution::Escalate {
violations: violation_from_hint(hint),
});
}
_ => PolicyResolution::Escalate {
violations: violation_from_hint(hint),
},
};
match &resolution {
PolicyResolution::Escalate { .. } => {
self.delete_local_row(&collection, &doc_id);
self.pending_deltas.remove(pos);
}
PolicyResolution::AutoResolved(_) => {
self.pending_deltas.remove(pos);
}
PolicyResolution::Deferred { .. } | PolicyResolution::WebhookRequired { .. } => {}
}
self.sync_indexes(&collection, [doc_id.as_str()]);
Some(resolution)
}
fn delete_local_row(&self, collection: &str, doc_id: &str) {
if let Some(state) = self.states.get(collection) {
let _ = state.delete(collection, doc_id);
}
}
}