use crate::config::{ConnectorSpec, PipelineConfig};
use crate::error::{CliError, CliResult};
use crate::replication::spec::ReplicationSpec;
#[derive(Debug, Clone)]
pub struct CompiledReplication {
pub snapshot_source: ConnectorSpec,
pub continuous: bool,
}
impl CompiledReplication {
pub fn compile(spec: &ReplicationSpec, cfg: &PipelineConfig) -> CliResult<Self> {
if !cfg.matrix.is_empty() {
return Err(CliError::Config(
"replication does not support a `matrix:` — define a single CDC \
pipeline (pipeline.source + pipeline.sink) plus replication.snapshot"
.into(),
));
}
let cdc = cfg.pipeline.source.as_ref().ok_or_else(|| {
CliError::Config(
"replication requires `pipeline.source` to be the CDC source \
(postgres-cdc / mysql-cdc / mongodb-cdc)"
.into(),
)
})?;
if !crate::registry::source_supports_exactly_once(&cdc.kind) {
return Err(CliError::Config(format!(
"replication `pipeline.source` must be a CDC source \
(postgres-cdc / mysql-cdc / mongodb-cdc); got '{}'",
cdc.kind
)));
}
let snap = &spec.snapshot.source;
if crate::registry::source_supports_exactly_once(&snap.kind) {
return Err(CliError::Config(format!(
"replication.snapshot.source must be a non-CDC bulk source \
(e.g. postgres / mysql / mongodb); got CDC source '{}'",
snap.kind
)));
}
crate::registry::source_schema(&snap.kind)?; let sink = cfg.pipeline.sink.as_ref().ok_or_else(|| {
CliError::Config("replication requires `pipeline.sink` (the destination)".into())
})?;
let state = cfg.pipeline.state.as_ref().ok_or_else(|| {
CliError::Config(
"replication requires a `state:` store (for the phase + bookmark)".into(),
)
})?;
if state.kind == "memory" {
return Err(CliError::Config(
"replication requires a durable state backend (file / redis / postgres), \
not `memory` — the snapshot→CDC handoff and resume depend on it"
.into(),
));
}
let write_mode = sink
.config
.get("write_mode")
.and_then(|v| v.as_str())
.unwrap_or("append");
if write_mode != "upsert" {
tracing::warn!(
write_mode,
"replication sink is not in upsert mode — the snapshot↔CDC boundary may \
produce duplicate rows; use write_mode: upsert (with a key) for a true mirror"
);
}
Ok(Self {
snapshot_source: snap.clone(),
continuous: spec.continuous,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::parse_with_extension;
fn cfg(yaml: &str) -> PipelineConfig {
parse_with_extension(yaml, "yaml").unwrap()
}
const GOOD: &str = r#"
version: 1
name: mirror
pipeline:
source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
state: { type: file, config: { path: ./st } }
replication:
mode: snapshot_then_cdc
snapshot:
source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
"#;
#[test]
fn accepts_valid_config() {
let c = cfg(GOOD);
let r = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap();
assert_eq!(r.snapshot_source.kind, "postgres");
assert!(r.continuous);
}
#[test]
fn rejects_non_cdc_pipeline_source() {
let c = cfg(&GOOD.replace("postgres-cdc", "postgres"));
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("CDC source"), "{err}");
}
#[test]
fn rejects_memory_state() {
let c = cfg(&GOOD.replace(
"type: file, config: { path: ./st }",
"type: memory, config: {}",
));
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("durable state"), "{err}");
}
#[test]
fn rejects_cdc_snapshot_source() {
let bad = GOOD.replace(
"source: { type: postgres, config: { connection_url: \"postgres://x\", query: \"SELECT * FROM t\" } }",
"source: { type: postgres-cdc, config: {} }",
);
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("non-CDC"), "{err}");
}
#[test]
fn rejects_matrix() {
let bad = format!("{GOOD}matrix:\n - id: a\n");
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("matrix"), "{err}");
}
#[test]
fn rejects_missing_sink() {
let bad = GOOD
.lines()
.filter(|l| !l.trim_start().starts_with("sink:"))
.collect::<Vec<_>>()
.join("\n");
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("sink"), "{err}");
}
#[test]
fn rejects_missing_source() {
let bad = GOOD
.lines()
.filter(|l| !l.trim_start().starts_with("source: { type: postgres-cdc"))
.collect::<Vec<_>>()
.join("\n");
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("CDC source"), "{err}");
}
#[test]
fn rejects_unknown_snapshot_source_kind() {
let bad = GOOD.replace(
"source: { type: postgres, config: { connection_url: \"postgres://x\", query: \"SELECT * FROM t\" } }",
"source: { type: not_a_source, config: {} }",
);
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("not_a_source"), "{err}");
}
#[test]
fn rejects_missing_state() {
let bad = GOOD
.lines()
.filter(|l| !l.trim_start().starts_with("state:"))
.collect::<Vec<_>>()
.join("\n");
let c = cfg(&bad);
let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
assert!(format!("{err}").contains("state"), "{err}");
}
#[test]
fn non_upsert_sink_compiles_ok_with_warning() {
let appendish = GOOD.replace(", write_mode: upsert, key: [id]", "");
let c = cfg(&appendish);
let r = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c)
.expect("non-upsert sink should still compile (warn, not fail)");
assert_eq!(r.snapshot_source.kind, "postgres");
}
}