Skip to main content

force_sync/
plan.rs

1//! Pure planner and merge logic for sync envelopes.
2
3use serde_json::{Map, Value};
4
5use crate::config::{ObjectSync, Owner};
6use crate::model::{ChangeEnvelope, SourceSystem, payload_hash};
7
8/// Apply lanes chosen by the planner.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ApplyLane {
11    /// No write is required because the incoming change matches current state.
12    Noop,
13    /// The change requires operator intervention or later resolution.
14    Conflict,
15    /// Apply the change through the Salesforce REST API.
16    Rest,
17    /// Apply the change through Composite Graph for dependent records.
18    CompositeGraph,
19    /// Apply the change through bulk transport.
20    Bulk,
21}
22
23/// The pure inputs required to plan a change.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PlannerContext {
26    /// Object-level sync configuration.
27    pub object: ObjectSync,
28    /// The current canonical payload, if one is already known.
29    pub current_payload: Option<Value>,
30    /// The number of records being planned together.
31    pub batch_size: usize,
32    /// Whether the record should favor the fast path over batch economics.
33    pub urgent: bool,
34    /// Whether the change depends on related records being applied together.
35    pub has_dependencies: bool,
36}
37
38/// The final planner decision after merge and lane selection.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PlanDecision {
41    /// The selected apply lane.
42    pub lane: ApplyLane,
43    /// The merged payload to apply, if any.
44    pub payload: Option<Value>,
45    /// Fields that could not be resolved automatically.
46    pub conflicts: Vec<String>,
47}
48
49/// The result of merging payloads before lane selection.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum MergeOutcome {
52    /// No material change was detected.
53    Noop,
54    /// The payload was merged successfully.
55    Merged(Value),
56    /// The merge could not be resolved automatically.
57    Conflict {
58        /// The fields that caused the conflict.
59        fields: Vec<String>,
60    },
61}
62
63/// Merges an incoming payload into the current payload using field ownership rules.
64#[must_use]
65pub fn merge_payload(
66    object: &ObjectSync,
67    current_payload: Option<&Value>,
68    source: SourceSystem,
69    incoming_payload: &Value,
70) -> MergeOutcome {
71    let Some(current_payload) = current_payload else {
72        return MergeOutcome::Merged(incoming_payload.clone());
73    };
74
75    if payload_hash(current_payload) == payload_hash(incoming_payload) {
76        return MergeOutcome::Noop;
77    }
78
79    match (current_payload, incoming_payload) {
80        (Value::Object(current), Value::Object(incoming)) => {
81            match merge_object_payload(object, current, source, incoming) {
82                MergeOutcome::Noop => MergeOutcome::Merged(Value::Object(current.clone())),
83                other => other,
84            }
85        }
86        _ if current_payload == incoming_payload => MergeOutcome::Noop,
87        _ => MergeOutcome::Conflict {
88            fields: vec!["<root>".to_string()],
89        },
90    }
91}
92
93/// Plans how a change should be applied after merge resolution.
94#[must_use]
95pub fn plan_change(context: &PlannerContext, envelope: &ChangeEnvelope) -> PlanDecision {
96    if context
97        .current_payload
98        .as_ref()
99        .is_some_and(|current| envelope.payload_hash_matches(current))
100    {
101        return PlanDecision {
102            lane: ApplyLane::Noop,
103            payload: None,
104            conflicts: Vec::new(),
105        };
106    }
107
108    match merge_payload(
109        &context.object,
110        context.current_payload.as_ref(),
111        envelope.source(),
112        envelope.payload(),
113    ) {
114        MergeOutcome::Noop => PlanDecision {
115            lane: ApplyLane::Noop,
116            payload: None,
117            conflicts: Vec::new(),
118        },
119        MergeOutcome::Conflict { fields } => PlanDecision {
120            lane: ApplyLane::Conflict,
121            payload: None,
122            conflicts: fields,
123        },
124        MergeOutcome::Merged(payload) => {
125            let is_noop = context
126                .current_payload
127                .as_ref()
128                .is_some_and(|current| current == &payload);
129
130            PlanDecision {
131                lane: if is_noop {
132                    ApplyLane::Noop
133                } else {
134                    choose_lane(context)
135                },
136                payload: if is_noop { None } else { Some(payload) },
137                conflicts: Vec::new(),
138            }
139        }
140    }
141}
142
143const fn choose_lane(context: &PlannerContext) -> ApplyLane {
144    if context.has_dependencies {
145        return ApplyLane::CompositeGraph;
146    }
147
148    if context.urgent {
149        return ApplyLane::Rest;
150    }
151
152    if context.batch_size >= context.object.lane_thresholds().bulk_min_batch_size() {
153        ApplyLane::Bulk
154    } else {
155        ApplyLane::Rest
156    }
157}
158
159fn merge_object_payload(
160    object: &ObjectSync,
161    current: &Map<String, Value>,
162    source: SourceSystem,
163    incoming: &Map<String, Value>,
164) -> MergeOutcome {
165    // ⚡ Bolt: Delay cloning `current` until an actual mutation is needed.
166    // If all incoming fields already match or are ignored, we avoid cloning the entire map.
167    let mut merged: Option<Map<String, Value>> = None;
168    let mut conflicts = Vec::new();
169
170    for (field, incoming_value) in incoming {
171        if let Some(existing_value) = current.get(field) {
172            if existing_value == incoming_value {
173                continue;
174            }
175        }
176
177        let should_update = match object.field_owner_for(field) {
178            Some(Owner::Salesforce) if source == SourceSystem::Salesforce => true,
179            Some(Owner::Postgres) if source == SourceSystem::Postgres => true,
180            Some(Owner::Salesforce | Owner::Postgres) => false,
181            Some(Owner::Shared) | None => {
182                if current.contains_key(field) {
183                    conflicts.push(field.clone());
184                    false
185                } else {
186                    true
187                }
188            }
189        };
190
191        if should_update {
192            merged
193                .get_or_insert_with(|| current.clone())
194                .insert(field.clone(), incoming_value.clone());
195        }
196    }
197
198    if !conflicts.is_empty() {
199        return MergeOutcome::Conflict { fields: conflicts };
200    }
201
202    merged.map_or_else(
203        || MergeOutcome::Noop,
204        |merged| MergeOutcome::Merged(Value::Object(merged)),
205    )
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::identity::SyncKey;
212    use crate::model::{ChangeEnvelope, ChangeOperation, SourceSystem};
213    use chrono::Utc;
214    use serde_json::json;
215
216    fn test_object_sync() -> ObjectSync {
217        ObjectSync::new("Account")
218    }
219
220    fn test_object_sync_with_ownership() -> ObjectSync {
221        ObjectSync::new("Account")
222            .field_owner("Name", Owner::Salesforce)
223            .field_owner("Billing", Owner::Postgres)
224            .field_owner("Phone", Owner::Shared)
225    }
226
227    fn test_envelope(
228        source: SourceSystem,
229        operation: ChangeOperation,
230        payload: Value,
231    ) -> ChangeEnvelope {
232        let Ok(sync_key) = SyncKey::new("tenant", "Account", "ext-001") else {
233            panic!("failed to create test sync key");
234        };
235        ChangeEnvelope::new(sync_key, source, operation, Utc::now(), payload)
236    }
237
238    fn test_context(object: &ObjectSync, current_payload: Option<Value>) -> PlannerContext {
239        PlannerContext {
240            object: object.clone(),
241            current_payload,
242            batch_size: 1,
243            urgent: false,
244            has_dependencies: false,
245        }
246    }
247
248    // ── merge_payload ─────────────────────────────────────────────────
249
250    #[test]
251    fn merge_payload_no_current_returns_incoming_as_merged() {
252        let object = test_object_sync();
253        let incoming = json!({"Name": "Acme"});
254
255        let result = merge_payload(&object, None, SourceSystem::Salesforce, &incoming);
256        assert_eq!(result, MergeOutcome::Merged(json!({"Name": "Acme"})));
257    }
258
259    #[test]
260    fn merge_payload_identical_payloads_returns_noop() {
261        let object = test_object_sync();
262        let current = json!({"Name": "Acme"});
263        let incoming = json!({"Name": "Acme"});
264
265        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
266        assert_eq!(result, MergeOutcome::Noop);
267    }
268
269    #[test]
270    fn merge_payload_non_object_mismatch_returns_conflict() {
271        let object = test_object_sync();
272        let current = json!("string_a");
273        let incoming = json!("string_b");
274
275        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
276        assert!(matches!(result, MergeOutcome::Conflict { fields } if fields == vec!["<root>"]));
277    }
278
279    #[test]
280    fn merge_payload_non_object_identical_returns_noop() {
281        let object = test_object_sync();
282        let current = json!(42);
283        let incoming = json!(42);
284
285        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
286        assert_eq!(result, MergeOutcome::Noop);
287    }
288
289    #[test]
290    fn merge_payload_new_field_no_ownership_is_added() {
291        let object = test_object_sync();
292        let current = json!({"Name": "Acme"});
293        let incoming = json!({"Name": "Acme", "Phone": "555-1234"});
294
295        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
296        assert_eq!(
297            result,
298            MergeOutcome::Merged(json!({"Name": "Acme", "Phone": "555-1234"}))
299        );
300    }
301
302    #[test]
303    fn merge_payload_salesforce_owned_field_accepted_from_salesforce() {
304        let object = test_object_sync_with_ownership();
305        let current = json!({"Name": "Old"});
306        let incoming = json!({"Name": "New"});
307
308        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
309        assert_eq!(result, MergeOutcome::Merged(json!({"Name": "New"})));
310    }
311
312    #[test]
313    fn merge_payload_salesforce_owned_field_rejected_from_postgres() {
314        let object = test_object_sync_with_ownership();
315        let current = json!({"Name": "Acme"});
316        let incoming = json!({"Name": "Changed"});
317
318        let result = merge_payload(&object, Some(&current), SourceSystem::Postgres, &incoming);
319        assert_eq!(result, MergeOutcome::Merged(json!({"Name": "Acme"})));
320    }
321
322    #[test]
323    fn merge_payload_shared_field_conflict_on_change() {
324        let object = test_object_sync_with_ownership();
325        let current = json!({"Phone": "111"});
326        let incoming = json!({"Phone": "222"});
327
328        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
329        assert!(matches!(result, MergeOutcome::Conflict { fields } if fields == vec!["Phone"]));
330    }
331
332    #[test]
333    fn merge_payload_null_field_in_incoming_with_ownership() {
334        let object = ObjectSync::new("Account")
335            .field_owner("Name", Owner::Salesforce)
336            .field_owner("Phone", Owner::Salesforce);
337        let current = json!({"Name": "Acme", "Phone": "555"});
338        let incoming = json!({"Name": "Acme", "Phone": null});
339
340        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
341        assert_eq!(
342            result,
343            MergeOutcome::Merged(json!({"Name": "Acme", "Phone": null}))
344        );
345    }
346
347    #[test]
348    fn merge_payload_empty_incoming_object_returns_current() {
349        let object = test_object_sync();
350        let current = json!({"Name": "Acme"});
351        let incoming = json!({});
352
353        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
354        assert_eq!(result, MergeOutcome::Merged(json!({"Name": "Acme"})));
355    }
356
357    #[test]
358    fn merge_payload_postgres_owned_field_accepted_from_postgres() {
359        let object = test_object_sync_with_ownership();
360        let current = json!({"Billing": "Old Address"});
361        let incoming = json!({"Billing": "New Address"});
362
363        let result = merge_payload(&object, Some(&current), SourceSystem::Postgres, &incoming);
364        assert_eq!(
365            result,
366            MergeOutcome::Merged(json!({"Billing": "New Address"}))
367        );
368    }
369
370    #[test]
371    fn merge_payload_postgres_owned_field_rejected_from_salesforce() {
372        let object = test_object_sync_with_ownership();
373        let current = json!({"Billing": "Original"});
374        let incoming = json!({"Billing": "Overwritten"});
375
376        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
377        assert_eq!(result, MergeOutcome::Merged(json!({"Billing": "Original"})));
378    }
379
380    #[test]
381    fn merge_payload_new_field_salesforce_owned_added_from_salesforce() {
382        let object = test_object_sync_with_ownership();
383        let current = json!({});
384        let incoming = json!({"Name": "Added"});
385
386        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
387        assert_eq!(result, MergeOutcome::Merged(json!({"Name": "Added"})));
388    }
389
390    #[test]
391    fn merge_payload_new_field_salesforce_owned_ignored_from_postgres() {
392        let object = test_object_sync_with_ownership();
393        let current = json!({});
394        let incoming = json!({"Name": "ShouldBeIgnored"});
395
396        let result = merge_payload(&object, Some(&current), SourceSystem::Postgres, &incoming);
397        assert_eq!(result, MergeOutcome::Merged(json!({})));
398    }
399
400    #[test]
401    fn merge_payload_new_field_postgres_owned_ignored_from_salesforce() {
402        let object = test_object_sync_with_ownership();
403        let current = json!({});
404        let incoming = json!({"Billing": "ShouldBeIgnored"});
405
406        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
407        assert_eq!(result, MergeOutcome::Merged(json!({})));
408    }
409
410    #[test]
411    fn merge_payload_new_shared_field_is_added() {
412        let object = test_object_sync_with_ownership();
413        let current = json!({});
414        let incoming = json!({"Phone": "555-0100"});
415
416        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
417        assert_eq!(result, MergeOutcome::Merged(json!({"Phone": "555-0100"})));
418    }
419
420    #[test]
421    fn merge_payload_multiple_conflicts_collected() {
422        let object = ObjectSync::new("Account")
423            .field_owner("A", Owner::Shared)
424            .field_owner("B", Owner::Shared);
425        let current = json!({"A": 1, "B": 2});
426        let incoming = json!({"A": 10, "B": 20});
427
428        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
429        match result {
430            MergeOutcome::Conflict { fields } => {
431                assert!(fields.contains(&"A".to_string()));
432                assert!(fields.contains(&"B".to_string()));
433                assert_eq!(fields.len(), 2);
434            }
435            other => panic!("expected Conflict, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn merge_payload_null_field_in_incoming_no_ownership() {
441        let object = test_object_sync();
442        let current = json!({"Name": "Acme", "Phone": "555"});
443        let incoming = json!({"Name": "Acme", "Phone": null});
444
445        let result = merge_payload(&object, Some(&current), SourceSystem::Salesforce, &incoming);
446        assert!(matches!(result, MergeOutcome::Conflict { fields } if fields == vec!["Phone"]));
447    }
448
449    // ── plan_change ───────────────────────────────────────────────────
450
451    #[test]
452    fn plan_change_delete_no_current_routes_to_rest() {
453        let object = test_object_sync();
454        let envelope = test_envelope(SourceSystem::Salesforce, ChangeOperation::Delete, json!({}));
455        let ctx = test_context(&object, None);
456
457        let decision = plan_change(&ctx, &envelope);
458        assert_eq!(decision.lane, ApplyLane::Rest);
459    }
460
461    #[test]
462    fn plan_change_identical_payload_returns_noop() {
463        let object = test_object_sync();
464        let payload = json!({"Name": "Acme"});
465        let envelope = test_envelope(
466            SourceSystem::Salesforce,
467            ChangeOperation::Upsert,
468            payload.clone(),
469        );
470        let ctx = test_context(&object, Some(payload));
471
472        let decision = plan_change(&ctx, &envelope);
473        assert_eq!(decision.lane, ApplyLane::Noop);
474        assert!(decision.payload.is_none());
475        assert!(decision.conflicts.is_empty());
476    }
477
478    #[test]
479    fn plan_change_new_record_routes_to_rest() {
480        let object = test_object_sync();
481        let envelope = test_envelope(
482            SourceSystem::Postgres,
483            ChangeOperation::Upsert,
484            json!({"Name": "New"}),
485        );
486        let ctx = test_context(&object, None);
487
488        let decision = plan_change(&ctx, &envelope);
489        assert_eq!(decision.lane, ApplyLane::Rest);
490        assert_eq!(decision.payload, Some(json!({"Name": "New"})));
491    }
492
493    #[test]
494    fn plan_change_urgent_routes_to_rest() {
495        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
496        let envelope = test_envelope(
497            SourceSystem::Postgres,
498            ChangeOperation::Upsert,
499            json!({"Name": "Updated"}),
500        );
501        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
502        ctx.urgent = true;
503
504        let decision = plan_change(&ctx, &envelope);
505        assert_eq!(decision.lane, ApplyLane::Rest);
506    }
507
508    #[test]
509    fn plan_change_with_dependencies_routes_to_composite_graph() {
510        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
511        let envelope = test_envelope(
512            SourceSystem::Postgres,
513            ChangeOperation::Upsert,
514            json!({"Name": "Updated"}),
515        );
516        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
517        ctx.has_dependencies = true;
518
519        let decision = plan_change(&ctx, &envelope);
520        assert_eq!(decision.lane, ApplyLane::CompositeGraph);
521    }
522
523    #[test]
524    fn plan_change_large_batch_routes_to_bulk() {
525        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
526        let envelope = test_envelope(
527            SourceSystem::Postgres,
528            ChangeOperation::Upsert,
529            json!({"Name": "Updated"}),
530        );
531        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
532        ctx.batch_size = 1000;
533
534        let decision = plan_change(&ctx, &envelope);
535        assert_eq!(decision.lane, ApplyLane::Bulk);
536    }
537
538    #[test]
539    fn plan_change_conflict_returns_conflict_lane() {
540        let object = test_object_sync_with_ownership();
541        let envelope = test_envelope(
542            SourceSystem::Salesforce,
543            ChangeOperation::Upsert,
544            json!({"Phone": "new-value"}),
545        );
546        let ctx = test_context(&object, Some(json!({"Phone": "old-value"})));
547
548        let decision = plan_change(&ctx, &envelope);
549        assert_eq!(decision.lane, ApplyLane::Conflict);
550        assert!(decision.payload.is_none());
551        assert!(!decision.conflicts.is_empty());
552    }
553
554    #[test]
555    fn plan_change_merged_equals_current_returns_noop() {
556        let object = test_object_sync_with_ownership();
557        let current = json!({"Name": "Acme", "Billing": "123 Main"});
558        let incoming = json!({"Name": "Changed"});
559        let envelope = test_envelope(SourceSystem::Postgres, ChangeOperation::Upsert, incoming);
560        let ctx = test_context(&object, Some(current));
561
562        let decision = plan_change(&ctx, &envelope);
563        assert_eq!(decision.lane, ApplyLane::Noop);
564        assert!(decision.payload.is_none());
565    }
566
567    #[test]
568    fn plan_change_batch_below_bulk_threshold_routes_to_rest() {
569        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
570        let envelope = test_envelope(
571            SourceSystem::Postgres,
572            ChangeOperation::Upsert,
573            json!({"Name": "Updated"}),
574        );
575        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
576        ctx.batch_size = 499;
577
578        let decision = plan_change(&ctx, &envelope);
579        assert_eq!(decision.lane, ApplyLane::Rest);
580    }
581
582    #[test]
583    fn plan_change_batch_at_bulk_threshold_routes_to_bulk() {
584        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
585        let envelope = test_envelope(
586            SourceSystem::Postgres,
587            ChangeOperation::Upsert,
588            json!({"Name": "Updated"}),
589        );
590        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
591        ctx.batch_size = 500;
592
593        let decision = plan_change(&ctx, &envelope);
594        assert_eq!(decision.lane, ApplyLane::Bulk);
595    }
596
597    #[test]
598    fn plan_change_dependencies_take_priority_over_bulk() {
599        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
600        let envelope = test_envelope(
601            SourceSystem::Postgres,
602            ChangeOperation::Upsert,
603            json!({"Name": "Updated"}),
604        );
605        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
606        ctx.batch_size = 1000;
607        ctx.has_dependencies = true;
608
609        let decision = plan_change(&ctx, &envelope);
610        assert_eq!(decision.lane, ApplyLane::CompositeGraph);
611    }
612
613    #[test]
614    fn plan_change_dependencies_take_priority_over_urgent() {
615        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
616        let envelope = test_envelope(
617            SourceSystem::Postgres,
618            ChangeOperation::Upsert,
619            json!({"Name": "Updated"}),
620        );
621        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
622        ctx.urgent = true;
623        ctx.has_dependencies = true;
624
625        let decision = plan_change(&ctx, &envelope);
626        assert_eq!(decision.lane, ApplyLane::CompositeGraph);
627    }
628
629    #[test]
630    fn plan_change_urgent_takes_priority_over_bulk() {
631        let object = ObjectSync::new("Account").field_owner("Name", Owner::Postgres);
632        let envelope = test_envelope(
633            SourceSystem::Postgres,
634            ChangeOperation::Upsert,
635            json!({"Name": "Updated"}),
636        );
637        let mut ctx = test_context(&object, Some(json!({"Name": "Old"})));
638        ctx.batch_size = 1000;
639        ctx.urgent = true;
640
641        let decision = plan_change(&ctx, &envelope);
642        assert_eq!(decision.lane, ApplyLane::Rest);
643    }
644
645    #[test]
646    fn plan_change_semantically_equal_payload_returns_noop() {
647        let object = test_object_sync();
648        let current = json!({"A": 1, "B": 2});
649        let incoming = json!({"B": 2, "A": 1});
650        let envelope = test_envelope(SourceSystem::Postgres, ChangeOperation::Upsert, incoming);
651        let ctx = test_context(&object, Some(current));
652
653        let decision = plan_change(&ctx, &envelope);
654        assert_eq!(decision.lane, ApplyLane::Noop);
655    }
656}