1use crate::config::{
16 ConnectorSpec, MatrixRow, PartialConnector, PipelineConfig, PipelineSpec, StateStoreSpec,
17 TransformSpec,
18};
19use crate::error::{CliError, CliResult};
20use crate::interpolate::{Directive, iter_directives};
21use crate::merge::merge_value;
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap, HashSet};
24
25pub const RESERVED_IDS: &[&str] = &[
28 "env", "file", "secret", "matrix", "pipeline", "now", "backfill",
29];
30
31#[derive(Debug, Clone)]
33pub struct ExpandedNode {
34 pub id: String,
35 pub row_index: usize,
36 pub role: NodeRole,
37 pub source: ConnectorSpec,
38 pub sink: ConnectorSpec,
39 pub transforms: Vec<TransformSpec>,
40 pub state: Option<StateStoreSpec>,
41 pub dlq: Option<crate::config::DlqSpec>,
43 #[cfg(feature = "quality")]
46 pub quality: Option<faucet_core::QualitySpec>,
47 #[cfg(feature = "contract")]
50 pub contract: Option<faucet_core::ContractSpec>,
51 #[cfg(feature = "masking")]
59 pub masking: Option<faucet_core::MaskingSpec>,
60 pub sink_ref: String,
64 pub schema: Option<faucet_core::SchemaDriftSpec>,
66 pub delivery: faucet_core::DeliveryMode,
69 pub delivery_guarantee: faucet_core::DeliveryGuarantee,
75 pub depends_on: Vec<String>,
79 pub status: crate::config::SourceStatus,
86 pub tags: Vec<String>,
90 pub deferred_refs: Vec<DeferredRef>,
94 pub source_override: Option<crate::dlq_replay::reader::SourceOverride>,
100}
101
102#[derive(Debug, Clone)]
103pub enum NodeRole {
104 Root,
106 Child {
108 parent_id: String,
109 parent_key: String,
110 },
111}
112
113#[derive(Debug, Clone)]
114pub struct DeferredRef {
115 pub referenced_id: String,
116 pub dotted_path: String,
117 pub token: String,
118}
119
120struct Registry<'a> {
124 sources: HashMap<&'a str, &'a ConnectorSpec>,
125 sinks: HashMap<&'a str, &'a ConnectorSpec>,
126}
127
128impl<'a> Registry<'a> {
129 fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
130 let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
131 if let Some(default) = spec.source.as_ref() {
132 sources.insert("default", default);
133 }
134 for (name, s) in spec.sources.iter() {
135 if sources.contains_key(name.as_str()) {
136 return Err(CliError::DuplicateTemplate {
137 kind: "source",
138 name: name.clone(),
139 });
140 }
141 sources.insert(name.as_str(), s);
142 }
143
144 let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
145 if let Some(default) = spec.sink.as_ref() {
146 if default.transforms.is_some() {
147 return Err(CliError::TransformsOnSink {
148 name: "default".to_string(),
149 });
150 }
151 if !default.inherit_transforms {
152 return Err(CliError::InheritTransformsOnSink {
153 name: "default".to_string(),
154 });
155 }
156 sinks.insert("default", default);
157 }
158 for (name, s) in spec.sinks.iter() {
159 if sinks.contains_key(name.as_str()) {
160 return Err(CliError::DuplicateTemplate {
161 kind: "sink",
162 name: name.clone(),
163 });
164 }
165 if s.transforms.is_some() {
166 return Err(CliError::TransformsOnSink { name: name.clone() });
167 }
168 if !s.inherit_transforms {
169 return Err(CliError::InheritTransformsOnSink { name: name.clone() });
170 }
171 sinks.insert(name.as_str(), s);
172 }
173 Ok(Self { sources, sinks })
174 }
175
176 fn known(&self, kind: &'static str) -> Vec<String> {
177 debug_assert!(
178 matches!(kind, "source" | "sink"),
179 "Registry::known called with kind = {:?}",
180 kind
181 );
182 let map = if kind == "source" {
183 &self.sources
184 } else {
185 &self.sinks
186 };
187 let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
188 out.sort();
189 out
190 }
191
192 fn resolve(
193 &self,
194 kind: &'static str,
195 row_id: &str,
196 overlay: Option<&PartialConnector>,
197 ) -> CliResult<ConnectorSpec> {
198 debug_assert!(
199 matches!(kind, "source" | "sink"),
200 "Registry::resolve called with kind = {:?}",
201 kind
202 );
203 let map = if kind == "source" {
204 &self.sources
205 } else {
206 &self.sinks
207 };
208 let ref_name = overlay
209 .and_then(|p| p.r#ref.as_deref())
210 .unwrap_or("default");
211 let base = map.get(ref_name).ok_or_else(|| {
212 if ref_name == "default" {
213 CliError::MissingTemplate {
214 kind,
215 row_id: row_id.to_owned(),
216 }
217 } else {
218 CliError::UnknownTemplate {
219 kind,
220 name: ref_name.to_owned(),
221 row_id: row_id.to_owned(),
222 known: self.known(kind),
223 }
224 }
225 })?;
226 let mut out = (*base).clone();
227 if let Some(p) = overlay {
228 if let Some(k) = &p.kind {
229 out.kind = k.clone();
230 }
231 if let Some(c) = &p.config {
232 merge_value(&mut out.config, c.clone());
233 }
234 if p.status.is_some() {
238 out.status = p.status;
239 }
240 }
241 Ok(out)
242 }
243}
244
245pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
248 if let Some(ab) = cfg
253 .execution
254 .as_ref()
255 .and_then(|e| e.adaptive_batch_size.as_ref())
256 {
257 ab.validate()?;
260 }
261
262 let synthetic_row;
264 let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
265 synthetic_row = [MatrixRow {
266 id: None,
267 parent: None,
268 depends_on: Vec::new(),
269 parent_key: "id".into(),
270 source: None,
271 sink: None,
272 transforms: None,
273 inherit_transforms: true,
274 state: None,
275 dlq: None,
276 delivery: None,
277 tags: Vec::new(),
278 }];
279 &synthetic_row
280 } else {
281 &cfg.matrix
282 };
283
284 let mut ids: Vec<String> = Vec::with_capacity(rows.len());
286 let mut seen: HashSet<String> = HashSet::new();
287 for (i, row) in rows.iter().enumerate() {
288 let id = match &row.id {
289 Some(s) => s.clone(),
290 None => format!("row-{i}"),
291 };
292 if RESERVED_IDS.contains(&id.as_str()) {
293 return Err(CliError::ReservedRowId { id });
294 }
295 if !seen.insert(id.clone()) {
296 return Err(CliError::DuplicateRowId { id });
297 }
298 ids.push(id);
299 }
300 let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
301
302 let mut parents: HashMap<&str, &str> = HashMap::new();
304 for (i, row) in rows.iter().enumerate() {
305 let id = ids[i].as_str();
306 if let Some(parent) = row.parent.as_deref() {
307 if !id_set.contains(parent) {
308 return Err(CliError::UnknownParent {
309 id: id.to_owned(),
310 parent: parent.to_owned(),
311 });
312 }
313 if parent == id {
314 return Err(CliError::ParentCycle {
315 ids: vec![id.to_owned()],
316 });
317 }
318 parents.insert(id, parent);
319 }
320 }
321 detect_cycle(&parents)?;
322
323 let mut deps_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
329 for (i, row) in rows.iter().enumerate() {
330 let id = ids[i].as_str();
331 let mut deps: Vec<String> = Vec::with_capacity(row.depends_on.len());
332 for dep in &row.depends_on {
333 if !id_set.contains(dep.as_str()) {
334 return Err(CliError::UnknownDependency {
335 id: id.to_owned(),
336 depends_on: dep.clone(),
337 });
338 }
339 if dep == id {
340 return Err(CliError::DependencyCycle {
341 ids: vec![id.to_owned()],
342 });
343 }
344 if !deps.contains(dep) {
345 deps.push(dep.clone());
346 }
347 }
348 deps_by_row.push(deps);
349 }
350 detect_combined_cycle(&ids, &parents, &deps_by_row)?;
351
352 for (i, row) in rows.iter().enumerate() {
356 let id = ids[i].as_str();
357 if let Some(p) = &row.source
358 && let Some(c) = &p.config
359 {
360 check_refs(c, &id_set, id)?;
361 }
362 if let Some(p) = &row.sink
363 && let Some(c) = &p.config
364 {
365 check_refs(c, &id_set, id)?;
366 }
367 }
368 if let Some(s) = &cfg.pipeline.source {
369 check_refs(&s.config, &id_set, "pipeline.source")?;
370 }
371 if let Some(s) = &cfg.pipeline.sink {
372 check_refs(&s.config, &id_set, "pipeline.sink")?;
373 }
374 for (name, s) in &cfg.pipeline.sources {
375 check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
376 }
377 for (name, s) in &cfg.pipeline.sinks {
378 check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
379 }
380
381 let registry = Registry::build(&cfg.pipeline)?;
383
384 let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
387 let mut roots: Vec<usize> = Vec::new();
388 for (i, row) in rows.iter().enumerate() {
389 match row.parent.as_deref() {
390 None => roots.push(i),
391 Some(p) => by_parent.entry(p).or_default().push(i),
392 }
393 }
394
395 let mut order: Vec<usize> = Vec::with_capacity(rows.len());
396 let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
397 while let Some(idx) = queue.pop_front() {
398 order.push(idx);
399 if let Some(children) = by_parent.get(ids[idx].as_str()) {
400 queue.extend(children.iter().copied());
401 }
402 }
403 debug_assert_eq!(order.len(), rows.len());
404
405 let mut out = Vec::with_capacity(rows.len());
406 for &i in &order {
407 let row = &rows[i];
408 let row_id = ids[i].as_str();
409 let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
410 let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
411 let sink_ref = row
414 .sink
415 .as_ref()
416 .and_then(|s| s.r#ref.clone())
417 .unwrap_or_else(|| "default".to_string());
418 let role = match &row.parent {
419 None => NodeRole::Root,
420 Some(p) => NodeRole::Child {
421 parent_id: p.clone(),
422 parent_key: row.parent_key.clone(),
423 },
424 };
425 let mut deferred = Vec::new();
426 collect_deferred(&merged_source.config, &mut deferred);
427 collect_deferred(&merged_sink.config, &mut deferred);
428
429 let status = merged_source.status.unwrap_or_default();
433
434 let tags = resolve_tags(&merged_source.tags, &row.tags, row_id)?;
439
440 let src_inherit = merged_source.inherit_transforms;
445 let row_inherit = row.inherit_transforms;
446 let mut transforms: Vec<TransformSpec> = Vec::new();
447 if src_inherit && row_inherit {
448 transforms.extend(cfg.pipeline.transforms.iter().cloned());
449 }
450 if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
451 transforms.extend(src_ts.iter().cloned());
452 }
453 if let Some(row_ts) = row.transforms.as_ref() {
454 transforms.extend(row_ts.iter().cloned());
455 }
456 let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
457 let delivery = row.delivery.unwrap_or(cfg.delivery);
459 let dlq = match row.dlq.clone() {
463 Some(None) => None,
464 Some(Some(spec)) => Some(spec),
465 None => cfg.pipeline.dlq.clone(),
466 };
467
468 if let Some(ref d) = dlq {
469 if matches!(d.max_failures_per_page, Some(0)) {
470 return Err(CliError::InvalidDlqBudget {
471 field: "max_failures_per_page",
472 });
473 }
474 if matches!(d.max_failures_total, Some(0)) {
475 return Err(CliError::InvalidDlqBudget {
476 field: "max_failures_total",
477 });
478 }
479 if !crate::registry::sink_exists(&d.sink.kind) {
480 return Err(CliError::UnknownDlqSinkKind {
481 kind: d.sink.kind.clone(),
482 context: format!("row `{row_id}`"),
483 });
484 }
485 }
486
487 for (ti, t) in transforms.iter().enumerate() {
492 reject_runtime_tokens(
493 &t.config,
494 &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
495 )?;
496 }
497 if let Some(ref st) = state {
498 reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
499 }
500 if let Some(ref d) = dlq {
501 reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
502 }
503
504 #[cfg(feature = "quality")]
511 let quality = cfg.pipeline.quality.clone();
512 #[cfg(feature = "quality")]
513 if let Some(ref spec) = quality {
514 let compiled = faucet_core::CompiledQuality::compile(spec)
515 .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
516 if compiled.requires_dlq() && dlq.is_none() {
517 return Err(CliError::Config(format!(
518 "row `{row_id}`: a quality check uses `on_failure: quarantine` \
519 but no DLQ is configured — add a `dlq:` block (or change the \
520 check's `on_failure` to `abort`)"
521 )));
522 }
523 }
524
525 #[cfg(feature = "contract")]
530 let contract = cfg.pipeline.contract.clone();
531 #[cfg(feature = "contract")]
532 if let Some(ref spec) = contract {
533 let compiled = faucet_core::CompiledContract::compile(spec)
534 .map_err(|e| CliError::Config(format!("contract (row `{row_id}`): {e}")))?;
535 if compiled.requires_dlq() && dlq.is_none() {
536 return Err(CliError::Config(format!(
537 "row `{row_id}`: the contract uses `on_breach: quarantine` \
538 but no DLQ is configured — add a `dlq:` block (or change \
539 `on_breach` to `fail` or `warn`)"
540 )));
541 }
542 }
543
544 #[cfg(feature = "masking")]
549 let masking = cfg.pipeline.masking.clone();
550 #[cfg(feature = "masking")]
551 if let Some(ref spec) = masking {
552 faucet_core::CompiledMasking::compile(spec)
553 .map_err(|e| CliError::Config(format!("masking (row `{row_id}`): {e}")))?;
554 }
555
556 if let Some(spec) = &cfg.resilience
560 && matches!(
561 spec.poison.as_ref().map(|p| p.action),
562 Some(crate::config::PoisonActionSpec::Dlq)
563 )
564 && dlq.is_none()
565 {
566 return Err(CliError::Config(format!(
567 "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
568 )));
569 }
570
571 if let Some(ref sla) = cfg.sla {
576 sla.validate()
577 .map_err(|e| CliError::Config(format!("sla: {e}")))?;
578 if sla.needs_state() {
579 match state.as_ref() {
580 None => {
581 return Err(CliError::Config(format!(
582 "row '{row_id}': sla.max_staleness_secs / sla.volume_anomaly \
583 need persisted run history — add a `state:` block \
584 (min_rows_per_run alone works without one)"
585 )));
586 }
587 Some(s) if s.kind == "memory" => {
588 tracing::warn!(
589 row = %row_id,
590 "sla: the `memory` state store resets on process exit — \
591 staleness/volume baselines only persist within a single \
592 `faucet schedule`/`serve` process; use `file`, `redis`, \
593 or `postgres` for one-shot runs"
594 );
595 }
596 Some(_) => {}
597 }
598 }
599 }
600
601 let requested_mode = merged_sink
605 .config
606 .get("write_mode")
607 .and_then(|v| v.as_str())
608 .unwrap_or("append");
609 let mode = match requested_mode {
610 "append" => faucet_core::WriteMode::Append,
611 "upsert" => faucet_core::WriteMode::Upsert,
612 "delete" => faucet_core::WriteMode::Delete,
613 other => {
614 return Err(CliError::Config(format!(
615 "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
616 ids[i], other
617 )));
618 }
619 };
620 if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
621 return Err(CliError::Config(format!(
622 "row '{}': write_mode '{}' is not supported by sink '{}' \
623 (upsert/delete sinks: {})",
624 ids[i],
625 requested_mode,
626 merged_sink.kind,
627 crate::registry::UPSERT_SINK_KINDS.join(", ")
628 )));
629 }
630 if matches!(
631 mode,
632 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
633 ) {
634 let key_present = merged_sink
635 .config
636 .get("key")
637 .and_then(|v| v.as_array())
638 .map(|a| !a.is_empty())
639 .unwrap_or(false);
640 if !key_present {
641 return Err(CliError::Config(format!(
642 "row '{}': write_mode '{}' requires a non-empty `key`",
643 ids[i], requested_mode
644 )));
645 }
646 }
647
648 let keyed_upsert_configured = matches!(
655 mode,
656 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
657 );
658 let guarantee_inputs = faucet_core::GuaranteeInputs {
659 replay: crate::registry::source_replay_guarantee(&merged_source.kind),
660 sink_atomic: crate::registry::sink_supports_idempotent_writes(&merged_sink.kind),
661 keyed_upsert_configured,
662 durable_state: matches!(state.as_ref(), Some(s) if s.kind != "memory"),
663 dlq: dlq.is_some(),
664 };
665 let delivery_guarantee = faucet_core::derive_delivery_guarantee(&guarantee_inputs);
666
667 if delivery == faucet_core::DeliveryMode::ExactlyOnce
675 && delivery_guarantee == faucet_core::DeliveryGuarantee::AtLeastOnce
676 {
677 if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
678 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
679 {
680 format!(
681 ", or configure `write_mode: upsert` + `key` on sink '{}' for \
682 keyed-upsert effectively-once with any source",
683 merged_sink.kind
684 )
685 } else {
686 String::new()
687 };
688 return Err(CliError::Config(format!(
689 "row '{}': delivery: exactly_once is not supported by source '{}' \
690 (deterministic-replay sources only: {}{})",
691 ids[i],
692 merged_source.kind,
693 crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", "),
694 keyed_hint
695 )));
696 }
697 if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
698 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
699 {
700 format!(
701 "; alternatively configure `write_mode: upsert` + `key` on '{}' for \
702 keyed-upsert effectively-once",
703 merged_sink.kind
704 )
705 } else {
706 String::new()
707 };
708 return Err(CliError::Config(format!(
709 "row '{}': delivery: exactly_once is not supported by sink '{}' \
710 (idempotent sinks only: {}{})",
711 ids[i],
712 merged_sink.kind,
713 crate::registry::IDEMPOTENT_SINK_KINDS.join(", "),
714 keyed_hint
715 )));
716 }
717 match state.as_ref() {
725 None => {
726 return Err(CliError::Config(format!(
727 "row '{}': delivery: exactly_once requires a state store",
728 ids[i]
729 )));
730 }
731 Some(s) if s.kind == "memory" => {
732 return Err(CliError::Config(format!(
733 "row '{}': delivery: exactly_once requires a durable state store, \
734 not `memory` — the cross-restart watermark/sequence guarantee \
735 depends on it (use `file`, `redis`, or `postgres`)",
736 ids[i]
737 )));
738 }
739 Some(_) => {}
740 }
741 if dlq.is_some() {
742 return Err(CliError::Config(format!(
743 "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
744 ids[i]
745 )));
746 }
747 unreachable!("delivery-guarantee derivation and the exactly-once gate diverged");
751 }
752
753 if let Some(ref sd) = cfg.pipeline.schema {
758 let policy = faucet_core::SchemaDriftPolicy::compile(sd);
759 if policy.on_drift == faucet_core::OnDrift::Evolve
760 && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
761 {
762 return Err(CliError::Config(format!(
763 "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
764 (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
765 ids[i], merged_sink.kind
766 )));
767 }
768 if policy.requires_dlq() && dlq.is_none() {
769 return Err(CliError::Config(format!(
770 "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
771 ids[i]
772 )));
773 }
774 if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
775 return Err(CliError::Config(format!(
776 "row '{}': schema quarantine is incompatible with delivery: exactly_once \
777 (exactly_once forbids a DLQ)",
778 ids[i]
779 )));
780 }
781 }
782
783 out.push(ExpandedNode {
784 id: ids[i].clone(),
785 row_index: i,
786 role,
787 source: merged_source,
788 sink: merged_sink,
789 transforms,
790 state,
791 dlq,
792 delivery,
793 delivery_guarantee,
794 #[cfg(feature = "quality")]
795 quality,
796 #[cfg(feature = "contract")]
797 contract,
798 #[cfg(feature = "masking")]
799 masking,
800 sink_ref,
801 schema: cfg.pipeline.schema.clone(),
802 depends_on: deps_by_row[i].clone(),
803 status,
804 tags,
805 deferred_refs: deferred,
806 source_override: None,
807 });
808 }
809 Ok(out)
810}
811
812fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
813 for &start in parents.keys() {
816 let mut visited: BTreeSet<&str> = BTreeSet::new();
817 let mut cur = start;
818 while let Some(&p) = parents.get(cur) {
819 if !visited.insert(cur) {
820 let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
821 return Err(CliError::ParentCycle { ids: chain });
822 }
823 cur = p;
824 if cur == start {
825 let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
826 chain.push(start.to_string());
827 return Err(CliError::ParentCycle { ids: chain });
828 }
829 }
830 }
831 Ok(())
832}
833
834fn detect_combined_cycle(
841 ids: &[String],
842 parents: &HashMap<&str, &str>,
843 deps_by_row: &[Vec<String>],
844) -> CliResult<()> {
845 let index_of: HashMap<&str, usize> = ids
846 .iter()
847 .enumerate()
848 .map(|(i, id)| (id.as_str(), i))
849 .collect();
850 let mut in_degree = vec![0usize; ids.len()];
851 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
852 for (i, id) in ids.iter().enumerate() {
853 let mut prereqs: Vec<usize> = Vec::new();
854 if let Some(p) = parents.get(id.as_str()) {
855 prereqs.push(index_of[p]);
856 }
857 prereqs.extend(deps_by_row[i].iter().map(|d| index_of[d.as_str()]));
858 for p in prereqs {
859 in_degree[i] += 1;
860 dependents[p].push(i);
861 }
862 }
863 let mut queue: std::collections::VecDeque<usize> =
864 (0..ids.len()).filter(|&i| in_degree[i] == 0).collect();
865 let mut processed = 0usize;
866 while let Some(i) = queue.pop_front() {
867 processed += 1;
868 for &d in &dependents[i] {
869 in_degree[d] -= 1;
870 if in_degree[d] == 0 {
871 queue.push_back(d);
872 }
873 }
874 }
875 if processed < ids.len() {
876 let mut stuck: Vec<String> = (0..ids.len())
877 .filter(|&i| in_degree[i] > 0)
878 .map(|i| ids[i].clone())
879 .collect();
880 stuck.sort();
881 return Err(CliError::DependencyCycle { ids: stuck });
882 }
883 Ok(())
884}
885
886fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
890 walk_strings(value, &mut |s| {
891 for (token, dir) in iter_directives(s) {
892 if let Directive::Deferred { id, .. } = dir
898 && id != "now"
899 && id != "backfill"
900 && !id_set.contains(id)
901 {
902 return Err(CliError::UnknownInterpolationId {
903 id: id.to_owned(),
904 token: format!("{token} (in {owner})"),
905 });
906 }
907 }
908 Ok(())
909 })
910}
911
912fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
920 walk_strings(value, &mut |s| {
921 for (token, dir) in iter_directives(s) {
922 if let Directive::Deferred { .. } = dir {
923 return Err(CliError::Config(format!(
924 "interpolation token `{token}` in {location} is not supported: \
925 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
926 resolve only in source/sink configs"
927 )));
928 }
929 }
930 Ok(())
931 })
932}
933
934fn resolve_tags(
939 template_tags: &[String],
940 row_tags: &[String],
941 row_id: &str,
942) -> CliResult<Vec<String>> {
943 let mut set: BTreeSet<String> = BTreeSet::new();
944 for tag in template_tags.iter().chain(row_tags.iter()) {
945 validate_tag(tag, row_id)?;
946 set.insert(tag.clone());
947 }
948 Ok(set.into_iter().collect())
949}
950
951fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
953 let ok = {
954 let mut chars = tag.chars();
955 match chars.next() {
956 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
957 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
958 }
959 _ => false,
960 }
961 };
962 if !ok {
963 return Err(CliError::Config(format!(
964 "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
965 (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
966 )));
967 }
968 Ok(())
969}
970
971fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
972 let _ = walk_strings(value, &mut |s| {
973 for (token, dir) in iter_directives(s) {
974 if let Directive::Deferred { id, path } = dir {
975 if id == "now" || id == "backfill" {
979 continue;
980 }
981 out.push(DeferredRef {
982 referenced_id: id.to_owned(),
983 dotted_path: path.to_owned(),
984 token: token.to_owned(),
985 });
986 }
987 }
988 Ok(())
989 });
990}
991
992fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
993where
994 F: FnMut(&str) -> CliResult<()>,
995{
996 match value {
997 Value::String(s) => f(s),
998 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
999 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1000 _ => Ok(()),
1001 }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use crate::config::{OnBatchErrorSpec, parse_with_extension};
1008
1009 fn cfg(yaml: &str) -> PipelineConfig {
1010 parse_with_extension(yaml, "yaml").unwrap()
1011 }
1012
1013 #[test]
1014 fn implicit_single_row_when_matrix_absent() {
1015 let c = cfg(r#"
1016version: 1
1017pipeline:
1018 source: { type: rest, config: { base_url: https://x } }
1019 sink: { type: jsonl, config: { path: ./o } }
1020"#);
1021 let nodes = expand(&c).unwrap();
1022 assert_eq!(nodes.len(), 1);
1023 assert_eq!(nodes[0].id, "row-0");
1024 assert!(matches!(nodes[0].role, NodeRole::Root));
1025 assert_eq!(nodes[0].source.kind, "rest");
1026 assert_eq!(nodes[0].sink.kind, "jsonl");
1027 }
1028
1029 #[test]
1030 fn rejects_runtime_token_in_dlq_config() {
1031 let c = cfg(r#"
1035version: 1
1036pipeline:
1037 source: { type: rest, config: { base_url: https://x } }
1038 sink: { type: jsonl, config: { path: ./o } }
1039 dlq:
1040 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1041"#);
1042 let err = expand(&c).unwrap_err();
1043 assert!(
1044 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1045 "got: {err:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn rejects_runtime_token_in_state_config() {
1051 let c = cfg(r#"
1052version: 1
1053pipeline:
1054 source: { type: rest, config: { base_url: https://x } }
1055 sink: { type: jsonl, config: { path: ./o } }
1056 state:
1057 type: file
1058 config: { path: "state-${now.date}" }
1059"#);
1060 let err = expand(&c).unwrap_err();
1061 assert!(
1062 matches!(&err, CliError::Config(m) if m.contains("state")),
1063 "got: {err:?}"
1064 );
1065 }
1066
1067 #[test]
1068 fn rejects_runtime_token_in_transform_config() {
1069 let c = cfg(r#"
1070version: 1
1071pipeline:
1072 source: { type: rest, config: { base_url: https://x } }
1073 sink: { type: jsonl, config: { path: ./o } }
1074 transforms:
1075 - type: set
1076 config: { field: ts, value: "${now.datetime}" }
1077"#);
1078 let err = expand(&c).unwrap_err();
1079 assert!(
1080 matches!(&err, CliError::Config(m) if m.contains("transform")),
1081 "got: {err:?}"
1082 );
1083 }
1084
1085 #[test]
1086 fn allows_runtime_token_in_source_and_sink_configs() {
1087 let c = cfg(r#"
1090version: 1
1091pipeline:
1092 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1093 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1094"#);
1095 let nodes = expand(&c).unwrap();
1096 assert_eq!(nodes.len(), 1);
1097 }
1098
1099 #[test]
1100 fn merges_row_overrides_into_pipeline_source() {
1101 let c = cfg(r#"
1102version: 1
1103pipeline:
1104 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1105 sink: { type: jsonl, config: { path: ./o } }
1106matrix:
1107 - id: users
1108 source: { config: { path: /v1/users, headers: { b: 2 } } }
1109"#);
1110 let nodes = expand(&c).unwrap();
1111 assert_eq!(nodes[0].id, "users");
1112 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1113 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1114 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1115 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1116 }
1117
1118 #[test]
1119 fn errors_on_unknown_parent() {
1120 let c = cfg(r#"
1121version: 1
1122pipeline:
1123 source: { type: rest, config: {} }
1124 sink: { type: jsonl, config: { path: ./o } }
1125matrix:
1126 - id: child
1127 parent: nobody
1128"#);
1129 assert!(matches!(
1130 expand(&c).unwrap_err(),
1131 CliError::UnknownParent { .. }
1132 ));
1133 }
1134
1135 #[test]
1136 fn errors_on_duplicate_ids() {
1137 let c = cfg(r#"
1138version: 1
1139pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1140matrix:
1141 - { id: x }
1142 - { id: x }
1143"#);
1144 assert!(matches!(
1145 expand(&c).unwrap_err(),
1146 CliError::DuplicateRowId { .. }
1147 ));
1148 }
1149
1150 #[test]
1151 fn errors_on_reserved_id() {
1152 let c = cfg(r#"
1153version: 1
1154pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1155matrix:
1156 - { id: env }
1157"#);
1158 assert!(matches!(
1159 expand(&c).unwrap_err(),
1160 CliError::ReservedRowId { .. }
1161 ));
1162 }
1163
1164 #[test]
1165 fn errors_on_self_parent_cycle() {
1166 let c = cfg(r#"
1167version: 1
1168pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1169matrix:
1170 - { id: a, parent: a }
1171"#);
1172 assert!(matches!(
1173 expand(&c).unwrap_err(),
1174 CliError::ParentCycle { .. }
1175 ));
1176 }
1177
1178 #[test]
1179 fn errors_on_two_node_cycle() {
1180 let c = cfg(r#"
1181version: 1
1182pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1183matrix:
1184 - { id: a, parent: b }
1185 - { id: b, parent: a }
1186"#);
1187 assert!(matches!(
1188 expand(&c).unwrap_err(),
1189 CliError::ParentCycle { .. }
1190 ));
1191 }
1192
1193 #[test]
1194 fn errors_on_unknown_dependency() {
1195 let c = cfg(r#"
1196version: 1
1197pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1198matrix:
1199 - { id: facts, depends_on: [nobody] }
1200"#);
1201 match expand(&c).unwrap_err() {
1202 CliError::UnknownDependency { id, depends_on } => {
1203 assert_eq!(id, "facts");
1204 assert_eq!(depends_on, "nobody");
1205 }
1206 other => panic!("expected UnknownDependency, got {other:?}"),
1207 }
1208 }
1209
1210 #[test]
1211 fn errors_on_self_dependency() {
1212 let c = cfg(r#"
1213version: 1
1214pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1215matrix:
1216 - { id: a, depends_on: [a] }
1217"#);
1218 match expand(&c).unwrap_err() {
1219 CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1220 other => panic!("expected DependencyCycle, got {other:?}"),
1221 }
1222 }
1223
1224 #[test]
1225 fn errors_on_depends_on_cycle() {
1226 let c = cfg(r#"
1227version: 1
1228pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1229matrix:
1230 - { id: a, depends_on: [b] }
1231 - { id: b, depends_on: [a] }
1232"#);
1233 match expand(&c).unwrap_err() {
1234 CliError::DependencyCycle { ids } => {
1235 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1236 }
1237 other => panic!("expected DependencyCycle, got {other:?}"),
1238 }
1239 }
1240
1241 #[test]
1242 fn errors_on_mixed_parent_depends_on_cycle() {
1243 let c = cfg(r#"
1247version: 1
1248pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1249matrix:
1250 - { id: a, parent: b }
1251 - { id: b, depends_on: [a] }
1252"#);
1253 match expand(&c).unwrap_err() {
1254 CliError::DependencyCycle { ids } => {
1255 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1256 }
1257 other => panic!("expected DependencyCycle, got {other:?}"),
1258 }
1259 }
1260
1261 #[test]
1262 fn depends_on_is_recorded_and_deduped() {
1263 let c = cfg(r#"
1264version: 1
1265pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1266matrix:
1267 - { id: dims }
1268 - { id: staging }
1269 - { id: facts, depends_on: [dims, staging, dims] }
1270"#);
1271 let nodes = expand(&c).unwrap();
1272 let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
1273 assert_eq!(
1274 facts.depends_on,
1275 vec!["dims".to_string(), "staging".to_string()]
1276 );
1277 assert!(matches!(facts.role, NodeRole::Root));
1278 let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
1279 assert!(dims.depends_on.is_empty());
1280 }
1281
1282 #[test]
1283 fn depends_on_may_target_a_child_row() {
1284 let c = cfg(r#"
1287version: 1
1288pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1289matrix:
1290 - { id: users }
1291 - { id: posts, parent: users }
1292 - { id: rollup, depends_on: [posts] }
1293"#);
1294 let nodes = expand(&c).unwrap();
1295 let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
1296 assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
1297 }
1298
1299 #[test]
1300 fn errors_on_unknown_interpolation_id() {
1301 let c = cfg(r#"
1302version: 1
1303pipeline:
1304 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
1305 sink: { type: jsonl, config: { path: ./o } }
1306"#);
1307 assert!(matches!(
1308 expand(&c).unwrap_err(),
1309 CliError::UnknownInterpolationId { .. }
1310 ));
1311 }
1312
1313 #[test]
1314 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
1315 let c = cfg(r#"
1320version: 1
1321pipeline:
1322 source: { type: rest, config: { url: "https://x/${env.foo}" } }
1323 sink: { type: jsonl, config: { path: ./o } }
1324"#);
1325 match expand(&c).unwrap_err() {
1326 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
1327 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
1328 }
1329 }
1330
1331 #[test]
1332 fn accepts_id_path_when_referenced_row_exists() {
1333 let c = cfg(r#"
1334version: 1
1335pipeline:
1336 source: { type: rest, config: {} }
1337 sink: { type: jsonl, config: { path: ./o } }
1338matrix:
1339 - id: users
1340 - id: posts
1341 parent: users
1342 source: { config: { path: "/v1/users/${users.id}/posts" } }
1343"#);
1344 let nodes = expand(&c).unwrap();
1345 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1346 assert_eq!(posts.deferred_refs.len(), 1);
1347 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
1348 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
1349 }
1350
1351 #[test]
1352 fn nested_referenced_path_resolves() {
1353 let c = cfg(r#"
1354version: 1
1355pipeline:
1356 source: { type: rest, config: {} }
1357 sink: { type: jsonl, config: { path: ./o } }
1358matrix:
1359 - id: users
1360 - id: addrs
1361 parent: users
1362 source: { config: { path: "/users/${users.addr.city}/addr" } }
1363"#);
1364 let nodes = expand(&c).unwrap();
1365 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
1366 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
1367 }
1368
1369 #[test]
1370 fn roots_come_before_children_in_order() {
1371 let c = cfg(r#"
1372version: 1
1373pipeline:
1374 source: { type: rest, config: {} }
1375 sink: { type: jsonl, config: { path: ./o } }
1376matrix:
1377 - id: posts
1378 parent: users
1379 - id: users
1380"#);
1381 let nodes = expand(&c).unwrap();
1382 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
1383 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
1384 assert!(users_idx < posts_idx, "users must precede posts");
1385 }
1386
1387 #[test]
1388 fn child_node_has_parent_role() {
1389 let c = cfg(r#"
1390version: 1
1391pipeline:
1392 source: { type: rest, config: {} }
1393 sink: { type: jsonl, config: { path: ./o } }
1394matrix:
1395 - id: users
1396 - id: posts
1397 parent: users
1398 parent_key: user_id
1399"#);
1400 let nodes = expand(&c).unwrap();
1401 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1402 match &posts.role {
1403 NodeRole::Child {
1404 parent_id,
1405 parent_key,
1406 } => {
1407 assert_eq!(parent_id, "users");
1408 assert_eq!(parent_key, "user_id");
1409 }
1410 other => panic!("expected Child, got {other:?}"),
1411 }
1412 }
1413
1414 #[test]
1415 fn expand_rejects_zero_per_page_budget() {
1416 let yaml = r#"
1417version: 1
1418pipeline:
1419 source: { type: rest, config: {} }
1420 sink: { type: jsonl, config: { path: ./o.jsonl } }
1421 dlq:
1422 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1423 max_failures_per_page: 0
1424"#;
1425 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1426 let err = expand(&cfg).unwrap_err();
1427 assert!(matches!(
1428 err,
1429 CliError::InvalidDlqBudget {
1430 field: "max_failures_per_page"
1431 }
1432 ));
1433 }
1434
1435 #[test]
1436 fn expand_rejects_zero_total_budget() {
1437 let yaml = r#"
1438version: 1
1439pipeline:
1440 source: { type: rest, config: {} }
1441 sink: { type: jsonl, config: { path: ./o.jsonl } }
1442 dlq:
1443 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1444 max_failures_total: 0
1445"#;
1446 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1447 let err = expand(&cfg).unwrap_err();
1448 assert!(matches!(
1449 err,
1450 CliError::InvalidDlqBudget {
1451 field: "max_failures_total"
1452 }
1453 ));
1454 }
1455
1456 #[test]
1457 fn expand_rejects_unknown_dlq_sink_kind() {
1458 let yaml = r#"
1459version: 1
1460pipeline:
1461 source: { type: rest, config: {} }
1462 sink: { type: jsonl, config: { path: ./o.jsonl } }
1463 dlq:
1464 sink: { type: not_a_sink, config: {} }
1465"#;
1466 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1467 let err = expand(&cfg).unwrap_err();
1468 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1469 }
1470
1471 #[cfg(feature = "quality")]
1472 #[test]
1473 fn expand_rejects_quarantine_without_dlq() {
1474 let yaml = r#"
1477version: 1
1478pipeline:
1479 source: { type: rest, config: {} }
1480 sink: { type: jsonl, config: { path: ./o.jsonl } }
1481 quality:
1482 record:
1483 - { type: not_null, field: id, on_failure: quarantine }
1484"#;
1485 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1486 let err = expand(&cfg).unwrap_err();
1487 match err {
1488 CliError::Config(msg) => {
1489 assert!(msg.contains("quarantine"), "{msg}");
1490 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1491 }
1492 other => panic!("expected Config error, got {other:?}"),
1493 }
1494 }
1495
1496 #[cfg(feature = "quality")]
1497 #[test]
1498 fn expand_accepts_quarantine_with_dlq() {
1499 let yaml = r#"
1500version: 1
1501pipeline:
1502 source: { type: rest, config: {} }
1503 sink: { type: jsonl, config: { path: ./o.jsonl } }
1504 dlq:
1505 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1506 quality:
1507 record:
1508 - { type: not_null, field: id, on_failure: quarantine }
1509"#;
1510 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1511 let nodes = expand(&cfg).unwrap();
1512 assert_eq!(nodes.len(), 1);
1513 let q = nodes[0]
1514 .quality
1515 .as_ref()
1516 .expect("quality threaded onto node");
1517 assert_eq!(q.record.len(), 1);
1518 }
1519
1520 #[cfg(feature = "quality")]
1521 #[test]
1522 fn expand_accepts_abort_quality_without_dlq() {
1523 let yaml = r#"
1525version: 1
1526pipeline:
1527 source: { type: rest, config: {} }
1528 sink: { type: jsonl, config: { path: ./o.jsonl } }
1529 quality:
1530 record:
1531 - { type: not_null, field: id, on_failure: abort }
1532"#;
1533 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1534 let nodes = expand(&cfg).unwrap();
1535 assert!(nodes[0].quality.is_some());
1536 }
1537
1538 #[cfg(feature = "contract")]
1539 #[test]
1540 fn expand_rejects_contract_quarantine_without_dlq() {
1541 let yaml = r#"
1542version: 1
1543pipeline:
1544 source: { type: rest, config: {} }
1545 sink: { type: jsonl, config: { path: ./o.jsonl } }
1546 contract:
1547 version: "1.0.0"
1548 on_breach: quarantine
1549 fields:
1550 - { name: id, type: integer }
1551"#;
1552 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1553 let err = expand(&cfg).unwrap_err();
1554 match err {
1555 CliError::Config(msg) => {
1556 assert!(msg.contains("on_breach: quarantine"), "{msg}");
1557 assert!(msg.contains("dlq"), "{msg}");
1558 }
1559 other => panic!("expected Config error, got {other:?}"),
1560 }
1561 }
1562
1563 #[cfg(feature = "contract")]
1564 #[test]
1565 fn expand_accepts_contract_quarantine_with_dlq() {
1566 let yaml = r#"
1567version: 1
1568pipeline:
1569 source: { type: rest, config: {} }
1570 sink: { type: jsonl, config: { path: ./o.jsonl } }
1571 dlq:
1572 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1573 contract:
1574 version: "1.0.0"
1575 on_breach: quarantine
1576 fields:
1577 - { name: id, type: integer }
1578"#;
1579 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1580 let nodes = expand(&cfg).unwrap();
1581 assert_eq!(nodes.len(), 1);
1582 let c = nodes[0]
1583 .contract
1584 .as_ref()
1585 .expect("contract threaded onto node");
1586 assert_eq!(c.version, "1.0.0");
1587 assert_eq!(c.fields.len(), 1);
1588 }
1589
1590 #[cfg(feature = "contract")]
1591 #[test]
1592 fn expand_accepts_contract_fail_without_dlq() {
1593 let yaml = r#"
1595version: 1
1596pipeline:
1597 source: { type: rest, config: {} }
1598 sink: { type: jsonl, config: { path: ./o.jsonl } }
1599 contract:
1600 version: "1.0.0"
1601 fields:
1602 - { name: id, type: integer }
1603"#;
1604 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1605 let nodes = expand(&cfg).unwrap();
1606 assert!(nodes[0].contract.is_some());
1607 }
1608
1609 #[cfg(feature = "contract")]
1610 #[test]
1611 fn expand_rejects_malformed_contract() {
1612 let yaml = r#"
1614version: 1
1615pipeline:
1616 source: { type: rest, config: {} }
1617 sink: { type: jsonl, config: { path: ./o.jsonl } }
1618 contract:
1619 version: "1.0.0"
1620 fields:
1621 - { name: email, type: string, pattern: "[invalid" }
1622"#;
1623 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1624 let err = expand(&cfg).unwrap_err();
1625 match err {
1626 CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
1627 other => panic!("expected Config error, got {other:?}"),
1628 }
1629 }
1630
1631 #[test]
1632 fn legacy_singular_source_resolves_as_default_template() {
1633 let c = cfg(r#"
1634version: 1
1635pipeline:
1636 source: { type: rest, config: { base_url: https://x } }
1637 sink: { type: jsonl, config: { path: ./o } }
1638"#);
1639 let nodes = expand(&c).unwrap();
1640 assert_eq!(nodes[0].source.kind, "rest");
1641 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1642 }
1643
1644 #[test]
1645 fn row_with_ref_picks_named_template() {
1646 let c = cfg(r#"
1647version: 1
1648pipeline:
1649 sources:
1650 users_api: { type: rest, config: { base_url: https://x } }
1651 sinks:
1652 archive: { type: jsonl, config: { path: ./out } }
1653matrix:
1654 - id: load_users
1655 source:
1656 ref: users_api
1657 config: { path: /v1/users }
1658 sink:
1659 ref: archive
1660 config: { path: ./users.jsonl }
1661"#);
1662 let nodes = expand(&c).unwrap();
1663 assert_eq!(nodes[0].source.kind, "rest");
1664 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1665 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1666 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1667 }
1668
1669 #[test]
1670 fn row_without_ref_falls_back_to_default_template() {
1671 let c = cfg(r#"
1672version: 1
1673pipeline:
1674 source: { type: rest, config: { base_url: https://x } }
1675 sink: { type: jsonl, config: { path: ./o } }
1676matrix:
1677 - id: users
1678 source: { config: { path: /v1/users } }
1679"#);
1680 let nodes = expand(&c).unwrap();
1681 assert_eq!(nodes[0].source.kind, "rest");
1682 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1683 }
1684
1685 #[test]
1686 fn unknown_template_ref_errors_with_known_list() {
1687 let c = cfg(r#"
1688version: 1
1689pipeline:
1690 sources:
1691 a: { type: rest, config: {} }
1692 b: { type: rest, config: {} }
1693 sinks:
1694 s: { type: jsonl, config: { path: ./o } }
1695matrix:
1696 - id: x
1697 source: { ref: c }
1698 sink: { ref: s }
1699"#);
1700 let err = expand(&c).unwrap_err();
1701 match err {
1702 CliError::UnknownTemplate {
1703 kind,
1704 name,
1705 row_id,
1706 known,
1707 } => {
1708 assert_eq!(kind, "source");
1709 assert_eq!(name, "c");
1710 assert_eq!(row_id, "x");
1711 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1712 }
1713 other => panic!("expected UnknownTemplate, got {other:?}"),
1714 }
1715 }
1716
1717 #[test]
1718 fn missing_default_template_errors() {
1719 let c = cfg(r#"
1722version: 1
1723pipeline:
1724 sources:
1725 users_api: { type: rest, config: {} }
1726 sink: { type: jsonl, config: { path: ./o } }
1727matrix:
1728 - id: x
1729 source: { config: { path: /v1 } }
1730"#);
1731 let err = expand(&c).unwrap_err();
1732 match err {
1733 CliError::MissingTemplate { kind, row_id } => {
1734 assert_eq!(kind, "source");
1735 assert_eq!(row_id, "x");
1736 }
1737 other => panic!("expected MissingTemplate, got {other:?}"),
1738 }
1739 }
1740
1741 #[test]
1742 fn duplicate_default_template_errors() {
1743 let c = cfg(r#"
1745version: 1
1746pipeline:
1747 source: { type: rest, config: {} }
1748 sources:
1749 default: { type: rest, config: {} }
1750 sink: { type: jsonl, config: { path: ./o } }
1751"#);
1752 let err = expand(&c).unwrap_err();
1753 match err {
1754 CliError::DuplicateTemplate { kind, name } => {
1755 assert_eq!(kind, "source");
1756 assert_eq!(name, "default");
1757 }
1758 other => panic!("expected DuplicateTemplate, got {other:?}"),
1759 }
1760 }
1761
1762 #[test]
1763 fn row_can_override_template_kind() {
1764 let c = cfg(r#"
1765version: 1
1766pipeline:
1767 sources:
1768 api: { type: rest, config: { base_url: https://x } }
1769 sinks:
1770 out: { type: jsonl, config: { path: ./o } }
1771matrix:
1772 - id: x
1773 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1774 sink: { ref: out }
1775"#);
1776 let nodes = expand(&c).unwrap();
1777 assert_eq!(nodes[0].source.kind, "graphql");
1778 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1779 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1780 }
1781
1782 #[test]
1783 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1784 let yaml = r#"
1785version: 1
1786pipeline:
1787 source: { type: rest, config: {} }
1788 sink: { type: jsonl, config: { path: ./o.jsonl } }
1789 dlq:
1790 sink: { type: jsonl, config: { path: ./base.jsonl } }
1791matrix:
1792 - id: a
1793 - id: b
1794 dlq: null
1795 - id: c
1796 dlq:
1797 sink: { type: jsonl, config: { path: ./c.jsonl } }
1798 on_batch_error: dlq_all
1799"#;
1800 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1801 let nodes = expand(&cfg).unwrap();
1802 assert_eq!(nodes.len(), 3);
1803 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1805 assert_eq!(
1806 nodes[0]
1807 .dlq
1808 .as_ref()
1809 .unwrap()
1810 .sink
1811 .config
1812 .get("path")
1813 .unwrap(),
1814 "./base.jsonl"
1815 );
1816 assert!(nodes[1].dlq.is_none());
1818 assert_eq!(
1820 nodes[2].dlq.as_ref().unwrap().on_batch_error,
1821 OnBatchErrorSpec::DlqAll
1822 );
1823 assert_eq!(
1824 nodes[2]
1825 .dlq
1826 .as_ref()
1827 .unwrap()
1828 .sink
1829 .config
1830 .get("path")
1831 .unwrap(),
1832 "./c.jsonl"
1833 );
1834 }
1835
1836 #[test]
1837 fn multiple_rows_pick_different_templates() {
1838 let c = cfg(r#"
1839version: 1
1840pipeline:
1841 sources:
1842 users_api: { type: rest, config: { base_url: https://users.example } }
1843 orders_api: { type: rest, config: { base_url: https://orders.example } }
1844 sinks:
1845 archive: { type: jsonl, config: { path: ./out } }
1846matrix:
1847 - id: load_users
1848 source: { ref: users_api, config: { path: /v1/users } }
1849 sink: { ref: archive, config: { path: ./users.jsonl } }
1850 - id: load_orders
1851 source: { ref: orders_api, config: { path: /v1/orders } }
1852 sink: { ref: archive, config: { path: ./orders.jsonl } }
1853"#);
1854 let nodes = expand(&c).unwrap();
1855 assert_eq!(nodes.len(), 2);
1856 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1857 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1858 assert_eq!(users.source.config["base_url"], "https://users.example");
1859 assert_eq!(users.source.config["path"], "/v1/users");
1860 assert_eq!(orders.source.config["base_url"], "https://orders.example");
1861 assert_eq!(orders.source.config["path"], "/v1/orders");
1862 assert_eq!(users.sink.config["path"], "./users.jsonl");
1864 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1865 }
1866
1867 #[test]
1868 fn sink_template_with_transforms_errors_at_expand() {
1869 let yaml = r#"
1870version: 1
1871pipeline:
1872 source:
1873 type: rest
1874 config: {}
1875 sinks:
1876 bad:
1877 type: jsonl
1878 config: { destination: /tmp/x.jsonl }
1879 transforms:
1880 - { type: flatten, config: { separator: "_" } }
1881matrix:
1882 - id: row
1883 sink: { ref: bad }
1884"#;
1885 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1886 .unwrap();
1887 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1888 match err {
1889 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1890 other => panic!("expected TransformsOnSink, got {other:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1896 let yaml = r#"
1897version: 1
1898pipeline:
1899 source:
1900 type: rest
1901 config: {}
1902 sinks:
1903 bad:
1904 type: jsonl
1905 config: { destination: /tmp/x.jsonl }
1906 inherit_transforms: false
1907matrix:
1908 - id: row
1909 sink: { ref: bad }
1910"#;
1911 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1912 .unwrap();
1913 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1914 match err {
1915 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1916 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1917 }
1918 }
1919
1920 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1921 transforms.iter().map(|t| t.kind.clone()).collect()
1922 }
1923
1924 #[test]
1925 fn three_layer_concat_default_inherit() {
1926 let yaml = r#"
1927version: 1
1928pipeline:
1929 transforms:
1930 - { type: flatten, config: { separator: "_" } }
1931 sources:
1932 s:
1933 type: rest
1934 config: {}
1935 transforms:
1936 - { type: keys_case, config: { mode: snake } }
1937 sink:
1938 type: jsonl
1939 config: { destination: /tmp/x.jsonl }
1940matrix:
1941 - id: row
1942 source: { ref: s }
1943 transforms:
1944 - { type: select, config: { fields: [id] } }
1945"#;
1946 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1947 .unwrap();
1948 let nodes = crate::expand::expand(&cfg).unwrap();
1949 assert_eq!(nodes.len(), 1);
1950 assert_eq!(
1951 kinds(&nodes[0].transforms),
1952 vec!["flatten", "keys_case", "select"]
1953 );
1954 }
1955
1956 #[test]
1957 fn source_inherit_false_drops_pipeline_layer() {
1958 let yaml = r#"
1959version: 1
1960pipeline:
1961 transforms:
1962 - { type: flatten, config: { separator: "_" } }
1963 sources:
1964 s:
1965 type: rest
1966 config: {}
1967 inherit_transforms: false
1968 transforms:
1969 - { type: keys_case, config: { mode: snake } }
1970 sink:
1971 type: jsonl
1972 config: { destination: /tmp/x.jsonl }
1973matrix:
1974 - id: row
1975 source: { ref: s }
1976 transforms:
1977 - { type: select, config: { fields: [id] } }
1978"#;
1979 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1980 .unwrap();
1981 let nodes = crate::expand::expand(&cfg).unwrap();
1982 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1983 }
1984
1985 #[test]
1986 fn row_inherit_false_drops_pipeline_and_source_layers() {
1987 let yaml = r#"
1988version: 1
1989pipeline:
1990 transforms:
1991 - { type: flatten, config: { separator: "_" } }
1992 sources:
1993 s:
1994 type: rest
1995 config: {}
1996 transforms:
1997 - { type: keys_case, config: { mode: snake } }
1998 sink:
1999 type: jsonl
2000 config: { destination: /tmp/x.jsonl }
2001matrix:
2002 - id: row
2003 source: { ref: s }
2004 inherit_transforms: false
2005 transforms:
2006 - { type: select, config: { fields: [id] } }
2007"#;
2008 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2009 .unwrap();
2010 let nodes = crate::expand::expand(&cfg).unwrap();
2011 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2012 }
2013
2014 #[test]
2015 fn both_inherit_false_yields_row_only() {
2016 let yaml = r#"
2017version: 1
2018pipeline:
2019 transforms:
2020 - { type: flatten, config: { separator: "_" } }
2021 sources:
2022 s:
2023 type: rest
2024 config: {}
2025 inherit_transforms: false
2026 transforms:
2027 - { type: keys_case, config: { mode: snake } }
2028 sink:
2029 type: jsonl
2030 config: { destination: /tmp/x.jsonl }
2031matrix:
2032 - id: row
2033 source: { ref: s }
2034 inherit_transforms: false
2035 transforms:
2036 - { type: select, config: { fields: [id] } }
2037"#;
2038 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2039 .unwrap();
2040 let nodes = crate::expand::expand(&cfg).unwrap();
2041 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2042 }
2043
2044 #[test]
2045 fn all_layers_omitted_yields_empty_transforms() {
2046 let yaml = r#"
2047version: 1
2048pipeline:
2049 source:
2050 type: rest
2051 config: {}
2052 sink:
2053 type: jsonl
2054 config: { destination: /tmp/x.jsonl }
2055matrix:
2056 - id: row
2057"#;
2058 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2059 .unwrap();
2060 let nodes = crate::expand::expand(&cfg).unwrap();
2061 assert!(nodes[0].transforms.is_empty());
2062 }
2063
2064 #[test]
2065 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
2066 let yaml = r#"
2068version: 1
2069pipeline:
2070 source: { type: rest, config: {} }
2071 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
2072"#;
2073 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2074 assert!(expand(&cfg).is_ok());
2076 }
2077
2078 #[test]
2079 fn now_is_a_reserved_row_id() {
2080 let yaml = r#"
2081version: 1
2082pipeline:
2083 source: { type: rest, config: {} }
2084 sink: { type: jsonl, config: { path: ./o.jsonl } }
2085matrix:
2086 - id: now
2087"#;
2088 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2089 match expand(&cfg).unwrap_err() {
2090 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
2091 other => panic!("expected ReservedRowId, got {other:?}"),
2092 }
2093 }
2094
2095 #[test]
2096 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
2097 let yaml = r#"
2101version: 1
2102pipeline:
2103 source: { type: rest, config: {} }
2104 sink: { type: jsonl, config: { path: ./o.jsonl } }
2105execution:
2106 adaptive_batch_size:
2107 enabled: true
2108 min: 5000
2109 max: 100
2110"#;
2111 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2112 let err = expand(&cfg).unwrap_err();
2113 assert!(
2114 err.to_string().contains("adaptive_batch_size.min"),
2115 "expected adaptive validation error, got: {err}"
2116 );
2117 }
2118
2119 #[test]
2120 fn expand_accepts_valid_adaptive_batch_size() {
2121 let yaml = r#"
2122version: 1
2123pipeline:
2124 source: { type: rest, config: {} }
2125 sink: { type: jsonl, config: { path: ./o.jsonl } }
2126execution:
2127 adaptive_batch_size:
2128 enabled: true
2129 min: 100
2130 max: 5000
2131 target_latency_ms: 500
2132"#;
2133 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2134 assert!(expand(&cfg).is_ok());
2135 }
2136
2137 #[test]
2140 fn exactly_once_rejects_non_cdc_source() {
2141 let yaml = r#"
2143version: 1
2144delivery: exactly_once
2145pipeline:
2146 source: { type: rest, config: { base_url: https://x } }
2147 sink: { type: stdout, config: {} }
2148 state:
2149 type: memory
2150 config: {}
2151"#;
2152 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2153 let err = expand(&cfg).unwrap_err();
2154 match &err {
2155 CliError::Config(msg) => {
2156 assert!(
2157 msg.contains("rest"),
2158 "expected source kind in error, got: {msg}"
2159 );
2160 assert!(
2161 msg.contains("exactly_once") || msg.contains("not supported"),
2162 "got: {msg}"
2163 );
2164 }
2165 other => panic!("expected Config error, got {other:?}"),
2166 }
2167 }
2168
2169 #[test]
2170 fn exactly_once_rejects_non_idempotent_sink() {
2171 let yaml = r#"
2173version: 1
2174delivery: exactly_once
2175pipeline:
2176 source: { type: postgres-cdc, config: {} }
2177 sink: { type: stdout, config: {} }
2178 state:
2179 type: memory
2180 config: {}
2181"#;
2182 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2183 let err = expand(&cfg).unwrap_err();
2184 match &err {
2185 CliError::Config(msg) => {
2186 assert!(
2187 msg.contains("stdout"),
2188 "expected sink kind in error, got: {msg}"
2189 );
2190 assert!(
2191 msg.contains("exactly_once") || msg.contains("not supported"),
2192 "got: {msg}"
2193 );
2194 }
2195 other => panic!("expected Config error, got {other:?}"),
2196 }
2197 }
2198
2199 #[test]
2200 fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
2201 let yaml = r#"
2205version: 1
2206delivery: exactly_once
2207pipeline:
2208 source: { type: postgres-cdc, config: {} }
2209 sink: { type: sqlite, config: {} }
2210 state:
2211 type: file
2212 config: { path: "/tmp/faucet-eo-state.json" }
2213"#;
2214 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2215 let nodes = expand(&cfg).unwrap();
2216 assert_eq!(nodes.len(), 1);
2217 assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
2218 assert_eq!(
2219 nodes[0].delivery_guarantee,
2220 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2221 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2222 )
2223 );
2224 }
2225
2226 #[test]
2227 fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
2228 let yaml = r#"
2232version: 1
2233delivery: exactly_once
2234pipeline:
2235 source: { type: rest, config: { base_url: https://x } }
2236 sink:
2237 type: postgres
2238 config:
2239 connection_url: "postgres://localhost/db"
2240 table_name: t
2241 column_mapping: auto_map
2242 write_mode: upsert
2243 key: [id]
2244"#;
2245 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2246 let nodes = expand(&cfg).unwrap();
2247 assert_eq!(
2248 nodes[0].delivery_guarantee,
2249 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2250 faucet_core::EffectivelyOnceMechanism::KeyedUpsert
2251 )
2252 );
2253 }
2254
2255 #[test]
2256 fn exactly_once_kafka_source_accepted_with_atomic_sink() {
2257 let yaml = r#"
2260version: 1
2261delivery: exactly_once
2262pipeline:
2263 source:
2264 type: kafka
2265 config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
2266 sink: { type: sqlite, config: {} }
2267 state:
2268 type: file
2269 config: { path: "/tmp/faucet-eo-kafka-state.json" }
2270"#;
2271 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2272 let nodes = expand(&cfg).unwrap();
2273 assert_eq!(
2274 nodes[0].delivery_guarantee,
2275 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2276 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2277 )
2278 );
2279 }
2280
2281 #[test]
2282 fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
2283 let yaml = r#"
2286version: 1
2287delivery: exactly_once
2288pipeline:
2289 source: { type: rest, config: { base_url: https://x } }
2290 sink:
2291 type: postgres
2292 config:
2293 connection_url: "postgres://localhost/db"
2294 table_name: t
2295 column_mapping: auto_map
2296 state:
2297 type: file
2298 config: { path: "/tmp/faucet-eo-hint-state.json" }
2299"#;
2300 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2301 let err = expand(&cfg).unwrap_err();
2302 match &err {
2303 CliError::Config(msg) => assert!(
2304 msg.contains("write_mode: upsert"),
2305 "expected keyed-upsert hint, got: {msg}"
2306 ),
2307 other => panic!("expected Config error, got {other:?}"),
2308 }
2309 }
2310
2311 #[test]
2312 fn derived_guarantee_is_at_least_once_by_default() {
2313 let yaml = r#"
2314version: 1
2315pipeline:
2316 source: { type: rest, config: { base_url: https://x } }
2317 sink: { type: stdout, config: {} }
2318"#;
2319 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2320 let nodes = expand(&cfg).unwrap();
2321 assert_eq!(
2322 nodes[0].delivery_guarantee,
2323 faucet_core::DeliveryGuarantee::AtLeastOnce
2324 );
2325 }
2326
2327 #[test]
2328 fn exactly_once_rejects_memory_state() {
2329 let yaml = r#"
2332version: 1
2333delivery: exactly_once
2334pipeline:
2335 source: { type: postgres-cdc, config: {} }
2336 sink: { type: sqlite, config: {} }
2337 state:
2338 type: memory
2339 config: {}
2340"#;
2341 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2342 let err = expand(&cfg).unwrap_err();
2343 match &err {
2344 CliError::Config(msg) => assert!(
2345 msg.contains("durable") && msg.contains("memory"),
2346 "expected durable/memory mention, got: {msg}"
2347 ),
2348 other => panic!("expected Config error, got {other:?}"),
2349 }
2350 }
2351
2352 #[test]
2353 fn exactly_once_rejects_missing_state_store() {
2354 let yaml = r#"
2356version: 1
2357delivery: exactly_once
2358pipeline:
2359 source: { type: postgres-cdc, config: {} }
2360 sink: { type: sqlite, config: {} }
2361"#;
2362 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2363 let err = expand(&cfg).unwrap_err();
2364 match &err {
2365 CliError::Config(msg) => {
2366 assert!(
2367 msg.contains("state store") || msg.contains("state"),
2368 "expected state-store mention in error, got: {msg}"
2369 );
2370 }
2371 other => panic!("expected Config error, got {other:?}"),
2372 }
2373 }
2374
2375 #[test]
2376 fn rejects_upsert_on_unsupported_sink() {
2377 let c = cfg(r#"
2378version: 1
2379name: t
2380pipeline:
2381 source: { type: rest, config: { url: "http://x" } }
2382 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
2383"#);
2384 let err = expand(&c).unwrap_err();
2385 let msg = format!("{err}");
2386 assert!(
2387 msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
2388 "{msg}"
2389 );
2390 }
2391
2392 #[test]
2393 fn rejects_upsert_without_key() {
2394 let c = cfg(r#"
2395version: 1
2396name: t
2397pipeline:
2398 source: { type: rest, config: { url: "http://x" } }
2399 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
2400"#);
2401 let err = expand(&c).unwrap_err();
2402 let msg = format!("{err}");
2403 assert!(msg.contains("key"), "{msg}");
2404 }
2405
2406 #[test]
2407 fn accepts_upsert_on_postgres_with_key() {
2408 let c = cfg(r#"
2409version: 1
2410name: t
2411pipeline:
2412 source: { type: rest, config: { url: "http://x" } }
2413 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
2414"#);
2415 assert!(expand(&c).is_ok());
2416 }
2417
2418 #[test]
2419 fn bigquery_upsert_passes_write_mode_gate() {
2420 let c = cfg(r#"
2421version: 1
2422name: t
2423pipeline:
2424 source: { type: rest, config: { url: "http://x" } }
2425 sink: { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
2426"#);
2427 assert!(expand(&c).is_ok());
2428 }
2429
2430 #[test]
2431 fn accepts_append_by_default_on_any_sink() {
2432 let c = cfg(r#"
2433version: 1
2434name: t
2435pipeline:
2436 source: { type: rest, config: { url: "http://x" } }
2437 sink: { type: jsonl, config: { path: "out.jsonl" } }
2438"#);
2439 assert!(expand(&c).is_ok());
2440 }
2441
2442 #[test]
2443 fn rejects_delete_without_key() {
2444 let c = cfg(r#"
2445version: 1
2446name: t
2447pipeline:
2448 source: { type: rest, config: { url: "http://x" } }
2449 sink: { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
2450"#);
2451 let err = expand(&c).unwrap_err();
2452 let msg = format!("{err}");
2453 assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
2454 }
2455
2456 #[test]
2457 fn rejects_unknown_write_mode() {
2458 let c = cfg(r#"
2459version: 1
2460name: t
2461pipeline:
2462 source: { type: rest, config: { url: "http://x" } }
2463 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
2464"#);
2465 let err = expand(&c).unwrap_err();
2466 let msg = format!("{err}");
2467 assert!(
2468 msg.contains("unknown write_mode") && msg.contains("replace"),
2469 "{msg}"
2470 );
2471 }
2472
2473 #[test]
2474 fn rejects_poison_dlq_action_without_dlq() {
2475 let c = cfg(r#"
2476version: 1
2477pipeline:
2478 source: { type: rest, config: { base_url: https://x } }
2479 sink: { type: jsonl, config: { path: ./o } }
2480resilience:
2481 poison: { max_row_attempts: 3, action: dlq }
2482"#);
2483 let err = expand(&c).unwrap_err();
2484 assert!(
2485 matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
2486 "got: {err:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn accepts_poison_dlq_action_with_dlq() {
2492 let c = cfg(r#"
2493version: 1
2494pipeline:
2495 source: { type: rest, config: { base_url: https://x } }
2496 sink: { type: jsonl, config: { path: ./o } }
2497 dlq:
2498 sink: { type: jsonl, config: { path: ./dead.jsonl } }
2499resilience:
2500 poison: { max_row_attempts: 3, action: dlq }
2501"#);
2502 let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
2503 assert_eq!(nodes.len(), 1);
2504 }
2505
2506 #[test]
2507 fn accepts_poison_drop_action_without_dlq() {
2508 let c = cfg(r#"
2510version: 1
2511pipeline:
2512 source: { type: rest, config: { base_url: https://x } }
2513 sink: { type: jsonl, config: { path: ./o } }
2514resilience:
2515 poison: { max_row_attempts: 3, action: drop }
2516"#);
2517 let nodes = expand(&c).expect("poison.action=drop needs no dlq");
2518 assert_eq!(nodes.len(), 1);
2519 }
2520
2521 #[test]
2524 fn evolve_on_non_evolvable_sink_rejected() {
2525 let c = cfg(r#"
2527version: 1
2528pipeline:
2529 source: { type: rest, config: { base_url: https://x } }
2530 sink: { type: jsonl, config: { path: ./o.jsonl } }
2531 schema:
2532 on_drift: evolve
2533"#);
2534 let err = expand(&c).unwrap_err();
2535 match &err {
2536 CliError::Config(msg) => {
2537 assert!(
2538 msg.contains("evolve"),
2539 "expected evolve mention, got: {msg}"
2540 );
2541 assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
2542 }
2543 other => panic!("expected Config error, got {other:?}"),
2544 }
2545 }
2546
2547 #[test]
2548 fn quarantine_drift_without_dlq_rejected() {
2549 let c = cfg(r#"
2551version: 1
2552pipeline:
2553 source: { type: rest, config: { base_url: https://x } }
2554 sink: { type: postgres, config: {} }
2555 schema:
2556 on_drift: quarantine
2557"#);
2558 let err = expand(&c).unwrap_err();
2559 match &err {
2560 CliError::Config(msg) => {
2561 assert!(
2562 msg.contains("quarantine"),
2563 "expected quarantine mention, got: {msg}"
2564 );
2565 assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
2566 }
2567 other => panic!("expected Config error, got {other:?}"),
2568 }
2569 }
2570
2571 #[test]
2572 fn evolve_on_postgres_passes() {
2573 let c = cfg(r#"
2575version: 1
2576pipeline:
2577 source: { type: rest, config: { base_url: https://x } }
2578 sink: { type: postgres, config: {} }
2579 schema:
2580 on_drift: evolve
2581"#);
2582 assert!(expand(&c).is_ok());
2583 }
2584}