Skip to main content

nodedb_types/sync/
compensation.rs

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