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(injected) =
669 self.base.identity_provider().await
670 {
671 injected
672 } else if let Some(ref ip) = self.identity_provider {
673 Arc::from(ip.clone_box())
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 entities: Vec<String>,
895 entity_set_overrides: HashMap<String, String>,
896 entity_columns: HashMap<String, Vec<String>>,
897 min_interval_ms: u64,
898 max_interval_seconds: u64,
899 api_version: String,
900 dispatch_mode: Option<DispatchMode>,
901 dispatch_buffer_capacity: Option<usize>,
902 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
903 identity_provider: Option<Box<dyn IdentityProvider>>,
904 auto_start: bool,
905}
906
907impl DataverseSourceBuilder {
908 pub fn new(id: impl Into<String>) -> Self {
910 Self {
911 id: id.into(),
912 environment_url: String::new(),
913 tenant_id: String::new(),
914 client_id: String::new(),
915 client_secret: String::new(),
916 entities: Vec::new(),
917 entity_set_overrides: HashMap::new(),
918 entity_columns: HashMap::new(),
919 min_interval_ms: 500,
920 max_interval_seconds: 30,
921 api_version: "v9.2".to_string(),
922 dispatch_mode: None,
923 dispatch_buffer_capacity: None,
924 bootstrap_provider: None,
925 identity_provider: None,
926 auto_start: true,
927 }
928 }
929
930 pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
932 self.environment_url = url.into();
933 self
934 }
935
936 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
938 self.tenant_id = tenant_id.into();
939 self
940 }
941
942 pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
944 self.client_id = client_id.into();
945 self
946 }
947
948 pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
950 self.client_secret = client_secret.into();
951 self
952 }
953
954 pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
984 self.identity_provider = Some(Box::new(provider));
985 self
986 }
987
988 pub fn with_entities(mut self, entities: Vec<String>) -> Self {
990 self.entities = entities;
991 self
992 }
993
994 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
996 self.entities.push(entity.into());
997 self
998 }
999
1000 pub fn with_entity_set_override(
1002 mut self,
1003 entity_name: impl Into<String>,
1004 entity_set_name: impl Into<String>,
1005 ) -> Self {
1006 self.entity_set_overrides
1007 .insert(entity_name.into(), entity_set_name.into());
1008 self
1009 }
1010
1011 pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1013 self.entity_columns.insert(entity.into(), columns);
1014 self
1015 }
1016
1017 pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1019 self.min_interval_ms = ms;
1020 self
1021 }
1022
1023 pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1025 self.max_interval_seconds = seconds;
1026 self
1027 }
1028
1029 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1031 self.api_version = version.into();
1032 self
1033 }
1034
1035 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1037 self.dispatch_mode = Some(mode);
1038 self
1039 }
1040
1041 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1043 self.dispatch_buffer_capacity = Some(capacity);
1044 self
1045 }
1046
1047 pub fn with_bootstrap_provider(
1049 mut self,
1050 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1051 ) -> Self {
1052 self.bootstrap_provider = Some(Box::new(provider));
1053 self
1054 }
1055
1056 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1058 self.auto_start = auto_start;
1059 self
1060 }
1061
1062 pub fn build(self) -> Result<DataverseSource> {
1064 let config = DataverseSourceConfig {
1065 environment_url: self.environment_url,
1066 tenant_id: self.tenant_id,
1067 client_id: self.client_id,
1068 client_secret: self.client_secret,
1069 entities: self.entities,
1070 entity_set_overrides: self.entity_set_overrides,
1071 entity_columns: self.entity_columns,
1072 min_interval_ms: self.min_interval_ms,
1073 max_interval_seconds: self.max_interval_seconds,
1074 api_version: self.api_version,
1075 };
1076
1077 let no_builtin_credentials = config.tenant_id.is_empty()
1084 && config.client_id.is_empty()
1085 && config.client_secret.is_empty();
1086 if self.identity_provider.is_some() || no_builtin_credentials {
1087 config
1088 .validate_with_identity_provider()
1089 .map_err(|e| anyhow::anyhow!(e))?;
1090 } else {
1091 config.validate().map_err(|e| anyhow::anyhow!(e))?;
1092 }
1093
1094 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1095 if let Some(mode) = self.dispatch_mode {
1096 params = params.with_dispatch_mode(mode);
1097 }
1098 if let Some(capacity) = self.dispatch_buffer_capacity {
1099 params = params.with_dispatch_buffer_capacity(capacity);
1100 }
1101 if let Some(provider) = self.bootstrap_provider {
1102 params = params.with_bootstrap_provider(provider);
1103 }
1104
1105 Ok(DataverseSource {
1106 base: SourceBase::new(params)?,
1107 config,
1108 identity_provider: self.identity_provider,
1109 })
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116
1117 mod construction {
1118 use super::*;
1119
1120 #[test]
1121 fn test_builder_creates_source() {
1122 let source = DataverseSource::builder("dv-source")
1123 .with_environment_url("https://myorg.crm.dynamics.com")
1124 .with_tenant_id("tenant-1")
1125 .with_client_id("client-1")
1126 .with_client_secret("secret-1")
1127 .with_entities(vec!["account".to_string()])
1128 .build();
1129 assert!(source.is_ok());
1130 }
1131
1132 #[test]
1133 fn test_builder_fails_without_entities() {
1134 let source = DataverseSource::builder("dv-source")
1135 .with_environment_url("https://myorg.crm.dynamics.com")
1136 .with_tenant_id("tenant-1")
1137 .with_client_id("client-1")
1138 .with_client_secret("secret-1")
1139 .build();
1140 assert!(source.is_err());
1141 }
1142
1143 #[test]
1144 fn test_builder_fails_without_url() {
1145 let source = DataverseSource::builder("dv-source")
1146 .with_tenant_id("tenant-1")
1147 .with_client_id("client-1")
1148 .with_client_secret("secret-1")
1149 .with_entities(vec!["account".to_string()])
1150 .build();
1151 assert!(source.is_err());
1152 }
1153
1154 #[test]
1155 fn test_new_with_valid_config() {
1156 let config = DataverseSourceConfig {
1157 environment_url: "https://test.crm.dynamics.com".to_string(),
1158 tenant_id: "t".to_string(),
1159 client_id: "c".to_string(),
1160 client_secret: "s".to_string(),
1161 entities: vec!["account".to_string()],
1162 entity_set_overrides: HashMap::new(),
1163 entity_columns: HashMap::new(),
1164 min_interval_ms: 500,
1165 max_interval_seconds: 30,
1166 api_version: "v9.2".to_string(),
1167 };
1168 let source = DataverseSource::new("test-source", config);
1169 assert!(source.is_ok());
1170 }
1171 }
1172
1173 mod properties {
1174 use super::*;
1175
1176 #[test]
1177 fn test_id_returns_correct_value() {
1178 let source = DataverseSource::builder("my-dv-source")
1179 .with_environment_url("https://test.crm.dynamics.com")
1180 .with_tenant_id("t")
1181 .with_client_id("c")
1182 .with_client_secret("s")
1183 .with_entities(vec!["account".to_string()])
1184 .build()
1185 .expect("should build");
1186 assert_eq!(source.id(), "my-dv-source");
1187 }
1188
1189 #[test]
1190 fn test_type_name_returns_dataverse() {
1191 let source = DataverseSource::builder("test")
1192 .with_environment_url("https://test.crm.dynamics.com")
1193 .with_tenant_id("t")
1194 .with_client_id("c")
1195 .with_client_secret("s")
1196 .with_entities(vec!["account".to_string()])
1197 .build()
1198 .expect("should build");
1199 assert_eq!(source.type_name(), "dataverse");
1200 }
1201
1202 #[test]
1203 fn test_properties_does_not_expose_secret() {
1204 let source = DataverseSource::builder("test")
1205 .with_environment_url("https://test.crm.dynamics.com")
1206 .with_tenant_id("t")
1207 .with_client_id("c")
1208 .with_client_secret("super-secret-value")
1209 .with_entities(vec!["account".to_string()])
1210 .build()
1211 .expect("should build");
1212 let props = source.properties();
1213
1214 assert!(props.contains_key("environment_url"));
1215 assert!(props.contains_key("tenant_id"));
1216 assert!(props.contains_key("client_id"));
1217 assert!(props.contains_key("entities"));
1218 assert!(!props.contains_key("client_secret"));
1219 }
1220
1221 #[test]
1222 fn test_properties_contains_correct_values() {
1223 let source = DataverseSource::builder("test")
1224 .with_environment_url("https://myorg.crm.dynamics.com")
1225 .with_tenant_id("tenant-123")
1226 .with_client_id("client-456")
1227 .with_client_secret("s")
1228 .with_entities(vec!["account".to_string(), "contact".to_string()])
1229 .build()
1230 .expect("should build");
1231 let props = source.properties();
1232
1233 assert_eq!(
1234 props.get("environment_url"),
1235 Some(&serde_json::Value::String(
1236 "https://myorg.crm.dynamics.com".to_string()
1237 ))
1238 );
1239 assert_eq!(
1240 props.get("tenant_id"),
1241 Some(&serde_json::Value::String("tenant-123".to_string()))
1242 );
1243 }
1244 }
1245
1246 mod lifecycle {
1247 use super::*;
1248
1249 #[tokio::test]
1250 async fn test_initial_status_is_stopped() {
1251 let source = DataverseSource::builder("test")
1252 .with_environment_url("https://test.crm.dynamics.com")
1253 .with_tenant_id("t")
1254 .with_client_id("c")
1255 .with_client_secret("s")
1256 .with_entities(vec!["account".to_string()])
1257 .build()
1258 .expect("should build");
1259 assert_eq!(source.status().await, ComponentStatus::Stopped);
1260 }
1261 }
1262
1263 mod builder {
1264 use super::*;
1265
1266 #[test]
1267 fn test_builder_defaults() {
1268 let source = DataverseSource::builder("test")
1269 .with_environment_url("https://test.crm.dynamics.com")
1270 .with_tenant_id("t")
1271 .with_client_id("c")
1272 .with_client_secret("s")
1273 .with_entities(vec!["account".to_string()])
1274 .build()
1275 .expect("should build");
1276
1277 assert_eq!(source.config.min_interval_ms, 500);
1278 assert_eq!(source.config.max_interval_seconds, 30);
1279 assert_eq!(source.config.api_version, "v9.2");
1280 assert!(source.identity_provider.is_none());
1281 }
1282
1283 #[test]
1284 fn test_builder_custom_values() {
1285 let source = DataverseSource::builder("test")
1286 .with_environment_url("https://custom.crm.dynamics.com")
1287 .with_tenant_id("custom-tenant")
1288 .with_client_id("custom-client")
1289 .with_client_secret("custom-secret")
1290 .with_entities(vec!["account".to_string()])
1291 .with_min_interval_ms(200)
1292 .with_max_interval_seconds(60)
1293 .with_api_version("v9.1")
1294 .build()
1295 .expect("should build");
1296
1297 assert_eq!(
1298 source.config.environment_url,
1299 "https://custom.crm.dynamics.com"
1300 );
1301 assert_eq!(source.config.min_interval_ms, 200);
1302 assert_eq!(source.config.max_interval_seconds, 60);
1303 assert_eq!(source.config.api_version, "v9.1");
1304 }
1305
1306 #[test]
1307 fn test_builder_with_entity() {
1308 let source = DataverseSource::builder("test")
1309 .with_environment_url("https://test.crm.dynamics.com")
1310 .with_tenant_id("t")
1311 .with_client_id("c")
1312 .with_client_secret("s")
1313 .with_entity("account")
1314 .with_entity("contact")
1315 .build()
1316 .expect("should build");
1317
1318 assert_eq!(source.config.entities, vec!["account", "contact"]);
1319 }
1320
1321 #[test]
1322 fn test_builder_with_entity_set_override() {
1323 let source = DataverseSource::builder("test")
1324 .with_environment_url("https://test.crm.dynamics.com")
1325 .with_tenant_id("t")
1326 .with_client_id("c")
1327 .with_client_secret("s")
1328 .with_entity("activityparty")
1329 .with_entity_set_override("activityparty", "activityparties")
1330 .build()
1331 .expect("should build");
1332
1333 assert_eq!(
1334 source.config.entity_set_name("activityparty"),
1335 "activityparties"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_builder_with_entity_columns() {
1341 let source = DataverseSource::builder("test")
1342 .with_environment_url("https://test.crm.dynamics.com")
1343 .with_tenant_id("t")
1344 .with_client_id("c")
1345 .with_client_secret("s")
1346 .with_entity("account")
1347 .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1348 .build()
1349 .expect("should build");
1350
1351 assert_eq!(
1352 source.config.select_columns("account"),
1353 Some("name,revenue,accountid".to_string())
1354 );
1355 }
1356
1357 #[test]
1358 fn test_builder_with_identity_provider() {
1359 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1361 let source = DataverseSource::builder("test")
1362 .with_environment_url("https://test.crm.dynamics.com")
1363 .with_entities(vec!["account".to_string()])
1364 .with_identity_provider(provider)
1365 .build()
1366 .expect("should build with identity provider and no client credentials");
1367
1368 assert!(source.identity_provider.is_some());
1369 }
1370
1371 #[test]
1372 fn test_builder_with_identity_provider_still_needs_url() {
1373 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1374 let result = DataverseSource::builder("test")
1375 .with_entities(vec!["account".to_string()])
1376 .with_identity_provider(provider)
1377 .build();
1378 assert!(result.is_err(), "should fail without environment_url");
1379 }
1380
1381 #[test]
1382 fn test_builder_with_identity_provider_still_needs_entities() {
1383 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1384 let result = DataverseSource::builder("test")
1385 .with_environment_url("https://test.crm.dynamics.com")
1386 .with_identity_provider(provider)
1387 .build();
1388 assert!(result.is_err(), "should fail without entities");
1389 }
1390 }
1391
1392 mod change_conversion {
1393 use super::*;
1394
1395 #[test]
1396 fn test_convert_new_or_updated() {
1397 let mut attributes = serde_json::Map::new();
1398 attributes.insert(
1399 "name".to_string(),
1400 serde_json::Value::String("Contoso".to_string()),
1401 );
1402 attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1403 attributes.insert(
1404 "accountid".to_string(),
1405 serde_json::Value::String("abc-123".to_string()),
1406 );
1407
1408 let change = DataverseChange::NewOrUpdated {
1409 id: "abc-123".to_string(),
1410 entity_name: "account".to_string(),
1411 attributes,
1412 };
1413
1414 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1415 match source_change {
1416 SourceChange::Update { element } => match element {
1417 Element::Node {
1418 metadata,
1419 properties,
1420 } => {
1421 assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1422 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1423 assert_eq!(metadata.labels.len(), 1);
1424 assert_eq!(metadata.labels[0].as_ref(), "account");
1425 assert!(properties.get("name").is_some());
1426 }
1427 _ => panic!("Expected Node element"),
1428 },
1429 _ => panic!("Expected Update change"),
1430 }
1431 }
1432
1433 #[test]
1434 fn test_convert_deleted() {
1435 let change = DataverseChange::Deleted {
1436 id: "def-456".to_string(),
1437 entity_name: "contact".to_string(),
1438 };
1439
1440 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1441 match source_change {
1442 SourceChange::Delete { metadata } => {
1443 assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1444 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1445 assert_eq!(metadata.labels[0].as_ref(), "contact");
1446 }
1447 _ => panic!("Expected Delete change"),
1448 }
1449 }
1450
1451 #[test]
1452 fn test_convert_json_value_primitives() {
1453 assert_eq!(
1454 DataverseSource::convert_json_value(&serde_json::Value::Null),
1455 ElementValue::Null
1456 );
1457 assert_eq!(
1458 DataverseSource::convert_json_value(&serde_json::json!(true)),
1459 ElementValue::Bool(true)
1460 );
1461 assert_eq!(
1462 DataverseSource::convert_json_value(&serde_json::json!(42)),
1463 ElementValue::Integer(42)
1464 );
1465 assert_eq!(
1466 DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1467 ElementValue::Float(ordered_float::OrderedFloat(3.15))
1468 );
1469 assert_eq!(
1470 DataverseSource::convert_json_value(&serde_json::json!("hello")),
1471 ElementValue::String(Arc::from("hello"))
1472 );
1473 }
1474
1475 #[test]
1476 fn test_convert_json_value_extracts_value() {
1477 let json = serde_json::json!({"Value": 123});
1479 assert_eq!(
1480 DataverseSource::convert_json_value(&json),
1481 ElementValue::Integer(123)
1482 );
1483 }
1484
1485 #[test]
1486 fn test_convert_json_value_multi_select_choice() {
1487 let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1489 let result = DataverseSource::convert_json_value(&json);
1490 match result {
1491 ElementValue::List(values) => {
1492 assert_eq!(values.len(), 2);
1493 assert_eq!(values[0], ElementValue::Integer(1));
1494 assert_eq!(values[1], ElementValue::Integer(2));
1495 }
1496 _ => panic!("Expected List"),
1497 }
1498 }
1499 }
1500
1501 mod backoff {
1502 use super::*;
1503
1504 #[test]
1505 fn resets_to_min_when_changes_detected() {
1506 let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1509 assert_eq!(next, 500);
1510 }
1511
1512 #[test]
1513 fn slow_backoff_under_threshold() {
1514 let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1516 assert_eq!(next, 1200);
1517
1518 let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1519 assert_eq!(next, 4800);
1520 }
1521
1522 #[test]
1523 fn fast_backoff_above_threshold() {
1524 let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1526 assert_eq!(next, 7500);
1527
1528 let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1529 assert_eq!(next, 15_000);
1530 }
1531
1532 #[test]
1533 fn does_not_exceed_max() {
1534 let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1536 assert_eq!(next, 30_000);
1537
1538 let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1540 assert_eq!(next, 30_000);
1541 }
1542
1543 #[test]
1544 fn full_progression_no_changes() {
1545 let min = 500;
1548 let max = 30_000;
1549 let mut current = min;
1550 let mut steps = 0;
1551 while current < max {
1552 let next = DataverseSource::next_backoff_interval(current, min, max, false);
1553 assert!(
1554 next > current || next == max,
1555 "interval should grow or hit the cap (was {current}, became {next})"
1556 );
1557 assert!(
1558 next <= max,
1559 "interval must never exceed max ({next} > {max})"
1560 );
1561 current = next;
1562 steps += 1;
1563 assert!(steps < 100, "backoff failed to converge");
1564 }
1565 assert_eq!(current, max);
1566 }
1567
1568 #[test]
1569 fn full_progression_with_change_resets() {
1570 let min = 500;
1572 let max = 30_000;
1573 let mut current = min;
1574 current = DataverseSource::next_backoff_interval(current, min, max, false);
1575 current = DataverseSource::next_backoff_interval(current, min, max, false);
1576 assert!(current > min);
1577 current = DataverseSource::next_backoff_interval(current, min, max, true);
1578 assert_eq!(current, min);
1579 }
1580 }
1581
1582 mod state_store_helpers {
1583 use super::*;
1584 use drasi_lib::MemoryStateStoreProvider;
1585
1586 fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1587 Arc::new(MemoryStateStoreProvider::new())
1588 }
1589
1590 #[test]
1591 fn delta_token_key_matches_platform_format() {
1592 assert_eq!(
1595 DataverseSource::delta_token_key("account"),
1596 "account-deltatoken"
1597 );
1598 }
1599
1600 #[tokio::test]
1601 async fn load_returns_none_on_empty_store() {
1602 let s = store();
1603 let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1604 assert!(
1605 result.is_none(),
1606 "no checkpoint should be present initially"
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn save_then_load_round_trip() {
1612 let s = store();
1613 DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1614
1615 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1616 assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1617 }
1618
1619 #[tokio::test]
1620 async fn checkpoints_are_isolated_per_entity() {
1621 let s = store();
1622 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1623 DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1624
1625 assert_eq!(
1626 DataverseSource::load_delta_token(&s, "src-1", "account")
1627 .await
1628 .as_deref(),
1629 Some("token-A")
1630 );
1631 assert_eq!(
1632 DataverseSource::load_delta_token(&s, "src-1", "contact")
1633 .await
1634 .as_deref(),
1635 Some("token-B")
1636 );
1637 }
1638
1639 #[tokio::test]
1640 async fn checkpoints_are_isolated_per_source() {
1641 let s = store();
1642 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1643 DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1644
1645 assert_eq!(
1646 DataverseSource::load_delta_token(&s, "src-1", "account")
1647 .await
1648 .as_deref(),
1649 Some("token-A")
1650 );
1651 assert_eq!(
1652 DataverseSource::load_delta_token(&s, "src-2", "account")
1653 .await
1654 .as_deref(),
1655 Some("token-B")
1656 );
1657 }
1658
1659 #[tokio::test]
1660 async fn save_overwrites_previous_value() {
1661 let s = store();
1662 DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1663 DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1664
1665 assert_eq!(
1666 DataverseSource::load_delta_token(&s, "src-1", "account")
1667 .await
1668 .as_deref(),
1669 Some("token-new")
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn load_skips_invalid_utf8() {
1675 let s = store();
1676 let key = DataverseSource::delta_token_key("account");
1677 s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1679 .await
1680 .expect("set should succeed");
1681
1682 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1683 assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1684 }
1685 }
1686}
1687
1688#[cfg(feature = "dynamic-plugin")]
1690drasi_plugin_sdk::export_plugin!(
1691 plugin_id = "dataverse-source",
1692 core_version = env!("CARGO_PKG_VERSION"),
1693 lib_version = env!("CARGO_PKG_VERSION"),
1694 plugin_version = env!("CARGO_PKG_VERSION"),
1695 source_descriptors = [descriptor::DataverseSourceDescriptor],
1696 reaction_descriptors = [],
1697 bootstrap_descriptors = [],
1698);