1#![allow(unexpected_cfgs)]
16
17pub mod client;
73pub mod config;
74pub mod descriptor;
75pub mod types;
76
77pub use config::DataverseSourceConfig;
78
79use anyhow::Result;
80use async_trait::async_trait;
81use std::collections::HashMap;
82use std::sync::Arc;
83use std::time::Duration;
84
85use drasi_core::models::{
86 Element, ElementMetadata, ElementPropertyMap, ElementReference, ElementValue, SourceChange,
87};
88use drasi_lib::channels::{ComponentStatus, DispatchMode, SourceEvent, SourceEventWrapper};
89use drasi_lib::identity::IdentityProvider;
90use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
91use drasi_lib::Source;
92use tracing::Instrument;
93
94use crate::client::DataverseClient;
95use crate::types::{parse_delta_changes, DataverseChange};
96
97pub struct DataverseSource {
107 base: SourceBase,
109 config: DataverseSourceConfig,
111 identity_provider: Option<Box<dyn IdentityProvider>>,
114}
115
116impl DataverseSource {
117 pub fn new(id: impl Into<String>, config: DataverseSourceConfig) -> Result<Self> {
128 config.validate().map_err(|e| anyhow::anyhow!(e))?;
129 let params = SourceBaseParams::new(id.into());
130 Ok(Self {
131 base: SourceBase::new(params)?,
132 config,
133 identity_provider: None,
134 })
135 }
136
137 pub fn builder(id: impl Into<String>) -> DataverseSourceBuilder {
143 DataverseSourceBuilder::new(id)
144 }
145
146 pub(crate) fn dataverse_scope(environment_url: &str) -> String {
153 match url::Url::parse(environment_url) {
154 Ok(url) => match url.host_str() {
155 Some(host) => format!("{}://{}/.default", url.scheme(), host),
156 None => format!("{}/.default", environment_url.trim_end_matches('/')),
157 },
158 Err(_) => format!("{}/.default", environment_url.trim_end_matches('/')),
159 }
160 }
161
162 pub(crate) fn next_backoff_interval(
172 current_interval_ms: u64,
173 min_interval_ms: u64,
174 max_interval_ms: u64,
175 changes_detected: bool,
176 ) -> u64 {
177 const THRESHOLD_MS: u64 = 5000;
178 const SLOW_BACKOFF: f64 = 1.2;
179 const FAST_BACKOFF: f64 = 1.5;
180
181 if changes_detected {
182 return min_interval_ms;
183 }
184 let multiplier = if current_interval_ms < THRESHOLD_MS {
185 SLOW_BACKOFF
186 } else {
187 FAST_BACKOFF
188 };
189 ((current_interval_ms as f64 * multiplier) as u64).min(max_interval_ms)
190 }
191
192 pub(crate) fn delta_token_key(entity_name: &str) -> String {
195 format!("{entity_name}-deltatoken")
196 }
197
198 pub(crate) async fn load_delta_token(
203 store: &Arc<dyn drasi_lib::StateStoreProvider>,
204 source_id: &str,
205 entity_name: &str,
206 ) -> Option<String> {
207 let key = Self::delta_token_key(entity_name);
208 match store.get(source_id, &key).await {
209 Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
210 Ok(None) => None,
211 Err(e) => {
212 log::warn!("[{source_id}] Failed to load delta token for {entity_name}: {e}");
213 None
214 }
215 }
216 }
217
218 pub(crate) async fn save_delta_token(
220 store: &Arc<dyn drasi_lib::StateStoreProvider>,
221 source_id: &str,
222 entity_name: &str,
223 token: &str,
224 ) {
225 let key = Self::delta_token_key(entity_name);
226 if let Err(e) = store.set(source_id, &key, token.as_bytes().to_vec()).await {
227 log::warn!("[{source_id}] Failed to persist delta token for {entity_name}: {e}");
228 }
229 }
230
231 #[allow(clippy::too_many_arguments)]
240 async fn run_entity_worker(
241 source_id: String,
242 entity_name: String,
243 entity_set_name: String,
244 select: Option<String>,
245 client: Arc<DataverseClient>,
246 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 let mut profiling = drasi_lib::profiling::ProfilingMetadata::new();
462 profiling.source_send_ns = Some(drasi_lib::profiling::timestamp_ns());
463
464 let wrapper = SourceEventWrapper::with_profiling(
465 source_id.to_string(),
466 SourceEvent::Change(source_change),
467 chrono::Utc::now(),
468 profiling,
469 );
470
471 if let Err(e) = base.dispatch_event(wrapper).await {
472 log::error!("[{source_id}] Failed to dispatch change for {entity_name}: {e}");
473 }
474 }
475 }
476
477 fn convert_to_source_change(source_id: &str, change: &DataverseChange) -> SourceChange {
483 match change {
484 DataverseChange::NewOrUpdated {
485 id,
486 entity_name,
487 attributes,
488 } => {
489 let mut properties = ElementPropertyMap::new();
492 for (key, value) in attributes {
493 let element_value = Self::convert_json_value(value);
494 properties.insert(key, element_value);
495 }
496
497 let effective_from = attributes
500 .get("modifiedon")
501 .and_then(|v| v.as_str())
502 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
503 .map(|dt| dt.timestamp_millis().max(0) as u64)
504 .unwrap_or_else(|| chrono::Utc::now().timestamp_millis().max(0) as u64);
505
506 let element_id = format!("{entity_name}:{id}");
507 let metadata = ElementMetadata {
508 reference: ElementReference::new(source_id, &element_id),
509 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
510 effective_from,
511 };
512
513 SourceChange::Update {
514 element: Element::Node {
515 metadata,
516 properties,
517 },
518 }
519 }
520 DataverseChange::Deleted { id, entity_name } => {
521 let element_id = format!("{entity_name}:{id}");
522 let metadata = ElementMetadata {
523 reference: ElementReference::new(source_id, &element_id),
524 labels: Arc::from(vec![Arc::from(entity_name.as_str())]),
525 effective_from: chrono::Utc::now().timestamp_millis().max(0) as u64,
527 };
528
529 SourceChange::Delete { metadata }
530 }
531 }
532 }
533
534 fn convert_json_value(value: &serde_json::Value) -> ElementValue {
540 match value {
541 serde_json::Value::Null => ElementValue::Null,
542 serde_json::Value::Bool(b) => ElementValue::Bool(*b),
543 serde_json::Value::Number(n) => {
544 if let Some(i) = n.as_i64() {
545 ElementValue::Integer(i)
546 } else if let Some(f) = n.as_f64() {
547 ElementValue::Float(ordered_float::OrderedFloat(f))
548 } else {
549 ElementValue::Null
550 }
551 }
552 serde_json::Value::String(s) => ElementValue::String(Arc::from(s.as_str())),
553 serde_json::Value::Array(arr) => {
554 if !arr.is_empty() {
557 if let Some(first_obj) = arr[0].as_object() {
558 if first_obj.contains_key("Value") {
559 let values: Vec<ElementValue> = arr
560 .iter()
561 .filter_map(|item| {
562 item.as_object()
563 .and_then(|obj| obj.get("Value"))
564 .map(Self::convert_json_value)
565 })
566 .collect();
567 return ElementValue::List(values);
568 }
569 }
570 }
571 ElementValue::List(arr.iter().map(Self::convert_json_value).collect())
572 }
573 serde_json::Value::Object(obj) => {
574 if obj.contains_key("Value") && obj.len() <= 2 {
577 if let Some(val) = obj.get("Value") {
578 return Self::convert_json_value(val);
579 }
580 }
581 let mut map = ElementPropertyMap::new();
583 for (k, v) in obj {
584 map.insert(k, Self::convert_json_value(v));
585 }
586 ElementValue::Object(map)
587 }
588 }
589 }
590}
591
592#[async_trait]
593impl Source for DataverseSource {
594 fn id(&self) -> &str {
595 &self.base.id
596 }
597
598 fn type_name(&self) -> &str {
599 "dataverse"
600 }
601
602 fn properties(&self) -> HashMap<String, serde_json::Value> {
603 let mut props = HashMap::new();
604 props.insert(
605 "environment_url".to_string(),
606 serde_json::Value::String(self.config.environment_url.clone()),
607 );
608 props.insert(
609 "tenant_id".to_string(),
610 serde_json::Value::String(self.config.tenant_id.clone()),
611 );
612 props.insert(
613 "client_id".to_string(),
614 serde_json::Value::String(self.config.client_id.clone()),
615 );
616 props.insert(
618 "entities".to_string(),
619 serde_json::Value::Array(
620 self.config
621 .entities
622 .iter()
623 .map(|e| serde_json::Value::String(e.clone()))
624 .collect(),
625 ),
626 );
627 props.insert(
628 "api_version".to_string(),
629 serde_json::Value::String(self.config.api_version.clone()),
630 );
631 props
632 }
633
634 fn auto_start(&self) -> bool {
635 self.base.get_auto_start()
636 }
637
638 async fn start(&self) -> Result<()> {
639 log::info!("[{}] Starting Dataverse source", self.base.id);
640
641 self.base
642 .set_status(
643 ComponentStatus::Starting,
644 Some("Starting Dataverse source".to_string()),
645 )
646 .await;
647
648 let base_url = self.config.environment_url.clone();
650
651 let provider: Arc<dyn IdentityProvider> = if let Some(injected) =
663 self.base.identity_provider().await
664 {
665 injected
666 } else if let Some(ref ip) = self.identity_provider {
667 Arc::from(ip.clone_box())
668 } else {
669 let azure_provider = drasi_identity_azure::AzureIdentityProvider::with_client_secret(
670 "dataverse",
671 &self.config.tenant_id,
672 &self.config.client_id,
673 &self.config.client_secret,
674 )?
675 .with_scope(Self::dataverse_scope(&base_url));
676 Arc::new(azure_provider)
677 };
678
679 let client = Arc::new(DataverseClient::new(
680 &base_url,
681 &self.config.api_version,
682 provider,
683 ));
684
685 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
687 let shutdown_tx = Arc::new(shutdown_tx);
688
689 let base = self.base.clone_shared();
690 let state_store = self.base.state_store().await;
691 let source_id = self.base.id.clone();
692
693 let entity_count = self.config.entities.len() as f64;
698 let effective_max_interval_seconds =
699 (self.config.max_interval_seconds as f64 * entity_count.sqrt()).max(1.0) as u64;
700 log::info!(
701 "[{}] Effective max polling interval: {}s (base {}s * sqrt({} entities))",
702 self.base.id,
703 effective_max_interval_seconds,
704 self.config.max_interval_seconds,
705 self.config.entities.len()
706 );
707
708 let instance_id = self
710 .base
711 .context()
712 .await
713 .map(|c| c.instance_id)
714 .unwrap_or_default();
715
716 let mut task_handles = Vec::new();
718 for entity_name in &self.config.entities {
719 let entity_set_name = self.config.entity_set_name(entity_name);
720 let select = self.config.select_columns(entity_name);
721 let source_id = source_id.clone();
722 let entity_name = entity_name.clone();
723 let client = client.clone();
724 let base = base.clone_shared();
725 let state_store = state_store.clone();
726 let shutdown_rx = shutdown_tx.subscribe();
727 let min_interval_ms = self.config.min_interval_ms;
728 let max_interval_seconds = effective_max_interval_seconds;
729 let instance_id = instance_id.clone();
730
731 let span = tracing::info_span!(
732 "dataverse_entity_worker",
733 instance_id = %instance_id,
734 component_id = %source_id,
735 component_type = "source",
736 entity = %entity_name
737 );
738
739 let handle = tokio::spawn(
740 async move {
741 Self::run_entity_worker(
742 source_id,
743 entity_name,
744 entity_set_name,
745 select,
746 client,
747 base,
748 state_store,
749 shutdown_rx,
750 min_interval_ms,
751 max_interval_seconds,
752 )
753 .await;
754 }
755 .instrument(span),
756 );
757 task_handles.push(handle);
758 }
759
760 let source_id = self.base.id.clone();
763 let combined_handle = tokio::spawn(async move {
764 for (i, handle) in task_handles.into_iter().enumerate() {
765 if let Err(e) = handle.await {
766 log::error!("[{source_id}] Entity worker {i} terminated with error: {e}");
767 }
768 }
769 log::info!("[{source_id}] All entity workers stopped");
770 });
771
772 *self.base.task_handle.write().await = Some(combined_handle);
773
774 {
776 let mut lock = self.base.shutdown_tx.write().await;
777 let shutdown_tx_for_stop = shutdown_tx.clone();
778 let (bridge_tx, bridge_rx) = tokio::sync::oneshot::channel::<()>();
779 tokio::spawn(async move {
780 let _ = bridge_rx.await;
781 let _ = shutdown_tx_for_stop.send(true);
782 });
783 *lock = Some(bridge_tx);
784 }
785
786 self.base
787 .set_status(
788 ComponentStatus::Running,
789 Some(format!(
790 "Dataverse source running, monitoring {} entities",
791 self.config.entities.len()
792 )),
793 )
794 .await;
795
796 Ok(())
797 }
798
799 async fn stop(&self) -> Result<()> {
800 log::info!("[{}] Stopping Dataverse source", self.base.id);
801
802 self.base
803 .set_status(
804 ComponentStatus::Stopping,
805 Some("Stopping Dataverse source".to_string()),
806 )
807 .await;
808
809 if let Some(tx) = self.base.shutdown_tx.write().await.take() {
811 let _ = tx.send(());
812 }
813
814 if let Some(handle) = self.base.task_handle.write().await.take() {
816 let mut handle = handle;
817 if tokio::time::timeout(Duration::from_secs(10), &mut handle)
818 .await
819 .is_err()
820 {
821 handle.abort();
822 let _ = handle.await;
823 }
824 }
825
826 self.base
827 .set_status(
828 ComponentStatus::Stopped,
829 Some("Dataverse source stopped".to_string()),
830 )
831 .await;
832
833 Ok(())
834 }
835
836 async fn status(&self) -> ComponentStatus {
837 self.base.get_status().await
838 }
839
840 async fn subscribe(
841 &self,
842 settings: drasi_lib::config::SourceSubscriptionSettings,
843 ) -> Result<drasi_lib::channels::SubscriptionResponse> {
844 self.base
845 .subscribe_with_bootstrap(&settings, "Dataverse")
846 .await
847 }
848
849 fn as_any(&self) -> &dyn std::any::Any {
850 self
851 }
852
853 async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
854 self.base.initialize(context).await;
855 }
856
857 async fn set_bootstrap_provider(
858 &self,
859 provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
860 ) {
861 self.base.set_bootstrap_provider(provider).await;
862 }
863}
864
865pub struct DataverseSourceBuilder {
883 id: String,
884 environment_url: String,
885 tenant_id: String,
886 client_id: String,
887 client_secret: String,
888 entities: Vec<String>,
889 entity_set_overrides: HashMap<String, String>,
890 entity_columns: HashMap<String, Vec<String>>,
891 min_interval_ms: u64,
892 max_interval_seconds: u64,
893 api_version: String,
894 dispatch_mode: Option<DispatchMode>,
895 dispatch_buffer_capacity: Option<usize>,
896 bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
897 identity_provider: Option<Box<dyn IdentityProvider>>,
898 auto_start: bool,
899}
900
901impl DataverseSourceBuilder {
902 pub fn new(id: impl Into<String>) -> Self {
904 Self {
905 id: id.into(),
906 environment_url: String::new(),
907 tenant_id: String::new(),
908 client_id: String::new(),
909 client_secret: String::new(),
910 entities: Vec::new(),
911 entity_set_overrides: HashMap::new(),
912 entity_columns: HashMap::new(),
913 min_interval_ms: 500,
914 max_interval_seconds: 30,
915 api_version: "v9.2".to_string(),
916 dispatch_mode: None,
917 dispatch_buffer_capacity: None,
918 bootstrap_provider: None,
919 identity_provider: None,
920 auto_start: true,
921 }
922 }
923
924 pub fn with_environment_url(mut self, url: impl Into<String>) -> Self {
926 self.environment_url = url.into();
927 self
928 }
929
930 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
932 self.tenant_id = tenant_id.into();
933 self
934 }
935
936 pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
938 self.client_id = client_id.into();
939 self
940 }
941
942 pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
944 self.client_secret = client_secret.into();
945 self
946 }
947
948 pub fn with_identity_provider(mut self, provider: impl IdentityProvider + 'static) -> Self {
978 self.identity_provider = Some(Box::new(provider));
979 self
980 }
981
982 pub fn with_entities(mut self, entities: Vec<String>) -> Self {
984 self.entities = entities;
985 self
986 }
987
988 pub fn with_entity(mut self, entity: impl Into<String>) -> Self {
990 self.entities.push(entity.into());
991 self
992 }
993
994 pub fn with_entity_set_override(
996 mut self,
997 entity_name: impl Into<String>,
998 entity_set_name: impl Into<String>,
999 ) -> Self {
1000 self.entity_set_overrides
1001 .insert(entity_name.into(), entity_set_name.into());
1002 self
1003 }
1004
1005 pub fn with_entity_columns(mut self, entity: impl Into<String>, columns: Vec<String>) -> Self {
1007 self.entity_columns.insert(entity.into(), columns);
1008 self
1009 }
1010
1011 pub fn with_min_interval_ms(mut self, ms: u64) -> Self {
1013 self.min_interval_ms = ms;
1014 self
1015 }
1016
1017 pub fn with_max_interval_seconds(mut self, seconds: u64) -> Self {
1019 self.max_interval_seconds = seconds;
1020 self
1021 }
1022
1023 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
1025 self.api_version = version.into();
1026 self
1027 }
1028
1029 pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1031 self.dispatch_mode = Some(mode);
1032 self
1033 }
1034
1035 pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1037 self.dispatch_buffer_capacity = Some(capacity);
1038 self
1039 }
1040
1041 pub fn with_bootstrap_provider(
1043 mut self,
1044 provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1045 ) -> Self {
1046 self.bootstrap_provider = Some(Box::new(provider));
1047 self
1048 }
1049
1050 pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1052 self.auto_start = auto_start;
1053 self
1054 }
1055
1056 pub fn build(self) -> Result<DataverseSource> {
1058 let config = DataverseSourceConfig {
1059 environment_url: self.environment_url,
1060 tenant_id: self.tenant_id,
1061 client_id: self.client_id,
1062 client_secret: self.client_secret,
1063 entities: self.entities,
1064 entity_set_overrides: self.entity_set_overrides,
1065 entity_columns: self.entity_columns,
1066 min_interval_ms: self.min_interval_ms,
1067 max_interval_seconds: self.max_interval_seconds,
1068 api_version: self.api_version,
1069 };
1070
1071 let no_builtin_credentials = config.tenant_id.is_empty()
1078 && config.client_id.is_empty()
1079 && config.client_secret.is_empty();
1080 if self.identity_provider.is_some() || no_builtin_credentials {
1081 config
1082 .validate_with_identity_provider()
1083 .map_err(|e| anyhow::anyhow!(e))?;
1084 } else {
1085 config.validate().map_err(|e| anyhow::anyhow!(e))?;
1086 }
1087
1088 let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1089 if let Some(mode) = self.dispatch_mode {
1090 params = params.with_dispatch_mode(mode);
1091 }
1092 if let Some(capacity) = self.dispatch_buffer_capacity {
1093 params = params.with_dispatch_buffer_capacity(capacity);
1094 }
1095 if let Some(provider) = self.bootstrap_provider {
1096 params = params.with_bootstrap_provider(provider);
1097 }
1098
1099 Ok(DataverseSource {
1100 base: SourceBase::new(params)?,
1101 config,
1102 identity_provider: self.identity_provider,
1103 })
1104 }
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110
1111 mod construction {
1112 use super::*;
1113
1114 #[test]
1115 fn test_builder_creates_source() {
1116 let source = DataverseSource::builder("dv-source")
1117 .with_environment_url("https://myorg.crm.dynamics.com")
1118 .with_tenant_id("tenant-1")
1119 .with_client_id("client-1")
1120 .with_client_secret("secret-1")
1121 .with_entities(vec!["account".to_string()])
1122 .build();
1123 assert!(source.is_ok());
1124 }
1125
1126 #[test]
1127 fn test_builder_fails_without_entities() {
1128 let source = DataverseSource::builder("dv-source")
1129 .with_environment_url("https://myorg.crm.dynamics.com")
1130 .with_tenant_id("tenant-1")
1131 .with_client_id("client-1")
1132 .with_client_secret("secret-1")
1133 .build();
1134 assert!(source.is_err());
1135 }
1136
1137 #[test]
1138 fn test_builder_fails_without_url() {
1139 let source = DataverseSource::builder("dv-source")
1140 .with_tenant_id("tenant-1")
1141 .with_client_id("client-1")
1142 .with_client_secret("secret-1")
1143 .with_entities(vec!["account".to_string()])
1144 .build();
1145 assert!(source.is_err());
1146 }
1147
1148 #[test]
1149 fn test_new_with_valid_config() {
1150 let config = DataverseSourceConfig {
1151 environment_url: "https://test.crm.dynamics.com".to_string(),
1152 tenant_id: "t".to_string(),
1153 client_id: "c".to_string(),
1154 client_secret: "s".to_string(),
1155 entities: vec!["account".to_string()],
1156 entity_set_overrides: HashMap::new(),
1157 entity_columns: HashMap::new(),
1158 min_interval_ms: 500,
1159 max_interval_seconds: 30,
1160 api_version: "v9.2".to_string(),
1161 };
1162 let source = DataverseSource::new("test-source", config);
1163 assert!(source.is_ok());
1164 }
1165 }
1166
1167 mod properties {
1168 use super::*;
1169
1170 #[test]
1171 fn test_id_returns_correct_value() {
1172 let source = DataverseSource::builder("my-dv-source")
1173 .with_environment_url("https://test.crm.dynamics.com")
1174 .with_tenant_id("t")
1175 .with_client_id("c")
1176 .with_client_secret("s")
1177 .with_entities(vec!["account".to_string()])
1178 .build()
1179 .expect("should build");
1180 assert_eq!(source.id(), "my-dv-source");
1181 }
1182
1183 #[test]
1184 fn test_type_name_returns_dataverse() {
1185 let source = DataverseSource::builder("test")
1186 .with_environment_url("https://test.crm.dynamics.com")
1187 .with_tenant_id("t")
1188 .with_client_id("c")
1189 .with_client_secret("s")
1190 .with_entities(vec!["account".to_string()])
1191 .build()
1192 .expect("should build");
1193 assert_eq!(source.type_name(), "dataverse");
1194 }
1195
1196 #[test]
1197 fn test_properties_does_not_expose_secret() {
1198 let source = DataverseSource::builder("test")
1199 .with_environment_url("https://test.crm.dynamics.com")
1200 .with_tenant_id("t")
1201 .with_client_id("c")
1202 .with_client_secret("super-secret-value")
1203 .with_entities(vec!["account".to_string()])
1204 .build()
1205 .expect("should build");
1206 let props = source.properties();
1207
1208 assert!(props.contains_key("environment_url"));
1209 assert!(props.contains_key("tenant_id"));
1210 assert!(props.contains_key("client_id"));
1211 assert!(props.contains_key("entities"));
1212 assert!(!props.contains_key("client_secret"));
1213 }
1214
1215 #[test]
1216 fn test_properties_contains_correct_values() {
1217 let source = DataverseSource::builder("test")
1218 .with_environment_url("https://myorg.crm.dynamics.com")
1219 .with_tenant_id("tenant-123")
1220 .with_client_id("client-456")
1221 .with_client_secret("s")
1222 .with_entities(vec!["account".to_string(), "contact".to_string()])
1223 .build()
1224 .expect("should build");
1225 let props = source.properties();
1226
1227 assert_eq!(
1228 props.get("environment_url"),
1229 Some(&serde_json::Value::String(
1230 "https://myorg.crm.dynamics.com".to_string()
1231 ))
1232 );
1233 assert_eq!(
1234 props.get("tenant_id"),
1235 Some(&serde_json::Value::String("tenant-123".to_string()))
1236 );
1237 }
1238 }
1239
1240 mod lifecycle {
1241 use super::*;
1242
1243 #[tokio::test]
1244 async fn test_initial_status_is_stopped() {
1245 let source = DataverseSource::builder("test")
1246 .with_environment_url("https://test.crm.dynamics.com")
1247 .with_tenant_id("t")
1248 .with_client_id("c")
1249 .with_client_secret("s")
1250 .with_entities(vec!["account".to_string()])
1251 .build()
1252 .expect("should build");
1253 assert_eq!(source.status().await, ComponentStatus::Stopped);
1254 }
1255 }
1256
1257 mod builder {
1258 use super::*;
1259
1260 #[test]
1261 fn test_builder_defaults() {
1262 let source = DataverseSource::builder("test")
1263 .with_environment_url("https://test.crm.dynamics.com")
1264 .with_tenant_id("t")
1265 .with_client_id("c")
1266 .with_client_secret("s")
1267 .with_entities(vec!["account".to_string()])
1268 .build()
1269 .expect("should build");
1270
1271 assert_eq!(source.config.min_interval_ms, 500);
1272 assert_eq!(source.config.max_interval_seconds, 30);
1273 assert_eq!(source.config.api_version, "v9.2");
1274 assert!(source.identity_provider.is_none());
1275 }
1276
1277 #[test]
1278 fn test_builder_custom_values() {
1279 let source = DataverseSource::builder("test")
1280 .with_environment_url("https://custom.crm.dynamics.com")
1281 .with_tenant_id("custom-tenant")
1282 .with_client_id("custom-client")
1283 .with_client_secret("custom-secret")
1284 .with_entities(vec!["account".to_string()])
1285 .with_min_interval_ms(200)
1286 .with_max_interval_seconds(60)
1287 .with_api_version("v9.1")
1288 .build()
1289 .expect("should build");
1290
1291 assert_eq!(
1292 source.config.environment_url,
1293 "https://custom.crm.dynamics.com"
1294 );
1295 assert_eq!(source.config.min_interval_ms, 200);
1296 assert_eq!(source.config.max_interval_seconds, 60);
1297 assert_eq!(source.config.api_version, "v9.1");
1298 }
1299
1300 #[test]
1301 fn test_builder_with_entity() {
1302 let source = DataverseSource::builder("test")
1303 .with_environment_url("https://test.crm.dynamics.com")
1304 .with_tenant_id("t")
1305 .with_client_id("c")
1306 .with_client_secret("s")
1307 .with_entity("account")
1308 .with_entity("contact")
1309 .build()
1310 .expect("should build");
1311
1312 assert_eq!(source.config.entities, vec!["account", "contact"]);
1313 }
1314
1315 #[test]
1316 fn test_builder_with_entity_set_override() {
1317 let source = DataverseSource::builder("test")
1318 .with_environment_url("https://test.crm.dynamics.com")
1319 .with_tenant_id("t")
1320 .with_client_id("c")
1321 .with_client_secret("s")
1322 .with_entity("activityparty")
1323 .with_entity_set_override("activityparty", "activityparties")
1324 .build()
1325 .expect("should build");
1326
1327 assert_eq!(
1328 source.config.entity_set_name("activityparty"),
1329 "activityparties"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_builder_with_entity_columns() {
1335 let source = DataverseSource::builder("test")
1336 .with_environment_url("https://test.crm.dynamics.com")
1337 .with_tenant_id("t")
1338 .with_client_id("c")
1339 .with_client_secret("s")
1340 .with_entity("account")
1341 .with_entity_columns("account", vec!["name".to_string(), "revenue".to_string()])
1342 .build()
1343 .expect("should build");
1344
1345 assert_eq!(
1346 source.config.select_columns("account"),
1347 Some("name,revenue,accountid".to_string())
1348 );
1349 }
1350
1351 #[test]
1352 fn test_builder_with_identity_provider() {
1353 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1355 let source = DataverseSource::builder("test")
1356 .with_environment_url("https://test.crm.dynamics.com")
1357 .with_entities(vec!["account".to_string()])
1358 .with_identity_provider(provider)
1359 .build()
1360 .expect("should build with identity provider and no client credentials");
1361
1362 assert!(source.identity_provider.is_some());
1363 }
1364
1365 #[test]
1366 fn test_builder_with_identity_provider_still_needs_url() {
1367 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1368 let result = DataverseSource::builder("test")
1369 .with_entities(vec!["account".to_string()])
1370 .with_identity_provider(provider)
1371 .build();
1372 assert!(result.is_err(), "should fail without environment_url");
1373 }
1374
1375 #[test]
1376 fn test_builder_with_identity_provider_still_needs_entities() {
1377 let provider = drasi_lib::identity::PasswordIdentityProvider::new("user", "token");
1378 let result = DataverseSource::builder("test")
1379 .with_environment_url("https://test.crm.dynamics.com")
1380 .with_identity_provider(provider)
1381 .build();
1382 assert!(result.is_err(), "should fail without entities");
1383 }
1384 }
1385
1386 mod change_conversion {
1387 use super::*;
1388
1389 #[test]
1390 fn test_convert_new_or_updated() {
1391 let mut attributes = serde_json::Map::new();
1392 attributes.insert(
1393 "name".to_string(),
1394 serde_json::Value::String("Contoso".to_string()),
1395 );
1396 attributes.insert("revenue".to_string(), serde_json::json!(1000000.0));
1397 attributes.insert(
1398 "accountid".to_string(),
1399 serde_json::Value::String("abc-123".to_string()),
1400 );
1401
1402 let change = DataverseChange::NewOrUpdated {
1403 id: "abc-123".to_string(),
1404 entity_name: "account".to_string(),
1405 attributes,
1406 };
1407
1408 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1409 match source_change {
1410 SourceChange::Update { element } => match element {
1411 Element::Node {
1412 metadata,
1413 properties,
1414 } => {
1415 assert_eq!(metadata.reference.element_id.as_ref(), "account:abc-123");
1416 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1417 assert_eq!(metadata.labels.len(), 1);
1418 assert_eq!(metadata.labels[0].as_ref(), "account");
1419 assert!(properties.get("name").is_some());
1420 }
1421 _ => panic!("Expected Node element"),
1422 },
1423 _ => panic!("Expected Update change"),
1424 }
1425 }
1426
1427 #[test]
1428 fn test_convert_deleted() {
1429 let change = DataverseChange::Deleted {
1430 id: "def-456".to_string(),
1431 entity_name: "contact".to_string(),
1432 };
1433
1434 let source_change = DataverseSource::convert_to_source_change("test-source", &change);
1435 match source_change {
1436 SourceChange::Delete { metadata } => {
1437 assert_eq!(metadata.reference.element_id.as_ref(), "contact:def-456");
1438 assert_eq!(metadata.reference.source_id.as_ref(), "test-source");
1439 assert_eq!(metadata.labels[0].as_ref(), "contact");
1440 }
1441 _ => panic!("Expected Delete change"),
1442 }
1443 }
1444
1445 #[test]
1446 fn test_convert_json_value_primitives() {
1447 assert_eq!(
1448 DataverseSource::convert_json_value(&serde_json::Value::Null),
1449 ElementValue::Null
1450 );
1451 assert_eq!(
1452 DataverseSource::convert_json_value(&serde_json::json!(true)),
1453 ElementValue::Bool(true)
1454 );
1455 assert_eq!(
1456 DataverseSource::convert_json_value(&serde_json::json!(42)),
1457 ElementValue::Integer(42)
1458 );
1459 assert_eq!(
1460 DataverseSource::convert_json_value(&serde_json::json!(3.15)),
1461 ElementValue::Float(ordered_float::OrderedFloat(3.15))
1462 );
1463 assert_eq!(
1464 DataverseSource::convert_json_value(&serde_json::json!("hello")),
1465 ElementValue::String(Arc::from("hello"))
1466 );
1467 }
1468
1469 #[test]
1470 fn test_convert_json_value_extracts_value() {
1471 let json = serde_json::json!({"Value": 123});
1473 assert_eq!(
1474 DataverseSource::convert_json_value(&json),
1475 ElementValue::Integer(123)
1476 );
1477 }
1478
1479 #[test]
1480 fn test_convert_json_value_multi_select_choice() {
1481 let json = serde_json::json!([{"Value": 1}, {"Value": 2}]);
1483 let result = DataverseSource::convert_json_value(&json);
1484 match result {
1485 ElementValue::List(values) => {
1486 assert_eq!(values.len(), 2);
1487 assert_eq!(values[0], ElementValue::Integer(1));
1488 assert_eq!(values[1], ElementValue::Integer(2));
1489 }
1490 _ => panic!("Expected List"),
1491 }
1492 }
1493
1494 #[tokio::test]
1503 async fn dispatch_changes_stamps_monotonic_sequence() {
1504 use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
1505
1506 let source_id = "dataverse-seq-source";
1507 let base = SourceBase::new(SourceBaseParams::new(source_id.to_string())).unwrap();
1508 let mut rx = base.test_subscribe().await;
1509
1510 let changes = vec![
1512 DataverseChange::NewOrUpdated {
1513 id: "a-1".to_string(),
1514 entity_name: "account".to_string(),
1515 attributes: serde_json::Map::new(),
1516 },
1517 DataverseChange::NewOrUpdated {
1518 id: "a-2".to_string(),
1519 entity_name: "account".to_string(),
1520 attributes: serde_json::Map::new(),
1521 },
1522 DataverseChange::Deleted {
1523 id: "a-1".to_string(),
1524 entity_name: "account".to_string(),
1525 },
1526 ];
1527 let expected = changes.len() as u64;
1528
1529 DataverseSource::dispatch_changes(source_id, "account", &base, &changes).await;
1530
1531 let mut sequences = Vec::new();
1532 for _ in 0..expected {
1533 let event = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv())
1534 .await
1535 .expect("timed out waiting for event")
1536 .expect("event stream closed unexpectedly");
1537 sequences.push(
1538 event
1539 .sequence
1540 .expect("dispatched change must carry a framework sequence"),
1541 );
1542 }
1543
1544 assert_eq!(
1545 sequences,
1546 vec![1, 2, 3],
1547 "delta changes must carry unique, strictly increasing sequences"
1548 );
1549 }
1550 }
1551
1552 mod backoff {
1553 use super::*;
1554
1555 #[test]
1556 fn resets_to_min_when_changes_detected() {
1557 let next = DataverseSource::next_backoff_interval(20_000, 500, 30_000, true);
1560 assert_eq!(next, 500);
1561 }
1562
1563 #[test]
1564 fn slow_backoff_under_threshold() {
1565 let next = DataverseSource::next_backoff_interval(1000, 500, 30_000, false);
1567 assert_eq!(next, 1200);
1568
1569 let next = DataverseSource::next_backoff_interval(4000, 500, 30_000, false);
1570 assert_eq!(next, 4800);
1571 }
1572
1573 #[test]
1574 fn fast_backoff_above_threshold() {
1575 let next = DataverseSource::next_backoff_interval(5000, 500, 30_000, false);
1577 assert_eq!(next, 7500);
1578
1579 let next = DataverseSource::next_backoff_interval(10_000, 500, 30_000, false);
1580 assert_eq!(next, 15_000);
1581 }
1582
1583 #[test]
1584 fn does_not_exceed_max() {
1585 let next = DataverseSource::next_backoff_interval(25_000, 500, 30_000, false);
1587 assert_eq!(next, 30_000);
1588
1589 let next = DataverseSource::next_backoff_interval(30_000, 500, 30_000, false);
1591 assert_eq!(next, 30_000);
1592 }
1593
1594 #[test]
1595 fn full_progression_no_changes() {
1596 let min = 500;
1599 let max = 30_000;
1600 let mut current = min;
1601 let mut steps = 0;
1602 while current < max {
1603 let next = DataverseSource::next_backoff_interval(current, min, max, false);
1604 assert!(
1605 next > current || next == max,
1606 "interval should grow or hit the cap (was {current}, became {next})"
1607 );
1608 assert!(
1609 next <= max,
1610 "interval must never exceed max ({next} > {max})"
1611 );
1612 current = next;
1613 steps += 1;
1614 assert!(steps < 100, "backoff failed to converge");
1615 }
1616 assert_eq!(current, max);
1617 }
1618
1619 #[test]
1620 fn full_progression_with_change_resets() {
1621 let min = 500;
1623 let max = 30_000;
1624 let mut current = min;
1625 current = DataverseSource::next_backoff_interval(current, min, max, false);
1626 current = DataverseSource::next_backoff_interval(current, min, max, false);
1627 assert!(current > min);
1628 current = DataverseSource::next_backoff_interval(current, min, max, true);
1629 assert_eq!(current, min);
1630 }
1631 }
1632
1633 mod state_store_helpers {
1634 use super::*;
1635 use drasi_lib::MemoryStateStoreProvider;
1636
1637 fn store() -> Arc<dyn drasi_lib::StateStoreProvider> {
1638 Arc::new(MemoryStateStoreProvider::new())
1639 }
1640
1641 #[test]
1642 fn delta_token_key_matches_platform_format() {
1643 assert_eq!(
1646 DataverseSource::delta_token_key("account"),
1647 "account-deltatoken"
1648 );
1649 }
1650
1651 #[tokio::test]
1652 async fn load_returns_none_on_empty_store() {
1653 let s = store();
1654 let result = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1655 assert!(
1656 result.is_none(),
1657 "no checkpoint should be present initially"
1658 );
1659 }
1660
1661 #[tokio::test]
1662 async fn save_then_load_round_trip() {
1663 let s = store();
1664 DataverseSource::save_delta_token(&s, "src-1", "account", "delta-token-123").await;
1665
1666 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1667 assert_eq!(loaded.as_deref(), Some("delta-token-123"));
1668 }
1669
1670 #[tokio::test]
1671 async fn checkpoints_are_isolated_per_entity() {
1672 let s = store();
1673 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1674 DataverseSource::save_delta_token(&s, "src-1", "contact", "token-B").await;
1675
1676 assert_eq!(
1677 DataverseSource::load_delta_token(&s, "src-1", "account")
1678 .await
1679 .as_deref(),
1680 Some("token-A")
1681 );
1682 assert_eq!(
1683 DataverseSource::load_delta_token(&s, "src-1", "contact")
1684 .await
1685 .as_deref(),
1686 Some("token-B")
1687 );
1688 }
1689
1690 #[tokio::test]
1691 async fn checkpoints_are_isolated_per_source() {
1692 let s = store();
1693 DataverseSource::save_delta_token(&s, "src-1", "account", "token-A").await;
1694 DataverseSource::save_delta_token(&s, "src-2", "account", "token-B").await;
1695
1696 assert_eq!(
1697 DataverseSource::load_delta_token(&s, "src-1", "account")
1698 .await
1699 .as_deref(),
1700 Some("token-A")
1701 );
1702 assert_eq!(
1703 DataverseSource::load_delta_token(&s, "src-2", "account")
1704 .await
1705 .as_deref(),
1706 Some("token-B")
1707 );
1708 }
1709
1710 #[tokio::test]
1711 async fn save_overwrites_previous_value() {
1712 let s = store();
1713 DataverseSource::save_delta_token(&s, "src-1", "account", "token-old").await;
1714 DataverseSource::save_delta_token(&s, "src-1", "account", "token-new").await;
1715
1716 assert_eq!(
1717 DataverseSource::load_delta_token(&s, "src-1", "account")
1718 .await
1719 .as_deref(),
1720 Some("token-new")
1721 );
1722 }
1723
1724 #[tokio::test]
1725 async fn load_skips_invalid_utf8() {
1726 let s = store();
1727 let key = DataverseSource::delta_token_key("account");
1728 s.set("src-1", &key, vec![0xff, 0xfe, 0xfd])
1730 .await
1731 .expect("set should succeed");
1732
1733 let loaded = DataverseSource::load_delta_token(&s, "src-1", "account").await;
1734 assert!(loaded.is_none(), "non-UTF8 stored value should be ignored");
1735 }
1736 }
1737}
1738
1739#[cfg(feature = "dynamic-plugin")]
1741drasi_plugin_sdk::export_plugin!(
1742 plugin_id = "dataverse-source",
1743 core_version = env!("CARGO_PKG_VERSION"),
1744 lib_version = env!("CARGO_PKG_VERSION"),
1745 plugin_version = env!("CARGO_PKG_VERSION"),
1746 source_descriptors = [descriptor::DataverseSourceDescriptor],
1747 reaction_descriptors = [],
1748 bootstrap_descriptors = [],
1749);