1use serde_json::{Map, Value};
4
5use crate::config::{ObjectSync, Owner};
6use crate::model::{ChangeEnvelope, SourceSystem, payload_hash};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ApplyLane {
11 Noop,
13 Conflict,
15 Rest,
17 CompositeGraph,
19 Bulk,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PlannerContext {
26 pub object: ObjectSync,
28 pub current_payload: Option<Value>,
30 pub batch_size: usize,
32 pub urgent: bool,
34 pub has_dependencies: bool,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PlanDecision {
41 pub lane: ApplyLane,
43 pub payload: Option<Value>,
45 pub conflicts: Vec<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum MergeOutcome {
52 Noop,
54 Merged(Value),
56 Conflict {
58 fields: Vec<String>,
60 },
61}
62
63#[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#[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 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 #[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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), SourceSystem::Salesforce, &incoming);
446 assert!(matches!(result, MergeOutcome::Conflict { fields } if fields == vec!["Phone"]));
447 }
448
449 #[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}