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 source: DatasetObservation,
77 pub sink: DatasetObservation,
78 pub column_lineage: Option<Value>,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ConfigSnapshot {
96 pub pipeline: String,
97 pub recorded_at: DateTime<Utc>,
98 pub faucet_version: String,
100 pub rows: std::collections::BTreeMap<String, RowSnapshot>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct RowSnapshot {
108 pub source: ConnectorSnapshot,
109 pub sink: ConnectorSnapshot,
110 pub transforms: Vec<TransformSnapshot>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub state_key: Option<String>,
114 pub delivery_guarantee: String,
116 pub on_error: String,
118 pub dlq: bool,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ConnectorSnapshot {
125 pub kind: String,
126 pub config: Value,
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct TransformSnapshot {
134 pub kind: String,
135 pub config: Value,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CatalogDataset {
141 pub id: String,
143 pub uri: String,
144 pub kind: String,
145 pub roles: Vec<String>,
147 pub first_seen: DateTime<Utc>,
148 pub last_seen: DateTime<Utc>,
149 pub last_success: DateTime<Utc>,
151 pub last_run_id: String,
152 pub pipeline: String,
154 pub last_records: u64,
156 pub total_records: u64,
158 pub runs: u64,
160 pub schema_versions: u32,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub current_schema: Option<Value>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub current_schema_hash: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct CatalogSchemaVersion {
174 pub dataset_id: String,
175 pub version: u32,
177 pub recorded_at: DateTime<Utc>,
178 pub run_id: String,
179 pub schema: Value,
180 pub schema_hash: String,
181 #[serde(skip_serializing_if = "Option::is_none")]
184 pub diff: Option<Value>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct CatalogStatsPoint {
190 pub recorded_at: DateTime<Utc>,
191 pub run_id: String,
192 pub records: u64,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct CatalogLineageEdge {
198 pub src_id: String,
199 pub dst_id: String,
200 pub src_uri: String,
201 pub dst_uri: String,
202 pub pipeline: String,
203 pub row: String,
204 pub first_seen: DateTime<Utc>,
205 pub last_seen: DateTime<Utc>,
206 pub last_run_id: String,
207 pub runs: u64,
209 pub last_records: u64,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub column_lineage: Option<Value>,
214}
215
216#[derive(Debug, Default, Clone)]
218pub struct CatalogListFilter {
219 pub kind: Option<String>,
221 pub q: Option<String>,
223 pub limit: usize,
224 pub cursor: Option<String>,
226}
227
228#[derive(Debug, Serialize)]
230pub struct CatalogDatasetPage {
231 pub datasets: Vec<CatalogDataset>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub next_cursor: Option<String>,
234}
235
236#[derive(Debug, Serialize)]
238pub struct CatalogDatasetDetail {
239 #[serde(flatten)]
240 pub dataset: CatalogDataset,
241 pub schema_timeline: Vec<CatalogSchemaVersion>,
243 pub stats: Vec<CatalogStatsPoint>,
246 pub upstream: Vec<CatalogLineageEdge>,
248 pub downstream: Vec<CatalogLineageEdge>,
250}
251
252pub fn dataset_id(uri: &str) -> String {
255 use sha2::{Digest, Sha256};
256 let digest = Sha256::digest(uri.as_bytes());
257 hex_prefix(&digest, 16)
258}
259
260pub fn schema_hash(schema: &Value) -> String {
264 use sha2::{Digest, Sha256};
265 let mut canonical = String::new();
266 canonical_json(schema, &mut canonical);
267 let digest = Sha256::digest(canonical.as_bytes());
268 hex_prefix(&digest, 16)
269}
270
271fn hex_prefix(bytes: &[u8], chars: usize) -> String {
272 let mut out = String::with_capacity(chars);
273 for b in bytes {
274 use std::fmt::Write as _;
275 let _ = write!(out, "{b:02x}");
276 if out.len() >= chars {
277 break;
278 }
279 }
280 out.truncate(chars);
281 out
282}
283
284fn canonical_json(v: &Value, out: &mut String) {
286 match v {
287 Value::Object(map) => {
288 let mut keys: Vec<&String> = map.keys().collect();
289 keys.sort();
290 out.push('{');
291 for (i, k) in keys.iter().enumerate() {
292 if i > 0 {
293 out.push(',');
294 }
295 out.push_str(&Value::String((*k).clone()).to_string());
296 out.push(':');
297 canonical_json(&map[*k], out);
298 }
299 out.push('}');
300 }
301 Value::Array(items) => {
302 out.push('[');
303 for (i, item) in items.iter().enumerate() {
304 if i > 0 {
305 out.push(',');
306 }
307 canonical_json(item, out);
308 }
309 out.push(']');
310 }
311 scalar => out.push_str(&scalar.to_string()),
312 }
313}
314
315fn diff_to_value(diff: &faucet_core::SchemaDiff) -> Value {
318 let change = |c: &faucet_core::ColumnChange| -> Value {
319 json!({ "column": c.name, "from": c.from, "to": c.to })
320 };
321 json!({
322 "added": diff.additions.iter().map(change).collect::<Vec<_>>(),
323 "widened": diff.widenings.iter().map(change).collect::<Vec<_>>(),
324 "changed": diff.incompatible.iter().map(change).collect::<Vec<_>>(),
325 "removed": diff.droppable_required.clone(),
326 })
327}
328
329fn diff_is_empty(diff: &Value) -> bool {
332 ["added", "widened", "changed", "removed"].iter().all(|k| {
333 diff.get(k)
334 .and_then(Value::as_array)
335 .is_none_or(Vec::is_empty)
336 })
337}
338
339pub fn apply_observation(
344 existing: Option<&CatalogDataset>,
345 obs: &DatasetObservation,
346 run_id: &str,
347 pipeline: &str,
348 row: &str,
349 now: DateTime<Utc>,
350) -> (CatalogDataset, Option<CatalogSchemaVersion>) {
351 let _ = row; let id = dataset_id(&obs.uri);
353 let mut ds = match existing {
354 Some(prev) => prev.clone(),
355 None => CatalogDataset {
356 id: id.clone(),
357 uri: obs.uri.clone(),
358 kind: obs.kind.clone(),
359 roles: Vec::new(),
360 first_seen: now,
361 last_seen: now,
362 last_success: now,
363 last_run_id: run_id.to_string(),
364 pipeline: pipeline.to_string(),
365 last_records: 0,
366 total_records: 0,
367 runs: 0,
368 schema_versions: 0,
369 current_schema: None,
370 current_schema_hash: None,
371 },
372 };
373 let role = obs.role.as_str().to_string();
374 if !ds.roles.contains(&role) {
375 ds.roles.push(role);
376 ds.roles.sort();
377 }
378 ds.kind = obs.kind.clone();
379 ds.last_seen = now;
380 ds.last_success = now;
381 ds.last_run_id = run_id.to_string();
382 ds.pipeline = pipeline.to_string();
383 ds.last_records = obs.records;
384 ds.total_records = ds.total_records.saturating_add(obs.records);
385 ds.runs = ds.runs.saturating_add(1);
386
387 let new_version = match &obs.schema {
388 Some(schema) => {
389 let hash = schema_hash(schema);
390 if ds.current_schema_hash.as_deref() == Some(hash.as_str()) {
391 None
392 } else {
393 let diff = ds.current_schema.as_ref().map(|prev| {
394 diff_to_value(&faucet_core::drift::diff_schema(prev, schema, true))
395 });
396 let diff = diff.filter(|d| !diff_is_empty(d));
397 ds.schema_versions += 1;
398 ds.current_schema = Some(schema.clone());
399 ds.current_schema_hash = Some(hash.clone());
400 Some(CatalogSchemaVersion {
401 dataset_id: id,
402 version: ds.schema_versions,
403 recorded_at: now,
404 run_id: run_id.to_string(),
405 schema: schema.clone(),
406 schema_hash: hash,
407 diff,
408 })
409 }
410 }
411 None => None,
412 };
413 (ds, new_version)
414}
415
416pub fn apply_edge(
418 existing: Option<&CatalogLineageEdge>,
419 update: &CatalogUpdate,
420) -> CatalogLineageEdge {
421 let mut edge = match existing {
422 Some(prev) => prev.clone(),
423 None => CatalogLineageEdge {
424 src_id: dataset_id(&update.source.uri),
425 dst_id: dataset_id(&update.sink.uri),
426 src_uri: update.source.uri.clone(),
427 dst_uri: update.sink.uri.clone(),
428 pipeline: update.pipeline.clone(),
429 row: update.row.clone(),
430 first_seen: update.recorded_at,
431 last_seen: update.recorded_at,
432 last_run_id: update.run_id.clone(),
433 runs: 0,
434 last_records: 0,
435 column_lineage: None,
436 },
437 };
438 edge.pipeline = update.pipeline.clone();
439 edge.row = update.row.clone();
440 edge.last_seen = update.recorded_at;
441 edge.last_run_id = update.run_id.clone();
442 edge.runs = edge.runs.saturating_add(1);
443 edge.last_records = update.sink.records;
444 if update.column_lineage.is_some() {
445 edge.column_lineage = update.column_lineage.clone();
446 }
447 edge
448}
449
450pub fn filter_datasets(
454 mut all: Vec<CatalogDataset>,
455 filter: &CatalogListFilter,
456) -> CatalogDatasetPage {
457 all.retain(|d| filter.kind.as_deref().is_none_or(|k| d.kind == k));
458 if let Some(q) = filter.q.as_deref() {
459 let q = q.to_lowercase();
460 all.retain(|d| d.uri.to_lowercase().contains(&q));
461 }
462 all.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| b.id.cmp(&a.id)));
463 if let Some(cursor) = &filter.cursor
464 && let Some(pos) = all.iter().position(|d| &d.id == cursor)
465 {
466 all.drain(..=pos);
467 }
468 let limit = filter.limit.max(1);
469 let next_cursor = if all.len() > limit {
470 Some(all[limit - 1].id.clone())
471 } else {
472 None
473 };
474 all.truncate(limit);
475 CatalogDatasetPage {
476 datasets: all,
477 next_cursor,
478 }
479}
480
481pub fn lineage_slice(
485 edges: Vec<CatalogLineageEdge>,
486 root: Option<&str>,
487 depth: u32,
488) -> Vec<CatalogLineageEdge> {
489 let Some(root) = root else {
490 return edges;
491 };
492 let mut frontier: std::collections::HashSet<String> =
493 std::collections::HashSet::from([root.to_string()]);
494 let mut reached = frontier.clone();
495 let mut kept: Vec<usize> = Vec::new();
496 let mut kept_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
497 for _ in 0..depth.max(1) {
498 let mut next: std::collections::HashSet<String> = std::collections::HashSet::new();
499 for (i, e) in edges.iter().enumerate() {
500 if kept_set.contains(&i) {
501 continue;
502 }
503 if frontier.contains(&e.src_id) || frontier.contains(&e.dst_id) {
504 kept.push(i);
505 kept_set.insert(i);
506 for id in [&e.src_id, &e.dst_id] {
507 if reached.insert(id.clone()) {
508 next.insert(id.clone());
509 }
510 }
511 }
512 }
513 if next.is_empty() {
514 break;
515 }
516 frontier = next;
517 }
518 kept.sort_unstable();
519 let mut kept_edges = Vec::with_capacity(kept.len());
520 let mut edges = edges;
521 for i in kept.iter().rev() {
523 kept_edges.push(edges.swap_remove(*i));
524 }
525 kept_edges.reverse();
526 kept_edges
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use serde_json::json;
533
534 fn obs(
535 uri: &str,
536 role: DatasetRole,
537 schema: Option<Value>,
538 records: u64,
539 ) -> DatasetObservation {
540 DatasetObservation {
541 uri: uri.into(),
542 kind: "csv".into(),
543 role,
544 schema,
545 records,
546 }
547 }
548
549 fn schema_a() -> Value {
550 json!({"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}})
551 }
552
553 fn schema_b() -> Value {
554 json!({"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}, "email": {"type": "string"}}})
555 }
556
557 #[test]
558 fn dataset_id_is_stable_and_short() {
559 let a = dataset_id("csv://./in.csv");
560 assert_eq!(a.len(), 16);
561 assert_eq!(a, dataset_id("csv://./in.csv"));
562 assert_ne!(a, dataset_id("csv://./other.csv"));
563 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
564 }
565
566 #[test]
567 fn schema_hash_is_key_order_independent() {
568 let a = json!({"properties": {"a": {"type": "string"}, "b": {"type": "integer"}}});
569 let b = json!({"properties": {"b": {"type": "integer"}, "a": {"type": "string"}}});
570 assert_eq!(schema_hash(&a), schema_hash(&b));
571 assert_ne!(
572 schema_hash(&a),
573 schema_hash(&json!({"properties": {"a": {"type": "integer"}}}))
574 );
575 }
576
577 #[test]
578 fn schema_hash_covers_arrays_and_preserves_their_order() {
579 let a = json!({"properties": {"a": {"type": ["string", "null"]}}});
583 let b = json!({"properties": {"a": {"type": ["null", "string"]}}});
584 assert_ne!(schema_hash(&a), schema_hash(&b), "array order is meaning");
585 assert_eq!(schema_hash(&a), schema_hash(&a.clone()));
586 }
587
588 #[test]
589 fn first_observation_creates_dataset_and_version_one() {
590 let now = Utc::now();
591 let (ds, v) = apply_observation(
592 None,
593 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
594 "r1",
595 "p",
596 "default",
597 now,
598 );
599 assert_eq!(ds.id, dataset_id("csv://./in.csv"));
600 assert_eq!(ds.roles, vec!["source"]);
601 assert_eq!(ds.runs, 1);
602 assert_eq!(ds.total_records, 10);
603 assert_eq!(ds.schema_versions, 1);
604 let v = v.expect("first schema observation appends version 1");
605 assert_eq!(v.version, 1);
606 assert!(v.diff.is_none(), "no previous schema, no diff");
607 }
608
609 #[test]
610 fn unchanged_schema_does_not_append_a_version() {
611 let now = Utc::now();
612 let (ds, _) = apply_observation(
613 None,
614 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
615 "r1",
616 "p",
617 "default",
618 now,
619 );
620 let (ds2, v2) = apply_observation(
621 Some(&ds),
622 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 7),
623 "r2",
624 "p",
625 "default",
626 now,
627 );
628 assert!(v2.is_none(), "identical schema must dedupe");
629 assert_eq!(ds2.schema_versions, 1);
630 assert_eq!(ds2.runs, 2);
631 assert_eq!(ds2.total_records, 17);
632 assert_eq!(ds2.last_records, 7);
633 assert_eq!(ds2.last_run_id, "r2");
634 }
635
636 #[test]
637 fn changed_schema_appends_a_version_with_a_diff() {
638 let now = Utc::now();
639 let (ds, _) = apply_observation(
640 None,
641 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_a()), 10),
642 "r1",
643 "p",
644 "default",
645 now,
646 );
647 let (ds2, v2) = apply_observation(
648 Some(&ds),
649 &obs("csv://./in.csv", DatasetRole::Source, Some(schema_b()), 10),
650 "r2",
651 "p",
652 "default",
653 now,
654 );
655 assert_eq!(ds2.schema_versions, 2);
656 let v2 = v2.expect("schema change appends version 2");
657 assert_eq!(v2.version, 2);
658 let diff = v2.diff.expect("second version diffs against the first");
659 let added = diff["added"].as_array().unwrap();
660 assert_eq!(added.len(), 1);
661 assert_eq!(added[0]["column"], "email");
662 }
663
664 #[test]
665 fn roles_accumulate_and_sort() {
666 let now = Utc::now();
667 let (ds, _) = apply_observation(
668 None,
669 &obs("x://d", DatasetRole::Sink, None, 1),
670 "r1",
671 "p",
672 "default",
673 now,
674 );
675 let (ds2, _) = apply_observation(
676 Some(&ds),
677 &obs("x://d", DatasetRole::Source, None, 1),
678 "r2",
679 "p",
680 "default",
681 now,
682 );
683 assert_eq!(ds2.roles, vec!["sink", "source"]);
684 assert!(ds2.current_schema.is_none());
685 assert_eq!(ds2.schema_versions, 0);
686 }
687
688 fn update(src: &str, dst: &str, records: u64) -> CatalogUpdate {
689 CatalogUpdate {
690 run_id: "r1".into(),
691 pipeline: "p".into(),
692 row: "default".into(),
693 recorded_at: Utc::now(),
694 source: obs(src, DatasetRole::Source, None, records),
695 sink: obs(dst, DatasetRole::Sink, None, records),
696 column_lineage: None,
697 }
698 }
699
700 #[test]
701 fn edge_accumulates_and_keeps_last_column_lineage() {
702 let mut u = update("a://1", "b://2", 5);
703 u.column_lineage = Some(json!({"fields": {"x": {}}}));
704 let e = apply_edge(None, &u);
705 assert_eq!(e.runs, 1);
706 assert_eq!(e.last_records, 5);
707 assert!(e.column_lineage.is_some());
708
709 let mut u2 = update("a://1", "b://2", 9);
711 u2.run_id = "r2".into();
712 let e2 = apply_edge(Some(&e), &u2);
713 assert_eq!(e2.runs, 2);
714 assert_eq!(e2.last_records, 9);
715 assert_eq!(e2.last_run_id, "r2");
716 assert!(e2.column_lineage.is_some(), "opaque run keeps prior facet");
717 }
718
719 fn ds(id_uri: &str, kind: &str, last_seen: DateTime<Utc>) -> CatalogDataset {
720 CatalogDataset {
721 id: dataset_id(id_uri),
722 uri: id_uri.into(),
723 kind: kind.into(),
724 roles: vec!["source".into()],
725 first_seen: last_seen,
726 last_seen,
727 last_success: last_seen,
728 last_run_id: "r".into(),
729 pipeline: "p".into(),
730 last_records: 0,
731 total_records: 0,
732 runs: 1,
733 schema_versions: 0,
734 current_schema: None,
735 current_schema_hash: None,
736 }
737 }
738
739 #[test]
740 fn filter_datasets_filters_orders_and_paginates() {
741 let t0 = Utc::now();
742 let all = vec![
743 ds("csv://a", "csv", t0),
744 ds("csv://b", "csv", t0 + chrono::Duration::seconds(1)),
745 ds(
746 "postgres://h/db",
747 "postgres",
748 t0 + chrono::Duration::seconds(2),
749 ),
750 ];
751 let page = filter_datasets(
753 all.clone(),
754 &CatalogListFilter {
755 kind: Some("postgres".into()),
756 limit: 10,
757 ..Default::default()
758 },
759 );
760 assert_eq!(page.datasets.len(), 1);
761 assert_eq!(page.datasets[0].kind, "postgres");
762 let page = filter_datasets(
764 all.clone(),
765 &CatalogListFilter {
766 q: Some("CSV://".into()),
767 limit: 10,
768 ..Default::default()
769 },
770 );
771 assert_eq!(page.datasets.len(), 2);
772 let page = filter_datasets(
774 all.clone(),
775 &CatalogListFilter {
776 limit: 2,
777 ..Default::default()
778 },
779 );
780 assert_eq!(page.datasets[0].kind, "postgres");
781 let cursor = page.next_cursor.expect("3 rows, page of 2");
782 let page2 = filter_datasets(
783 all,
784 &CatalogListFilter {
785 limit: 2,
786 cursor: Some(cursor),
787 ..Default::default()
788 },
789 );
790 assert_eq!(page2.datasets.len(), 1);
791 assert!(page2.next_cursor.is_none());
792 }
793
794 fn edge(src: &str, dst: &str) -> CatalogLineageEdge {
795 apply_edge(None, &update(src, dst, 1))
796 }
797
798 #[test]
799 fn lineage_slice_respects_root_and_depth() {
800 let edges = vec![
802 edge("a", "b"),
803 edge("b", "c"),
804 edge("c", "d"),
805 edge("x", "y"),
806 ];
807 let all = lineage_slice(edges.clone(), None, 5);
808 assert_eq!(all.len(), 4, "no root returns everything");
809
810 let b = dataset_id("b");
811 let d1 = lineage_slice(edges.clone(), Some(&b), 1);
813 assert_eq!(d1.len(), 2);
814 let d2 = lineage_slice(edges.clone(), Some(&b), 2);
816 assert_eq!(d2.len(), 3);
817 assert!(d2.iter().all(|e| e.src_uri != "x"));
818 assert!(lineage_slice(edges, Some("nope"), 3).is_empty());
820 }
821}