use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncConfig {
pub enabled: bool,
pub interval: String,
pub batch_size: usize,
pub tables_to_sync: Vec<String>,
pub min_row_diff_threshold: i64,
#[serde(default)]
pub tenant_id_filter: Option<String>,
#[serde(default)]
pub allow_cross_tenant_restore: bool,
}
impl Default for SyncConfig {
fn default() -> Self {
Self {
enabled: false,
interval: "15m".to_string(),
batch_size: 500,
tables_to_sync: Vec::new(),
min_row_diff_threshold: 0,
tenant_id_filter: None,
allow_cross_tenant_restore: false,
}
}
}
impl SyncConfig {
pub fn effective_batch_size(&self) -> usize {
if self.batch_size > 0 {
self.batch_size
} else {
500
}
}
pub fn has_explicit_tables(&self) -> bool {
!self.tables_to_sync.is_empty()
}
pub fn tenant_scoped(&self) -> bool {
self.tenant_id_filter
.as_deref()
.map(str::trim)
.is_some_and(|tenant| !tenant.is_empty())
}
pub fn validate_tenant_scope(&self) -> Result<(), String> {
if self.enabled && !self.tenant_scoped() && !self.allow_cross_tenant_restore {
return Err(
"sync/restore is enabled without tenant_id_filter; set tenant_id_filter or allow_cross_tenant_restore=true"
.to_string(),
);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SyncResult {
pub started_at_unix: u64,
pub finished_at_unix: u64,
pub tables_synced: usize,
pub rows_copied: i64,
pub errors: Vec<String>,
}
impl SyncResult {
pub fn is_clean(&self) -> bool {
self.errors.is_empty()
}
pub fn elapsed_ms(&self) -> u64 {
self.finished_at_unix.saturating_sub(self.started_at_unix) * 1000
}
pub fn summary(&self) -> String {
if self.is_clean() {
format!(
"sync cycle ok: tables={} rows={} elapsed={}ms",
self.tables_synced,
self.rows_copied,
self.elapsed_ms()
)
} else {
format!(
"sync cycle finished with {} error(s): tables={} rows={} elapsed={}ms",
self.errors.len(),
self.tables_synced,
self.rows_copied,
self.elapsed_ms()
)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SyncStatus {
pub enabled: bool,
pub cycles_completed: u64,
pub total_rows_copied: i64,
pub total_errors: usize,
pub last_result: Option<SyncResult>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_config_defaults() {
let cfg = SyncConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.interval, "15m");
assert_eq!(cfg.effective_batch_size(), 500);
}
#[test]
fn sync_result_is_clean_when_no_errors() {
let r = SyncResult {
tables_synced: 5,
rows_copied: 120,
..Default::default()
};
assert!(r.is_clean());
}
#[test]
fn sync_result_not_clean_with_errors() {
let r = SyncResult {
errors: vec!["table app_intake.artifacts: PK discovery failed".to_string()],
..Default::default()
};
assert!(!r.is_clean());
}
#[test]
fn sync_result_summary_clean() {
let r = SyncResult {
tables_synced: 3,
rows_copied: 42,
started_at_unix: 1000,
finished_at_unix: 1002,
..Default::default()
};
let s = r.summary();
assert!(s.contains("tables=3"));
assert!(s.contains("rows=42"));
assert!(s.contains("elapsed=2000ms"));
}
#[test]
fn sync_config_requires_tenant_scope_or_break_glass_when_enabled() {
let mut cfg = SyncConfig {
enabled: true,
..SyncConfig::default()
};
assert!(cfg.validate_tenant_scope().is_err());
cfg.tenant_id_filter = Some("tenant-a".to_string());
assert!(cfg.validate_tenant_scope().is_ok());
cfg.tenant_id_filter = None;
cfg.allow_cross_tenant_restore = true;
assert!(cfg.validate_tenant_scope().is_ok());
}
}