1use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22pub const STATS_RETAIN: usize = 500;
24
25pub const STATS_DETAIL_LIMIT: usize = 50;
27
28pub const LINEAGE_DEFAULT_DEPTH: u32 = 5;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum DatasetRole {
35 Source,
36 Sink,
37}
38
39impl DatasetRole {
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Source => "source",
43 Self::Sink => "sink",
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct DatasetObservation {
51 pub uri: String,
55 pub kind: String,
57 pub role: DatasetRole,
58 pub schema: Option<Value>,
61 pub records: u64,
63}
64
65#[derive(Debug, Clone)]
69pub struct CatalogUpdate {
70 pub run_id: String,
72 pub pipeline: String,
73 pub row: String,
75 pub recorded_at: DateTime<Utc>,
76 pub sources: Vec<DatasetObservation>,
81 pub sink: DatasetObservation,
82 pub column_lineage: Option<Value>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct ConfigSnapshot {
100 pub pipeline: String,
101 pub recorded_at: DateTime<Utc>,
102 pub faucet_version: String,
104 pub rows: std::collections::BTreeMap<String, RowSnapshot>,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct RowSnapshot {
112 pub source: ConnectorSnapshot,
113 pub sink: ConnectorSnapshot,
114 pub transforms: Vec<TransformSnapshot>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub state_key: Option<String>,
118 pub delivery_guarantee: String,
120 pub on_error: String,
122 pub dlq: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ConnectorSnapshot {
129 pub kind: String,
130 pub config: Value,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct TransformSnapshot {
138 pub kind: String,
139 pub config: Value,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct CatalogDataset {
145 pub id: String,
147 pub uri: String,
148 pub kind: String,
149 pub roles: Vec<String>,
151 pub first_seen: DateTime<Utc>,
152 pub last_seen: DateTime<Utc>,
153 pub last_success: DateTime<Utc>,
155 pub last_run_id: String,
156 pub pipeline: String,
158 pub last_records: u64,
160 pub total_records: u64,
162 pub runs: u64,
164 pub schema_versions: u32,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub current_schema: Option<Value>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub current_schema_hash: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CatalogSchemaVersion {
178 pub dataset_id: String,
179 pub version: u32,
181 pub recorded_at: DateTime<Utc>,
182 pub run_id: String,
183 pub schema: Value,
184 pub schema_hash: String,
185 #[serde(skip_serializing_if = "Option::is_none")]
188 pub diff: Option<Value>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct CatalogStatsPoint {
194 pub recorded_at: DateTime<Utc>,
195 pub run_id: String,
196 pub records: u64,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CatalogLineageEdge {
202 pub src_id: String,
203 pub dst_id: String,
204 pub src_uri: String,
205 pub dst_uri: String,
206 pub pipeline: String,
207 pub row: String,
208 pub first_seen: DateTime<Utc>,
209 pub last_seen: DateTime<Utc>,
210 pub last_run_id: String,
211 pub runs: u64,
213 pub last_records: u64,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub column_lineage: Option<Value>,
218}
219
220#[derive(Debug, Default, Clone)]
222pub struct CatalogListFilter {
223 pub kind: Option<String>,
225 pub q: Option<String>,
227 pub limit: usize,
228 pub cursor: Option<String>,
230}
231
232#[derive(Debug, Serialize)]
234pub struct CatalogDatasetPage {
235 pub datasets: Vec<CatalogDataset>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub next_cursor: Option<String>,
238}
239
240#[derive(Debug, Serialize)]
242pub struct CatalogDatasetDetail {
243 #[serde(flatten)]
244 pub dataset: CatalogDataset,
245 pub schema_timeline: Vec<CatalogSchemaVersion>,
247 pub stats: Vec<CatalogStatsPoint>,
250 pub upstream: Vec<CatalogLineageEdge>,
252 pub downstream: Vec<CatalogLineageEdge>,
254}
255
256pub fn dataset_id(uri: &str) -> String {
259 use sha2::{Digest, Sha256};
260 let digest = Sha256::digest(uri.as_bytes());
261 hex_prefix(&digest, 16)
262}
263
264pub fn schema_hash(schema: &Value) -> String {
268 use sha2::{Digest, Sha256};
269 let mut canonical = String::new();
270 canonical_json(schema, &mut canonical);
271 let digest = Sha256::digest(canonical.as_bytes());
272 hex_prefix(&digest, 16)
273}
274
275fn hex_prefix(bytes: &[u8], chars: usize) -> String {
276 let mut out = String::with_capacity(chars);
277 for b in bytes {
278 use std::fmt::Write as _;
279 let _ = write!(out, "{b:02x}");
280 if out.len() >= chars {
281 break;
282 }
283 }
284 out.truncate(chars);
285 out
286}
287
288fn canonical_json(v: &Value, out: &mut String) {
290 match v {
291 Value::Object(map) => {
292 let mut keys: Vec<&String> = map.keys().collect();
293 keys.sort();
294 out.push('{');
295 for (i, k) in keys.iter().enumerate() {
296 if i > 0 {
297 out.push(',');
298 }
299 out.push_str(&Value::String((*k).clone()).to_string());
300 out.push(':');
301 canonical_json(&map[*k], out);
302 }
303 out.push('}');
304 }
305 Value::Array(items) => {
306 out.push('[');
307 for (i, item) in items.iter().enumerate() {
308 if i > 0 {
309 out.push(',');
310 }
311 canonical_json(item, out);
312 }
313 out.push(']');
314 }
315 scalar => out.push_str(&scalar.to_string()),
316 }
317}
318
319fn diff_to_value(diff: &faucet_core::SchemaDiff) -> Value {
322 let change = |c: &faucet_core::ColumnChange| -> Value {
323 json!({ "column": c.name, "from": c.from, "to": c.to })
324 };
325 json!({
326 "added": diff.additions.iter().map(change).collect::<Vec<_>>(),
327 "widened": diff.widenings.iter().map(change).collect::<Vec<_>>(),
328 "changed": diff.incompatible.iter().map(change).collect::<Vec<_>>(),
329 "removed": diff.droppable_required.clone(),
330 })
331}
332
333fn diff_is_empty(diff: &Value) -> bool {
336 ["added", "widened", "changed", "removed"].iter().all(|k| {
337 diff.get(k)
338 .and_then(Value::as_array)
339 .is_none_or(Vec::is_empty)
340 })
341}
342
343pub fn apply_observation(
348 existing: Option<&CatalogDataset>,
349 obs: &DatasetObservation,
350 run_id: &str,
351 pipeline: &str,
352 row: &str,
353 now: DateTime<Utc>,
354) -> (CatalogDataset, Option<CatalogSchemaVersion>) {
355 let _ = row; let id = dataset_id(&obs.uri);
357 let mut ds = match existing {
358 Some(prev) => prev.clone(),
359 None => CatalogDataset {
360 id: id.clone(),
361 uri: obs.uri.clone(),
362 kind: obs.kind.clone(),
363 roles: Vec::new(),
364 first_seen: now,
365 last_seen: now,
366 last_success: now,
367 last_run_id: run_id.to_string(),
368 pipeline: pipeline.to_string(),
369 last_records: 0,
370 total_records: 0,
371 runs: 0,
372 schema_versions: 0,
373 current_schema: None,
374 current_schema_hash: None,
375 },
376 };
377 let role = obs.role.as_str().to_string();
378 if !ds.roles.contains(&role) {
379 ds.roles.push(role);
380 ds.roles.sort();
381 }
382 ds.kind = obs.kind.clone();
383 ds.last_seen = now;
384 ds.last_success = now;
385 ds.last_run_id = run_id.to_string();
386 ds.pipeline = pipeline.to_string();
387 ds.last_records = obs.records;
388 ds.total_records = ds.total_records.saturating_add(obs.records);
389 ds.runs = ds.runs.saturating_add(1);
390
391 let new_version = match &obs.schema {
392 Some(schema) => {
393 let hash = schema_hash(schema);
394 if ds.current_schema_hash.as_deref() == Some(hash.as_str()) {
395 None
396 } else {
397 let diff = ds.current_schema.as_ref().map(|prev| {
398 diff_to_value(&faucet_core::drift::diff_schema(prev, schema, true))
399 });
400 let diff = diff.filter(|d| !diff_is_empty(d));
401 ds.schema_versions += 1;
402 ds.current_schema = Some(schema.clone());
403 ds.current_schema_hash = Some(hash.clone());
404 Some(CatalogSchemaVersion {
405 dataset_id: id,
406 version: ds.schema_versions,
407 recorded_at: now,
408 run_id: run_id.to_string(),
409 schema: schema.clone(),
410 schema_hash: hash,
411 diff,
412 })
413 }
414 }
415 None => None,
416 };
417 (ds, new_version)
418}
419
420pub fn apply_edge(
422 existing: Option<&CatalogLineageEdge>,
423 update: &CatalogUpdate,
424 source: &DatasetObservation,
425) -> CatalogLineageEdge {
426 let mut edge = match existing {
427 Some(prev) => prev.clone(),
428 None => CatalogLineageEdge {
429 src_id: dataset_id(&source.uri),
430 dst_id: dataset_id(&update.sink.uri),
431 src_uri: source.uri.clone(),
432 dst_uri: update.sink.uri.clone(),
433 pipeline: update.pipeline.clone(),
434 row: update.row.clone(),
435 first_seen: update.recorded_at,
436 last_seen: update.recorded_at,
437 last_run_id: update.run_id.clone(),
438 runs: 0,
439 last_records: 0,
440 column_lineage: None,
441 },
442 };
443 edge.pipeline = update.pipeline.clone();
444 edge.row = update.row.clone();
445 edge.last_seen = update.recorded_at;
446 edge.last_run_id = update.run_id.clone();
447 edge.runs = edge.runs.saturating_add(1);
448 edge.last_records = if update.sources.len() == 1 {
453 update.sink.records
454 } else {
455 source.records
456 };
457 if update.column_lineage.is_some() {
458 edge.column_lineage = update.column_lineage.clone();
459 }
460 edge
461}
462
463pub fn filter_datasets(
467 mut all: Vec<CatalogDataset>,
468 filter: &CatalogListFilter,
469) -> CatalogDatasetPage {
470 all.retain(|d| filter.kind.as_deref().is_none_or(|k| d.kind == k));
471 if let Some(q) = filter.q.as_deref() {
472 let q = q.to_lowercase();
473 all.retain(|d| d.uri.to_lowercase().contains(&q));
474 }
475 all.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| b.id.cmp(&a.id)));
476 if let Some(cursor) = &filter.cursor
477 && let Some(pos) = all.iter().position(|d| &d.id == cursor)
478 {
479 all.drain(..=pos);
480 }
481 let limit = filter.limit.max(1);
482 let next_cursor = if all.len() > limit {
483 Some(all[limit - 1].id.clone())
484 } else {
485 None
486 };
487 all.truncate(limit);
488 CatalogDatasetPage {
489 datasets: all,
490 next_cursor,
491 }
492}
493
494pub fn lineage_slice(
498 edges: Vec<CatalogLineageEdge>,
499 root: Option<&str>,
500 depth: u32,
501) -> Vec<CatalogLineageEdge> {
502 let Some(root) = root else {
503 return edges;
504 };
505 let mut frontier: std::collections::HashSet<String> =
506 std::collections::HashSet::from([root.to_string()]);
507 let mut reached = frontier.clone();
508 let mut kept: Vec<usize> = Vec::new();
509 let mut kept_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
510 for _ in 0..depth.max(1) {
511 let mut next: std::collections::HashSet<String> = std::collections::HashSet::new();
512 for (i, e) in edges.iter().enumerate() {
513 if kept_set.contains(&i) {
514 continue;
515 }
516 if frontier.contains(&e.src_id) || frontier.contains(&e.dst_id) {
517 kept.push(i);
518 kept_set.insert(i);
519 for id in [&e.src_id, &e.dst_id] {
520 if reached.insert(id.clone()) {
521 next.insert(id.clone());
522 }
523 }
524 }
525 }
526 if next.is_empty() {
527 break;
528 }
529 frontier = next;
530 }
531 kept.sort_unstable();
532 let mut kept_edges = Vec::with_capacity(kept.len());
533 let mut edges = edges;
534 for i in kept.iter().rev() {
536 kept_edges.push(edges.swap_remove(*i));
537 }
538 kept_edges.reverse();
539 kept_edges
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use serde_json::json;
546
547 fn obs(
548 uri: &str,
549 role: DatasetRole,
550 schema: Option<Value>,
551 records: u64,
552 ) -> DatasetObservation {
553 DatasetObservation {
554 uri: uri.into(),
555 kind: "csv".into(),
556 role,
557 schema,
558 records,
559 }
560 }
561
562 fn schema_a() -> Value {
563 json!({"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}})
564 }
565
566 fn schema_b() -> Value {
567 json!({"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}, "email": {"type": "string"}}})
568 }
569
570 #[test]
571 fn dataset_id_is_stable_and_short() {
572 let a = dataset_id("csv://./in.csv");
573 assert_eq!(a.len(), 16);
574 assert_eq!(a, dataset_id("csv://./in.csv"));
575 assert_ne!(a, dataset_id("csv://./other.csv"));
576 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
577 }
578
579 #[test]
580 fn schema_hash_is_key_order_independent() {
581 let a = json!({"properties": {"a": {"type": "string"}, "b": {"type": "integer"}}});
582 let b = json!({"properties": {"b": {"type": "integer"}, "a": {"type": "string"}}});
583 assert_eq!(schema_hash(&a), schema_hash(&b));
584 assert_ne!(
585 schema_hash(&a),
586 schema_hash(&json!({"properties": {"a": {"type": "integer"}}}))
587 );
588 }
589
590 #[test]
591 fn schema_hash_covers_arrays_and_preserves_their_order() {
592 let a = json!({"properties": {"a": {"type": ["string", "null"]}}});
596 let b = json!({"properties": {"a": {"type": ["null", "string"]}}});
597 assert_ne!(schema_hash(&a), schema_hash(&b), "array order is meaning");
598 assert_eq!(schema_hash(&a), schema_hash(&a.clone()));
599 }
600
601 #[test]
602 fn first_observation_creates_dataset_and_version_one() {
603 let now = Utc::now();
604 let (ds, v) = apply_observation(
605 None,
606 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
607 "r1",
608 "p",
609 "default",
610 now,
611 );
612 assert_eq!(ds.id, dataset_id("csv://./in.csv"));
613 assert_eq!(ds.roles, vec!["source"]);
614 assert_eq!(ds.runs, 1);
615 assert_eq!(ds.total_records, 10);
616 assert_eq!(ds.schema_versions, 1);
617 let v = v.expect("first schema observation appends version 1");
618 assert_eq!(v.version, 1);
619 assert!(v.diff.is_none(), "no previous schema, no diff");
620 }
621
622 #[test]
623 fn unchanged_schema_does_not_append_a_version() {
624 let now = Utc::now();
625 let (ds, _) = apply_observation(
626 None,
627 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
628 "r1",
629 "p",
630 "default",
631 now,
632 );
633 let (ds2, v2) = apply_observation(
634 Some(&ds),
635 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 7),
636 "r2",
637 "p",
638 "default",
639 now,
640 );
641 assert!(v2.is_none(), "identical schema must dedupe");
642 assert_eq!(ds2.schema_versions, 1);
643 assert_eq!(ds2.runs, 2);
644 assert_eq!(ds2.total_records, 17);
645 assert_eq!(ds2.last_records, 7);
646 assert_eq!(ds2.last_run_id, "r2");
647 }
648
649 #[test]
650 fn changed_schema_appends_a_version_with_a_diff() {
651 let now = Utc::now();
652 let (ds, _) = apply_observation(
653 None,
654 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
655 "r1",
656 "p",
657 "default",
658 now,
659 );
660 let (ds2, v2) = apply_observation(
661 Some(&ds),
662 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_b()), 10),
663 "r2",
664 "p",
665 "default",
666 now,
667 );
668 assert_eq!(ds2.schema_versions, 2);
669 let v2 = v2.expect("schema change appends version 2");
670 assert_eq!(v2.version, 2);
671 let diff = v2.diff.expect("second version diffs against the first");
672 let added = diff["added"].as_array().unwrap();
673 assert_eq!(added.len(), 1);
674 assert_eq!(added[0]["column"], "email");
675 }
676
677 #[test]
678 fn roles_accumulate_and_sort() {
679 let now = Utc::now();
680 let (ds, _) = apply_observation(
681 None,
682 &obs("x://d", DatasetRole::Sink, None, 1),
683 "r1",
684 "p",
685 "default",
686 now,
687 );
688 let (ds2, _) = apply_observation(
689 Some(&ds),
690 &obs("x://d", DatasetRole::Source, None, 1),
691 "r2",
692 "p",
693 "default",
694 now,
695 );
696 assert_eq!(ds2.roles, vec!["sink", "source"]);
697 assert!(ds2.current_schema.is_none());
698 assert_eq!(ds2.schema_versions, 0);
699 }
700
701 fn update(src: &str, dst: &str, records: u64) -> CatalogUpdate {
702 CatalogUpdate {
703 run_id: "r1".into(),
704 pipeline: "p".into(),
705 row: "default".into(),
706 recorded_at: Utc::now(),
707 sources: vec![obs(src, DatasetRole::Source, None, records)],
708 sink: obs(dst, DatasetRole::Sink, None, records),
709 column_lineage: None,
710 }
711 }
712
713 #[test]
714 fn edge_accumulates_and_keeps_last_column_lineage() {
715 let mut u = update("a://1", "b://2", 5);
716 u.column_lineage = Some(json!({"fields": {"x": {}}}));
717 let e = apply_edge(None, &u, &u.sources[0]);
718 assert_eq!(e.runs, 1);
719 assert_eq!(e.last_records, 5);
720 assert!(e.column_lineage.is_some());
721
722 let mut u2 = update("a://1", "b://2", 9);
724 u2.run_id = "r2".into();
725 let e2 = apply_edge(Some(&e), &u2, &u2.sources[0]);
726 assert_eq!(e2.runs, 2);
727 assert_eq!(e2.last_records, 9);
728 assert_eq!(e2.last_run_id, "r2");
729 assert!(e2.column_lineage.is_some(), "opaque run keeps prior facet");
730 }
731
732 fn ds(id_uri: &str, kind: &str, last_seen: DateTime<Utc>) -> CatalogDataset {
733 CatalogDataset {
734 id: dataset_id(id_uri),
735 uri: id_uri.into(),
736 kind: kind.into(),
737 roles: vec!["source".into()],
738 first_seen: last_seen,
739 last_seen,
740 last_success: last_seen,
741 last_run_id: "r".into(),
742 pipeline: "p".into(),
743 last_records: 0,
744 total_records: 0,
745 runs: 1,
746 schema_versions: 0,
747 current_schema: None,
748 current_schema_hash: None,
749 }
750 }
751
752 #[test]
753 fn filter_datasets_filters_orders_and_paginates() {
754 let t0 = Utc::now();
755 let all = vec![
756 ds("csv://a", "csv", t0),
757 ds("csv://b", "csv", t0 + chrono::Duration::seconds(1)),
758 ds(
759 "postgres://h/db",
760 "postgres",
761 t0 + chrono::Duration::seconds(2),
762 ),
763 ];
764 let page = filter_datasets(
766 all.clone(),
767 &CatalogListFilter {
768 kind: Some("postgres".into()),
769 limit: 10,
770 ..Default::default()
771 },
772 );
773 assert_eq!(page.datasets.len(), 1);
774 assert_eq!(page.datasets[0].kind, "postgres");
775 let page = filter_datasets(
777 all.clone(),
778 &CatalogListFilter {
779 q: Some("CSV://".into()),
780 limit: 10,
781 ..Default::default()
782 },
783 );
784 assert_eq!(page.datasets.len(), 2);
785 let page = filter_datasets(
787 all.clone(),
788 &CatalogListFilter {
789 limit: 2,
790 ..Default::default()
791 },
792 );
793 assert_eq!(page.datasets[0].kind, "postgres");
794 let cursor = page.next_cursor.expect("3 rows, page of 2");
795 let page2 = filter_datasets(
796 all,
797 &CatalogListFilter {
798 limit: 2,
799 cursor: Some(cursor),
800 ..Default::default()
801 },
802 );
803 assert_eq!(page2.datasets.len(), 1);
804 assert!(page2.next_cursor.is_none());
805 }
806
807 fn edge(src: &str, dst: &str) -> CatalogLineageEdge {
808 {
809 let u = update(src, dst, 1);
810 let src_obs = u.sources[0].clone();
811 apply_edge(None, &u, &src_obs)
812 }
813 }
814
815 #[test]
816 fn lineage_slice_respects_root_and_depth() {
817 let edges = vec![
819 edge("a", "b"),
820 edge("b", "c"),
821 edge("c", "d"),
822 edge("x", "y"),
823 ];
824 let all = lineage_slice(edges.clone(), None, 5);
825 assert_eq!(all.len(), 4, "no root returns everything");
826
827 let b = dataset_id("b");
828 let d1 = lineage_slice(edges.clone(), Some(&b), 1);
830 assert_eq!(d1.len(), 2);
831 let d2 = lineage_slice(edges.clone(), Some(&b), 2);
833 assert_eq!(d2.len(), 3);
834 assert!(d2.iter().all(|e| e.src_uri != "x"));
835 assert!(lineage_slice(edges, Some("nope"), 3).is_empty());
837 }
838}
839
840#[cfg(test)]
841mod multi_source_tests {
842 use super::*;
843
844 fn obs2(uri: &str, role: DatasetRole, records: u64) -> DatasetObservation {
845 DatasetObservation {
846 uri: uri.into(),
847 kind: "csv".into(),
848 role,
849 schema: None,
850 records,
851 }
852 }
853
854 #[test]
858 fn a_merge_sink_yields_one_edge_per_input_with_its_own_volume() {
859 let update = CatalogUpdate {
860 run_id: "r1".into(),
861 pipeline: "p".into(),
862 row: "w".into(),
863 recorded_at: Utc::now(),
864 sources: vec![
865 obs2("csv://a.csv", DatasetRole::Source, 4),
866 obs2("csv://b.csv", DatasetRole::Source, 3),
867 ],
868 sink: obs2("jsonl://out.jsonl", DatasetRole::Sink, 7),
869 column_lineage: None,
870 };
871
872 let a = apply_edge(None, &update, &update.sources[0]);
873 let b = apply_edge(None, &update, &update.sources[1]);
874 assert_eq!(a.src_uri, "csv://a.csv");
875 assert_eq!(b.src_uri, "csv://b.csv");
876 assert_eq!(a.dst_uri, "jsonl://out.jsonl");
877 assert_eq!(a.dst_id, b.dst_id, "both edges land on the same sink");
878 assert_eq!((a.last_records, b.last_records), (4, 3));
880 assert_eq!(a.last_records + b.last_records, update.sink.records);
881 }
882
883 #[test]
886 fn a_single_source_edge_still_reports_the_sink_count() {
887 let update = CatalogUpdate {
888 run_id: "r1".into(),
889 pipeline: "p".into(),
890 row: "default".into(),
891 recorded_at: Utc::now(),
892 sources: vec![obs2("csv://a.csv", DatasetRole::Source, 9)],
893 sink: obs2("jsonl://out.jsonl", DatasetRole::Sink, 9),
894 column_lineage: None,
895 };
896 let e = apply_edge(None, &update, &update.sources[0]);
897 assert_eq!(e.last_records, 9);
898 }
899}