1#![allow(unexpected_cfgs)]
16
17pub mod client;
73pub mod config;
74pub mod descriptor;
75pub mod types;
76
77pub use config::DataverseSourceConfig;
78
79use anyhow::Result;
80use async_trait::async_trait;
81use std::collections::HashMap;
82use std::sync::Arc;
83use std::time::Duration;
84
85use drasi_core::models::{
86 Element, ElementMetadata, ElementPropertyMap, ElementReference, ElementValue, SourceChange,
87};
88use drasi_lib::channels::{ComponentStatus, DispatchMode, SourceEvent, SourceEventWrapper};
89use drasi_lib::identity::IdentityProvider;
90use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
91use drasi_lib::Source;
92use tracing::Instrument;
93
94use crate::client::DataverseClient;
95use crate::types::{parse_delta_changes, DataverseChange};
96
97pub struct DataverseSource {
107 base: SourceBase,
109 config: DataverseSourceConfig,
111 identity_provider: Option<Box<dyn IdentityProvider>>,
114}
115
116impl DataverseSource {
117 pub fn new(id: impl Into<String>, config: DataverseSourceConfig) -> Result<Self> {
128 config.validate().map_err(|e| anyhow::anyhow!(e))?;
129 let params = SourceBaseParams::new(id.into());
130 Ok(Self {
131 base: SourceBase::new(params)?,
132 config,
133 identity_provider: None,
134 })
135 }
136
137 pub fn builder(id: impl Into<String>) -> DataverseSourceBuilder {
143 DataverseSourceBuilder::new(id)
144 }
145
146 pub(crate) fn dataverse_scope(environment_url: &str) -> String {
153 match url::Url::parse(environment_url) {
154 Ok(url) => match url.host_str() {
155 Some(host) => format!("{}://{}/.default", url.scheme(), host),
156 None => format!("{}/.default", environment_url.trim_end_matches('/')),
157 },
158 Err(_) => format!("{}/.default", environment_url.trim_end_matches('/')),
159 }
160 }
161
162 pub(crate) fn next_backoff_interval(
172 current_interval_ms: u64,
173 min_interval_ms: u64,
174 max_interval_ms: u64,
175 changes_detected: bool,
176 ) -> u64 {
177 const THRESHOLD_MS: u64 = 5000;
178 const SLOW_BACKOFF: f64 = 1.2;
179 const FAST_BACKOFF: f64 = 1.5;
180
181 if changes_detected {
182 return min_interval_ms;
183 }
184 let multiplier = if current_interval_ms < THRESHOLD_MS {
185 SLOW_BACKOFF
186 } else {
187 FAST_BACKOFF
188 };
189 ((current_interval_ms as f64 * multiplier) as u64).min(max_interval_ms)
190 }
191
192 pub(crate) fn delta_token_key(entity_name: &str) -> String {
195 format!("{entity_name}-deltatoken")
196 }
197
198 pub(crate) async fn load_delta_token(
203 store: &Arc<dyn drasi_lib::StateStoreProvider>,
204 source_id: &str,
205 entity_name: &str,
206 ) -> Option<String> {
207 let key = Self::delta_token_key(entity_name);
208 match store.get(source_id, &key).await {
209 Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
210 Ok(None) => None,
211 Err(e) => {
212 log::warn!("[{source_id}] Failed to load delta token for {entity_name}: {e}");
213 None
214 }
215 }
216 }
217
218 pub(crate) async fn save_delta_token(
220 store: &Arc<dyn drasi_lib::StateStoreProvider>,
221 source_id: &str,
222 entity_name: &str,
223 token: &str,
224 ) {
225 let key = Self::delta_token_key(entity_name);
226 if let Err(e) = store.set(source_id, &key, token.as_bytes().to_vec()).await {
227 log::warn!("[{source_id}] Failed to persist delta token for {entity_name}: {e}");
228 }
229 }
230
231 #[allow(clippy::too_many_arguments)]
240 async fn run_entity_worker(
241 source_id: String,
242 entity_name: String,
243 entity_set_name: String,
244 select: Option<String>,
245 client: Arc<DataverseClient>,
246 dispatchers: Arc<
247 tokio::sync::RwLock<
248 Vec<
249 Box<
250 dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
251 >,
252 >,
253 >,
254 >,
255 state_store: Option<Arc<dyn drasi_lib::StateStoreProvider>>,
256 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
257 min_interval_ms: u64,
258 max_interval_seconds: u64,
259 ) {
260 let mut current_interval_ms = min_interval_ms;
261 let max_interval_ms = max_interval_seconds * 1000;
262
263 let mut delta_link: Option<String> = None;
265
266 if let Some(ref store) = state_store {
267 delta_link = Self::load_delta_token(store, &source_id, &entity_name).await;
268 if delta_link.is_some() {
269 log::info!("[{source_id}] Resuming from checkpoint for {entity_name}");
270 } else {
271 log::info!("[{source_id}] No checkpoint found for {entity_name}, getting current delta token");
272 }
273 }
274
275 if delta_link.is_none() {
278 let mut retry_interval_ms: u64 = 1000;
279 const MAX_RETRY_INTERVAL_MS: u64 = 60_000;
280
281 loop {
282 if *shutdown_rx.borrow() {
284 log::info!("[{source_id}] Shutting down entity worker for {entity_name} during initial token acquisition");
285 return;
286 }
287
288 match Self::get_initial_delta_token(&client, &entity_set_name, select.as_deref())
289 .await
290 {
291 Ok(token) => {
292 log::info!("[{source_id}] Initial delta token obtained for {entity_name}");
293 if let Some(ref store) = state_store {
295 Self::save_delta_token(store, &source_id, &entity_name, &token).await;
296 }
297 delta_link = Some(token);
298 break;
299 }
300 Err(e) => {
301 log::error!(
302 "[{source_id}] Failed to get initial delta token for {entity_name}: {e}. Retrying in {retry_interval_ms}ms"
303 );
304 tokio::select! {
305 _ = tokio::time::sleep(Duration::from_millis(retry_interval_ms)) => {}
306 _ = shutdown_rx.changed() => {
307 if *shutdown_rx.borrow() {
308 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
309 return;
310 }
311 }
312 }
313 retry_interval_ms = (retry_interval_ms * 2).min(MAX_RETRY_INTERVAL_MS);
314 }
315 }
316 }
317 }
318
319 loop {
321 if *shutdown_rx.borrow() {
323 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
324 break;
325 }
326
327 log::debug!(
328 "[{source_id}] Polling for changes in entity: {entity_name} (interval: {current_interval_ms}ms)"
329 );
330
331 match Self::poll_for_changes(
332 &source_id,
333 &entity_name,
334 &client,
335 delta_link.as_deref(),
336 &dispatchers,
337 )
338 .await
339 {
340 Ok((new_delta_link, change_count)) => {
341 let changes_detected = change_count > 0;
342 if changes_detected {
343 log::info!(
344 "[{source_id}] Got {change_count} changes for entity {entity_name}"
345 );
346 }
347 current_interval_ms = Self::next_backoff_interval(
348 current_interval_ms,
349 min_interval_ms,
350 max_interval_ms,
351 changes_detected,
352 );
353
354 if let Some(ref dl) = new_delta_link {
356 delta_link = Some(dl.clone());
357 if let Some(ref store) = state_store {
358 Self::save_delta_token(store, &source_id, &entity_name, dl).await;
359 }
360 }
361
362 if changes_detected {
363 continue;
364 }
365 }
366 Err(e) => {
367 log::error!("[{source_id}] Error polling entity {entity_name}: {e}");
368 current_interval_ms = 5000;
369 }
370 }
371
372 tokio::select! {
374 _ = tokio::time::sleep(Duration::from_millis(current_interval_ms)) => {}
375 _ = shutdown_rx.changed() => {
376 if *shutdown_rx.borrow() {
377 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
378 break;
379 }
380 }
381 }
382 }
383 }
384
385 async fn get_initial_delta_token(
390 client: &DataverseClient,
391 entity_set_name: &str,
392 select: Option<&str>,
393 ) -> Result<String> {
394 let mut response = client
395 .initial_change_tracking(entity_set_name, select)
396 .await?;
397
398 while response.next_link.is_some() {
401 let next = response
402 .next_link
403 .as_ref()
404 .expect("next_link checked above");
405 response = client.follow_next_link(next).await?;
406 }
407
408 response.delta_link.ok_or_else(|| {
409 anyhow::anyhow!("No delta link returned from initial change tracking request")
410 })
411 }
412
413 async fn poll_for_changes(
418 source_id: &str,
419 entity_name: &str,
420 client: &DataverseClient,
421 delta_link: Option<&str>,
422 dispatchers: &Arc<
423 tokio::sync::RwLock<
424 Vec<
425 Box<
426 dyn drasi_lib::channels::ChangeDispatcher<SourceEventWrapper> + Send + Sync,
427 >,
428 >,
429 >,
430 >,
431 ) -> Result<(Option<String>, usize)> {
432 let delta_link =
433 delta_link.ok_or_else(|| anyhow::anyhow!("No delta link available for polling"))?;
434
435 let mut response = client.follow_delta_link(delta_link).await?;
436 let mut all_changes = Vec::new();
437 let mut final_delta_link = response.delta_link.clone();
438
439 let changes = parse_delta_changes(&response, entity_name);
441 all_changes.extend(changes);
442
443 while response.next_link.is_some() {
445 let next = response
446 .next_link
447 .as_ref()
448 .expect("next_link checked above");
449 response = client.follow_next_link(next).await?;
450 let changes = parse_delta_changes(&response, entity_name);
451 all_changes.extend(changes);
452 if response.delta_link.is_some() {
453 final_delta_link = response.delta_link.clone();
454 }
455 }
456
457 let change_count = all_changes.len();
458
459 for change in &all_changes {
461 let source_change = Self::convert_to_source_change(source_id, change);
462 let timestamp = chrono::Utc::now();
463
464 let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
465 profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
466
467 let wrapper = SourceEventWrapper::with_profiling(
468 source_id.to_string(),
469 SourceEvent::Change(source_change),
470 timestamp,
471 profiling,
472 );
473
474 if let Err(e) =
475 SourceBase::dispatch_from_task(dispatchers.clone(), wrapper, source_id).await
476 {
477 log::error!("[{source_id}] Failed to dispatch change for {entity_name}: {e}");
478 }
479 }
480
481 Ok((final_delta_link, change_count))
482 }
483
484 fn convert_to_source_change(source_id: &str, change: &DataverseChange) -> SourceChange {
490 match change {
491 DataverseChange::NewOrUpdated {
492 id,
493 entity_name,
494 attributes,
495 } => {
496 let mut properties = ElementPropertyMap::new();
499 for (key, value) in attributes {
500 let element_value = Self::convert_json_value(value);
501 properties.insert(key, element_value);
502 }
503
504 let effective_from = attributes
507 .get("modifiedon")
508 .and_then(|v| v.as_str())
509 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
510 .map(|dt| dt.timestamp_millis().max(0) as u64)
511 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().max(0) as u64);
512
513 let element_id = format!("{entity_name}:{id}");
514 let metadata = ElementMetadata {
515 reference: ElementReference::new(source_id, &element_id),
516 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
517 effective_from,
518 };
519
520 SourceChange::Update {
521 element: Element::Node {
522 metadata,
523 properties,
524 },
525 }
526 }
527 DataverseChange::Deleted { id, entity_name } => {
528 let element_id = format!("{entity_name}:{id}");
529 let metadata = ElementMetadata {
530 reference: ElementReference::new(source_id, &element_id),
531 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
532 effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
534 };
535
536 SourceChange::Delete { metadata }
537 }
538 }
539 }
540
541 fn convert_json_value(value: &serde_json::Value) -> ElementValue {
547 match value {
548 serde_json::Value::Null => ElementValue::Null,
549 serde_json::Value::Bool(b) => ElementValue::Bool(*b),
550 serde_json::Value::Number(n) => {
551 if let Some(i) = n.as_i64() {
552 ElementValue::Integer(i)
553 } else if let Some(f) = n.as_f64() {
554 ElementValue::Float(ordered_float::OrderedFloat(f))
555 } else {
556 ElementValue::Null
557 }
558 }
559 serde_json::Value::String(s) => ElementValue::String(Arc::from(s.as_str())),
560 serde_json::Value::Array(arr) => {
561 if !arr.is_empty() {
564 if let Some(first_obj) = arr[0].as_object() {
565 if first_obj.contains_key("Value") {
566 let values: Vec<ElementValue> = arr
567 .iter()
568 .filter_map(|item| {
569 item.as_object()
570 .and_then(|obj| obj.get("Value"))
571 .map(Self::convert_json_value)
572 })
573 .collect();
574 return ElementValue::List(values);
575 }
576 }
577 }
578 ElementValue::List(arr.iter().map(Self::convert_json_value).collect())
579 }
580 serde_json::Value::Object(obj) => {
581 if obj.contains_key("Value") && obj.len() <= 2 {
584 if let Some(val) = obj.get("Value") {
585 return Self::convert_json_value(val);
586 }
587 }
588 let mut map = ElementPropertyMap::new();
590 for (k, v) in obj {
591 map.insert(k, Self::convert_json_value(v));
592 }
593 ElementValue::Object(map)
594 }
595 }
596 }
597}
598
599#[async_trait]
600impl Source for DataverseSource {
601 fn id(&self) -> &str {
602 &self.base.id
603 }
604
605 fn type_name(&self) -> &str {
606 "dataverse"
607 }
608
609 fn properties(&self) -> HashMap<String, serde_json::Value> {
610 let mut props = HashMap::new();
611 props.insert(
612 "environment_url".to_string(),
613 serde_json::Value::String(self.config.environment_url.clone()),
614 );
615 props.insert(
616 "tenant_id".to_string(),
617 serde_json::Value::String(self.config.tenant_id.clone()),
618 );
619 props.insert(
620 "client_id".to_string(),
621 serde_json::Value::String(self.config.client_id.clone()),
622 );
623 props.insert(
625 "entities".to_string(),
626 serde_json::Value::Array(
627 self.config
628 .entities
629 .iter()
630 .map(|e| serde_json::Value::String(e.clone()))
631 .collect(),
632 ),
633 );
634 props.insert(
635 "api_version".to_string(),
636 serde_json::Value::String(self.config.api_version.clone()),
637 );
638 props
639 }
640
641 fn auto_start(&self) -> bool {
642 self.base.get_auto_start()
643 }
644
645 async fn start(&self) -> Result<()> {
646 log::info!("[{}] Starting Dataverse source", self.base.id);
647
648 self.base
649 .set_status(
650 ComponentStatus::Starting,
651 Some("Starting Dataverse source".to_string()),
652 )
653 .await;
654
655 let base_url = self.config.environment_url.clone();
657
658 let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
670 self.base.identity_provider().await
671 {
672 injected
673 } else if let Some(ref ip) = self.identity_provider {
674 Arc::from(ip.clone_box())
675 } else {
676 let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
677 "dataverse",
678 &self.config.tenant_id,
679 &self.config.client_id,
680 &self.config.client_secret,
681 )?
682 .with_scope(Self::dataverse_scope(&base_url));
683 Arc::new(azure_provider)
684 };
685
686 let client = Arc::new(DataverseClient::new(
687 &base_url,
688 &self.config.api_version,
689 provider,
690 ));
691
692 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
694 let shutdown_tx = Arc::new(shutdown_tx);
695
696 let dispatchers = self.base.dispatchers.clone();
697 let state_store = self.base.state_store().await;
698 let source_id = self.base.id.clone();
699
700 let entity_count = self.config.entities.len() as f64;
705 let effective_max_interval_seconds =
706 (self.config.max_interval_seconds as f64 * entity_count.sqrt()).max(1.0) as u64;
707 log::info!(
708 "[{}] Effective max polling interval: {}s (base {}s * sqrt({} entities))",
709 self.base.id,
710 effective_max_interval_seconds,
711 self.config.max_interval_seconds,
712 self.config.entities.len()
713 );
714
715 let instance_id = self
717 .base
718 .context()
719 .await
720 .map(|c| c.instance_id)
721 .unwrap_or_default();
722
723 let mut task_handles = Vec::new();
725 for entity_name in &self.config.entities {
726 let entity_set_name = self.config.entity_set_name(entity_name);
727 let select = self.config.select_columns(entity_name);
728 let source_id = source_id.clone();
729 let entity_name = entity_name.clone();
730 let client = client.clone();
731 let dispatchers = dispatchers.clone();
732 let state_store = state_store.clone();
733 let shutdown_rx = shutdown_tx.subscribe();
734 let min_interval_ms = self.config.min_interval_ms;
735 let max_interval_seconds = effective_max_interval_seconds;
736 let instance_id = instance_id.clone();
737
738 let span = tracing::info_span!(
739 "dataverse_entity_worker",
740 instance_id = %instance_id,
741 component_id = %source_id,
742 component_type = "source",
743 entity = %entity_name
744 );
745
746 let handle = tokio::spawn(
747 async move {
748 Self::run_entity_worker(
749 source_id,
750 entity_name,
751 entity_set_name,
752 select,
753 client,
754 dispatchers,
755 state_store,
756 shutdown_rx,
757 min_interval_ms,
758 max_interval_seconds,
759 )
760 .await;
761 }
762 .instrument(span),
763 );
764 task_handles.push(handle);
765 }
766
767 let source_id = self.base.id.clone();
770 let combined_handle = tokio::spawn(async move {
771 for (i, handle) in task_handles.into_iter().enumerate() {
772 if let Err(e) = handle.await {
773 log::error!("[{source_id}] Entity worker {i} terminated with error: {e}");
774 }
775 }
776 log::info!("[{source_id}] All entity workers stopped");
777 });
778
779 *self.base.task_handle.write().await = Some(combined_handle);
780
781 {
783 let mut lock = self.base.shutdown_tx.write().await;
784 let shutdown_tx_for_stop = shutdown_tx.clone();
785 let (bridge_tx, bridge_rx) = tokio::sync::oneshot::channel::<()>();
786 tokio::spawn(async move {
787 let _ = bridge_rx.await;
788 let _ = shutdown_tx_for_stop.send(true);
789 });
790 *lock = Some(bridge_tx);
791 }
792
793 self.base
794 .set_status(
795 ComponentStatus::Running,
796 Some(format!(
797 "Dataverse source running, monitoring {} entities",
798 self.config.entities.len()
799 )),
800 )
801 .await;
802
803 Ok(())
804 }
805
806 async fn stop(&self) -> Result<()> {
807 log::info!("[{}] Stopping Dataverse source", self.base.id);
808
809 self.base
810 .set_status(
811 ComponentStatus::Stopping,
812 Some("Stopping Dataverse source".to_string()),
813 )
814 .await;
815
816 if let Some(tx) = self.base.shutdown_tx.write().await.take() {
818 let _ = tx.send(());
819 }
820
821 if let Some(handle) = self.base.task_handle.write().await.take() {
823 let mut handle = handle;
824 if tokio::time::timeout(Duration::from_secs(10), &mut handle)
825 .await
826 .is_err()
827 {
828 handle.abort();
829 let _ = handle.await;
830 }
831 }
832
833 self.base
834 .set_status(
835 ComponentStatus::Stopped,
836 Some("Dataverse source stopped".to_string()),
837 )
838 .await;
839
840 Ok(())
841 }
842
843 async fn status(&self) -> ComponentStatus {
844 self.base.get_status().await
845 }
846
847 async fn subscribe(
848 &self,
849 settings: drasi_lib::config::SourceSubscriptionSettings,
850 ) -> Result<drasi_lib::channels::SubscriptionResponse> {
851 self.base
852 .subscribe_with_bootstrap(&settings, "Dataverse")
853 .await
854 }
855
856 fn as_any(&self) -> &dyn std::any::Any {
857 self
858 }
859
860 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
861 self.base.initialize(context).await;
862 }
863
864 async fn set_bootstrap_provider(
865 &self,
866 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
867 ) {
868 self.base.set_bootstrap_provider(provider).await;
869 }
870}
871
872pub struct DataverseSourceBuilder {
890 id: String,
891 environment_url: String,
892 tenant_id: String,
893 client_id: String,
894 client_secret: String,
895 entities: Vec<String>,
896 entity_set_overrides: HashMap<String, String>,
897 entity_columns: HashMap<String, Vec<String>>,
898 min_interval_ms: u64,
899 max_interval_seconds: u64,
900 api_version: String,
901 dispatch_mode: Option<DispatchMode>,
902 dispatch_buffer_capacity: Option<usize>,
903 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
904 identity_provider: Option<Box<dyn IdentityProvider>>,
905 auto_start: bool,
906}
907
908impl DataverseSourceBuilder {
909 pub fn new(id: impl Into<String>) -> Self {
911 Self {
912 id: id.into(),
913 environment_url: String::new(),
914 tenant_id: String::new(),
915 client_id: String::new(),
916 client_secret: String::new(),
917 entities: Vec::new(),
918 entity_set_overrides: HashMap::new(),
919 entity_columns: HashMap::new(),
920 min_interval_ms: 500,
921 max_interval_seconds: 30,
922 api_version: "v9.2".to_string(),
923 dispatch_mode: None,
924 dispatch_buffer_capacity: None,
925 bootstrap_provider: None,
926 identity_provider: None,
927 auto_start: true,
928 }
929 }
930
931 pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
933 self.environment_url = url.into();
934 self
935 }
936
937 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
939 self.tenant_id = tenant_id.into();
940 self
941 }
942
943 pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
945 self.client_id = client_id.into();
946 self
947 }
948
949 pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
951 self.client_secret = client_secret.into();
952 self
953 }
954
955 pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
985 self.identity_provider = Some(Box::new(provider));
986 self
987 }
988
989 pub fn with_entities(mut self, entities: Vec<String>) -> Self {
991 self.entities = entities;
992 self
993 }
994
995 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
997 self.entities.push(entity.into());
998 self
999 }
1000
1001 pub fn with_entity_set_override(
1003 mut self,
1004 entity_name: impl Into<String>,
1005 entity_set_name: impl Into<String>,
1006 ) -> Self {
1007 self.entity_set_overrides
1008 .insert(entity_name.into(), entity_set_name.into());
1009 self
1010 }
1011
1012 pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1014 self.entity_columns.insert(entity.into(), columns);
1015 self
1016 }
1017
1018 pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1020 self.min_interval_ms = ms;
1021 self
1022 }
1023
1024 pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1026 self.max_interval_seconds = seconds;
1027 self
1028 }
1029
1030 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1032 self.api_version = version.into();
1033 self
1034 }
1035
1036 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1038 self.dispatch_mode = Some(mode);
1039 self
1040 }
1041
1042 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1044 self.dispatch_buffer_capacity = Some(capacity);
1045 self
1046 }
1047
1048 pub fn with_bootstrap_provider(
1050 mut self,
1051 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1052 ) -> Self {
1053 self.bootstrap_provider = Some(Box::new(provider));
1054 self
1055 }
1056
1057 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1059 self.auto_start = auto_start;
1060 self
1061 }
1062
1063 pub fn build(self) -> Result<DataverseSource> {
1065 let config = DataverseSourceConfig {
1066 environment_url: self.environment_url,
1067 tenant_id: self.tenant_id,
1068 client_id: self.client_id,
1069 client_secret: self.client_secret,
1070 entities: self.entities,
1071 entity_set_overrides: self.entity_set_overrides,
1072 entity_columns: self.entity_columns,
1073 min_interval_ms: self.min_interval_ms,
1074 max_interval_seconds: self.max_interval_seconds,
1075 api_version: self.api_version,
1076 };
1077
1078 let no_builtin_credentials = config.tenant_id.is_empty()
1085 && config.client_id.is_empty()
1086 && config.client_secret.is_empty();
1087 if self.identity_provider.is_some() || no_builtin_credentials {
1088 config
1089 .validate_with_identity_provider()
1090 .map_err(|e| anyhow::anyhow!(e))?;
1091 } else {
1092 config.validate().map_err(|e| anyhow::anyhow!(e))?;
1093 }
1094
1095 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1096 if let Some(mode) = self.dispatch_mode {
1097 params = params.with_dispatch_mode(mode);
1098 }
1099 if let Some(capacity) = self.dispatch_buffer_capacity {
1100 params = params.with_dispatch_buffer_capacity(capacity);
1101 }
1102 if let Some(provider) = self.bootstrap_provider {
1103 params = params.with_bootstrap_provider(provider);
1104 }
1105
1106 Ok(DataverseSource {
1107 base: SourceBase::new(params)?,
1108 config,
1109 identity_provider: self.identity_provider,
1110 })
1111 }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117
1118 mod construction {
1119 use super::*;
1120
1121 #[test]
1122 fn test_builder_creates_source() {
1123 let source = DataverseSource::builder("dv-source")
1124 .with_environment_url("https://myorg.crm.dynamics.com")
1125 .with_tenant_id("tenant-1")
1126 .with_client_id("client-1")
1127 .with_client_secret("secret-1")
1128 .with_entities(vec!["account".to_string()])
1129 .build();
1130 assert!(source.is_ok());
1131 }
1132
1133 #[test]
1134 fn test_builder_fails_without_entities() {
1135 let source = DataverseSource::builder("dv-source")
1136 .with_environment_url("https://myorg.crm.dynamics.com")
1137 .with_tenant_id("tenant-1")
1138 .with_client_id("client-1")
1139 .with_client_secret("secret-1")
1140 .build();
1141 assert!(source.is_err());
1142 }
1143
1144 #[test]
1145 fn test_builder_fails_without_url() {
1146 let source = DataverseSource::builder("dv-source")
1147 .with_tenant_id("tenant-1")
1148 .with_client_id("client-1")
1149 .with_client_secret("secret-1")
1150 .with_entities(vec!["account".to_string()])
1151 .build();
1152 assert!(source.is_err());
1153 }
1154
1155 #[test]
1156 fn test_new_with_valid_config() {
1157 let config = DataverseSourceConfig {
1158 environment_url: "https://test.crm.dynamics.com".to_string(),
1159 tenant_id: "t".to_string(),
1160 client_id: "c".to_string(),
1161 client_secret: "s".to_string(),
1162 entities: vec!["account".to_string()],
1163 entity_set_overrides: HashMap::new(),
1164 entity_columns: HashMap::new(),
1165 min_interval_ms: 500,
1166 max_interval_seconds: 30,
1167 api_version: "v9.2".to_string(),
1168 };
1169 let source = DataverseSource::new("test-source", config);
1170 assert!(source.is_ok());
1171 }
1172 }
1173
1174 mod properties {
1175 use super::*;
1176
1177 #[test]
1178 fn test_id_returns_correct_value() {
1179 let source = DataverseSource::builder("my-dv-source")
1180 .with_environment_url("https://test.crm.dynamics.com")
1181 .with_tenant_id("t")
1182 .with_client_id("c")
1183 .with_client_secret("s")
1184 .with_entities(vec!["account".to_string()])
1185 .build()
1186 .expect("should build");
1187 assert_eq!(source.id(), "my-dv-source");
1188 }
1189
1190 #[test]
1191 fn test_type_name_returns_dataverse() {
1192 let source = DataverseSource::builder("test")
1193 .with_environment_url("https://test.crm.dynamics.com")
1194 .with_tenant_id("t")
1195 .with_client_id("c")
1196 .with_client_secret("s")
1197 .with_entities(vec!["account".to_string()])
1198 .build()
1199 .expect("should build");
1200 assert_eq!(source.type_name(), "dataverse");
1201 }
1202
1203 #[test]
1204 fn test_properties_does_not_expose_secret() {
1205 let source = DataverseSource::builder("test")
1206 .with_environment_url("https://test.crm.dynamics.com")
1207 .with_tenant_id("t")
1208 .with_client_id("c")
1209 .with_client_secret("super-secret-value")
1210 .with_entities(vec!["account".to_string()])
1211 .build()
1212 .expect("should build");
1213 let props = source.properties();
1214
1215 assert!(props.contains_key("environment_url"));
1216 assert!(props.contains_key("tenant_id"));
1217 assert!(props.contains_key("client_id"));
1218 assert!(props.contains_key("entities"));
1219 assert!(!props.contains_key("client_secret"));
1220 }
1221
1222 #[test]
1223 fn test_properties_contains_correct_values() {
1224 let source = DataverseSource::builder("test")
1225 .with_environment_url("https://myorg.crm.dynamics.com")
1226 .with_tenant_id("tenant-123")
1227 .with_client_id("client-456")
1228 .with_client_secret("s")
1229 .with_entities(vec!["account".to_string(), "contact".to_string()])
1230 .build()
1231 .expect("should build");
1232 let props = source.properties();
1233
1234 assert_eq!(
1235 props.get("environment_url"),
1236 Some(&serde_json::Value::String(
1237 "https://myorg.crm.dynamics.com".to_string()
1238 ))
1239 );
1240 assert_eq!(
1241 props.get("tenant_id"),
1242 Some(&serde_json::Value::String("tenant-123".to_string()))
1243 );
1244 }
1245 }
1246
1247 mod lifecycle {
1248 use super::*;
1249
1250 #[tokio::test]
1251 async fn test_initial_status_is_stopped() {
1252 let source = DataverseSource::builder("test")
1253 .with_environment_url("https://test.crm.dynamics.com")
1254 .with_tenant_id("t")
1255 .with_client_id("c")
1256 .with_client_secret("s")
1257 .with_entities(vec!["account".to_string()])
1258 .build()
1259 .expect("should build");
1260 assert_eq!(source.status().await, ComponentStatus::Stopped);
1261 }
1262 }
1263
1264 mod builder {
1265 use super::*;
1266
1267 #[test]
1268 fn test_builder_defaults() {
1269 let source = DataverseSource::builder("test")
1270 .with_environment_url("https://test.crm.dynamics.com")
1271 .with_tenant_id("t")
1272 .with_client_id("c")
1273 .with_client_secret("s")
1274 .with_entities(vec!["account".to_string()])
1275 .build()
1276 .expect("should build");
1277
1278 assert_eq!(source.config.min_interval_ms, 500);
1279 assert_eq!(source.config.max_interval_seconds, 30);
1280 assert_eq!(source.config.api_version, "v9.2");
1281 assert!(source.identity_provider.is_none());
1282 }
1283
1284 #[test]
1285 fn test_builder_custom_values() {
1286 let source = DataverseSource::builder("test")
1287 .with_environment_url("https://custom.crm.dynamics.com")
1288 .with_tenant_id("custom-tenant")
1289 .with_client_id("custom-client")
1290 .with_client_secret("custom-secret")
1291 .with_entities(vec!["account".to_string()])
1292 .with_min_interval_ms(200)
1293 .with_max_interval_seconds(60)
1294 .with_api_version("v9.1")
1295 .build()
1296 .expect("should build");
1297
1298 assert_eq!(
1299 source.config.environment_url,
1300 "https://custom.crm.dynamics.com"
1301 );
1302 assert_eq!(source.config.min_interval_ms, 200);
1303 assert_eq!(source.config.max_interval_seconds, 60);
1304 assert_eq!(source.config.api_version, "v9.1");
1305 }
1306
1307 #[test]
1308 fn test_builder_with_entity() {
1309 let source = DataverseSource::builder("test")
1310 .with_environment_url("https://test.crm.dynamics.com")
1311 .with_tenant_id("t")
1312 .with_client_id("c")
1313 .with_client_secret("s")
1314 .with_entity("account")
1315 .with_entity("contact")
1316 .build()
1317 .expect("should build");
1318
1319 assert_eq!(source.config.entities, vec!["account", "contact"]);
1320 }
1321
1322 #[test]
1323 fn test_builder_with_entity_set_override() {
1324 let source = DataverseSource::builder("test")
1325 .with_environment_url("https://test.crm.dynamics.com")
1326 .with_tenant_id("t")
1327 .with_client_id("c")
1328 .with_client_secret("s")
1329 .with_entity("activityparty")
1330 .with_entity_set_override("activityparty", "activityparties")
1331 .build()
1332 .expect("should build");
1333
1334 assert_eq!(
1335 source.config.entity_set_name("activityparty"),
1336 "activityparties"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_builder_with_entity_columns() {
1342 let source = DataverseSource::builder("test")
1343 .with_environment_url("https://test.crm.dynamics.com")
1344 .with_tenant_id("t")
1345 .with_client_id("c")
1346 .with_client_secret("s")
1347 .with_entity("account")
1348 .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1349 .build()
1350 .expect("should build");
1351
1352 assert_eq!(
1353 source.config.select_columns("account"),
1354 Some("name,revenue,accountid".to_string())
1355 );
1356 }
1357
1358 #[test]
1359 fn test_builder_with_identity_provider() {
1360 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1362 let source = DataverseSource::builder("test")
1363 .with_environment_url("https://test.crm.dynamics.com")
1364 .with_entities(vec!["account".to_string()])
1365 .with_identity_provider(provider)
1366 .build()
1367 .expect("should build with identity provider and no client credentials");
1368
1369 assert!(source.identity_provider.is_some());
1370 }
1371
1372 #[test]
1373 fn test_builder_with_identity_provider_still_needs_url() {
1374 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1375 let result = DataverseSource::builder("test")
1376 .with_entities(vec!["account".to_string()])
1377 .with_identity_provider(provider)
1378 .build();
1379 assert!(result.is_err(), "should fail without environment_url");
1380 }
1381
1382 #[test]
1383 fn test_builder_with_identity_provider_still_needs_entities() {
1384 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1385 let result = DataverseSource::builder("test")
1386 .with_environment_url("https://test.crm.dynamics.com")
1387 .with_identity_provider(provider)
1388 .build();
1389 assert!(result.is_err(), "should fail without entities");
1390 }
1391 }
1392
1393 mod change_conversion {
1394 use super::*;
1395
1396 #[test]
1397 fn test_convert_new_or_updated() {
1398 let mut attributes = serde_json::Map::new();
1399 attributes.insert(
1400 "name".to_string(),
1401 serde_json::Value::String("Contoso".to_string()),
1402 );
1403 attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1404 attributes.insert(
1405 "accountid".to_string(),
1406 serde_json::Value::String("abc-123".to_string()),
1407 );
1408
1409 let change = DataverseChange::NewOrUpdated {
1410 id: "abc-123".to_string(),
1411 entity_name: "account".to_string(),
1412 attributes,
1413 };
1414
1415 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1416 match source_change {
1417 SourceChange::Update { element } => match element {
1418 Element::Node {
1419 metadata,
1420 properties,
1421 } => {
1422 assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1423 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1424 assert_eq!(metadata.labels.len(), 1);
1425 assert_eq!(metadata.labels[0].as_ref(), "account");
1426 assert!(properties.get("name").is_some());
1427 }
1428 _ => panic!("Expected Node element"),
1429 },
1430 _ => panic!("Expected Update change"),
1431 }
1432 }
1433
1434 #[test]
1435 fn test_convert_deleted() {
1436 let change = DataverseChange::Deleted {
1437 id: "def-456".to_string(),
1438 entity_name: "contact".to_string(),
1439 };
1440
1441 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1442 match source_change {
1443 SourceChange::Delete { metadata } => {
1444 assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1445 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1446 assert_eq!(metadata.labels[0].as_ref(), "contact");
1447 }
1448 _ => panic!("Expected Delete change"),
1449 }
1450 }
1451
1452 #[test]
1453 fn test_convert_json_value_primitives() {
1454 assert_eq!(
1455 DataverseSource::convert_json_value(&serde_json::Value::Null),
1456 ElementValue::Null
1457 );
1458 assert_eq!(
1459 DataverseSource::convert_json_value(&serde_json::json!(true)),
1460 ElementValue::Bool(true)
1461 );
1462 assert_eq!(
1463 DataverseSource::convert_json_value(&serde_json::json!(42)),
1464 ElementValue::Integer(42)
1465 );
1466 assert_eq!(
1467 DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1468 ElementValue::Float(ordered_float::OrderedFloat(3.15))
1469 );
1470 assert_eq!(
1471 DataverseSource::convert_json_value(&serde_json::json!("hello")),
1472 ElementValue::String(Arc::from("hello"))
1473 );
1474 }
1475
1476 #[test]
1477 fn test_convert_json_value_extracts_value() {
1478 let json = serde_json::json!({"Value": 123});
1480 assert_eq!(
1481 DataverseSource::convert_json_value(&json),
1482 ElementValue::Integer(123)
1483 );
1484 }
1485
1486 #[test]
1487 fn test_convert_json_value_multi_select_choice() {
1488 let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1490 let result = DataverseSource::convert_json_value(&json);
1491 match result {
1492 ElementValue::List(values) => {
1493 assert_eq!(values.len(), 2);
1494 assert_eq!(values[0], ElementValue::Integer(1));
1495 assert_eq!(values[1], ElementValue::Integer(2));
1496 }
1497 _ => panic!("Expected List"),
1498 }
1499 }
1500 }
1501
1502 mod backoff {
1503 use super::*;
1504
1505 #[test]
1506 fn resets_to_min_when_changes_detected() {
1507 let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1510 assert_eq!(next, 500);
1511 }
1512
1513 #[test]
1514 fn slow_backoff_under_threshold() {
1515 let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1517 assert_eq!(next, 1200);
1518
1519 let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1520 assert_eq!(next, 4800);
1521 }
1522
1523 #[test]
1524 fn fast_backoff_above_threshold() {
1525 let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1527 assert_eq!(next, 7500);
1528
1529 let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1530 assert_eq!(next, 15_000);
1531 }
1532
1533 #[test]
1534 fn does_not_exceed_max() {
1535 let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1537 assert_eq!(next, 30_000);
1538
1539 let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1541 assert_eq!(next, 30_000);
1542 }
1543
1544 #[test]
1545 fn full_progression_no_changes() {
1546 let min = 500;
1549 let max = 30_000;
1550 let mut current = min;
1551 let mut steps = 0;
1552 while current < max {
1553 let next = DataverseSource::next_backoff_interval(current, min, max, false);
1554 assert!(
1555 next > current || next == max,
1556 "interval should grow or hit the cap (was {current}, became {next})"
1557 );
1558 assert!(
1559 next <= max,
1560 "interval must never exceed max ({next} > {max})"
1561 );
1562 current = next;
1563 steps += 1;
1564 assert!(steps < 100, "backoff failed to converge");
1565 }
1566 assert_eq!(current, max);
1567 }
1568
1569 #[test]
1570 fn full_progression_with_change_resets() {
1571 let min = 500;
1573 let max = 30_000;
1574 let mut current = min;
1575 current = DataverseSource::next_backoff_interval(current, min, max, false);
1576 current = DataverseSource::next_backoff_interval(current, min, max, false);
1577 assert!(current > min);
1578 current = DataverseSource::next_backoff_interval(current, min, max, true);
1579 assert_eq!(current, min);
1580 }
1581 }
1582
1583 mod state_store_helpers {
1584 use super::*;
1585 use drasi_lib::MemoryStateStoreProvider;
1586
1587 fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1588 Arc::new(MemoryStateStoreProvider::new())
1589 }
1590
1591 #[test]
1592 fn delta_token_key_matches_platform_format() {
1593 assert_eq!(
1596 DataverseSource::delta_token_key("account"),
1597 "account-deltatoken"
1598 );
1599 }
1600
1601 #[tokio::test]
1602 async fn load_returns_none_on_empty_store() {
1603 let s = store();
1604 let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1605 assert!(
1606 result.is_none(),
1607 "no checkpoint should be present initially"
1608 );
1609 }
1610
1611 #[tokio::test]
1612 async fn save_then_load_round_trip() {
1613 let s = store();
1614 DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1615
1616 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1617 assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1618 }
1619
1620 #[tokio::test]
1621 async fn checkpoints_are_isolated_per_entity() {
1622 let s = store();
1623 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1624 DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1625
1626 assert_eq!(
1627 DataverseSource::load_delta_token(&s, "src-1", "account")
1628 .await
1629 .as_deref(),
1630 Some("token-A")
1631 );
1632 assert_eq!(
1633 DataverseSource::load_delta_token(&s, "src-1", "contact")
1634 .await
1635 .as_deref(),
1636 Some("token-B")
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn checkpoints_are_isolated_per_source() {
1642 let s = store();
1643 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1644 DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1645
1646 assert_eq!(
1647 DataverseSource::load_delta_token(&s, "src-1", "account")
1648 .await
1649 .as_deref(),
1650 Some("token-A")
1651 );
1652 assert_eq!(
1653 DataverseSource::load_delta_token(&s, "src-2", "account")
1654 .await
1655 .as_deref(),
1656 Some("token-B")
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn save_overwrites_previous_value() {
1662 let s = store();
1663 DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1664 DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1665
1666 assert_eq!(
1667 DataverseSource::load_delta_token(&s, "src-1", "account")
1668 .await
1669 .as_deref(),
1670 Some("token-new")
1671 );
1672 }
1673
1674 #[tokio::test]
1675 async fn load_skips_invalid_utf8() {
1676 let s = store();
1677 let key = DataverseSource::delta_token_key("account");
1678 s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1680 .await
1681 .expect("set should succeed");
1682
1683 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1684 assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1685 }
1686 }
1687}
1688
1689#[cfg(feature = "dynamic-plugin")]
1691drasi_plugin_sdk::export_plugin!(
1692 plugin_id = "dataverse-source",
1693 core_version = env!("CARGO_PKG_VERSION"),
1694 lib_version = env!("CARGO_PKG_VERSION"),
1695 plugin_version = env!("CARGO_PKG_VERSION"),
1696 source_descriptors = [descriptor::DataverseSourceDescriptor],
1697 reaction_descriptors = [],
1698 bootstrap_descriptors = [],
1699);