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",
29 "file",
30 "secret",
31 "matrix",
32 "pipeline",
33 "now",
34 "backfill",
35 "param",
36 "partition",
37 "bookmark",
38 "job_id",
39 "window",
40];
41
42#[derive(Debug, Clone)]
44pub struct ExpandedNode {
45 pub id: String,
46 pub row_index: usize,
47 pub role: NodeRole,
48 pub source: ConnectorSpec,
49 pub sink: ConnectorSpec,
50 pub transforms: Vec<TransformSpec>,
51 pub state: Option<StateStoreSpec>,
52 pub dlq: Option<crate::config::DlqSpec>,
54 #[cfg(feature = "quality")]
57 pub quality: Option<faucet_core::QualitySpec>,
58 #[cfg(feature = "contract")]
61 pub contract: Option<faucet_core::ContractSpec>,
62 #[cfg(feature = "masking")]
70 pub masking: Option<faucet_core::MaskingSpec>,
71 pub sink_ref: String,
75 pub schema: Option<faucet_core::SchemaDriftSpec>,
77 pub delivery: faucet_core::DeliveryMode,
80 pub delivery_guarantee: faucet_core::DeliveryGuarantee,
86 pub depends_on: Vec<String>,
90 pub status: crate::config::SourceStatus,
97 pub tags: Vec<String>,
101 pub deferred_refs: Vec<DeferredRef>,
105 pub source_override: Option<crate::dlq_replay::reader::SourceOverride>,
111 pub cleanup_scope: Option<std::collections::BTreeMap<String, serde_json::Value>>,
117 pub metadata_columns: Option<faucet_core::MetadataColumnsSpec>,
120}
121
122#[derive(Debug, Clone)]
123pub enum NodeRole {
124 Root,
126 Child {
128 parent_id: String,
129 parent_key: String,
130 },
131 Discovery {
142 select: String,
143 as_alias: String,
144 collect: bool,
145 dims: Vec<String>,
146 },
147 Product {
153 dims: Vec<String>,
154 collected: Vec<String>,
155 },
156}
157
158#[derive(Debug, Clone)]
159pub struct DeferredRef {
160 pub referenced_id: String,
161 pub dotted_path: String,
162 pub token: String,
163}
164
165struct Registry<'a> {
169 sources: HashMap<&'a str, &'a ConnectorSpec>,
170 sinks: HashMap<&'a str, &'a ConnectorSpec>,
171}
172
173impl<'a> Registry<'a> {
174 fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
175 let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
176 if let Some(default) = spec.source.as_ref() {
177 sources.insert("default", default);
178 }
179 for (name, s) in spec.sources.iter() {
180 if sources.contains_key(name.as_str()) {
181 return Err(CliError::DuplicateTemplate {
182 kind: "source",
183 name: name.clone(),
184 });
185 }
186 sources.insert(name.as_str(), s);
187 }
188
189 let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
190 if let Some(default) = spec.sink.as_ref() {
191 if default.transforms.is_some() {
192 return Err(CliError::TransformsOnSink {
193 name: "default".to_string(),
194 });
195 }
196 if !default.inherit_transforms {
197 return Err(CliError::InheritTransformsOnSink {
198 name: "default".to_string(),
199 });
200 }
201 sinks.insert("default", default);
202 }
203 for (name, s) in spec.sinks.iter() {
204 if sinks.contains_key(name.as_str()) {
205 return Err(CliError::DuplicateTemplate {
206 kind: "sink",
207 name: name.clone(),
208 });
209 }
210 if s.transforms.is_some() {
211 return Err(CliError::TransformsOnSink { name: name.clone() });
212 }
213 if !s.inherit_transforms {
214 return Err(CliError::InheritTransformsOnSink { name: name.clone() });
215 }
216 sinks.insert(name.as_str(), s);
217 }
218 Ok(Self { sources, sinks })
219 }
220
221 fn known(&self, kind: &'static str) -> Vec<String> {
222 debug_assert!(
223 matches!(kind, "source" | "sink"),
224 "Registry::known called with kind = {:?}",
225 kind
226 );
227 let map = if kind == "source" {
228 &self.sources
229 } else {
230 &self.sinks
231 };
232 let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
233 out.sort();
234 out
235 }
236
237 fn resolve(
238 &self,
239 kind: &'static str,
240 row_id: &str,
241 overlay: Option<&PartialConnector>,
242 ) -> CliResult<ConnectorSpec> {
243 debug_assert!(
244 matches!(kind, "source" | "sink"),
245 "Registry::resolve called with kind = {:?}",
246 kind
247 );
248 let map = if kind == "source" {
249 &self.sources
250 } else {
251 &self.sinks
252 };
253 let ref_name = overlay
254 .and_then(|p| p.r#ref.as_deref())
255 .unwrap_or("default");
256 let base = map.get(ref_name).ok_or_else(|| {
257 if ref_name == "default" {
258 CliError::MissingTemplate {
259 kind,
260 row_id: row_id.to_owned(),
261 }
262 } else {
263 CliError::UnknownTemplate {
264 kind,
265 name: ref_name.to_owned(),
266 row_id: row_id.to_owned(),
267 known: self.known(kind),
268 }
269 }
270 })?;
271 let mut out = (*base).clone();
272 if let Some(p) = overlay {
273 if let Some(k) = &p.kind {
274 out.kind = k.clone();
275 }
276 if let Some(c) = &p.config {
277 merge_value(&mut out.config, c.clone());
278 }
279 if p.status.is_some() {
283 out.status = p.status;
284 }
285 }
286 Ok(out)
287 }
288}
289
290pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
293 if let Some(ab) = cfg
298 .execution
299 .as_ref()
300 .and_then(|e| e.adaptive_batch_size.as_ref())
301 {
302 ab.validate()?;
305 }
306
307 let synthetic_row;
309 let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
310 synthetic_row = [MatrixRow {
311 id: None,
312 parent: None,
313 depends_on: Vec::new(),
314 parent_key: "id".into(),
315 source: None,
316 sink: None,
317 transforms: None,
318 inherit_transforms: true,
319 state: None,
320 dlq: None,
321 delivery: None,
322 tags: Vec::new(),
323 partition: None,
324 discover: None,
325 for_each: Vec::new(),
326 }];
327 &synthetic_row
328 } else {
329 &cfg.matrix
330 };
331
332 let mut ids: Vec<String> = Vec::with_capacity(rows.len());
334 let mut seen: HashSet<String> = HashSet::new();
335 for (i, row) in rows.iter().enumerate() {
336 let id = match &row.id {
337 Some(s) => s.clone(),
338 None => format!("row-{i}"),
339 };
340 if RESERVED_IDS.contains(&id.as_str()) {
341 return Err(CliError::ReservedRowId { id });
342 }
343 if !seen.insert(id.clone()) {
344 return Err(CliError::DuplicateRowId { id });
345 }
346 ids.push(id);
347 }
348 let capture_names: Vec<String> = collect_flow_capture_names(cfg);
353 let id_set: HashSet<&str> = ids
354 .iter()
355 .chain(capture_names.iter())
356 .map(String::as_str)
357 .collect();
358
359 let discovery_ids: HashSet<&str> = rows
363 .iter()
364 .zip(ids.iter())
365 .filter(|(row, _)| row.discover.is_some())
366 .map(|(_, id)| id.as_str())
367 .collect();
368 let collect_discovery_ids: HashSet<&str> = rows
371 .iter()
372 .zip(ids.iter())
373 .filter(|(row, _)| row.discover.as_ref().is_some_and(|d| d.collect))
374 .map(|(_, id)| id.as_str())
375 .collect();
376 for (i, row) in rows.iter().enumerate() {
377 let id = ids[i].as_str();
378 if let Some(disc) = &row.discover {
379 if !row.for_each.is_empty() && !disc.collect {
383 return Err(CliError::Config(format!(
384 "matrix row '{id}': a chained `discover:` row (with `for_each:`) must set \
385 `collect: true` — it publishes one list per upstream tuple"
386 )));
387 }
388 if disc.collect && row.for_each.is_empty() {
389 return Err(CliError::Config(format!(
390 "matrix row '{id}': `discover.collect: true` requires `for_each:` — it collects \
391 one list per upstream discovery tuple"
392 )));
393 }
394 if row.parent.is_some() {
395 return Err(CliError::Config(format!(
396 "matrix row '{id}': a `discover:` row cannot also declare `parent:`"
397 )));
398 }
399 if row.sink.is_some() {
400 return Err(CliError::Config(format!(
401 "matrix row '{id}': a `discover:` row has no sink — remove its `sink:` override"
402 )));
403 }
404 if row.transforms.is_some() {
405 return Err(CliError::Config(format!(
406 "matrix row '{id}': a `discover:` row does not run transforms"
407 )));
408 }
409 if disc.select.trim().is_empty() {
410 return Err(CliError::Config(format!(
411 "matrix row '{id}': `discover.select` must not be empty"
412 )));
413 }
414 if !is_ident(&disc.as_alias) {
415 return Err(CliError::Config(format!(
416 "matrix row '{id}': `discover.as` ('{}') must match ^[a-z0-9][a-z0-9_-]*$",
417 disc.as_alias
418 )));
419 }
420 }
421 if !row.for_each.is_empty() {
422 if row.parent.is_some() {
423 return Err(CliError::Config(format!(
424 "matrix row '{id}': `for_each:` and `parent:` cannot be combined (v1) — a row \
425 fans out over the discovery cross-product OR a parent's records, not both"
426 )));
427 }
428 let mut seen_dims: HashSet<&str> = HashSet::new();
429 for dim in &row.for_each {
430 if dim.as_str() == id {
431 return Err(CliError::Config(format!(
432 "matrix row '{id}': `for_each` cannot reference itself"
433 )));
434 }
435 if !id_set.contains(dim.as_str()) {
436 return Err(CliError::Config(format!(
437 "matrix row '{id}': `for_each` references unknown row '{dim}'"
438 )));
439 }
440 if !discovery_ids.contains(dim.as_str()) {
441 return Err(CliError::Config(format!(
442 "matrix row '{id}': `for_each` row '{dim}' is not a `discover:` row"
443 )));
444 }
445 if !seen_dims.insert(dim.as_str()) {
446 return Err(CliError::Config(format!(
447 "matrix row '{id}': `for_each` lists '{dim}' more than once"
448 )));
449 }
450 }
451 }
452 }
453
454 let mut parents: HashMap<&str, &str> = HashMap::new();
456 for (i, row) in rows.iter().enumerate() {
457 let id = ids[i].as_str();
458 if let Some(parent) = row.parent.as_deref() {
459 if !id_set.contains(parent) {
460 return Err(CliError::UnknownParent {
461 id: id.to_owned(),
462 parent: parent.to_owned(),
463 });
464 }
465 if parent == id {
466 return Err(CliError::ParentCycle {
467 ids: vec![id.to_owned()],
468 });
469 }
470 parents.insert(id, parent);
471 }
472 }
473 detect_cycle(&parents)?;
474
475 let mut deps_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
481 let mut collected_refs_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
485 for (i, row) in rows.iter().enumerate() {
486 let id = ids[i].as_str();
487 let mut deps: Vec<String> = Vec::with_capacity(row.depends_on.len());
488 for dep in &row.depends_on {
489 if !id_set.contains(dep.as_str()) {
490 return Err(CliError::UnknownDependency {
491 id: id.to_owned(),
492 depends_on: dep.clone(),
493 });
494 }
495 if dep == id {
496 return Err(CliError::DependencyCycle {
497 ids: vec![id.to_owned()],
498 });
499 }
500 if !deps.contains(dep) {
501 deps.push(dep.clone());
502 }
503 }
504 for dim in &row.for_each {
508 if !deps.contains(dim) {
509 deps.push(dim.clone());
510 }
511 }
512 let mut collected_refs: Vec<String> = Vec::new();
516 let mut refs = Vec::new();
517 if let Some(p) = &row.source
518 && let Some(c) = &p.config
519 {
520 collect_deferred(c, &mut refs);
521 }
522 if let Some(p) = &row.sink
523 && let Some(c) = &p.config
524 {
525 collect_deferred(c, &mut refs);
526 }
527 if let Some(disc) = &row.discover
528 && let Some(c) = &disc.source.config
529 {
530 collect_deferred(c, &mut refs);
531 }
532 for r in &refs {
533 if r.referenced_id == id {
534 continue;
535 }
536 if collect_discovery_ids.contains(r.referenced_id.as_str()) {
537 if !deps.contains(&r.referenced_id) {
538 deps.push(r.referenced_id.clone());
539 }
540 if !collected_refs.contains(&r.referenced_id) {
541 collected_refs.push(r.referenced_id.clone());
542 }
543 }
544 }
545 deps_by_row.push(deps);
546 collected_refs_by_row.push(collected_refs);
547 }
548 detect_combined_cycle(&ids, &parents, &deps_by_row)?;
549
550 for (i, row) in rows.iter().enumerate() {
554 let id = ids[i].as_str();
555 if let Some(p) = &row.source
556 && let Some(c) = &p.config
557 {
558 check_refs(c, &id_set, id)?;
559 }
560 if let Some(p) = &row.sink
561 && let Some(c) = &p.config
562 {
563 check_refs(c, &id_set, id)?;
564 }
565 if let Some(disc) = &row.discover
568 && let Some(c) = &disc.source.config
569 {
570 check_refs(c, &id_set, id)?;
571 }
572 }
573 if let Some(s) = &cfg.pipeline.source {
574 check_refs(&s.config, &id_set, "pipeline.source")?;
575 }
576 if let Some(s) = &cfg.pipeline.sink {
577 check_refs(&s.config, &id_set, "pipeline.sink")?;
578 }
579 for (name, s) in &cfg.pipeline.sources {
580 check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
581 }
582 for (name, s) in &cfg.pipeline.sinks {
583 check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
584 }
585
586 let registry = Registry::build(&cfg.pipeline)?;
588
589 let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
592 let mut roots: Vec<usize> = Vec::new();
593 for (i, row) in rows.iter().enumerate() {
594 match row.parent.as_deref() {
595 None => roots.push(i),
596 Some(p) => by_parent.entry(p).or_default().push(i),
597 }
598 }
599
600 let mut order: Vec<usize> = Vec::with_capacity(rows.len());
601 let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
602 while let Some(idx) = queue.pop_front() {
603 order.push(idx);
604 if let Some(children) = by_parent.get(ids[idx].as_str()) {
605 queue.extend(children.iter().copied());
606 }
607 }
608 debug_assert_eq!(order.len(), rows.len());
609
610 let mut out = Vec::with_capacity(rows.len());
611 for &i in &order {
612 let row = &rows[i];
613 let row_id = ids[i].as_str();
614
615 if let Some(disc) = &row.discover {
620 let src = if disc.source.r#ref.is_some() {
625 registry.resolve("source", row_id, Some(&disc.source))?
626 } else {
627 let kind = disc.source.kind.clone().ok_or_else(|| {
628 CliError::Config(format!(
629 "matrix row '{row_id}': `discover.source` needs a `type` (or a `ref` to a \
630 pipeline.sources template)"
631 ))
632 })?;
633 ConnectorSpec {
634 kind,
635 config: disc
636 .source
637 .config
638 .clone()
639 .unwrap_or_else(|| Value::Object(Default::default())),
640 transforms: None,
641 inherit_transforms: true,
642 status: None,
643 tags: Vec::new(),
644 complete_for: None,
645 }
646 };
647 out.push(ExpandedNode {
648 id: ids[i].clone(),
649 row_index: i,
650 role: NodeRole::Discovery {
651 select: disc.select.clone(),
652 as_alias: disc.as_alias.clone(),
653 collect: disc.collect,
654 dims: row.for_each.clone(),
655 },
656 sink: src.clone(),
658 source: src,
659 transforms: Vec::new(),
660 state: None,
661 dlq: None,
662 delivery: faucet_core::DeliveryMode::AtLeastOnce,
663 delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
664 #[cfg(feature = "quality")]
665 quality: None,
666 #[cfg(feature = "contract")]
667 contract: None,
668 #[cfg(feature = "masking")]
669 masking: None,
670 sink_ref: "default".to_string(),
671 schema: None,
672 depends_on: deps_by_row[i].clone(),
673 status: crate::config::SourceStatus::default(),
674 tags: Vec::new(),
675 deferred_refs: Vec::new(),
676 source_override: None,
677 cleanup_scope: None,
678 metadata_columns: None,
679 });
680 continue;
681 }
682
683 let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
684 let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
685 let sink_ref = row
688 .sink
689 .as_ref()
690 .and_then(|s| s.r#ref.clone())
691 .unwrap_or_else(|| "default".to_string());
692 let role = if !row.for_each.is_empty() {
693 NodeRole::Product {
698 dims: row.for_each.clone(),
699 collected: collected_refs_by_row[i].clone(),
700 }
701 } else {
702 match &row.parent {
703 None => NodeRole::Root,
704 Some(p) => NodeRole::Child {
705 parent_id: p.clone(),
706 parent_key: row.parent_key.clone(),
707 },
708 }
709 };
710 let mut deferred = Vec::new();
711 collect_deferred(&merged_source.config, &mut deferred);
712 collect_deferred(&merged_sink.config, &mut deferred);
713
714 let status = merged_source.status.unwrap_or_default();
718
719 let tags = resolve_tags(&merged_source.tags, &row.tags, row_id)?;
724
725 let src_inherit = merged_source.inherit_transforms;
730 let row_inherit = row.inherit_transforms;
731 let mut transforms: Vec<TransformSpec> = Vec::new();
732 if src_inherit && row_inherit {
733 transforms.extend(cfg.pipeline.transforms.iter().cloned());
734 }
735 if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
736 transforms.extend(src_ts.iter().cloned());
737 }
738 if let Some(row_ts) = row.transforms.as_ref() {
739 transforms.extend(row_ts.iter().cloned());
740 }
741 let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
742 let delivery = row.delivery.unwrap_or(cfg.delivery);
744 let dlq = match row.dlq.clone() {
748 Some(None) => None,
749 Some(Some(spec)) => Some(spec),
750 None => cfg.pipeline.dlq.clone(),
751 };
752
753 if let Some(ref d) = dlq {
754 if matches!(d.max_failures_per_page, Some(0)) {
755 return Err(CliError::InvalidDlqBudget {
756 field: "max_failures_per_page",
757 });
758 }
759 if matches!(d.max_failures_total, Some(0)) {
760 return Err(CliError::InvalidDlqBudget {
761 field: "max_failures_total",
762 });
763 }
764 if !crate::registry::sink_exists(&d.sink.kind) {
765 return Err(CliError::UnknownDlqSinkKind {
766 kind: d.sink.kind.clone(),
767 context: format!("row `{row_id}`"),
768 });
769 }
770 }
771
772 for (ti, t) in transforms.iter().enumerate() {
779 check_refs(
780 &t.config,
781 &id_set,
782 &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
783 )?;
784 }
785 if let Some(ref st) = state {
786 reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
787 }
788 if let Some(ref d) = dlq {
789 reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
790 }
791
792 #[cfg(feature = "quality")]
799 let quality = cfg.pipeline.quality.clone();
800 #[cfg(feature = "quality")]
801 if let Some(ref spec) = quality {
802 let compiled = faucet_core::CompiledQuality::compile(spec)
803 .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
804 if compiled.requires_dlq() && dlq.is_none() {
805 return Err(CliError::Config(format!(
806 "row `{row_id}`: a quality check uses `on_failure: quarantine` \
807 but no DLQ is configured — add a `dlq:` block (or change the \
808 check's `on_failure` to `abort`)"
809 )));
810 }
811 }
812
813 #[cfg(feature = "contract")]
818 let contract = cfg.pipeline.contract.clone();
819 #[cfg(feature = "contract")]
820 if let Some(ref spec) = contract {
821 let compiled = faucet_core::CompiledContract::compile(spec)
822 .map_err(|e| CliError::Config(format!("contract (row `{row_id}`): {e}")))?;
823 if compiled.requires_dlq() && dlq.is_none() {
824 return Err(CliError::Config(format!(
825 "row `{row_id}`: the contract uses `on_breach: quarantine` \
826 but no DLQ is configured — add a `dlq:` block (or change \
827 `on_breach` to `fail` or `warn`)"
828 )));
829 }
830 }
831
832 #[cfg(feature = "masking")]
837 let masking = cfg.pipeline.masking.clone();
838 #[cfg(feature = "masking")]
839 if let Some(ref spec) = masking {
840 faucet_core::CompiledMasking::compile(spec)
841 .map_err(|e| CliError::Config(format!("masking (row `{row_id}`): {e}")))?;
842 }
843
844 if let Some(spec) = &cfg.resilience
848 && matches!(
849 spec.poison.as_ref().map(|p| p.action),
850 Some(crate::config::PoisonActionSpec::Dlq)
851 )
852 && dlq.is_none()
853 {
854 return Err(CliError::Config(format!(
855 "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
856 )));
857 }
858
859 if let Some(ref sla) = cfg.sla {
864 sla.validate()
865 .map_err(|e| CliError::Config(format!("sla: {e}")))?;
866 if sla.needs_state() {
867 match state.as_ref() {
868 None => {
869 return Err(CliError::Config(format!(
870 "row '{row_id}': sla.max_staleness_secs / sla.volume_anomaly \
871 need persisted run history — add a `state:` block \
872 (min_rows_per_run alone works without one)"
873 )));
874 }
875 Some(s) if s.kind == "memory" => {
876 tracing::warn!(
877 row = %row_id,
878 "sla: the `memory` state store resets on process exit — \
879 staleness/volume baselines only persist within a single \
880 `faucet schedule`/`serve` process; use `file`, `redis`, \
881 or `postgres` for one-shot runs"
882 );
883 }
884 Some(_) => {}
885 }
886 }
887 }
888
889 let requested_mode = merged_sink
893 .config
894 .get("write_mode")
895 .and_then(|v| v.as_str())
896 .unwrap_or("append");
897 let mode = match requested_mode {
898 "append" => faucet_core::WriteMode::Append,
899 "upsert" => faucet_core::WriteMode::Upsert,
900 "delete" => faucet_core::WriteMode::Delete,
901 "overwrite" => faucet_core::WriteMode::Overwrite,
902 other => {
903 return Err(CliError::Config(format!(
904 "row '{}': unknown write_mode '{}' (expected append, upsert, delete, or overwrite)",
905 ids[i], other
906 )));
907 }
908 };
909 if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
910 let sinks = if matches!(mode, faucet_core::WriteMode::Overwrite) {
911 format!(
912 "overwrite sinks: {}",
913 crate::registry::OVERWRITE_SINK_KINDS.join(", ")
914 )
915 } else {
916 format!(
917 "upsert/delete sinks: {}",
918 crate::registry::UPSERT_SINK_KINDS.join(", ")
919 )
920 };
921 return Err(CliError::Config(format!(
922 "row '{}': write_mode '{}' is not supported by sink '{}' ({})",
923 ids[i], requested_mode, merged_sink.kind, sinks
924 )));
925 }
926 if matches!(
927 mode,
928 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
929 ) {
930 let key_present = merged_sink
931 .config
932 .get("key")
933 .and_then(|v| v.as_array())
934 .map(|a| !a.is_empty())
935 .unwrap_or(false);
936 if !key_present {
937 return Err(CliError::Config(format!(
938 "row '{}': write_mode '{}' requires a non-empty `key`",
939 ids[i], requested_mode
940 )));
941 }
942 }
943
944 if matches!(mode, faucet_core::WriteMode::Overwrite) {
954 if delivery == faucet_core::DeliveryMode::ExactlyOnce {
955 return Err(CliError::Config(format!(
956 "row '{}': write_mode: overwrite is incompatible with delivery: exactly_once \
957 — a full-destination replace has no per-page watermark to resume from",
958 ids[i]
959 )));
960 }
961 if let Some(ref sd) = cfg.pipeline.schema
962 && faucet_core::SchemaDriftPolicy::compile(sd).on_drift
963 == faucet_core::OnDrift::Evolve
964 {
965 return Err(CliError::Config(format!(
966 "row '{}': write_mode: overwrite is incompatible with schema.on_drift: evolve \
967 — overwrite stages into a pre-run clone of the target, so evolving the \
968 target mid-run would leave the staged data a column short at swap time",
969 ids[i]
970 )));
971 }
972 }
973
974 if let Some(scope_val) = merged_sink.config.get("scope") {
979 if !matches!(mode, faucet_core::WriteMode::Overwrite) {
980 return Err(CliError::Config(format!(
981 "row '{}': `scope` is only valid with `write_mode: overwrite`",
982 ids[i]
983 )));
984 }
985 if !crate::registry::sink_supports_scoped_overwrite(&merged_sink.kind) {
986 return Err(CliError::Config(format!(
987 "row '{}': scoped overwrite (`scope`) is not supported by sink '{}' \
988 (scoped-overwrite sinks: {})",
989 ids[i],
990 merged_sink.kind,
991 crate::registry::SCOPED_OVERWRITE_SINK_KINDS.join(", ")
992 )));
993 }
994 let scope: faucet_core::OverwriteScope = serde_json::from_value(scope_val.clone())
995 .map_err(|e| CliError::Config(format!("row '{}': invalid `scope`: {e}", ids[i])))?;
996 scope
997 .validate()
998 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
999 }
1000
1001 let keyed_upsert_configured = matches!(
1008 mode,
1009 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
1010 );
1011 let guarantee_inputs = faucet_core::GuaranteeInputs {
1012 replay: crate::registry::source_replay_guarantee(&merged_source.kind),
1013 sink_atomic: crate::registry::sink_supports_idempotent_writes(&merged_sink.kind),
1014 keyed_upsert_configured,
1015 durable_state: matches!(state.as_ref(), Some(s) if s.kind != "memory"),
1016 dlq: dlq.is_some(),
1017 };
1018 let delivery_guarantee = faucet_core::derive_delivery_guarantee(&guarantee_inputs);
1019
1020 if delivery == faucet_core::DeliveryMode::ExactlyOnce
1028 && delivery_guarantee == faucet_core::DeliveryGuarantee::AtLeastOnce
1029 {
1030 if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
1031 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
1032 {
1033 format!(
1034 ", or configure `write_mode: upsert` + `key` on sink '{}' for \
1035 keyed-upsert effectively-once with any source",
1036 merged_sink.kind
1037 )
1038 } else {
1039 String::new()
1040 };
1041 return Err(CliError::Config(format!(
1042 "row '{}': delivery: exactly_once is not supported by source '{}' \
1043 (deterministic-replay sources only: {}{})",
1044 ids[i],
1045 merged_source.kind,
1046 crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", "),
1047 keyed_hint
1048 )));
1049 }
1050 if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
1051 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
1052 {
1053 format!(
1054 "; alternatively configure `write_mode: upsert` + `key` on '{}' for \
1055 keyed-upsert effectively-once",
1056 merged_sink.kind
1057 )
1058 } else {
1059 String::new()
1060 };
1061 return Err(CliError::Config(format!(
1062 "row '{}': delivery: exactly_once is not supported by sink '{}' \
1063 (idempotent sinks only: {}{})",
1064 ids[i],
1065 merged_sink.kind,
1066 crate::registry::IDEMPOTENT_SINK_KINDS.join(", "),
1067 keyed_hint
1068 )));
1069 }
1070 match state.as_ref() {
1078 None => {
1079 return Err(CliError::Config(format!(
1080 "row '{}': delivery: exactly_once requires a state store",
1081 ids[i]
1082 )));
1083 }
1084 Some(s) if s.kind == "memory" => {
1085 return Err(CliError::Config(format!(
1086 "row '{}': delivery: exactly_once requires a durable state store, \
1087 not `memory` — the cross-restart watermark/sequence guarantee \
1088 depends on it (use `file`, `redis`, or `postgres`)",
1089 ids[i]
1090 )));
1091 }
1092 Some(_) => {}
1093 }
1094 if dlq.is_some() {
1095 return Err(CliError::Config(format!(
1096 "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
1097 ids[i]
1098 )));
1099 }
1100 unreachable!("delivery-guarantee derivation and the exactly-once gate diverged");
1104 }
1105
1106 if merged_sink.complete_for.is_some() {
1110 return Err(CliError::Config(format!(
1111 "row '{}': `complete_for` belongs on the source, not the sink — only the \
1112 source can claim a fetch returned every record for a scope",
1113 ids[i]
1114 )));
1115 }
1116 let cleanup_scope = match merged_source.complete_for.as_ref() {
1117 None => None,
1118 Some(claim) if claim.on_missing == crate::config::OnMissing::Ignore => {
1119 None
1122 }
1123 Some(claim) => {
1124 if claim.scope.is_empty() {
1125 return Err(CliError::Config(format!(
1126 "row '{}': `complete_for.scope` is empty — an empty scope matches \
1127 every row in the destination",
1128 ids[i]
1129 )));
1130 }
1131 if !crate::registry::sink_supports_cleanup(&merged_sink.kind) {
1132 return Err(CliError::Config(format!(
1133 "row '{}': `complete_for.on_missing: delete` is not supported by sink \
1134 '{}' (cleanup-capable sinks: {})",
1135 ids[i],
1136 merged_sink.kind,
1137 crate::registry::CLEANUP_SINK_KINDS.join(", ")
1138 )));
1139 }
1140 if !matches!(mode, faucet_core::WriteMode::Upsert) {
1141 return Err(CliError::Config(format!(
1142 "row '{}': `complete_for.on_missing: delete` requires \
1143 `write_mode: upsert` (got '{}') — on an append sink there is no key \
1144 to tell a written row from a stale one",
1145 ids[i], requested_mode
1146 )));
1147 }
1148 if matches!(delivery, faucet_core::DeliveryMode::ExactlyOnce) {
1152 return Err(CliError::Config(format!(
1153 "row '{}': `complete_for.on_missing: delete` is incompatible with \
1154 `delivery: exactly_once` — the scoped delete happens outside the \
1155 commit-token transaction, so it cannot be replayed idempotently",
1156 ids[i]
1157 )));
1158 }
1159 let mut quarantines: Vec<&str> = Vec::new();
1164 #[cfg(feature = "quality")]
1165 if let Some(q) = cfg.pipeline.quality.as_ref()
1166 && faucet_core::CompiledQuality::compile(q)
1167 .map(|c| c.requires_dlq())
1168 .unwrap_or(false)
1169 {
1170 quarantines.push("quality");
1171 }
1172 #[cfg(feature = "contract")]
1173 if let Some(c) = cfg.pipeline.contract.as_ref()
1174 && faucet_core::CompiledContract::compile(c)
1175 .map(|c| c.requires_dlq())
1176 .unwrap_or(false)
1177 {
1178 quarantines.push("contract");
1179 }
1180 if let Some(sd) = cfg.pipeline.schema.as_ref()
1181 && faucet_core::SchemaDriftPolicy::compile(sd).requires_dlq()
1182 {
1183 quarantines.push("schema");
1184 }
1185 if !quarantines.is_empty() {
1186 return Err(CliError::Config(format!(
1187 "row '{}': `complete_for.on_missing: delete` is incompatible with a \
1188 quarantining `{}` policy — a quarantined record never reaches the \
1189 sink, so cleanup cannot tell it from a record deleted at the source \
1190 and would delete its destination row",
1191 ids[i],
1192 quarantines.join("`/`")
1193 )));
1194 }
1195 Some(claim.scope.clone())
1196 }
1197 };
1198
1199 if let Some(ref sd) = cfg.pipeline.schema {
1204 let policy = faucet_core::SchemaDriftPolicy::compile(sd);
1205 if policy.on_drift == faucet_core::OnDrift::Evolve
1206 && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
1207 {
1208 return Err(CliError::Config(format!(
1209 "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
1210 (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
1211 ids[i], merged_sink.kind
1212 )));
1213 }
1214 if policy.requires_dlq() && dlq.is_none() {
1215 return Err(CliError::Config(format!(
1216 "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
1217 ids[i]
1218 )));
1219 }
1220 if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
1221 return Err(CliError::Config(format!(
1222 "row '{}': schema quarantine is incompatible with delivery: exactly_once \
1223 (exactly_once forbids a DLQ)",
1224 ids[i]
1225 )));
1226 }
1227 }
1228
1229 let partition_spec = row.partition.clone().or_else(|| {
1236 matches!(role, NodeRole::Root)
1240 .then(|| cfg.partition.clone())
1241 .flatten()
1242 });
1243 let chunks = match partition_spec.as_ref() {
1244 None => Vec::new(),
1245 Some(spec) => {
1246 let me = ids[i].as_str();
1251 let dependents: Vec<&str> = rows
1252 .iter()
1253 .enumerate()
1254 .filter(|(j, r)| {
1255 *j != i
1256 && (r.parent.as_deref() == Some(me)
1257 || r.depends_on.iter().any(|d| d == me))
1258 })
1259 .map(|(j, _)| ids[j].as_str())
1260 .collect();
1261 if !dependents.is_empty() {
1262 return Err(CliError::Config(format!(
1263 "row '{}': a partitioned row cannot be referenced by another row \
1264 (`parent:` or `depends_on:`) — it expands into one node per chunk, \
1265 so there is no single node for '{}' to attach to. Partition the \
1266 dependent row instead, or drop the reference",
1267 me,
1268 dependents.join("', '")
1269 )));
1270 }
1271 let serialized = merged_source.config.to_string();
1272 if !crate::partition::references_partition(&serialized) {
1273 return Err(CliError::Config(format!(
1274 "row '{}': a `partition:` block is set but the source config references \
1275 no `${{partition.*}}` token — every chunk would run the identical \
1276 query. Scope the source to the chunk (e.g. \
1277 `?id_from=${{partition.start}}&id_to=${{partition.end}}`). Available \
1278 tokens for kind `{}`: {}",
1279 ids[i],
1280 spec.kind_str(),
1281 spec.token_names().join(", ")
1282 )));
1283 }
1284 crate::partition::plan(spec)
1285 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?
1286 }
1287 };
1288 if chunks.len() >= crate::chunking::WARN_UNITS {
1289 tracing::warn!(
1290 row = %ids[i],
1291 chunks = chunks.len(),
1292 "this row plans a very large number of partitions; each is a full pipeline \
1293 invocation with its own connector clients"
1294 );
1295 }
1296
1297 let base = ExpandedNode {
1298 id: ids[i].clone(),
1299 row_index: i,
1300 role,
1301 source: merged_source,
1302 sink: merged_sink,
1303 transforms,
1304 state,
1305 dlq,
1306 delivery,
1307 delivery_guarantee,
1308 #[cfg(feature = "quality")]
1309 quality,
1310 #[cfg(feature = "contract")]
1311 contract,
1312 #[cfg(feature = "masking")]
1313 masking,
1314 sink_ref,
1315 schema: cfg.pipeline.schema.clone(),
1316 depends_on: deps_by_row[i].clone(),
1317 status,
1318 tags,
1319 deferred_refs: deferred,
1320 source_override: None,
1321 cleanup_scope,
1322 metadata_columns: cfg.metadata_columns.clone(),
1323 };
1324
1325 if chunks.is_empty() {
1326 out.push(base);
1327 } else {
1328 for chunk in &chunks {
1333 let mut n = base.clone();
1334 n.id = format!("{}::partition::{}", base.id, chunk.id);
1335 crate::partition::substitute(&mut n.source.config, chunk)
1336 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
1337 crate::partition::substitute(&mut n.sink.config, chunk)
1338 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
1339 out.push(n);
1340 }
1341 }
1342 }
1343 Ok(out)
1344}
1345
1346fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
1347 for &start in parents.keys() {
1350 let mut visited: BTreeSet<&str> = BTreeSet::new();
1351 let mut cur = start;
1352 while let Some(&p) = parents.get(cur) {
1353 if !visited.insert(cur) {
1354 let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1355 return Err(CliError::ParentCycle { ids: chain });
1356 }
1357 cur = p;
1358 if cur == start {
1359 let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1360 chain.push(start.to_string());
1361 return Err(CliError::ParentCycle { ids: chain });
1362 }
1363 }
1364 }
1365 Ok(())
1366}
1367
1368fn detect_combined_cycle(
1375 ids: &[String],
1376 parents: &HashMap<&str, &str>,
1377 deps_by_row: &[Vec<String>],
1378) -> CliResult<()> {
1379 let index_of: HashMap<&str, usize> = ids
1380 .iter()
1381 .enumerate()
1382 .map(|(i, id)| (id.as_str(), i))
1383 .collect();
1384 let mut in_degree = vec![0usize; ids.len()];
1385 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
1386 for (i, id) in ids.iter().enumerate() {
1387 let mut prereqs: Vec<usize> = Vec::new();
1388 if let Some(p) = parents.get(id.as_str()) {
1389 prereqs.push(index_of[p]);
1390 }
1391 prereqs.extend(deps_by_row[i].iter().map(|d| index_of[d.as_str()]));
1392 for p in prereqs {
1393 in_degree[i] += 1;
1394 dependents[p].push(i);
1395 }
1396 }
1397 let mut queue: std::collections::VecDeque<usize> =
1398 (0..ids.len()).filter(|&i| in_degree[i] == 0).collect();
1399 let mut processed = 0usize;
1400 while let Some(i) = queue.pop_front() {
1401 processed += 1;
1402 for &d in &dependents[i] {
1403 in_degree[d] -= 1;
1404 if in_degree[d] == 0 {
1405 queue.push_back(d);
1406 }
1407 }
1408 }
1409 if processed < ids.len() {
1410 let mut stuck: Vec<String> = (0..ids.len())
1411 .filter(|&i| in_degree[i] > 0)
1412 .map(|i| ids[i].clone())
1413 .collect();
1414 stuck.sort();
1415 return Err(CliError::DependencyCycle { ids: stuck });
1416 }
1417 Ok(())
1418}
1419
1420fn collect_flow_capture_names(cfg: &PipelineConfig) -> Vec<String> {
1429 let mut names = Vec::new();
1430 let Some(auth) = &cfg.auth else {
1431 return names;
1432 };
1433 for provider in auth.values() {
1434 if provider.get("type").and_then(Value::as_str) != Some("flow") {
1435 continue;
1436 }
1437 let Some(config) = provider.get("config") else {
1438 continue;
1439 };
1440 if let Some(steps) = config.get("steps").and_then(Value::as_array) {
1441 for step in steps {
1442 if let Some(cap) = step.get("capture").and_then(Value::as_object) {
1443 names.extend(cap.keys().cloned());
1444 }
1445 }
1446 }
1447 if let Some(apply) = config.get("apply").and_then(Value::as_array) {
1448 for a in apply {
1449 if let Some(n) = a.get("name").and_then(Value::as_str) {
1450 names.push(n.to_string());
1451 }
1452 }
1453 }
1454 }
1455 names
1456}
1457
1458fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
1459 walk_strings(value, &mut |s| {
1460 for (token, dir) in iter_directives(s) {
1461 if let Directive::Deferred { id, .. } = dir
1472 && id == crate::params::PARAM_ID
1473 {
1474 return Err(CliError::Config(format!(
1475 "interpolation token `{token}` (in {owner}) was never bound — a `${{param.*}}` \
1476 reference is resolved when the run is triggered. Load the config through \
1477 `PipelineConfig::from_path*` (or supply values with `--param`) so params are \
1478 bound before expansion"
1479 )));
1480 }
1481 if let Directive::Deferred { id, .. } = dir
1482 && id != "now"
1483 && id != "backfill"
1484 && id != "partition"
1485 && id != "bookmark"
1486 && id != "job_id"
1487 && id != "window"
1488 && !id_set.contains(id)
1489 {
1490 return Err(CliError::UnknownInterpolationId {
1491 id: id.to_owned(),
1492 token: format!("{token} (in {owner})"),
1493 });
1494 }
1495 }
1496 Ok(())
1497 })
1498}
1499
1500fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
1508 walk_strings(value, &mut |s| {
1509 for (token, dir) in iter_directives(s) {
1510 if let Directive::Deferred { .. } = dir {
1511 return Err(CliError::Config(format!(
1512 "interpolation token `{token}` in {location} is not supported: \
1513 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
1514 resolve only in source/sink configs"
1515 )));
1516 }
1517 }
1518 Ok(())
1519 })
1520}
1521
1522fn resolve_tags(
1527 template_tags: &[String],
1528 row_tags: &[String],
1529 row_id: &str,
1530) -> CliResult<Vec<String>> {
1531 let mut set: BTreeSet<String> = BTreeSet::new();
1532 for tag in template_tags.iter().chain(row_tags.iter()) {
1533 validate_tag(tag, row_id)?;
1534 set.insert(tag.clone());
1535 }
1536 Ok(set.into_iter().collect())
1537}
1538
1539fn is_ident(s: &str) -> bool {
1541 let mut chars = s.chars();
1542 match chars.next() {
1543 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
1544 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1545 }
1546 _ => false,
1547 }
1548}
1549
1550fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
1552 let ok = {
1553 let mut chars = tag.chars();
1554 match chars.next() {
1555 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
1556 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1557 }
1558 _ => false,
1559 }
1560 };
1561 if !ok {
1562 return Err(CliError::Config(format!(
1563 "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
1564 (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
1565 )));
1566 }
1567 Ok(())
1568}
1569
1570fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
1571 let _ = walk_strings(value, &mut |s| {
1572 for (token, dir) in iter_directives(s) {
1573 if let Directive::Deferred { id, path } = dir {
1574 if id == "now"
1582 || id == "backfill"
1583 || id == "partition"
1584 || id == "bookmark"
1585 || id == "job_id"
1586 || id == "window"
1587 {
1588 continue;
1589 }
1590 out.push(DeferredRef {
1591 referenced_id: id.to_owned(),
1592 dotted_path: path.to_owned(),
1593 token: token.to_owned(),
1594 });
1595 }
1596 }
1597 Ok(())
1598 });
1599}
1600
1601fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
1602where
1603 F: FnMut(&str) -> CliResult<()>,
1604{
1605 match value {
1606 Value::String(s) => f(s),
1607 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
1608 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1609 _ => Ok(()),
1610 }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use super::*;
1616 use crate::config::{OnBatchErrorSpec, parse_with_extension};
1617
1618 fn cfg(yaml: &str) -> PipelineConfig {
1619 parse_with_extension(yaml, "yaml").unwrap()
1620 }
1621
1622 #[test]
1623 fn implicit_single_row_when_matrix_absent() {
1624 let c = cfg(r#"
1625version: 1
1626pipeline:
1627 source: { type: rest, config: { base_url: https://x } }
1628 sink: { type: jsonl, config: { path: ./o } }
1629"#);
1630 let nodes = expand(&c).unwrap();
1631 assert_eq!(nodes.len(), 1);
1632 assert_eq!(nodes[0].id, "row-0");
1633 assert!(matches!(nodes[0].role, NodeRole::Root));
1634 assert_eq!(nodes[0].source.kind, "rest");
1635 assert_eq!(nodes[0].sink.kind, "jsonl");
1636 }
1637
1638 #[test]
1639 fn rejects_runtime_token_in_dlq_config() {
1640 let c = cfg(r#"
1644version: 1
1645pipeline:
1646 source: { type: rest, config: { base_url: https://x } }
1647 sink: { type: jsonl, config: { path: ./o } }
1648 dlq:
1649 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1650"#);
1651 let err = expand(&c).unwrap_err();
1652 assert!(
1653 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1654 "got: {err:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn rejects_runtime_token_in_state_config() {
1660 let c = cfg(r#"
1661version: 1
1662pipeline:
1663 source: { type: rest, config: { base_url: https://x } }
1664 sink: { type: jsonl, config: { path: ./o } }
1665 state:
1666 type: file
1667 config: { path: "state-${now.date}" }
1668"#);
1669 let err = expand(&c).unwrap_err();
1670 assert!(
1671 matches!(&err, CliError::Config(m) if m.contains("state")),
1672 "got: {err:?}"
1673 );
1674 }
1675
1676 #[test]
1677 fn allows_now_token_in_transform_config() {
1678 let c = cfg(r#"
1681version: 1
1682pipeline:
1683 source: { type: rest, config: { base_url: https://x } }
1684 sink: { type: jsonl, config: { path: ./o } }
1685 transforms:
1686 - type: set
1687 config: { values: { ts: "${now.datetime}" } }
1688"#);
1689 assert_eq!(expand(&c).unwrap().len(), 1);
1690 }
1691
1692 #[test]
1693 fn allows_reserved_builtin_tokens_in_transform_config() {
1694 let c = cfg(r#"
1698version: 1
1699pipeline:
1700 source: { type: rest, config: { base_url: https://x } }
1701 sink: { type: jsonl, config: { path: ./o } }
1702 transforms:
1703 - type: set
1704 config: { values: { a: "${now.date}", b: "${window.from}", c: "${backfill.start}", d: "${bookmark}", e: "${job_id}", f: "${partition.id}" } }
1705"#);
1706 assert_eq!(expand(&c).unwrap().len(), 1);
1707 }
1708
1709 #[test]
1710 fn rejects_unknown_id_token_in_transform_config() {
1711 let c = cfg(r#"
1714version: 1
1715pipeline:
1716 source: { type: rest, config: { base_url: https://x } }
1717 sink: { type: jsonl, config: { path: ./o } }
1718 transforms:
1719 - type: set
1720 config: { values: { who: "${nobody.name}" } }
1721"#);
1722 let err = expand(&c).unwrap_err();
1723 assert!(
1724 matches!(&err, CliError::UnknownInterpolationId { id, .. } if id == "nobody"),
1725 "got: {err:?}"
1726 );
1727 }
1728
1729 #[test]
1730 fn allows_flow_capture_token_in_source_config() {
1731 let c = cfg(r#"
1735version: 1
1736auth:
1737 intacct:
1738 type: flow
1739 config:
1740 steps:
1741 - request: { url: "https://x/login", method: POST }
1742 capture: { session_id: "$.sessionid" }
1743 apply: []
1744pipeline:
1745 source:
1746 type: xml
1747 config:
1748 base_url: "https://x"
1749 path: /gw
1750 body: "<r><sessionid>${session_id}</sessionid></r>"
1751 auth: { ref: intacct }
1752 sink: { type: jsonl, config: { path: ./o } }
1753"#);
1754 assert_eq!(expand(&c).unwrap().len(), 1);
1755 }
1756
1757 #[test]
1758 fn rejects_capture_token_without_a_declaring_flow_provider() {
1759 let c = cfg(r#"
1762version: 1
1763pipeline:
1764 source:
1765 type: xml
1766 config:
1767 base_url: "https://x"
1768 path: /gw
1769 body: "<r><sessionid>${session_id}</sessionid></r>"
1770 sink: { type: jsonl, config: { path: ./o } }
1771"#);
1772 let err = expand(&c).unwrap_err();
1773 assert!(
1774 matches!(&err, CliError::UnknownInterpolationId { id, .. } if id == "session_id"),
1775 "got: {err:?}"
1776 );
1777 }
1778
1779 #[test]
1780 fn allows_runtime_token_in_source_and_sink_configs() {
1781 let c = cfg(r#"
1784version: 1
1785pipeline:
1786 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1787 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1788"#);
1789 let nodes = expand(&c).unwrap();
1790 assert_eq!(nodes.len(), 1);
1791 }
1792
1793 #[test]
1794 fn merges_row_overrides_into_pipeline_source() {
1795 let c = cfg(r#"
1796version: 1
1797pipeline:
1798 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1799 sink: { type: jsonl, config: { path: ./o } }
1800matrix:
1801 - id: users
1802 source: { config: { path: /v1/users, headers: { b: 2 } } }
1803"#);
1804 let nodes = expand(&c).unwrap();
1805 assert_eq!(nodes[0].id, "users");
1806 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1807 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1808 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1809 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1810 }
1811
1812 #[test]
1813 fn errors_on_unknown_parent() {
1814 let c = cfg(r#"
1815version: 1
1816pipeline:
1817 source: { type: rest, config: {} }
1818 sink: { type: jsonl, config: { path: ./o } }
1819matrix:
1820 - id: child
1821 parent: nobody
1822"#);
1823 assert!(matches!(
1824 expand(&c).unwrap_err(),
1825 CliError::UnknownParent { .. }
1826 ));
1827 }
1828
1829 #[test]
1830 fn errors_on_duplicate_ids() {
1831 let c = cfg(r#"
1832version: 1
1833pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1834matrix:
1835 - { id: x }
1836 - { id: x }
1837"#);
1838 assert!(matches!(
1839 expand(&c).unwrap_err(),
1840 CliError::DuplicateRowId { .. }
1841 ));
1842 }
1843
1844 #[test]
1845 fn errors_on_reserved_id() {
1846 let c = cfg(r#"
1847version: 1
1848pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1849matrix:
1850 - { id: env }
1851"#);
1852 assert!(matches!(
1853 expand(&c).unwrap_err(),
1854 CliError::ReservedRowId { .. }
1855 ));
1856 }
1857
1858 #[test]
1859 fn bookmark_token_is_reserved_and_passes_through() {
1860 let c = cfg(r#"
1864version: 1
1865pipeline:
1866 source:
1867 type: rest
1868 config:
1869 base_url: https://x
1870 replication_bind: { into: query, name: since, template: "gt ${bookmark}" }
1871 sink: { type: jsonl, config: { path: ./o } }
1872"#);
1873 let nodes = expand(&c).unwrap();
1874 assert_eq!(nodes.len(), 1);
1875 }
1876
1877 #[test]
1878 fn job_id_token_is_reserved_and_passes_through() {
1879 let c = cfg(r#"
1882version: 1
1883pipeline:
1884 source:
1885 type: rest
1886 config:
1887 base_url: https://x
1888 async_job: { submit: { url: /jobs }, job_id: "$.id", poll: { url: "/jobs/${job_id}" }, status: { path: "$.s", success: [Done] }, fetch: { url: "/jobs/${job_id}/r" } }
1889 sink: { type: jsonl, config: { path: ./o } }
1890"#);
1891 let nodes = expand(&c).unwrap();
1892 assert_eq!(nodes.len(), 1);
1893 }
1894
1895 #[test]
1896 fn window_token_is_reserved_and_passes_through() {
1897 let c = cfg(r#"
1900version: 1
1901pipeline:
1902 source:
1903 type: rest
1904 config:
1905 base_url: https://x
1906 path: /report
1907 replication_method: incremental
1908 replication_key: updated_at
1909 start_replication_value: "2024-01-01"
1910 window:
1911 step: 30d
1912 lower: { into: query, name: start_date, template: "${window}", format: date }
1913 upper: { into: query, name: end_date, template: "${window}", format: date }
1914 sink: { type: jsonl, config: { path: ./o } }
1915"#);
1916 let nodes = expand(&c).unwrap();
1917 assert_eq!(nodes.len(), 1);
1918 }
1919
1920 #[test]
1921 fn errors_on_self_parent_cycle() {
1922 let c = cfg(r#"
1923version: 1
1924pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1925matrix:
1926 - { id: a, parent: a }
1927"#);
1928 assert!(matches!(
1929 expand(&c).unwrap_err(),
1930 CliError::ParentCycle { .. }
1931 ));
1932 }
1933
1934 #[test]
1935 fn errors_on_two_node_cycle() {
1936 let c = cfg(r#"
1937version: 1
1938pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1939matrix:
1940 - { id: a, parent: b }
1941 - { id: b, parent: a }
1942"#);
1943 assert!(matches!(
1944 expand(&c).unwrap_err(),
1945 CliError::ParentCycle { .. }
1946 ));
1947 }
1948
1949 #[test]
1950 fn errors_on_unknown_dependency() {
1951 let c = cfg(r#"
1952version: 1
1953pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1954matrix:
1955 - { id: facts, depends_on: [nobody] }
1956"#);
1957 match expand(&c).unwrap_err() {
1958 CliError::UnknownDependency { id, depends_on } => {
1959 assert_eq!(id, "facts");
1960 assert_eq!(depends_on, "nobody");
1961 }
1962 other => panic!("expected UnknownDependency, got {other:?}"),
1963 }
1964 }
1965
1966 #[test]
1967 fn errors_on_self_dependency() {
1968 let c = cfg(r#"
1969version: 1
1970pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1971matrix:
1972 - { id: a, depends_on: [a] }
1973"#);
1974 match expand(&c).unwrap_err() {
1975 CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1976 other => panic!("expected DependencyCycle, got {other:?}"),
1977 }
1978 }
1979
1980 #[test]
1981 fn errors_on_depends_on_cycle() {
1982 let c = cfg(r#"
1983version: 1
1984pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1985matrix:
1986 - { id: a, depends_on: [b] }
1987 - { id: b, depends_on: [a] }
1988"#);
1989 match expand(&c).unwrap_err() {
1990 CliError::DependencyCycle { ids } => {
1991 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1992 }
1993 other => panic!("expected DependencyCycle, got {other:?}"),
1994 }
1995 }
1996
1997 #[test]
1998 fn errors_on_mixed_parent_depends_on_cycle() {
1999 let c = cfg(r#"
2003version: 1
2004pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2005matrix:
2006 - { id: a, parent: b }
2007 - { id: b, depends_on: [a] }
2008"#);
2009 match expand(&c).unwrap_err() {
2010 CliError::DependencyCycle { ids } => {
2011 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
2012 }
2013 other => panic!("expected DependencyCycle, got {other:?}"),
2014 }
2015 }
2016
2017 #[test]
2018 fn depends_on_is_recorded_and_deduped() {
2019 let c = cfg(r#"
2020version: 1
2021pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2022matrix:
2023 - { id: dims }
2024 - { id: staging }
2025 - { id: facts, depends_on: [dims, staging, dims] }
2026"#);
2027 let nodes = expand(&c).unwrap();
2028 let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
2029 assert_eq!(
2030 facts.depends_on,
2031 vec!["dims".to_string(), "staging".to_string()]
2032 );
2033 assert!(matches!(facts.role, NodeRole::Root));
2034 let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
2035 assert!(dims.depends_on.is_empty());
2036 }
2037
2038 fn disc_cfg(matrix: &str) -> String {
2041 format!(
2042 r#"
2043version: 1
2044pipeline: {{ source: {{ type: rest, config: {{}} }}, sink: {{ type: jsonl, config: {{ path: ./o }} }} }}
2045matrix:
2046{matrix}
2047"#
2048 )
2049 }
2050
2051 #[test]
2052 fn discovery_and_product_roles_are_assigned() {
2053 let c = cfg(&disc_cfg(
2054 r#" - id: subs
2055 discover:
2056 source: { type: rest, config: {} }
2057 select: "$.id"
2058 as: subsidiary_id
2059 - id: report
2060 for_each: [subs]"#,
2061 ));
2062 let nodes = expand(&c).unwrap();
2063 let subs = nodes.iter().find(|n| n.id == "subs").unwrap();
2064 match &subs.role {
2065 NodeRole::Discovery {
2066 select, as_alias, ..
2067 } => {
2068 assert_eq!(select, "$.id");
2069 assert_eq!(as_alias, "subsidiary_id");
2070 }
2071 other => panic!("expected Discovery, got {other:?}"),
2072 }
2073 let report = nodes.iter().find(|n| n.id == "report").unwrap();
2074 match &report.role {
2075 NodeRole::Product { dims, .. } => assert_eq!(dims, &vec!["subs".to_string()]),
2076 other => panic!("expected Product, got {other:?}"),
2077 }
2078 assert_eq!(report.depends_on, vec!["subs".to_string()]);
2080 }
2081
2082 #[test]
2083 fn chained_discover_without_collect_is_rejected() {
2084 let c = cfg(&disc_cfg(
2086 r#" - id: types
2087 discover: { source: { type: rest, config: {} }, select: "$.name", as: name }
2088 - id: props
2089 for_each: [types]
2090 discover: { source: { type: rest, config: {} }, select: "$.name", as: name }"#,
2091 ));
2092 let err = expand(&c).unwrap_err().to_string();
2093 assert!(err.contains("collect: true"), "{err}");
2094 }
2095
2096 #[test]
2097 fn collect_without_for_each_is_rejected() {
2098 let c = cfg(&disc_cfg(
2100 r#" - id: types
2101 discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }"#,
2102 ));
2103 let err = expand(&c).unwrap_err().to_string();
2104 assert!(err.contains("requires `for_each:`"), "{err}");
2105 }
2106
2107 #[test]
2108 fn chained_discovery_roles_and_deps_are_wired() {
2109 let c = cfg(&disc_cfg(
2111 r#" - id: types
2112 discover: { source: { type: rest, config: {} }, select: "$.name", as: name }
2113 - id: props
2114 for_each: [types]
2115 discover:
2116 source: { type: rest, config: { path: "/props/${types.name}" } }
2117 select: "$.name"
2118 as: name
2119 collect: true
2120 - id: records
2121 for_each: [types]
2122 source: { type: rest, config: { path: "/obj/${types.name}", query_params: { properties: "${props.name}" } } }"#,
2123 ));
2124 let nodes = expand(&c).unwrap();
2125 let props = nodes.iter().find(|n| n.id == "props").unwrap();
2127 match &props.role {
2128 NodeRole::Discovery { collect, dims, .. } => {
2129 assert!(*collect);
2130 assert_eq!(dims, &vec!["types".to_string()]);
2131 }
2132 other => panic!("expected chained Discovery, got {other:?}"),
2133 }
2134 assert_eq!(props.depends_on, vec!["types".to_string()]);
2135 let records = nodes.iter().find(|n| n.id == "records").unwrap();
2137 match &records.role {
2138 NodeRole::Product { dims, collected } => {
2139 assert_eq!(dims, &vec!["types".to_string()]);
2140 assert_eq!(collected, &vec!["props".to_string()]);
2141 }
2142 other => panic!("expected Product, got {other:?}"),
2143 }
2144 assert!(records.depends_on.contains(&"types".to_string()));
2146 assert!(records.depends_on.contains(&"props".to_string()));
2147 }
2148
2149 #[test]
2150 fn chained_discovery_cycle_is_rejected() {
2151 let c = cfg(&disc_cfg(
2153 r#" - id: a
2154 for_each: [b]
2155 discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }
2156 - id: b
2157 for_each: [a]
2158 discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }"#,
2159 ));
2160 let err = expand(&c).unwrap_err().to_string();
2161 assert!(err.to_lowercase().contains("cycle"), "{err}");
2162 }
2163
2164 #[test]
2165 fn discover_with_sink_is_rejected() {
2166 let c = cfg(&disc_cfg(
2167 r#" - id: a
2168 discover: { source: { type: rest, config: {} }, select: "$.id", as: x }
2169 sink: { type: jsonl, config: { path: ./o } }"#,
2170 ));
2171 let err = expand(&c).unwrap_err().to_string();
2172 assert!(err.contains("has no sink"), "{err}");
2173 }
2174
2175 #[test]
2176 fn for_each_on_non_discovery_row_is_rejected() {
2177 let c = cfg(&disc_cfg(
2178 r#" - id: plain
2179 - id: report
2180 for_each: [plain]"#,
2181 ));
2182 let err = expand(&c).unwrap_err().to_string();
2183 assert!(err.contains("is not a `discover:` row"), "{err}");
2184 }
2185
2186 #[test]
2187 fn for_each_unknown_row_is_rejected() {
2188 let c = cfg(&disc_cfg(
2189 r#" - id: report
2190 for_each: [ghost]"#,
2191 ));
2192 let err = expand(&c).unwrap_err().to_string();
2193 assert!(err.contains("unknown row 'ghost'"), "{err}");
2194 }
2195
2196 #[test]
2197 fn for_each_with_parent_is_rejected() {
2198 let c = cfg(&disc_cfg(
2199 r#" - id: subs
2200 discover: { source: { type: rest, config: {} }, select: "$.id", as: x }
2201 - id: p
2202 - id: report
2203 parent: p
2204 for_each: [subs]"#,
2205 ));
2206 let err = expand(&c).unwrap_err().to_string();
2207 assert!(err.contains("cannot be combined"), "{err}");
2208 }
2209
2210 #[test]
2211 fn discover_bad_alias_is_rejected() {
2212 let c = cfg(&disc_cfg(
2213 r#" - id: a
2214 discover: { source: { type: rest, config: {} }, select: "$.id", as: "Bad Alias" }"#,
2215 ));
2216 let err = expand(&c).unwrap_err().to_string();
2217 assert!(err.contains("must match"), "{err}");
2218 }
2219
2220 #[test]
2221 fn discovery_source_ref_resolves_named_template() {
2222 let c = cfg(r#"
2223version: 1
2224pipeline:
2225 sources:
2226 api: { type: rest, config: { base_url: https://x, path: /list } }
2227 sink: { type: jsonl, config: { path: ./o } }
2228matrix:
2229 - id: subs
2230 discover:
2231 source: { ref: api, config: { path: /subsidiaries } }
2232 select: "$.id"
2233 as: sid
2234 - id: report
2235 for_each: [subs]
2236 source: { ref: api }
2237"#);
2238 let nodes = expand(&c).unwrap();
2239 let subs = nodes.iter().find(|n| n.id == "subs").unwrap();
2240 assert_eq!(subs.source.kind, "rest");
2242 assert_eq!(subs.source.config["path"], "/subsidiaries");
2243 assert_eq!(subs.source.config["base_url"], "https://x");
2244 }
2245
2246 #[test]
2247 fn depends_on_may_target_a_child_row() {
2248 let c = cfg(r#"
2251version: 1
2252pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2253matrix:
2254 - { id: users }
2255 - { id: posts, parent: users }
2256 - { id: rollup, depends_on: [posts] }
2257"#);
2258 let nodes = expand(&c).unwrap();
2259 let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
2260 assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
2261 }
2262
2263 #[test]
2264 fn errors_on_unknown_interpolation_id() {
2265 let c = cfg(r#"
2266version: 1
2267pipeline:
2268 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
2269 sink: { type: jsonl, config: { path: ./o } }
2270"#);
2271 assert!(matches!(
2272 expand(&c).unwrap_err(),
2273 CliError::UnknownInterpolationId { .. }
2274 ));
2275 }
2276
2277 #[test]
2278 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
2279 let c = cfg(r#"
2284version: 1
2285pipeline:
2286 source: { type: rest, config: { url: "https://x/${env.foo}" } }
2287 sink: { type: jsonl, config: { path: ./o } }
2288"#);
2289 match expand(&c).unwrap_err() {
2290 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
2291 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
2292 }
2293 }
2294
2295 #[test]
2296 fn accepts_id_path_when_referenced_row_exists() {
2297 let c = cfg(r#"
2298version: 1
2299pipeline:
2300 source: { type: rest, config: {} }
2301 sink: { type: jsonl, config: { path: ./o } }
2302matrix:
2303 - id: users
2304 - id: posts
2305 parent: users
2306 source: { config: { path: "/v1/users/${users.id}/posts" } }
2307"#);
2308 let nodes = expand(&c).unwrap();
2309 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
2310 assert_eq!(posts.deferred_refs.len(), 1);
2311 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
2312 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
2313 }
2314
2315 #[test]
2316 fn nested_referenced_path_resolves() {
2317 let c = cfg(r#"
2318version: 1
2319pipeline:
2320 source: { type: rest, config: {} }
2321 sink: { type: jsonl, config: { path: ./o } }
2322matrix:
2323 - id: users
2324 - id: addrs
2325 parent: users
2326 source: { config: { path: "/users/${users.addr.city}/addr" } }
2327"#);
2328 let nodes = expand(&c).unwrap();
2329 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
2330 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
2331 }
2332
2333 #[test]
2334 fn roots_come_before_children_in_order() {
2335 let c = cfg(r#"
2336version: 1
2337pipeline:
2338 source: { type: rest, config: {} }
2339 sink: { type: jsonl, config: { path: ./o } }
2340matrix:
2341 - id: posts
2342 parent: users
2343 - id: users
2344"#);
2345 let nodes = expand(&c).unwrap();
2346 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
2347 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
2348 assert!(users_idx < posts_idx, "users must precede posts");
2349 }
2350
2351 #[test]
2352 fn child_node_has_parent_role() {
2353 let c = cfg(r#"
2354version: 1
2355pipeline:
2356 source: { type: rest, config: {} }
2357 sink: { type: jsonl, config: { path: ./o } }
2358matrix:
2359 - id: users
2360 - id: posts
2361 parent: users
2362 parent_key: user_id
2363"#);
2364 let nodes = expand(&c).unwrap();
2365 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
2366 match &posts.role {
2367 NodeRole::Child {
2368 parent_id,
2369 parent_key,
2370 } => {
2371 assert_eq!(parent_id, "users");
2372 assert_eq!(parent_key, "user_id");
2373 }
2374 other => panic!("expected Child, got {other:?}"),
2375 }
2376 }
2377
2378 #[test]
2379 fn expand_rejects_zero_per_page_budget() {
2380 let yaml = r#"
2381version: 1
2382pipeline:
2383 source: { type: rest, config: {} }
2384 sink: { type: jsonl, config: { path: ./o.jsonl } }
2385 dlq:
2386 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2387 max_failures_per_page: 0
2388"#;
2389 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2390 let err = expand(&cfg).unwrap_err();
2391 assert!(matches!(
2392 err,
2393 CliError::InvalidDlqBudget {
2394 field: "max_failures_per_page"
2395 }
2396 ));
2397 }
2398
2399 #[test]
2400 fn expand_rejects_zero_total_budget() {
2401 let yaml = r#"
2402version: 1
2403pipeline:
2404 source: { type: rest, config: {} }
2405 sink: { type: jsonl, config: { path: ./o.jsonl } }
2406 dlq:
2407 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2408 max_failures_total: 0
2409"#;
2410 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2411 let err = expand(&cfg).unwrap_err();
2412 assert!(matches!(
2413 err,
2414 CliError::InvalidDlqBudget {
2415 field: "max_failures_total"
2416 }
2417 ));
2418 }
2419
2420 #[test]
2421 fn expand_rejects_unknown_dlq_sink_kind() {
2422 let yaml = r#"
2423version: 1
2424pipeline:
2425 source: { type: rest, config: {} }
2426 sink: { type: jsonl, config: { path: ./o.jsonl } }
2427 dlq:
2428 sink: { type: not_a_sink, config: {} }
2429"#;
2430 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2431 let err = expand(&cfg).unwrap_err();
2432 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
2433 }
2434
2435 #[cfg(feature = "quality")]
2436 #[test]
2437 fn expand_rejects_quarantine_without_dlq() {
2438 let yaml = r#"
2441version: 1
2442pipeline:
2443 source: { type: rest, config: {} }
2444 sink: { type: jsonl, config: { path: ./o.jsonl } }
2445 quality:
2446 record:
2447 - { type: not_null, field: id, on_failure: quarantine }
2448"#;
2449 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2450 let err = expand(&cfg).unwrap_err();
2451 match err {
2452 CliError::Config(msg) => {
2453 assert!(msg.contains("quarantine"), "{msg}");
2454 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
2455 }
2456 other => panic!("expected Config error, got {other:?}"),
2457 }
2458 }
2459
2460 #[cfg(feature = "quality")]
2461 #[test]
2462 fn expand_accepts_quarantine_with_dlq() {
2463 let yaml = r#"
2464version: 1
2465pipeline:
2466 source: { type: rest, config: {} }
2467 sink: { type: jsonl, config: { path: ./o.jsonl } }
2468 dlq:
2469 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2470 quality:
2471 record:
2472 - { type: not_null, field: id, on_failure: quarantine }
2473"#;
2474 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2475 let nodes = expand(&cfg).unwrap();
2476 assert_eq!(nodes.len(), 1);
2477 let q = nodes[0]
2478 .quality
2479 .as_ref()
2480 .expect("quality threaded onto node");
2481 assert_eq!(q.record.len(), 1);
2482 }
2483
2484 #[cfg(feature = "quality")]
2485 #[test]
2486 fn expand_accepts_abort_quality_without_dlq() {
2487 let yaml = r#"
2489version: 1
2490pipeline:
2491 source: { type: rest, config: {} }
2492 sink: { type: jsonl, config: { path: ./o.jsonl } }
2493 quality:
2494 record:
2495 - { type: not_null, field: id, on_failure: abort }
2496"#;
2497 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2498 let nodes = expand(&cfg).unwrap();
2499 assert!(nodes[0].quality.is_some());
2500 }
2501
2502 #[cfg(feature = "contract")]
2503 #[test]
2504 fn expand_rejects_contract_quarantine_without_dlq() {
2505 let yaml = r#"
2506version: 1
2507pipeline:
2508 source: { type: rest, config: {} }
2509 sink: { type: jsonl, config: { path: ./o.jsonl } }
2510 contract:
2511 version: "1.0.0"
2512 on_breach: quarantine
2513 fields:
2514 - { name: id, type: integer }
2515"#;
2516 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2517 let err = expand(&cfg).unwrap_err();
2518 match err {
2519 CliError::Config(msg) => {
2520 assert!(msg.contains("on_breach: quarantine"), "{msg}");
2521 assert!(msg.contains("dlq"), "{msg}");
2522 }
2523 other => panic!("expected Config error, got {other:?}"),
2524 }
2525 }
2526
2527 #[cfg(feature = "contract")]
2528 #[test]
2529 fn expand_accepts_contract_quarantine_with_dlq() {
2530 let yaml = r#"
2531version: 1
2532pipeline:
2533 source: { type: rest, config: {} }
2534 sink: { type: jsonl, config: { path: ./o.jsonl } }
2535 dlq:
2536 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2537 contract:
2538 version: "1.0.0"
2539 on_breach: quarantine
2540 fields:
2541 - { name: id, type: integer }
2542"#;
2543 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2544 let nodes = expand(&cfg).unwrap();
2545 assert_eq!(nodes.len(), 1);
2546 let c = nodes[0]
2547 .contract
2548 .as_ref()
2549 .expect("contract threaded onto node");
2550 assert_eq!(c.version, "1.0.0");
2551 assert_eq!(c.fields.len(), 1);
2552 }
2553
2554 #[cfg(feature = "contract")]
2555 #[test]
2556 fn expand_accepts_contract_fail_without_dlq() {
2557 let yaml = r#"
2559version: 1
2560pipeline:
2561 source: { type: rest, config: {} }
2562 sink: { type: jsonl, config: { path: ./o.jsonl } }
2563 contract:
2564 version: "1.0.0"
2565 fields:
2566 - { name: id, type: integer }
2567"#;
2568 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2569 let nodes = expand(&cfg).unwrap();
2570 assert!(nodes[0].contract.is_some());
2571 }
2572
2573 #[cfg(feature = "contract")]
2574 #[test]
2575 fn expand_rejects_malformed_contract() {
2576 let yaml = r#"
2578version: 1
2579pipeline:
2580 source: { type: rest, config: {} }
2581 sink: { type: jsonl, config: { path: ./o.jsonl } }
2582 contract:
2583 version: "1.0.0"
2584 fields:
2585 - { name: email, type: string, pattern: "[invalid" }
2586"#;
2587 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2588 let err = expand(&cfg).unwrap_err();
2589 match err {
2590 CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
2591 other => panic!("expected Config error, got {other:?}"),
2592 }
2593 }
2594
2595 #[test]
2596 fn legacy_singular_source_resolves_as_default_template() {
2597 let c = cfg(r#"
2598version: 1
2599pipeline:
2600 source: { type: rest, config: { base_url: https://x } }
2601 sink: { type: jsonl, config: { path: ./o } }
2602"#);
2603 let nodes = expand(&c).unwrap();
2604 assert_eq!(nodes[0].source.kind, "rest");
2605 assert_eq!(nodes[0].source.config["base_url"], "https://x");
2606 }
2607
2608 #[test]
2609 fn row_with_ref_picks_named_template() {
2610 let c = cfg(r#"
2611version: 1
2612pipeline:
2613 sources:
2614 users_api: { type: rest, config: { base_url: https://x } }
2615 sinks:
2616 archive: { type: jsonl, config: { path: ./out } }
2617matrix:
2618 - id: load_users
2619 source:
2620 ref: users_api
2621 config: { path: /v1/users }
2622 sink:
2623 ref: archive
2624 config: { path: ./users.jsonl }
2625"#);
2626 let nodes = expand(&c).unwrap();
2627 assert_eq!(nodes[0].source.kind, "rest");
2628 assert_eq!(nodes[0].source.config["base_url"], "https://x");
2629 assert_eq!(nodes[0].source.config["path"], "/v1/users");
2630 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
2631 }
2632
2633 #[test]
2634 fn row_without_ref_falls_back_to_default_template() {
2635 let c = cfg(r#"
2636version: 1
2637pipeline:
2638 source: { type: rest, config: { base_url: https://x } }
2639 sink: { type: jsonl, config: { path: ./o } }
2640matrix:
2641 - id: users
2642 source: { config: { path: /v1/users } }
2643"#);
2644 let nodes = expand(&c).unwrap();
2645 assert_eq!(nodes[0].source.kind, "rest");
2646 assert_eq!(nodes[0].source.config["path"], "/v1/users");
2647 }
2648
2649 #[test]
2650 fn unknown_template_ref_errors_with_known_list() {
2651 let c = cfg(r#"
2652version: 1
2653pipeline:
2654 sources:
2655 a: { type: rest, config: {} }
2656 b: { type: rest, config: {} }
2657 sinks:
2658 s: { type: jsonl, config: { path: ./o } }
2659matrix:
2660 - id: x
2661 source: { ref: c }
2662 sink: { ref: s }
2663"#);
2664 let err = expand(&c).unwrap_err();
2665 match err {
2666 CliError::UnknownTemplate {
2667 kind,
2668 name,
2669 row_id,
2670 known,
2671 } => {
2672 assert_eq!(kind, "source");
2673 assert_eq!(name, "c");
2674 assert_eq!(row_id, "x");
2675 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
2676 }
2677 other => panic!("expected UnknownTemplate, got {other:?}"),
2678 }
2679 }
2680
2681 #[test]
2682 fn missing_default_template_errors() {
2683 let c = cfg(r#"
2686version: 1
2687pipeline:
2688 sources:
2689 users_api: { type: rest, config: {} }
2690 sink: { type: jsonl, config: { path: ./o } }
2691matrix:
2692 - id: x
2693 source: { config: { path: /v1 } }
2694"#);
2695 let err = expand(&c).unwrap_err();
2696 match err {
2697 CliError::MissingTemplate { kind, row_id } => {
2698 assert_eq!(kind, "source");
2699 assert_eq!(row_id, "x");
2700 }
2701 other => panic!("expected MissingTemplate, got {other:?}"),
2702 }
2703 }
2704
2705 #[test]
2706 fn duplicate_default_template_errors() {
2707 let c = cfg(r#"
2709version: 1
2710pipeline:
2711 source: { type: rest, config: {} }
2712 sources:
2713 default: { type: rest, config: {} }
2714 sink: { type: jsonl, config: { path: ./o } }
2715"#);
2716 let err = expand(&c).unwrap_err();
2717 match err {
2718 CliError::DuplicateTemplate { kind, name } => {
2719 assert_eq!(kind, "source");
2720 assert_eq!(name, "default");
2721 }
2722 other => panic!("expected DuplicateTemplate, got {other:?}"),
2723 }
2724 }
2725
2726 #[test]
2727 fn row_can_override_template_kind() {
2728 let c = cfg(r#"
2729version: 1
2730pipeline:
2731 sources:
2732 api: { type: rest, config: { base_url: https://x } }
2733 sinks:
2734 out: { type: jsonl, config: { path: ./o } }
2735matrix:
2736 - id: x
2737 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
2738 sink: { ref: out }
2739"#);
2740 let nodes = expand(&c).unwrap();
2741 assert_eq!(nodes[0].source.kind, "graphql");
2742 assert_eq!(nodes[0].source.config["base_url"], "https://x");
2743 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
2744 }
2745
2746 #[test]
2747 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
2748 let yaml = r#"
2749version: 1
2750pipeline:
2751 source: { type: rest, config: {} }
2752 sink: { type: jsonl, config: { path: ./o.jsonl } }
2753 dlq:
2754 sink: { type: jsonl, config: { path: ./base.jsonl } }
2755matrix:
2756 - id: a
2757 - id: b
2758 dlq: null
2759 - id: c
2760 dlq:
2761 sink: { type: jsonl, config: { path: ./c.jsonl } }
2762 on_batch_error: dlq_all
2763"#;
2764 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2765 let nodes = expand(&cfg).unwrap();
2766 assert_eq!(nodes.len(), 3);
2767 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
2769 assert_eq!(
2770 nodes[0]
2771 .dlq
2772 .as_ref()
2773 .unwrap()
2774 .sink
2775 .config
2776 .get("path")
2777 .unwrap(),
2778 "./base.jsonl"
2779 );
2780 assert!(nodes[1].dlq.is_none());
2782 assert_eq!(
2784 nodes[2].dlq.as_ref().unwrap().on_batch_error,
2785 OnBatchErrorSpec::DlqAll
2786 );
2787 assert_eq!(
2788 nodes[2]
2789 .dlq
2790 .as_ref()
2791 .unwrap()
2792 .sink
2793 .config
2794 .get("path")
2795 .unwrap(),
2796 "./c.jsonl"
2797 );
2798 }
2799
2800 #[test]
2801 fn multiple_rows_pick_different_templates() {
2802 let c = cfg(r#"
2803version: 1
2804pipeline:
2805 sources:
2806 users_api: { type: rest, config: { base_url: https://users.example } }
2807 orders_api: { type: rest, config: { base_url: https://orders.example } }
2808 sinks:
2809 archive: { type: jsonl, config: { path: ./out } }
2810matrix:
2811 - id: load_users
2812 source: { ref: users_api, config: { path: /v1/users } }
2813 sink: { ref: archive, config: { path: ./users.jsonl } }
2814 - id: load_orders
2815 source: { ref: orders_api, config: { path: /v1/orders } }
2816 sink: { ref: archive, config: { path: ./orders.jsonl } }
2817"#);
2818 let nodes = expand(&c).unwrap();
2819 assert_eq!(nodes.len(), 2);
2820 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
2821 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
2822 assert_eq!(users.source.config["base_url"], "https://users.example");
2823 assert_eq!(users.source.config["path"], "/v1/users");
2824 assert_eq!(orders.source.config["base_url"], "https://orders.example");
2825 assert_eq!(orders.source.config["path"], "/v1/orders");
2826 assert_eq!(users.sink.config["path"], "./users.jsonl");
2828 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
2829 }
2830
2831 #[test]
2832 fn sink_template_with_transforms_errors_at_expand() {
2833 let yaml = r#"
2834version: 1
2835pipeline:
2836 source:
2837 type: rest
2838 config: {}
2839 sinks:
2840 bad:
2841 type: jsonl
2842 config: { destination: /tmp/x.jsonl }
2843 transforms:
2844 - { type: flatten, config: { separator: "_" } }
2845matrix:
2846 - id: row
2847 sink: { ref: bad }
2848"#;
2849 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2850 .unwrap();
2851 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
2852 match err {
2853 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
2854 other => panic!("expected TransformsOnSink, got {other:?}"),
2855 }
2856 }
2857
2858 #[test]
2859 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
2860 let yaml = r#"
2861version: 1
2862pipeline:
2863 source:
2864 type: rest
2865 config: {}
2866 sinks:
2867 bad:
2868 type: jsonl
2869 config: { destination: /tmp/x.jsonl }
2870 inherit_transforms: false
2871matrix:
2872 - id: row
2873 sink: { ref: bad }
2874"#;
2875 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2876 .unwrap();
2877 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
2878 match err {
2879 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
2880 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
2881 }
2882 }
2883
2884 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
2885 transforms.iter().map(|t| t.kind.clone()).collect()
2886 }
2887
2888 #[test]
2889 fn three_layer_concat_default_inherit() {
2890 let yaml = r#"
2891version: 1
2892pipeline:
2893 transforms:
2894 - { type: flatten, config: { separator: "_" } }
2895 sources:
2896 s:
2897 type: rest
2898 config: {}
2899 transforms:
2900 - { type: keys_case, config: { mode: snake } }
2901 sink:
2902 type: jsonl
2903 config: { destination: /tmp/x.jsonl }
2904matrix:
2905 - id: row
2906 source: { ref: s }
2907 transforms:
2908 - { type: select, config: { fields: [id] } }
2909"#;
2910 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2911 .unwrap();
2912 let nodes = crate::expand::expand(&cfg).unwrap();
2913 assert_eq!(nodes.len(), 1);
2914 assert_eq!(
2915 kinds(&nodes[0].transforms),
2916 vec!["flatten", "keys_case", "select"]
2917 );
2918 }
2919
2920 #[test]
2921 fn source_inherit_false_drops_pipeline_layer() {
2922 let yaml = r#"
2923version: 1
2924pipeline:
2925 transforms:
2926 - { type: flatten, config: { separator: "_" } }
2927 sources:
2928 s:
2929 type: rest
2930 config: {}
2931 inherit_transforms: false
2932 transforms:
2933 - { type: keys_case, config: { mode: snake } }
2934 sink:
2935 type: jsonl
2936 config: { destination: /tmp/x.jsonl }
2937matrix:
2938 - id: row
2939 source: { ref: s }
2940 transforms:
2941 - { type: select, config: { fields: [id] } }
2942"#;
2943 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2944 .unwrap();
2945 let nodes = crate::expand::expand(&cfg).unwrap();
2946 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
2947 }
2948
2949 #[test]
2950 fn row_inherit_false_drops_pipeline_and_source_layers() {
2951 let yaml = r#"
2952version: 1
2953pipeline:
2954 transforms:
2955 - { type: flatten, config: { separator: "_" } }
2956 sources:
2957 s:
2958 type: rest
2959 config: {}
2960 transforms:
2961 - { type: keys_case, config: { mode: snake } }
2962 sink:
2963 type: jsonl
2964 config: { destination: /tmp/x.jsonl }
2965matrix:
2966 - id: row
2967 source: { ref: s }
2968 inherit_transforms: false
2969 transforms:
2970 - { type: select, config: { fields: [id] } }
2971"#;
2972 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2973 .unwrap();
2974 let nodes = crate::expand::expand(&cfg).unwrap();
2975 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2976 }
2977
2978 #[test]
2979 fn both_inherit_false_yields_row_only() {
2980 let yaml = r#"
2981version: 1
2982pipeline:
2983 transforms:
2984 - { type: flatten, config: { separator: "_" } }
2985 sources:
2986 s:
2987 type: rest
2988 config: {}
2989 inherit_transforms: false
2990 transforms:
2991 - { type: keys_case, config: { mode: snake } }
2992 sink:
2993 type: jsonl
2994 config: { destination: /tmp/x.jsonl }
2995matrix:
2996 - id: row
2997 source: { ref: s }
2998 inherit_transforms: false
2999 transforms:
3000 - { type: select, config: { fields: [id] } }
3001"#;
3002 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
3003 .unwrap();
3004 let nodes = crate::expand::expand(&cfg).unwrap();
3005 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
3006 }
3007
3008 #[test]
3009 fn all_layers_omitted_yields_empty_transforms() {
3010 let yaml = r#"
3011version: 1
3012pipeline:
3013 source:
3014 type: rest
3015 config: {}
3016 sink:
3017 type: jsonl
3018 config: { destination: /tmp/x.jsonl }
3019matrix:
3020 - id: row
3021"#;
3022 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
3023 .unwrap();
3024 let nodes = crate::expand::expand(&cfg).unwrap();
3025 assert!(nodes[0].transforms.is_empty());
3026 }
3027
3028 #[test]
3029 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
3030 let yaml = r#"
3032version: 1
3033pipeline:
3034 source: { type: rest, config: {} }
3035 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
3036"#;
3037 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3038 assert!(expand(&cfg).is_ok());
3040 }
3041
3042 #[test]
3043 fn now_is_a_reserved_row_id() {
3044 let yaml = r#"
3045version: 1
3046pipeline:
3047 source: { type: rest, config: {} }
3048 sink: { type: jsonl, config: { path: ./o.jsonl } }
3049matrix:
3050 - id: now
3051"#;
3052 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3053 match expand(&cfg).unwrap_err() {
3054 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
3055 other => panic!("expected ReservedRowId, got {other:?}"),
3056 }
3057 }
3058
3059 #[test]
3060 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
3061 let yaml = r#"
3065version: 1
3066pipeline:
3067 source: { type: rest, config: {} }
3068 sink: { type: jsonl, config: { path: ./o.jsonl } }
3069execution:
3070 adaptive_batch_size:
3071 enabled: true
3072 min: 5000
3073 max: 100
3074"#;
3075 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3076 let err = expand(&cfg).unwrap_err();
3077 assert!(
3078 err.to_string().contains("adaptive_batch_size.min"),
3079 "expected adaptive validation error, got: {err}"
3080 );
3081 }
3082
3083 #[test]
3084 fn expand_accepts_valid_adaptive_batch_size() {
3085 let yaml = r#"
3086version: 1
3087pipeline:
3088 source: { type: rest, config: {} }
3089 sink: { type: jsonl, config: { path: ./o.jsonl } }
3090execution:
3091 adaptive_batch_size:
3092 enabled: true
3093 min: 100
3094 max: 5000
3095 target_latency_ms: 500
3096"#;
3097 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3098 assert!(expand(&cfg).is_ok());
3099 }
3100
3101 #[test]
3104 fn exactly_once_rejects_non_cdc_source() {
3105 let yaml = r#"
3107version: 1
3108delivery: exactly_once
3109pipeline:
3110 source: { type: rest, config: { base_url: https://x } }
3111 sink: { type: stdout, config: {} }
3112 state:
3113 type: memory
3114 config: {}
3115"#;
3116 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3117 let err = expand(&cfg).unwrap_err();
3118 match &err {
3119 CliError::Config(msg) => {
3120 assert!(
3121 msg.contains("rest"),
3122 "expected source kind in error, got: {msg}"
3123 );
3124 assert!(
3125 msg.contains("exactly_once") || msg.contains("not supported"),
3126 "got: {msg}"
3127 );
3128 }
3129 other => panic!("expected Config error, got {other:?}"),
3130 }
3131 }
3132
3133 #[test]
3134 fn exactly_once_rejects_non_idempotent_sink() {
3135 let yaml = r#"
3137version: 1
3138delivery: exactly_once
3139pipeline:
3140 source: { type: postgres-cdc, config: {} }
3141 sink: { type: stdout, config: {} }
3142 state:
3143 type: memory
3144 config: {}
3145"#;
3146 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3147 let err = expand(&cfg).unwrap_err();
3148 match &err {
3149 CliError::Config(msg) => {
3150 assert!(
3151 msg.contains("stdout"),
3152 "expected sink kind in error, got: {msg}"
3153 );
3154 assert!(
3155 msg.contains("exactly_once") || msg.contains("not supported"),
3156 "got: {msg}"
3157 );
3158 }
3159 other => panic!("expected Config error, got {other:?}"),
3160 }
3161 }
3162
3163 #[test]
3164 fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
3165 let yaml = r#"
3169version: 1
3170delivery: exactly_once
3171pipeline:
3172 source: { type: postgres-cdc, config: {} }
3173 sink: { type: sqlite, config: {} }
3174 state:
3175 type: file
3176 config: { path: "/tmp/faucet-eo-state.json" }
3177"#;
3178 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3179 let nodes = expand(&cfg).unwrap();
3180 assert_eq!(nodes.len(), 1);
3181 assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
3182 assert_eq!(
3183 nodes[0].delivery_guarantee,
3184 faucet_core::DeliveryGuarantee::EffectivelyOnce(
3185 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
3186 )
3187 );
3188 }
3189
3190 #[test]
3191 fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
3192 let yaml = r#"
3196version: 1
3197delivery: exactly_once
3198pipeline:
3199 source: { type: rest, config: { base_url: https://x } }
3200 sink:
3201 type: postgres
3202 config:
3203 connection_url: "postgres://localhost/db"
3204 table_name: t
3205 column_mapping: auto_map
3206 write_mode: upsert
3207 key: [id]
3208"#;
3209 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3210 let nodes = expand(&cfg).unwrap();
3211 assert_eq!(
3212 nodes[0].delivery_guarantee,
3213 faucet_core::DeliveryGuarantee::EffectivelyOnce(
3214 faucet_core::EffectivelyOnceMechanism::KeyedUpsert
3215 )
3216 );
3217 }
3218
3219 #[test]
3220 fn exactly_once_kafka_source_accepted_with_atomic_sink() {
3221 let yaml = r#"
3224version: 1
3225delivery: exactly_once
3226pipeline:
3227 source:
3228 type: kafka
3229 config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
3230 sink: { type: sqlite, config: {} }
3231 state:
3232 type: file
3233 config: { path: "/tmp/faucet-eo-kafka-state.json" }
3234"#;
3235 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3236 let nodes = expand(&cfg).unwrap();
3237 assert_eq!(
3238 nodes[0].delivery_guarantee,
3239 faucet_core::DeliveryGuarantee::EffectivelyOnce(
3240 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
3241 )
3242 );
3243 }
3244
3245 #[test]
3246 fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
3247 let yaml = r#"
3250version: 1
3251delivery: exactly_once
3252pipeline:
3253 source: { type: rest, config: { base_url: https://x } }
3254 sink:
3255 type: postgres
3256 config:
3257 connection_url: "postgres://localhost/db"
3258 table_name: t
3259 column_mapping: auto_map
3260 state:
3261 type: file
3262 config: { path: "/tmp/faucet-eo-hint-state.json" }
3263"#;
3264 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3265 let err = expand(&cfg).unwrap_err();
3266 match &err {
3267 CliError::Config(msg) => assert!(
3268 msg.contains("write_mode: upsert"),
3269 "expected keyed-upsert hint, got: {msg}"
3270 ),
3271 other => panic!("expected Config error, got {other:?}"),
3272 }
3273 }
3274
3275 #[test]
3276 fn derived_guarantee_is_at_least_once_by_default() {
3277 let yaml = r#"
3278version: 1
3279pipeline:
3280 source: { type: rest, config: { base_url: https://x } }
3281 sink: { type: stdout, config: {} }
3282"#;
3283 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3284 let nodes = expand(&cfg).unwrap();
3285 assert_eq!(
3286 nodes[0].delivery_guarantee,
3287 faucet_core::DeliveryGuarantee::AtLeastOnce
3288 );
3289 }
3290
3291 #[test]
3292 fn exactly_once_rejects_memory_state() {
3293 let yaml = r#"
3296version: 1
3297delivery: exactly_once
3298pipeline:
3299 source: { type: postgres-cdc, config: {} }
3300 sink: { type: sqlite, config: {} }
3301 state:
3302 type: memory
3303 config: {}
3304"#;
3305 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3306 let err = expand(&cfg).unwrap_err();
3307 match &err {
3308 CliError::Config(msg) => assert!(
3309 msg.contains("durable") && msg.contains("memory"),
3310 "expected durable/memory mention, got: {msg}"
3311 ),
3312 other => panic!("expected Config error, got {other:?}"),
3313 }
3314 }
3315
3316 #[test]
3317 fn exactly_once_rejects_missing_state_store() {
3318 let yaml = r#"
3320version: 1
3321delivery: exactly_once
3322pipeline:
3323 source: { type: postgres-cdc, config: {} }
3324 sink: { type: sqlite, config: {} }
3325"#;
3326 let cfg = parse_with_extension(yaml, "yaml").unwrap();
3327 let err = expand(&cfg).unwrap_err();
3328 match &err {
3329 CliError::Config(msg) => {
3330 assert!(
3331 msg.contains("state store") || msg.contains("state"),
3332 "expected state-store mention in error, got: {msg}"
3333 );
3334 }
3335 other => panic!("expected Config error, got {other:?}"),
3336 }
3337 }
3338
3339 #[test]
3340 fn rejects_upsert_on_unsupported_sink() {
3341 let c = cfg(r#"
3342version: 1
3343name: t
3344pipeline:
3345 source: { type: rest, config: { url: "http://x" } }
3346 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
3347"#);
3348 let err = expand(&c).unwrap_err();
3349 let msg = format!("{err}");
3350 assert!(
3351 msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
3352 "{msg}"
3353 );
3354 }
3355
3356 #[test]
3357 fn rejects_upsert_without_key() {
3358 let c = cfg(r#"
3359version: 1
3360name: t
3361pipeline:
3362 source: { type: rest, config: { url: "http://x" } }
3363 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
3364"#);
3365 let err = expand(&c).unwrap_err();
3366 let msg = format!("{err}");
3367 assert!(msg.contains("key"), "{msg}");
3368 }
3369
3370 #[test]
3371 fn accepts_upsert_on_postgres_with_key() {
3372 let c = cfg(r#"
3373version: 1
3374name: t
3375pipeline:
3376 source: { type: rest, config: { url: "http://x" } }
3377 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
3378"#);
3379 assert!(expand(&c).is_ok());
3380 }
3381
3382 #[test]
3383 fn bigquery_upsert_passes_write_mode_gate() {
3384 let c = cfg(r#"
3385version: 1
3386name: t
3387pipeline:
3388 source: { type: rest, config: { url: "http://x" } }
3389 sink: { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
3390"#);
3391 assert!(expand(&c).is_ok());
3392 }
3393
3394 #[test]
3395 fn accepts_append_by_default_on_any_sink() {
3396 let c = cfg(r#"
3397version: 1
3398name: t
3399pipeline:
3400 source: { type: rest, config: { url: "http://x" } }
3401 sink: { type: jsonl, config: { path: "out.jsonl" } }
3402"#);
3403 assert!(expand(&c).is_ok());
3404 }
3405
3406 #[test]
3407 fn rejects_delete_without_key() {
3408 let c = cfg(r#"
3409version: 1
3410name: t
3411pipeline:
3412 source: { type: rest, config: { url: "http://x" } }
3413 sink: { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
3414"#);
3415 let err = expand(&c).unwrap_err();
3416 let msg = format!("{err}");
3417 assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
3418 }
3419
3420 #[test]
3421 fn rejects_unknown_write_mode() {
3422 let c = cfg(r#"
3423version: 1
3424name: t
3425pipeline:
3426 source: { type: rest, config: { url: "http://x" } }
3427 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
3428"#);
3429 let err = expand(&c).unwrap_err();
3430 let msg = format!("{err}");
3431 assert!(
3432 msg.contains("unknown write_mode") && msg.contains("replace"),
3433 "{msg}"
3434 );
3435 }
3436
3437 #[test]
3438 fn overwrite_passes_on_capable_sink() {
3439 let c = cfg(r#"
3440version: 1
3441name: t
3442pipeline:
3443 source: { type: rest, config: { url: "http://x" } }
3444 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3445"#);
3446 assert!(
3447 expand(&c).is_ok(),
3448 "overwrite needs no key and postgres supports it"
3449 );
3450 }
3451
3452 #[test]
3453 fn scoped_overwrite_passes_on_postgres() {
3454 let c = cfg(r#"
3455version: 1
3456name: t
3457pipeline:
3458 source: { type: rest, config: { url: "http://x" } }
3459 sink:
3460 type: postgres
3461 config:
3462 connection_url: "postgres://x"
3463 table_name: t
3464 column_mapping: auto_map
3465 write_mode: overwrite
3466 scope: { window: { column: posting_date, from: "2024-06-01", to: "2024-07-01" } }
3467"#);
3468 assert!(expand(&c).is_ok(), "postgres supports scoped overwrite");
3469 }
3470
3471 #[test]
3472 fn rejects_scope_on_non_scoped_sink() {
3473 let c = cfg(r#"
3474version: 1
3475name: t
3476pipeline:
3477 source: { type: rest, config: { url: "http://x" } }
3478 sink:
3479 type: sqlite
3480 config:
3481 connection_url: "sqlite://x"
3482 table_name: t
3483 column_mapping: auto_map
3484 write_mode: overwrite
3485 scope: { window: { column: d, from: 1, to: 2 } }
3486"#);
3487 let msg = format!("{}", expand(&c).unwrap_err());
3488 assert!(
3489 msg.contains("scoped overwrite") && msg.contains("not supported"),
3490 "{msg}"
3491 );
3492 }
3493
3494 #[test]
3495 fn rejects_scope_without_overwrite_mode() {
3496 let c = cfg(r#"
3497version: 1
3498name: t
3499pipeline:
3500 source: { type: rest, config: { url: "http://x" } }
3501 sink:
3502 type: postgres
3503 config:
3504 connection_url: "postgres://x"
3505 table_name: t
3506 column_mapping: auto_map
3507 scope: { window: { column: d, from: 1, to: 2 } }
3508"#);
3509 let msg = format!("{}", expand(&c).unwrap_err());
3510 assert!(
3511 msg.contains("only valid with `write_mode: overwrite`"),
3512 "{msg}"
3513 );
3514 }
3515
3516 #[test]
3517 fn rejects_overwrite_on_unsupported_sink() {
3518 let c = cfg(r#"
3519version: 1
3520name: t
3521pipeline:
3522 source: { type: rest, config: { url: "http://x" } }
3523 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: overwrite } }
3524"#);
3525 let err = expand(&c).unwrap_err();
3526 let msg = format!("{err}");
3527 assert!(
3528 msg.contains("overwrite")
3529 && msg.contains("not supported")
3530 && msg.contains("overwrite sinks"),
3531 "{msg}"
3532 );
3533 }
3534
3535 #[test]
3536 fn rejects_overwrite_with_exactly_once() {
3537 let c = cfg(r#"
3538version: 1
3539name: t
3540delivery: exactly_once
3541pipeline:
3542 source: { type: rest, config: { url: "http://x" } }
3543 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3544 state: { type: file, config: { path: "./s.json" } }
3545"#);
3546 let err = expand(&c).unwrap_err();
3547 let msg = format!("{err}");
3548 assert!(
3549 msg.contains("overwrite") && msg.contains("exactly_once"),
3550 "{msg}"
3551 );
3552 }
3553
3554 #[test]
3555 fn rejects_overwrite_with_schema_evolve() {
3556 let c = cfg(r#"
3557version: 1
3558name: t
3559pipeline:
3560 source: { type: rest, config: { url: "http://x" } }
3561 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3562 schema: { on_drift: evolve }
3563"#);
3564 let err = expand(&c).unwrap_err();
3565 let msg = format!("{err}");
3566 assert!(msg.contains("overwrite") && msg.contains("evolve"), "{msg}");
3567 }
3568
3569 #[test]
3570 fn rejects_poison_dlq_action_without_dlq() {
3571 let c = cfg(r#"
3572version: 1
3573pipeline:
3574 source: { type: rest, config: { base_url: https://x } }
3575 sink: { type: jsonl, config: { path: ./o } }
3576resilience:
3577 poison: { max_row_attempts: 3, action: dlq }
3578"#);
3579 let err = expand(&c).unwrap_err();
3580 assert!(
3581 matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
3582 "got: {err:?}"
3583 );
3584 }
3585
3586 #[test]
3587 fn accepts_poison_dlq_action_with_dlq() {
3588 let c = cfg(r#"
3589version: 1
3590pipeline:
3591 source: { type: rest, config: { base_url: https://x } }
3592 sink: { type: jsonl, config: { path: ./o } }
3593 dlq:
3594 sink: { type: jsonl, config: { path: ./dead.jsonl } }
3595resilience:
3596 poison: { max_row_attempts: 3, action: dlq }
3597"#);
3598 let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
3599 assert_eq!(nodes.len(), 1);
3600 }
3601
3602 #[test]
3603 fn accepts_poison_drop_action_without_dlq() {
3604 let c = cfg(r#"
3606version: 1
3607pipeline:
3608 source: { type: rest, config: { base_url: https://x } }
3609 sink: { type: jsonl, config: { path: ./o } }
3610resilience:
3611 poison: { max_row_attempts: 3, action: drop }
3612"#);
3613 let nodes = expand(&c).expect("poison.action=drop needs no dlq");
3614 assert_eq!(nodes.len(), 1);
3615 }
3616
3617 #[test]
3620 fn evolve_on_non_evolvable_sink_rejected() {
3621 let c = cfg(r#"
3623version: 1
3624pipeline:
3625 source: { type: rest, config: { base_url: https://x } }
3626 sink: { type: jsonl, config: { path: ./o.jsonl } }
3627 schema:
3628 on_drift: evolve
3629"#);
3630 let err = expand(&c).unwrap_err();
3631 match &err {
3632 CliError::Config(msg) => {
3633 assert!(
3634 msg.contains("evolve"),
3635 "expected evolve mention, got: {msg}"
3636 );
3637 assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
3638 }
3639 other => panic!("expected Config error, got {other:?}"),
3640 }
3641 }
3642
3643 #[test]
3644 fn quarantine_drift_without_dlq_rejected() {
3645 let c = cfg(r#"
3647version: 1
3648pipeline:
3649 source: { type: rest, config: { base_url: https://x } }
3650 sink: { type: postgres, config: {} }
3651 schema:
3652 on_drift: quarantine
3653"#);
3654 let err = expand(&c).unwrap_err();
3655 match &err {
3656 CliError::Config(msg) => {
3657 assert!(
3658 msg.contains("quarantine"),
3659 "expected quarantine mention, got: {msg}"
3660 );
3661 assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
3662 }
3663 other => panic!("expected Config error, got {other:?}"),
3664 }
3665 }
3666
3667 #[test]
3668 fn evolve_on_postgres_passes() {
3669 let c = cfg(r#"
3671version: 1
3672pipeline:
3673 source: { type: rest, config: { base_url: https://x } }
3674 sink: { type: postgres, config: {} }
3675 schema:
3676 on_drift: evolve
3677"#);
3678 assert!(expand(&c).is_ok());
3679 }
3680}
3681
3682#[cfg(test)]
3683mod partition_tests {
3684 use super::*;
3686 use crate::config::PipelineConfig;
3687
3688 fn cfg(yaml: &str) -> PipelineConfig {
3689 PipelineConfig::from_text(yaml, std::path::Path::new("p.yaml")).expect("config parses")
3690 }
3691
3692 const SCOPED_SOURCE: &str = r#"
3693 type: rest
3694 config:
3695 base_url: "https://api.example.com"
3696 path: "/records?id_from=${partition.start}&id_to=${partition.end}""#;
3697
3698 fn doc(partition: &str, source: &str) -> String {
3699 format!(
3700 "version: 1\nname: p\npipeline:\n source:{source}\n sink:\n type: jsonl\n config:\n path: ./out.jsonl\n{partition}"
3701 )
3702 }
3703
3704 #[test]
3705 fn a_partitioned_row_expands_into_one_node_per_chunk() {
3706 let nodes = expand(&cfg(&doc(
3707 "partition:\n kind: integer\n from: 0\n to: 24\n chunk_size: 10\n bounds: inclusive\n",
3708 SCOPED_SOURCE,
3709 )))
3710 .expect("expand");
3711 assert_eq!(nodes.len(), 3, "24 values / 10 = 3 chunks");
3712 let urls: Vec<String> = nodes
3714 .iter()
3715 .map(|n| n.source.config["path"].as_str().unwrap().to_string())
3716 .collect();
3717 assert!(urls[0].contains("id_from=0&id_to=9"), "{:?}", urls[0]);
3718 assert!(urls[1].contains("id_from=10&id_to=19"), "{:?}", urls[1]);
3719 assert!(urls[2].contains("id_from=20&id_to=24"), "{:?}", urls[2]);
3720 }
3721
3722 #[test]
3723 fn chunk_ids_are_distinct_and_namespaced_so_state_keys_cannot_collide() {
3724 let nodes = expand(&cfg(&doc(
3725 "partition:\n kind: integer\n from: 0\n to: 24\n chunk_size: 10\n bounds: inclusive\n",
3726 SCOPED_SOURCE,
3727 )))
3728 .unwrap();
3729 let ids: std::collections::BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
3730 assert_eq!(ids.len(), nodes.len(), "ids must be unique");
3731 assert!(nodes.iter().all(|n| n.id.contains("::partition::")));
3732 }
3733
3734 #[test]
3735 fn an_unpartitioned_config_is_completely_unchanged() {
3736 let nodes = expand(&cfg(&doc(
3737 "",
3738 "\n type: csv\n config:\n path: ./in.csv",
3739 )))
3740 .unwrap();
3741 assert_eq!(nodes.len(), 1);
3742 assert!(!nodes[0].id.contains("partition"));
3743 }
3744
3745 #[test]
3746 fn a_partition_block_whose_source_ignores_the_tokens_is_rejected() {
3747 let err = expand(&cfg(&doc(
3749 "partition:\n kind: integer\n from: 0\n to: 9\n chunk_size: 5\n bounds: inclusive\n",
3750 "\n type: csv\n config:\n path: ./in.csv",
3751 )))
3752 .expect_err("must be rejected");
3753 let msg = err.to_string();
3754 assert!(msg.contains("no `${partition.*}` token"), "{msg}");
3755 assert!(msg.contains("start"), "should list available tokens: {msg}");
3756 }
3757
3758 #[test]
3759 fn a_wrong_kind_token_is_rejected_naming_the_real_tokens() {
3760 let err = expand(&cfg(&doc(
3761 "partition:\n kind: offset\n total: 20\n chunk_size: 10\n",
3762 SCOPED_SOURCE,
3763 )))
3764 .expect_err("id-range tokens are not offset tokens");
3765 let msg = err.to_string();
3766 assert!(msg.contains("start"), "{msg}");
3767 assert!(msg.contains("offset"), "{msg}");
3768 }
3769
3770 #[test]
3771 fn a_partitioned_row_cannot_be_a_parent_or_a_dependency() {
3772 for edge in ["parent: a\n parent_key: id", "depends_on: [a]"] {
3774 let yaml = format!(
3775 "version: 1\nname: p\npipeline:\n source:\n type: csv\n config:\n path: ./in.csv\n sink:\n type: jsonl\n config:\n path: ./out.jsonl\nmatrix:\n - id: a\n partition:\n kind: integer\n from: 0\n to: 9\n chunk_size: 5\n bounds: inclusive\n source:\n config:\n path: \"./in-${{partition.start}}.csv\"\n - id: b\n {edge}\n"
3776 );
3777 let err = expand(&cfg(&yaml)).expect_err("must be rejected");
3778 assert!(
3779 err.to_string()
3780 .contains("partitioned row cannot be referenced"),
3781 "{err}"
3782 );
3783 }
3784 }
3785
3786 #[test]
3787 fn the_top_level_block_applies_to_root_rows() {
3788 let nodes = expand(&cfg(&doc(
3789 "partition:\n kind: offset\n total: 25\n chunk_size: 10\n",
3790 "\n type: rest\n config:\n base_url: \"https://x\"\n path: \"/r?offset=${partition.offset}&limit=${partition.limit}\"",
3791 )))
3792 .unwrap();
3793 assert_eq!(nodes.len(), 3);
3794 let p = nodes[2].source.config["path"].as_str().unwrap();
3795 assert!(p.contains("offset=20&limit=5"), "{p}");
3796 }
3797
3798 #[test]
3799 fn a_row_level_block_overrides_the_top_level_default() {
3800 let yaml = format!(
3801 "version: 1\nname: p\npipeline:\n source:{SCOPED_SOURCE}\n sink:\n type: jsonl\n config:\n path: ./out.jsonl\npartition:\n kind: integer\n from: 0\n to: 99\n chunk_size: 10\n bounds: inclusive\nmatrix:\n - id: a\n partition:\n kind: integer\n from: 0\n to: 4\n chunk_size: 5\n bounds: inclusive\n"
3802 );
3803 let nodes = expand(&cfg(&yaml)).unwrap();
3804 assert_eq!(
3805 nodes.len(),
3806 1,
3807 "the row's own 5-wide range wins over 100/10"
3808 );
3809 }
3810}