Skip to main content

nodedb_types/sync/
compensation.rs

1//! Typed compensation hints for rejected sync deltas.
2//!
3//! When the Origin rejects a CRDT delta (constraint violation, RLS, rate limit),
4//! it sends a `CompensationHint` back to the edge client. The edge uses this
5//! to roll back optimistic local state and notify the application with a
6//! typed, actionable error — not a generic string.
7
8use serde::{Deserialize, Serialize};
9
10/// Typed compensation hint sent from Origin to edge when a delta is rejected.
11///
12/// The edge's `CompensationHandler` receives this and can programmatically
13/// decide how to react (prompt user, auto-retry with suffix, silently merge).
14#[derive(
15    Debug,
16    Clone,
17    PartialEq,
18    Eq,
19    Serialize,
20    Deserialize,
21    rkyv::Archive,
22    rkyv::Serialize,
23    rkyv::Deserialize,
24)]
25pub enum CompensationHint {
26    /// UNIQUE constraint violated — another device wrote the same value first.
27    UniqueViolation {
28        /// The field that has the UNIQUE constraint (e.g., "username").
29        field: String,
30        /// The conflicting value that was already taken.
31        conflicting_value: String,
32    },
33
34    /// Foreign key reference missing — the referenced entity doesn't exist.
35    ForeignKeyMissing {
36        /// The ID that was referenced but not found.
37        referenced_id: String,
38    },
39
40    /// Permission denied — the user doesn't have write access.
41    /// No details are leaked (security: the edge is untrusted).
42    PermissionDenied,
43
44    /// Rate limit exceeded — try again later.
45    RateLimited {
46        /// Suggested delay before retrying (milliseconds).
47        retry_after_ms: u64,
48    },
49
50    /// Schema violation — the delta doesn't conform to the collection schema.
51    SchemaViolation {
52        /// Which field failed validation.
53        field: String,
54        /// Human-readable reason.
55        reason: String,
56    },
57
58    /// Custom application-defined constraint violation.
59    Custom {
60        /// Constraint name.
61        constraint: String,
62        /// Typed payload for the application to interpret.
63        detail: String,
64    },
65
66    /// Data integrity violation — CRC32C checksum mismatch on delta payload.
67    /// The client should re-send the delta.
68    IntegrityViolation,
69}
70
71impl CompensationHint {
72    /// Returns a short, machine-readable code for the hint type.
73    pub fn code(&self) -> &'static str {
74        match self {
75            Self::UniqueViolation { .. } => "UNIQUE_VIOLATION",
76            Self::ForeignKeyMissing { .. } => "FK_MISSING",
77            Self::PermissionDenied => "PERMISSION_DENIED",
78            Self::RateLimited { .. } => "RATE_LIMITED",
79            Self::SchemaViolation { .. } => "SCHEMA_VIOLATION",
80            Self::Custom { .. } => "CUSTOM",
81            Self::IntegrityViolation => "INTEGRITY_VIOLATION",
82        }
83    }
84}
85
86impl std::fmt::Display for CompensationHint {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::UniqueViolation {
90                field,
91                conflicting_value,
92            } => write!(
93                f,
94                "UNIQUE({field}): value '{conflicting_value}' already exists"
95            ),
96            Self::ForeignKeyMissing { referenced_id } => {
97                write!(f, "FK_MISSING: referenced ID '{referenced_id}' not found")
98            }
99            Self::PermissionDenied => write!(f, "PERMISSION_DENIED"),
100            Self::RateLimited { retry_after_ms } => {
101                write!(f, "RATE_LIMITED: retry after {retry_after_ms}ms")
102            }
103            Self::SchemaViolation { field, reason } => {
104                write!(f, "SCHEMA({field}): {reason}")
105            }
106            Self::Custom {
107                constraint, detail, ..
108            } => write!(f, "CUSTOM({constraint}): {detail}"),
109            Self::IntegrityViolation => write!(f, "INTEGRITY_VIOLATION: CRC32C mismatch"),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn compensation_codes() {
120        assert_eq!(
121            CompensationHint::UniqueViolation {
122                field: "email".into(),
123                conflicting_value: "a@b.com".into()
124            }
125            .code(),
126            "UNIQUE_VIOLATION"
127        );
128        assert_eq!(
129            CompensationHint::PermissionDenied.code(),
130            "PERMISSION_DENIED"
131        );
132        assert_eq!(
133            CompensationHint::RateLimited {
134                retry_after_ms: 5000
135            }
136            .code(),
137            "RATE_LIMITED"
138        );
139    }
140
141    #[test]
142    fn compensation_display() {
143        let hint = CompensationHint::UniqueViolation {
144            field: "username".into(),
145            conflicting_value: "alice".into(),
146        };
147        assert!(hint.to_string().contains("alice"));
148        assert!(hint.to_string().contains("username"));
149    }
150
151    #[test]
152    fn msgpack_roundtrip() {
153        let hint = CompensationHint::ForeignKeyMissing {
154            referenced_id: "user-42".into(),
155        };
156        let bytes = rmp_serde::to_vec_named(&hint).unwrap();
157        let decoded: CompensationHint = rmp_serde::from_slice(&bytes).unwrap();
158        assert_eq!(hint, decoded);
159    }
160}