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    zerompk::ToMessagePack,
25    zerompk::FromMessagePack,
26)]
27pub enum CompensationHint {
28    /// UNIQUE constraint violated — another device wrote the same value first.
29    UniqueViolation {
30        /// The field that has the UNIQUE constraint (e.g., "username").
31        field: String,
32        /// The conflicting value that was already taken.
33        conflicting_value: String,
34    },
35
36    /// Foreign key reference missing — the referenced entity doesn't exist.
37    ForeignKeyMissing {
38        /// The ID that was referenced but not found.
39        referenced_id: String,
40    },
41
42    /// Permission denied — the user doesn't have write access.
43    /// No details are leaked (security: the edge is untrusted).
44    PermissionDenied,
45
46    /// Rate limit exceeded — try again later.
47    RateLimited {
48        /// Suggested delay before retrying (milliseconds).
49        retry_after_ms: u64,
50    },
51
52    /// Schema violation — the delta doesn't conform to the collection schema.
53    SchemaViolation {
54        /// Which field failed validation.
55        field: String,
56        /// Human-readable reason.
57        reason: String,
58    },
59
60    /// Custom application-defined constraint violation.
61    Custom {
62        /// Constraint name.
63        constraint: String,
64        /// Typed payload for the application to interpret.
65        detail: String,
66    },
67
68    /// Data integrity violation — CRC32C checksum mismatch on delta payload.
69    /// The client should re-send the delta.
70    IntegrityViolation,
71}
72
73impl CompensationHint {
74    /// Returns a short, machine-readable code for the hint type.
75    pub fn code(&self) -> &'static str {
76        match self {
77            Self::UniqueViolation { .. } => "UNIQUE_VIOLATION",
78            Self::ForeignKeyMissing { .. } => "FK_MISSING",
79            Self::PermissionDenied => "PERMISSION_DENIED",
80            Self::RateLimited { .. } => "RATE_LIMITED",
81            Self::SchemaViolation { .. } => "SCHEMA_VIOLATION",
82            Self::Custom { .. } => "CUSTOM",
83            Self::IntegrityViolation => "INTEGRITY_VIOLATION",
84        }
85    }
86}
87
88impl std::fmt::Display for CompensationHint {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::UniqueViolation {
92                field,
93                conflicting_value,
94            } => write!(
95                f,
96                "UNIQUE({field}): value '{conflicting_value}' already exists"
97            ),
98            Self::ForeignKeyMissing { referenced_id } => {
99                write!(f, "FK_MISSING: referenced ID '{referenced_id}' not found")
100            }
101            Self::PermissionDenied => write!(f, "PERMISSION_DENIED"),
102            Self::RateLimited { retry_after_ms } => {
103                write!(f, "RATE_LIMITED: retry after {retry_after_ms}ms")
104            }
105            Self::SchemaViolation { field, reason } => {
106                write!(f, "SCHEMA({field}): {reason}")
107            }
108            Self::Custom {
109                constraint, detail, ..
110            } => write!(f, "CUSTOM({constraint}): {detail}"),
111            Self::IntegrityViolation => write!(f, "INTEGRITY_VIOLATION: CRC32C mismatch"),
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn compensation_codes() {
122        assert_eq!(
123            CompensationHint::UniqueViolation {
124                field: "email".into(),
125                conflicting_value: "a@b.com".into()
126            }
127            .code(),
128            "UNIQUE_VIOLATION"
129        );
130        assert_eq!(
131            CompensationHint::PermissionDenied.code(),
132            "PERMISSION_DENIED"
133        );
134        assert_eq!(
135            CompensationHint::RateLimited {
136                retry_after_ms: 5000
137            }
138            .code(),
139            "RATE_LIMITED"
140        );
141    }
142
143    #[test]
144    fn compensation_display() {
145        let hint = CompensationHint::UniqueViolation {
146            field: "username".into(),
147            conflicting_value: "alice".into(),
148        };
149        assert!(hint.to_string().contains("alice"));
150        assert!(hint.to_string().contains("username"));
151    }
152
153    #[test]
154    fn msgpack_roundtrip() {
155        let hint = CompensationHint::ForeignKeyMissing {
156            referenced_id: "user-42".into(),
157        };
158        let bytes = zerompk::to_msgpack_vec(&hint).unwrap();
159        let decoded: CompensationHint = zerompk::from_msgpack(&bytes).unwrap();
160        assert_eq!(hint, decoded);
161    }
162}