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    /// Transient rejection — the delta was admitted against a constraint
84    /// version not yet installed on the accepting replica. Not a failure:
85    /// re-push the delta as a new sequence after the suggested delay.
86    #[serde(rename = "retry")]
87    Retry {
88        /// Suggested delay before re-pushing (milliseconds).
89        retry_after_ms: u64,
90    },
91}
92
93impl CompensationHint {
94    /// Returns a short, machine-readable code for the hint type.
95    pub fn code(&self) -> &'static str {
96        match self {
97            Self::UniqueViolation { .. } => "UNIQUE_VIOLATION",
98            Self::ForeignKeyMissing { .. } => "FK_MISSING",
99            Self::PermissionDenied => "PERMISSION_DENIED",
100            Self::RateLimited { .. } => "RATE_LIMITED",
101            Self::SchemaViolation { .. } => "SCHEMA_VIOLATION",
102            Self::Custom { .. } => "CUSTOM",
103            Self::IntegrityViolation => "INTEGRITY_VIOLATION",
104            Self::Retry { .. } => "RETRY",
105        }
106    }
107}
108
109impl std::fmt::Display for CompensationHint {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::UniqueViolation {
113                field,
114                conflicting_value,
115            } => write!(
116                f,
117                "UNIQUE({field}): value '{conflicting_value}' already exists"
118            ),
119            Self::ForeignKeyMissing { referenced_id } => {
120                write!(f, "FK_MISSING: referenced ID '{referenced_id}' not found")
121            }
122            Self::PermissionDenied => write!(f, "PERMISSION_DENIED"),
123            Self::RateLimited { retry_after_ms } => {
124                write!(f, "RATE_LIMITED: retry after {retry_after_ms}ms")
125            }
126            Self::SchemaViolation { field, reason } => {
127                write!(f, "SCHEMA({field}): {reason}")
128            }
129            Self::Custom {
130                constraint, detail, ..
131            } => write!(f, "CUSTOM({constraint}): {detail}"),
132            Self::IntegrityViolation => write!(f, "INTEGRITY_VIOLATION: CRC32C mismatch"),
133            Self::Retry { retry_after_ms } => {
134                write!(f, "RETRY: retry after {retry_after_ms}ms")
135            }
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn compensation_codes() {
146        assert_eq!(
147            CompensationHint::UniqueViolation {
148                field: "email".into(),
149                conflicting_value: "a@b.com".into()
150            }
151            .code(),
152            "UNIQUE_VIOLATION"
153        );
154        assert_eq!(
155            CompensationHint::PermissionDenied.code(),
156            "PERMISSION_DENIED"
157        );
158        assert_eq!(
159            CompensationHint::RateLimited {
160                retry_after_ms: 5000
161            }
162            .code(),
163            "RATE_LIMITED"
164        );
165    }
166
167    #[test]
168    fn compensation_display() {
169        let hint = CompensationHint::UniqueViolation {
170            field: "username".into(),
171            conflicting_value: "alice".into(),
172        };
173        assert!(hint.to_string().contains("alice"));
174        assert!(hint.to_string().contains("username"));
175    }
176
177    #[test]
178    fn retry_code_and_display() {
179        let hint = CompensationHint::Retry {
180            retry_after_ms: 1000,
181        };
182        assert_eq!(hint.code(), "RETRY");
183        assert!(hint.to_string().contains("1000"));
184    }
185
186    #[test]
187    fn msgpack_roundtrip() {
188        let hint = CompensationHint::ForeignKeyMissing {
189            referenced_id: "user-42".into(),
190        };
191        let bytes = zerompk::to_msgpack_vec(&hint).unwrap();
192        let decoded: CompensationHint = zerompk::from_msgpack(&bytes).unwrap();
193        assert_eq!(hint, decoded);
194    }
195}