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};
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 base: SourceBase,
247 state_store: Option<Arc<dyn drasi_lib::StateStoreProvider>>,
248 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
249 min_interval_ms: u64,
250 max_interval_seconds: u64,
251 ) {
252 let mut current_interval_ms = min_interval_ms;
253 let max_interval_ms = max_interval_seconds * 1000;
254
255 let mut delta_link: Option<String> = None;
257
258 if let Some(ref store) = state_store {
259 delta_link = Self::load_delta_token(store, &source_id, &entity_name).await;
260 if delta_link.is_some() {
261 log::info!("[{source_id}] Resuming from checkpoint for {entity_name}");
262 } else {
263 log::info!("[{source_id}] No checkpoint found for {entity_name}, getting current delta token");
264 }
265 }
266
267 if delta_link.is_none() {
270 let mut retry_interval_ms: u64 = 1000;
271 const MAX_RETRY_INTERVAL_MS: u64 = 60_000;
272
273 loop {
274 if *shutdown_rx.borrow() {
276 log::info!("[{source_id}] Shutting down entity worker for {entity_name} during initial token acquisition");
277 return;
278 }
279
280 match Self::get_initial_delta_token(&client, &entity_set_name, select.as_deref())
281 .await
282 {
283 Ok(token) => {
284 log::info!("[{source_id}] Initial delta token obtained for {entity_name}");
285 if let Some(ref store) = state_store {
287 Self::save_delta_token(store, &source_id, &entity_name, &token).await;
288 }
289 delta_link = Some(token);
290 break;
291 }
292 Err(e) => {
293 log::error!(
294 "[{source_id}] Failed to get initial delta token for {entity_name}: {e}. Retrying in {retry_interval_ms}ms"
295 );
296 tokio::select! {
297 _ = tokio::time::sleep(Duration::from_millis(retry_interval_ms)) => {}
298 _ = shutdown_rx.changed() => {
299 if *shutdown_rx.borrow() {
300 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
301 return;
302 }
303 }
304 }
305 retry_interval_ms = (retry_interval_ms * 2).min(MAX_RETRY_INTERVAL_MS);
306 }
307 }
308 }
309 }
310
311 loop {
313 if *shutdown_rx.borrow() {
315 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
316 break;
317 }
318
319 log::debug!(
320 "[{source_id}] Polling for changes in entity: {entity_name} (interval: {current_interval_ms}ms)"
321 );
322
323 match Self::poll_for_changes(
324 &source_id,
325 &entity_name,
326 &client,
327 delta_link.as_deref(),
328 &base,
329 )
330 .await
331 {
332 Ok((new_delta_link, change_count)) => {
333 let changes_detected = change_count > 0;
334 if changes_detected {
335 log::info!(
336 "[{source_id}] Got {change_count} changes for entity {entity_name}"
337 );
338 }
339 current_interval_ms = Self::next_backoff_interval(
340 current_interval_ms,
341 min_interval_ms,
342 max_interval_ms,
343 changes_detected,
344 );
345
346 if let Some(ref dl) = new_delta_link {
348 delta_link = Some(dl.clone());
349 if let Some(ref store) = state_store {
350 Self::save_delta_token(store, &source_id, &entity_name, dl).await;
351 }
352 }
353
354 if changes_detected {
355 continue;
356 }
357 }
358 Err(e) => {
359 log::error!("[{source_id}] Error polling entity {entity_name}: {e}");
360 current_interval_ms = 5000;
361 }
362 }
363
364 tokio::select! {
366 _ = tokio::time::sleep(Duration::from_millis(current_interval_ms)) => {}
367 _ = shutdown_rx.changed() => {
368 if *shutdown_rx.borrow() {
369 log::info!("[{source_id}] Shutting down entity worker for {entity_name}");
370 break;
371 }
372 }
373 }
374 }
375 }
376
377 async fn get_initial_delta_token(
382 client: &DataverseClient,
383 entity_set_name: &str,
384 select: Option<&str>,
385 ) -> Result<String> {
386 let mut response = client
387 .initial_change_tracking(entity_set_name, select)
388 .await?;
389
390 while response.next_link.is_some() {
393 let next = response
394 .next_link
395 .as_ref()
396 .expect("next_link checked above");
397 response = client.follow_next_link(next).await?;
398 }
399
400 response.delta_link.ok_or_else(|| {
401 anyhow::anyhow!("No delta link returned from initial change tracking request")
402 })
403 }
404
405 async fn poll_for_changes(
410 source_id: &str,
411 entity_name: &str,
412 client: &DataverseClient,
413 delta_link: Option<&str>,
414 base: &SourceBase,
415 ) -> Result<(Option<String>, usize)> {
416 let delta_link =
417 delta_link.ok_or_else(|| anyhow::anyhow!("No delta link available for polling"))?;
418
419 let mut response = client.follow_delta_link(delta_link).await?;
420 let mut all_changes = Vec::new();
421 let mut final_delta_link = response.delta_link.clone();
422
423 let changes = parse_delta_changes(&response, entity_name);
425 all_changes.extend(changes);
426
427 while response.next_link.is_some() {
429 let next = response
430 .next_link
431 .as_ref()
432 .expect("next_link checked above");
433 response = client.follow_next_link(next).await?;
434 let changes = parse_delta_changes(&response, entity_name);
435 all_changes.extend(changes);
436 if response.delta_link.is_some() {
437 final_delta_link = response.delta_link.clone();
438 }
439 }
440
441 let change_count = all_changes.len();
442
443 Self::dispatch_changes(source_id, entity_name, base, &all_changes).await;
445
446 Ok((final_delta_link, change_count))
447 }
448
449 async fn dispatch_changes(
453 source_id: &str,
454 entity_name: &str,
455 base: &SourceBase,
456 changes: &[DataverseChange],
457 ) {
458 for change in changes {
459 let source_change = Self::convert_to_source_change(source_id, change);
460
461 if let Err(e) = base.dispatch_source_change(source_change).await {
462 log::error!("[{source_id}] Failed to dispatch change for {entity_name}: {e}");
463 }
464 }
465 }
466
467 fn convert_to_source_change(source_id: &str, change: &DataverseChange) -> SourceChange {
473 match change {
474 DataverseChange::NewOrUpdated {
475 id,
476 entity_name,
477 attributes,
478 } => {
479 let mut properties = ElementPropertyMap::new();
482 for (key, value) in attributes {
483 let element_value = Self::convert_json_value(value);
484 properties.insert(key, element_value);
485 }
486
487 let effective_from = attributes
490 .get("modifiedon")
491 .and_then(|v| v.as_str())
492 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
493 .map(|dt| dt.timestamp_millis().max(0) as u64)
494 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().max(0) as u64);
495
496 let element_id = format!("{entity_name}:{id}");
497 let metadata = ElementMetadata {
498 reference: ElementReference::new(source_id, &element_id),
499 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
500 effective_from,
501 };
502
503 SourceChange::Update {
504 element: Element::Node {
505 metadata,
506 properties,
507 },
508 }
509 }
510 DataverseChange::Deleted { id, entity_name } => {
511 let element_id = format!("{entity_name}:{id}");
512 let metadata = ElementMetadata {
513 reference: ElementReference::new(source_id, &element_id),
514 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
515 effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
517 };
518
519 SourceChange::Delete { metadata }
520 }
521 }
522 }
523
524 fn convert_json_value(value: &serde_json::Value) -> ElementValue {
530 match value {
531 serde_json::Value::Null => ElementValue::Null,
532 serde_json::Value::Bool(b) => ElementValue::Bool(*b),
533 serde_json::Value::Number(n) => {
534 if let Some(i) = n.as_i64() {
535 ElementValue::Integer(i)
536 } else if let Some(f) = n.as_f64() {
537 ElementValue::Float(ordered_float::OrderedFloat(f))
538 } else {
539 ElementValue::Null
540 }
541 }
542 serde_json::Value::String(s) => ElementValue::String(Arc::from(s.as_str())),
543 serde_json::Value::Array(arr) => {
544 if !arr.is_empty() {
547 if let Some(first_obj) = arr[0].as_object() {
548 if first_obj.contains_key("Value") {
549 let values: Vec<ElementValue> = arr
550 .iter()
551 .filter_map(|item| {
552 item.as_object()
553 .and_then(|obj| obj.get("Value"))
554 .map(Self::convert_json_value)
555 })
556 .collect();
557 return ElementValue::List(values);
558 }
559 }
560 }
561 ElementValue::List(arr.iter().map(Self::convert_json_value).collect())
562 }
563 serde_json::Value::Object(obj) => {
564 if obj.contains_key("Value") && obj.len() <= 2 {
567 if let Some(val) = obj.get("Value") {
568 return Self::convert_json_value(val);
569 }
570 }
571 let mut map = ElementPropertyMap::new();
573 for (k, v) in obj {
574 map.insert(k, Self::convert_json_value(v));
575 }
576 ElementValue::Object(map)
577 }
578 }
579 }
580}
581
582#[async_trait]
583impl Source for DataverseSource {
584 fn id(&self) -> &str {
585 &self.base.id
586 }
587
588 fn type_name(&self) -> &str {
589 "dataverse"
590 }
591
592 fn properties(&self) -> HashMap<String, serde_json::Value> {
593 let mut props = HashMap::new();
594 props.insert(
595 "environment_url".to_string(),
596 serde_json::Value::String(self.config.environment_url.clone()),
597 );
598 props.insert(
599 "tenant_id".to_string(),
600 serde_json::Value::String(self.config.tenant_id.clone()),
601 );
602 props.insert(
603 "client_id".to_string(),
604 serde_json::Value::String(self.config.client_id.clone()),
605 );
606 props.insert(
608 "entities".to_string(),
609 serde_json::Value::Array(
610 self.config
611 .entities
612 .iter()
613 .map(|e| serde_json::Value::String(e.clone()))
614 .collect(),
615 ),
616 );
617 props.insert(
618 "api_version".to_string(),
619 serde_json::Value::String(self.config.api_version.clone()),
620 );
621 props
622 }
623
624 fn auto_start(&self) -> bool {
625 self.base.get_auto_start()
626 }
627
628 async fn start(&self) -> Result<()> {
629 log::info!("[{}] Starting Dataverse source", self.base.id);
630
631 self.base
632 .set_status(
633 ComponentStatus::Starting,
634 Some("Starting Dataverse source".to_string()),
635 )
636 .await;
637
638 let base_url = self.config.environment_url.clone();
640
641 let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
653 self.base.identity_provider().await
654 {
655 injected
656 } else if let Some(ref ip) = self.identity_provider {
657 Arc::from(ip.clone_box())
658 } else {
659 let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
660 "dataverse",
661 &self.config.tenant_id,
662 &self.config.client_id,
663 &self.config.client_secret,
664 )?
665 .with_scope(Self::dataverse_scope(&base_url));
666 Arc::new(azure_provider)
667 };
668
669 let client = Arc::new(DataverseClient::new(
670 &base_url,
671 &self.config.api_version,
672 provider,
673 ));
674
675 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
677 let shutdown_tx = Arc::new(shutdown_tx);
678
679 let base = self.base.clone_shared();
680 let state_store = self.base.state_store().await;
681 let source_id = self.base.id.clone();
682
683 let entity_count = self.config.entities.len() as f64;
688 let effective_max_interval_seconds =
689 (self.config.max_interval_seconds as f64 * entity_count.sqrt()).max(1.0) as u64;
690 log::info!(
691 "[{}] Effective max polling interval: {}s (base {}s * sqrt({} entities))",
692 self.base.id,
693 effective_max_interval_seconds,
694 self.config.max_interval_seconds,
695 self.config.entities.len()
696 );
697
698 let instance_id = self
700 .base
701 .context()
702 .await
703 .map(|c| c.instance_id)
704 .unwrap_or_default();
705
706 let mut task_handles = Vec::new();
708 for entity_name in &self.config.entities {
709 let entity_set_name = self.config.entity_set_name(entity_name);
710 let select = self.config.select_columns(entity_name);
711 let source_id = source_id.clone();
712 let entity_name = entity_name.clone();
713 let client = client.clone();
714 let base = base.clone_shared();
715 let state_store = state_store.clone();
716 let shutdown_rx = shutdown_tx.subscribe();
717 let min_interval_ms = self.config.min_interval_ms;
718 let max_interval_seconds = effective_max_interval_seconds;
719 let instance_id = instance_id.clone();
720
721 let span = tracing::info_span!(
722 "dataverse_entity_worker",
723 instance_id = %instance_id,
724 component_id = %source_id,
725 component_type = "source",
726 entity = %entity_name
727 );
728
729 let handle = tokio::spawn(
730 async move {
731 Self::run_entity_worker(
732 source_id,
733 entity_name,
734 entity_set_name,
735 select,
736 client,
737 base,
738 state_store,
739 shutdown_rx,
740 min_interval_ms,
741 max_interval_seconds,
742 )
743 .await;
744 }
745 .instrument(span),
746 );
747 task_handles.push(handle);
748 }
749
750 let source_id = self.base.id.clone();
753 let combined_handle = tokio::spawn(async move {
754 for (i, handle) in task_handles.into_iter().enumerate() {
755 if let Err(e) = handle.await {
756 log::error!("[{source_id}] Entity worker {i} terminated with error: {e}");
757 }
758 }
759 log::info!("[{source_id}] All entity workers stopped");
760 });
761
762 *self.base.task_handle.write().await = Some(combined_handle);
763
764 {
766 let mut lock = self.base.shutdown_tx.write().await;
767 let shutdown_tx_for_stop = shutdown_tx.clone();
768 let (bridge_tx, bridge_rx) = tokio::sync::oneshot::channel::<()>();
769 tokio::spawn(async move {
770 let _ = bridge_rx.await;
771 let _ = shutdown_tx_for_stop.send(true);
772 });
773 *lock = Some(bridge_tx);
774 }
775
776 self.base
777 .set_status(
778 ComponentStatus::Running,
779 Some(format!(
780 "Dataverse source running, monitoring {} entities",
781 self.config.entities.len()
782 )),
783 )
784 .await;
785
786 Ok(())
787 }
788
789 async fn stop(&self) -> Result<()> {
790 log::info!("[{}] Stopping Dataverse source", self.base.id);
791
792 self.base
793 .set_status(
794 ComponentStatus::Stopping,
795 Some("Stopping Dataverse source".to_string()),
796 )
797 .await;
798
799 if let Some(tx) = self.base.shutdown_tx.write().await.take() {
801 let _ = tx.send(());
802 }
803
804 if let Some(handle) = self.base.task_handle.write().await.take() {
806 let mut handle = handle;
807 if tokio::time::timeout(Duration::from_secs(10), &mut handle)
808 .await
809 .is_err()
810 {
811 handle.abort();
812 let _ = handle.await;
813 }
814 }
815
816 self.base
817 .set_status(
818 ComponentStatus::Stopped,
819 Some("Dataverse source stopped".to_string()),
820 )
821 .await;
822
823 Ok(())
824 }
825
826 async fn status(&self) -> ComponentStatus {
827 self.base.get_status().await
828 }
829
830 async fn subscribe(
831 &self,
832 settings: drasi_lib::config::SourceSubscriptionSettings,
833 ) -> Result<drasi_lib::channels::SubscriptionResponse> {
834 self.base
835 .subscribe_with_bootstrap(&settings, "Dataverse")
836 .await
837 }
838
839 fn as_any(&self) -> &dyn std::any::Any {
840 self
841 }
842
843 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
844 self.base.initialize(context).await;
845 }
846
847 async fn set_bootstrap_provider(
848 &self,
849 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
850 ) {
851 self.base.set_bootstrap_provider(provider).await;
852 }
853}
854
855pub struct DataverseSourceBuilder {
873 id: String,
874 environment_url: String,
875 tenant_id: String,
876 client_id: String,
877 client_secret: String,
878 entities: Vec<String>,
879 entity_set_overrides: HashMap<String, String>,
880 entity_columns: HashMap<String, Vec<String>>,
881 min_interval_ms: u64,
882 max_interval_seconds: u64,
883 api_version: String,
884 dispatch_mode: Option<DispatchMode>,
885 dispatch_buffer_capacity: Option<usize>,
886 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
887 identity_provider: Option<Box<dyn IdentityProvider>>,
888 auto_start: bool,
889}
890
891impl DataverseSourceBuilder {
892 pub fn new(id: impl Into<String>) -> Self {
894 Self {
895 id: id.into(),
896 environment_url: String::new(),
897 tenant_id: String::new(),
898 client_id: String::new(),
899 client_secret: String::new(),
900 entities: Vec::new(),
901 entity_set_overrides: HashMap::new(),
902 entity_columns: HashMap::new(),
903 min_interval_ms: 500,
904 max_interval_seconds: 30,
905 api_version: "v9.2".to_string(),
906 dispatch_mode: None,
907 dispatch_buffer_capacity: None,
908 bootstrap_provider: None,
909 identity_provider: None,
910 auto_start: true,
911 }
912 }
913
914 pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
916 self.environment_url = url.into();
917 self
918 }
919
920 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
922 self.tenant_id = tenant_id.into();
923 self
924 }
925
926 pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
928 self.client_id = client_id.into();
929 self
930 }
931
932 pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
934 self.client_secret = client_secret.into();
935 self
936 }
937
938 pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
968 self.identity_provider = Some(Box::new(provider));
969 self
970 }
971
972 pub fn with_entities(mut self, entities: Vec<String>) -> Self {
974 self.entities = entities;
975 self
976 }
977
978 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
980 self.entities.push(entity.into());
981 self
982 }
983
984 pub fn with_entity_set_override(
986 mut self,
987 entity_name: impl Into<String>,
988 entity_set_name: impl Into<String>,
989 ) -> Self {
990 self.entity_set_overrides
991 .insert(entity_name.into(), entity_set_name.into());
992 self
993 }
994
995 pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
997 self.entity_columns.insert(entity.into(), columns);
998 self
999 }
1000
1001 pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1003 self.min_interval_ms = ms;
1004 self
1005 }
1006
1007 pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1009 self.max_interval_seconds = seconds;
1010 self
1011 }
1012
1013 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1015 self.api_version = version.into();
1016 self
1017 }
1018
1019 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1021 self.dispatch_mode = Some(mode);
1022 self
1023 }
1024
1025 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1027 self.dispatch_buffer_capacity = Some(capacity);
1028 self
1029 }
1030
1031 pub fn with_bootstrap_provider(
1033 mut self,
1034 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1035 ) -> Self {
1036 self.bootstrap_provider = Some(Box::new(provider));
1037 self
1038 }
1039
1040 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1042 self.auto_start = auto_start;
1043 self
1044 }
1045
1046 pub fn build(self) -> Result<DataverseSource> {
1048 let config = DataverseSourceConfig {
1049 environment_url: self.environment_url,
1050 tenant_id: self.tenant_id,
1051 client_id: self.client_id,
1052 client_secret: self.client_secret,
1053 entities: self.entities,
1054 entity_set_overrides: self.entity_set_overrides,
1055 entity_columns: self.entity_columns,
1056 min_interval_ms: self.min_interval_ms,
1057 max_interval_seconds: self.max_interval_seconds,
1058 api_version: self.api_version,
1059 };
1060
1061 let no_builtin_credentials = config.tenant_id.is_empty()
1068 && config.client_id.is_empty()
1069 && config.client_secret.is_empty();
1070 if self.identity_provider.is_some() || no_builtin_credentials {
1071 config
1072 .validate_with_identity_provider()
1073 .map_err(|e| anyhow::anyhow!(e))?;
1074 } else {
1075 config.validate().map_err(|e| anyhow::anyhow!(e))?;
1076 }
1077
1078 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1079 if let Some(mode) = self.dispatch_mode {
1080 params = params.with_dispatch_mode(mode);
1081 }
1082 if let Some(capacity) = self.dispatch_buffer_capacity {
1083 params = params.with_dispatch_buffer_capacity(capacity);
1084 }
1085 if let Some(provider) = self.bootstrap_provider {
1086 params = params.with_bootstrap_provider(provider);
1087 }
1088
1089 Ok(DataverseSource {
1090 base: SourceBase::new(params)?,
1091 config,
1092 identity_provider: self.identity_provider,
1093 })
1094 }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 mod construction {
1102 use super::*;
1103
1104 #[test]
1105 fn test_builder_creates_source() {
1106 let source = DataverseSource::builder("dv-source")
1107 .with_environment_url("https://myorg.crm.dynamics.com")
1108 .with_tenant_id("tenant-1")
1109 .with_client_id("client-1")
1110 .with_client_secret("secret-1")
1111 .with_entities(vec!["account".to_string()])
1112 .build();
1113 assert!(source.is_ok());
1114 }
1115
1116 #[test]
1117 fn test_builder_fails_without_entities() {
1118 let source = DataverseSource::builder("dv-source")
1119 .with_environment_url("https://myorg.crm.dynamics.com")
1120 .with_tenant_id("tenant-1")
1121 .with_client_id("client-1")
1122 .with_client_secret("secret-1")
1123 .build();
1124 assert!(source.is_err());
1125 }
1126
1127 #[test]
1128 fn test_builder_fails_without_url() {
1129 let source = DataverseSource::builder("dv-source")
1130 .with_tenant_id("tenant-1")
1131 .with_client_id("client-1")
1132 .with_client_secret("secret-1")
1133 .with_entities(vec!["account".to_string()])
1134 .build();
1135 assert!(source.is_err());
1136 }
1137
1138 #[test]
1139 fn test_new_with_valid_config() {
1140 let config = DataverseSourceConfig {
1141 environment_url: "https://test.crm.dynamics.com".to_string(),
1142 tenant_id: "t".to_string(),
1143 client_id: "c".to_string(),
1144 client_secret: "s".to_string(),
1145 entities: vec!["account".to_string()],
1146 entity_set_overrides: HashMap::new(),
1147 entity_columns: HashMap::new(),
1148 min_interval_ms: 500,
1149 max_interval_seconds: 30,
1150 api_version: "v9.2".to_string(),
1151 };
1152 let source = DataverseSource::new("test-source", config);
1153 assert!(source.is_ok());
1154 }
1155 }
1156
1157 mod properties {
1158 use super::*;
1159
1160 #[test]
1161 fn test_id_returns_correct_value() {
1162 let source = DataverseSource::builder("my-dv-source")
1163 .with_environment_url("https://test.crm.dynamics.com")
1164 .with_tenant_id("t")
1165 .with_client_id("c")
1166 .with_client_secret("s")
1167 .with_entities(vec!["account".to_string()])
1168 .build()
1169 .expect("should build");
1170 assert_eq!(source.id(), "my-dv-source");
1171 }
1172
1173 #[test]
1174 fn test_type_name_returns_dataverse() {
1175 let source = DataverseSource::builder("test")
1176 .with_environment_url("https://test.crm.dynamics.com")
1177 .with_tenant_id("t")
1178 .with_client_id("c")
1179 .with_client_secret("s")
1180 .with_entities(vec!["account".to_string()])
1181 .build()
1182 .expect("should build");
1183 assert_eq!(source.type_name(), "dataverse");
1184 }
1185
1186 #[test]
1187 fn test_properties_does_not_expose_secret() {
1188 let source = DataverseSource::builder("test")
1189 .with_environment_url("https://test.crm.dynamics.com")
1190 .with_tenant_id("t")
1191 .with_client_id("c")
1192 .with_client_secret("super-secret-value")
1193 .with_entities(vec!["account".to_string()])
1194 .build()
1195 .expect("should build");
1196 let props = source.properties();
1197
1198 assert!(props.contains_key("environment_url"));
1199 assert!(props.contains_key("tenant_id"));
1200 assert!(props.contains_key("client_id"));
1201 assert!(props.contains_key("entities"));
1202 assert!(!props.contains_key("client_secret"));
1203 }
1204
1205 #[test]
1206 fn test_properties_contains_correct_values() {
1207 let source = DataverseSource::builder("test")
1208 .with_environment_url("https://myorg.crm.dynamics.com")
1209 .with_tenant_id("tenant-123")
1210 .with_client_id("client-456")
1211 .with_client_secret("s")
1212 .with_entities(vec!["account".to_string(), "contact".to_string()])
1213 .build()
1214 .expect("should build");
1215 let props = source.properties();
1216
1217 assert_eq!(
1218 props.get("environment_url"),
1219 Some(&serde_json::Value::String(
1220 "https://myorg.crm.dynamics.com".to_string()
1221 ))
1222 );
1223 assert_eq!(
1224 props.get("tenant_id"),
1225 Some(&serde_json::Value::String("tenant-123".to_string()))
1226 );
1227 }
1228 }
1229
1230 mod lifecycle {
1231 use super::*;
1232
1233 #[tokio::test]
1234 async fn test_initial_status_is_stopped() {
1235 let source = DataverseSource::builder("test")
1236 .with_environment_url("https://test.crm.dynamics.com")
1237 .with_tenant_id("t")
1238 .with_client_id("c")
1239 .with_client_secret("s")
1240 .with_entities(vec!["account".to_string()])
1241 .build()
1242 .expect("should build");
1243 assert_eq!(source.status().await, ComponentStatus::Stopped);
1244 }
1245 }
1246
1247 mod builder {
1248 use super::*;
1249
1250 #[test]
1251 fn test_builder_defaults() {
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
1261 assert_eq!(source.config.min_interval_ms, 500);
1262 assert_eq!(source.config.max_interval_seconds, 30);
1263 assert_eq!(source.config.api_version, "v9.2");
1264 assert!(source.identity_provider.is_none());
1265 }
1266
1267 #[test]
1268 fn test_builder_custom_values() {
1269 let source = DataverseSource::builder("test")
1270 .with_environment_url("https://custom.crm.dynamics.com")
1271 .with_tenant_id("custom-tenant")
1272 .with_client_id("custom-client")
1273 .with_client_secret("custom-secret")
1274 .with_entities(vec!["account".to_string()])
1275 .with_min_interval_ms(200)
1276 .with_max_interval_seconds(60)
1277 .with_api_version("v9.1")
1278 .build()
1279 .expect("should build");
1280
1281 assert_eq!(
1282 source.config.environment_url,
1283 "https://custom.crm.dynamics.com"
1284 );
1285 assert_eq!(source.config.min_interval_ms, 200);
1286 assert_eq!(source.config.max_interval_seconds, 60);
1287 assert_eq!(source.config.api_version, "v9.1");
1288 }
1289
1290 #[test]
1291 fn test_builder_with_entity() {
1292 let source = DataverseSource::builder("test")
1293 .with_environment_url("https://test.crm.dynamics.com")
1294 .with_tenant_id("t")
1295 .with_client_id("c")
1296 .with_client_secret("s")
1297 .with_entity("account")
1298 .with_entity("contact")
1299 .build()
1300 .expect("should build");
1301
1302 assert_eq!(source.config.entities, vec!["account", "contact"]);
1303 }
1304
1305 #[test]
1306 fn test_builder_with_entity_set_override() {
1307 let source = DataverseSource::builder("test")
1308 .with_environment_url("https://test.crm.dynamics.com")
1309 .with_tenant_id("t")
1310 .with_client_id("c")
1311 .with_client_secret("s")
1312 .with_entity("activityparty")
1313 .with_entity_set_override("activityparty", "activityparties")
1314 .build()
1315 .expect("should build");
1316
1317 assert_eq!(
1318 source.config.entity_set_name("activityparty"),
1319 "activityparties"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_builder_with_entity_columns() {
1325 let source = DataverseSource::builder("test")
1326 .with_environment_url("https://test.crm.dynamics.com")
1327 .with_tenant_id("t")
1328 .with_client_id("c")
1329 .with_client_secret("s")
1330 .with_entity("account")
1331 .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1332 .build()
1333 .expect("should build");
1334
1335 assert_eq!(
1336 source.config.select_columns("account"),
1337 Some("name,revenue,accountid".to_string())
1338 );
1339 }
1340
1341 #[test]
1342 fn test_builder_with_identity_provider() {
1343 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1345 let source = DataverseSource::builder("test")
1346 .with_environment_url("https://test.crm.dynamics.com")
1347 .with_entities(vec!["account".to_string()])
1348 .with_identity_provider(provider)
1349 .build()
1350 .expect("should build with identity provider and no client credentials");
1351
1352 assert!(source.identity_provider.is_some());
1353 }
1354
1355 #[test]
1356 fn test_builder_with_identity_provider_still_needs_url() {
1357 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1358 let result = DataverseSource::builder("test")
1359 .with_entities(vec!["account".to_string()])
1360 .with_identity_provider(provider)
1361 .build();
1362 assert!(result.is_err(), "should fail without environment_url");
1363 }
1364
1365 #[test]
1366 fn test_builder_with_identity_provider_still_needs_entities() {
1367 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1368 let result = DataverseSource::builder("test")
1369 .with_environment_url("https://test.crm.dynamics.com")
1370 .with_identity_provider(provider)
1371 .build();
1372 assert!(result.is_err(), "should fail without entities");
1373 }
1374 }
1375
1376 mod change_conversion {
1377 use super::*;
1378
1379 #[test]
1380 fn test_convert_new_or_updated() {
1381 let mut attributes = serde_json::Map::new();
1382 attributes.insert(
1383 "name".to_string(),
1384 serde_json::Value::String("Contoso".to_string()),
1385 );
1386 attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1387 attributes.insert(
1388 "accountid".to_string(),
1389 serde_json::Value::String("abc-123".to_string()),
1390 );
1391
1392 let change = DataverseChange::NewOrUpdated {
1393 id: "abc-123".to_string(),
1394 entity_name: "account".to_string(),
1395 attributes,
1396 };
1397
1398 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1399 match source_change {
1400 SourceChange::Update { element } => match element {
1401 Element::Node {
1402 metadata,
1403 properties,
1404 } => {
1405 assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1406 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1407 assert_eq!(metadata.labels.len(), 1);
1408 assert_eq!(metadata.labels[0].as_ref(), "account");
1409 assert!(properties.get("name").is_some());
1410 }
1411 _ => panic!("Expected Node element"),
1412 },
1413 _ => panic!("Expected Update change"),
1414 }
1415 }
1416
1417 #[test]
1418 fn test_convert_deleted() {
1419 let change = DataverseChange::Deleted {
1420 id: "def-456".to_string(),
1421 entity_name: "contact".to_string(),
1422 };
1423
1424 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1425 match source_change {
1426 SourceChange::Delete { metadata } => {
1427 assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1428 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1429 assert_eq!(metadata.labels[0].as_ref(), "contact");
1430 }
1431 _ => panic!("Expected Delete change"),
1432 }
1433 }
1434
1435 #[test]
1436 fn test_convert_json_value_primitives() {
1437 assert_eq!(
1438 DataverseSource::convert_json_value(&serde_json::Value::Null),
1439 ElementValue::Null
1440 );
1441 assert_eq!(
1442 DataverseSource::convert_json_value(&serde_json::json!(true)),
1443 ElementValue::Bool(true)
1444 );
1445 assert_eq!(
1446 DataverseSource::convert_json_value(&serde_json::json!(42)),
1447 ElementValue::Integer(42)
1448 );
1449 assert_eq!(
1450 DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1451 ElementValue::Float(ordered_float::OrderedFloat(3.15))
1452 );
1453 assert_eq!(
1454 DataverseSource::convert_json_value(&serde_json::json!("hello")),
1455 ElementValue::String(Arc::from("hello"))
1456 );
1457 }
1458
1459 #[test]
1460 fn test_convert_json_value_extracts_value() {
1461 let json = serde_json::json!({"Value": 123});
1463 assert_eq!(
1464 DataverseSource::convert_json_value(&json),
1465 ElementValue::Integer(123)
1466 );
1467 }
1468
1469 #[test]
1470 fn test_convert_json_value_multi_select_choice() {
1471 let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1473 let result = DataverseSource::convert_json_value(&json);
1474 match result {
1475 ElementValue::List(values) => {
1476 assert_eq!(values.len(), 2);
1477 assert_eq!(values[0], ElementValue::Integer(1));
1478 assert_eq!(values[1], ElementValue::Integer(2));
1479 }
1480 _ => panic!("Expected List"),
1481 }
1482 }
1483
1484 #[tokio::test]
1493 async fn dispatch_changes_stamps_monotonic_sequence() {
1494 use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
1495
1496 let source_id = "dataverse-seq-source";
1497 let base = SourceBase::new(SourceBaseParams::new(source_id.to_string())).unwrap();
1498 let mut rx = base.test_subscribe().await;
1499
1500 let changes = vec![
1502 DataverseChange::NewOrUpdated {
1503 id: "a-1".to_string(),
1504 entity_name: "account".to_string(),
1505 attributes: serde_json::Map::new(),
1506 },
1507 DataverseChange::NewOrUpdated {
1508 id: "a-2".to_string(),
1509 entity_name: "account".to_string(),
1510 attributes: serde_json::Map::new(),
1511 },
1512 DataverseChange::Deleted {
1513 id: "a-1".to_string(),
1514 entity_name: "account".to_string(),
1515 },
1516 ];
1517 let expected = changes.len() as u64;
1518
1519 DataverseSource::dispatch_changes(source_id, "account", &base, &changes).await;
1520
1521 let mut sequences = Vec::new();
1522 for _ in 0..expected {
1523 let event = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
1524 .await
1525 .expect("timed out waiting for event")
1526 .expect("event stream closed unexpectedly");
1527 sequences.push(event.sequence);
1528 }
1529
1530 assert_eq!(
1531 sequences,
1532 vec![1, 2, 3],
1533 "delta changes must carry unique, strictly increasing sequences"
1534 );
1535 }
1536 }
1537
1538 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1539 async fn concurrent_entity_workers_deliver_in_sequence_order() {
1540 use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
1541
1542 let source_id = "concurrent-entities";
1543 let base = SourceBase::new(SourceBaseParams::new(source_id)).unwrap();
1544 let mut receiver = base.test_subscribe().await;
1545 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1546 let mut workers = tokio::task::JoinSet::new();
1547 for entity in ["account", "contact"] {
1548 let base = base.clone_shared();
1549 let barrier = barrier.clone();
1550 workers.spawn(async move {
1551 for index in 0..250 {
1552 barrier.wait().await;
1553 DataverseSource::dispatch_changes(
1554 source_id,
1555 entity,
1556 &base,
1557 &[DataverseChange::NewOrUpdated {
1558 id: format!("{entity}-{index}"),
1559 entity_name: entity.to_string(),
1560 attributes: serde_json::Map::new(),
1561 }],
1562 )
1563 .await;
1564 }
1565 });
1566 }
1567 for expected in 1..=500 {
1568 let event = tokio::time::timeout(std::time::Duration::from_secs(5), receiver.recv())
1569 .await
1570 .unwrap()
1571 .unwrap();
1572 assert_eq!(event.sequence, expected);
1573 assert_eq!(event.source_id, source_id);
1574 assert!(event.profiling.as_ref().unwrap().source_send_ns.is_some());
1575 }
1576 while let Some(result) = workers.join_next().await {
1577 result.unwrap();
1578 }
1579 }
1580
1581 mod backoff {
1582 use super::*;
1583
1584 #[test]
1585 fn resets_to_min_when_changes_detected() {
1586 let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1589 assert_eq!(next, 500);
1590 }
1591
1592 #[test]
1593 fn slow_backoff_under_threshold() {
1594 let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1596 assert_eq!(next, 1200);
1597
1598 let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1599 assert_eq!(next, 4800);
1600 }
1601
1602 #[test]
1603 fn fast_backoff_above_threshold() {
1604 let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1606 assert_eq!(next, 7500);
1607
1608 let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1609 assert_eq!(next, 15_000);
1610 }
1611
1612 #[test]
1613 fn does_not_exceed_max() {
1614 let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1616 assert_eq!(next, 30_000);
1617
1618 let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1620 assert_eq!(next, 30_000);
1621 }
1622
1623 #[test]
1624 fn full_progression_no_changes() {
1625 let min = 500;
1628 let max = 30_000;
1629 let mut current = min;
1630 let mut steps = 0;
1631 while current < max {
1632 let next = DataverseSource::next_backoff_interval(current, min, max, false);
1633 assert!(
1634 next > current || next == max,
1635 "interval should grow or hit the cap (was {current}, became {next})"
1636 );
1637 assert!(
1638 next <= max,
1639 "interval must never exceed max ({next} > {max})"
1640 );
1641 current = next;
1642 steps += 1;
1643 assert!(steps < 100, "backoff failed to converge");
1644 }
1645 assert_eq!(current, max);
1646 }
1647
1648 #[test]
1649 fn full_progression_with_change_resets() {
1650 let min = 500;
1652 let max = 30_000;
1653 let mut current = min;
1654 current = DataverseSource::next_backoff_interval(current, min, max, false);
1655 current = DataverseSource::next_backoff_interval(current, min, max, false);
1656 assert!(current > min);
1657 current = DataverseSource::next_backoff_interval(current, min, max, true);
1658 assert_eq!(current, min);
1659 }
1660 }
1661
1662 mod state_store_helpers {
1663 use super::*;
1664 use drasi_lib::MemoryStateStoreProvider;
1665
1666 fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1667 Arc::new(MemoryStateStoreProvider::new())
1668 }
1669
1670 #[test]
1671 fn delta_token_key_matches_platform_format() {
1672 assert_eq!(
1675 DataverseSource::delta_token_key("account"),
1676 "account-deltatoken"
1677 );
1678 }
1679
1680 #[tokio::test]
1681 async fn load_returns_none_on_empty_store() {
1682 let s = store();
1683 let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1684 assert!(
1685 result.is_none(),
1686 "no checkpoint should be present initially"
1687 );
1688 }
1689
1690 #[tokio::test]
1691 async fn save_then_load_round_trip() {
1692 let s = store();
1693 DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1694
1695 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1696 assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1697 }
1698
1699 #[tokio::test]
1700 async fn checkpoints_are_isolated_per_entity() {
1701 let s = store();
1702 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1703 DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1704
1705 assert_eq!(
1706 DataverseSource::load_delta_token(&s, "src-1", "account")
1707 .await
1708 .as_deref(),
1709 Some("token-A")
1710 );
1711 assert_eq!(
1712 DataverseSource::load_delta_token(&s, "src-1", "contact")
1713 .await
1714 .as_deref(),
1715 Some("token-B")
1716 );
1717 }
1718
1719 #[tokio::test]
1720 async fn checkpoints_are_isolated_per_source() {
1721 let s = store();
1722 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1723 DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1724
1725 assert_eq!(
1726 DataverseSource::load_delta_token(&s, "src-1", "account")
1727 .await
1728 .as_deref(),
1729 Some("token-A")
1730 );
1731 assert_eq!(
1732 DataverseSource::load_delta_token(&s, "src-2", "account")
1733 .await
1734 .as_deref(),
1735 Some("token-B")
1736 );
1737 }
1738
1739 #[tokio::test]
1740 async fn save_overwrites_previous_value() {
1741 let s = store();
1742 DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1743 DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1744
1745 assert_eq!(
1746 DataverseSource::load_delta_token(&s, "src-1", "account")
1747 .await
1748 .as_deref(),
1749 Some("token-new")
1750 );
1751 }
1752
1753 #[tokio::test]
1754 async fn load_skips_invalid_utf8() {
1755 let s = store();
1756 let key = DataverseSource::delta_token_key("account");
1757 s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1759 .await
1760 .expect("set should succeed");
1761
1762 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1763 assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1764 }
1765 }
1766}
1767
1768#[cfg(feature = "dynamic-plugin")]
1770drasi_plugin_sdk::export_plugin!(
1771 plugin_id = "dataverse-source",
1772 core_version = env!("CARGO_PKG_VERSION"),
1773 lib_version = env!("CARGO_PKG_VERSION"),
1774 plugin_version = env!("CARGO_PKG_VERSION"),
1775 source_descriptors = [descriptor::DataverseSourceDescriptor],
1776 reaction_descriptors = [],
1777 bootstrap_descriptors = [],
1778);