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