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