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];
38
39#[derive(Debug, Clone)]
41pub struct ExpandedNode {
42 pub id: String,
43 pub row_index: usize,
44 pub role: NodeRole,
45 pub source: ConnectorSpec,
46 pub sink: ConnectorSpec,
47 pub transforms: Vec<TransformSpec>,
48 pub state: Option<StateStoreSpec>,
49 pub dlq: Option<crate::config::DlqSpec>,
51 #[cfg(feature = "quality")]
54 pub quality: Option<faucet_core::QualitySpec>,
55 #[cfg(feature = "contract")]
58 pub contract: Option<faucet_core::ContractSpec>,
59 #[cfg(feature = "masking")]
67 pub masking: Option<faucet_core::MaskingSpec>,
68 pub sink_ref: String,
72 pub schema: Option<faucet_core::SchemaDriftSpec>,
74 pub delivery: faucet_core::DeliveryMode,
77 pub delivery_guarantee: faucet_core::DeliveryGuarantee,
83 pub depends_on: Vec<String>,
87 pub status: crate::config::SourceStatus,
94 pub tags: Vec<String>,
98 pub deferred_refs: Vec<DeferredRef>,
102 pub source_override: Option<crate::dlq_replay::reader::SourceOverride>,
108 pub cleanup_scope: Option<std::collections::BTreeMap<String, serde_json::Value>>,
114}
115
116#[derive(Debug, Clone)]
117pub enum NodeRole {
118 Root,
120 Child {
122 parent_id: String,
123 parent_key: String,
124 },
125}
126
127#[derive(Debug, Clone)]
128pub struct DeferredRef {
129 pub referenced_id: String,
130 pub dotted_path: String,
131 pub token: String,
132}
133
134struct Registry<'a> {
138 sources: HashMap<&'a str, &'a ConnectorSpec>,
139 sinks: HashMap<&'a str, &'a ConnectorSpec>,
140}
141
142impl<'a> Registry<'a> {
143 fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
144 let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
145 if let Some(default) = spec.source.as_ref() {
146 sources.insert("default", default);
147 }
148 for (name, s) in spec.sources.iter() {
149 if sources.contains_key(name.as_str()) {
150 return Err(CliError::DuplicateTemplate {
151 kind: "source",
152 name: name.clone(),
153 });
154 }
155 sources.insert(name.as_str(), s);
156 }
157
158 let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
159 if let Some(default) = spec.sink.as_ref() {
160 if default.transforms.is_some() {
161 return Err(CliError::TransformsOnSink {
162 name: "default".to_string(),
163 });
164 }
165 if !default.inherit_transforms {
166 return Err(CliError::InheritTransformsOnSink {
167 name: "default".to_string(),
168 });
169 }
170 sinks.insert("default", default);
171 }
172 for (name, s) in spec.sinks.iter() {
173 if sinks.contains_key(name.as_str()) {
174 return Err(CliError::DuplicateTemplate {
175 kind: "sink",
176 name: name.clone(),
177 });
178 }
179 if s.transforms.is_some() {
180 return Err(CliError::TransformsOnSink { name: name.clone() });
181 }
182 if !s.inherit_transforms {
183 return Err(CliError::InheritTransformsOnSink { name: name.clone() });
184 }
185 sinks.insert(name.as_str(), s);
186 }
187 Ok(Self { sources, sinks })
188 }
189
190 fn known(&self, kind: &'static str) -> Vec<String> {
191 debug_assert!(
192 matches!(kind, "source" | "sink"),
193 "Registry::known called with kind = {:?}",
194 kind
195 );
196 let map = if kind == "source" {
197 &self.sources
198 } else {
199 &self.sinks
200 };
201 let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
202 out.sort();
203 out
204 }
205
206 fn resolve(
207 &self,
208 kind: &'static str,
209 row_id: &str,
210 overlay: Option<&PartialConnector>,
211 ) -> CliResult<ConnectorSpec> {
212 debug_assert!(
213 matches!(kind, "source" | "sink"),
214 "Registry::resolve called with kind = {:?}",
215 kind
216 );
217 let map = if kind == "source" {
218 &self.sources
219 } else {
220 &self.sinks
221 };
222 let ref_name = overlay
223 .and_then(|p| p.r#ref.as_deref())
224 .unwrap_or("default");
225 let base = map.get(ref_name).ok_or_else(|| {
226 if ref_name == "default" {
227 CliError::MissingTemplate {
228 kind,
229 row_id: row_id.to_owned(),
230 }
231 } else {
232 CliError::UnknownTemplate {
233 kind,
234 name: ref_name.to_owned(),
235 row_id: row_id.to_owned(),
236 known: self.known(kind),
237 }
238 }
239 })?;
240 let mut out = (*base).clone();
241 if let Some(p) = overlay {
242 if let Some(k) = &p.kind {
243 out.kind = k.clone();
244 }
245 if let Some(c) = &p.config {
246 merge_value(&mut out.config, c.clone());
247 }
248 if p.status.is_some() {
252 out.status = p.status;
253 }
254 }
255 Ok(out)
256 }
257}
258
259pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
262 if let Some(ab) = cfg
267 .execution
268 .as_ref()
269 .and_then(|e| e.adaptive_batch_size.as_ref())
270 {
271 ab.validate()?;
274 }
275
276 let synthetic_row;
278 let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
279 synthetic_row = [MatrixRow {
280 id: None,
281 parent: None,
282 depends_on: Vec::new(),
283 parent_key: "id".into(),
284 source: None,
285 sink: None,
286 transforms: None,
287 inherit_transforms: true,
288 state: None,
289 dlq: None,
290 delivery: None,
291 tags: Vec::new(),
292 partition: None,
293 }];
294 &synthetic_row
295 } else {
296 &cfg.matrix
297 };
298
299 let mut ids: Vec<String> = Vec::with_capacity(rows.len());
301 let mut seen: HashSet<String> = HashSet::new();
302 for (i, row) in rows.iter().enumerate() {
303 let id = match &row.id {
304 Some(s) => s.clone(),
305 None => format!("row-{i}"),
306 };
307 if RESERVED_IDS.contains(&id.as_str()) {
308 return Err(CliError::ReservedRowId { id });
309 }
310 if !seen.insert(id.clone()) {
311 return Err(CliError::DuplicateRowId { id });
312 }
313 ids.push(id);
314 }
315 let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
316
317 let mut parents: HashMap<&str, &str> = HashMap::new();
319 for (i, row) in rows.iter().enumerate() {
320 let id = ids[i].as_str();
321 if let Some(parent) = row.parent.as_deref() {
322 if !id_set.contains(parent) {
323 return Err(CliError::UnknownParent {
324 id: id.to_owned(),
325 parent: parent.to_owned(),
326 });
327 }
328 if parent == id {
329 return Err(CliError::ParentCycle {
330 ids: vec![id.to_owned()],
331 });
332 }
333 parents.insert(id, parent);
334 }
335 }
336 detect_cycle(&parents)?;
337
338 let mut deps_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
344 for (i, row) in rows.iter().enumerate() {
345 let id = ids[i].as_str();
346 let mut deps: Vec<String> = Vec::with_capacity(row.depends_on.len());
347 for dep in &row.depends_on {
348 if !id_set.contains(dep.as_str()) {
349 return Err(CliError::UnknownDependency {
350 id: id.to_owned(),
351 depends_on: dep.clone(),
352 });
353 }
354 if dep == id {
355 return Err(CliError::DependencyCycle {
356 ids: vec![id.to_owned()],
357 });
358 }
359 if !deps.contains(dep) {
360 deps.push(dep.clone());
361 }
362 }
363 deps_by_row.push(deps);
364 }
365 detect_combined_cycle(&ids, &parents, &deps_by_row)?;
366
367 for (i, row) in rows.iter().enumerate() {
371 let id = ids[i].as_str();
372 if let Some(p) = &row.source
373 && let Some(c) = &p.config
374 {
375 check_refs(c, &id_set, id)?;
376 }
377 if let Some(p) = &row.sink
378 && let Some(c) = &p.config
379 {
380 check_refs(c, &id_set, id)?;
381 }
382 }
383 if let Some(s) = &cfg.pipeline.source {
384 check_refs(&s.config, &id_set, "pipeline.source")?;
385 }
386 if let Some(s) = &cfg.pipeline.sink {
387 check_refs(&s.config, &id_set, "pipeline.sink")?;
388 }
389 for (name, s) in &cfg.pipeline.sources {
390 check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
391 }
392 for (name, s) in &cfg.pipeline.sinks {
393 check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
394 }
395
396 let registry = Registry::build(&cfg.pipeline)?;
398
399 let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
402 let mut roots: Vec<usize> = Vec::new();
403 for (i, row) in rows.iter().enumerate() {
404 match row.parent.as_deref() {
405 None => roots.push(i),
406 Some(p) => by_parent.entry(p).or_default().push(i),
407 }
408 }
409
410 let mut order: Vec<usize> = Vec::with_capacity(rows.len());
411 let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
412 while let Some(idx) = queue.pop_front() {
413 order.push(idx);
414 if let Some(children) = by_parent.get(ids[idx].as_str()) {
415 queue.extend(children.iter().copied());
416 }
417 }
418 debug_assert_eq!(order.len(), rows.len());
419
420 let mut out = Vec::with_capacity(rows.len());
421 for &i in &order {
422 let row = &rows[i];
423 let row_id = ids[i].as_str();
424 let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
425 let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
426 let sink_ref = row
429 .sink
430 .as_ref()
431 .and_then(|s| s.r#ref.clone())
432 .unwrap_or_else(|| "default".to_string());
433 let role = match &row.parent {
434 None => NodeRole::Root,
435 Some(p) => NodeRole::Child {
436 parent_id: p.clone(),
437 parent_key: row.parent_key.clone(),
438 },
439 };
440 let mut deferred = Vec::new();
441 collect_deferred(&merged_source.config, &mut deferred);
442 collect_deferred(&merged_sink.config, &mut deferred);
443
444 let status = merged_source.status.unwrap_or_default();
448
449 let tags = resolve_tags(&merged_source.tags, &row.tags, row_id)?;
454
455 let src_inherit = merged_source.inherit_transforms;
460 let row_inherit = row.inherit_transforms;
461 let mut transforms: Vec<TransformSpec> = Vec::new();
462 if src_inherit && row_inherit {
463 transforms.extend(cfg.pipeline.transforms.iter().cloned());
464 }
465 if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
466 transforms.extend(src_ts.iter().cloned());
467 }
468 if let Some(row_ts) = row.transforms.as_ref() {
469 transforms.extend(row_ts.iter().cloned());
470 }
471 let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
472 let delivery = row.delivery.unwrap_or(cfg.delivery);
474 let dlq = match row.dlq.clone() {
478 Some(None) => None,
479 Some(Some(spec)) => Some(spec),
480 None => cfg.pipeline.dlq.clone(),
481 };
482
483 if let Some(ref d) = dlq {
484 if matches!(d.max_failures_per_page, Some(0)) {
485 return Err(CliError::InvalidDlqBudget {
486 field: "max_failures_per_page",
487 });
488 }
489 if matches!(d.max_failures_total, Some(0)) {
490 return Err(CliError::InvalidDlqBudget {
491 field: "max_failures_total",
492 });
493 }
494 if !crate::registry::sink_exists(&d.sink.kind) {
495 return Err(CliError::UnknownDlqSinkKind {
496 kind: d.sink.kind.clone(),
497 context: format!("row `{row_id}`"),
498 });
499 }
500 }
501
502 for (ti, t) in transforms.iter().enumerate() {
507 reject_runtime_tokens(
508 &t.config,
509 &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
510 )?;
511 }
512 if let Some(ref st) = state {
513 reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
514 }
515 if let Some(ref d) = dlq {
516 reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
517 }
518
519 #[cfg(feature = "quality")]
526 let quality = cfg.pipeline.quality.clone();
527 #[cfg(feature = "quality")]
528 if let Some(ref spec) = quality {
529 let compiled = faucet_core::CompiledQuality::compile(spec)
530 .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
531 if compiled.requires_dlq() && dlq.is_none() {
532 return Err(CliError::Config(format!(
533 "row `{row_id}`: a quality check uses `on_failure: quarantine` \
534 but no DLQ is configured — add a `dlq:` block (or change the \
535 check's `on_failure` to `abort`)"
536 )));
537 }
538 }
539
540 #[cfg(feature = "contract")]
545 let contract = cfg.pipeline.contract.clone();
546 #[cfg(feature = "contract")]
547 if let Some(ref spec) = contract {
548 let compiled = faucet_core::CompiledContract::compile(spec)
549 .map_err(|e| CliError::Config(format!("contract (row `{row_id}`): {e}")))?;
550 if compiled.requires_dlq() && dlq.is_none() {
551 return Err(CliError::Config(format!(
552 "row `{row_id}`: the contract uses `on_breach: quarantine` \
553 but no DLQ is configured — add a `dlq:` block (or change \
554 `on_breach` to `fail` or `warn`)"
555 )));
556 }
557 }
558
559 #[cfg(feature = "masking")]
564 let masking = cfg.pipeline.masking.clone();
565 #[cfg(feature = "masking")]
566 if let Some(ref spec) = masking {
567 faucet_core::CompiledMasking::compile(spec)
568 .map_err(|e| CliError::Config(format!("masking (row `{row_id}`): {e}")))?;
569 }
570
571 if let Some(spec) = &cfg.resilience
575 && matches!(
576 spec.poison.as_ref().map(|p| p.action),
577 Some(crate::config::PoisonActionSpec::Dlq)
578 )
579 && dlq.is_none()
580 {
581 return Err(CliError::Config(format!(
582 "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
583 )));
584 }
585
586 if let Some(ref sla) = cfg.sla {
591 sla.validate()
592 .map_err(|e| CliError::Config(format!("sla: {e}")))?;
593 if sla.needs_state() {
594 match state.as_ref() {
595 None => {
596 return Err(CliError::Config(format!(
597 "row '{row_id}': sla.max_staleness_secs / sla.volume_anomaly \
598 need persisted run history — add a `state:` block \
599 (min_rows_per_run alone works without one)"
600 )));
601 }
602 Some(s) if s.kind == "memory" => {
603 tracing::warn!(
604 row = %row_id,
605 "sla: the `memory` state store resets on process exit — \
606 staleness/volume baselines only persist within a single \
607 `faucet schedule`/`serve` process; use `file`, `redis`, \
608 or `postgres` for one-shot runs"
609 );
610 }
611 Some(_) => {}
612 }
613 }
614 }
615
616 let requested_mode = merged_sink
620 .config
621 .get("write_mode")
622 .and_then(|v| v.as_str())
623 .unwrap_or("append");
624 let mode = match requested_mode {
625 "append" => faucet_core::WriteMode::Append,
626 "upsert" => faucet_core::WriteMode::Upsert,
627 "delete" => faucet_core::WriteMode::Delete,
628 other => {
629 return Err(CliError::Config(format!(
630 "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
631 ids[i], other
632 )));
633 }
634 };
635 if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
636 return Err(CliError::Config(format!(
637 "row '{}': write_mode '{}' is not supported by sink '{}' \
638 (upsert/delete sinks: {})",
639 ids[i],
640 requested_mode,
641 merged_sink.kind,
642 crate::registry::UPSERT_SINK_KINDS.join(", ")
643 )));
644 }
645 if matches!(
646 mode,
647 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
648 ) {
649 let key_present = merged_sink
650 .config
651 .get("key")
652 .and_then(|v| v.as_array())
653 .map(|a| !a.is_empty())
654 .unwrap_or(false);
655 if !key_present {
656 return Err(CliError::Config(format!(
657 "row '{}': write_mode '{}' requires a non-empty `key`",
658 ids[i], requested_mode
659 )));
660 }
661 }
662
663 let keyed_upsert_configured = matches!(
670 mode,
671 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
672 );
673 let guarantee_inputs = faucet_core::GuaranteeInputs {
674 replay: crate::registry::source_replay_guarantee(&merged_source.kind),
675 sink_atomic: crate::registry::sink_supports_idempotent_writes(&merged_sink.kind),
676 keyed_upsert_configured,
677 durable_state: matches!(state.as_ref(), Some(s) if s.kind != "memory"),
678 dlq: dlq.is_some(),
679 };
680 let delivery_guarantee = faucet_core::derive_delivery_guarantee(&guarantee_inputs);
681
682 if delivery == faucet_core::DeliveryMode::ExactlyOnce
690 && delivery_guarantee == faucet_core::DeliveryGuarantee::AtLeastOnce
691 {
692 if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
693 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
694 {
695 format!(
696 ", or configure `write_mode: upsert` + `key` on sink '{}' for \
697 keyed-upsert effectively-once with any source",
698 merged_sink.kind
699 )
700 } else {
701 String::new()
702 };
703 return Err(CliError::Config(format!(
704 "row '{}': delivery: exactly_once is not supported by source '{}' \
705 (deterministic-replay sources only: {}{})",
706 ids[i],
707 merged_source.kind,
708 crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", "),
709 keyed_hint
710 )));
711 }
712 if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
713 let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
714 {
715 format!(
716 "; alternatively configure `write_mode: upsert` + `key` on '{}' for \
717 keyed-upsert effectively-once",
718 merged_sink.kind
719 )
720 } else {
721 String::new()
722 };
723 return Err(CliError::Config(format!(
724 "row '{}': delivery: exactly_once is not supported by sink '{}' \
725 (idempotent sinks only: {}{})",
726 ids[i],
727 merged_sink.kind,
728 crate::registry::IDEMPOTENT_SINK_KINDS.join(", "),
729 keyed_hint
730 )));
731 }
732 match state.as_ref() {
740 None => {
741 return Err(CliError::Config(format!(
742 "row '{}': delivery: exactly_once requires a state store",
743 ids[i]
744 )));
745 }
746 Some(s) if s.kind == "memory" => {
747 return Err(CliError::Config(format!(
748 "row '{}': delivery: exactly_once requires a durable state store, \
749 not `memory` — the cross-restart watermark/sequence guarantee \
750 depends on it (use `file`, `redis`, or `postgres`)",
751 ids[i]
752 )));
753 }
754 Some(_) => {}
755 }
756 if dlq.is_some() {
757 return Err(CliError::Config(format!(
758 "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
759 ids[i]
760 )));
761 }
762 unreachable!("delivery-guarantee derivation and the exactly-once gate diverged");
766 }
767
768 if merged_sink.complete_for.is_some() {
772 return Err(CliError::Config(format!(
773 "row '{}': `complete_for` belongs on the source, not the sink — only the \
774 source can claim a fetch returned every record for a scope",
775 ids[i]
776 )));
777 }
778 let cleanup_scope = match merged_source.complete_for.as_ref() {
779 None => None,
780 Some(claim) if claim.on_missing == crate::config::OnMissing::Ignore => {
781 None
784 }
785 Some(claim) => {
786 if claim.scope.is_empty() {
787 return Err(CliError::Config(format!(
788 "row '{}': `complete_for.scope` is empty — an empty scope matches \
789 every row in the destination",
790 ids[i]
791 )));
792 }
793 if !crate::registry::sink_supports_cleanup(&merged_sink.kind) {
794 return Err(CliError::Config(format!(
795 "row '{}': `complete_for.on_missing: delete` is not supported by sink \
796 '{}' (cleanup-capable sinks: {})",
797 ids[i],
798 merged_sink.kind,
799 crate::registry::CLEANUP_SINK_KINDS.join(", ")
800 )));
801 }
802 if !matches!(mode, faucet_core::WriteMode::Upsert) {
803 return Err(CliError::Config(format!(
804 "row '{}': `complete_for.on_missing: delete` requires \
805 `write_mode: upsert` (got '{}') — on an append sink there is no key \
806 to tell a written row from a stale one",
807 ids[i], requested_mode
808 )));
809 }
810 if matches!(delivery, faucet_core::DeliveryMode::ExactlyOnce) {
814 return Err(CliError::Config(format!(
815 "row '{}': `complete_for.on_missing: delete` is incompatible with \
816 `delivery: exactly_once` — the scoped delete happens outside the \
817 commit-token transaction, so it cannot be replayed idempotently",
818 ids[i]
819 )));
820 }
821 let mut quarantines: Vec<&str> = Vec::new();
826 #[cfg(feature = "quality")]
827 if let Some(q) = cfg.pipeline.quality.as_ref()
828 && faucet_core::CompiledQuality::compile(q)
829 .map(|c| c.requires_dlq())
830 .unwrap_or(false)
831 {
832 quarantines.push("quality");
833 }
834 #[cfg(feature = "contract")]
835 if let Some(c) = cfg.pipeline.contract.as_ref()
836 && faucet_core::CompiledContract::compile(c)
837 .map(|c| c.requires_dlq())
838 .unwrap_or(false)
839 {
840 quarantines.push("contract");
841 }
842 if let Some(sd) = cfg.pipeline.schema.as_ref()
843 && faucet_core::SchemaDriftPolicy::compile(sd).requires_dlq()
844 {
845 quarantines.push("schema");
846 }
847 if !quarantines.is_empty() {
848 return Err(CliError::Config(format!(
849 "row '{}': `complete_for.on_missing: delete` is incompatible with a \
850 quarantining `{}` policy — a quarantined record never reaches the \
851 sink, so cleanup cannot tell it from a record deleted at the source \
852 and would delete its destination row",
853 ids[i],
854 quarantines.join("`/`")
855 )));
856 }
857 Some(claim.scope.clone())
858 }
859 };
860
861 if let Some(ref sd) = cfg.pipeline.schema {
866 let policy = faucet_core::SchemaDriftPolicy::compile(sd);
867 if policy.on_drift == faucet_core::OnDrift::Evolve
868 && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
869 {
870 return Err(CliError::Config(format!(
871 "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
872 (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
873 ids[i], merged_sink.kind
874 )));
875 }
876 if policy.requires_dlq() && dlq.is_none() {
877 return Err(CliError::Config(format!(
878 "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
879 ids[i]
880 )));
881 }
882 if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
883 return Err(CliError::Config(format!(
884 "row '{}': schema quarantine is incompatible with delivery: exactly_once \
885 (exactly_once forbids a DLQ)",
886 ids[i]
887 )));
888 }
889 }
890
891 let partition_spec = row.partition.clone().or_else(|| {
898 matches!(role, NodeRole::Root)
902 .then(|| cfg.partition.clone())
903 .flatten()
904 });
905 let chunks = match partition_spec.as_ref() {
906 None => Vec::new(),
907 Some(spec) => {
908 let me = ids[i].as_str();
913 let dependents: Vec<&str> = rows
914 .iter()
915 .enumerate()
916 .filter(|(j, r)| {
917 *j != i
918 && (r.parent.as_deref() == Some(me)
919 || r.depends_on.iter().any(|d| d == me))
920 })
921 .map(|(j, _)| ids[j].as_str())
922 .collect();
923 if !dependents.is_empty() {
924 return Err(CliError::Config(format!(
925 "row '{}': a partitioned row cannot be referenced by another row \
926 (`parent:` or `depends_on:`) — it expands into one node per chunk, \
927 so there is no single node for '{}' to attach to. Partition the \
928 dependent row instead, or drop the reference",
929 me,
930 dependents.join("', '")
931 )));
932 }
933 let serialized = merged_source.config.to_string();
934 if !crate::partition::references_partition(&serialized) {
935 return Err(CliError::Config(format!(
936 "row '{}': a `partition:` block is set but the source config references \
937 no `${{partition.*}}` token — every chunk would run the identical \
938 query. Scope the source to the chunk (e.g. \
939 `?id_from=${{partition.start}}&id_to=${{partition.end}}`). Available \
940 tokens for kind `{}`: {}",
941 ids[i],
942 spec.kind_str(),
943 spec.token_names().join(", ")
944 )));
945 }
946 crate::partition::plan(spec)
947 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?
948 }
949 };
950 if chunks.len() >= crate::chunking::WARN_UNITS {
951 tracing::warn!(
952 row = %ids[i],
953 chunks = chunks.len(),
954 "this row plans a very large number of partitions; each is a full pipeline \
955 invocation with its own connector clients"
956 );
957 }
958
959 let base = ExpandedNode {
960 id: ids[i].clone(),
961 row_index: i,
962 role,
963 source: merged_source,
964 sink: merged_sink,
965 transforms,
966 state,
967 dlq,
968 delivery,
969 delivery_guarantee,
970 #[cfg(feature = "quality")]
971 quality,
972 #[cfg(feature = "contract")]
973 contract,
974 #[cfg(feature = "masking")]
975 masking,
976 sink_ref,
977 schema: cfg.pipeline.schema.clone(),
978 depends_on: deps_by_row[i].clone(),
979 status,
980 tags,
981 deferred_refs: deferred,
982 source_override: None,
983 cleanup_scope,
984 };
985
986 if chunks.is_empty() {
987 out.push(base);
988 } else {
989 for chunk in &chunks {
994 let mut n = base.clone();
995 n.id = format!("{}::partition::{}", base.id, chunk.id);
996 crate::partition::substitute(&mut n.source.config, chunk)
997 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
998 crate::partition::substitute(&mut n.sink.config, chunk)
999 .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
1000 out.push(n);
1001 }
1002 }
1003 }
1004 Ok(out)
1005}
1006
1007fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
1008 for &start in parents.keys() {
1011 let mut visited: BTreeSet<&str> = BTreeSet::new();
1012 let mut cur = start;
1013 while let Some(&p) = parents.get(cur) {
1014 if !visited.insert(cur) {
1015 let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1016 return Err(CliError::ParentCycle { ids: chain });
1017 }
1018 cur = p;
1019 if cur == start {
1020 let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1021 chain.push(start.to_string());
1022 return Err(CliError::ParentCycle { ids: chain });
1023 }
1024 }
1025 }
1026 Ok(())
1027}
1028
1029fn detect_combined_cycle(
1036 ids: &[String],
1037 parents: &HashMap<&str, &str>,
1038 deps_by_row: &[Vec<String>],
1039) -> CliResult<()> {
1040 let index_of: HashMap<&str, usize> = ids
1041 .iter()
1042 .enumerate()
1043 .map(|(i, id)| (id.as_str(), i))
1044 .collect();
1045 let mut in_degree = vec![0usize; ids.len()];
1046 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
1047 for (i, id) in ids.iter().enumerate() {
1048 let mut prereqs: Vec<usize> = Vec::new();
1049 if let Some(p) = parents.get(id.as_str()) {
1050 prereqs.push(index_of[p]);
1051 }
1052 prereqs.extend(deps_by_row[i].iter().map(|d| index_of[d.as_str()]));
1053 for p in prereqs {
1054 in_degree[i] += 1;
1055 dependents[p].push(i);
1056 }
1057 }
1058 let mut queue: std::collections::VecDeque<usize> =
1059 (0..ids.len()).filter(|&i| in_degree[i] == 0).collect();
1060 let mut processed = 0usize;
1061 while let Some(i) = queue.pop_front() {
1062 processed += 1;
1063 for &d in &dependents[i] {
1064 in_degree[d] -= 1;
1065 if in_degree[d] == 0 {
1066 queue.push_back(d);
1067 }
1068 }
1069 }
1070 if processed < ids.len() {
1071 let mut stuck: Vec<String> = (0..ids.len())
1072 .filter(|&i| in_degree[i] > 0)
1073 .map(|i| ids[i].clone())
1074 .collect();
1075 stuck.sort();
1076 return Err(CliError::DependencyCycle { ids: stuck });
1077 }
1078 Ok(())
1079}
1080
1081fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
1085 walk_strings(value, &mut |s| {
1086 for (token, dir) in iter_directives(s) {
1087 if let Directive::Deferred { id, .. } = dir
1098 && id == crate::params::PARAM_ID
1099 {
1100 return Err(CliError::Config(format!(
1101 "interpolation token `{token}` (in {owner}) was never bound — a `${{param.*}}` \
1102 reference is resolved when the run is triggered. Load the config through \
1103 `PipelineConfig::from_path*` (or supply values with `--param`) so params are \
1104 bound before expansion"
1105 )));
1106 }
1107 if let Directive::Deferred { id, .. } = dir
1108 && id != "now"
1109 && id != "backfill"
1110 && id != "partition"
1111 && !id_set.contains(id)
1112 {
1113 return Err(CliError::UnknownInterpolationId {
1114 id: id.to_owned(),
1115 token: format!("{token} (in {owner})"),
1116 });
1117 }
1118 }
1119 Ok(())
1120 })
1121}
1122
1123fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
1131 walk_strings(value, &mut |s| {
1132 for (token, dir) in iter_directives(s) {
1133 if let Directive::Deferred { .. } = dir {
1134 return Err(CliError::Config(format!(
1135 "interpolation token `{token}` in {location} is not supported: \
1136 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
1137 resolve only in source/sink configs"
1138 )));
1139 }
1140 }
1141 Ok(())
1142 })
1143}
1144
1145fn resolve_tags(
1150 template_tags: &[String],
1151 row_tags: &[String],
1152 row_id: &str,
1153) -> CliResult<Vec<String>> {
1154 let mut set: BTreeSet<String> = BTreeSet::new();
1155 for tag in template_tags.iter().chain(row_tags.iter()) {
1156 validate_tag(tag, row_id)?;
1157 set.insert(tag.clone());
1158 }
1159 Ok(set.into_iter().collect())
1160}
1161
1162fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
1164 let ok = {
1165 let mut chars = tag.chars();
1166 match chars.next() {
1167 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
1168 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1169 }
1170 _ => false,
1171 }
1172 };
1173 if !ok {
1174 return Err(CliError::Config(format!(
1175 "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
1176 (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
1177 )));
1178 }
1179 Ok(())
1180}
1181
1182fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
1183 let _ = walk_strings(value, &mut |s| {
1184 for (token, dir) in iter_directives(s) {
1185 if let Directive::Deferred { id, path } = dir {
1186 if id == "now" || id == "backfill" || id == "partition" {
1191 continue;
1192 }
1193 out.push(DeferredRef {
1194 referenced_id: id.to_owned(),
1195 dotted_path: path.to_owned(),
1196 token: token.to_owned(),
1197 });
1198 }
1199 }
1200 Ok(())
1201 });
1202}
1203
1204fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
1205where
1206 F: FnMut(&str) -> CliResult<()>,
1207{
1208 match value {
1209 Value::String(s) => f(s),
1210 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
1211 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1212 _ => Ok(()),
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use crate::config::{OnBatchErrorSpec, parse_with_extension};
1220
1221 fn cfg(yaml: &str) -> PipelineConfig {
1222 parse_with_extension(yaml, "yaml").unwrap()
1223 }
1224
1225 #[test]
1226 fn implicit_single_row_when_matrix_absent() {
1227 let c = cfg(r#"
1228version: 1
1229pipeline:
1230 source: { type: rest, config: { base_url: https://x } }
1231 sink: { type: jsonl, config: { path: ./o } }
1232"#);
1233 let nodes = expand(&c).unwrap();
1234 assert_eq!(nodes.len(), 1);
1235 assert_eq!(nodes[0].id, "row-0");
1236 assert!(matches!(nodes[0].role, NodeRole::Root));
1237 assert_eq!(nodes[0].source.kind, "rest");
1238 assert_eq!(nodes[0].sink.kind, "jsonl");
1239 }
1240
1241 #[test]
1242 fn rejects_runtime_token_in_dlq_config() {
1243 let c = cfg(r#"
1247version: 1
1248pipeline:
1249 source: { type: rest, config: { base_url: https://x } }
1250 sink: { type: jsonl, config: { path: ./o } }
1251 dlq:
1252 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1253"#);
1254 let err = expand(&c).unwrap_err();
1255 assert!(
1256 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1257 "got: {err:?}"
1258 );
1259 }
1260
1261 #[test]
1262 fn rejects_runtime_token_in_state_config() {
1263 let c = cfg(r#"
1264version: 1
1265pipeline:
1266 source: { type: rest, config: { base_url: https://x } }
1267 sink: { type: jsonl, config: { path: ./o } }
1268 state:
1269 type: file
1270 config: { path: "state-${now.date}" }
1271"#);
1272 let err = expand(&c).unwrap_err();
1273 assert!(
1274 matches!(&err, CliError::Config(m) if m.contains("state")),
1275 "got: {err:?}"
1276 );
1277 }
1278
1279 #[test]
1280 fn rejects_runtime_token_in_transform_config() {
1281 let c = cfg(r#"
1282version: 1
1283pipeline:
1284 source: { type: rest, config: { base_url: https://x } }
1285 sink: { type: jsonl, config: { path: ./o } }
1286 transforms:
1287 - type: set
1288 config: { field: ts, value: "${now.datetime}" }
1289"#);
1290 let err = expand(&c).unwrap_err();
1291 assert!(
1292 matches!(&err, CliError::Config(m) if m.contains("transform")),
1293 "got: {err:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn allows_runtime_token_in_source_and_sink_configs() {
1299 let c = cfg(r#"
1302version: 1
1303pipeline:
1304 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1305 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1306"#);
1307 let nodes = expand(&c).unwrap();
1308 assert_eq!(nodes.len(), 1);
1309 }
1310
1311 #[test]
1312 fn merges_row_overrides_into_pipeline_source() {
1313 let c = cfg(r#"
1314version: 1
1315pipeline:
1316 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1317 sink: { type: jsonl, config: { path: ./o } }
1318matrix:
1319 - id: users
1320 source: { config: { path: /v1/users, headers: { b: 2 } } }
1321"#);
1322 let nodes = expand(&c).unwrap();
1323 assert_eq!(nodes[0].id, "users");
1324 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1325 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1326 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1327 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1328 }
1329
1330 #[test]
1331 fn errors_on_unknown_parent() {
1332 let c = cfg(r#"
1333version: 1
1334pipeline:
1335 source: { type: rest, config: {} }
1336 sink: { type: jsonl, config: { path: ./o } }
1337matrix:
1338 - id: child
1339 parent: nobody
1340"#);
1341 assert!(matches!(
1342 expand(&c).unwrap_err(),
1343 CliError::UnknownParent { .. }
1344 ));
1345 }
1346
1347 #[test]
1348 fn errors_on_duplicate_ids() {
1349 let c = cfg(r#"
1350version: 1
1351pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1352matrix:
1353 - { id: x }
1354 - { id: x }
1355"#);
1356 assert!(matches!(
1357 expand(&c).unwrap_err(),
1358 CliError::DuplicateRowId { .. }
1359 ));
1360 }
1361
1362 #[test]
1363 fn errors_on_reserved_id() {
1364 let c = cfg(r#"
1365version: 1
1366pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1367matrix:
1368 - { id: env }
1369"#);
1370 assert!(matches!(
1371 expand(&c).unwrap_err(),
1372 CliError::ReservedRowId { .. }
1373 ));
1374 }
1375
1376 #[test]
1377 fn errors_on_self_parent_cycle() {
1378 let c = cfg(r#"
1379version: 1
1380pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1381matrix:
1382 - { id: a, parent: a }
1383"#);
1384 assert!(matches!(
1385 expand(&c).unwrap_err(),
1386 CliError::ParentCycle { .. }
1387 ));
1388 }
1389
1390 #[test]
1391 fn errors_on_two_node_cycle() {
1392 let c = cfg(r#"
1393version: 1
1394pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1395matrix:
1396 - { id: a, parent: b }
1397 - { id: b, parent: a }
1398"#);
1399 assert!(matches!(
1400 expand(&c).unwrap_err(),
1401 CliError::ParentCycle { .. }
1402 ));
1403 }
1404
1405 #[test]
1406 fn errors_on_unknown_dependency() {
1407 let c = cfg(r#"
1408version: 1
1409pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1410matrix:
1411 - { id: facts, depends_on: [nobody] }
1412"#);
1413 match expand(&c).unwrap_err() {
1414 CliError::UnknownDependency { id, depends_on } => {
1415 assert_eq!(id, "facts");
1416 assert_eq!(depends_on, "nobody");
1417 }
1418 other => panic!("expected UnknownDependency, got {other:?}"),
1419 }
1420 }
1421
1422 #[test]
1423 fn errors_on_self_dependency() {
1424 let c = cfg(r#"
1425version: 1
1426pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1427matrix:
1428 - { id: a, depends_on: [a] }
1429"#);
1430 match expand(&c).unwrap_err() {
1431 CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1432 other => panic!("expected DependencyCycle, got {other:?}"),
1433 }
1434 }
1435
1436 #[test]
1437 fn errors_on_depends_on_cycle() {
1438 let c = cfg(r#"
1439version: 1
1440pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1441matrix:
1442 - { id: a, depends_on: [b] }
1443 - { id: b, depends_on: [a] }
1444"#);
1445 match expand(&c).unwrap_err() {
1446 CliError::DependencyCycle { ids } => {
1447 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1448 }
1449 other => panic!("expected DependencyCycle, got {other:?}"),
1450 }
1451 }
1452
1453 #[test]
1454 fn errors_on_mixed_parent_depends_on_cycle() {
1455 let c = cfg(r#"
1459version: 1
1460pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1461matrix:
1462 - { id: a, parent: b }
1463 - { id: b, depends_on: [a] }
1464"#);
1465 match expand(&c).unwrap_err() {
1466 CliError::DependencyCycle { ids } => {
1467 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1468 }
1469 other => panic!("expected DependencyCycle, got {other:?}"),
1470 }
1471 }
1472
1473 #[test]
1474 fn depends_on_is_recorded_and_deduped() {
1475 let c = cfg(r#"
1476version: 1
1477pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1478matrix:
1479 - { id: dims }
1480 - { id: staging }
1481 - { id: facts, depends_on: [dims, staging, dims] }
1482"#);
1483 let nodes = expand(&c).unwrap();
1484 let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
1485 assert_eq!(
1486 facts.depends_on,
1487 vec!["dims".to_string(), "staging".to_string()]
1488 );
1489 assert!(matches!(facts.role, NodeRole::Root));
1490 let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
1491 assert!(dims.depends_on.is_empty());
1492 }
1493
1494 #[test]
1495 fn depends_on_may_target_a_child_row() {
1496 let c = cfg(r#"
1499version: 1
1500pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1501matrix:
1502 - { id: users }
1503 - { id: posts, parent: users }
1504 - { id: rollup, depends_on: [posts] }
1505"#);
1506 let nodes = expand(&c).unwrap();
1507 let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
1508 assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
1509 }
1510
1511 #[test]
1512 fn errors_on_unknown_interpolation_id() {
1513 let c = cfg(r#"
1514version: 1
1515pipeline:
1516 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
1517 sink: { type: jsonl, config: { path: ./o } }
1518"#);
1519 assert!(matches!(
1520 expand(&c).unwrap_err(),
1521 CliError::UnknownInterpolationId { .. }
1522 ));
1523 }
1524
1525 #[test]
1526 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
1527 let c = cfg(r#"
1532version: 1
1533pipeline:
1534 source: { type: rest, config: { url: "https://x/${env.foo}" } }
1535 sink: { type: jsonl, config: { path: ./o } }
1536"#);
1537 match expand(&c).unwrap_err() {
1538 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
1539 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
1540 }
1541 }
1542
1543 #[test]
1544 fn accepts_id_path_when_referenced_row_exists() {
1545 let c = cfg(r#"
1546version: 1
1547pipeline:
1548 source: { type: rest, config: {} }
1549 sink: { type: jsonl, config: { path: ./o } }
1550matrix:
1551 - id: users
1552 - id: posts
1553 parent: users
1554 source: { config: { path: "/v1/users/${users.id}/posts" } }
1555"#);
1556 let nodes = expand(&c).unwrap();
1557 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1558 assert_eq!(posts.deferred_refs.len(), 1);
1559 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
1560 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
1561 }
1562
1563 #[test]
1564 fn nested_referenced_path_resolves() {
1565 let c = cfg(r#"
1566version: 1
1567pipeline:
1568 source: { type: rest, config: {} }
1569 sink: { type: jsonl, config: { path: ./o } }
1570matrix:
1571 - id: users
1572 - id: addrs
1573 parent: users
1574 source: { config: { path: "/users/${users.addr.city}/addr" } }
1575"#);
1576 let nodes = expand(&c).unwrap();
1577 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
1578 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
1579 }
1580
1581 #[test]
1582 fn roots_come_before_children_in_order() {
1583 let c = cfg(r#"
1584version: 1
1585pipeline:
1586 source: { type: rest, config: {} }
1587 sink: { type: jsonl, config: { path: ./o } }
1588matrix:
1589 - id: posts
1590 parent: users
1591 - id: users
1592"#);
1593 let nodes = expand(&c).unwrap();
1594 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
1595 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
1596 assert!(users_idx < posts_idx, "users must precede posts");
1597 }
1598
1599 #[test]
1600 fn child_node_has_parent_role() {
1601 let c = cfg(r#"
1602version: 1
1603pipeline:
1604 source: { type: rest, config: {} }
1605 sink: { type: jsonl, config: { path: ./o } }
1606matrix:
1607 - id: users
1608 - id: posts
1609 parent: users
1610 parent_key: user_id
1611"#);
1612 let nodes = expand(&c).unwrap();
1613 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1614 match &posts.role {
1615 NodeRole::Child {
1616 parent_id,
1617 parent_key,
1618 } => {
1619 assert_eq!(parent_id, "users");
1620 assert_eq!(parent_key, "user_id");
1621 }
1622 other => panic!("expected Child, got {other:?}"),
1623 }
1624 }
1625
1626 #[test]
1627 fn expand_rejects_zero_per_page_budget() {
1628 let yaml = r#"
1629version: 1
1630pipeline:
1631 source: { type: rest, config: {} }
1632 sink: { type: jsonl, config: { path: ./o.jsonl } }
1633 dlq:
1634 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1635 max_failures_per_page: 0
1636"#;
1637 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1638 let err = expand(&cfg).unwrap_err();
1639 assert!(matches!(
1640 err,
1641 CliError::InvalidDlqBudget {
1642 field: "max_failures_per_page"
1643 }
1644 ));
1645 }
1646
1647 #[test]
1648 fn expand_rejects_zero_total_budget() {
1649 let yaml = r#"
1650version: 1
1651pipeline:
1652 source: { type: rest, config: {} }
1653 sink: { type: jsonl, config: { path: ./o.jsonl } }
1654 dlq:
1655 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1656 max_failures_total: 0
1657"#;
1658 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1659 let err = expand(&cfg).unwrap_err();
1660 assert!(matches!(
1661 err,
1662 CliError::InvalidDlqBudget {
1663 field: "max_failures_total"
1664 }
1665 ));
1666 }
1667
1668 #[test]
1669 fn expand_rejects_unknown_dlq_sink_kind() {
1670 let yaml = r#"
1671version: 1
1672pipeline:
1673 source: { type: rest, config: {} }
1674 sink: { type: jsonl, config: { path: ./o.jsonl } }
1675 dlq:
1676 sink: { type: not_a_sink, config: {} }
1677"#;
1678 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1679 let err = expand(&cfg).unwrap_err();
1680 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1681 }
1682
1683 #[cfg(feature = "quality")]
1684 #[test]
1685 fn expand_rejects_quarantine_without_dlq() {
1686 let yaml = r#"
1689version: 1
1690pipeline:
1691 source: { type: rest, config: {} }
1692 sink: { type: jsonl, config: { path: ./o.jsonl } }
1693 quality:
1694 record:
1695 - { type: not_null, field: id, on_failure: quarantine }
1696"#;
1697 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1698 let err = expand(&cfg).unwrap_err();
1699 match err {
1700 CliError::Config(msg) => {
1701 assert!(msg.contains("quarantine"), "{msg}");
1702 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1703 }
1704 other => panic!("expected Config error, got {other:?}"),
1705 }
1706 }
1707
1708 #[cfg(feature = "quality")]
1709 #[test]
1710 fn expand_accepts_quarantine_with_dlq() {
1711 let yaml = r#"
1712version: 1
1713pipeline:
1714 source: { type: rest, config: {} }
1715 sink: { type: jsonl, config: { path: ./o.jsonl } }
1716 dlq:
1717 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1718 quality:
1719 record:
1720 - { type: not_null, field: id, on_failure: quarantine }
1721"#;
1722 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1723 let nodes = expand(&cfg).unwrap();
1724 assert_eq!(nodes.len(), 1);
1725 let q = nodes[0]
1726 .quality
1727 .as_ref()
1728 .expect("quality threaded onto node");
1729 assert_eq!(q.record.len(), 1);
1730 }
1731
1732 #[cfg(feature = "quality")]
1733 #[test]
1734 fn expand_accepts_abort_quality_without_dlq() {
1735 let yaml = r#"
1737version: 1
1738pipeline:
1739 source: { type: rest, config: {} }
1740 sink: { type: jsonl, config: { path: ./o.jsonl } }
1741 quality:
1742 record:
1743 - { type: not_null, field: id, on_failure: abort }
1744"#;
1745 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1746 let nodes = expand(&cfg).unwrap();
1747 assert!(nodes[0].quality.is_some());
1748 }
1749
1750 #[cfg(feature = "contract")]
1751 #[test]
1752 fn expand_rejects_contract_quarantine_without_dlq() {
1753 let yaml = r#"
1754version: 1
1755pipeline:
1756 source: { type: rest, config: {} }
1757 sink: { type: jsonl, config: { path: ./o.jsonl } }
1758 contract:
1759 version: "1.0.0"
1760 on_breach: quarantine
1761 fields:
1762 - { name: id, type: integer }
1763"#;
1764 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1765 let err = expand(&cfg).unwrap_err();
1766 match err {
1767 CliError::Config(msg) => {
1768 assert!(msg.contains("on_breach: quarantine"), "{msg}");
1769 assert!(msg.contains("dlq"), "{msg}");
1770 }
1771 other => panic!("expected Config error, got {other:?}"),
1772 }
1773 }
1774
1775 #[cfg(feature = "contract")]
1776 #[test]
1777 fn expand_accepts_contract_quarantine_with_dlq() {
1778 let yaml = r#"
1779version: 1
1780pipeline:
1781 source: { type: rest, config: {} }
1782 sink: { type: jsonl, config: { path: ./o.jsonl } }
1783 dlq:
1784 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1785 contract:
1786 version: "1.0.0"
1787 on_breach: quarantine
1788 fields:
1789 - { name: id, type: integer }
1790"#;
1791 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1792 let nodes = expand(&cfg).unwrap();
1793 assert_eq!(nodes.len(), 1);
1794 let c = nodes[0]
1795 .contract
1796 .as_ref()
1797 .expect("contract threaded onto node");
1798 assert_eq!(c.version, "1.0.0");
1799 assert_eq!(c.fields.len(), 1);
1800 }
1801
1802 #[cfg(feature = "contract")]
1803 #[test]
1804 fn expand_accepts_contract_fail_without_dlq() {
1805 let yaml = r#"
1807version: 1
1808pipeline:
1809 source: { type: rest, config: {} }
1810 sink: { type: jsonl, config: { path: ./o.jsonl } }
1811 contract:
1812 version: "1.0.0"
1813 fields:
1814 - { name: id, type: integer }
1815"#;
1816 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1817 let nodes = expand(&cfg).unwrap();
1818 assert!(nodes[0].contract.is_some());
1819 }
1820
1821 #[cfg(feature = "contract")]
1822 #[test]
1823 fn expand_rejects_malformed_contract() {
1824 let yaml = r#"
1826version: 1
1827pipeline:
1828 source: { type: rest, config: {} }
1829 sink: { type: jsonl, config: { path: ./o.jsonl } }
1830 contract:
1831 version: "1.0.0"
1832 fields:
1833 - { name: email, type: string, pattern: "[invalid" }
1834"#;
1835 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1836 let err = expand(&cfg).unwrap_err();
1837 match err {
1838 CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
1839 other => panic!("expected Config error, got {other:?}"),
1840 }
1841 }
1842
1843 #[test]
1844 fn legacy_singular_source_resolves_as_default_template() {
1845 let c = cfg(r#"
1846version: 1
1847pipeline:
1848 source: { type: rest, config: { base_url: https://x } }
1849 sink: { type: jsonl, config: { path: ./o } }
1850"#);
1851 let nodes = expand(&c).unwrap();
1852 assert_eq!(nodes[0].source.kind, "rest");
1853 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1854 }
1855
1856 #[test]
1857 fn row_with_ref_picks_named_template() {
1858 let c = cfg(r#"
1859version: 1
1860pipeline:
1861 sources:
1862 users_api: { type: rest, config: { base_url: https://x } }
1863 sinks:
1864 archive: { type: jsonl, config: { path: ./out } }
1865matrix:
1866 - id: load_users
1867 source:
1868 ref: users_api
1869 config: { path: /v1/users }
1870 sink:
1871 ref: archive
1872 config: { path: ./users.jsonl }
1873"#);
1874 let nodes = expand(&c).unwrap();
1875 assert_eq!(nodes[0].source.kind, "rest");
1876 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1877 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1878 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1879 }
1880
1881 #[test]
1882 fn row_without_ref_falls_back_to_default_template() {
1883 let c = cfg(r#"
1884version: 1
1885pipeline:
1886 source: { type: rest, config: { base_url: https://x } }
1887 sink: { type: jsonl, config: { path: ./o } }
1888matrix:
1889 - id: users
1890 source: { config: { path: /v1/users } }
1891"#);
1892 let nodes = expand(&c).unwrap();
1893 assert_eq!(nodes[0].source.kind, "rest");
1894 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1895 }
1896
1897 #[test]
1898 fn unknown_template_ref_errors_with_known_list() {
1899 let c = cfg(r#"
1900version: 1
1901pipeline:
1902 sources:
1903 a: { type: rest, config: {} }
1904 b: { type: rest, config: {} }
1905 sinks:
1906 s: { type: jsonl, config: { path: ./o } }
1907matrix:
1908 - id: x
1909 source: { ref: c }
1910 sink: { ref: s }
1911"#);
1912 let err = expand(&c).unwrap_err();
1913 match err {
1914 CliError::UnknownTemplate {
1915 kind,
1916 name,
1917 row_id,
1918 known,
1919 } => {
1920 assert_eq!(kind, "source");
1921 assert_eq!(name, "c");
1922 assert_eq!(row_id, "x");
1923 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1924 }
1925 other => panic!("expected UnknownTemplate, got {other:?}"),
1926 }
1927 }
1928
1929 #[test]
1930 fn missing_default_template_errors() {
1931 let c = cfg(r#"
1934version: 1
1935pipeline:
1936 sources:
1937 users_api: { type: rest, config: {} }
1938 sink: { type: jsonl, config: { path: ./o } }
1939matrix:
1940 - id: x
1941 source: { config: { path: /v1 } }
1942"#);
1943 let err = expand(&c).unwrap_err();
1944 match err {
1945 CliError::MissingTemplate { kind, row_id } => {
1946 assert_eq!(kind, "source");
1947 assert_eq!(row_id, "x");
1948 }
1949 other => panic!("expected MissingTemplate, got {other:?}"),
1950 }
1951 }
1952
1953 #[test]
1954 fn duplicate_default_template_errors() {
1955 let c = cfg(r#"
1957version: 1
1958pipeline:
1959 source: { type: rest, config: {} }
1960 sources:
1961 default: { type: rest, config: {} }
1962 sink: { type: jsonl, config: { path: ./o } }
1963"#);
1964 let err = expand(&c).unwrap_err();
1965 match err {
1966 CliError::DuplicateTemplate { kind, name } => {
1967 assert_eq!(kind, "source");
1968 assert_eq!(name, "default");
1969 }
1970 other => panic!("expected DuplicateTemplate, got {other:?}"),
1971 }
1972 }
1973
1974 #[test]
1975 fn row_can_override_template_kind() {
1976 let c = cfg(r#"
1977version: 1
1978pipeline:
1979 sources:
1980 api: { type: rest, config: { base_url: https://x } }
1981 sinks:
1982 out: { type: jsonl, config: { path: ./o } }
1983matrix:
1984 - id: x
1985 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1986 sink: { ref: out }
1987"#);
1988 let nodes = expand(&c).unwrap();
1989 assert_eq!(nodes[0].source.kind, "graphql");
1990 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1991 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1992 }
1993
1994 #[test]
1995 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1996 let yaml = r#"
1997version: 1
1998pipeline:
1999 source: { type: rest, config: {} }
2000 sink: { type: jsonl, config: { path: ./o.jsonl } }
2001 dlq:
2002 sink: { type: jsonl, config: { path: ./base.jsonl } }
2003matrix:
2004 - id: a
2005 - id: b
2006 dlq: null
2007 - id: c
2008 dlq:
2009 sink: { type: jsonl, config: { path: ./c.jsonl } }
2010 on_batch_error: dlq_all
2011"#;
2012 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2013 let nodes = expand(&cfg).unwrap();
2014 assert_eq!(nodes.len(), 3);
2015 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
2017 assert_eq!(
2018 nodes[0]
2019 .dlq
2020 .as_ref()
2021 .unwrap()
2022 .sink
2023 .config
2024 .get("path")
2025 .unwrap(),
2026 "./base.jsonl"
2027 );
2028 assert!(nodes[1].dlq.is_none());
2030 assert_eq!(
2032 nodes[2].dlq.as_ref().unwrap().on_batch_error,
2033 OnBatchErrorSpec::DlqAll
2034 );
2035 assert_eq!(
2036 nodes[2]
2037 .dlq
2038 .as_ref()
2039 .unwrap()
2040 .sink
2041 .config
2042 .get("path")
2043 .unwrap(),
2044 "./c.jsonl"
2045 );
2046 }
2047
2048 #[test]
2049 fn multiple_rows_pick_different_templates() {
2050 let c = cfg(r#"
2051version: 1
2052pipeline:
2053 sources:
2054 users_api: { type: rest, config: { base_url: https://users.example } }
2055 orders_api: { type: rest, config: { base_url: https://orders.example } }
2056 sinks:
2057 archive: { type: jsonl, config: { path: ./out } }
2058matrix:
2059 - id: load_users
2060 source: { ref: users_api, config: { path: /v1/users } }
2061 sink: { ref: archive, config: { path: ./users.jsonl } }
2062 - id: load_orders
2063 source: { ref: orders_api, config: { path: /v1/orders } }
2064 sink: { ref: archive, config: { path: ./orders.jsonl } }
2065"#);
2066 let nodes = expand(&c).unwrap();
2067 assert_eq!(nodes.len(), 2);
2068 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
2069 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
2070 assert_eq!(users.source.config["base_url"], "https://users.example");
2071 assert_eq!(users.source.config["path"], "/v1/users");
2072 assert_eq!(orders.source.config["base_url"], "https://orders.example");
2073 assert_eq!(orders.source.config["path"], "/v1/orders");
2074 assert_eq!(users.sink.config["path"], "./users.jsonl");
2076 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
2077 }
2078
2079 #[test]
2080 fn sink_template_with_transforms_errors_at_expand() {
2081 let yaml = r#"
2082version: 1
2083pipeline:
2084 source:
2085 type: rest
2086 config: {}
2087 sinks:
2088 bad:
2089 type: jsonl
2090 config: { destination: /tmp/x.jsonl }
2091 transforms:
2092 - { type: flatten, config: { separator: "_" } }
2093matrix:
2094 - id: row
2095 sink: { ref: bad }
2096"#;
2097 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2098 .unwrap();
2099 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
2100 match err {
2101 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
2102 other => panic!("expected TransformsOnSink, got {other:?}"),
2103 }
2104 }
2105
2106 #[test]
2107 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
2108 let yaml = r#"
2109version: 1
2110pipeline:
2111 source:
2112 type: rest
2113 config: {}
2114 sinks:
2115 bad:
2116 type: jsonl
2117 config: { destination: /tmp/x.jsonl }
2118 inherit_transforms: false
2119matrix:
2120 - id: row
2121 sink: { ref: bad }
2122"#;
2123 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2124 .unwrap();
2125 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
2126 match err {
2127 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
2128 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
2129 }
2130 }
2131
2132 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
2133 transforms.iter().map(|t| t.kind.clone()).collect()
2134 }
2135
2136 #[test]
2137 fn three_layer_concat_default_inherit() {
2138 let yaml = r#"
2139version: 1
2140pipeline:
2141 transforms:
2142 - { type: flatten, config: { separator: "_" } }
2143 sources:
2144 s:
2145 type: rest
2146 config: {}
2147 transforms:
2148 - { type: keys_case, config: { mode: snake } }
2149 sink:
2150 type: jsonl
2151 config: { destination: /tmp/x.jsonl }
2152matrix:
2153 - id: row
2154 source: { ref: s }
2155 transforms:
2156 - { type: select, config: { fields: [id] } }
2157"#;
2158 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2159 .unwrap();
2160 let nodes = crate::expand::expand(&cfg).unwrap();
2161 assert_eq!(nodes.len(), 1);
2162 assert_eq!(
2163 kinds(&nodes[0].transforms),
2164 vec!["flatten", "keys_case", "select"]
2165 );
2166 }
2167
2168 #[test]
2169 fn source_inherit_false_drops_pipeline_layer() {
2170 let yaml = r#"
2171version: 1
2172pipeline:
2173 transforms:
2174 - { type: flatten, config: { separator: "_" } }
2175 sources:
2176 s:
2177 type: rest
2178 config: {}
2179 inherit_transforms: false
2180 transforms:
2181 - { type: keys_case, config: { mode: snake } }
2182 sink:
2183 type: jsonl
2184 config: { destination: /tmp/x.jsonl }
2185matrix:
2186 - id: row
2187 source: { ref: s }
2188 transforms:
2189 - { type: select, config: { fields: [id] } }
2190"#;
2191 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2192 .unwrap();
2193 let nodes = crate::expand::expand(&cfg).unwrap();
2194 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
2195 }
2196
2197 #[test]
2198 fn row_inherit_false_drops_pipeline_and_source_layers() {
2199 let yaml = r#"
2200version: 1
2201pipeline:
2202 transforms:
2203 - { type: flatten, config: { separator: "_" } }
2204 sources:
2205 s:
2206 type: rest
2207 config: {}
2208 transforms:
2209 - { type: keys_case, config: { mode: snake } }
2210 sink:
2211 type: jsonl
2212 config: { destination: /tmp/x.jsonl }
2213matrix:
2214 - id: row
2215 source: { ref: s }
2216 inherit_transforms: false
2217 transforms:
2218 - { type: select, config: { fields: [id] } }
2219"#;
2220 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2221 .unwrap();
2222 let nodes = crate::expand::expand(&cfg).unwrap();
2223 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2224 }
2225
2226 #[test]
2227 fn both_inherit_false_yields_row_only() {
2228 let yaml = r#"
2229version: 1
2230pipeline:
2231 transforms:
2232 - { type: flatten, config: { separator: "_" } }
2233 sources:
2234 s:
2235 type: rest
2236 config: {}
2237 inherit_transforms: false
2238 transforms:
2239 - { type: keys_case, config: { mode: snake } }
2240 sink:
2241 type: jsonl
2242 config: { destination: /tmp/x.jsonl }
2243matrix:
2244 - id: row
2245 source: { ref: s }
2246 inherit_transforms: false
2247 transforms:
2248 - { type: select, config: { fields: [id] } }
2249"#;
2250 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2251 .unwrap();
2252 let nodes = crate::expand::expand(&cfg).unwrap();
2253 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2254 }
2255
2256 #[test]
2257 fn all_layers_omitted_yields_empty_transforms() {
2258 let yaml = r#"
2259version: 1
2260pipeline:
2261 source:
2262 type: rest
2263 config: {}
2264 sink:
2265 type: jsonl
2266 config: { destination: /tmp/x.jsonl }
2267matrix:
2268 - id: row
2269"#;
2270 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2271 .unwrap();
2272 let nodes = crate::expand::expand(&cfg).unwrap();
2273 assert!(nodes[0].transforms.is_empty());
2274 }
2275
2276 #[test]
2277 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
2278 let yaml = r#"
2280version: 1
2281pipeline:
2282 source: { type: rest, config: {} }
2283 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
2284"#;
2285 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2286 assert!(expand(&cfg).is_ok());
2288 }
2289
2290 #[test]
2291 fn now_is_a_reserved_row_id() {
2292 let yaml = r#"
2293version: 1
2294pipeline:
2295 source: { type: rest, config: {} }
2296 sink: { type: jsonl, config: { path: ./o.jsonl } }
2297matrix:
2298 - id: now
2299"#;
2300 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2301 match expand(&cfg).unwrap_err() {
2302 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
2303 other => panic!("expected ReservedRowId, got {other:?}"),
2304 }
2305 }
2306
2307 #[test]
2308 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
2309 let yaml = r#"
2313version: 1
2314pipeline:
2315 source: { type: rest, config: {} }
2316 sink: { type: jsonl, config: { path: ./o.jsonl } }
2317execution:
2318 adaptive_batch_size:
2319 enabled: true
2320 min: 5000
2321 max: 100
2322"#;
2323 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2324 let err = expand(&cfg).unwrap_err();
2325 assert!(
2326 err.to_string().contains("adaptive_batch_size.min"),
2327 "expected adaptive validation error, got: {err}"
2328 );
2329 }
2330
2331 #[test]
2332 fn expand_accepts_valid_adaptive_batch_size() {
2333 let yaml = r#"
2334version: 1
2335pipeline:
2336 source: { type: rest, config: {} }
2337 sink: { type: jsonl, config: { path: ./o.jsonl } }
2338execution:
2339 adaptive_batch_size:
2340 enabled: true
2341 min: 100
2342 max: 5000
2343 target_latency_ms: 500
2344"#;
2345 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2346 assert!(expand(&cfg).is_ok());
2347 }
2348
2349 #[test]
2352 fn exactly_once_rejects_non_cdc_source() {
2353 let yaml = r#"
2355version: 1
2356delivery: exactly_once
2357pipeline:
2358 source: { type: rest, config: { base_url: https://x } }
2359 sink: { type: stdout, config: {} }
2360 state:
2361 type: memory
2362 config: {}
2363"#;
2364 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2365 let err = expand(&cfg).unwrap_err();
2366 match &err {
2367 CliError::Config(msg) => {
2368 assert!(
2369 msg.contains("rest"),
2370 "expected source kind in error, got: {msg}"
2371 );
2372 assert!(
2373 msg.contains("exactly_once") || msg.contains("not supported"),
2374 "got: {msg}"
2375 );
2376 }
2377 other => panic!("expected Config error, got {other:?}"),
2378 }
2379 }
2380
2381 #[test]
2382 fn exactly_once_rejects_non_idempotent_sink() {
2383 let yaml = r#"
2385version: 1
2386delivery: exactly_once
2387pipeline:
2388 source: { type: postgres-cdc, config: {} }
2389 sink: { type: stdout, config: {} }
2390 state:
2391 type: memory
2392 config: {}
2393"#;
2394 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2395 let err = expand(&cfg).unwrap_err();
2396 match &err {
2397 CliError::Config(msg) => {
2398 assert!(
2399 msg.contains("stdout"),
2400 "expected sink kind in error, got: {msg}"
2401 );
2402 assert!(
2403 msg.contains("exactly_once") || msg.contains("not supported"),
2404 "got: {msg}"
2405 );
2406 }
2407 other => panic!("expected Config error, got {other:?}"),
2408 }
2409 }
2410
2411 #[test]
2412 fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
2413 let yaml = r#"
2417version: 1
2418delivery: exactly_once
2419pipeline:
2420 source: { type: postgres-cdc, config: {} }
2421 sink: { type: sqlite, config: {} }
2422 state:
2423 type: file
2424 config: { path: "/tmp/faucet-eo-state.json" }
2425"#;
2426 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2427 let nodes = expand(&cfg).unwrap();
2428 assert_eq!(nodes.len(), 1);
2429 assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
2430 assert_eq!(
2431 nodes[0].delivery_guarantee,
2432 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2433 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2434 )
2435 );
2436 }
2437
2438 #[test]
2439 fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
2440 let yaml = r#"
2444version: 1
2445delivery: exactly_once
2446pipeline:
2447 source: { type: rest, config: { base_url: https://x } }
2448 sink:
2449 type: postgres
2450 config:
2451 connection_url: "postgres://localhost/db"
2452 table_name: t
2453 column_mapping: auto_map
2454 write_mode: upsert
2455 key: [id]
2456"#;
2457 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2458 let nodes = expand(&cfg).unwrap();
2459 assert_eq!(
2460 nodes[0].delivery_guarantee,
2461 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2462 faucet_core::EffectivelyOnceMechanism::KeyedUpsert
2463 )
2464 );
2465 }
2466
2467 #[test]
2468 fn exactly_once_kafka_source_accepted_with_atomic_sink() {
2469 let yaml = r#"
2472version: 1
2473delivery: exactly_once
2474pipeline:
2475 source:
2476 type: kafka
2477 config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
2478 sink: { type: sqlite, config: {} }
2479 state:
2480 type: file
2481 config: { path: "/tmp/faucet-eo-kafka-state.json" }
2482"#;
2483 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2484 let nodes = expand(&cfg).unwrap();
2485 assert_eq!(
2486 nodes[0].delivery_guarantee,
2487 faucet_core::DeliveryGuarantee::EffectivelyOnce(
2488 faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2489 )
2490 );
2491 }
2492
2493 #[test]
2494 fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
2495 let yaml = r#"
2498version: 1
2499delivery: exactly_once
2500pipeline:
2501 source: { type: rest, config: { base_url: https://x } }
2502 sink:
2503 type: postgres
2504 config:
2505 connection_url: "postgres://localhost/db"
2506 table_name: t
2507 column_mapping: auto_map
2508 state:
2509 type: file
2510 config: { path: "/tmp/faucet-eo-hint-state.json" }
2511"#;
2512 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2513 let err = expand(&cfg).unwrap_err();
2514 match &err {
2515 CliError::Config(msg) => assert!(
2516 msg.contains("write_mode: upsert"),
2517 "expected keyed-upsert hint, got: {msg}"
2518 ),
2519 other => panic!("expected Config error, got {other:?}"),
2520 }
2521 }
2522
2523 #[test]
2524 fn derived_guarantee_is_at_least_once_by_default() {
2525 let yaml = r#"
2526version: 1
2527pipeline:
2528 source: { type: rest, config: { base_url: https://x } }
2529 sink: { type: stdout, config: {} }
2530"#;
2531 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2532 let nodes = expand(&cfg).unwrap();
2533 assert_eq!(
2534 nodes[0].delivery_guarantee,
2535 faucet_core::DeliveryGuarantee::AtLeastOnce
2536 );
2537 }
2538
2539 #[test]
2540 fn exactly_once_rejects_memory_state() {
2541 let yaml = r#"
2544version: 1
2545delivery: exactly_once
2546pipeline:
2547 source: { type: postgres-cdc, config: {} }
2548 sink: { type: sqlite, config: {} }
2549 state:
2550 type: memory
2551 config: {}
2552"#;
2553 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2554 let err = expand(&cfg).unwrap_err();
2555 match &err {
2556 CliError::Config(msg) => assert!(
2557 msg.contains("durable") && msg.contains("memory"),
2558 "expected durable/memory mention, got: {msg}"
2559 ),
2560 other => panic!("expected Config error, got {other:?}"),
2561 }
2562 }
2563
2564 #[test]
2565 fn exactly_once_rejects_missing_state_store() {
2566 let yaml = r#"
2568version: 1
2569delivery: exactly_once
2570pipeline:
2571 source: { type: postgres-cdc, config: {} }
2572 sink: { type: sqlite, config: {} }
2573"#;
2574 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2575 let err = expand(&cfg).unwrap_err();
2576 match &err {
2577 CliError::Config(msg) => {
2578 assert!(
2579 msg.contains("state store") || msg.contains("state"),
2580 "expected state-store mention in error, got: {msg}"
2581 );
2582 }
2583 other => panic!("expected Config error, got {other:?}"),
2584 }
2585 }
2586
2587 #[test]
2588 fn rejects_upsert_on_unsupported_sink() {
2589 let c = cfg(r#"
2590version: 1
2591name: t
2592pipeline:
2593 source: { type: rest, config: { url: "http://x" } }
2594 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
2595"#);
2596 let err = expand(&c).unwrap_err();
2597 let msg = format!("{err}");
2598 assert!(
2599 msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
2600 "{msg}"
2601 );
2602 }
2603
2604 #[test]
2605 fn rejects_upsert_without_key() {
2606 let c = cfg(r#"
2607version: 1
2608name: t
2609pipeline:
2610 source: { type: rest, config: { url: "http://x" } }
2611 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
2612"#);
2613 let err = expand(&c).unwrap_err();
2614 let msg = format!("{err}");
2615 assert!(msg.contains("key"), "{msg}");
2616 }
2617
2618 #[test]
2619 fn accepts_upsert_on_postgres_with_key() {
2620 let c = cfg(r#"
2621version: 1
2622name: t
2623pipeline:
2624 source: { type: rest, config: { url: "http://x" } }
2625 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
2626"#);
2627 assert!(expand(&c).is_ok());
2628 }
2629
2630 #[test]
2631 fn bigquery_upsert_passes_write_mode_gate() {
2632 let c = cfg(r#"
2633version: 1
2634name: t
2635pipeline:
2636 source: { type: rest, config: { url: "http://x" } }
2637 sink: { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
2638"#);
2639 assert!(expand(&c).is_ok());
2640 }
2641
2642 #[test]
2643 fn accepts_append_by_default_on_any_sink() {
2644 let c = cfg(r#"
2645version: 1
2646name: t
2647pipeline:
2648 source: { type: rest, config: { url: "http://x" } }
2649 sink: { type: jsonl, config: { path: "out.jsonl" } }
2650"#);
2651 assert!(expand(&c).is_ok());
2652 }
2653
2654 #[test]
2655 fn rejects_delete_without_key() {
2656 let c = cfg(r#"
2657version: 1
2658name: t
2659pipeline:
2660 source: { type: rest, config: { url: "http://x" } }
2661 sink: { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
2662"#);
2663 let err = expand(&c).unwrap_err();
2664 let msg = format!("{err}");
2665 assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
2666 }
2667
2668 #[test]
2669 fn rejects_unknown_write_mode() {
2670 let c = cfg(r#"
2671version: 1
2672name: t
2673pipeline:
2674 source: { type: rest, config: { url: "http://x" } }
2675 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
2676"#);
2677 let err = expand(&c).unwrap_err();
2678 let msg = format!("{err}");
2679 assert!(
2680 msg.contains("unknown write_mode") && msg.contains("replace"),
2681 "{msg}"
2682 );
2683 }
2684
2685 #[test]
2686 fn rejects_poison_dlq_action_without_dlq() {
2687 let c = cfg(r#"
2688version: 1
2689pipeline:
2690 source: { type: rest, config: { base_url: https://x } }
2691 sink: { type: jsonl, config: { path: ./o } }
2692resilience:
2693 poison: { max_row_attempts: 3, action: dlq }
2694"#);
2695 let err = expand(&c).unwrap_err();
2696 assert!(
2697 matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
2698 "got: {err:?}"
2699 );
2700 }
2701
2702 #[test]
2703 fn accepts_poison_dlq_action_with_dlq() {
2704 let c = cfg(r#"
2705version: 1
2706pipeline:
2707 source: { type: rest, config: { base_url: https://x } }
2708 sink: { type: jsonl, config: { path: ./o } }
2709 dlq:
2710 sink: { type: jsonl, config: { path: ./dead.jsonl } }
2711resilience:
2712 poison: { max_row_attempts: 3, action: dlq }
2713"#);
2714 let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
2715 assert_eq!(nodes.len(), 1);
2716 }
2717
2718 #[test]
2719 fn accepts_poison_drop_action_without_dlq() {
2720 let c = cfg(r#"
2722version: 1
2723pipeline:
2724 source: { type: rest, config: { base_url: https://x } }
2725 sink: { type: jsonl, config: { path: ./o } }
2726resilience:
2727 poison: { max_row_attempts: 3, action: drop }
2728"#);
2729 let nodes = expand(&c).expect("poison.action=drop needs no dlq");
2730 assert_eq!(nodes.len(), 1);
2731 }
2732
2733 #[test]
2736 fn evolve_on_non_evolvable_sink_rejected() {
2737 let c = cfg(r#"
2739version: 1
2740pipeline:
2741 source: { type: rest, config: { base_url: https://x } }
2742 sink: { type: jsonl, config: { path: ./o.jsonl } }
2743 schema:
2744 on_drift: evolve
2745"#);
2746 let err = expand(&c).unwrap_err();
2747 match &err {
2748 CliError::Config(msg) => {
2749 assert!(
2750 msg.contains("evolve"),
2751 "expected evolve mention, got: {msg}"
2752 );
2753 assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
2754 }
2755 other => panic!("expected Config error, got {other:?}"),
2756 }
2757 }
2758
2759 #[test]
2760 fn quarantine_drift_without_dlq_rejected() {
2761 let c = cfg(r#"
2763version: 1
2764pipeline:
2765 source: { type: rest, config: { base_url: https://x } }
2766 sink: { type: postgres, config: {} }
2767 schema:
2768 on_drift: quarantine
2769"#);
2770 let err = expand(&c).unwrap_err();
2771 match &err {
2772 CliError::Config(msg) => {
2773 assert!(
2774 msg.contains("quarantine"),
2775 "expected quarantine mention, got: {msg}"
2776 );
2777 assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
2778 }
2779 other => panic!("expected Config error, got {other:?}"),
2780 }
2781 }
2782
2783 #[test]
2784 fn evolve_on_postgres_passes() {
2785 let c = cfg(r#"
2787version: 1
2788pipeline:
2789 source: { type: rest, config: { base_url: https://x } }
2790 sink: { type: postgres, config: {} }
2791 schema:
2792 on_drift: evolve
2793"#);
2794 assert!(expand(&c).is_ok());
2795 }
2796}
2797
2798#[cfg(test)]
2799mod partition_tests {
2800 use super::*;
2802 use crate::config::PipelineConfig;
2803
2804 fn cfg(yaml: &str) -> PipelineConfig {
2805 PipelineConfig::from_text(yaml, std::path::Path::new("p.yaml")).expect("config parses")
2806 }
2807
2808 const SCOPED_SOURCE: &str = r#"
2809 type: rest
2810 config:
2811 base_url: "https://api.example.com"
2812 path: "/records?id_from=${partition.start}&id_to=${partition.end}""#;
2813
2814 fn doc(partition: &str, source: &str) -> String {
2815 format!(
2816 "version: 1\nname: p\npipeline:\n source:{source}\n sink:\n type: jsonl\n config:\n path: ./out.jsonl\n{partition}"
2817 )
2818 }
2819
2820 #[test]
2821 fn a_partitioned_row_expands_into_one_node_per_chunk() {
2822 let nodes = expand(&cfg(&doc(
2823 "partition:\n kind: integer\n from: 0\n to: 24\n chunk_size: 10\n bounds: inclusive\n",
2824 SCOPED_SOURCE,
2825 )))
2826 .expect("expand");
2827 assert_eq!(nodes.len(), 3, "24 values / 10 = 3 chunks");
2828 let urls: Vec<String> = nodes
2830 .iter()
2831 .map(|n| n.source.config["path"].as_str().unwrap().to_string())
2832 .collect();
2833 assert!(urls[0].contains("id_from=0&id_to=9"), "{:?}", urls[0]);
2834 assert!(urls[1].contains("id_from=10&id_to=19"), "{:?}", urls[1]);
2835 assert!(urls[2].contains("id_from=20&id_to=24"), "{:?}", urls[2]);
2836 }
2837
2838 #[test]
2839 fn chunk_ids_are_distinct_and_namespaced_so_state_keys_cannot_collide() {
2840 let nodes = expand(&cfg(&doc(
2841 "partition:\n kind: integer\n from: 0\n to: 24\n chunk_size: 10\n bounds: inclusive\n",
2842 SCOPED_SOURCE,
2843 )))
2844 .unwrap();
2845 let ids: std::collections::BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
2846 assert_eq!(ids.len(), nodes.len(), "ids must be unique");
2847 assert!(nodes.iter().all(|n| n.id.contains("::partition::")));
2848 }
2849
2850 #[test]
2851 fn an_unpartitioned_config_is_completely_unchanged() {
2852 let nodes = expand(&cfg(&doc(
2853 "",
2854 "\n type: csv\n config:\n path: ./in.csv",
2855 )))
2856 .unwrap();
2857 assert_eq!(nodes.len(), 1);
2858 assert!(!nodes[0].id.contains("partition"));
2859 }
2860
2861 #[test]
2862 fn a_partition_block_whose_source_ignores_the_tokens_is_rejected() {
2863 let err = expand(&cfg(&doc(
2865 "partition:\n kind: integer\n from: 0\n to: 9\n chunk_size: 5\n bounds: inclusive\n",
2866 "\n type: csv\n config:\n path: ./in.csv",
2867 )))
2868 .expect_err("must be rejected");
2869 let msg = err.to_string();
2870 assert!(msg.contains("no `${partition.*}` token"), "{msg}");
2871 assert!(msg.contains("start"), "should list available tokens: {msg}");
2872 }
2873
2874 #[test]
2875 fn a_wrong_kind_token_is_rejected_naming_the_real_tokens() {
2876 let err = expand(&cfg(&doc(
2877 "partition:\n kind: offset\n total: 20\n chunk_size: 10\n",
2878 SCOPED_SOURCE,
2879 )))
2880 .expect_err("id-range tokens are not offset tokens");
2881 let msg = err.to_string();
2882 assert!(msg.contains("start"), "{msg}");
2883 assert!(msg.contains("offset"), "{msg}");
2884 }
2885
2886 #[test]
2887 fn a_partitioned_row_cannot_be_a_parent_or_a_dependency() {
2888 for edge in ["parent: a\n parent_key: id", "depends_on: [a]"] {
2890 let yaml = format!(
2891 "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"
2892 );
2893 let err = expand(&cfg(&yaml)).expect_err("must be rejected");
2894 assert!(
2895 err.to_string()
2896 .contains("partitioned row cannot be referenced"),
2897 "{err}"
2898 );
2899 }
2900 }
2901
2902 #[test]
2903 fn the_top_level_block_applies_to_root_rows() {
2904 let nodes = expand(&cfg(&doc(
2905 "partition:\n kind: offset\n total: 25\n chunk_size: 10\n",
2906 "\n type: rest\n config:\n base_url: \"https://x\"\n path: \"/r?offset=${partition.offset}&limit=${partition.limit}\"",
2907 )))
2908 .unwrap();
2909 assert_eq!(nodes.len(), 3);
2910 let p = nodes[2].source.config["path"].as_str().unwrap();
2911 assert!(p.contains("offset=20&limit=5"), "{p}");
2912 }
2913
2914 #[test]
2915 fn a_row_level_block_overrides_the_top_level_default() {
2916 let yaml = format!(
2917 "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"
2918 );
2919 let nodes = expand(&cfg(&yaml)).unwrap();
2920 assert_eq!(
2921 nodes.len(),
2922 1,
2923 "the row's own 5-wide range wins over 100/10"
2924 );
2925 }
2926}