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/// One catalogued dataset — the list element and the head of the detail view.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CatalogDataset {
86    /// Stable id: 16 hex chars of sha256(uri). Used in URLs and edge keys.
87    pub id: String,
88    pub uri: String,
89    pub kind: String,
90    /// Roles this dataset has been seen in (`"source"` / `"sink"`), sorted.
91    pub roles: Vec<String>,
92    pub first_seen: DateTime<Utc>,
93    pub last_seen: DateTime<Utc>,
94    /// Last successful run that touched this dataset (freshness).
95    pub last_success: DateTime<Utc>,
96    pub last_run_id: String,
97    /// Pipeline name of the most recent run that touched this dataset.
98    pub pipeline: String,
99    /// Records moved in the most recent run.
100    pub last_records: u64,
101    /// Records moved across all recorded runs.
102    pub total_records: u64,
103    /// Number of recorded runs that touched this dataset.
104    pub runs: u64,
105    /// Number of schema-timeline entries (0 when never sampled).
106    pub schema_versions: u32,
107    /// Latest observed schema (`None` when never sampled).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub current_schema: Option<Value>,
110    /// Content hash of `current_schema` — the dedupe key for the timeline.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub current_schema_hash: Option<String>,
113}
114
115/// One schema-timeline entry. Appended only when the observed schema's content
116/// hash differs from the previous entry, so the timeline stays compact.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct CatalogSchemaVersion {
119    pub dataset_id: String,
120    /// 1-based, monotonically increasing per dataset.
121    pub version: u32,
122    pub recorded_at: DateTime<Utc>,
123    pub run_id: String,
124    pub schema: Value,
125    pub schema_hash: String,
126    /// Diff against the previous version (computed via
127    /// `faucet_core::drift::diff_schema`); `None` for the first version.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub diff: Option<Value>,
130}
131
132/// One per-run volume point for a dataset.
133#[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/// One source→sink lineage edge, keyed by `(src_id, dst_id)`.
141#[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    /// Recorded runs that traversed this edge.
153    pub runs: u64,
154    /// Records moved along this edge in the most recent run.
155    pub last_records: u64,
156    /// Column-lineage facet from the most recent run (`None` when opaque).
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub column_lineage: Option<Value>,
159}
160
161/// Filter + keyset pagination for the dataset list.
162#[derive(Debug, Default, Clone)]
163pub struct CatalogListFilter {
164    /// Exact connector-kind match.
165    pub kind: Option<String>,
166    /// Case-insensitive substring match on the dataset URI.
167    pub q: Option<String>,
168    pub limit: usize,
169    /// Dataset id of the last element of the previous page.
170    pub cursor: Option<String>,
171}
172
173/// One page of the dataset list, ordered `(last_seen DESC, id DESC)`.
174#[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/// The full detail view for one dataset.
182#[derive(Debug, Serialize)]
183pub struct CatalogDatasetDetail {
184    #[serde(flatten)]
185    pub dataset: CatalogDataset,
186    /// Schema timeline, oldest first.
187    pub schema_timeline: Vec<CatalogSchemaVersion>,
188    /// Most recent volume points, newest first (bounded by
189    /// [`STATS_DETAIL_LIMIT`]).
190    pub stats: Vec<CatalogStatsPoint>,
191    /// Edges whose destination is this dataset.
192    pub upstream: Vec<CatalogLineageEdge>,
193    /// Edges whose source is this dataset.
194    pub downstream: Vec<CatalogLineageEdge>,
195}
196
197/// Stable dataset id: the first 16 hex chars of sha256(uri). Short enough for
198/// URLs, long enough that collisions are out of reach at catalog scale.
199pub 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
205/// Content hash of a schema value over a canonical (recursively key-sorted)
206/// rendering, so the hash is independent of `serde_json`'s map ordering
207/// (`preserve_order` flips between builds).
208pub 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
229/// Render `v` with object keys sorted recursively (arrays keep order).
230fn 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
260/// Serialize a `faucet_core::drift::SchemaDiff` (not `Serialize` itself) into
261/// a stable JSON shape for the schema-timeline `diff` field.
262fn 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
274/// Whether a diff value carries any actual change (an all-empty diff is
275/// omitted from the timeline entry).
276fn 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
284/// Fold one run's observation into the (possibly absent) existing dataset
285/// record. Returns the updated record plus a new schema-timeline entry when —
286/// and only when — the observed schema's content hash differs from the
287/// current one.
288pub 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; // provenance detail carried on the edge, not the dataset
297    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
361/// Fold one run's traversal into the (possibly absent) existing lineage edge.
362pub 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
395/// Filter, order (`last_seen DESC, id DESC`), and keyset-paginate the dataset
396/// list. Shared by the memory and SQL backends (which fetch all rows and
397/// delegate here) so the two can never disagree on filter semantics.
398pub 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
426/// Slice the edge graph for the lineage read: with no root, return everything;
427/// with a root, BFS outward (both directions) up to `depth` hops. Shared by
428/// both backends.
429pub 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    // Drain in reverse so earlier indices stay valid.
467    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        // Nullable columns infer as `"type": ["string", "null"]` — the
525        // canonical rendering must keep array ORDER significant while still
526        // sorting object keys.
527        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        // A later opaque run keeps the previous column lineage.
655        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        // Kind filter.
697        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        // Substring filter, case-insensitive.
708        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        // Newest-first + pagination.
718        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        // a → b → c → d, plus x → y off to the side.
746        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        // Depth 1 from b: edges touching b only.
757        let d1 = lineage_slice(edges.clone(), Some(&b), 1);
758        assert_eq!(d1.len(), 2);
759        // Depth 2 from b: reaches c→d too, never x→y.
760        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        // Unknown root: nothing.
764        assert!(lineage_slice(edges, Some("nope"), 3).is_empty());
765    }
766}