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", "param",
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
903 && id == crate::params::PARAM_ID
904 {
905 return Err(CliError::Config(format!(
906 "interpolation token `{token}` (in {owner}) was never bound — a `${{param.*}}` \
907 reference is resolved when the run is triggered. Load the config through \
908 `PipelineConfig::from_path*` (or supply values with `--param`) so params are \
909 bound before expansion"
910 )));
911 }
912 if let Directive::Deferred { id, .. } = dir
913 && id != "now"
914 && id != "backfill"
915 && !id_set.contains(id)
916 {
917 return Err(CliError::UnknownInterpolationId {
918 id: id.to_owned(),
919 token: format!("{token} (in {owner})"),
920 });
921 }
922 }
923 Ok(())
924 })
925}
926
927fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
935 walk_strings(value, &mut |s| {
936 for (token, dir) in iter_directives(s) {
937 if let Directive::Deferred { .. } = dir {
938 return Err(CliError::Config(format!(
939 "interpolation token `{token}` in {location} is not supported: \
940 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
941 resolve only in source/sink configs"
942 )));
943 }
944 }
945 Ok(())
946 })
947}
948
949fn resolve_tags(
954 template_tags: &[String],
955 row_tags: &[String],
956 row_id: &str,
957) -> CliResult<Vec<String>> {
958 let mut set: BTreeSet<String> = BTreeSet::new();
959 for tag in template_tags.iter().chain(row_tags.iter()) {
960 validate_tag(tag, row_id)?;
961 set.insert(tag.clone());
962 }
963 Ok(set.into_iter().collect())
964}
965
966fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
968 let ok = {
969 let mut chars = tag.chars();
970 match chars.next() {
971 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
972 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
973 }
974 _ => false,
975 }
976 };
977 if !ok {
978 return Err(CliError::Config(format!(
979 "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
980 (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
981 )));
982 }
983 Ok(())
984}
985
986fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
987 let _ = walk_strings(value, &mut |s| {
988 for (token, dir) in iter_directives(s) {
989 if let Directive::Deferred { id, path } = dir {
990 if id == "now" || id == "backfill" {
994 continue;
995 }
996 out.push(DeferredRef {
997 referenced_id: id.to_owned(),
998 dotted_path: path.to_owned(),
999 token: token.to_owned(),
1000 });
1001 }
1002 }
1003 Ok(())
1004 });
1005}
1006
1007fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
1008where
1009 F: FnMut(&str) -> CliResult<()>,
1010{
1011 match value {
1012 Value::String(s) => f(s),
1013 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
1014 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1015 _ => Ok(()),
1016 }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use crate::config::{OnBatchErrorSpec, parse_with_extension};
1023
1024 fn cfg(yaml: &str) -> PipelineConfig {
1025 parse_with_extension(yaml, "yaml").unwrap()
1026 }
1027
1028 #[test]
1029 fn implicit_single_row_when_matrix_absent() {
1030 let c = cfg(r#"
1031version: 1
1032pipeline:
1033 source: { type: rest, config: { base_url: https://x } }
1034 sink: { type: jsonl, config: { path: ./o } }
1035"#);
1036 let nodes = expand(&c).unwrap();
1037 assert_eq!(nodes.len(), 1);
1038 assert_eq!(nodes[0].id, "row-0");
1039 assert!(matches!(nodes[0].role, NodeRole::Root));
1040 assert_eq!(nodes[0].source.kind, "rest");
1041 assert_eq!(nodes[0].sink.kind, "jsonl");
1042 }
1043
1044 #[test]
1045 fn rejects_runtime_token_in_dlq_config() {
1046 let c = cfg(r#"
1050version: 1
1051pipeline:
1052 source: { type: rest, config: { base_url: https://x } }
1053 sink: { type: jsonl, config: { path: ./o } }
1054 dlq:
1055 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1056"#);
1057 let err = expand(&c).unwrap_err();
1058 assert!(
1059 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1060 "got: {err:?}"
1061 );
1062 }
1063
1064 #[test]
1065 fn rejects_runtime_token_in_state_config() {
1066 let c = cfg(r#"
1067version: 1
1068pipeline:
1069 source: { type: rest, config: { base_url: https://x } }
1070 sink: { type: jsonl, config: { path: ./o } }
1071 state:
1072 type: file
1073 config: { path: "state-${now.date}" }
1074"#);
1075 let err = expand(&c).unwrap_err();
1076 assert!(
1077 matches!(&err, CliError::Config(m) if m.contains("state")),
1078 "got: {err:?}"
1079 );
1080 }
1081
1082 #[test]
1083 fn rejects_runtime_token_in_transform_config() {
1084 let c = cfg(r#"
1085version: 1
1086pipeline:
1087 source: { type: rest, config: { base_url: https://x } }
1088 sink: { type: jsonl, config: { path: ./o } }
1089 transforms:
1090 - type: set
1091 config: { field: ts, value: "${now.datetime}" }
1092"#);
1093 let err = expand(&c).unwrap_err();
1094 assert!(
1095 matches!(&err, CliError::Config(m) if m.contains("transform")),
1096 "got: {err:?}"
1097 );
1098 }
1099
1100 #[test]
1101 fn allows_runtime_token_in_source_and_sink_configs() {
1102 let c = cfg(r#"
1105version: 1
1106pipeline:
1107 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1108 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1109"#);
1110 let nodes = expand(&c).unwrap();
1111 assert_eq!(nodes.len(), 1);
1112 }
1113
1114 #[test]
1115 fn merges_row_overrides_into_pipeline_source() {
1116 let c = cfg(r#"
1117version: 1
1118pipeline:
1119 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1120 sink: { type: jsonl, config: { path: ./o } }
1121matrix:
1122 - id: users
1123 source: { config: { path: /v1/users, headers: { b: 2 } } }
1124"#);
1125 let nodes = expand(&c).unwrap();
1126 assert_eq!(nodes[0].id, "users");
1127 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1128 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1129 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1130 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1131 }
1132
1133 #[test]
1134 fn errors_on_unknown_parent() {
1135 let c = cfg(r#"
1136version: 1
1137pipeline:
1138 source: { type: rest, config: {} }
1139 sink: { type: jsonl, config: { path: ./o } }
1140matrix:
1141 - id: child
1142 parent: nobody
1143"#);
1144 assert!(matches!(
1145 expand(&c).unwrap_err(),
1146 CliError::UnknownParent { .. }
1147 ));
1148 }
1149
1150 #[test]
1151 fn errors_on_duplicate_ids() {
1152 let c = cfg(r#"
1153version: 1
1154pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1155matrix:
1156 - { id: x }
1157 - { id: x }
1158"#);
1159 assert!(matches!(
1160 expand(&c).unwrap_err(),
1161 CliError::DuplicateRowId { .. }
1162 ));
1163 }
1164
1165 #[test]
1166 fn errors_on_reserved_id() {
1167 let c = cfg(r#"
1168version: 1
1169pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1170matrix:
1171 - { id: env }
1172"#);
1173 assert!(matches!(
1174 expand(&c).unwrap_err(),
1175 CliError::ReservedRowId { .. }
1176 ));
1177 }
1178
1179 #[test]
1180 fn errors_on_self_parent_cycle() {
1181 let c = cfg(r#"
1182version: 1
1183pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1184matrix:
1185 - { id: a, parent: a }
1186"#);
1187 assert!(matches!(
1188 expand(&c).unwrap_err(),
1189 CliError::ParentCycle { .. }
1190 ));
1191 }
1192
1193 #[test]
1194 fn errors_on_two_node_cycle() {
1195 let c = cfg(r#"
1196version: 1
1197pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1198matrix:
1199 - { id: a, parent: b }
1200 - { id: b, parent: a }
1201"#);
1202 assert!(matches!(
1203 expand(&c).unwrap_err(),
1204 CliError::ParentCycle { .. }
1205 ));
1206 }
1207
1208 #[test]
1209 fn errors_on_unknown_dependency() {
1210 let c = cfg(r#"
1211version: 1
1212pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1213matrix:
1214 - { id: facts, depends_on: [nobody] }
1215"#);
1216 match expand(&c).unwrap_err() {
1217 CliError::UnknownDependency { id, depends_on } => {
1218 assert_eq!(id, "facts");
1219 assert_eq!(depends_on, "nobody");
1220 }
1221 other => panic!("expected UnknownDependency, got {other:?}"),
1222 }
1223 }
1224
1225 #[test]
1226 fn errors_on_self_dependency() {
1227 let c = cfg(r#"
1228version: 1
1229pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1230matrix:
1231 - { id: a, depends_on: [a] }
1232"#);
1233 match expand(&c).unwrap_err() {
1234 CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1235 other => panic!("expected DependencyCycle, got {other:?}"),
1236 }
1237 }
1238
1239 #[test]
1240 fn errors_on_depends_on_cycle() {
1241 let c = cfg(r#"
1242version: 1
1243pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1244matrix:
1245 - { id: a, depends_on: [b] }
1246 - { id: b, depends_on: [a] }
1247"#);
1248 match expand(&c).unwrap_err() {
1249 CliError::DependencyCycle { ids } => {
1250 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1251 }
1252 other => panic!("expected DependencyCycle, got {other:?}"),
1253 }
1254 }
1255
1256 #[test]
1257 fn errors_on_mixed_parent_depends_on_cycle() {
1258 let c = cfg(r#"
1262version: 1
1263pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1264matrix:
1265 - { id: a, parent: b }
1266 - { id: b, depends_on: [a] }
1267"#);
1268 match expand(&c).unwrap_err() {
1269 CliError::DependencyCycle { ids } => {
1270 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1271 }
1272 other => panic!("expected DependencyCycle, got {other:?}"),
1273 }
1274 }
1275
1276 #[test]
1277 fn depends_on_is_recorded_and_deduped() {
1278 let c = cfg(r#"
1279version: 1
1280pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1281matrix:
1282 - { id: dims }
1283 - { id: staging }
1284 - { id: facts, depends_on: [dims, staging, dims] }
1285"#);
1286 let nodes = expand(&c).unwrap();
1287 let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
1288 assert_eq!(
1289 facts.depends_on,
1290 vec!["dims".to_string(), "staging".to_string()]
1291 );
1292 assert!(matches!(facts.role, NodeRole::Root));
1293 let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
1294 assert!(dims.depends_on.is_empty());
1295 }
1296
1297 #[test]
1298 fn depends_on_may_target_a_child_row() {
1299 let c = cfg(r#"
1302version: 1
1303pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1304matrix:
1305 - { id: users }
1306 - { id: posts, parent: users }
1307 - { id: rollup, depends_on: [posts] }
1308"#);
1309 let nodes = expand(&c).unwrap();
1310 let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
1311 assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
1312 }
1313
1314 #[test]
1315 fn errors_on_unknown_interpolation_id() {
1316 let c = cfg(r#"
1317version: 1
1318pipeline:
1319 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
1320 sink: { type: jsonl, config: { path: ./o } }
1321"#);
1322 assert!(matches!(
1323 expand(&c).unwrap_err(),
1324 CliError::UnknownInterpolationId { .. }
1325 ));
1326 }
1327
1328 #[test]
1329 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
1330 let c = cfg(r#"
1335version: 1
1336pipeline:
1337 source: { type: rest, config: { url: "https://x/${env.foo}" } }
1338 sink: { type: jsonl, config: { path: ./o } }
1339"#);
1340 match expand(&c).unwrap_err() {
1341 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
1342 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
1343 }
1344 }
1345
1346 #[test]
1347 fn accepts_id_path_when_referenced_row_exists() {
1348 let c = cfg(r#"
1349version: 1
1350pipeline:
1351 source: { type: rest, config: {} }
1352 sink: { type: jsonl, config: { path: ./o } }
1353matrix:
1354 - id: users
1355 - id: posts
1356 parent: users
1357 source: { config: { path: "/v1/users/${users.id}/posts" } }
1358"#);
1359 let nodes = expand(&c).unwrap();
1360 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1361 assert_eq!(posts.deferred_refs.len(), 1);
1362 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
1363 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
1364 }
1365
1366 #[test]
1367 fn nested_referenced_path_resolves() {
1368 let c = cfg(r#"
1369version: 1
1370pipeline:
1371 source: { type: rest, config: {} }
1372 sink: { type: jsonl, config: { path: ./o } }
1373matrix:
1374 - id: users
1375 - id: addrs
1376 parent: users
1377 source: { config: { path: "/users/${users.addr.city}/addr" } }
1378"#);
1379 let nodes = expand(&c).unwrap();
1380 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
1381 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
1382 }
1383
1384 #[test]
1385 fn roots_come_before_children_in_order() {
1386 let c = cfg(r#"
1387version: 1
1388pipeline:
1389 source: { type: rest, config: {} }
1390 sink: { type: jsonl, config: { path: ./o } }
1391matrix:
1392 - id: posts
1393 parent: users
1394 - id: users
1395"#);
1396 let nodes = expand(&c).unwrap();
1397 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
1398 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
1399 assert!(users_idx < posts_idx, "users must precede posts");
1400 }
1401
1402 #[test]
1403 fn child_node_has_parent_role() {
1404 let c = cfg(r#"
1405version: 1
1406pipeline:
1407 source: { type: rest, config: {} }
1408 sink: { type: jsonl, config: { path: ./o } }
1409matrix:
1410 - id: users
1411 - id: posts
1412 parent: users
1413 parent_key: user_id
1414"#);
1415 let nodes = expand(&c).unwrap();
1416 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1417 match &posts.role {
1418 NodeRole::Child {
1419 parent_id,
1420 parent_key,
1421 } => {
1422 assert_eq!(parent_id, "users");
1423 assert_eq!(parent_key, "user_id");
1424 }
1425 other => panic!("expected Child, got {other:?}"),
1426 }
1427 }
1428
1429 #[test]
1430 fn expand_rejects_zero_per_page_budget() {
1431 let yaml = r#"
1432version: 1
1433pipeline:
1434 source: { type: rest, config: {} }
1435 sink: { type: jsonl, config: { path: ./o.jsonl } }
1436 dlq:
1437 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1438 max_failures_per_page: 0
1439"#;
1440 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1441 let err = expand(&cfg).unwrap_err();
1442 assert!(matches!(
1443 err,
1444 CliError::InvalidDlqBudget {
1445 field: "max_failures_per_page"
1446 }
1447 ));
1448 }
1449
1450 #[test]
1451 fn expand_rejects_zero_total_budget() {
1452 let yaml = r#"
1453version: 1
1454pipeline:
1455 source: { type: rest, config: {} }
1456 sink: { type: jsonl, config: { path: ./o.jsonl } }
1457 dlq:
1458 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1459 max_failures_total: 0
1460"#;
1461 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1462 let err = expand(&cfg).unwrap_err();
1463 assert!(matches!(
1464 err,
1465 CliError::InvalidDlqBudget {
1466 field: "max_failures_total"
1467 }
1468 ));
1469 }
1470
1471 #[test]
1472 fn expand_rejects_unknown_dlq_sink_kind() {
1473 let yaml = r#"
1474version: 1
1475pipeline:
1476 source: { type: rest, config: {} }
1477 sink: { type: jsonl, config: { path: ./o.jsonl } }
1478 dlq:
1479 sink: { type: not_a_sink, config: {} }
1480"#;
1481 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1482 let err = expand(&cfg).unwrap_err();
1483 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1484 }
1485
1486 #[cfg(feature = "quality")]
1487 #[test]
1488 fn expand_rejects_quarantine_without_dlq() {
1489 let yaml = r#"
1492version: 1
1493pipeline:
1494 source: { type: rest, config: {} }
1495 sink: { type: jsonl, config: { path: ./o.jsonl } }
1496 quality:
1497 record:
1498 - { type: not_null, field: id, on_failure: quarantine }
1499"#;
1500 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1501 let err = expand(&cfg).unwrap_err();
1502 match err {
1503 CliError::Config(msg) => {
1504 assert!(msg.contains("quarantine"), "{msg}");
1505 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1506 }
1507 other => panic!("expected Config error, got {other:?}"),
1508 }
1509 }
1510
1511 #[cfg(feature = "quality")]
1512 #[test]
1513 fn expand_accepts_quarantine_with_dlq() {
1514 let yaml = r#"
1515version: 1
1516pipeline:
1517 source: { type: rest, config: {} }
1518 sink: { type: jsonl, config: { path: ./o.jsonl } }
1519 dlq:
1520 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1521 quality:
1522 record:
1523 - { type: not_null, field: id, on_failure: quarantine }
1524"#;
1525 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1526 let nodes = expand(&cfg).unwrap();
1527 assert_eq!(nodes.len(), 1);
1528 let q = nodes[0]
1529 .quality
1530 .as_ref()
1531 .expect("quality threaded onto node");
1532 assert_eq!(q.record.len(), 1);
1533 }
1534
1535 #[cfg(feature = "quality")]
1536 #[test]
1537 fn expand_accepts_abort_quality_without_dlq() {
1538 let yaml = r#"
1540version: 1
1541pipeline:
1542 source: { type: rest, config: {} }
1543 sink: { type: jsonl, config: { path: ./o.jsonl } }
1544 quality:
1545 record:
1546 - { type: not_null, field: id, on_failure: abort }
1547"#;
1548 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1549 let nodes = expand(&cfg).unwrap();
1550 assert!(nodes[0].quality.is_some());
1551 }
1552
1553 #[cfg(feature = "contract")]
1554 #[test]
1555 fn expand_rejects_contract_quarantine_without_dlq() {
1556 let yaml = r#"
1557version: 1
1558pipeline:
1559 source: { type: rest, config: {} }
1560 sink: { type: jsonl, config: { path: ./o.jsonl } }
1561 contract:
1562 version: "1.0.0"
1563 on_breach: quarantine
1564 fields:
1565 - { name: id, type: integer }
1566"#;
1567 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1568 let err = expand(&cfg).unwrap_err();
1569 match err {
1570 CliError::Config(msg) => {
1571 assert!(msg.contains("on_breach: quarantine"), "{msg}");
1572 assert!(msg.contains("dlq"), "{msg}");
1573 }
1574 other => panic!("expected Config error, got {other:?}"),
1575 }
1576 }
1577
1578 #[cfg(feature = "contract")]
1579 #[test]
1580 fn expand_accepts_contract_quarantine_with_dlq() {
1581 let yaml = r#"
1582version: 1
1583pipeline:
1584 source: { type: rest, config: {} }
1585 sink: { type: jsonl, config: { path: ./o.jsonl } }
1586 dlq:
1587 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1588 contract:
1589 version: "1.0.0"
1590 on_breach: quarantine
1591 fields:
1592 - { name: id, type: integer }
1593"#;
1594 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1595 let nodes = expand(&cfg).unwrap();
1596 assert_eq!(nodes.len(), 1);
1597 let c = nodes[0]
1598 .contract
1599 .as_ref()
1600 .expect("contract threaded onto node");
1601 assert_eq!(c.version, "1.0.0");
1602 assert_eq!(c.fields.len(), 1);
1603 }
1604
1605 #[cfg(feature = "contract")]
1606 #[test]
1607 fn expand_accepts_contract_fail_without_dlq() {
1608 let yaml = r#"
1610version: 1
1611pipeline:
1612 source: { type: rest, config: {} }
1613 sink: { type: jsonl, config: { path: ./o.jsonl } }
1614 contract:
1615 version: "1.0.0"
1616 fields:
1617 - { name: id, type: integer }
1618"#;
1619 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1620 let nodes = expand(&cfg).unwrap();
1621 assert!(nodes[0].contract.is_some());
1622 }
1623
1624 #[cfg(feature = "contract")]
1625 #[test]
1626 fn expand_rejects_malformed_contract() {
1627 let yaml = r#"
1629version: 1
1630pipeline:
1631 source: { type: rest, config: {} }
1632 sink: { type: jsonl, config: { path: ./o.jsonl } }
1633 contract:
1634 version: "1.0.0"
1635 fields:
1636 - { name: email, type: string, pattern: "[invalid" }
1637"#;
1638 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1639 let err = expand(&cfg).unwrap_err();
1640 match err {
1641 CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
1642 other => panic!("expected Config error, got {other:?}"),
1643 }
1644 }
1645
1646 #[test]
1647 fn legacy_singular_source_resolves_as_default_template() {
1648 let c = cfg(r#"
1649version: 1
1650pipeline:
1651 source: { type: rest, config: { base_url: https://x } }
1652 sink: { type: jsonl, config: { path: ./o } }
1653"#);
1654 let nodes = expand(&c).unwrap();
1655 assert_eq!(nodes[0].source.kind, "rest");
1656 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1657 }
1658
1659 #[test]
1660 fn row_with_ref_picks_named_template() {
1661 let c = cfg(r#"
1662version: 1
1663pipeline:
1664 sources:
1665 users_api: { type: rest, config: { base_url: https://x } }
1666 sinks:
1667 archive: { type: jsonl, config: { path: ./out } }
1668matrix:
1669 - id: load_users
1670 source:
1671 ref: users_api
1672 config: { path: /v1/users }
1673 sink:
1674 ref: archive
1675 config: { path: ./users.jsonl }
1676"#);
1677 let nodes = expand(&c).unwrap();
1678 assert_eq!(nodes[0].source.kind, "rest");
1679 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1680 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1681 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1682 }
1683
1684 #[test]
1685 fn row_without_ref_falls_back_to_default_template() {
1686 let c = cfg(r#"
1687version: 1
1688pipeline:
1689 source: { type: rest, config: { base_url: https://x } }
1690 sink: { type: jsonl, config: { path: ./o } }
1691matrix:
1692 - id: users
1693 source: { config: { path: /v1/users } }
1694"#);
1695 let nodes = expand(&c).unwrap();
1696 assert_eq!(nodes[0].source.kind, "rest");
1697 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1698 }
1699
1700 #[test]
1701 fn unknown_template_ref_errors_with_known_list() {
1702 let c = cfg(r#"
1703version: 1
1704pipeline:
1705 sources:
1706 a: { type: rest, config: {} }
1707 b: { type: rest, config: {} }
1708 sinks:
1709 s: { type: jsonl, config: { path: ./o } }
1710matrix:
1711 - id: x
1712 source: { ref: c }
1713 sink: { ref: s }
1714"#);
1715 let err = expand(&c).unwrap_err();
1716 match err {
1717 CliError::UnknownTemplate {
1718 kind,
1719 name,
1720 row_id,
1721 known,
1722 } => {
1723 assert_eq!(kind, "source");
1724 assert_eq!(name, "c");
1725 assert_eq!(row_id, "x");
1726 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1727 }
1728 other => panic!("expected UnknownTemplate, got {other:?}"),
1729 }
1730 }
1731
1732 #[test]
1733 fn missing_default_template_errors() {
1734 let c = cfg(r#"
1737version: 1
1738pipeline:
1739 sources:
1740 users_api: { type: rest, config: {} }
1741 sink: { type: jsonl, config: { path: ./o } }
1742matrix:
1743 - id: x
1744 source: { config: { path: /v1 } }
1745"#);
1746 let err = expand(&c).unwrap_err();
1747 match err {
1748 CliError::MissingTemplate { kind, row_id } => {
1749 assert_eq!(kind, "source");
1750 assert_eq!(row_id, "x");
1751 }
1752 other => panic!("expected MissingTemplate, got {other:?}"),
1753 }
1754 }
1755
1756 #[test]
1757 fn duplicate_default_template_errors() {
1758 let c = cfg(r#"
1760version: 1
1761pipeline:
1762 source: { type: rest, config: {} }
1763 sources:
1764 default: { type: rest, config: {} }
1765 sink: { type: jsonl, config: { path: ./o } }
1766"#);
1767 let err = expand(&c).unwrap_err();
1768 match err {
1769 CliError::DuplicateTemplate { kind, name } => {
1770 assert_eq!(kind, "source");
1771 assert_eq!(name, "default");
1772 }
1773 other => panic!("expected DuplicateTemplate, got {other:?}"),
1774 }
1775 }
1776
1777 #[test]
1778 fn row_can_override_template_kind() {
1779 let c = cfg(r#"
1780version: 1
1781pipeline:
1782 sources:
1783 api: { type: rest, config: { base_url: https://x } }
1784 sinks:
1785 out: { type: jsonl, config: { path: ./o } }
1786matrix:
1787 - id: x
1788 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1789 sink: { ref: out }
1790"#);
1791 let nodes = expand(&c).unwrap();
1792 assert_eq!(nodes[0].source.kind, "graphql");
1793 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1794 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1795 }
1796
1797 #[test]
1798 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1799 let yaml = r#"
1800version: 1
1801pipeline:
1802 source: { type: rest, config: {} }
1803 sink: { type: jsonl, config: { path: ./o.jsonl } }
1804 dlq:
1805 sink: { type: jsonl, config: { path: ./base.jsonl } }
1806matrix:
1807 - id: a
1808 - id: b
1809 dlq: null
1810 - id: c
1811 dlq:
1812 sink: { type: jsonl, config: { path: ./c.jsonl } }
1813 on_batch_error: dlq_all
1814"#;
1815 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1816 let nodes = expand(&cfg).unwrap();
1817 assert_eq!(nodes.len(), 3);
1818 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1820 assert_eq!(
1821 nodes[0]
1822 .dlq
1823 .as_ref()
1824 .unwrap()
1825 .sink
1826 .config
1827 .get("path")
1828 .unwrap(),
1829 "./base.jsonl"
1830 );
1831 assert!(nodes[1].dlq.is_none());
1833 assert_eq!(
1835 nodes[2].dlq.as_ref().unwrap().on_batch_error,
1836 OnBatchErrorSpec::DlqAll
1837 );
1838 assert_eq!(
1839 nodes[2]
1840 .dlq
1841 .as_ref()
1842 .unwrap()
1843 .sink
1844 .config
1845 .get("path")
1846 .unwrap(),
1847 "./c.jsonl"
1848 );
1849 }
1850
1851 #[test]
1852 fn multiple_rows_pick_different_templates() {
1853 let c = cfg(r#"
1854version: 1
1855pipeline:
1856 sources:
1857 users_api: { type: rest, config: { base_url: https://users.example } }
1858 orders_api: { type: rest, config: { base_url: https://orders.example } }
1859 sinks:
1860 archive: { type: jsonl, config: { path: ./out } }
1861matrix:
1862 - id: load_users
1863 source: { ref: users_api, config: { path: /v1/users } }
1864 sink: { ref: archive, config: { path: ./users.jsonl } }
1865 - id: load_orders
1866 source: { ref: orders_api, config: { path: /v1/orders } }
1867 sink: { ref: archive, config: { path: ./orders.jsonl } }
1868"#);
1869 let nodes = expand(&c).unwrap();
1870 assert_eq!(nodes.len(), 2);
1871 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1872 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1873 assert_eq!(users.source.config["base_url"], "https://users.example");
1874 assert_eq!(users.source.config["path"], "/v1/users");
1875 assert_eq!(orders.source.config["base_url"], "https://orders.example");
1876 assert_eq!(orders.source.config["path"], "/v1/orders");
1877 assert_eq!(users.sink.config["path"], "./users.jsonl");
1879 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1880 }
1881
1882 #[test]
1883 fn sink_template_with_transforms_errors_at_expand() {
1884 let yaml = r#"
1885version: 1
1886pipeline:
1887 source:
1888 type: rest
1889 config: {}
1890 sinks:
1891 bad:
1892 type: jsonl
1893 config: { destination: /tmp/x.jsonl }
1894 transforms:
1895 - { type: flatten, config: { separator: "_" } }
1896matrix:
1897 - id: row
1898 sink: { ref: bad }
1899"#;
1900 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1901 .unwrap();
1902 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1903 match err {
1904 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1905 other => panic!("expected TransformsOnSink, got {other:?}"),
1906 }
1907 }
1908
1909 #[test]
1910 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1911 let yaml = r#"
1912version: 1
1913pipeline:
1914 source:
1915 type: rest
1916 config: {}
1917 sinks:
1918 bad:
1919 type: jsonl
1920 config: { destination: /tmp/x.jsonl }
1921 inherit_transforms: false
1922matrix:
1923 - id: row
1924 sink: { ref: bad }
1925"#;
1926 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1927 .unwrap();
1928 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1929 match err {
1930 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1931 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1932 }
1933 }
1934
1935 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1936 transforms.iter().map(|t| t.kind.clone()).collect()
1937 }
1938
1939 #[test]
1940 fn three_layer_concat_default_inherit() {
1941 let yaml = r#"
1942version: 1
1943pipeline:
1944 transforms:
1945 - { type: flatten, config: { separator: "_" } }
1946 sources:
1947 s:
1948 type: rest
1949 config: {}
1950 transforms:
1951 - { type: keys_case, config: { mode: snake } }
1952 sink:
1953 type: jsonl
1954 config: { destination: /tmp/x.jsonl }
1955matrix:
1956 - id: row
1957 source: { ref: s }
1958 transforms:
1959 - { type: select, config: { fields: [id] } }
1960"#;
1961 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1962 .unwrap();
1963 let nodes = crate::expand::expand(&cfg).unwrap();
1964 assert_eq!(nodes.len(), 1);
1965 assert_eq!(
1966 kinds(&nodes[0].transforms),
1967 vec!["flatten", "keys_case", "select"]
1968 );
1969 }
1970
1971 #[test]
1972 fn source_inherit_false_drops_pipeline_layer() {
1973 let yaml = r#"
1974version: 1
1975pipeline:
1976 transforms:
1977 - { type: flatten, config: { separator: "_" } }
1978 sources:
1979 s:
1980 type: rest
1981 config: {}
1982 inherit_transforms: false
1983 transforms:
1984 - { type: keys_case, config: { mode: snake } }
1985 sink:
1986 type: jsonl
1987 config: { destination: /tmp/x.jsonl }
1988matrix:
1989 - id: row
1990 source: { ref: s }
1991 transforms:
1992 - { type: select, config: { fields: [id] } }
1993"#;
1994 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1995 .unwrap();
1996 let nodes = crate::expand::expand(&cfg).unwrap();
1997 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1998 }
1999
2000 #[test]
2001 fn row_inherit_false_drops_pipeline_and_source_layers() {
2002 let yaml = r#"
2003version: 1
2004pipeline:
2005 transforms:
2006 - { type: flatten, config: { separator: "_" } }
2007 sources:
2008 s:
2009 type: rest
2010 config: {}
2011 transforms:
2012 - { type: keys_case, config: { mode: snake } }
2013 sink:
2014 type: jsonl
2015 config: { destination: /tmp/x.jsonl }
2016matrix:
2017 - id: row
2018 source: { ref: s }
2019 inherit_transforms: false
2020 transforms:
2021 - { type: select, config: { fields: [id] } }
2022"#;
2023 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2024 .unwrap();
2025 let nodes = crate::expand::expand(&cfg).unwrap();
2026 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2027 }
2028
2029 #[test]
2030 fn both_inherit_false_yields_row_only() {
2031 let yaml = r#"
2032version: 1
2033pipeline:
2034 transforms:
2035 - { type: flatten, config: { separator: "_" } }
2036 sources:
2037 s:
2038 type: rest
2039 config: {}
2040 inherit_transforms: false
2041 transforms:
2042 - { type: keys_case, config: { mode: snake } }
2043 sink:
2044 type: jsonl
2045 config: { destination: /tmp/x.jsonl }
2046matrix:
2047 - id: row
2048 source: { ref: s }
2049 inherit_transforms: false
2050 transforms:
2051 - { type: select, config: { fields: [id] } }
2052"#;
2053 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2054 .unwrap();
2055 let nodes = crate::expand::expand(&cfg).unwrap();
2056 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2057 }
2058
2059 #[test]
2060 fn all_layers_omitted_yields_empty_transforms() {
2061 let yaml = r#"
2062version: 1
2063pipeline:
2064 source:
2065 type: rest
2066 config: {}
2067 sink:
2068 type: jsonl
2069 config: { destination: /tmp/x.jsonl }
2070matrix:
2071 - id: row
2072"#;
2073 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2074 .unwrap();
2075 let nodes = crate::expand::expand(&cfg).unwrap();
2076 assert!(nodes[0].transforms.is_empty());
2077 }
2078
2079 #[test]
2080 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
2081 let yaml = r#"
2083version: 1
2084pipeline:
2085 source: { type: rest, config: {} }
2086 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
2087"#;
2088 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2089 assert!(expand(&cfg).is_ok());
2091 }
2092
2093 #[test]
2094 fn now_is_a_reserved_row_id() {
2095 let yaml = r#"
2096version: 1
2097pipeline:
2098 source: { type: rest, config: {} }
2099 sink: { type: jsonl, config: { path: ./o.jsonl } }
2100matrix:
2101 - id: now
2102"#;
2103 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2104 match expand(&cfg).unwrap_err() {
2105 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
2106 other => panic!("expected ReservedRowId, got {other:?}"),
2107 }
2108 }
2109
2110 #[test]
2111 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
2112 let yaml = r#"
2116version: 1
2117pipeline:
2118 source: { type: rest, config: {} }
2119 sink: { type: jsonl, config: { path: ./o.jsonl } }
2120execution:
2121 adaptive_batch_size:
2122 enabled: true
2123 min: 5000
2124 max: 100
2125"#;
2126 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2127 let err = expand(&cfg).unwrap_err();
2128 assert!(
2129 err.to_string().contains("adaptive_batch_size.min"),
2130 "expected adaptive validation error, got: {err}"
2131 );
2132 }
2133
2134 #[test]
2135 fn expand_accepts_valid_adaptive_batch_size() {
2136 let yaml = r#"
2137version: 1
2138pipeline:
2139 source: { type: rest, config: {} }
2140 sink: { type: jsonl, config: { path: ./o.jsonl } }
2141execution:
2142 adaptive_batch_size:
2143 enabled: true
2144 min: 100
2145 max: 5000
2146 target_latency_ms: 500
2147"#;
2148 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2149 assert!(expand(&cfg).is_ok());
2150 }
2151
2152 #[test]
2155 fn exactly_once_rejects_non_cdc_source() {
2156 let yaml = r#"
2158version: 1
2159delivery: exactly_once
2160pipeline:
2161 source: { type: rest, config: { base_url: https://x } }
2162 sink: { type: stdout, config: {} }
2163 state:
2164 type: memory
2165 config: {}
2166"#;
2167 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2168 let err = expand(&cfg).unwrap_err();
2169 match &err {
2170 CliError::Config(msg) => {
2171 assert!(
2172 msg.contains("rest"),
2173 "expected source kind in error, got: {msg}"
2174 );
2175 assert!(
2176 msg.contains("exactly_once") || msg.contains("not supported"),
2177 "got: {msg}"
2178 );
2179 }
2180 other => panic!("expected Config error, got {other:?}"),
2181 }
2182 }
2183
2184 #[test]
2185 fn exactly_once_rejects_non_idempotent_sink() {
2186 let yaml = r#"
2188version: 1
2189delivery: exactly_once
2190pipeline:
2191 source: { type: postgres-cdc, config: {} }
2192 sink: { type: stdout, config: {} }
2193 state:
2194 type: memory
2195 config: {}
2196"#;
2197 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2198 let err = expand(&cfg).unwrap_err();
2199 match &err {
2200 CliError::Config(msg) => {
2201 assert!(
2202 msg.contains("stdout"),
2203 "expected sink kind in error, got: {msg}"
2204 );
2205 assert!(
2206 msg.contains("exactly_once") || msg.contains("not supported"),
2207 "got: {msg}"
2208 );
2209 }
2210 other => panic!("expected Config error, got {other:?}"),
2211 }
2212 }
2213
2214 #[test]
2215 fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
2216 let yaml = r#"
2220version: 1
2221delivery: exactly_once
2222pipeline:
2223 source: { type: postgres-cdc, config: {} }
2224 sink: { type: sqlite, config: {} }
2225 state:
2226 type: file
2227 config: { path: "/tmp/faucet-eo-state.json" }
2228"#;
2229 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2230 let nodes = expand(&cfg).unwrap();
2231 assert_eq!(nodes.len(), 1);
2232 assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
2233 assert_eq!(
2234 nodes[0].delivery_guarantee,
2235 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2236 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2237 )
2238 );
2239 }
2240
2241 #[test]
2242 fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
2243 let yaml = r#"
2247version: 1
2248delivery: exactly_once
2249pipeline:
2250 source: { type: rest, config: { base_url: https://x } }
2251 sink:
2252 type: postgres
2253 config:
2254 connection_url: "postgres://localhost/db"
2255 table_name: t
2256 column_mapping: auto_map
2257 write_mode: upsert
2258 key: [id]
2259"#;
2260 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2261 let nodes = expand(&cfg).unwrap();
2262 assert_eq!(
2263 nodes[0].delivery_guarantee,
2264 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2265 faucet_core::EffectivelyOnceMechanism::KeyedUpsert
2266 )
2267 );
2268 }
2269
2270 #[test]
2271 fn exactly_once_kafka_source_accepted_with_atomic_sink() {
2272 let yaml = r#"
2275version: 1
2276delivery: exactly_once
2277pipeline:
2278 source:
2279 type: kafka
2280 config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
2281 sink: { type: sqlite, config: {} }
2282 state:
2283 type: file
2284 config: { path: "/tmp/faucet-eo-kafka-state.json" }
2285"#;
2286 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2287 let nodes = expand(&cfg).unwrap();
2288 assert_eq!(
2289 nodes[0].delivery_guarantee,
2290 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2291 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2292 )
2293 );
2294 }
2295
2296 #[test]
2297 fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
2298 let yaml = r#"
2301version: 1
2302delivery: exactly_once
2303pipeline:
2304 source: { type: rest, config: { base_url: https://x } }
2305 sink:
2306 type: postgres
2307 config:
2308 connection_url: "postgres://localhost/db"
2309 table_name: t
2310 column_mapping: auto_map
2311 state:
2312 type: file
2313 config: { path: "/tmp/faucet-eo-hint-state.json" }
2314"#;
2315 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2316 let err = expand(&cfg).unwrap_err();
2317 match &err {
2318 CliError::Config(msg) => assert!(
2319 msg.contains("write_mode: upsert"),
2320 "expected keyed-upsert hint, got: {msg}"
2321 ),
2322 other => panic!("expected Config error, got {other:?}"),
2323 }
2324 }
2325
2326 #[test]
2327 fn derived_guarantee_is_at_least_once_by_default() {
2328 let yaml = r#"
2329version: 1
2330pipeline:
2331 source: { type: rest, config: { base_url: https://x } }
2332 sink: { type: stdout, config: {} }
2333"#;
2334 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2335 let nodes = expand(&cfg).unwrap();
2336 assert_eq!(
2337 nodes[0].delivery_guarantee,
2338 faucet_core::DeliveryGuarantee::AtLeastOnce
2339 );
2340 }
2341
2342 #[test]
2343 fn exactly_once_rejects_memory_state() {
2344 let yaml = r#"
2347version: 1
2348delivery: exactly_once
2349pipeline:
2350 source: { type: postgres-cdc, config: {} }
2351 sink: { type: sqlite, config: {} }
2352 state:
2353 type: memory
2354 config: {}
2355"#;
2356 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2357 let err = expand(&cfg).unwrap_err();
2358 match &err {
2359 CliError::Config(msg) => assert!(
2360 msg.contains("durable") && msg.contains("memory"),
2361 "expected durable/memory mention, got: {msg}"
2362 ),
2363 other => panic!("expected Config error, got {other:?}"),
2364 }
2365 }
2366
2367 #[test]
2368 fn exactly_once_rejects_missing_state_store() {
2369 let yaml = r#"
2371version: 1
2372delivery: exactly_once
2373pipeline:
2374 source: { type: postgres-cdc, config: {} }
2375 sink: { type: sqlite, config: {} }
2376"#;
2377 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2378 let err = expand(&cfg).unwrap_err();
2379 match &err {
2380 CliError::Config(msg) => {
2381 assert!(
2382 msg.contains("state store") || msg.contains("state"),
2383 "expected state-store mention in error, got: {msg}"
2384 );
2385 }
2386 other => panic!("expected Config error, got {other:?}"),
2387 }
2388 }
2389
2390 #[test]
2391 fn rejects_upsert_on_unsupported_sink() {
2392 let c = cfg(r#"
2393version: 1
2394name: t
2395pipeline:
2396 source: { type: rest, config: { url: "http://x" } }
2397 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
2398"#);
2399 let err = expand(&c).unwrap_err();
2400 let msg = format!("{err}");
2401 assert!(
2402 msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
2403 "{msg}"
2404 );
2405 }
2406
2407 #[test]
2408 fn rejects_upsert_without_key() {
2409 let c = cfg(r#"
2410version: 1
2411name: t
2412pipeline:
2413 source: { type: rest, config: { url: "http://x" } }
2414 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
2415"#);
2416 let err = expand(&c).unwrap_err();
2417 let msg = format!("{err}");
2418 assert!(msg.contains("key"), "{msg}");
2419 }
2420
2421 #[test]
2422 fn accepts_upsert_on_postgres_with_key() {
2423 let c = cfg(r#"
2424version: 1
2425name: t
2426pipeline:
2427 source: { type: rest, config: { url: "http://x" } }
2428 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
2429"#);
2430 assert!(expand(&c).is_ok());
2431 }
2432
2433 #[test]
2434 fn bigquery_upsert_passes_write_mode_gate() {
2435 let c = cfg(r#"
2436version: 1
2437name: t
2438pipeline:
2439 source: { type: rest, config: { url: "http://x" } }
2440 sink: { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
2441"#);
2442 assert!(expand(&c).is_ok());
2443 }
2444
2445 #[test]
2446 fn accepts_append_by_default_on_any_sink() {
2447 let c = cfg(r#"
2448version: 1
2449name: t
2450pipeline:
2451 source: { type: rest, config: { url: "http://x" } }
2452 sink: { type: jsonl, config: { path: "out.jsonl" } }
2453"#);
2454 assert!(expand(&c).is_ok());
2455 }
2456
2457 #[test]
2458 fn rejects_delete_without_key() {
2459 let c = cfg(r#"
2460version: 1
2461name: t
2462pipeline:
2463 source: { type: rest, config: { url: "http://x" } }
2464 sink: { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
2465"#);
2466 let err = expand(&c).unwrap_err();
2467 let msg = format!("{err}");
2468 assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
2469 }
2470
2471 #[test]
2472 fn rejects_unknown_write_mode() {
2473 let c = cfg(r#"
2474version: 1
2475name: t
2476pipeline:
2477 source: { type: rest, config: { url: "http://x" } }
2478 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
2479"#);
2480 let err = expand(&c).unwrap_err();
2481 let msg = format!("{err}");
2482 assert!(
2483 msg.contains("unknown write_mode") && msg.contains("replace"),
2484 "{msg}"
2485 );
2486 }
2487
2488 #[test]
2489 fn rejects_poison_dlq_action_without_dlq() {
2490 let c = cfg(r#"
2491version: 1
2492pipeline:
2493 source: { type: rest, config: { base_url: https://x } }
2494 sink: { type: jsonl, config: { path: ./o } }
2495resilience:
2496 poison: { max_row_attempts: 3, action: dlq }
2497"#);
2498 let err = expand(&c).unwrap_err();
2499 assert!(
2500 matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
2501 "got: {err:?}"
2502 );
2503 }
2504
2505 #[test]
2506 fn accepts_poison_dlq_action_with_dlq() {
2507 let c = cfg(r#"
2508version: 1
2509pipeline:
2510 source: { type: rest, config: { base_url: https://x } }
2511 sink: { type: jsonl, config: { path: ./o } }
2512 dlq:
2513 sink: { type: jsonl, config: { path: ./dead.jsonl } }
2514resilience:
2515 poison: { max_row_attempts: 3, action: dlq }
2516"#);
2517 let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
2518 assert_eq!(nodes.len(), 1);
2519 }
2520
2521 #[test]
2522 fn accepts_poison_drop_action_without_dlq() {
2523 let c = cfg(r#"
2525version: 1
2526pipeline:
2527 source: { type: rest, config: { base_url: https://x } }
2528 sink: { type: jsonl, config: { path: ./o } }
2529resilience:
2530 poison: { max_row_attempts: 3, action: drop }
2531"#);
2532 let nodes = expand(&c).expect("poison.action=drop needs no dlq");
2533 assert_eq!(nodes.len(), 1);
2534 }
2535
2536 #[test]
2539 fn evolve_on_non_evolvable_sink_rejected() {
2540 let c = cfg(r#"
2542version: 1
2543pipeline:
2544 source: { type: rest, config: { base_url: https://x } }
2545 sink: { type: jsonl, config: { path: ./o.jsonl } }
2546 schema:
2547 on_drift: evolve
2548"#);
2549 let err = expand(&c).unwrap_err();
2550 match &err {
2551 CliError::Config(msg) => {
2552 assert!(
2553 msg.contains("evolve"),
2554 "expected evolve mention, got: {msg}"
2555 );
2556 assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
2557 }
2558 other => panic!("expected Config error, got {other:?}"),
2559 }
2560 }
2561
2562 #[test]
2563 fn quarantine_drift_without_dlq_rejected() {
2564 let c = cfg(r#"
2566version: 1
2567pipeline:
2568 source: { type: rest, config: { base_url: https://x } }
2569 sink: { type: postgres, config: {} }
2570 schema:
2571 on_drift: quarantine
2572"#);
2573 let err = expand(&c).unwrap_err();
2574 match &err {
2575 CliError::Config(msg) => {
2576 assert!(
2577 msg.contains("quarantine"),
2578 "expected quarantine mention, got: {msg}"
2579 );
2580 assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
2581 }
2582 other => panic!("expected Config error, got {other:?}"),
2583 }
2584 }
2585
2586 #[test]
2587 fn evolve_on_postgres_passes() {
2588 let c = cfg(r#"
2590version: 1
2591pipeline:
2592 source: { type: rest, config: { base_url: https://x } }
2593 sink: { type: postgres, config: {} }
2594 schema:
2595 on_drift: evolve
2596"#);
2597 assert!(expand(&c).is_ok());
2598 }
2599}