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    pub source: DatasetObservation,
77    pub sink: DatasetObservation,
78    /// Column-lineage facet derived by `faucet-lineage` for the edge, when the
79    /// transform chain is expressible (`None` when opaque).
80    pub column_lineage: Option<Value>,
81}
82
83// ── Config snapshots (#374) ──────────────────────────────────────────────────
84//
85// A `faucet plan --diff`-able record of the *resolved + expanded* config as it
86// last ran. One snapshot per pipeline (latest wins); each carries a per-row,
87// secret-redacted view so the diff reflects real data-movement effects, never a
88// misleading raw-YAML text diff. Stored types only — the build-from-`ExpandedNode`
89// and diff/render logic lives in `cli/src/catalog/snapshot.rs` (a higher layer
90// that may depend on the CLI's expand types; this module must not).
91
92/// A redacted, resolved+expanded config snapshot recorded on a successful run.
93/// Diffed by `faucet plan --diff` against the current config.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ConfigSnapshot {
96    pub pipeline: String,
97    pub recorded_at: DateTime<Utc>,
98    /// `faucet` version that recorded the snapshot (informational).
99    pub faucet_version: String,
100    /// Expanded row id → row snapshot. `BTreeMap` so serialization and diffs are
101    /// deterministic regardless of expansion order.
102    pub rows: std::collections::BTreeMap<String, RowSnapshot>,
103}
104
105/// One expanded row (a single source→sink movement) as it last ran.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct RowSnapshot {
108    pub source: ConnectorSnapshot,
109    pub sink: ConnectorSnapshot,
110    pub transforms: Vec<TransformSnapshot>,
111    /// Durable state key for this row, when a state store is configured.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub state_key: Option<String>,
114    /// End-to-end delivery guarantee (`Debug` of `DeliveryGuarantee`).
115    pub delivery_guarantee: String,
116    /// Pipeline-level `execution.on_error` (`"stop"` / `"continue"`).
117    pub on_error: String,
118    /// Whether a DLQ sink is attached to this row.
119    pub dlq: bool,
120}
121
122/// A connector (source or sink) with its **secret-redacted** resolved config.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ConnectorSnapshot {
125    pub kind: String,
126    /// Resolved config with every secret-sourced value replaced by a stable
127    /// `<secret:sha256:…>` token — no secret material is ever persisted.
128    pub config: Value,
129}
130
131/// A transform stage with its resolved (redacted) config.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct TransformSnapshot {
134    pub kind: String,
135    pub config: Value,
136}
137
138/// One catalogued dataset — the list element and the head of the detail view.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CatalogDataset {
141    /// Stable id: 16 hex chars of sha256(uri). Used in URLs and edge keys.
142    pub id: String,
143    pub uri: String,
144    pub kind: String,
145    /// Roles this dataset has been seen in (`"source"` / `"sink"`), sorted.
146    pub roles: Vec<String>,
147    pub first_seen: DateTime<Utc>,
148    pub last_seen: DateTime<Utc>,
149    /// Last successful run that touched this dataset (freshness).
150    pub last_success: DateTime<Utc>,
151    pub last_run_id: String,
152    /// Pipeline name of the most recent run that touched this dataset.
153    pub pipeline: String,
154    /// Records moved in the most recent run.
155    pub last_records: u64,
156    /// Records moved across all recorded runs.
157    pub total_records: u64,
158    /// Number of recorded runs that touched this dataset.
159    pub runs: u64,
160    /// Number of schema-timeline entries (0 when never sampled).
161    pub schema_versions: u32,
162    /// Latest observed schema (`None` when never sampled).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub current_schema: Option<Value>,
165    /// Content hash of `current_schema` — the dedupe key for the timeline.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub current_schema_hash: Option<String>,
168}
169
170/// One schema-timeline entry. Appended only when the observed schema's content
171/// hash differs from the previous entry, so the timeline stays compact.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct CatalogSchemaVersion {
174    pub dataset_id: String,
175    /// 1-based, monotonically increasing per dataset.
176    pub version: u32,
177    pub recorded_at: DateTime<Utc>,
178    pub run_id: String,
179    pub schema: Value,
180    pub schema_hash: String,
181    /// Diff against the previous version (computed via
182    /// `faucet_core::drift::diff_schema`); `None` for the first version.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub diff: Option<Value>,
185}
186
187/// One per-run volume point for a dataset.
188#[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/// One source→sink lineage edge, keyed by `(src_id, dst_id)`.
196#[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    /// Recorded runs that traversed this edge.
208    pub runs: u64,
209    /// Records moved along this edge in the most recent run.
210    pub last_records: u64,
211    /// Column-lineage facet from the most recent run (`None` when opaque).
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub column_lineage: Option<Value>,
214}
215
216/// Filter + keyset pagination for the dataset list.
217#[derive(Debug, Default, Clone)]
218pub struct CatalogListFilter {
219    /// Exact connector-kind match.
220    pub kind: Option<String>,
221    /// Case-insensitive substring match on the dataset URI.
222    pub q: Option<String>,
223    pub limit: usize,
224    /// Dataset id of the last element of the previous page.
225    pub cursor: Option<String>,
226}
227
228/// One page of the dataset list, ordered `(last_seen DESC, id DESC)`.
229#[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/// The full detail view for one dataset.
237#[derive(Debug, Serialize)]
238pub struct CatalogDatasetDetail {
239    #[serde(flatten)]
240    pub dataset: CatalogDataset,
241    /// Schema timeline, oldest first.
242    pub schema_timeline: Vec<CatalogSchemaVersion>,
243    /// Most recent volume points, newest first (bounded by
244    /// [`STATS_DETAIL_LIMIT`]).
245    pub stats: Vec<CatalogStatsPoint>,
246    /// Edges whose destination is this dataset.
247    pub upstream: Vec<CatalogLineageEdge>,
248    /// Edges whose source is this dataset.
249    pub downstream: Vec<CatalogLineageEdge>,
250}
251
252/// Stable dataset id: the first 16 hex chars of sha256(uri). Short enough for
253/// URLs, long enough that collisions are out of reach at catalog scale.
254pub 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
260/// Content hash of a schema value over a canonical (recursively key-sorted)
261/// rendering, so the hash is independent of `serde_json`'s map ordering
262/// (`preserve_order` flips between builds).
263pub 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
284/// Render `v` with object keys sorted recursively (arrays keep order).
285fn 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
315/// Serialize a `faucet_core::drift::SchemaDiff` (not `Serialize` itself) into
316/// a stable JSON shape for the schema-timeline `diff` field.
317fn 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
329/// Whether a diff value carries any actual change (an all-empty diff is
330/// omitted from the timeline entry).
331fn 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
339/// Fold one run's observation into the (possibly absent) existing dataset
340/// record. Returns the updated record plus a new schema-timeline entry when —
341/// and only when — the observed schema's content hash differs from the
342/// current one.
343pub 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; // provenance detail carried on the edge, not the dataset
352    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
416/// Fold one run's traversal into the (possibly absent) existing lineage edge.
417pub 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
450/// Filter, order (`last_seen DESC, id DESC`), and keyset-paginate the dataset
451/// list. Shared by the memory and SQL backends (which fetch all rows and
452/// delegate here) so the two can never disagree on filter semantics.
453pub 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
481/// Slice the edge graph for the lineage read: with no root, return everything;
482/// with a root, BFS outward (both directions) up to `depth` hops. Shared by
483/// both backends.
484pub 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    // Drain in reverse so earlier indices stay valid.
522    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        // Nullable columns infer as `"type": ["string", "null"]` — the
580        // canonical rendering must keep array ORDER significant while still
581        // sorting object keys.
582        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        // A later opaque run keeps the previous column lineage.
710        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        // Kind filter.
752        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        // Substring filter, case-insensitive.
763        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        // Newest-first + pagination.
773        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        // a → b → c → d, plus x → y off to the side.
801        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        // Depth 1 from b: edges touching b only.
812        let d1 = lineage_slice(edges.clone(), Some(&b), 1);
813        assert_eq!(d1.len(), 2);
814        // Depth 2 from b: reaches c→d too, never x→y.
815        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        // Unknown root: nothing.
819        assert!(lineage_slice(edges, Some("nope"), 3).is_empty());
820    }
821}