Skip to main content

stateset_sync/
config.rs

1use serde::{Deserialize, Serialize};
2
3/// Default buffer capacity for the event buffer.
4const DEFAULT_BUFFER_CAPACITY: usize = 1000;
5
6/// Default batch size for push/pull operations.
7const DEFAULT_BATCH_SIZE: usize = 100;
8/// Default outbox capacity for pending local events.
9const DEFAULT_OUTBOX_CAPACITY: usize = 10_000;
10
11/// Configuration for the sync engine.
12///
13/// Maps to the JS `SyncConfig` (`agent_id`, `tenant_id`, `store_id`) plus
14/// tuning knobs for buffer capacity and batch sizes.
15///
16/// # Examples
17///
18/// ```
19/// use stateset_sync::SyncConfig;
20///
21/// let config = SyncConfig::new("agent-1", "tenant-1", "store-1");
22/// assert_eq!(config.agent_id, "agent-1");
23/// assert_eq!(config.buffer_capacity, 1000);
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SyncConfig {
27    /// Unique identifier for this agent.
28    pub agent_id: String,
29    /// Tenant identifier for multi-tenancy.
30    pub tenant_id: String,
31    /// Store identifier within the tenant.
32    pub store_id: String,
33    /// Maximum number of events the in-memory buffer can hold.
34    pub buffer_capacity: usize,
35    /// Maximum events per push/pull batch.
36    pub batch_size: usize,
37    /// Maximum pending local events in the outbox.
38    pub outbox_capacity: usize,
39    /// Optional durable outbox snapshot path.
40    pub outbox_path: Option<String>,
41}
42
43impl SyncConfig {
44    /// Create a new `SyncConfig` with sensible defaults.
45    #[must_use]
46    pub fn new(
47        agent_id: impl Into<String>,
48        tenant_id: impl Into<String>,
49        store_id: impl Into<String>,
50    ) -> Self {
51        Self {
52            agent_id: agent_id.into(),
53            tenant_id: tenant_id.into(),
54            store_id: store_id.into(),
55            buffer_capacity: DEFAULT_BUFFER_CAPACITY,
56            batch_size: DEFAULT_BATCH_SIZE,
57            outbox_capacity: DEFAULT_OUTBOX_CAPACITY,
58            outbox_path: None,
59        }
60    }
61
62    /// Set the buffer capacity.
63    #[must_use]
64    pub const fn with_buffer_capacity(mut self, capacity: usize) -> Self {
65        self.buffer_capacity = capacity;
66        self
67    }
68
69    /// Set the batch size.
70    #[must_use]
71    pub const fn with_batch_size(mut self, batch_size: usize) -> Self {
72        self.batch_size = batch_size;
73        self
74    }
75
76    /// Set the outbox capacity.
77    #[must_use]
78    pub const fn with_outbox_capacity(mut self, capacity: usize) -> Self {
79        self.outbox_capacity = capacity;
80        self
81    }
82
83    /// Set the durable outbox path.
84    #[must_use]
85    pub fn with_outbox_path(mut self, path: impl Into<String>) -> Self {
86        self.outbox_path = Some(path.into());
87        self
88    }
89
90    /// Resolve a valid buffer capacity.
91    #[must_use]
92    pub fn resolved_buffer_capacity(&self) -> usize {
93        self.buffer_capacity.max(1)
94    }
95
96    /// Resolve a valid batch size.
97    #[must_use]
98    pub fn resolved_batch_size(&self) -> usize {
99        self.batch_size.max(1)
100    }
101
102    /// Resolve a valid outbox capacity.
103    #[must_use]
104    pub fn resolved_outbox_capacity(&self) -> usize {
105        self.outbox_capacity.max(1)
106    }
107
108    /// Validate the configuration.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`crate::SyncError::InvalidConfig`] when required fields are invalid.
113    pub fn validate(&self) -> Result<(), crate::SyncError> {
114        if self.agent_id.trim().is_empty() {
115            return Err(crate::SyncError::InvalidConfig("agent_id must not be empty".into()));
116        }
117        if self.tenant_id.trim().is_empty() {
118            return Err(crate::SyncError::InvalidConfig("tenant_id must not be empty".into()));
119        }
120        if self.store_id.trim().is_empty() {
121            return Err(crate::SyncError::InvalidConfig("store_id must not be empty".into()));
122        }
123        if self.buffer_capacity == 0 {
124            return Err(crate::SyncError::InvalidConfig(
125                "buffer_capacity must be greater than 0".into(),
126            ));
127        }
128        if self.batch_size == 0 {
129            return Err(crate::SyncError::InvalidConfig(
130                "batch_size must be greater than 0".into(),
131            ));
132        }
133        if self.outbox_capacity == 0 {
134            return Err(crate::SyncError::InvalidConfig(
135                "outbox_capacity must be greater than 0".into(),
136            ));
137        }
138        if self.outbox_path.as_ref().is_some_and(|path| path.trim().is_empty()) {
139            return Err(crate::SyncError::InvalidConfig(
140                "outbox_path must not be empty when provided".into(),
141            ));
142        }
143        Ok(())
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn new_config_defaults() {
153        let config = SyncConfig::new("a", "t", "s");
154        assert_eq!(config.agent_id, "a");
155        assert_eq!(config.tenant_id, "t");
156        assert_eq!(config.store_id, "s");
157        assert_eq!(config.buffer_capacity, DEFAULT_BUFFER_CAPACITY);
158        assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
159        assert_eq!(config.outbox_capacity, DEFAULT_OUTBOX_CAPACITY);
160        assert!(config.outbox_path.is_none());
161    }
162
163    #[test]
164    fn config_builder_pattern() {
165        let config = SyncConfig::new("a", "t", "s")
166            .with_buffer_capacity(500)
167            .with_batch_size(50)
168            .with_outbox_capacity(900)
169            .with_outbox_path("/tmp/sync-outbox.json");
170        assert_eq!(config.buffer_capacity, 500);
171        assert_eq!(config.batch_size, 50);
172        assert_eq!(config.outbox_capacity, 900);
173        assert_eq!(config.outbox_path.as_deref(), Some("/tmp/sync-outbox.json"));
174    }
175
176    #[test]
177    fn config_serde_roundtrip() {
178        let config = SyncConfig::new("agent-1", "tenant-1", "store-1");
179        let json = serde_json::to_string(&config).unwrap();
180        let deserialized: SyncConfig = serde_json::from_str(&json).unwrap();
181        assert_eq!(deserialized.agent_id, config.agent_id);
182        assert_eq!(deserialized.tenant_id, config.tenant_id);
183        assert_eq!(deserialized.store_id, config.store_id);
184        assert_eq!(deserialized.buffer_capacity, config.buffer_capacity);
185        assert_eq!(deserialized.batch_size, config.batch_size);
186        assert_eq!(deserialized.outbox_capacity, config.outbox_capacity);
187        assert_eq!(deserialized.outbox_path, config.outbox_path);
188    }
189
190    #[test]
191    fn config_clone() {
192        let config = SyncConfig::new("a", "t", "s");
193        let cloned = config.clone();
194        assert_eq!(cloned.agent_id, config.agent_id);
195    }
196
197    #[test]
198    fn config_debug() {
199        let config = SyncConfig::new("a", "t", "s");
200        let debug = format!("{config:?}");
201        assert!(debug.contains("SyncConfig"));
202        assert!(debug.contains("agent_id"));
203    }
204
205    #[test]
206    fn validate_rejects_empty_ids_and_zero_caps() {
207        let bad = SyncConfig::new("", "tenant", "store");
208        assert!(bad.validate().is_err());
209
210        let bad = SyncConfig::new("agent", "", "store");
211        assert!(bad.validate().is_err());
212
213        let bad = SyncConfig::new("agent", "tenant", "")
214            .with_batch_size(0)
215            .with_buffer_capacity(0)
216            .with_outbox_capacity(0);
217        assert!(bad.validate().is_err());
218    }
219
220    #[test]
221    fn validate_accepts_good_config() {
222        let ok = SyncConfig::new("agent", "tenant", "store")
223            .with_buffer_capacity(100)
224            .with_batch_size(10)
225            .with_outbox_capacity(1000)
226            .with_outbox_path("/tmp/outbox.json");
227        assert!(ok.validate().is_ok());
228    }
229}