Skip to main content

faucet_cli/serve/history/
catalog.rs

1//! Data Movement Catalog storage types + pure merge logic (#279).
2//!
3//! The catalog is the accumulating, cross-run picture of every dataset a
4//! pipeline touches: identity (a canonical, credential-redacted dataset URI),
5//! a deduplicated schema timeline, per-run volume/freshness stats, and the
6//! lineage edges between datasets. It rides the run-history backends — the
7//! in-memory store and the shared SQL machinery both implement the
8//! `catalog_*` methods on [`RunHistory`](super::RunHistory) — so persistence
9//! reuses the existing `--history` / `serve-history-*` plumbing and the
10//! `FallbackHistory` degradation contract (a catalog write never fails a run).
11//!
12//! Everything in this module is **pure**: the merge of one run's observation
13//! into a dataset record ([`apply_observation`]), schema hashing/dedup
14//! ([`schema_hash`]), the list filter ([`filter_datasets`]), and the
15//! depth-bounded lineage BFS ([`lineage_slice`]) are shared by both backends
16//! so they can never drift apart. Backends only do I/O.
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22/// How many volume points a dataset keeps (older ones are pruned on write).
23pub const STATS_RETAIN: usize = 500;
24
25/// How many of the most recent volume points a detail read returns.
26pub const STATS_DETAIL_LIMIT: usize = 50;
27
28/// Default depth bound for the lineage graph read.
29pub const LINEAGE_DEFAULT_DEPTH: u32 = 5;
30
31/// Which side of a pipeline a dataset was observed on.
32#[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/// One observation of a dataset from a finished, successful root invocation.
49#[derive(Debug, Clone)]
50pub struct DatasetObservation {
51    /// Canonical dataset URI — credential-redacted and `${now.*}`-templated
52    /// segments folded back to their tokens, so dated paths converge on one
53    /// dataset instead of one per day.
54    pub uri: String,
55    /// Connector kind (`"csv"`, `"postgres"`, …).
56    pub kind: String,
57    pub role: DatasetRole,
58    /// Observed record schema (`infer_schema`-shaped
59    /// `{"type":"object","properties":{…}}`), `None` when nothing was sampled.
60    pub schema: Option<Value>,
61    /// Records read from / written to this dataset in the run.
62    pub records: u64,
63}
64
65/// The composite catalog write for one run: both dataset observations plus the
66/// source→sink lineage edge. One call so backends can keep the write paths
67/// together (and a partial failure degrades the whole update, not half of it).
68#[derive(Debug, Clone)]
69pub struct CatalogUpdate {
70    /// Provenance: the serve run id, or the invocation run id for CLI runs.
71    pub run_id: String,
72    pub pipeline: String,
73    /// Matrix row id (`"default"` for non-matrix runs).
74    pub row: String,
75    pub recorded_at: DateTime<Utc>,
76    /// Input datasets. A single-source pipeline has one; a topology sink fed by a
77    /// merge or join has one per source that reaches it (#459). Each carries its
78    /// own record count, so per-dataset volume stays accurate instead of the sink
79    /// total being repeated across edges.
80    pub sources: Vec<DatasetObservation>,
81    pub sink: DatasetObservation,
82    /// Column-lineage facet derived by `faucet-lineage` for the edge, when the
83    /// transform chain is expressible (`None` when opaque).
84    pub column_lineage: Option<Value>,
85}
86
87// ── Config snapshots (#374) ──────────────────────────────────────────────────
88//
89// A `faucet plan --diff`-able record of the *resolved + expanded* config as it
90// last ran. One snapshot per pipeline (latest wins); each carries a per-row,
91// secret-redacted view so the diff reflects real data-movement effects, never a
92// misleading raw-YAML text diff. Stored types only — the build-from-`ExpandedNode`
93// and diff/render logic lives in `cli/src/catalog/snapshot.rs` (a higher layer
94// that may depend on the CLI's expand types; this module must not).
95
96/// A redacted, resolved+expanded config snapshot recorded on a successful run.
97/// Diffed by `faucet plan --diff` against the current config.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct ConfigSnapshot {
100    pub pipeline: String,
101    pub recorded_at: DateTime<Utc>,
102    /// `faucet` version that recorded the snapshot (informational).
103    pub faucet_version: String,
104    /// Expanded row id → row snapshot. `BTreeMap` so serialization and diffs are
105    /// deterministic regardless of expansion order.
106    pub rows: std::collections::BTreeMap<String, RowSnapshot>,
107}
108
109/// One expanded row (a single source→sink movement) as it last ran.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct RowSnapshot {
112    pub source: ConnectorSnapshot,
113    pub sink: ConnectorSnapshot,
114    pub transforms: Vec<TransformSnapshot>,
115    /// Durable state key for this row, when a state store is configured.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub state_key: Option<String>,
118    /// End-to-end delivery guarantee (`Debug` of `DeliveryGuarantee`).
119    pub delivery_guarantee: String,
120    /// Pipeline-level `execution.on_error` (`"stop"` / `"continue"`).
121    pub on_error: String,
122    /// Whether a DLQ sink is attached to this row.
123    pub dlq: bool,
124}
125
126/// A connector (source or sink) with its **secret-redacted** resolved config.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ConnectorSnapshot {
129    pub kind: String,
130    /// Resolved config with every secret-sourced value replaced by a stable
131    /// `<secret:sha256:…>` token — no secret material is ever persisted.
132    pub config: Value,
133}
134
135/// A transform stage with its resolved (redacted) config.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct TransformSnapshot {
138    pub kind: String,
139    pub config: Value,
140}
141
142/// One catalogued dataset — the list element and the head of the detail view.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct CatalogDataset {
145    /// Stable id: 16 hex chars of sha256(uri). Used in URLs and edge keys.
146    pub id: String,
147    pub uri: String,
148    pub kind: String,
149    /// Roles this dataset has been seen in (`"source"` / `"sink"`), sorted.
150    pub roles: Vec<String>,
151    pub first_seen: DateTime<Utc>,
152    pub last_seen: DateTime<Utc>,
153    /// Last successful run that touched this dataset (freshness).
154    pub last_success: DateTime<Utc>,
155    pub last_run_id: String,
156    /// Pipeline name of the most recent run that touched this dataset.
157    pub pipeline: String,
158    /// Records moved in the most recent run.
159    pub last_records: u64,
160    /// Records moved across all recorded runs.
161    pub total_records: u64,
162    /// Number of recorded runs that touched this dataset.
163    pub runs: u64,
164    /// Number of schema-timeline entries (0 when never sampled).
165    pub schema_versions: u32,
166    /// Latest observed schema (`None` when never sampled).
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub current_schema: Option<Value>,
169    /// Content hash of `current_schema` — the dedupe key for the timeline.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub current_schema_hash: Option<String>,
172}
173
174/// One schema-timeline entry. Appended only when the observed schema's content
175/// hash differs from the previous entry, so the timeline stays compact.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CatalogSchemaVersion {
178    pub dataset_id: String,
179    /// 1-based, monotonically increasing per dataset.
180    pub version: u32,
181    pub recorded_at: DateTime<Utc>,
182    pub run_id: String,
183    pub schema: Value,
184    pub schema_hash: String,
185    /// Diff against the previous version (computed via
186    /// `faucet_core::drift::diff_schema`); `None` for the first version.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub diff: Option<Value>,
189}
190
191/// One per-run volume point for a dataset.
192#[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/// One source→sink lineage edge, keyed by `(src_id, dst_id)`.
200#[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    /// Recorded runs that traversed this edge.
212    pub runs: u64,
213    /// Records moved along this edge in the most recent run.
214    pub last_records: u64,
215    /// Column-lineage facet from the most recent run (`None` when opaque).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub column_lineage: Option<Value>,
218}
219
220/// Filter + keyset pagination for the dataset list.
221#[derive(Debug, Default, Clone)]
222pub struct CatalogListFilter {
223    /// Exact connector-kind match.
224    pub kind: Option<String>,
225    /// Case-insensitive substring match on the dataset URI.
226    pub q: Option<String>,
227    pub limit: usize,
228    /// Dataset id of the last element of the previous page.
229    pub cursor: Option<String>,
230}
231
232/// One page of the dataset list, ordered `(last_seen DESC, id DESC)`.
233#[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/// The full detail view for one dataset.
241#[derive(Debug, Serialize)]
242pub struct CatalogDatasetDetail {
243    #[serde(flatten)]
244    pub dataset: CatalogDataset,
245    /// Schema timeline, oldest first.
246    pub schema_timeline: Vec<CatalogSchemaVersion>,
247    /// Most recent volume points, newest first (bounded by
248    /// [`STATS_DETAIL_LIMIT`]).
249    pub stats: Vec<CatalogStatsPoint>,
250    /// Edges whose destination is this dataset.
251    pub upstream: Vec<CatalogLineageEdge>,
252    /// Edges whose source is this dataset.
253    pub downstream: Vec<CatalogLineageEdge>,
254}
255
256/// Stable dataset id: the first 16 hex chars of sha256(uri). Short enough for
257/// URLs, long enough that collisions are out of reach at catalog scale.
258pub 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
264/// Content hash of a schema value over a canonical (recursively key-sorted)
265/// rendering, so the hash is independent of `serde_json`'s map ordering
266/// (`preserve_order` flips between builds).
267pub 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
288/// Render `v` with object keys sorted recursively (arrays keep order).
289fn 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
319/// Serialize a `faucet_core::drift::SchemaDiff` (not `Serialize` itself) into
320/// a stable JSON shape for the schema-timeline `diff` field.
321fn 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
333/// Whether a diff value carries any actual change (an all-empty diff is
334/// omitted from the timeline entry).
335fn 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
343/// Fold one run's observation into the (possibly absent) existing dataset
344/// record. Returns the updated record plus a new schema-timeline entry when —
345/// and only when — the observed schema's content hash differs from the
346/// current one.
347pub 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; // provenance detail carried on the edge, not the dataset
356    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
420/// Fold one run's traversal into the (possibly absent) existing lineage edge.
421pub 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    // The records this edge carried. For a single-source pipeline the source read
449    // count and the sink write count coincide; for a merge, attributing the sink
450    // total to every edge would over-count, so each edge reports its own source's
451    // contribution (#459).
452    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
463/// Filter, order (`last_seen DESC, id DESC`), and keyset-paginate the dataset
464/// list. Shared by the memory and SQL backends (which fetch all rows and
465/// delegate here) so the two can never disagree on filter semantics.
466pub 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
494/// Slice the edge graph for the lineage read: with no root, return everything;
495/// with a root, BFS outward (both directions) up to `depth` hops. Shared by
496/// both backends.
497pub 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    // Drain in reverse so earlier indices stay valid.
535    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        // Nullable columns infer as `"type": ["string", "null"]` — the
593        // canonical rendering must keep array ORDER significant while still
594        // sorting object keys.
595        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        // A later opaque run keeps the previous column lineage.
723        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        // Kind filter.
765        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        // Substring filter, case-insensitive.
776        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        // Newest-first + pagination.
786        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        // a → b → c → d, plus x → y off to the side.
818        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        // Depth 1 from b: edges touching b only.
829        let d1 = lineage_slice(edges.clone(), Some(&b), 1);
830        assert_eq!(d1.len(), 2);
831        // Depth 2 from b: reaches c→d too, never x→y.
832        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        // Unknown root: nothing.
836        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    /// #459: a topology sink fed by a merge has several inputs. Each gets its own
855    /// edge, and each edge reports **its own** source's contribution — repeating
856    /// the sink total across edges would over-count the volume.
857    #[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        // Per-source volume, so a.last + b.last == the sink's 7 rather than 7 each.
879        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    /// The single-source case is unchanged: the edge reports the sink's count,
884    /// which is what every matrix pipeline records.
885    #[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}