use nodedb_crdt::validator::{ValidationOutcome, Violation};
use nodedb_types::Surrogate;
use nodedb_types::sync::violation::ViolationType;
use super::core::TenantCrdtEngine;
#[derive(Debug)]
pub enum ValidatedApplyOutcome {
Clean { write_set: Vec<(String, String)> },
Rejected(ViolationType),
Malformed,
}
impl TenantCrdtEngine {
pub fn apply_committed_delta_validated(
&mut self,
collection: &str,
delta: &[u8],
surrogate: Surrogate,
document_id: &str,
peer_id: u64,
) -> ValidatedApplyOutcome {
let write_set = {
let state = match self.state_mut(collection) {
Ok(s) => s,
Err(_) => return ValidatedApplyOutcome::Malformed,
};
let before = state.frontier();
if state.import(delta).is_err() {
return ValidatedApplyOutcome::Malformed;
}
match state.write_set_since(&before) {
Ok(ws) => ws,
Err(_) => return ValidatedApplyOutcome::Malformed,
}
};
for (coll, row) in &write_set {
let sg = if row.as_str() == document_id {
surrogate
} else {
Surrogate::ZERO
};
let ValidationOutcome::Rejected(violations) =
self.validate_committed_row(coll, row, sg)
else {
continue;
};
let Some(violation) = violations.into_iter().next() else {
continue;
};
return ValidatedApplyOutcome::Rejected(
self.dlq_and_translate(coll, delta, peer_id, violation),
);
}
ValidatedApplyOutcome::Clean { write_set }
}
fn dlq_and_translate(
&mut self,
collection: &str,
delta: &[u8],
peer_id: u64,
violation: Violation,
) -> ViolationType {
let user_id = 0u64;
let tenant_id = self.tenant_id().as_u64();
let constraint = self
.constraints_for_collection(collection)
.into_iter()
.find(|c| c.name == violation.constraint_name);
let reason = violation.reason.clone();
match constraint {
Some(constraint) => {
if let Err(e) =
self.validator
.dlq_mut()
.enqueue(nodedb_crdt::EnqueueDeadLetterArgs {
peer_id,
user_id,
tenant_id,
delta: delta.to_vec(),
constraint: &constraint,
reason,
hint: violation.hint.clone(),
})
{
tracing::warn!(
tenant = tenant_id,
collection,
error = %e,
"crdt: failed to enqueue rejected delta to DLQ"
);
}
}
None => {
let fallback = nodedb_crdt::Constraint {
name: violation.constraint_name.clone(),
collection: collection.to_string(),
field: String::new(),
kind: nodedb_crdt::ConstraintKind::Check {
expr: String::new(),
description: "unresolved constraint".to_string(),
},
};
let hint = nodedb_crdt::CompensationHint::ManualIntervention {
reason: reason.clone(),
};
if let Err(e) =
self.validator
.dlq_mut()
.enqueue(nodedb_crdt::EnqueueDeadLetterArgs {
peer_id,
user_id,
tenant_id,
delta: delta.to_vec(),
constraint: &fallback,
reason,
hint,
})
{
tracing::warn!(
tenant = tenant_id,
collection,
error = %e,
"crdt: failed to enqueue rejected delta to DLQ (unresolved constraint)"
);
}
}
}
violation_to_type(&violation)
}
}
fn violation_to_type(violation: &Violation) -> ViolationType {
use nodedb_crdt::CompensationHint;
match &violation.hint {
CompensationHint::RetryWithDifferentValue {
field,
conflicting_value,
..
} => ViolationType::UniqueViolation {
field: field.clone(),
value: conflicting_value.clone(),
},
CompensationHint::CreateReferencedRow { ref_key, .. } => ViolationType::ForeignKeyMissing {
referenced_id: ref_key.clone(),
},
CompensationHint::ProvideRequiredField { field } => ViolationType::SchemaViolation {
field: field.clone(),
reason: "required field missing".into(),
},
CompensationHint::DeleteThenRetry { .. } | CompensationHint::ManualIntervention { .. } => {
ViolationType::ConstraintViolation {
detail: violation.reason.clone(),
}
}
}
}
#[cfg(test)]
mod tests {
use loro::LoroValue;
use nodedb_crdt::CompensationHint;
use nodedb_crdt::constraint::ConstraintSet;
use nodedb_crdt::policy::CollectionPolicy;
use nodedb_crdt::state::CrdtState;
use nodedb_crdt::validator::Violation;
use super::*;
use crate::types::TenantId;
fn unique_engine() -> TenantCrdtEngine {
let mut cs = ConstraintSet::new();
cs.add_unique("users_email_unique", "users", "email");
let mut engine = TenantCrdtEngine::new(TenantId::new(1), 0, cs).unwrap();
engine.set_collection_policy_typed("users", CollectionPolicy::strict());
engine
}
fn row_delta(peer: u64, row_id: &str, email: &str, name: &str) -> Vec<u8> {
let state = CrdtState::new(peer).unwrap();
state
.upsert(
"users",
row_id,
&[
("email", LoroValue::String(email.into())),
("name", LoroValue::String(name.into())),
],
)
.unwrap();
state.export_snapshot().unwrap()
}
#[test]
fn valid_delta_is_clean() {
let mut engine = unique_engine();
let delta = row_delta(2, "a", "x@y.com", "A");
let outcome = engine.apply_committed_delta_validated(
"users",
&delta,
nodedb_types::Surrogate::ZERO,
"a",
2,
);
assert!(matches!(outcome, ValidatedApplyOutcome::Clean { .. }));
assert!(engine.row_exists("users", "a"));
assert_eq!(engine.dlq_len(), 0);
}
#[test]
fn multi_doc_delta_reports_full_write_set() {
let mut engine = unique_engine();
let state = CrdtState::new(7).unwrap();
state
.upsert(
"users",
"a",
&[("email", LoroValue::String("a@y.com".into()))],
)
.unwrap();
state
.upsert(
"users",
"b",
&[("email", LoroValue::String("b@y.com".into()))],
)
.unwrap();
let delta = state.export_snapshot().unwrap();
let outcome = engine.apply_committed_delta_validated(
"users",
&delta,
nodedb_types::Surrogate::ZERO,
"a",
7,
);
let ValidatedApplyOutcome::Clean { write_set } = outcome else {
panic!("expected Clean with a populated write-set");
};
assert!(write_set.iter().any(|(c, r)| c == "users" && r == "a"));
assert!(write_set.iter().any(|(c, r)| c == "users" && r == "b"));
}
#[test]
fn unique_dup_is_rejected_and_dlqd() {
let mut engine = unique_engine();
let delta_a = row_delta(2, "a", "x@y.com", "A");
let clean = engine.apply_committed_delta_validated(
"users",
&delta_a,
nodedb_types::Surrogate::ZERO,
"a",
2,
);
assert!(matches!(clean, ValidatedApplyOutcome::Clean { .. }));
let delta_b = row_delta(3, "b", "x@y.com", "B");
let outcome = engine.apply_committed_delta_validated(
"users",
&delta_b,
nodedb_types::Surrogate::ZERO,
"b",
3,
);
match outcome {
ValidatedApplyOutcome::Rejected(ViolationType::UniqueViolation { field, value }) => {
assert_eq!(field, "email");
assert_eq!(value, "x@y.com");
}
other => panic!("expected UniqueViolation, got {other:?}"),
}
assert_eq!(engine.dlq_len(), 1);
}
#[test]
fn corrupt_delta_is_malformed() {
let mut engine = unique_engine();
let outcome = engine.apply_committed_delta_validated(
"users",
b"not a valid loro snapshot",
nodedb_types::Surrogate::ZERO,
"z",
9,
);
assert!(matches!(outcome, ValidatedApplyOutcome::Malformed));
assert_eq!(engine.dlq_len(), 0);
}
fn violation_with(hint: CompensationHint) -> Violation {
Violation {
constraint_name: "c".into(),
reason: "boom".into(),
hint,
}
}
#[test]
fn translator_maps_each_hint() {
assert_eq!(
violation_to_type(&violation_with(CompensationHint::RetryWithDifferentValue {
field: "email".into(),
conflicting_value: "x".into(),
suggestion: "x2".into(),
})),
ViolationType::UniqueViolation {
field: "email".into(),
value: "x".into(),
}
);
assert_eq!(
violation_to_type(&violation_with(CompensationHint::CreateReferencedRow {
ref_collection: "orgs".into(),
ref_key: "org-7".into(),
missing_value: "org-7".into(),
})),
ViolationType::ForeignKeyMissing {
referenced_id: "org-7".into(),
}
);
assert_eq!(
violation_to_type(&violation_with(CompensationHint::ProvideRequiredField {
field: "name".into(),
})),
ViolationType::SchemaViolation {
field: "name".into(),
reason: "required field missing".into(),
}
);
assert_eq!(
violation_to_type(&violation_with(CompensationHint::ManualIntervention {
reason: "nope".into(),
})),
ViolationType::ConstraintViolation {
detail: "boom".into(),
}
);
assert_eq!(
violation_to_type(&violation_with(CompensationHint::DeleteThenRetry {
collection: "users".into(),
conflicting_key: "a".into(),
})),
ViolationType::ConstraintViolation {
detail: "boom".into(),
}
);
}
#[test]
fn apply_module_stays_deterministic() {
const SRC: &str = include_str!("apply_validated.rs");
let forbidden = concat!("validate", "_or_", "reject");
assert!(
!SRC.contains(forbidden),
"apply_validated.rs must not reference the local write path's \
signed/seq-gated check — the Raft-applied peer-delta path must \
stay deterministic (pure Validator::validate only)"
);
}
}