pub mod capturer;
pub mod checkpoint;
pub mod dedup;
pub mod downstream;
pub mod masking;
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeOp {
Insert,
Update,
Delete,
}
pub type Row = HashMap<String, Value>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeEvent {
pub op: ChangeOp,
pub before: Option<Row>,
pub after: Option<Row>,
pub timestamp: u64,
pub transaction_id: String,
pub table: String,
pub schema: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DbType {
Postgres,
Mysql,
Sqlite,
Oracle,
Mssql,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CheckpointPosition {
WalLsn(u64),
BinlogGtid(String),
TriggerSeq(u64),
LogMinerScn(u64),
CdcLsn(u64),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdcCheckpoint {
pub dialect: DbType,
pub position: CheckpointPosition,
pub updated_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DownstreamConfig {
Kafka {
topic: String,
},
RabbitMq {
exchange: String,
},
Nats {
subject: String,
},
Pulsar {
topic: String,
},
RocketMq {
topic: String,
},
ActiveMq {
queue: String,
},
HttpWebhook {
url: String,
headers: HashMap<String, String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CheckpointStoreConfig {
File { path: String },
Memory,
}
pub type MaskingRuleMap = HashMap<String, MaskingRule>;
#[derive(Debug, Clone)]
pub struct CdcConfig {
pub tables: Vec<String>,
pub dialect: DbType,
pub downstream: Vec<DownstreamConfig>,
pub checkpoint_store: CheckpointStoreConfig,
pub masking: Option<MaskingRuleMap>,
}
#[derive(Debug, Clone)]
pub enum CdcError {
WalNotConfigured,
BinlogNotEnabled,
DownstreamUnavailable { downstream: String },
CheckpointFailed,
CaptureError { reason: String },
}
impl fmt::Display for CdcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CdcError::WalNotConfigured => {
write!(
f,
"WAL logical replication not configured (wal_level != logical)"
)
}
CdcError::BinlogNotEnabled => {
write!(f, "MySQL binlog not enabled (binlog_format != ROW)")
}
CdcError::DownstreamUnavailable { downstream } => {
write!(f, "downstream unavailable: {downstream}")
}
CdcError::CheckpointFailed => write!(f, "checkpoint persistence failed"),
CdcError::CaptureError { reason } => write!(f, "capture error: {reason}"),
}
}
}
impl std::error::Error for CdcError {}
pub use sz_orm_masking::MaskingRule;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_change_event_serde() {
let mut before = HashMap::new();
before.insert("name".to_string(), Value::String("old".to_string()));
let mut after = HashMap::new();
after.insert("name".to_string(), Value::String("new".to_string()));
let event = ChangeEvent {
op: ChangeOp::Update,
before: Some(before),
after: Some(after),
timestamp: 1234567890,
transaction_id: "tx-001".to_string(),
table: "users".to_string(),
schema: "public".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: ChangeEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.op, ChangeOp::Update);
assert_eq!(deserialized.transaction_id, "tx-001");
assert_eq!(deserialized.table, "users");
}
#[test]
fn test_change_op_serde() {
let json = serde_json::to_string(&ChangeOp::Insert).unwrap();
assert_eq!(json, "\"Insert\"");
let op: ChangeOp = serde_json::from_str(&json).unwrap();
assert_eq!(op, ChangeOp::Insert);
}
#[test]
fn test_checkpoint_with_wal_lsn() {
let checkpoint = CdcCheckpoint {
dialect: DbType::Postgres,
position: CheckpointPosition::WalLsn(123456),
updated_at: 1234567890,
};
let json = serde_json::to_string(&checkpoint).unwrap();
let deserialized: CdcCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.dialect, DbType::Postgres);
assert_eq!(deserialized.position, CheckpointPosition::WalLsn(123456));
}
#[test]
fn test_checkpoint_with_binlog_gtid() {
let checkpoint = CdcCheckpoint {
dialect: DbType::Mysql,
position: CheckpointPosition::BinlogGtid("gtid-001".to_string()),
updated_at: 1234567890,
};
let json = serde_json::to_string(&checkpoint).unwrap();
let deserialized: CdcCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.position,
CheckpointPosition::BinlogGtid("gtid-001".to_string())
);
}
#[test]
fn test_cdc_error_display() {
assert!(CdcError::WalNotConfigured.to_string().contains("WAL"));
assert!(CdcError::BinlogNotEnabled.to_string().contains("binlog"));
assert!(CdcError::CheckpointFailed
.to_string()
.contains("checkpoint"));
}
#[test]
fn test_downstream_config_serde() {
let config = DownstreamConfig::Kafka {
topic: "users_cdc".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: DownstreamConfig = serde_json::from_str(&json).unwrap();
match deserialized {
DownstreamConfig::Kafka { topic } => assert_eq!(topic, "users_cdc"),
_ => panic!("expected Kafka"),
}
}
#[test]
fn test_cdc_config() {
let config = CdcConfig {
tables: vec!["users".to_string(), "orders".to_string()],
dialect: DbType::Postgres,
downstream: vec![DownstreamConfig::Kafka {
topic: "cdc_topic".to_string(),
}],
checkpoint_store: CheckpointStoreConfig::Memory,
masking: None,
};
assert_eq!(config.tables.len(), 2);
assert_eq!(config.dialect, DbType::Postgres);
}
}