Skip to main content

faucet_core/
drift.rs

1//! Schema-drift detection + policy types (issue #194).
2//!
3//! Drift is the divergence between an incoming page's inferred top-level shape
4//! (via [`crate::schema::infer_schema`]) and the sink's live destination schema
5//! (via [`crate::Sink::current_schema`]). The pure [`diff_schema`] classifies
6//! each top-level column into one bucket; [`SchemaDriftPolicy`] decides what the
7//! pipeline does with the result. Nested objects are treated as a single column.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// One column's drift, expressed in JSON-Schema type-fragment terms
14/// (e.g. `{"type":"integer"}` or `{"type":["string","null"]}`).
15#[derive(Debug, Clone, PartialEq)]
16pub struct ColumnChange {
17    /// Top-level column name.
18    pub name: String,
19    /// Destination type fragment; `None` for an addition (not in destination).
20    pub from: Option<Value>,
21    /// Inferred type fragment from the incoming page.
22    pub to: Value,
23}
24
25/// Result of diffing a page's inferred shape against the destination schema.
26#[derive(Debug, Clone, Default, PartialEq)]
27pub struct SchemaDiff {
28    /// In the page, not in the destination.
29    pub additions: Vec<ColumnChange>,
30    /// Existing column whose type widened losslessly (e.g. integer→number,
31    /// or gained nullability).
32    pub widenings: Vec<ColumnChange>,
33    /// Existing column whose type changed in a way that cannot be auto-applied
34    /// (narrowing / incompatible type swap).
35    pub incompatible: Vec<ColumnChange>,
36    /// In the destination and NOT NULL, absent from the page — would fail an
37    /// insert unless relaxed to nullable.
38    pub droppable_required: Vec<String>,
39}
40
41impl SchemaDiff {
42    /// `true` when no drift of any kind was detected.
43    pub fn is_empty(&self) -> bool {
44        self.additions.is_empty()
45            && self.widenings.is_empty()
46            && self.incompatible.is_empty()
47            && self.droppable_required.is_empty()
48    }
49
50    /// Column names that drifted, for error messages / metrics.
51    pub fn changed_columns(&self) -> Vec<String> {
52        self.additions
53            .iter()
54            .chain(&self.widenings)
55            .chain(&self.incompatible)
56            .map(|c| c.name.clone())
57            .chain(self.droppable_required.iter().cloned())
58            .collect()
59    }
60}
61
62/// The applyable subset of a [`SchemaDiff`] handed to [`crate::Sink::evolve_schema`].
63/// Never carries `incompatible` columns — those are routed by the policy.
64#[derive(Debug, Clone, Default, PartialEq)]
65pub struct SchemaEvolution {
66    pub additions: Vec<ColumnChange>,
67    pub widenings: Vec<ColumnChange>,
68    /// Columns to relax from NOT NULL to nullable.
69    pub relax_nullability: Vec<String>,
70}
71
72impl SchemaEvolution {
73    pub fn is_empty(&self) -> bool {
74        self.additions.is_empty() && self.widenings.is_empty() && self.relax_nullability.is_empty()
75    }
76}
77
78/// The set of JSON-Schema primitive type names a fragment carries (excluding `null`),
79/// plus whether `null` is present.
80fn type_set(fragment: &Value) -> (Vec<String>, bool) {
81    let mut names = Vec::new();
82    let mut nullable = false;
83    match fragment.get("type") {
84        Some(Value::String(t)) => {
85            if t == "null" {
86                nullable = true
87            } else {
88                names.push(t.clone())
89            }
90        }
91        Some(Value::Array(arr)) => {
92            for v in arr {
93                if let Some(t) = v.as_str() {
94                    if t == "null" {
95                        nullable = true
96                    } else {
97                        names.push(t.to_string())
98                    }
99                }
100            }
101        }
102        _ => {}
103    }
104    names.sort();
105    (names, nullable)
106}
107
108/// Are the page's values already acceptable as-is by the destination?
109///
110/// True iff the page introduces no nulls a non-nullable destination would
111/// reject, AND every non-null base type the page carries is accepted by the
112/// destination (an exact base-type match, or `integer` landing in a `number`
113/// column). `fits` is never gated by `allow_widening` — a fitting page is not
114/// drift at all.
115fn fits(dest: &Value, page: &Value) -> bool {
116    let (dn, dnull) = type_set(dest);
117    let (pn, pnull) = type_set(page);
118    // The page must not introduce nulls a non-nullable destination rejects.
119    if pnull && !dnull {
120        return false;
121    }
122    // Every page base type must be accepted by the destination.
123    pn.iter()
124        .all(|t| dn.contains(t) || (t == "integer" && dn.iter().any(|d| d == "number")))
125}
126
127/// Can the destination column losslessly evolve to accept the page?
128///
129/// The merged non-null base family must collapse to a single base type: take
130/// `dest ∪ page`, drop `integer` if `number` is present (int→number collapse),
131/// and require exactly one element. Nullability relaxation is always evolvable,
132/// so only the base-family check gates this.
133fn evolvable(dest: &Value, page: &Value) -> bool {
134    let (dn, _) = type_set(dest);
135    let (pn, _) = type_set(page);
136    let mut merged: Vec<String> = dn.into_iter().chain(pn).collect();
137    merged.sort();
138    merged.dedup();
139    if merged.iter().any(|t| t == "number") {
140        merged.retain(|t| t != "integer");
141    }
142    merged.len() == 1
143}
144
145/// True when the non-null base type family changes from `from` to `to`
146/// (after the integer→number collapse) — i.e. the column needs an `ALTER TYPE`.
147///
148/// Mirrors the `evolvable` base-family collapse: take `from ∪ to`, drop
149/// `integer` when `number` is present, and treat the change as a base-type
150/// widening only when the resulting family is no longer the destination's
151/// original family. Nullability changes alone never count here.
152pub fn base_widened(from: &Value, to: &Value) -> bool {
153    let (dn, _) = type_set(from);
154    let (pn, _) = type_set(to);
155    // Collapse the destination's own family the same way (so a nullable-only
156    // change yields an identical family and returns false).
157    let collapse = |names: Vec<String>| -> Vec<String> {
158        let mut m: Vec<String> = names;
159        m.sort();
160        m.dedup();
161        if m.iter().any(|t| t == "number") {
162            m.retain(|t| t != "integer");
163        }
164        m
165    };
166    let dest_family = collapse(dn.clone());
167    let merged = collapse(dn.into_iter().chain(pn).collect());
168    merged != dest_family
169}
170
171/// True when `to` permits null but `from` does not — the column needs its
172/// `NOT NULL` constraint relaxed.
173pub fn adds_null(from: &Value, to: &Value) -> bool {
174    let (_, fnull) = type_set(from);
175    let (_, tnull) = type_set(to);
176    tnull && !fnull
177}
178
179/// Diff a page's inferred shape against the destination schema (top-level columns).
180///
181/// `destination` and `page` are both `infer_schema`-shaped object schemas
182/// (`{"type":"object","properties":{...}}`). `allow_widening` gates whether a
183/// lossless widening lands in `widenings` (true) or `incompatible` (false).
184pub fn diff_schema(destination: &Value, page: &Value, allow_widening: bool) -> SchemaDiff {
185    let empty = serde_json::Map::new();
186    let dest_props = destination
187        .get("properties")
188        .and_then(|p| p.as_object())
189        .unwrap_or(&empty);
190    let page_props = page
191        .get("properties")
192        .and_then(|p| p.as_object())
193        .unwrap_or(&empty);
194
195    let mut diff = SchemaDiff::default();
196
197    for (name, page_ty) in page_props {
198        match dest_props.get(name) {
199            None => diff.additions.push(ColumnChange {
200                name: name.clone(),
201                from: None,
202                to: page_ty.clone(),
203            }),
204            Some(dest_ty) => {
205                if fits(dest_ty, page_ty) {
206                    continue; // page values already acceptable — no drift
207                }
208                let change = ColumnChange {
209                    name: name.clone(),
210                    from: Some(dest_ty.clone()),
211                    to: page_ty.clone(),
212                };
213                if allow_widening && evolvable(dest_ty, page_ty) {
214                    diff.widenings.push(change);
215                } else {
216                    diff.incompatible.push(change);
217                }
218            }
219        }
220    }
221
222    // Destination columns absent from the page: drift only if NOT NULL.
223    for (name, dest_ty) in dest_props {
224        if !page_props.contains_key(name) {
225            let (_, nullable) = type_set(dest_ty);
226            if !nullable {
227                diff.droppable_required.push(name.clone());
228            }
229        }
230    }
231    diff.additions.sort_by(|a, b| a.name.cmp(&b.name));
232    diff.widenings.sort_by(|a, b| a.name.cmp(&b.name));
233    diff.incompatible.sort_by(|a, b| a.name.cmp(&b.name));
234    diff.droppable_required.sort();
235    diff
236}
237
238/// What to do when drift is detected.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
240#[serde(rename_all = "snake_case")]
241pub enum OnDrift {
242    /// Detect + emit a metric and a one-shot log; write the page unchanged.
243    #[default]
244    Warn,
245    /// Apply additive/widening DDL to the destination, then write.
246    Evolve,
247    /// Drop unknown (non-destination) fields from every record; write the rest.
248    Ignore,
249    /// Route the records that exhibit the drift to the DLQ; write the rest.
250    Quarantine,
251    /// Raise `FaucetError::SchemaDrift` and abort.
252    Fail,
253}
254
255/// `evolve`-only: what to do with a narrowing/incompatible change that can't be
256/// auto-applied.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum OnIncompatible {
260    /// Abort the run (default).
261    #[default]
262    Fail,
263    /// Route the offending records to the DLQ.
264    Quarantine,
265}
266
267fn default_true() -> bool {
268    true
269}
270
271/// User-facing `schema:` config block (pipeline level).
272#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
273#[serde(deny_unknown_fields)]
274pub struct SchemaDriftSpec {
275    /// Policy applied when drift is detected.
276    #[serde(default)]
277    pub on_drift: OnDrift,
278    /// Whether a lossless widening counts as evolvable (vs incompatible).
279    /// Only consulted by `evolve`. Default: true.
280    #[serde(default = "default_true")]
281    pub allow_type_widening: bool,
282    /// `evolve` only: action for an incompatible residue. Default: fail.
283    #[serde(default)]
284    pub on_incompatible: OnIncompatible,
285    /// `evolve` only: whether the **absence** of a destination `NOT NULL`
286    /// column from a page may drop that column's `NOT NULL` constraint.
287    ///
288    /// Default `false`: a column merely omitted from one batch is *not*
289    /// evidence the column is genuinely optional (a transient/partial page
290    /// omits it just as readily as a real schema change), so auto-relaxing
291    /// would silently and irreversibly weaken the destination's integrity
292    /// (issue #194 / F28). When `false`, an omitted required column is left
293    /// untouched — a page that genuinely lacks a required value then fails
294    /// loudly at write time rather than degrading the schema. Set `true` only
295    /// when you deliberately want missing-column omission to relax the
296    /// constraint. Nullability relaxation driven by an *observed* null value
297    /// in a present column (a widening that adds `null`) is unaffected by this
298    /// flag — that is evidence-based, not omission-based.
299    #[serde(default)]
300    pub relax_nullability_on_missing: bool,
301}
302
303/// Compiled, ready-to-run drift policy. Cheap to clone.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub struct SchemaDriftPolicy {
306    pub on_drift: OnDrift,
307    pub allow_widening: bool,
308    pub on_incompatible: OnIncompatible,
309    /// See [`SchemaDriftSpec::relax_nullability_on_missing`]. Default `false`.
310    pub relax_nullability_on_missing: bool,
311}
312
313impl SchemaDriftPolicy {
314    /// Compile a spec into a runnable policy. Infallible — there is nothing to
315    /// validate that serde hasn't already (the DLQ requirement is enforced by
316    /// the pipeline at run start and by the CLI at config-load).
317    pub fn compile(spec: &SchemaDriftSpec) -> Self {
318        Self {
319            on_drift: spec.on_drift,
320            allow_widening: spec.allow_type_widening,
321            on_incompatible: spec.on_incompatible,
322            relax_nullability_on_missing: spec.relax_nullability_on_missing,
323        }
324    }
325
326    /// `true` when this policy can route records to a DLQ (so one must exist).
327    pub fn requires_dlq(&self) -> bool {
328        self.on_drift == OnDrift::Quarantine
329            || (self.on_drift == OnDrift::Evolve
330                && self.on_incompatible == OnIncompatible::Quarantine)
331    }
332}
333
334/// Backend-neutral base column type inferred from a JSON-Schema fragment.
335/// Each SQL sink maps these to its concrete keyword.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum SqlBaseType {
338    Integer,
339    Double,
340    Boolean,
341    Text,
342    /// Nested object / array → stored as JSON/JSONB/NVARCHAR(MAX) text.
343    Json,
344}
345
346/// Map a top-level JSON-Schema type fragment to a base SQL type, or `None` for
347/// a pure-null fragment (caller falls back to TEXT/NVARCHAR for an added column
348/// whose only observed value was null).
349pub fn json_schema_base_type(fragment: &Value) -> Option<SqlBaseType> {
350    let (names, _nullable) = type_set(fragment);
351    // Prefer the widest informative type among the union.
352    if names.iter().any(|t| t == "object" || t == "array") {
353        return Some(SqlBaseType::Json);
354    }
355    if names.iter().any(|t| t == "string") {
356        return Some(SqlBaseType::Text);
357    }
358    if names.iter().any(|t| t == "number") {
359        return Some(SqlBaseType::Double);
360    }
361    if names.iter().any(|t| t == "integer") {
362        return Some(SqlBaseType::Integer);
363    }
364    if names.iter().any(|t| t == "boolean") {
365        return Some(SqlBaseType::Boolean);
366    }
367    None
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    fn schema(props: Value) -> Value {
376        json!({ "type": "object", "properties": props })
377    }
378
379    #[test]
380    fn sql_type_mapping() {
381        use super::SqlBaseType::*;
382        assert_eq!(
383            json_schema_base_type(&json!({"type":"integer"})),
384            Some(Integer)
385        );
386        assert_eq!(
387            json_schema_base_type(&json!({"type":"number"})),
388            Some(Double)
389        );
390        assert_eq!(
391            json_schema_base_type(&json!({"type":"boolean"})),
392            Some(Boolean)
393        );
394        assert_eq!(json_schema_base_type(&json!({"type":"string"})), Some(Text));
395        assert_eq!(
396            json_schema_base_type(&json!({"type":["string","null"]})),
397            Some(Text)
398        );
399        assert_eq!(json_schema_base_type(&json!({"type":"object"})), Some(Json));
400        assert_eq!(json_schema_base_type(&json!({"type":"array"})), Some(Json));
401        assert_eq!(json_schema_base_type(&json!({"type":"null"})), None);
402    }
403
404    #[test]
405    fn no_drift_when_shapes_match() {
406        let dest = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
407        let page = schema(json!({ "id": {"type": "integer"}, "name": {"type": "string"} }));
408        let d = diff_schema(&dest, &page, true);
409        assert!(d.is_empty(), "got {d:?}");
410    }
411
412    #[test]
413    fn detects_addition() {
414        let dest = schema(json!({ "id": {"type": "integer"} }));
415        let page = schema(json!({ "id": {"type": "integer"}, "email": {"type": "string"} }));
416        let d = diff_schema(&dest, &page, true);
417        assert_eq!(d.additions.len(), 1);
418        assert_eq!(d.additions[0].name, "email");
419        assert!(d.additions[0].from.is_none());
420        assert_eq!(d.additions[0].to, json!({"type": "string"}));
421        assert!(d.widenings.is_empty() && d.incompatible.is_empty());
422    }
423
424    #[test]
425    fn integer_to_number_is_widening_when_allowed() {
426        let dest = schema(json!({ "score": {"type": "integer"} }));
427        let page = schema(json!({ "score": {"type": "number"} }));
428        let d = diff_schema(&dest, &page, true);
429        assert_eq!(d.widenings.len(), 1, "got {d:?}");
430        assert_eq!(d.widenings[0].name, "score");
431        assert!(d.incompatible.is_empty());
432    }
433
434    #[test]
435    fn integer_to_number_is_incompatible_when_widening_disallowed() {
436        let dest = schema(json!({ "score": {"type": "integer"} }));
437        let page = schema(json!({ "score": {"type": "number"} }));
438        let d = diff_schema(&dest, &page, false);
439        assert_eq!(d.incompatible.len(), 1, "got {d:?}");
440        assert!(d.widenings.is_empty());
441    }
442
443    #[test]
444    fn gaining_nullability_is_widening() {
445        let dest = schema(json!({ "name": {"type": "string"} }));
446        let page = schema(json!({ "name": {"type": ["string", "null"]} }));
447        let d = diff_schema(&dest, &page, true);
448        assert_eq!(d.widenings.len(), 1, "got {d:?}");
449    }
450
451    #[test]
452    fn string_to_integer_is_incompatible() {
453        let dest = schema(json!({ "id": {"type": "string"} }));
454        let page = schema(json!({ "id": {"type": "integer"} }));
455        let d = diff_schema(&dest, &page, true);
456        assert_eq!(d.incompatible.len(), 1, "got {d:?}");
457        assert!(d.widenings.is_empty());
458    }
459
460    #[test]
461    fn required_destination_column_absent_from_page_is_droppable_required() {
462        // Destination has a non-nullable `created_at` the page never provides.
463        let dest = schema(json!({
464            "id": {"type": "integer"},
465            "created_at": {"type": "string"}
466        }));
467        let page = schema(json!({ "id": {"type": "integer"} }));
468        let d = diff_schema(&dest, &page, true);
469        assert_eq!(
470            d.droppable_required,
471            vec!["created_at".to_string()],
472            "got {d:?}"
473        );
474    }
475
476    #[test]
477    fn nullable_destination_column_absent_from_page_is_not_drift() {
478        // A column the destination already allows to be null is fine to omit.
479        let dest = schema(json!({
480            "id": {"type": "integer"},
481            "note": {"type": ["string", "null"]}
482        }));
483        let page = schema(json!({ "id": {"type": "integer"} }));
484        let d = diff_schema(&dest, &page, true);
485        assert!(d.is_empty(), "got {d:?}");
486    }
487
488    #[test]
489    fn nested_object_treated_as_single_column() {
490        // A change *inside* a nested object is invisible — top-level only.
491        let dest =
492            schema(json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}}} }));
493        let page = schema(
494            json!({ "meta": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}} }),
495        );
496        let d = diff_schema(&dest, &page, true);
497        assert!(
498            d.is_empty(),
499            "nested changes must not surface as drift; got {d:?}"
500        );
501    }
502
503    /// Which bucket a single-column diff landed in.
504    #[derive(Debug, PartialEq)]
505    enum Bucket {
506        None,
507        Widening,
508        Incompatible,
509    }
510
511    /// Diff a single column `col` (D vs P) and report which bucket it landed in.
512    fn classify_one(dest_ty: Value, page_ty: Value, allow_widening: bool) -> Bucket {
513        let dest = schema(json!({ "col": dest_ty }));
514        let page = schema(json!({ "col": page_ty }));
515        let d = diff_schema(&dest, &page, allow_widening);
516        assert!(d.additions.is_empty(), "unexpected addition: {d:?}");
517        assert!(
518            d.droppable_required.is_empty(),
519            "unexpected droppable: {d:?}"
520        );
521        match (d.widenings.len(), d.incompatible.len()) {
522            (0, 0) => Bucket::None,
523            (1, 0) => Bucket::Widening,
524            (0, 1) => Bucket::Incompatible,
525            _ => panic!("ambiguous classification: {d:?}"),
526        }
527    }
528
529    #[test]
530    fn truth_table_allow_widening() {
531        use Bucket::*;
532        // (dest, page, expected bucket) — allow_widening = true.
533        let cases: &[(Value, Value, Bucket)] = &[
534            (json!({"type": "integer"}), json!({"type": "integer"}), None),
535            (json!({"type": "string"}), json!({"type": "string"}), None),
536            (
537                json!({"type": ["string", "null"]}),
538                json!({"type": ["string", "null"]}),
539                None,
540            ),
541            // Regression guard: non-null page fits a nullable dest.
542            (
543                json!({"type": ["string", "null"]}),
544                json!({"type": "string"}),
545                None,
546            ),
547            // Regression guard: integer fits a number column.
548            (json!({"type": "number"}), json!({"type": "integer"}), None),
549            // integer → number widening.
550            (
551                json!({"type": "integer"}),
552                json!({"type": "number"}),
553                Widening,
554            ),
555            // string → nullable string (relax null).
556            (
557                json!({"type": "string"}),
558                json!({"type": ["string", "null"]}),
559                Widening,
560            ),
561            // integer → nullable number (int→number + null relax).
562            (
563                json!({"type": "integer"}),
564                json!({"type": ["number", "null"]}),
565                Widening,
566            ),
567            // nullable integer dest, number page → int→number, dest already nullable.
568            (
569                json!({"type": ["integer", "null"]}),
570                json!({"type": "number"}),
571                Widening,
572            ),
573            // Genuine incompatibilities.
574            (
575                json!({"type": "string"}),
576                json!({"type": "integer"}),
577                Incompatible,
578            ),
579            (
580                json!({"type": "integer"}),
581                json!({"type": "string"}),
582                Incompatible,
583            ),
584            (
585                json!({"type": "boolean"}),
586                json!({"type": "number"}),
587                Incompatible,
588            ),
589        ];
590        for (dest, page, want) in cases {
591            let got = classify_one(dest.clone(), page.clone(), true);
592            assert_eq!(
593                &got, want,
594                "allow_widening=true: D={dest} P={page} expected {want:?} got {got:?}"
595            );
596        }
597    }
598
599    #[test]
600    fn truth_table_widening_disallowed() {
601        use Bucket::*;
602        // (dest, page, expected bucket) — allow_widening = false.
603        let cases: &[(Value, Value, Bucket)] = &[
604            // int→number is no longer a widening; with widening off it is incompatible.
605            (
606                json!({"type": "integer"}),
607                json!({"type": "number"}),
608                Incompatible,
609            ),
610            // `fits` still applies regardless of allow_widening.
611            (
612                json!({"type": ["string", "null"]}),
613                json!({"type": "string"}),
614                None,
615            ),
616            // null relaxation is a widening, not a fit → incompatible when disallowed.
617            (
618                json!({"type": "string"}),
619                json!({"type": ["string", "null"]}),
620                Incompatible,
621            ),
622        ];
623        for (dest, page, want) in cases {
624            let got = classify_one(dest.clone(), page.clone(), false);
625            assert_eq!(
626                &got, want,
627                "allow_widening=false: D={dest} P={page} expected {want:?} got {got:?}"
628            );
629        }
630    }
631
632    #[test]
633    fn base_widened_detects_base_type_change() {
634        // integer → number is a base-type widening (needs ALTER TYPE).
635        assert!(base_widened(
636            &json!({"type": "integer"}),
637            &json!({"type": "number"})
638        ));
639        // nullability-only relaxation is NOT a base-type widening.
640        assert!(!base_widened(
641            &json!({"type": "string"}),
642            &json!({"type": ["string", "null"]})
643        ));
644        // identical base type, no change.
645        assert!(!base_widened(
646            &json!({"type": "integer"}),
647            &json!({"type": "integer"})
648        ));
649        // integer dest, nullable number page → base family changed.
650        assert!(base_widened(
651            &json!({"type": "integer"}),
652            &json!({"type": ["number", "null"]})
653        ));
654        // number dest, integer page → integer collapses into number, no change.
655        assert!(!base_widened(
656            &json!({"type": "number"}),
657            &json!({"type": "integer"})
658        ));
659    }
660
661    #[test]
662    fn adds_null_detects_nullability_relaxation() {
663        assert!(adds_null(
664            &json!({"type": "string"}),
665            &json!({"type": ["string", "null"]})
666        ));
667        // already nullable destination → not adding null.
668        assert!(!adds_null(
669            &json!({"type": ["string", "null"]}),
670            &json!({"type": "string"})
671        ));
672        assert!(!adds_null(
673            &json!({"type": "string"}),
674            &json!({"type": "string"})
675        ));
676        // page nullable, dest not → adds null even with a base change.
677        assert!(adds_null(
678            &json!({"type": "integer"}),
679            &json!({"type": ["number", "null"]})
680        ));
681    }
682
683    #[test]
684    fn spec_defaults() {
685        let spec: SchemaDriftSpec = serde_json::from_str("{}").unwrap();
686        assert_eq!(spec.on_drift, OnDrift::Warn);
687        assert!(spec.allow_type_widening);
688        assert_eq!(spec.on_incompatible, OnIncompatible::Fail);
689    }
690
691    #[test]
692    fn on_drift_serializes_snake_case() {
693        assert_eq!(
694            serde_json::to_string(&OnDrift::Evolve).unwrap(),
695            "\"evolve\""
696        );
697        assert_eq!(
698            serde_json::to_string(&OnDrift::Quarantine).unwrap(),
699            "\"quarantine\""
700        );
701    }
702
703    #[test]
704    fn policy_compile_carries_flags() {
705        let spec: SchemaDriftSpec =
706            serde_json::from_str(r#"{"on_drift":"evolve","allow_type_widening":false}"#).unwrap();
707        let policy = SchemaDriftPolicy::compile(&spec);
708        assert_eq!(policy.on_drift, OnDrift::Evolve);
709        assert!(!policy.allow_widening);
710        assert_eq!(policy.on_incompatible, OnIncompatible::Fail);
711    }
712
713    #[test]
714    fn policy_requires_dlq_only_for_quarantine_paths() {
715        let q: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"quarantine"}"#).unwrap();
716        assert!(SchemaDriftPolicy::compile(&q).requires_dlq());
717        let evo_q: SchemaDriftSpec =
718            serde_json::from_str(r#"{"on_drift":"evolve","on_incompatible":"quarantine"}"#)
719                .unwrap();
720        assert!(SchemaDriftPolicy::compile(&evo_q).requires_dlq());
721        let warn: SchemaDriftSpec = serde_json::from_str(r#"{"on_drift":"warn"}"#).unwrap();
722        assert!(!SchemaDriftPolicy::compile(&warn).requires_dlq());
723    }
724}