Skip to main content

appcore_sync_sqlite/
config.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: config.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/26 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Defines bounded config contracts and behavior for this crate.
12
13use crate::{SqliteSyncError, SqliteSyncResult};
14use std::fmt;
15use std::path::{Path, PathBuf};
16
17const MIB: u64 = 1024 * 1024;
18
19/// Bounded configuration for one `SQLite` sync database.
20#[derive(Clone, PartialEq, Eq)]
21pub struct SqliteSyncConfig {
22    pub(crate) path: PathBuf,
23    pub(crate) separate_outbox_path: Option<PathBuf>,
24    pub(crate) max_database_bytes: u64,
25    pub(crate) max_outbox_entries: usize,
26    pub(crate) max_checkpoints: usize,
27    pub(crate) max_outbox_record_bytes: usize,
28    pub(crate) max_read_records: usize,
29    pub(crate) max_read_bytes: usize,
30    pub(crate) max_tombstones: usize,
31    pub(crate) max_connections: usize,
32    pub(crate) busy_timeout_ms: u64,
33    pub(crate) backup_pages_per_step: i32,
34}
35
36impl SqliteSyncConfig {
37    /// Creates a configuration with conservative production bounds.
38    pub fn new(path: impl Into<PathBuf>) -> Self {
39        Self {
40            path: path.into(),
41            separate_outbox_path: None,
42            max_database_bytes: 512 * MIB,
43            max_outbox_entries: 10_000,
44            max_checkpoints: 10_000,
45            max_outbox_record_bytes: 48 * MIB as usize,
46            max_read_records: 4_096,
47            max_read_bytes: 16 * MIB as usize,
48            max_tombstones: 100_000,
49            max_connections: 8,
50            busy_timeout_ms: 5_000,
51            backup_pages_per_step: 128,
52        }
53    }
54
55    /// Stores pending synchronization messages in a separate SQLite file.
56    ///
57    /// The file must be in the same directory as the provider database so one
58    /// SQLite connection can commit local state and the pending queue together.
59    /// The provider rejects configurations that cannot meet its multi-file
60    /// durability requirements.
61    pub fn with_separate_outbox_path(mut self, path: impl Into<PathBuf>) -> Self {
62        self.separate_outbox_path = Some(path.into());
63        self
64    }
65
66    /// Selects the maximum database size in bytes.
67    pub fn with_max_database_bytes(mut self, value: u64) -> Self {
68        self.max_database_bytes = value;
69        self
70    }
71
72    /// Selects the maximum number of pending outbox entries.
73    pub fn with_max_outbox_entries(mut self, value: usize) -> Self {
74        self.max_outbox_entries = value;
75        self
76    }
77
78    /// Selects the maximum number of peer checkpoints.
79    pub fn with_max_checkpoints(mut self, value: usize) -> Self {
80        self.max_checkpoints = value;
81        self
82    }
83
84    /// Selects the maximum encoded size of one outbox entry.
85    pub fn with_max_outbox_record_bytes(mut self, value: usize) -> Self {
86        self.max_outbox_record_bytes = value;
87        self
88    }
89
90    /// Selects the maximum record count returned by one log read.
91    pub fn with_max_read_records(mut self, value: usize) -> Self {
92        self.max_read_records = value;
93        self
94    }
95
96    /// Selects the maximum payload bytes returned by one log read.
97    pub fn with_max_read_bytes(mut self, value: usize) -> Self {
98        self.max_read_bytes = value;
99        self
100    }
101
102    /// Selects the maximum retained tombstone count.
103    pub fn with_max_tombstones(mut self, value: usize) -> Self {
104        self.max_tombstones = value;
105        self
106    }
107
108    /// Selects the maximum number of simultaneously open `SQLite` connections.
109    pub fn with_max_connections(mut self, value: usize) -> Self {
110        self.max_connections = value;
111        self
112    }
113
114    /// Selects the `SQLite` writer-admission timeout in milliseconds.
115    pub fn with_busy_timeout_ms(mut self, value: u64) -> Self {
116        self.busy_timeout_ms = value;
117        self
118    }
119
120    /// Selects the number of pages copied by each online-backup step.
121    pub fn with_backup_pages_per_step(mut self, value: i32) -> Self {
122        self.backup_pages_per_step = value;
123        self
124    }
125
126    /// Returns the configured database path.
127    pub fn path(&self) -> &Path {
128        &self.path
129    }
130
131    /// Returns the optional path for the pending-only outbox database.
132    pub fn separate_outbox_path(&self) -> Option<&Path> {
133        self.separate_outbox_path.as_deref()
134    }
135
136    pub(crate) fn outbox_table(&self) -> &'static str {
137        if self.separate_outbox_path.is_some() {
138            "sync_queue.appcore_sync_outbox"
139        } else {
140            "main.appcore_sync_outbox"
141        }
142    }
143
144    pub(crate) fn outbox_database(&self) -> &'static str {
145        if self.separate_outbox_path.is_some() {
146            "sync_queue"
147        } else {
148            "main"
149        }
150    }
151
152    pub(crate) fn validate(&self) -> SqliteSyncResult<()> {
153        if self.path.as_os_str().is_empty() {
154            return Err(SqliteSyncError::InvalidConfiguration("empty path"));
155        }
156        if self
157            .separate_outbox_path
158            .as_ref()
159            .is_some_and(|path| path.as_os_str().is_empty())
160        {
161            return Err(SqliteSyncError::InvalidConfiguration("empty outbox path"));
162        }
163        if !(8 * MIB..=8 * 1024 * MIB).contains(&self.max_database_bytes) {
164            return Err(SqliteSyncError::InvalidConfiguration(
165                "database byte bound is outside 8 MiB..=8 GiB",
166            ));
167        }
168        if !(1..=100_000).contains(&self.max_outbox_entries) {
169            return Err(SqliteSyncError::InvalidConfiguration(
170                "outbox entry bound is outside 1..=100000",
171            ));
172        }
173        if !(1..=100_000).contains(&self.max_checkpoints) {
174            return Err(SqliteSyncError::InvalidConfiguration(
175                "checkpoint bound is outside 1..=100000",
176            ));
177        }
178        if !(MIB as usize..=48 * MIB as usize).contains(&self.max_outbox_record_bytes) {
179            return Err(SqliteSyncError::InvalidConfiguration(
180                "outbox record bound is outside 1 MiB..=48 MiB",
181            ));
182        }
183        if !(1..=10_000).contains(&self.max_read_records) {
184            return Err(SqliteSyncError::InvalidConfiguration(
185                "read record bound is outside 1..=10000",
186            ));
187        }
188        if !(MIB as usize..=64 * MIB as usize).contains(&self.max_read_bytes) {
189            return Err(SqliteSyncError::InvalidConfiguration(
190                "read byte bound is outside 1 MiB..=64 MiB",
191            ));
192        }
193        if !(1..=1_000_000).contains(&self.max_tombstones) {
194            return Err(SqliteSyncError::InvalidConfiguration(
195                "tombstone bound is outside 1..=1000000",
196            ));
197        }
198        if !(1..=32).contains(&self.max_connections) {
199            return Err(SqliteSyncError::InvalidConfiguration(
200                "connection bound is outside 1..=32",
201            ));
202        }
203        if !(1..=60_000).contains(&self.busy_timeout_ms) {
204            return Err(SqliteSyncError::InvalidConfiguration(
205                "busy timeout is outside 1..=60000 ms",
206            ));
207        }
208        if !(1..=4_096).contains(&self.backup_pages_per_step) {
209            return Err(SqliteSyncError::InvalidConfiguration(
210                "backup step is outside 1..=4096 pages",
211            ));
212        }
213        Ok(())
214    }
215}
216
217impl fmt::Debug for SqliteSyncConfig {
218    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
219        formatter
220            .debug_struct("SqliteSyncConfig")
221            .field("path_configured", &!self.path.as_os_str().is_empty())
222            .field("max_database_bytes", &self.max_database_bytes)
223            .field("max_outbox_entries", &self.max_outbox_entries)
224            .field("max_checkpoints", &self.max_checkpoints)
225            .field("max_outbox_record_bytes", &self.max_outbox_record_bytes)
226            .field("max_read_records", &self.max_read_records)
227            .field("max_read_bytes", &self.max_read_bytes)
228            .field("max_tombstones", &self.max_tombstones)
229            .field("max_connections", &self.max_connections)
230            .field("busy_timeout_ms", &self.busy_timeout_ms)
231            .field("backup_pages_per_step", &self.backup_pages_per_step)
232            .finish()
233    }
234}