Skip to main content

alembic_engine/
planner.rs

1//! diff and plan generation.
2
3use crate::state::StateStore;
4use crate::types::{FieldChange, ObservedState, Op, Plan};
5use alembic_core::{
6    key_string, uid_v5, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
7};
8use serde_json::Value;
9use std::cmp::Reverse;
10use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
11
12/// build a deterministic plan from desired and observed state.
13pub fn plan(
14    desired: &[Object],
15    observed: &ObservedState,
16    state: &StateStore,
17    schema: &alembic_core::Schema,
18    allow_delete: bool,
19) -> Plan {
20    let mut ops = Vec::new();
21    let mut matched = BTreeSet::new();
22    let mut backend_to_uid = BTreeMap::new();
23
24    for (type_name, mapping) in state.all_mappings() {
25        for (uid, backend_id) in mapping {
26            backend_to_uid.insert((backend_id.clone(), type_name.clone()), *uid);
27        }
28    }
29
30    let mut desired_sorted = desired.to_vec();
31    desired_sorted.sort_by_key(|a| op_sort_key(&a.type_name, &a.key));
32
33    for object in desired_sorted.iter() {
34        let observed_object = state
35            .backend_id(object.type_name.clone(), object.uid)
36            .and_then(|id| observed.by_backend_id.get(&(object.type_name.clone(), id)))
37            .or_else(|| {
38                observed
39                    .by_key
40                    .get(&(object.type_name.clone(), key_string(&object.key)))
41            });
42
43        if let Some(obs) = observed_object {
44            let changes = diff_object(obs, object);
45            if !changes.is_empty() {
46                ops.push(Op::Update {
47                    uid: object.uid,
48                    type_name: object.type_name.clone(),
49                    desired: object.clone(),
50                    changes,
51                    backend_id: obs.backend_id.clone(),
52                });
53            }
54            if let Some(backend_id) = &obs.backend_id {
55                matched.insert((object.type_name.clone(), backend_id.clone()));
56            }
57        } else {
58            ops.push(Op::Create {
59                uid: object.uid,
60                type_name: object.type_name.clone(),
61                desired: object.clone(),
62            });
63        }
64    }
65
66    if allow_delete {
67        for ((type_name, backend_id), obs) in &observed.by_backend_id {
68            if matched.contains(&(type_name.clone(), backend_id.clone())) {
69                continue;
70            }
71            let uid = backend_to_uid
72                .get(&(backend_id.clone(), type_name.clone()))
73                .copied()
74                .unwrap_or_else(|| uid_v5(type_name.as_str(), &key_string(&obs.key)));
75            ops.push(Op::Delete {
76                uid,
77                type_name: type_name.clone(),
78                key: obs.key.clone(),
79                backend_id: Some(backend_id.clone()),
80            });
81        }
82    }
83
84    ops.sort_by_key(op_order_key);
85
86    let mut plan = Plan {
87        schema: schema.clone(),
88        ops,
89        summary: None,
90    };
91    plan.summary = Some(plan.summary());
92    plan
93}
94
95/// compute field-level diffs for attrs.
96fn diff_attrs(existing: &JsonMap, desired: &JsonMap) -> Vec<FieldChange> {
97    let mut changes = Vec::new();
98    let keys: BTreeSet<String> = existing.keys().chain(desired.keys()).cloned().collect();
99
100    for key in keys.iter() {
101        let from = existing.get(key).cloned().unwrap_or(Value::Null);
102        let desired_has = desired.contains_key(key);
103        if !desired_has {
104            continue;
105        }
106        let to = desired.get(key).cloned().unwrap_or(Value::Null);
107        if from != to {
108            changes.push(FieldChange {
109                field: key.clone(),
110                from,
111                to,
112            });
113        }
114    }
115
116    changes
117}
118
119fn diff_object(existing: &crate::types::ObservedObject, desired: &Object) -> Vec<FieldChange> {
120    diff_attrs(&existing.attrs, &desired.attrs)
121}
122
123/// stable sort key for desired objects.
124fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
125    (type_name.as_str().to_string(), key_string(key))
126}
127
128/// deterministic ordering key for a plan op: (type name, op weight, key string).
129type OrderKey = (String, u8, String);
130
131/// stable sort key for plan operations.
132fn op_order_key(op: &Op) -> OrderKey {
133    let (type_name, key, weight) = match op {
134        Op::Create {
135            type_name, desired, ..
136        } => (type_name.clone(), key_string(&desired.key), 0u8),
137        Op::Update {
138            type_name, desired, ..
139        } => (type_name.clone(), key_string(&desired.key), 1u8),
140        Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
141    };
142    (type_name.as_str().to_string(), weight, key)
143}
144
145/// collect the referenced uids carried by a value, recursing through list and
146/// map containers, using the field's schema to know where refs live.
147fn collect_refs_in_value(field_type: &FieldType, value: &Value, out: &mut BTreeSet<Uid>) {
148    match field_type {
149        FieldType::Ref { .. } => {
150            if let Some(uid) = value.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
151                out.insert(uid);
152            }
153        }
154        FieldType::ListRef { .. } => {
155            if let Value::Array(items) = value {
156                for item in items {
157                    if let Some(uid) = item.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
158                        out.insert(uid);
159                    }
160                }
161            }
162        }
163        FieldType::List { item } => {
164            if let Value::Array(items) = value {
165                for item_value in items {
166                    collect_refs_in_value(item, item_value, out);
167                }
168            }
169        }
170        FieldType::Map { value: inner } => {
171            if let Value::Object(map) = value {
172                for entry in map.values() {
173                    collect_refs_in_value(inner, entry, out);
174                }
175            }
176        }
177        _ => {}
178    }
179}
180
181/// collect every uid referenced by an object's attrs (or key) given its schema.
182/// fields are looked up in `fields` first, falling back to `key` so that
183/// reference-typed key components are picked up too.
184fn collect_referenced_uids(
185    type_schema: &TypeSchema,
186    attrs: &BTreeMap<String, Value>,
187) -> BTreeSet<Uid> {
188    let mut uids = BTreeSet::new();
189    for (field, value) in attrs {
190        if let Some(field_schema) = type_schema
191            .fields
192            .get(field)
193            .or_else(|| type_schema.key.get(field))
194        {
195            collect_refs_in_value(&field_schema.r#type, value, &mut uids);
196        }
197    }
198    uids
199}
200
201/// the uids an op depends on. creates/updates draw refs from the desired attrs
202/// and key; deletes only carry a key, so refs come from reference-typed key
203/// components (the op has no attrs to inspect).
204fn op_referenced_uids(op: &Op, schema: &Schema) -> BTreeSet<Uid> {
205    let Some(type_schema) = schema.types.get(op.type_name().as_str()) else {
206        return BTreeSet::new();
207    };
208    match op {
209        Op::Create { desired, .. } | Op::Update { desired, .. } => {
210            let mut uids = collect_referenced_uids(type_schema, &desired.attrs);
211            uids.extend(collect_referenced_uids(type_schema, &desired.key));
212            uids
213        }
214        Op::Delete { key, .. } => collect_referenced_uids(type_schema, key),
215    }
216}
217
218/// Kahn's algorithm with `op_order_key` as a deterministic tie-breaker.
219/// `edges[a]` holds the nodes that must come *after* node `a`. Nodes that
220/// remain in a reference cycle never reach in-degree zero; they are appended in
221/// stable `op_order_key` order so the result is always a total order (the
222/// apply_retry fixpoint resolves any residual ordering for them at apply time).
223fn stable_toposort(ops: &[&Op], edges: &[BTreeSet<usize>]) -> Vec<usize> {
224    let n = ops.len();
225    let keys: Vec<OrderKey> = ops.iter().map(|&op| op_order_key(op)).collect();
226
227    let mut indegree = vec![0usize; n];
228    for succs in edges {
229        for &b in succs {
230            indegree[b] += 1;
231        }
232    }
233
234    let mut ready: BinaryHeap<Reverse<(OrderKey, usize)>> = BinaryHeap::new();
235    for (i, &deg) in indegree.iter().enumerate() {
236        if deg == 0 {
237            ready.push(Reverse((keys[i].clone(), i)));
238        }
239    }
240
241    let mut order = Vec::with_capacity(n);
242    while let Some(Reverse((_, i))) = ready.pop() {
243        order.push(i);
244        for &b in &edges[i] {
245            indegree[b] -= 1;
246            if indegree[b] == 0 {
247                ready.push(Reverse((keys[b].clone(), b)));
248            }
249        }
250    }
251
252    // cyclic remainder: nodes that never reached in-degree zero. fall back to
253    // stable ordering for them so we still emit a total order without panicking.
254    if order.len() < n {
255        let mut placed = vec![false; n];
256        for &i in &order {
257            placed[i] = true;
258        }
259        let mut remaining: Vec<usize> = (0..n).filter(|&i| !placed[i]).collect();
260        remaining.sort_by(|&a, &b| keys[a].cmp(&keys[b]).then(a.cmp(&b)));
261        order.extend(remaining);
262    }
263
264    order
265}
266
267/// order ops by reference dependency. when `reverse` is false (creates/updates)
268/// an op is placed after the ops that create the uids it references; when true
269/// (deletes) an op is placed before the ops it references, so an object is
270/// removed only after everything referencing it.
271fn ordered_by_refs(ops: &[&Op], schema: &Schema, reverse: bool) -> Vec<Op> {
272    let uid_to_node: BTreeMap<Uid, usize> = ops
273        .iter()
274        .enumerate()
275        .map(|(i, op)| (op.uid(), i))
276        .collect();
277
278    let mut edges: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); ops.len()];
279    for (i, &op) in ops.iter().enumerate() {
280        for referenced in op_referenced_uids(op, schema) {
281            let Some(&j) = uid_to_node.get(&referenced) else {
282                continue;
283            };
284            if i == j {
285                continue;
286            }
287            if reverse {
288                edges[i].insert(j); // i references j -> delete i before j
289            } else {
290                edges[j].insert(i); // i references j -> create j before i
291            }
292        }
293    }
294
295    stable_toposort(ops, &edges)
296        .into_iter()
297        .map(|i| ops[i].clone())
298        .collect()
299}
300
301/// order operations for apply: creates/updates first (topologically sorted so
302/// referenced objects are created before the objects that reference them),
303/// then deletes (reverse-toposorted so an object is deleted only after
304/// everything referencing it). reference cycles fall back to a stable order.
305pub fn sort_ops_for_apply(ops: &[Op], schema: &Schema) -> Vec<Op> {
306    let (non_deletes, deletes): (Vec<&Op>, Vec<&Op>) =
307        ops.iter().partition(|op| !matches!(op, Op::Delete { .. }));
308
309    let mut result = ordered_by_refs(&non_deletes, schema, false);
310    result.extend(ordered_by_refs(&deletes, schema, true));
311    result
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::state::{StateData, StateStore};
318    use crate::types::{BackendId, ObservedObject, ObservedState};
319    use alembic_core::{
320        FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
321    };
322    use serde_json::json;
323    use std::collections::BTreeMap;
324
325    fn make_key(slug: &str) -> Key {
326        let mut k = BTreeMap::new();
327        k.insert("slug".to_string(), json!(slug));
328        Key::from(k)
329    }
330
331    fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
332        let mut m = BTreeMap::new();
333        for (k, v) in pairs {
334            m.insert(k.to_string(), v.clone());
335        }
336        JsonMap::from(m)
337    }
338
339    fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
340        Object::new(
341            Uid::from_u128(uid),
342            TypeName::new(type_name),
343            make_key(slug),
344            attrs,
345        )
346        .unwrap()
347    }
348
349    fn empty_schema() -> Schema {
350        Schema {
351            types: BTreeMap::new(),
352        }
353    }
354
355    fn empty_state() -> StateStore {
356        StateStore::new(None, StateData::default())
357    }
358
359    // --- diff_attrs ---
360
361    #[test]
362    fn diff_attrs_identical_maps() {
363        let attrs = make_attrs(&[("name", json!("FRA1"))]);
364        let changes = diff_attrs(&attrs, &attrs);
365        assert!(changes.is_empty());
366    }
367
368    #[test]
369    fn diff_attrs_field_changed() {
370        let existing = make_attrs(&[("name", json!("FRA1"))]);
371        let desired = make_attrs(&[("name", json!("FRA2"))]);
372        let changes = diff_attrs(&existing, &desired);
373        assert_eq!(changes.len(), 1);
374        assert_eq!(changes[0].field, "name");
375        assert_eq!(changes[0].from, json!("FRA1"));
376        assert_eq!(changes[0].to, json!("FRA2"));
377    }
378
379    #[test]
380    fn diff_attrs_field_added() {
381        let existing = make_attrs(&[]);
382        let desired = make_attrs(&[("name", json!("FRA1"))]);
383        let changes = diff_attrs(&existing, &desired);
384        assert_eq!(changes.len(), 1);
385        assert_eq!(changes[0].field, "name");
386        assert_eq!(changes[0].from, json!(null));
387        assert_eq!(changes[0].to, json!("FRA1"));
388    }
389
390    #[test]
391    fn diff_attrs_field_removed_in_desired_is_ignored() {
392        let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
393        let desired = make_attrs(&[("name", json!("FRA1"))]);
394        let changes = diff_attrs(&existing, &desired);
395        assert!(changes.is_empty());
396    }
397
398    #[test]
399    fn diff_attrs_multiple_changes() {
400        let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
401        let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
402        let changes = diff_attrs(&existing, &desired);
403        assert_eq!(changes.len(), 2);
404        let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
405        assert!(fields.contains(&"a"));
406        assert!(fields.contains(&"b"));
407    }
408
409    #[test]
410    fn diff_attrs_type_change() {
411        let existing = make_attrs(&[("val", json!("string"))]);
412        let desired = make_attrs(&[("val", json!(42))]);
413        let changes = diff_attrs(&existing, &desired);
414        assert_eq!(changes.len(), 1);
415        assert_eq!(changes[0].from, json!("string"));
416        assert_eq!(changes[0].to, json!(42));
417    }
418
419    // --- plan() ---
420
421    #[test]
422    fn plan_creates_for_new_objects() {
423        let desired = vec![make_object(
424            1,
425            "dcim.site",
426            "fra1",
427            make_attrs(&[("name", json!("FRA1"))]),
428        )];
429        let observed = ObservedState::default();
430        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
431        assert_eq!(result.ops.len(), 1);
432        assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
433            if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
434        let summary = result.summary.unwrap();
435        assert_eq!(summary.create, 1);
436        assert_eq!(summary.update, 0);
437        assert_eq!(summary.delete, 0);
438    }
439
440    #[test]
441    fn plan_updates_when_attrs_differ() {
442        let desired = vec![make_object(
443            1,
444            "dcim.site",
445            "fra1",
446            make_attrs(&[("name", json!("FRA2"))]),
447        )];
448        let mut observed = ObservedState::default();
449        observed
450            .insert(ObservedObject {
451                type_name: TypeName::new("dcim.site"),
452                key: make_key("fra1"),
453                attrs: make_attrs(&[("name", json!("FRA1"))]),
454                backend_id: Some(BackendId::Int(100)),
455            })
456            .unwrap();
457        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
458        assert_eq!(result.ops.len(), 1);
459        match &result.ops[0] {
460            Op::Update {
461                changes,
462                backend_id,
463                ..
464            } => {
465                assert_eq!(changes.len(), 1);
466                assert_eq!(changes[0].field, "name");
467                assert_eq!(backend_id, &Some(BackendId::Int(100)));
468            }
469            other => panic!("expected Update, got {:?}", other),
470        }
471    }
472
473    #[test]
474    fn plan_no_op_when_identical() {
475        let desired = vec![make_object(
476            1,
477            "dcim.site",
478            "fra1",
479            make_attrs(&[("name", json!("FRA1"))]),
480        )];
481        let mut observed = ObservedState::default();
482        observed
483            .insert(ObservedObject {
484                type_name: TypeName::new("dcim.site"),
485                key: make_key("fra1"),
486                attrs: make_attrs(&[("name", json!("FRA1"))]),
487                backend_id: Some(BackendId::Int(100)),
488            })
489            .unwrap();
490        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
491        assert!(result.ops.is_empty());
492    }
493
494    #[test]
495    fn plan_deletes_unmatched_when_allowed() {
496        let desired = vec![];
497        let mut observed = ObservedState::default();
498        observed
499            .insert(ObservedObject {
500                type_name: TypeName::new("dcim.site"),
501                key: make_key("fra1"),
502                attrs: make_attrs(&[("name", json!("FRA1"))]),
503                backend_id: Some(BackendId::Int(100)),
504            })
505            .unwrap();
506        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
507        assert_eq!(result.ops.len(), 1);
508        assert!(matches!(
509            &result.ops[0],
510            Op::Delete {
511                backend_id: Some(BackendId::Int(100)),
512                ..
513            }
514        ));
515    }
516
517    #[test]
518    fn plan_no_deletes_when_disallowed() {
519        let desired = vec![];
520        let mut observed = ObservedState::default();
521        observed
522            .insert(ObservedObject {
523                type_name: TypeName::new("dcim.site"),
524                key: make_key("fra1"),
525                attrs: make_attrs(&[("name", json!("FRA1"))]),
526                backend_id: Some(BackendId::Int(100)),
527            })
528            .unwrap();
529        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
530        assert!(result.ops.is_empty());
531    }
532
533    #[test]
534    fn plan_matched_objects_not_deleted() {
535        let desired = vec![make_object(
536            1,
537            "dcim.site",
538            "fra1",
539            make_attrs(&[("name", json!("FRA1"))]),
540        )];
541        let mut observed = ObservedState::default();
542        observed
543            .insert(ObservedObject {
544                type_name: TypeName::new("dcim.site"),
545                key: make_key("fra1"),
546                attrs: make_attrs(&[("name", json!("FRA1"))]),
547                backend_id: Some(BackendId::Int(100)),
548            })
549            .unwrap();
550        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
551        assert!(result.ops.is_empty());
552    }
553
554    #[test]
555    fn plan_deletes_cross_type_id_collision() {
556        // both types carry backend id 100; declaring only the site must not
557        // suppress the undeclared device's delete.
558        let desired = vec![make_object(
559            1,
560            "dcim.site",
561            "fra1",
562            make_attrs(&[("name", json!("FRA1"))]),
563        )];
564        let mut observed = ObservedState::default();
565        observed
566            .insert(ObservedObject {
567                type_name: TypeName::new("dcim.site"),
568                key: make_key("fra1"),
569                attrs: make_attrs(&[("name", json!("FRA1"))]),
570                backend_id: Some(BackendId::Int(100)),
571            })
572            .unwrap();
573        observed
574            .insert(ObservedObject {
575                type_name: TypeName::new("dcim.device"),
576                key: make_key("leaf01"),
577                attrs: make_attrs(&[]),
578                backend_id: Some(BackendId::Int(100)),
579            })
580            .unwrap();
581        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
582        let deletes: Vec<&Op> = result
583            .ops
584            .iter()
585            .filter(|op| matches!(op, Op::Delete { .. }))
586            .collect();
587        assert_eq!(deletes.len(), 1);
588        assert!(matches!(
589            deletes[0],
590            Op::Delete { type_name, .. } if type_name.as_str() == "dcim.device"
591        ));
592    }
593
594    #[test]
595    fn plan_mixed_create_update_delete() {
596        let desired = vec![
597            make_object(
598                1,
599                "dcim.site",
600                "fra1",
601                make_attrs(&[("name", json!("FRA1-new"))]),
602            ),
603            make_object(
604                2,
605                "dcim.site",
606                "ams1",
607                make_attrs(&[("name", json!("AMS1"))]),
608            ),
609        ];
610        let mut observed = ObservedState::default();
611        observed
612            .insert(ObservedObject {
613                type_name: TypeName::new("dcim.site"),
614                key: make_key("fra1"),
615                attrs: make_attrs(&[("name", json!("FRA1"))]),
616                backend_id: Some(BackendId::Int(100)),
617            })
618            .unwrap();
619        observed
620            .insert(ObservedObject {
621                type_name: TypeName::new("dcim.site"),
622                key: make_key("lhr1"),
623                attrs: make_attrs(&[("name", json!("LHR1"))]),
624                backend_id: Some(BackendId::Int(200)),
625            })
626            .unwrap();
627        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
628
629        let creates: Vec<_> = result
630            .ops
631            .iter()
632            .filter(|op| matches!(op, Op::Create { .. }))
633            .collect();
634        let updates: Vec<_> = result
635            .ops
636            .iter()
637            .filter(|op| matches!(op, Op::Update { .. }))
638            .collect();
639        let deletes: Vec<_> = result
640            .ops
641            .iter()
642            .filter(|op| matches!(op, Op::Delete { .. }))
643            .collect();
644        assert_eq!(creates.len(), 1);
645        assert_eq!(updates.len(), 1);
646        assert_eq!(deletes.len(), 1);
647
648        let summary = result.summary.unwrap();
649        assert_eq!(summary.create, 1);
650        assert_eq!(summary.update, 1);
651        assert_eq!(summary.delete, 1);
652    }
653
654    #[test]
655    fn plan_uses_state_mapping_for_lookup() {
656        let mut state_data = StateData::default();
657        state_data
658            .mappings
659            .entry(TypeName::new("dcim.site"))
660            .or_default()
661            .insert(Uid::from_u128(1), BackendId::Int(100));
662        let state = StateStore::new(None, state_data);
663
664        let desired = vec![make_object(
665            1,
666            "dcim.site",
667            "fra1",
668            make_attrs(&[("name", json!("FRA2"))]),
669        )];
670        let mut observed = ObservedState::default();
671        observed
672            .insert(ObservedObject {
673                type_name: TypeName::new("dcim.site"),
674                key: make_key("fra1"),
675                attrs: make_attrs(&[("name", json!("FRA1"))]),
676                backend_id: Some(BackendId::Int(100)),
677            })
678            .unwrap();
679        let result = plan(&desired, &observed, &state, &empty_schema(), false);
680        assert_eq!(result.ops.len(), 1);
681        assert!(matches!(&result.ops[0], Op::Update { .. }));
682    }
683
684    // --- sort_ops_for_apply ---
685
686    #[test]
687    fn sort_ops_creates_before_deletes() {
688        let ops = vec![
689            Op::Delete {
690                uid: Uid::from_u128(1),
691                type_name: TypeName::new("dcim.site"),
692                key: make_key("fra1"),
693                backend_id: Some(BackendId::Int(100)),
694            },
695            Op::Create {
696                uid: Uid::from_u128(2),
697                type_name: TypeName::new("dcim.site"),
698                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
699            },
700        ];
701        let sorted = sort_ops_for_apply(&ops, &empty_schema());
702        assert!(matches!(&sorted[0], Op::Create { .. }));
703        assert!(matches!(&sorted[1], Op::Delete { .. }));
704    }
705
706    #[test]
707    fn sort_ops_updates_before_deletes() {
708        let ops = vec![
709            Op::Delete {
710                uid: Uid::from_u128(1),
711                type_name: TypeName::new("dcim.site"),
712                key: make_key("fra1"),
713                backend_id: None,
714            },
715            Op::Update {
716                uid: Uid::from_u128(2),
717                type_name: TypeName::new("dcim.site"),
718                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
719                changes: vec![],
720                backend_id: None,
721            },
722        ];
723        let sorted = sort_ops_for_apply(&ops, &empty_schema());
724        assert!(matches!(&sorted[0], Op::Update { .. }));
725        assert!(matches!(&sorted[1], Op::Delete { .. }));
726    }
727
728    #[test]
729    fn sort_ops_deletes_last() {
730        let ops = vec![
731            Op::Delete {
732                uid: Uid::from_u128(1),
733                type_name: TypeName::new("a.type"),
734                key: make_key("a"),
735                backend_id: None,
736            },
737            Op::Delete {
738                uid: Uid::from_u128(2),
739                type_name: TypeName::new("z.type"),
740                key: make_key("z"),
741                backend_id: None,
742            },
743        ];
744        let sorted = sort_ops_for_apply(&ops, &empty_schema());
745        // both deletes come last, sorted alphabetically by type
746        assert!(
747            matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
748        );
749        assert!(
750            matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
751        );
752    }
753
754    #[test]
755    fn sort_ops_empty_input() {
756        let sorted = sort_ops_for_apply(&[], &empty_schema());
757        assert!(sorted.is_empty());
758    }
759
760    #[test]
761    fn sort_ops_preserves_create_update_order() {
762        let ops = vec![
763            Op::Update {
764                uid: Uid::from_u128(2),
765                type_name: TypeName::new("dcim.site"),
766                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
767                changes: vec![],
768                backend_id: None,
769            },
770            Op::Create {
771                uid: Uid::from_u128(1),
772                type_name: TypeName::new("dcim.site"),
773                desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
774            },
775        ];
776        let sorted = sort_ops_for_apply(&ops, &empty_schema());
777        assert!(matches!(&sorted[0], Op::Create { .. }));
778        assert!(matches!(&sorted[1], Op::Update { .. }));
779    }
780
781    // --- sort_ops_for_apply: topological ordering by reference (issue #47) ---
782
783    fn field(r#type: FieldType) -> FieldSchema {
784        FieldSchema {
785            r#type,
786            required: false,
787            nullable: true,
788            format: None,
789            pattern: None,
790            description: None,
791        }
792    }
793
794    fn ref_t() -> FieldType {
795        FieldType::Ref {
796            target: "node".to_string(),
797        }
798    }
799
800    fn list_ref_t() -> FieldType {
801        FieldType::ListRef {
802            target: "node".to_string(),
803        }
804    }
805
806    /// schema with a single type `node` keyed by `slug` plus the given fields.
807    fn node_schema(fields: &[(&str, FieldType)]) -> Schema {
808        let mut field_map = BTreeMap::new();
809        for (name, ty) in fields {
810            field_map.insert(name.to_string(), field(ty.clone()));
811        }
812        let mut key = BTreeMap::new();
813        key.insert("slug".to_string(), field(FieldType::Slug));
814        let mut types = BTreeMap::new();
815        types.insert(
816            "node".to_string(),
817            TypeSchema {
818                key,
819                fields: field_map,
820            },
821        );
822        Schema { types }
823    }
824
825    /// a uuid string ref value for the given uid.
826    fn uref(uid: u128) -> serde_json::Value {
827        json!(Uid::from_u128(uid).to_string())
828    }
829
830    fn create_node(uid: u128, slug: &str, attrs: JsonMap) -> Op {
831        Op::Create {
832            uid: Uid::from_u128(uid),
833            type_name: TypeName::new("node"),
834            desired: make_object(uid, "node", slug, attrs),
835        }
836    }
837
838    /// position of the op carrying `uid` in the ordered output.
839    fn order_index(ops: &[Op], uid: u128) -> usize {
840        ops.iter()
841            .position(|op| op.uid().as_u128() == uid)
842            .expect("uid present in ordered ops")
843    }
844
845    #[test]
846    fn toposort_linear_ref_chain() {
847        let schema = node_schema(&[("next", ref_t())]);
848        let ops = vec![
849            create_node(1, "a", make_attrs(&[("next", uref(2))])),
850            create_node(2, "b", make_attrs(&[("next", uref(3))])),
851            create_node(3, "c", make_attrs(&[])),
852        ];
853        let sorted = sort_ops_for_apply(&ops, &schema);
854        // referenced objects are created before the objects referencing them.
855        assert!(order_index(&sorted, 3) < order_index(&sorted, 2));
856        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
857    }
858
859    #[test]
860    fn toposort_diamond_shared_dep() {
861        let schema = node_schema(&[("parent", ref_t()), ("left", ref_t()), ("right", ref_t())]);
862        let ops = vec![
863            create_node(1, "d", make_attrs(&[("left", uref(2)), ("right", uref(3))])),
864            create_node(2, "b", make_attrs(&[("parent", uref(4))])),
865            create_node(3, "c", make_attrs(&[("parent", uref(4))])),
866            create_node(4, "a", make_attrs(&[])),
867        ];
868        let sorted = sort_ops_for_apply(&ops, &schema);
869        let (a, b, c, d) = (
870            order_index(&sorted, 4),
871            order_index(&sorted, 2),
872            order_index(&sorted, 3),
873            order_index(&sorted, 1),
874        );
875        assert!(a < b && a < c, "shared dep created first");
876        assert!(b < d && c < d, "both branches created before the joiner");
877    }
878
879    #[test]
880    fn toposort_list_ref_fan_out() {
881        let schema = node_schema(&[("members", list_ref_t())]);
882        let ops = vec![
883            create_node(
884                1,
885                "a",
886                make_attrs(&[("members", json!([uref(2), uref(3), uref(4)]))]),
887            ),
888            create_node(2, "b", make_attrs(&[])),
889            create_node(3, "c", make_attrs(&[])),
890            create_node(4, "d", make_attrs(&[])),
891        ];
892        let sorted = sort_ops_for_apply(&ops, &schema);
893        let a = order_index(&sorted, 1);
894        assert!(order_index(&sorted, 2) < a);
895        assert!(order_index(&sorted, 3) < a);
896        assert!(order_index(&sorted, 4) < a);
897    }
898
899    #[test]
900    fn toposort_refs_nested_in_list_and_map() {
901        let schema = node_schema(&[
902            (
903                "group",
904                FieldType::List {
905                    item: Box::new(ref_t()),
906                },
907            ),
908            (
909                "lookup",
910                FieldType::Map {
911                    value: Box::new(ref_t()),
912                },
913            ),
914        ]);
915        let ops = vec![
916            create_node(
917                1,
918                "a",
919                make_attrs(&[
920                    ("group", json!([uref(2)])),
921                    ("lookup", json!({ "k": uref(3) })),
922                ]),
923            ),
924            create_node(2, "b", make_attrs(&[])),
925            create_node(3, "c", make_attrs(&[])),
926        ];
927        let sorted = sort_ops_for_apply(&ops, &schema);
928        let a = order_index(&sorted, 1);
929        assert!(order_index(&sorted, 2) < a, "ref nested in list");
930        assert!(order_index(&sorted, 3) < a, "ref nested in map");
931    }
932
933    #[test]
934    fn toposort_reference_cycle_falls_back_to_stable_order() {
935        let schema = node_schema(&[("next", ref_t())]);
936        let ops = vec![
937            create_node(1, "aaa", make_attrs(&[("next", uref(2))])),
938            create_node(2, "bbb", make_attrs(&[("next", uref(1))])),
939        ];
940        let sorted = sort_ops_for_apply(&ops, &schema);
941        // a cycle still yields a total order without panicking or dropping ops.
942        assert_eq!(sorted.len(), 2);
943        // the cyclic remainder is emitted in stable op_order_key (slug) order.
944        assert_eq!(order_index(&sorted, 1), 0);
945        assert_eq!(order_index(&sorted, 2), 1);
946    }
947
948    #[test]
949    fn reverse_toposort_deletes_child_before_parent() {
950        // `child` is keyed by a reference to `node`, so deleting the parent
951        // node must wait until the referencing child is gone.
952        let mut node_key = BTreeMap::new();
953        node_key.insert("slug".to_string(), field(FieldType::Slug));
954        let mut child_key = BTreeMap::new();
955        child_key.insert("parent".to_string(), field(ref_t()));
956        child_key.insert("slug".to_string(), field(FieldType::Slug));
957        let mut types = BTreeMap::new();
958        types.insert(
959            "node".to_string(),
960            TypeSchema {
961                key: node_key,
962                fields: BTreeMap::new(),
963            },
964        );
965        types.insert(
966            "child".to_string(),
967            TypeSchema {
968                key: child_key,
969                fields: BTreeMap::new(),
970            },
971        );
972        let schema = Schema { types };
973
974        let parent_key = Key::from(BTreeMap::from([("slug".to_string(), json!("p"))]));
975        let child_key_val = Key::from(BTreeMap::from([
976            ("parent".to_string(), uref(1)),
977            ("slug".to_string(), json!("c")),
978        ]));
979        let ops = vec![
980            Op::Delete {
981                uid: Uid::from_u128(1),
982                type_name: TypeName::new("node"),
983                key: parent_key,
984                backend_id: None,
985            },
986            Op::Delete {
987                uid: Uid::from_u128(2),
988                type_name: TypeName::new("child"),
989                key: child_key_val,
990                backend_id: None,
991            },
992        ];
993        let sorted = sort_ops_for_apply(&ops, &schema);
994        assert!(
995            order_index(&sorted, 2) < order_index(&sorted, 1),
996            "child deleted before the parent it references"
997        );
998        assert!(sorted.iter().all(|op| matches!(op, Op::Delete { .. })));
999    }
1000
1001    #[test]
1002    fn toposort_keeps_deletes_after_creates_and_updates() {
1003        let schema = node_schema(&[("next", ref_t())]);
1004        let ops = vec![
1005            Op::Delete {
1006                uid: Uid::from_u128(9),
1007                type_name: TypeName::new("node"),
1008                key: make_key("z"),
1009                backend_id: None,
1010            },
1011            create_node(1, "a", make_attrs(&[("next", uref(2))])),
1012            create_node(2, "b", make_attrs(&[])),
1013        ];
1014        let sorted = sort_ops_for_apply(&ops, &schema);
1015        let non_delete_count = sorted
1016            .iter()
1017            .filter(|op| !matches!(op, Op::Delete { .. }))
1018            .count();
1019        let delete_pos = sorted
1020            .iter()
1021            .position(|op| matches!(op, Op::Delete { .. }))
1022            .unwrap();
1023        // every create/update precedes the single delete.
1024        assert_eq!(delete_pos, non_delete_count);
1025        // and the create chain is still toposorted within its block.
1026        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1027    }
1028}