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